Compare commits
88 commits
tcef-maste
...
meteor3-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| b0ca838788 | |||
| 1153556f47 | |||
| 82d0399c86 | |||
| 56f53e2c23 | |||
| 072e76aaed | |||
| c92f25a9a6 | |||
| 19b76a570c | |||
| a3f2929ff7 | |||
| 3b04ebceb3 | |||
| e61efc681c | |||
| 755afccf90 | |||
| 6e448bda55 | |||
| 124ed4e135 | |||
| e68340931c | |||
| 685af89f20 | |||
| 775461f7b2 | |||
| 570cb49764 | |||
| 324936242b | |||
| 50ca9cc6cf | |||
| 4879d3482d | |||
| 110ad66e86 | |||
| fbb746fba2 | |||
| 3d52e2f709 | |||
| 3ac580f246 | |||
| f0be6652e8 | |||
| e18e00f298 | |||
| b4e5511cd0 | |||
| 23e5f26239 | |||
| f3ea93fc6f | |||
| 6aa057f739 | |||
| 72975e44ae | |||
| a24c7a567c | |||
| 03082a8d55 | |||
| e5f7a0f4be | |||
| c50690cc8a | |||
| 07993ffb91 | |||
| 2e298f9ee5 | |||
| 1ef0012af9 | |||
| bddfb392c1 | |||
| 9ff9abacc3 | |||
| f775d4ac26 | |||
| 8dead27d22 | |||
| 6b6f02d92d | |||
| bc778bfd97 | |||
| e61a3a9bcb | |||
| b710388f50 | |||
| 0abf5b4f02 | |||
| 258723ea72 | |||
| 229b2cb233 | |||
| 4ddaa0d8ec | |||
| 5661e129fb | |||
| 6e28bf3545 | |||
| e2d13ca64c | |||
| a1a1b9a801 | |||
| 12bdbe8fed | |||
| f1eae84e25 | |||
| f51acf4d78 | |||
| 6e9c3a5570 | |||
| a997aa7cae | |||
| 9feaca9d0b | |||
| c6c22643c1 | |||
| 62ac2fbd9c | |||
| 696789e2b1 | |||
| efa1e30bcb | |||
| 0711416300 | |||
| 4ca7e198d3 | |||
| 197a59f641 | |||
| e3f3f4aa20 | |||
| 713f1346e7 | |||
| 1f1f5c697d | |||
| 1892c0ead0 | |||
| eb3aec82ae | |||
| 0ff9848cd4 | |||
| 66bab7a43b | |||
| feaf66fe34 | |||
| 48d33c397b | |||
| 637a749e8d | |||
| b37069c3e5 | |||
| 25dc1b99c0 | |||
| 8fa10e47c7 | |||
| bddeca79a0 | |||
| 208e250277 | |||
| 9b0c53fa4a | |||
| dbe2f3759f | |||
| cd570b9d9c | |||
| ccfc80547a | |||
| dc8a05a0d4 | |||
| 296dab4f38 |
245 changed files with 23382 additions and 21865 deletions
15
.dockerignore
Normal file
15
.dockerignore
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Contexto de build de la imagen web. El builder hace su propio
|
||||||
|
# `meteor npm install`; nada del host debe colarse.
|
||||||
|
.git
|
||||||
|
**/.git
|
||||||
|
.meteor/local
|
||||||
|
node_modules
|
||||||
|
smoke/node_modules
|
||||||
|
smoke/snapshots
|
||||||
|
plan-modernizacion
|
||||||
|
secrets
|
||||||
|
docker-compose.yml
|
||||||
|
RUNBOOK.md
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
207
.forgejo/workflows/build-image.yml
Normal file
207
.forgejo/workflows/build-image.yml
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
# Construye la imagen Docker de la web y la sube al registry de Forgejo.
|
||||||
|
#
|
||||||
|
# POR QUÉ: antes el build corría en groucho, el host de despliegue (5,9 GB de RAM
|
||||||
|
# compartidos con Mongo 7 + web + notifications + redis + node-red). Un `meteor build`
|
||||||
|
# lo asfixiaba hasta dejarlo sin ssh. A partir de aquí groucho **no compila**: hace
|
||||||
|
# `docker compose pull && up -d web` y ya.
|
||||||
|
#
|
||||||
|
# runs-on: docker → el runner de aaron (forge.yml, label `docker`, capacity 1).
|
||||||
|
#
|
||||||
|
# ⚠️ POR QUÉ KANIKO Y NO `docker build`:
|
||||||
|
# aaron es un host COMPARTIDO — aloja git.comunes.org, Jenkins y GlitchTip. Construir con
|
||||||
|
# `docker build` exige montar /var/run/docker.sock en el job, que es poder
|
||||||
|
# root-equivalente sobre aaron; y además el límite de memoria del contenedor del job NO
|
||||||
|
# afectaría al build, porque quien construye es el demonio, fuera del job. Kaniko
|
||||||
|
# construye DENTRO del contenedor del job: no hace falta socket y `--memory` sí acota al
|
||||||
|
# proceso que se come la RAM. Si el `meteor build` muere por memoria, sube el `--memory`
|
||||||
|
# de `container.options` (y mira qué más está corriendo en aaron), no lo quites.
|
||||||
|
|
||||||
|
#
|
||||||
|
# ⚠️ POR QUÉ EL TEST VIVE EN ESTE FICHERO Y NO EN UNO APARTE:
|
||||||
|
# `needs:` solo enlaza jobs DEL MISMO workflow — no hay forma de que un fichero
|
||||||
|
# distinto bloquee a este. Y el requisito de la fase 11 es justo ese: que una
|
||||||
|
# imagen no se publique nunca con los tests en rojo. Así que los dos jobs van
|
||||||
|
# juntos, `build` depende de `test`, y se acabó. Si algún día act_runner soporta
|
||||||
|
# `workflow_call` de forma fiable, se puede extraer.
|
||||||
|
|
||||||
|
name: build-image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [meteor3-wip, tcef-master]
|
||||||
|
paths-ignore: ['**.md']
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Suite de servidor (meteortesting:mocha). Bloquea el build: ver la nota de
|
||||||
|
# arriba. Mide y publica duración y pico de RAM en el resumen del job para que
|
||||||
|
# el margen del runner de aaron sea un dato y no una intuición.
|
||||||
|
test:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm
|
||||||
|
# Medido en local: pico ~2,0 GB (meteor test + su mongod). 3g deja margen
|
||||||
|
# sin comerse los 4g que necesita el build de Kaniko justo después
|
||||||
|
# (capacity: 1 ⇒ los jobs no coinciden en el tiempo). Si el job muere por
|
||||||
|
# OOM, sube esto y mira antes qué más está corriendo en aaron.
|
||||||
|
options: --memory=3g --memory-swap=3g --cpus=3
|
||||||
|
env:
|
||||||
|
# meteor se niega a arrancar como root si no.
|
||||||
|
METEOR_ALLOW_SUPERUSER: '1'
|
||||||
|
# Node 22 + Happy Eyeballs elige IPv6 sin ruta contra warehouse.meteor.com
|
||||||
|
# (mismo workaround que en el Dockerfile y en dev).
|
||||||
|
NODE_OPTIONS: --dns-result-order=ipv4first --no-network-family-autoselection
|
||||||
|
METEOR_RELEASE: '3.1'
|
||||||
|
steps:
|
||||||
|
# `time` para medir el pico de RSS; el resto es lo mismo que instala el
|
||||||
|
# Dockerfile (node-gyp 3.x de algunas deps de Atmosphere llama a `python`).
|
||||||
|
- name: Herramientas
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl ca-certificates git time python3 python-is-python3 make g++ >/dev/null
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Sin actions JS: el código se baja por el API de Forgejo, igual que en el
|
||||||
|
# job de build (ver la nota de más abajo).
|
||||||
|
- name: Descargar el código
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mkdir -p src
|
||||||
|
wget -q -O src.tar.gz \
|
||||||
|
"http://x-access-token:${TOKEN}@forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
||||||
|
tar xzf src.tar.gz -C src --strip-components=1
|
||||||
|
rm -f src.tar.gz
|
||||||
|
test -f src/package.json || { echo "No hay package.json en el tarball"; ls src | head; exit 1; }
|
||||||
|
|
||||||
|
# ~700 MB de meteor-tool en cada job. No se cachea porque `actions/cache` es
|
||||||
|
# una action JS (fase 9 §3) y montar un volumen del host exige tocar
|
||||||
|
# `valid_volumes` en la config de act_runner de aaron. Pendiente: si el job
|
||||||
|
# se vuelve molesto, añadir /data/ci-cache/meteor -> /root/.meteor.
|
||||||
|
- name: Instalar meteor-tool
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
curl -fsSL "https://install.meteor.com/?release=${METEOR_RELEASE}" | sh
|
||||||
|
meteor --version
|
||||||
|
|
||||||
|
- name: Dependencias npm
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd src
|
||||||
|
meteor npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
# `meteor test` levanta su propio mongod (single-node replica set), que es
|
||||||
|
# lo que hace que el oplog esté disponible y los observadores reaccionen al
|
||||||
|
# instante: contra un mongo externo sin oplog, Meteor cae a polling cada 10s
|
||||||
|
# y los tests de subsUnion tardarían eso en ver cada cambio.
|
||||||
|
- name: Tests de servidor
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd src
|
||||||
|
START=$(date +%s)
|
||||||
|
/usr/bin/time -v -o ../time.log meteor npm run test:ci
|
||||||
|
echo "DURACION=$(( $(date +%s) - START ))s"
|
||||||
|
grep -E 'Maximum resident set size' ../time.log || true
|
||||||
|
|
||||||
|
- name: Resumen
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "### Tests de servidor"
|
||||||
|
echo ""
|
||||||
|
echo "- pico de RSS: $(grep -oE '[0-9]+$' time.log 2>/dev/null | head -1 || echo '?') KB"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY" || true
|
||||||
|
|
||||||
|
build:
|
||||||
|
# Ninguna imagen sale de aquí con los tests en rojo. Ese es el punto entero
|
||||||
|
# de la fase 11.
|
||||||
|
needs: test
|
||||||
|
# En un PR solo interesa saber si pasa; publicar imagen es cosa de la rama.
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
# `:debug` trae busybox: el runner necesita una shell para ejecutar los `run:`.
|
||||||
|
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||||
|
# Tope duro para no asfixiar aaron. memory-swap == memory ⇒ swap 0: preferimos que
|
||||||
|
# muera el build a que el host empiece a paginar y se lleve por delante la forja.
|
||||||
|
options: --memory=4g --memory-swap=4g --cpus=3
|
||||||
|
env:
|
||||||
|
IMAGE: git.comunes.org/comunes/tcef-web
|
||||||
|
steps:
|
||||||
|
# Primero de todo: sin credenciales el build tarda ~30 min en llegar al push para
|
||||||
|
# morir ahí. Mejor fallar en 2 segundos y decir exactamente qué falta.
|
||||||
|
- name: Comprobar secretos
|
||||||
|
shell: sh
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
export PATH=/busybox:/kaniko:$PATH
|
||||||
|
if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_TOKEN" ]; then
|
||||||
|
echo "Faltan los secretos REGISTRY_USER / REGISTRY_TOKEN."
|
||||||
|
echo "Crealos en Settings -> Actions -> Secrets de este repositorio."
|
||||||
|
echo "REGISTRY_TOKEN: token de git.comunes.org con scope write:package"
|
||||||
|
echo "(Settings -> Applications -> Generate New Token). No reutilices uno con scope 'all'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Nada de `actions/checkout` (necesita Node) ni de `git` (la imagen de kaniko solo trae
|
||||||
|
# busybox y los binarios de kaniko: no hay git, ni apk para instalarlo). Se baja el
|
||||||
|
# tarball del commit por el API de Forgejo, que busybox sí sabe hacer con wget + tar.
|
||||||
|
- name: Descargar el código
|
||||||
|
shell: sh
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export PATH=/busybox:/kaniko:$PATH
|
||||||
|
mkdir -p src
|
||||||
|
wget -q -O src.tar.gz \
|
||||||
|
"http://x-access-token:${TOKEN}@forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
||||||
|
tar xzf src.tar.gz -C src --strip-components=1
|
||||||
|
rm -f src.tar.gz
|
||||||
|
test -f src/Dockerfile || { echo "No hay Dockerfile en el tarball"; ls src | head; exit 1; }
|
||||||
|
|
||||||
|
- name: Credenciales del registry
|
||||||
|
shell: sh
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export PATH=/busybox:/kaniko:$PATH
|
||||||
|
mkdir -p /kaniko/.docker
|
||||||
|
AUTH=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 | tr -d '\n')
|
||||||
|
printf '{"auths":{"git.comunes.org":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
|
||||||
|
|
||||||
|
- name: Build y push
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export PATH=/busybox:/kaniko:$PATH
|
||||||
|
SHORT_SHA=$(echo "$GITHUB_SHA" | cut -c1-12)
|
||||||
|
BRANCH_TAG=$(echo "$GITHUB_REF_NAME" | tr '/' '-')
|
||||||
|
# --cache: reutiliza capas entre builds (apt, meteor-tool, npm install) contra
|
||||||
|
# el propio registry; sin esto cada push recompila el mundo.
|
||||||
|
# --single-snapshot y --compressed-caching=false bajan mucho el pico de RAM,
|
||||||
|
# que es justo lo que nos importa en un host compartido.
|
||||||
|
/kaniko/executor \
|
||||||
|
--context "dir://$(pwd)/src" \
|
||||||
|
--dockerfile Dockerfile \
|
||||||
|
--destination "${IMAGE}:${BRANCH_TAG}" \
|
||||||
|
--destination "${IMAGE}:${SHORT_SHA}" \
|
||||||
|
--cache=true \
|
||||||
|
--cache-repo "${IMAGE}/cache" \
|
||||||
|
--compressed-caching=false \
|
||||||
|
--snapshot-mode=redo \
|
||||||
|
--verbosity=info
|
||||||
|
echo "Publicado ${IMAGE}:${BRANCH_TAG} y :${SHORT_SHA}"
|
||||||
|
|
||||||
|
- name: Cómo desplegar
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
echo "En groucho: cd /data/tcef-staging && docker compose pull web && docker compose up -d web"
|
||||||
129
.forgejo/workflows/e2e.yml
Normal file
129
.forgejo/workflows/e2e.yml
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# Suite de navegador (Playwright) + smoke REST contra un stack EFÍMERO.
|
||||||
|
#
|
||||||
|
# POR QUÉ NOCTURNO Y NO EN CADA PUSH: esto construye el bundle de producción
|
||||||
|
# entero (`meteor build`, lo mismo que hace la imagen) y además baja un Chromium.
|
||||||
|
# aaron es un host compartido con `capacity: 1`: si esto corriera en cada push,
|
||||||
|
# la forja se quedaría sin turno. Los tests de servidor (build-image.yml) sí van
|
||||||
|
# en cada push y son los que bloquean la imagen.
|
||||||
|
#
|
||||||
|
# POR QUÉ NO USA docker compose: el runner no tiene socket de Docker (ver la nota
|
||||||
|
# de build-image.yml) — así que en vez de levantar docker-compose.e2e.yml, este
|
||||||
|
# job construye el bundle y lo arranca dentro de su propio contenedor, con Mongo
|
||||||
|
# como service container. El compose sigue siendo la forma de correrlo en local.
|
||||||
|
#
|
||||||
|
# ⚠️ Las dos siembras (smoke/seed.js y e2e/seed.js) BORRAN colecciones. Aquí eso
|
||||||
|
# es seguro porque el Mongo es un service container que muere con el job. Jamás
|
||||||
|
# apuntes esto a testfuegos.comunes.org.
|
||||||
|
|
||||||
|
name: e2e
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 04:15 UTC, con el runner libre.
|
||||||
|
- cron: '15 4 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm
|
||||||
|
# El pico es el `meteor build`, igual que en el job de la imagen.
|
||||||
|
options: --memory=4g --memory-swap=4g --cpus=3
|
||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
# Nodo suelto, sin replica set: los service containers no dejan cambiar
|
||||||
|
# el comando de arranque. Sin oplog Meteor cae a polling (cada 10s), así
|
||||||
|
# que los tests que esperan a la unión tardan más — por eso sus timeouts
|
||||||
|
# son de 60s. En local (docker-compose.e2e.yml) sí hay replica set.
|
||||||
|
options: >-
|
||||||
|
--health-cmd "mongosh --quiet --eval 'db.adminCommand({ping:1}).ok'"
|
||||||
|
--health-interval 5s --health-timeout 5s --health-retries 20
|
||||||
|
env:
|
||||||
|
METEOR_ALLOW_SUPERUSER: '1'
|
||||||
|
NODE_OPTIONS: --dns-result-order=ipv4first --no-network-family-autoselection
|
||||||
|
METEOR_RELEASE: '3.1'
|
||||||
|
MONGO_URL: mongodb://mongo:27017/fuegos
|
||||||
|
ROOT_URL: http://localhost:3000
|
||||||
|
PORT: '3000'
|
||||||
|
BASE_URL: http://localhost:3000
|
||||||
|
E2E_BASE_URL: http://localhost:3000
|
||||||
|
SETTINGS: settings-ci.json
|
||||||
|
steps:
|
||||||
|
- name: Herramientas
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl ca-certificates git python3 python-is-python3 make g++ >/dev/null
|
||||||
|
# mongosh por npm: el cliente no viene en node:22 y el service container
|
||||||
|
# de mongo no es accesible con `docker exec` desde el job.
|
||||||
|
npm install -g mongosh@2 >/dev/null
|
||||||
|
|
||||||
|
- name: Descargar el código
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mkdir -p src
|
||||||
|
wget -q -O src.tar.gz \
|
||||||
|
"http://x-access-token:${TOKEN}@forgejo:3000/api/v1/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
||||||
|
tar xzf src.tar.gz -C src --strip-components=1
|
||||||
|
rm -f src.tar.gz
|
||||||
|
|
||||||
|
- name: Instalar meteor-tool
|
||||||
|
run: curl -fsSL "https://install.meteor.com/?release=${METEOR_RELEASE}" | sh
|
||||||
|
|
||||||
|
- name: Construir el bundle de producción
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd src
|
||||||
|
meteor npm ci --no-audit --no-fund
|
||||||
|
meteor build /tmp/build --directory --server-only
|
||||||
|
cd /tmp/build/bundle/programs/server && npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Arrancar el servidor
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd /tmp/build/bundle
|
||||||
|
METEOR_SETTINGS="$(cat "${GITHUB_WORKSPACE}/src/settings-ci.json")" \
|
||||||
|
node main.js > /tmp/server.log 2>&1 &
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
wget -qO /dev/null "http://127.0.0.1:3000/" && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
wget -qO /dev/null "http://127.0.0.1:3000/" || { echo "El servidor no arrancó:"; tail -50 /tmp/server.log; exit 1; }
|
||||||
|
|
||||||
|
# El smoke va primero y con su propia siembra: compara respuestas byte a
|
||||||
|
# byte contra snapshots, así que cualquier dato que dejen los e2e (una
|
||||||
|
# suscripción, la unión recalculada) lo haría fallar.
|
||||||
|
- name: Smoke REST
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd src
|
||||||
|
mongosh --host mongo --quiet fuegos smoke/seed.js
|
||||||
|
node smoke/run.js
|
||||||
|
|
||||||
|
- name: Suite e2e
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd src
|
||||||
|
mongosh --host mongo --quiet fuegos e2e/seed.js
|
||||||
|
# Dentro de e2e/: playwright busca su playwright.config.js en el
|
||||||
|
# directorio de trabajo, y `npx --prefix` resuelve el binario pero NO
|
||||||
|
# cambia el cwd (correría sin config y sin encontrar los tests).
|
||||||
|
cd e2e
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
npx playwright test
|
||||||
|
|
||||||
|
# Sin `actions/upload-artifact` (action JS, ver fase 9 §3): al menos el log
|
||||||
|
# del servidor y la lista de trazas quedan en la salida del job.
|
||||||
|
- name: Diagnóstico en caso de fallo
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "===== últimas 200 líneas del servidor ====="
|
||||||
|
tail -200 /tmp/server.log || true
|
||||||
|
echo "===== trazas de Playwright ====="
|
||||||
|
ls -R src/e2e/test-results 2>/dev/null | head -50 || true
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -17,3 +17,8 @@ output.json
|
||||||
settings-production-*.json
|
settings-production-*.json
|
||||||
settings-tests.json
|
settings-tests.json
|
||||||
*.json~
|
*.json~
|
||||||
|
|
||||||
|
# fase-3: secretos del stack docker-compose local (solo se versionan los .example)
|
||||||
|
secrets/*
|
||||||
|
!secrets/README.md
|
||||||
|
!secrets/*.example
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,5 @@ notices-for-facebook-graph-api-2
|
||||||
1.4.1-add-shell-server-package
|
1.4.1-add-shell-server-package
|
||||||
1.4.3-split-account-service-packages
|
1.4.3-split-account-service-packages
|
||||||
1.5-add-dynamic-import-package
|
1.5-add-dynamic-import-package
|
||||||
|
1.7-split-underscore-from-meteor-base
|
||||||
|
1.8.3-split-jquery-from-blaze
|
||||||
|
|
|
||||||
|
|
@ -4,66 +4,56 @@
|
||||||
# 'meteor add' and 'meteor remove' will edit this file for you,
|
# 'meteor add' and 'meteor remove' will edit this file for you,
|
||||||
# but you can also edit it by hand.
|
# but you can also edit it by hand.
|
||||||
|
|
||||||
meteor-base@1.3.0 # Packages every Meteor app needs to have
|
meteor-base@1.5.2 # Packages every Meteor app needs to have
|
||||||
mobile-experience@1.0.5 # Packages for a great mobile UX
|
mobile-experience@1.1.2 # Packages for a great mobile UX
|
||||||
mongo@1.4.7 # The database Meteor supports right now
|
mongo@2.0.3 # The database Meteor supports right now
|
||||||
reactive-var@1.0.11 # Reactive variable for tracker
|
reactive-var@1.0.13 # Reactive variable for tracker
|
||||||
tracker@1.1.3 # Meteor's client-side reactive programming library
|
tracker@1.3.4 # Meteor's client-side reactive programming library
|
||||||
|
session@1.2.2 # Reactive Session var, required by themeteorchef:bert's Bert.alert()
|
||||||
|
|
||||||
standard-minifier-css@1.4.0 # CSS minifier run for production mode
|
standard-minifier-css@1.9.3 # CSS minifier run for production mode
|
||||||
standard-minifier-js@2.3.1 # JS minifier run for production mode
|
standard-minifier-js@3.0.0 # JS minifier run for production mode
|
||||||
es5-shim@4.7.0 # ECMAScript 5 compatibility for older browsers.
|
ecmascript@0.16.10 # Enable ECMAScript2015+ syntax in app code
|
||||||
ecmascript@0.10.6 # Enable ECMAScript2015+ syntax in app code
|
shell-server@0.6.1 # Server-side component of the `meteor shell` command
|
||||||
shell-server@0.3.1 # Server-side component of the `meteor shell` command
|
|
||||||
|
|
||||||
react-meteor-data
|
react-meteor-data@3.0.6
|
||||||
alanning:roles
|
fourseven:scss@5.0.0
|
||||||
fourseven:scss
|
accounts-base@3.0.3
|
||||||
accounts-base@1.4.2
|
accounts-password@3.0.3
|
||||||
accounts-password@1.5.0
|
service-configuration@1.3.5
|
||||||
service-configuration@1.0.11
|
accounts-facebook@1.3.4
|
||||||
accounts-facebook@1.3.1
|
accounts-github@1.5.1
|
||||||
accounts-github@1.4.1
|
accounts-google@1.4.1
|
||||||
accounts-google@1.3.1
|
|
||||||
themeteorchef:bert
|
themeteorchef:bert
|
||||||
fortawesome:fontawesome
|
fortawesome:fontawesome
|
||||||
aldeed:collection2-core@2.0.1
|
aldeed:collection2@4.0.4
|
||||||
audit-argument-checks@1.0.7
|
audit-argument-checks@1.0.8
|
||||||
ddp-rate-limiter@1.0.7
|
ddp-rate-limiter@1.2.2
|
||||||
dynamic-import@0.3.0
|
dynamic-import@0.7.4
|
||||||
static-html
|
static-html@1.4.0
|
||||||
alexwine:bootstrap-4
|
|
||||||
|
|
||||||
selaias:cookie-consent # Cookie consent
|
|
||||||
gadicc:blaze-react-component # Add braze to react
|
gadicc:blaze-react-component # Add braze to react
|
||||||
255kb:meteor-status # Connect status
|
255kb:meteor-status # Connect status
|
||||||
# mixmax:smart-disconnect # Disconnect on lost focus (disabled to get notif)
|
# mixmax:smart-disconnect # Disconnect on lost focus (disabled to get notif)
|
||||||
flowkey:raven # js errors
|
|
||||||
vjrj:piwik # Stats
|
vjrj:piwik # Stats
|
||||||
mdg:geolocation
|
mdg:geolocation
|
||||||
natestrauser:publish-performant-counts
|
tcef:publish-performant-counts
|
||||||
maximum:server-transform
|
|
||||||
mys:fonts # fonst in npm
|
|
||||||
ostrio:mailer # mailer
|
ostrio:mailer # mailer
|
||||||
# babrahams:constellation # dev utility
|
# babrahams:constellation # dev utility
|
||||||
percolate:migrations # db migrations
|
percolate:migrations # db migrations
|
||||||
ostrio:meteor-root
|
ostrio:meteor-root
|
||||||
meteortesting:mocha
|
|
||||||
practicalmeteor:chai
|
|
||||||
aldeed:schema-deny
|
aldeed:schema-deny
|
||||||
aldeed:template-extension
|
|
||||||
dburles:collection-helpers
|
dburles:collection-helpers
|
||||||
less@2.7.11
|
|
||||||
markdown@1.0.12
|
|
||||||
mongo-livedata@1.0.12
|
|
||||||
reywood:publish-composite
|
reywood:publish-composite
|
||||||
barbatus:stars-rating
|
# Bootstrap CSS/JS now comes from the `bootstrap` npm package (BS5), imported in
|
||||||
arkham:comments-ui
|
# imports/startup/client/index.js. The old alexwine:bootstrap-4 package (BS4 CSS +
|
||||||
facts@1.0.9
|
# jQuery + BS4 JS) was removed after the navbar/carousel/dropdown/feedback widgets
|
||||||
gadicohen:sitemaps
|
# were migrated off jQuery.
|
||||||
|
facts-base@1.0.2
|
||||||
nspangler:autoreconnect
|
nspangler:autoreconnect
|
||||||
saucecode:timezoned-synced-cron
|
quave:synced-cron
|
||||||
# lmachens:kadira
|
# lmachens:kadira
|
||||||
meteorhacks:zones
|
nimble:restivus@0.8.12
|
||||||
nimble:restivus
|
underscore@1.6.4
|
||||||
xolvio:cleaner
|
|
||||||
|
meteortesting:mocha # test driver (see `npm test` / `npm run test-watch`)
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
METEOR@1.6.1.1
|
METEOR@3.1
|
||||||
|
|
|
||||||
263
.meteor/versions
263
.meteor/versions
|
|
@ -1,155 +1,126 @@
|
||||||
255kb:meteor-status@1.5.0
|
255kb:meteor-status@1.5.0
|
||||||
accounts-base@1.4.2
|
accounts-base@3.0.3
|
||||||
accounts-facebook@1.3.1
|
accounts-facebook@1.3.4
|
||||||
accounts-github@1.4.1
|
accounts-github@1.5.1
|
||||||
accounts-google@1.3.1
|
accounts-google@1.4.1
|
||||||
accounts-oauth@1.1.15
|
accounts-oauth@1.4.5
|
||||||
accounts-password@1.5.1
|
accounts-password@3.0.3
|
||||||
alanning:roles@1.2.16
|
aldeed:collection2@4.2.0
|
||||||
aldeed:collection2-core@2.1.2
|
aldeed:schema-deny@4.0.2
|
||||||
aldeed:schema-deny@2.0.1
|
aldeed:simple-schema@1.13.1
|
||||||
aldeed:template-extension@4.1.0
|
allow-deny@2.0.0
|
||||||
alexwine:bootstrap-4@4.1.0
|
audit-argument-checks@1.0.8
|
||||||
allow-deny@1.1.0
|
autoupdate@2.0.0
|
||||||
arkham:comments-ui@1.4.4
|
babel-compiler@7.11.2
|
||||||
audit-argument-checks@1.0.7
|
babel-runtime@1.5.2
|
||||||
autoupdate@1.4.0
|
base64@1.0.13
|
||||||
babel-compiler@7.0.7
|
binary-heap@1.0.12
|
||||||
babel-runtime@1.2.2
|
blaze@3.0.3
|
||||||
barbatus:stars-rating@1.1.1
|
blaze-tools@2.0.1
|
||||||
base64@1.0.11
|
boilerplate-generator@2.0.0
|
||||||
binary-heap@1.0.10
|
caching-compiler@2.0.1
|
||||||
blaze@2.3.2
|
caching-html-compiler@2.0.1
|
||||||
blaze-tools@1.0.10
|
callback-hook@1.6.0
|
||||||
boilerplate-generator@1.4.0
|
check@1.4.4
|
||||||
browser-policy@1.1.0
|
core-runtime@1.0.0
|
||||||
browser-policy-common@1.0.11
|
dburles:collection-helpers@1.0.0
|
||||||
browser-policy-content@1.1.0
|
ddp@1.4.2
|
||||||
browser-policy-framing@1.1.0
|
ddp-client@3.0.3
|
||||||
caching-compiler@1.1.11
|
ddp-common@1.4.4
|
||||||
caching-html-compiler@1.1.2
|
ddp-rate-limiter@1.2.2
|
||||||
callback-hook@1.1.0
|
ddp-server@3.0.3
|
||||||
check@1.3.1
|
diff-sequence@1.1.3
|
||||||
coffeescript@1.0.17
|
dynamic-import@0.7.4
|
||||||
dburles:collection-helpers@1.1.0
|
ecmascript@0.16.10
|
||||||
dburles:mongo-collection-instances@0.3.5
|
ecmascript-runtime@0.8.3
|
||||||
ddp@1.4.0
|
ecmascript-runtime-client@0.12.2
|
||||||
ddp-client@2.3.2
|
ecmascript-runtime-server@0.11.1
|
||||||
ddp-common@1.4.0
|
ejson@1.1.4
|
||||||
ddp-rate-limiter@1.0.7
|
email@3.1.1
|
||||||
ddp-server@2.1.2
|
es5-shim@4.8.1
|
||||||
deps@1.0.12
|
facebook-oauth@1.11.4
|
||||||
diff-sequence@1.1.0
|
facts-base@1.0.2
|
||||||
dynamic-import@0.3.0
|
fetch@0.1.5
|
||||||
ecmascript@0.10.7
|
|
||||||
ecmascript-runtime@0.5.0
|
|
||||||
ecmascript-runtime-client@0.6.2
|
|
||||||
ecmascript-runtime-server@0.5.0
|
|
||||||
ejson@1.1.0
|
|
||||||
email@1.2.3
|
|
||||||
es5-shim@4.7.3
|
|
||||||
facebook-oauth@1.4.0
|
|
||||||
facts@1.0.9
|
|
||||||
flowkey:raven@1.1.0
|
|
||||||
fortawesome:fontawesome@4.7.0
|
fortawesome:fontawesome@4.7.0
|
||||||
fourseven:scss@4.5.4
|
fourseven:scss@5.0.0
|
||||||
gadicc:blaze-react-component@1.4.0
|
gadicc:blaze-react-component@2.0.1
|
||||||
gadicohen:robots-txt@0.0.10
|
geojson-utils@1.0.12
|
||||||
gadicohen:sitemaps@0.0.26
|
github-oauth@1.4.2
|
||||||
geojson-utils@1.0.10
|
google-oauth@1.4.5
|
||||||
github-oauth@1.2.0
|
hot-code-push@1.0.5
|
||||||
google-oauth@1.2.5
|
html-tools@2.0.1
|
||||||
hot-code-push@1.0.4
|
htmljs@2.0.2
|
||||||
html-tools@1.0.11
|
id-map@1.2.0
|
||||||
htmljs@1.0.11
|
inter-process-messaging@0.1.2
|
||||||
http@1.4.0
|
|
||||||
id-map@1.1.0
|
|
||||||
jquery@1.11.11
|
jquery@1.11.11
|
||||||
lai:collection-extensions@0.2.1_1
|
launch-screen@2.0.1
|
||||||
launch-screen@1.1.1
|
localstorage@1.2.1
|
||||||
less@2.7.12
|
logging@1.3.5
|
||||||
livedata@1.0.18
|
|
||||||
localstorage@1.2.0
|
|
||||||
logging@1.1.20
|
|
||||||
markdown@1.0.12
|
|
||||||
maximum:package-base@1.1.2
|
|
||||||
maximum:server-transform@0.5.0
|
|
||||||
mdg:geolocation@1.3.0
|
mdg:geolocation@1.3.0
|
||||||
meteor@1.8.6
|
meteor@2.0.2
|
||||||
meteor-base@1.3.0
|
meteor-base@1.5.2
|
||||||
meteorhacks:inject-initial@1.0.4
|
meteortesting:browser-tests@1.8.0
|
||||||
meteorhacks:zones@1.6.0
|
meteortesting:mocha@3.3.0
|
||||||
meteortesting:browser-tests@0.2.0
|
meteortesting:mocha-core@8.2.0
|
||||||
meteortesting:mocha@0.5.1
|
minifier-css@2.0.0
|
||||||
minifier-css@1.3.1
|
minifier-js@3.0.1
|
||||||
minifier-js@2.3.4
|
minimongo@2.0.2
|
||||||
minimongo@1.4.4
|
mobile-experience@1.1.2
|
||||||
mobile-experience@1.0.5
|
mobile-status-bar@1.1.1
|
||||||
mobile-status-bar@1.0.14
|
modern-browsers@0.1.11
|
||||||
modules@0.11.6
|
modules@0.20.3
|
||||||
modules-runtime@0.9.2
|
modules-runtime@0.13.2
|
||||||
mongo@1.4.7
|
mongo@2.0.3
|
||||||
mongo-dev-server@1.1.0
|
mongo-decimal@0.2.0
|
||||||
mongo-id@1.0.7
|
mongo-dev-server@1.1.1
|
||||||
mongo-livedata@1.0.12
|
mongo-id@1.0.9
|
||||||
mys:fonts@0.0.2
|
mongo-livedata@1.0.13
|
||||||
natestrauser:publish-performant-counts@0.1.2
|
|
||||||
nimble:restivus@0.8.12
|
nimble:restivus@0.8.12
|
||||||
npm-bcrypt@0.9.3
|
npm-mongo@6.10.0
|
||||||
npm-mongo@2.2.34
|
|
||||||
nspangler:autoreconnect@0.0.1
|
nspangler:autoreconnect@0.0.1
|
||||||
oauth@1.2.3
|
oauth@3.0.0
|
||||||
oauth2@1.2.0
|
oauth2@1.3.3
|
||||||
observe-sequence@1.0.16
|
observe-sequence@2.0.1
|
||||||
ordered-dict@1.1.0
|
ordered-dict@1.2.0
|
||||||
ostrio:cookies@2.1.3
|
ostrio:mailer@2.5.0
|
||||||
ostrio:mailer@2.1.0
|
|
||||||
ostrio:meteor-root@1.0.7
|
ostrio:meteor-root@1.0.7
|
||||||
peerlibrary:assert@0.2.5
|
percolate:migrations@2.0.1
|
||||||
peerlibrary:fiber-utils@0.6.0
|
promise@1.0.0
|
||||||
peerlibrary:reactive-mongo@0.1.1
|
quave:synced-cron@2.4.0
|
||||||
peerlibrary:server-autorun@0.5.2
|
raix:eventemitter@1.0.0
|
||||||
percolate:migrations@1.0.2
|
random@1.2.2
|
||||||
practicalmeteor:chai@2.1.0_1
|
rate-limit@1.1.2
|
||||||
practicalmeteor:mocha-core@1.0.1
|
react-fast-refresh@0.2.9
|
||||||
promise@0.10.2
|
react-meteor-data@3.0.6
|
||||||
raix:eventemitter@0.1.3
|
reactive-dict@1.3.2
|
||||||
random@1.1.0
|
reactive-var@1.0.13
|
||||||
rate-limit@1.0.9
|
reload@1.3.2
|
||||||
react-meteor-data@0.2.16
|
retry@1.1.1
|
||||||
reactive-dict@1.2.0
|
|
||||||
reactive-var@1.0.11
|
|
||||||
reload@1.2.0
|
|
||||||
retry@1.1.0
|
|
||||||
reywood:publish-composite@1.6.0
|
reywood:publish-composite@1.6.0
|
||||||
routepolicy@1.0.13
|
routepolicy@1.1.2
|
||||||
saucecode:timezoned-synced-cron@1.2.11
|
service-configuration@1.3.5
|
||||||
selaias:cookie-consent@0.4.0
|
session@1.2.2
|
||||||
server-render@0.3.0
|
sha@1.0.10
|
||||||
service-configuration@1.0.11
|
shell-server@0.6.1
|
||||||
session@1.1.7
|
simple:json-routes@3.0.0
|
||||||
sha@1.0.9
|
socket-stream-client@0.5.3
|
||||||
shell-server@0.3.1
|
spacebars@2.0.1
|
||||||
shim-common@0.1.0
|
spacebars-compiler@2.0.1
|
||||||
simple:json-routes@2.1.0
|
standard-minifier-css@1.9.3
|
||||||
socket-stream-client@0.1.0
|
standard-minifier-js@3.0.0
|
||||||
spacebars@1.0.15
|
static-html@1.4.0
|
||||||
spacebars-compiler@1.1.3
|
static-html-tools@1.0.0
|
||||||
srp@1.0.10
|
tcef:publish-performant-counts@0.2.0
|
||||||
standard-minifier-css@1.4.1
|
templating@1.4.4
|
||||||
standard-minifier-js@2.3.3
|
templating-compiler@2.0.1
|
||||||
static-html@1.2.2
|
templating-runtime@2.0.2
|
||||||
templating@1.3.2
|
templating-tools@2.0.1
|
||||||
templating-compiler@1.3.3
|
themeteorchef:bert@1.1.0
|
||||||
templating-runtime@1.3.2
|
tracker@1.3.4
|
||||||
templating-tools@1.1.2
|
typescript@5.6.3
|
||||||
themeteorchef:bert@2.1.3
|
underscore@1.6.4
|
||||||
tmeasday:check-npm-versions@0.3.2
|
url@1.3.5
|
||||||
tracker@1.1.3
|
|
||||||
ui@1.0.13
|
|
||||||
underscore@1.0.10
|
|
||||||
url@1.2.0
|
|
||||||
vjrj:piwik@0.3.1
|
vjrj:piwik@0.3.1
|
||||||
webapp@1.5.0
|
webapp@2.0.4
|
||||||
webapp-hashing@1.0.9
|
webapp-hashing@1.1.2
|
||||||
xolvio:cleaner@0.3.3
|
zodern:types@1.0.13
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
# Don't reload meteor server when we modify or do test
|
# Don't reload meteor server when we modify or do test
|
||||||
cucumber
|
cucumber
|
||||||
output.json
|
output.json
|
||||||
|
# REST smoke-test harness runs standalone (Node), never as Meteor server code
|
||||||
|
smoke
|
||||||
|
# Ops/deploy scripts (p.ej. scripts/*.mongo.js usan el global `db` de mongosh, no de Meteor)
|
||||||
|
scripts
|
||||||
|
# Suite Playwright: es un proyecto Node aparte (e2e/package.json), no codigo del servidor
|
||||||
|
e2e
|
||||||
|
# Banco de carga (fase 12): scripts Node sueltos, no codigo del servidor
|
||||||
|
bench
|
||||||
|
|
|
||||||
5
.npmrc
Normal file
5
.npmrc
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Las libs react-* antiguas (react-bootstrap 0.31, react-confirm 0.1.16, ...)
|
||||||
|
# declaran peers de react 15/16 pero funcionan con react 18 (ver UPGRADE.md).
|
||||||
|
# npm >= 7 fallaría con ERESOLVE sin esto — aplica igual en dev y en el build
|
||||||
|
# de la imagen Docker.
|
||||||
|
legacy-peer-deps=true
|
||||||
47
Dockerfile
Normal file
47
Dockerfile
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Web Meteor 3.1 — imagen de producción (fase 3, Docker Compose).
|
||||||
|
#
|
||||||
|
# Multi-stage:
|
||||||
|
# 1. meteor-builder (debian/glibc): instala el meteor-tool y construye el bundle.
|
||||||
|
# 2. server-deps (alpine/musl): npm install de programs/server con toolchain
|
||||||
|
# nativa, sobre la MISMA libc que el runtime.
|
||||||
|
# 3. runtime (node:22-alpine): solo el bundle listo.
|
||||||
|
#
|
||||||
|
# Secretos: NUNCA en la imagen. El entrypoint carga METEOR_SETTINGS desde el
|
||||||
|
# fichero apuntado por METEOR_SETTINGS_FILE (montado por compose).
|
||||||
|
|
||||||
|
FROM node:22-bookworm AS meteor-builder
|
||||||
|
ENV METEOR_ALLOW_SUPERUSER=1
|
||||||
|
# Node 22 + Happy Eyeballs elige IPv6 sin ruta contra warehouse.meteor.com
|
||||||
|
# (mismo workaround que en dev, ver UPGRADE.md).
|
||||||
|
ENV NODE_OPTIONS="--dns-result-order=ipv4first --no-network-family-autoselection"
|
||||||
|
# python-is-python3: node-gyp 3.x (pulled by some Atmosphere npm deps) invokes
|
||||||
|
# `python`, absent on bookworm by default.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl ca-certificates python3 python-is-python3 make g++ git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN curl -fsSL "https://install.meteor.com/?release=3.1" | sh
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN meteor npm install
|
||||||
|
RUN meteor build /build --directory --server-only
|
||||||
|
|
||||||
|
FROM node:22-alpine AS server-deps
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
WORKDIR /bundle
|
||||||
|
COPY --from=meteor-builder /build/bundle .
|
||||||
|
RUN cd programs/server && npm install --omit=dev
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
ENV NODE_ENV=production PORT=3000
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=server-deps --chown=node:node /bundle .
|
||||||
|
COPY --chown=node:node docker/web-entrypoint.sh /web-entrypoint.sh
|
||||||
|
RUN chmod +x /web-entrypoint.sh
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
# 127.0.0.1 (not localhost): busybox wget resolves localhost to ::1 first, but
|
||||||
|
# the Meteor server listens on IPv4 only -> the check would false-negative.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=5 \
|
||||||
|
CMD wget -qO /dev/null "http://127.0.0.1:${PORT}/" || exit 1
|
||||||
|
ENTRYPOINT ["/web-entrypoint.sh"]
|
||||||
|
CMD ["node", "main.js"]
|
||||||
129
PENDIENTE.md
Normal file
129
PENDIENTE.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# Pendiente — web "Todos contra el fuego" (rama `meteor3-wip`)
|
||||||
|
|
||||||
|
Estado a 2026-07-18. La migración **Meteor 1.6 → 3.1 + MongoDB 7** está completa
|
||||||
|
y verificada (server async, React 18, web renderiza, smoke REST byte-idéntico).
|
||||||
|
Lo que falta, agrupado. Ver `UPGRADE.md` para el detalle de lo ya hecho.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Modernización de librerías react-* antiguas (deuda de front grande)
|
||||||
|
|
||||||
|
Funcionan en React 18 por compatibilidad heredada, pero ensucian la consola en
|
||||||
|
desarrollo (no en producción) y **bloquean el salto a React 19**. Estado:
|
||||||
|
|
||||||
|
| Lib | De → A | Estado |
|
||||||
|
|---|---|---|
|
||||||
|
| react-helmet | 5.2 → react-helmet-async 2 | ✅ hecho |
|
||||||
|
| react-i18next + i18next | 7.4/10.5 → 14/23 | ✅ hecho |
|
||||||
|
| react-bootstrap | 0.31 → 2 | ✅ hecho (CSS sigue en Bootstrap 4) |
|
||||||
|
| reactstrap + 3 deps muertas | — | ✅ eliminadas (0 usos) |
|
||||||
|
| react-router-dom | 4.2 → 6.30 | ✅ hecho (history 5, HOC `withRouterCompat`) |
|
||||||
|
| react-leaflet + leaflet | 1.8/1.3 → 4.2/1.9 | ✅ hecho (Map→MapContainer, refs directas, useMap/useMapEvents; 4 plugins reimplementados: MapControl portal, GoogleMutantLayer, fullscreen, sleep/graphicscale vanilla; ver UPGRADE.md) |
|
||||||
|
| **Bootstrap CSS/JS** | 4.1 → 5 | ⏸️ **diferida** (carrusel/navbar jQuery BS4; ver UPGRADE.md) |
|
||||||
|
|
||||||
|
Lo verificado en navegador tras cada lib: `/`, `/fires`, `/fire/archive/<hex>`,
|
||||||
|
`/login`, `/subscriptions` + consola. Smoke REST byte-idéntico en cada paso.
|
||||||
|
Limpieza de warnings restantes YA hecha: `defaultProps` en componentes función →
|
||||||
|
parámetros por defecto; `UNSAFE_componentWillReceiveProps` (FromNow, FireStats,
|
||||||
|
Fires, SelectionMap) → `getDerivedStateFromProps`/`componentDidUpdate`; Reconnect
|
||||||
|
(banner Blaze `meteorStatus` con `findDOMNode`) → React nativo con
|
||||||
|
`useTracker(Meteor.status)`; react-share 2→5 y react-progress-bar.js → progressbar.js
|
||||||
|
(ambos tiraban `findDOMNode`/`defaultProps`); y react-leaflet 1.8→4.2 (mapa central,
|
||||||
|
4 plugins reimplementados). **Tras esto la consola tiene 0 warnings de React en las
|
||||||
|
rutas principales** (verificado forzando re-render en / y /fires). Único resto para
|
||||||
|
React 19: el `<Blaze serverFacts>` de Status.js (`findDOMNode`), solo en `/status` (admin).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Deuda funcional / de calidad de la web (independiente de prod)
|
||||||
|
|
||||||
|
- **Comentarios (feature React nueva): verificación manual en staging.** El smoke
|
||||||
|
no llega a la UI. Los **métodos** (insert/edit/remove/like/dislike) y el embed
|
||||||
|
(`mediaAnalyzers`) ya están cubiertos por mocha (`test/server/comments.test.js`),
|
||||||
|
pero quedan por probar a mano en una página de fuego:
|
||||||
|
- publicar / editar / borrar un comentario (UI React)
|
||||||
|
- like / dislike (toggle)
|
||||||
|
- render del embed de imagen y de YouTube
|
||||||
|
- email a otros comentaristas (`onCommentAdd.js`, entrega real de correo)
|
||||||
|
- ✅ **`FireContainer` endurecido** (`imports/ui/pages/Fires/Fires.js`): el
|
||||||
|
`findOne()` sin selector ahora se acota por el `_id` de la URL en la ruta
|
||||||
|
`archive` (`new Meteor.Collection.ObjectID(id)`); en active/alert/hash cada
|
||||||
|
publicación deja un único fire en minimongo, así que el read vacío es correcto.
|
||||||
|
- ✅ **Suite de tests con runner (`meteortesting:mocha`).** `npm test` corre
|
||||||
|
`meteor test … --driver-package meteortesting:mocha` (watch: `npm run test-watch`).
|
||||||
|
Tests reescritos a `chai` + APIs async de Meteor 3 en `test/server/*.test.js`
|
||||||
|
(server-only), incluyendo cobertura nueva de los métodos de comentarios. Jest y
|
||||||
|
`rest.test.js` (ya cubierto por `smoke/`) eliminados. **36 passing.** Pendiente
|
||||||
|
menor: portar los casos de token inválido (401/400) de `rest.test.js` al smoke.
|
||||||
|
- ✅ **Rate-limit de publications** (`imports/startup/server/api.js`): reglas
|
||||||
|
`type: 'subscription'` vía `rateLimitSubscriptions` — 5/1000ms en `fireFrom*` y
|
||||||
|
`comments.forReference`; 10/1000ms en las subs geo (map pan/zoom).
|
||||||
|
- ✅ **Google Maps con `loading=async`** (`imports/startup/client/Gkeys.js`):
|
||||||
|
se envuelve `GoogleMapsLoader.createUrl` para añadir el parámetro (el paquete
|
||||||
|
`google-maps` npm no expone hook). *Verificar en staging que el mapa carga y el
|
||||||
|
aviso de consola desaparece.*
|
||||||
|
- ✅ **`meteor-accounts-t9n` actualizado** a `^2.6.0` (es/en OK). El paquete sigue
|
||||||
|
sin build gallego (`gl`), así que se mantiene el fallback gl→es documentado en
|
||||||
|
`i18n.js`. Traducción gallega de la app (`gl/common.json`): 0 claves faltantes.
|
||||||
|
- ✅ **Salto Bootstrap 4→5 (CSS/JS) hecho** — react-bootstrap v2 ahora corre sobre
|
||||||
|
su CSS nativo (BS5). **Compila y arranca; falta SOLO verificación visual en
|
||||||
|
staging** (BS4→5 cambia sutilezas de grid/gutters/tipografía en todas las páginas).
|
||||||
|
- Widgets jQuery/BS4 migrados a React (los blockers): navbar collapse
|
||||||
|
(`Navigation.js`/`NavItem.js`), carrusel del home (`Index.js` → `<Carousel>`,
|
||||||
|
conserva `.lazy` vía `onSlide`), dropdowns de idioma/tipo (`Profile.js`/
|
||||||
|
`Fires.js` → `<Dropdown>`), y toggle del feedback (`Feedback.js` → estado React).
|
||||||
|
Deps `bootstrap-carousel-swipe` y `alexwine:bootstrap-4` eliminadas; jQuery
|
||||||
|
global ya no se usa (solo `jquery`+`jquery-validation` vía npm en `validate.js`).
|
||||||
|
- CSS: `bootstrap@5` npm importado en `imports/startup/client/index.js` (antes
|
||||||
|
que `app.scss`, para que los overrides ganen). Renombres de utilidades:
|
||||||
|
`ml-auto`→`ms-auto`, `float-right`→`float-end`, `btn-block`→`w-100`,
|
||||||
|
`data-toggle`→`data-bs-toggle`, `sr-only`→`.visually-hidden` (shim).
|
||||||
|
- **QA visual pendiente en staging (todas las páginas):** formularios (react-
|
||||||
|
bootstrap ya emite markup BS5 → deberían mejorar), navbar, botones, modales,
|
||||||
|
cards, grid/gutters, y los widgets migrados (menú móvil, carrusel, dropdowns,
|
||||||
|
feedback). Alertas de `themeteorchef:bert` (comprobar que siguen bien sin el
|
||||||
|
jQuery de alexwine).
|
||||||
|
- Menor: `popper.js@1` en `package.json` es legacy sin uso (react-bootstrap trae
|
||||||
|
`@popperjs/core@2`) → se puede quitar. El bloque `.form-label` de `forms.scss`
|
||||||
|
ya es redundante (BS5 lo trae) salvo por el `display:block` explícito.
|
||||||
|
- **Limpieza menor:** migración 217 `// TODO remove falsepositives lowercase
|
||||||
|
collection`; `prerender.js` gating comentado; sección per-fire del sitemap
|
||||||
|
eliminada (era código muerto tras `firesMapEnabled=false`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Observabilidad (Sentry/GlitchTip) — casi cerrado
|
||||||
|
|
||||||
|
- ✅ Server-side reporta a GlitchTip; ✅ cliente vía **túnel** `/sentry-tunnel`
|
||||||
|
(esquiva el 503 de Cloudflare). DSN en `settings-development.json` (gitignored).
|
||||||
|
- Pendiente: poner el DSN en el `METEOR_SETTINGS` de **producción** y confirmar
|
||||||
|
que el servidor de prod alcanza GlitchTip (IPv4). Nota: el túnel es un relay
|
||||||
|
acotado al proyecto propio (aceptable, documentado).
|
||||||
|
- Consola de dev: warnings de librería restantes solo se van con el punto 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Cutover a producción (infra — fase 3, es lo grande)
|
||||||
|
|
||||||
|
- **Migrar datos Mongo 3.2 → 7** (mongodump/restore) en el servidor real.
|
||||||
|
- **Desplegar el stack** (Docker) al servidor limpio vía el **ansible de
|
||||||
|
Comunes** — NO al viejo shiva. Ver `RUNBOOK.md` (stack compose local validado).
|
||||||
|
- **Coordinar orden con notificaciones:** la web NO debe desplegarse antes de que
|
||||||
|
el microservicio `tcef-notifications` emita en prod (si no, los usuarios dejan
|
||||||
|
de recibir avisos). Ver dependencia en `UPGRADE.md`.
|
||||||
|
- Secretos de prod (`METEOR_SETTINGS`, no el fichero gitignored), cron del
|
||||||
|
GeoLite2 City (MaxMind) en el nuevo host, proxy/TLS por el ansible compartido.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Fuera del repo web (bloquean el "hecho" global, pistas aparte)
|
||||||
|
|
||||||
|
- **tcef-notifications:** rollout por canal shadow→canary→full (FCM en canary
|
||||||
|
superado; email y telegram pendientes) y redeploy del servicio actualizado.
|
||||||
|
- **App móvil `fires_flutter`:** republicación en Play Store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Regla de trabajo en todo lo anterior: commits locales atómicos en `meteor3-wip`,
|
||||||
|
**smoke REST byte-idéntico** tras cada cambio, verificación visual en navegador,
|
||||||
|
y NUNCA `git push`.
|
||||||
117
RUNBOOK.md
Normal file
117
RUNBOOK.md
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# RUNBOOK — stack local dockerizado (fase 3, validación)
|
||||||
|
|
||||||
|
Stack Docker Compose del sistema modernizado de "Todos contra el fuego":
|
||||||
|
web Meteor 3.1 (Node 22), MongoDB 7 (replica set de un nodo), Redis (AOF),
|
||||||
|
`tcef-notifications` y node-red. **Es el entorno de validación local previo a
|
||||||
|
producción** — el despliegue real irá al ansible de Comunes (fase 3 del plan);
|
||||||
|
este compose no toca el proxy ni la infra compartida.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Docker + Docker Compose v2.
|
||||||
|
- El repo hermano `../tcef-notifications` (el compose construye su imagen desde ahí).
|
||||||
|
|
||||||
|
## Primer arranque
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Secretos (gitignorados; ver secrets/README.md)
|
||||||
|
cp settings-development.json secrets/meteor-settings.json
|
||||||
|
cp secrets/notifications.env.example secrets/notifications.env # MODE=dry-run
|
||||||
|
|
||||||
|
# 2. Construir y levantar (el primer build de la web tarda: descarga meteor-tool
|
||||||
|
# y compila el bundle; ~10-20 min en frío)
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 3. Comprobar salud
|
||||||
|
docker compose ps # todo "healthy" / mongo-init "exited (0)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Servicios y puertos en el host:
|
||||||
|
|
||||||
|
| Servicio | Contenedor | URL / puerto |
|
||||||
|
|---|---|---|
|
||||||
|
| web Meteor | `tcef-stack-web` | http://localhost:3200 |
|
||||||
|
| node-red | `tcef-stack-node-red` | http://localhost:1880 |
|
||||||
|
| MongoDB 7 | `tcef-stack-mongo` | solo red interna (usar `docker exec`) |
|
||||||
|
| Redis | `tcef-stack-redis` | solo red interna |
|
||||||
|
| notificaciones | `tcef-stack-notifications` | sin puerto (worker) |
|
||||||
|
|
||||||
|
`mongo-init` es un one-shot: inicia el replica set `rs0` y fija
|
||||||
|
`db.migrations` a la versión 18 (los `up()` históricos ya son async, pero se
|
||||||
|
pinta v18 porque los datos reales de producción llegan pre-migrados).
|
||||||
|
|
||||||
|
> Nota healthcheck: los checks usan `127.0.0.1`, no `localhost`. El `wget` de
|
||||||
|
> busybox (alpine) resuelve `localhost` a `::1` (IPv6) primero, pero los
|
||||||
|
> servidores Meteor/node-red escuchan solo en IPv4 → un `localhost` daría
|
||||||
|
> unhealthy aunque la app responda perfectamente por el puerto mapeado.
|
||||||
|
|
||||||
|
## Smoke test REST (red de seguridad del contrato Flutter)
|
||||||
|
|
||||||
|
Debe ser **byte-idéntico** a los snapshots committeados:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MONGO_CONTAINER=tcef-stack-mongo MONGO_SHELL=mongosh MONGO_PORT=27017 \
|
||||||
|
BASE_URL=http://localhost:3200 ./smoke/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operación diaria
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f web # logs de un servicio (rotados: 3×10MB)
|
||||||
|
docker compose ps # estado + healthchecks
|
||||||
|
docker compose restart notifications # reiniciar un servicio
|
||||||
|
docker compose down # parar todo (los volúmenes persisten)
|
||||||
|
docker compose down -v # ⚠️ borra también mongo/redis/node-red
|
||||||
|
```
|
||||||
|
|
||||||
|
## Desplegar una nueva versión
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# web (tras cambios en el repo)
|
||||||
|
docker compose build web && docker compose up -d web
|
||||||
|
|
||||||
|
# notificaciones (tras cambios en ../tcef-notifications)
|
||||||
|
docker compose build notifications && docker compose up -d notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
Verificar tras cada despliegue de la web: `docker compose ps` healthy + smoke
|
||||||
|
REST byte-idéntico (arriba).
|
||||||
|
|
||||||
|
## Backups y restauración (volúmenes locales)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mongo: dump / restore de la db fuegos
|
||||||
|
docker exec tcef-stack-mongo mongodump --db fuegos --archive > fuegos.dump
|
||||||
|
docker exec -i tcef-stack-mongo mongorestore --archive --drop < fuegos.dump
|
||||||
|
|
||||||
|
# node-red: tar del volumen de datos
|
||||||
|
docker run --rm -v tcef-stack_nodered-data:/data -v "$PWD:/backup" alpine \
|
||||||
|
tar czf /backup/nodered-data.tgz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- **web**: `git checkout <commit-anterior>` + `docker compose build web && docker compose up -d web`.
|
||||||
|
(En producción se conservará la imagen anterior etiquetada; aquí basta git.)
|
||||||
|
- **notificaciones**: igual, desde `../tcef-notifications`. `KILL_SWITCH=1` en
|
||||||
|
`secrets/notifications.env` + `docker compose up -d notifications` detiene
|
||||||
|
todo envío al instante sin parar el servicio.
|
||||||
|
- **datos**: restaurar el último dump (arriba).
|
||||||
|
|
||||||
|
## Salvaguardas de notificaciones
|
||||||
|
|
||||||
|
`secrets/notifications.env` arranca en `MODE=dry-run` (no envía nada). La
|
||||||
|
progresión dry-run → shadow → canary → full está documentada en
|
||||||
|
`../tcef-notifications/docs/rollout.md` y **nunca** se salta pasos. En este
|
||||||
|
stack local no hay credenciales reales salvo que las pongas tú.
|
||||||
|
|
||||||
|
## Deuda conocida de este compose
|
||||||
|
|
||||||
|
- `tcef-notifications` no expone endpoint HTTP de salud → healthcheck por
|
||||||
|
proceso (`pgrep`). Añadir `/healthz` al servicio cuando toque.
|
||||||
|
- node-red corre la imagen oficial vacía (los flows de los bots viven en
|
||||||
|
shiva; su migración de datos es parte del cutover real de fase 3).
|
||||||
|
- El driver mongodb 3.7 de `tcef-notifications` (elegido por el Mongo 3.2 de
|
||||||
|
producción) conecta con Mongo 7 en pruebas locales, pero al hacer el cutover
|
||||||
|
real a Mongo 7 debe subirse a driver 6.x (y habilitar change streams).
|
||||||
375
UPGRADE.md
Normal file
375
UPGRADE.md
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
# Meteor upgrade — 1.6.1.1 → 2.5 (Mongo 3.2) → 3.1 (Mongo 7, on `meteor3-wip`)
|
||||||
|
|
||||||
|
Incremental, verified upgrade of `todos-contra-el-fuego-web`. Branch:
|
||||||
|
`meteor3-upgrade` (base `tcef-master`). Local commits only — no push until
|
||||||
|
authorized.
|
||||||
|
|
||||||
|
## ⚠️ Hard blocker for the final 3.x jump: MongoDB 3.2
|
||||||
|
|
||||||
|
Production runs against the shared replica set **rsmain on MongoDB 3.2.11**.
|
||||||
|
Meteor 3 ships the modern `mongodb` Node driver, which requires a server
|
||||||
|
**≥ 4.2/4.4**. Meteor **2.x still works against Mongo 3.2**.
|
||||||
|
|
||||||
|
Therefore this branch lands on **Meteor 2.5** — empirically the highest release
|
||||||
|
whose bundled `mongodb` driver still connects to Mongo 3.2 (2.6 bumps the driver
|
||||||
|
to 4.x and fails; see "Escala ceiling" below) — with everything green. The
|
||||||
|
`meteor update --release 3.x` step (and even 2.6+) is **deliberately not taken**
|
||||||
|
until the Comunes infra team upgrades rsmain (shared infra — coordinated in a
|
||||||
|
separate phase, not unilaterally). Server code should still be migrated to
|
||||||
|
async/await before the final 3.x jump so it is mechanical (tracked as debt).
|
||||||
|
|
||||||
|
## ⚠️ Deploy-ordering dependency: notifications cutover
|
||||||
|
|
||||||
|
The old push/email notification code was removed from the web (see the
|
||||||
|
notifications commit) because it now lives in the **tcef-notifications**
|
||||||
|
microservice. **Production must not run this build until tcef-notifications is
|
||||||
|
emitting in prod** — otherwise users stop receiving notifications. The web
|
||||||
|
deploy and the notifications cutover must be coordinated.
|
||||||
|
|
||||||
|
## Safety net: REST smoke test
|
||||||
|
|
||||||
|
`smoke/` is a standalone Node harness that calls every REST endpoint the Flutter
|
||||||
|
app consumes, against a seeded local dev server, and diffs the responses against
|
||||||
|
committed snapshots (`smoke/snapshots/`). See `smoke/README.md`.
|
||||||
|
|
||||||
|
**Run after every escala — it must stay byte-identical:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name tcef-mongo32 -p 27018:27017 mongo:3.2 # once
|
||||||
|
export PATH="$HOME/.meteor:$PATH" MONGO_URL="mongodb://localhost:27018/fuegos"
|
||||||
|
meteor --settings settings-development.json --port 3100 & # dev server
|
||||||
|
./smoke/smoke.sh # seed + compare
|
||||||
|
```
|
||||||
|
|
||||||
|
## Escalas
|
||||||
|
|
||||||
|
### Baseline — Meteor 1.6.1.1 (starting point)
|
||||||
|
|
||||||
|
- Boots against Mongo 3.2 with `settings-development.json`.
|
||||||
|
- Dev-enabling changes (do not affect production behavior):
|
||||||
|
- `IPGeocoder.js`: degrade gracefully when the MaxMind GeoLite2 DB is absent
|
||||||
|
(provisioned by cron in prod) instead of crashing at boot.
|
||||||
|
- `settings-development.json`: added dev-only `private.internalApiToken`
|
||||||
|
(`dev-smoke-token`) — the REST API only registers its routes when this is
|
||||||
|
set.
|
||||||
|
- **Notifications code removed** (migrated to tcef-notifications): deleted
|
||||||
|
`notificationsObserver.js`, `notificationsProcess.js`, the "Process pending
|
||||||
|
notif" SyncedCron job, and the `node-gcm` dependency. `subsUnion.js` kept (it
|
||||||
|
feeds `subs-public-union`, consumed by the app).
|
||||||
|
- REST smoke baseline captured here. ✅ green.
|
||||||
|
|
||||||
|
### Escala 1 — 1.6.1.1 → 1.8.3 ✅ green
|
||||||
|
|
||||||
|
```bash
|
||||||
|
meteor update --release 1.8.3
|
||||||
|
meteor npm install --save @babel/runtime@^7.26.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Core packages bumped (highlights): `meteor` 1.8.6→1.9.3, `ecmascript`
|
||||||
|
0.10.7→0.13.2, `modules` 0.11.6→0.14.0, `mongo` 1.4.7→1.7.0, `npm-mongo`
|
||||||
|
2.2.34→**3.2.0** (mongodb driver 3.x — still fine against Mongo 3.2 server),
|
||||||
|
`webapp` 1.5.0→1.7.5, `standard-minifier-js` →2.5.2. `underscore` auto-added
|
||||||
|
(1.7 dropped it from meteor-base), `fetch` + `modern-browsers` added.
|
||||||
|
|
||||||
|
**Breaking change — Babel beta → stable.** Boot failed with
|
||||||
|
`Cannot find module '@babel/runtime/helpers/objectSpread2'`: the pinned
|
||||||
|
`@babel/runtime@7.0.0-beta.44` predates helpers that 1.8's `ecmascript` emits.
|
||||||
|
Fixed by upgrading to stable **7.x** (`@babel/runtime@^7.29.7`). Note: `@latest`
|
||||||
|
resolves to `8.x`, which is too new for Meteor's 7-style helper layout — pin to
|
||||||
|
`^7`.
|
||||||
|
|
||||||
|
REST smoke test: **byte-identical to baseline.** ✅
|
||||||
|
|
||||||
|
### Escala 2 — 1.8.3 → 1.11 (version resolution only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
meteor update --release 1.11
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution reached 1.11 (`ecmascript` 0.14.3, `mongo` 1.10.0, `npm-mongo`
|
||||||
|
**3.8.0** — still Mongo-3.2-compatible, `accounts-*` 1.x, `email` 2.0). But this
|
||||||
|
release surfaced the two big blockers below. The app was ultimately driven to
|
||||||
|
**2.3** (see next section); 1.11 was a transit point, not a landing.
|
||||||
|
|
||||||
|
### Escala 3 — → Meteor 2.3 ✅ green (builds + runs against Mongo 3.2, smoke byte-identical)
|
||||||
|
|
||||||
|
`.meteor/release = METEOR@2.3`. `npm-mongo` **3.9.0** (mongodb driver 3.x) — the
|
||||||
|
last driver line that still speaks to a Mongo 3.2 server. Core bumps:
|
||||||
|
`accounts-*` 2.0, `ecmascript` 0.15.2, `mongo` 1.12.0, HMR added.
|
||||||
|
|
||||||
|
This escala was dominated by **dead Atmosphere packages** (the plan's #1 risk).
|
||||||
|
Root causes and fixes, in the order they were hit:
|
||||||
|
|
||||||
|
1. **`arkham:comments-ui` — no Meteor 2.x build.** The vendored local package
|
||||||
|
used `Npm.depends`, and rebuilding a *local* package's npm deps crashes
|
||||||
|
meteor-tool ≥1.11 on this host (`node_contextify.cc … Assertion
|
||||||
|
args[1]->IsString()` → SIGABRT; reproducible with any `Npm.depends`, even
|
||||||
|
`is-number`). Switching to the Atmosphere isopack avoided the local rebuild
|
||||||
|
but the package is abandoned: its newest 2.x-solvable version (0.2.15) pins
|
||||||
|
`accounts-password@1.0.1`, conflicting with Meteor 2.0's accounts-password 2.0.
|
||||||
|
**Decision (user-approved): reimplement comments as a small React feature**
|
||||||
|
(`imports/api/Comments/*`, `imports/ui/components/Comments/CommentsBox.js`),
|
||||||
|
dropping the package entirely. Fire-page comments only (the sole usage), with
|
||||||
|
likes/dislikes, image/YouTube embed, owner edit/remove, and the
|
||||||
|
"email other commenters" side-effect preserved. Comment text is now rendered
|
||||||
|
as **safe plain text** (+ media embed) instead of markdown-to-HTML — a small
|
||||||
|
scope reduction and a security improvement (no raw-HTML injection of user
|
||||||
|
content). The old local fork is preserved in its nested git repo
|
||||||
|
(commit `4761da9`).
|
||||||
|
|
||||||
|
2. **`coffeescript@1.0.17` build plugin — crashes the build.** The same
|
||||||
|
`node_contextify` assertion fired at *build start* on this host whenever the
|
||||||
|
coffeescript compiler plugin loaded. It was pulled transitively by the dead
|
||||||
|
**test stack** (`meteortesting:mocha`, `practicalmeteor:chai`,
|
||||||
|
`xolvio:cleaner`) and by the old auto-downgraded `nimble:restivus@0.6.6`
|
||||||
|
(which needs `iron:router`). Removing those removed coffeescript and
|
||||||
|
unblocked the build. A trivial `meteor create` app builds fine on the same
|
||||||
|
tool, confirming the crash is package-specific, not a broken tool.
|
||||||
|
|
||||||
|
3. **`nimble:restivus` — the REST API package — pins `accounts-password@1.3.3`**
|
||||||
|
(incompatible with 2.x) and is CoffeeScript (needs the crashing plugin).
|
||||||
|
**Vendored** as `packages/nimble-restivus`: the `.coffee` sources were
|
||||||
|
precompiled to plain JS (`lib/restivus-all.js`, single translation unit so
|
||||||
|
the `Auth`/`Route`/`Restivus` classes share scope; `Restivus` left
|
||||||
|
un-`var`'d so Meteor's `api.export` picks it up), and `package.js` loosens
|
||||||
|
`accounts-password` to 2.x and drops the coffeescript dependency. This also
|
||||||
|
let restivus resolve to 0.8.12 (json-routes based), which **removed the whole
|
||||||
|
`iron:router` stack**. REST behavior unchanged (smoke byte-identical).
|
||||||
|
|
||||||
|
4. **`maximum:server-transform` (unused) removal** broke
|
||||||
|
`Meteor.publishTransformed` in `FalsePositives/server/publications.js`; no
|
||||||
|
transform was actually configured, so replaced with plain `Meteor.publish`.
|
||||||
|
|
||||||
|
5. **`fourseven:scss` 4.5.4 → 4.14.1** — `node-sass@4.5.3` won't build on node 12
|
||||||
|
(Meteor ≥1.9); 4.14.1 uses a node-12-compatible node-sass with prebuilt
|
||||||
|
binaries. (Meteor 1.8 built it on node 8.) `4.15.0` needs ecmascript ≥ 0.15.1
|
||||||
|
→ too new for 1.11.
|
||||||
|
|
||||||
|
6. **`less`, `markdown` removed** — no `.less`/`.md` source files; their build
|
||||||
|
plugins were dead weight.
|
||||||
|
|
||||||
|
### ⚠️ Local build-host caveat
|
||||||
|
|
||||||
|
Meteor-tool's bundled node (12.x/14.x) SIGABRTs (`node_contextify` assertion) on
|
||||||
|
this machine (kernel 6.12) when certain old build plugins load — this is an
|
||||||
|
environment interaction, not a code defect (the deploy host built these fine
|
||||||
|
historically). It was fully worked around by removing the dead build plugins
|
||||||
|
above. If a future escala hits it again, build in a container matching the
|
||||||
|
deploy target (Debian) rather than on this host.
|
||||||
|
|
||||||
|
### Escalas 2.4 & 2.5 — ✅ green
|
||||||
|
|
||||||
|
`meteor update --release 2.4`, then `--release 2.5`. Both trivial; `npm-mongo`
|
||||||
|
stays at **3.9.1** (mongodb driver 3.x). REST smoke byte-identical against
|
||||||
|
`mongo:3.2` on both.
|
||||||
|
|
||||||
|
## Escala ceiling vs Mongo 3.2 — **landing on Meteor 2.5** (empirically verified)
|
||||||
|
|
||||||
|
Meteor bumps the bundled `mongodb` Node driver to **4.3.1 at Meteor 2.6**, which
|
||||||
|
requires server wire version ≥ 6 (**MongoDB ≥ 3.6**). Production's rsmain is
|
||||||
|
**Mongo 3.2** (wire version 4). Verified directly by booting 2.6 against a real
|
||||||
|
`mongo:3.2`:
|
||||||
|
|
||||||
|
```
|
||||||
|
MongoCompatibilityError: Server at localhost:27018 reports maximum wire
|
||||||
|
version 4, but this version of the Node.js Driver requires at least 6
|
||||||
|
(MongoDB 3.6)
|
||||||
|
=> Exited with code: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
So **Meteor 2.5 is the highest release that runs against the production Mongo
|
||||||
|
3.2** (npm-mongo 3.9.1). That is where this branch lands: everything green, REST
|
||||||
|
smoke byte-identical. Going past 2.5 (to 2.16 or 3.x) is gated on upgrading the
|
||||||
|
shared rsmain replica set to Mongo ≥ 4.4 first — the same DB blocker as 3.x.
|
||||||
|
|
||||||
|
## ✅ Meteor 3.1 + MongoDB 7 — REST smoke GREEN (branch `meteor3-wip`)
|
||||||
|
|
||||||
|
**The 3.x jump works end-to-end for the REST/Flutter contract.** On branch
|
||||||
|
`meteor3-wip`, the web runs on **Meteor 3.1** (Node 22, async/await, no Fibers)
|
||||||
|
against a **dockerized MongoDB 7** (npm-mongo 6.10 / mongodb driver 6), and the
|
||||||
|
REST smoke test is **byte-identical to the 1.6.1.1 baseline** (all 12 endpoints).
|
||||||
|
This is the "mongo superior" decision realized: give fuegos its own Mongo 7
|
||||||
|
instead of the shared rsmain 3.2 — which is what unblocks Meteor 3's driver.
|
||||||
|
|
||||||
|
`meteor3-upgrade` stays at the deployable **Meteor 2.5** (Mongo 3.2). The 3.x
|
||||||
|
work lives on `meteor3-wip` until (a) production Mongo is migrated to 7 and
|
||||||
|
(b) the web-UI async migration below is finished.
|
||||||
|
|
||||||
|
### Reproduce the 3.1 dev stack
|
||||||
|
```bash
|
||||||
|
docker run -d --name tcef-mongo7 -p 27019:27019 mongo:7 --replSet rs0 --port 27019 --bind_ip_all
|
||||||
|
docker exec tcef-mongo7 mongosh --port 27019 --quiet --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27019"}]})'
|
||||||
|
# (migrations run from scratch on an empty DB — all up() bodies are async now,
|
||||||
|
# no need to pre-mark db.migrations at version 18)
|
||||||
|
export MONGO_URL="mongodb://localhost:27019/fuegos?replicaSet=rs0"
|
||||||
|
export NODE_OPTIONS="--dns-result-order=ipv4first --no-network-family-autoselection" # reach warehouse.meteor.com on Node 22
|
||||||
|
meteor --settings settings-development.json --port 3100
|
||||||
|
MONGO_CONTAINER=tcef-mongo7 MONGO_SHELL=mongosh MONGO_PORT=27019 ./smoke/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key build/runtime unblocks (Meteor 3.1)
|
||||||
|
- **underscore linker crash** (`Runtime is not available … underscore`): resolved
|
||||||
|
`underscore@1.6.2` had a runtime-less `web.browser` unibuild → pin
|
||||||
|
**`underscore@1.6.4`** (diagnosed by instrumenting the tool's `linker.js`).
|
||||||
|
- **collection2 v4** is fully lazy with no main module → import its eager entry
|
||||||
|
`meteor/aldeed:collection2/static.js` from `server|client/00-collection2-init.js`
|
||||||
|
so `attachSchema`/autoValues are patched before any collection loads.
|
||||||
|
- **Fibers gone**: removed `fibers.js`; server sync Mongo → `*Async` throughout
|
||||||
|
(Rest.js + helpers + methods + startup: oauth/subsUnion/migrations/email/facts).
|
||||||
|
- **vendored restivus** patched to `await` async endpoint handlers.
|
||||||
|
- **`$near` + `countAsync()`**: driver 6 runs `count` via aggregation where
|
||||||
|
`$near` is illegal → derive total from `fetchAsync().length`.
|
||||||
|
- **json-routes 3.0** matches routes in registration order → reordered the
|
||||||
|
`mobile/subscriptions/all/...` route before `.../:subsId` in Rest.js.
|
||||||
|
- **fixtures.js / sitemaps.js** disabled (sync-only `@cleverbeagle/seeder` /
|
||||||
|
pre-0.9 `gadicohen:sitemaps`) — not on the REST path. Debt.
|
||||||
|
|
||||||
|
### Web-UI async migration (beyond the REST contract)
|
||||||
|
- ✅ **Fires publications → async** (`fireFromId`, `fireFromAlertId`,
|
||||||
|
`fireFromActiveId`, `fireFromHash`): `findOne`→`findOneAsync`,
|
||||||
|
`count()`→`countAsync()`, `firesUnion` awaited, handlers `async` so the
|
||||||
|
try/catch actually catches rejections. Verified over raw DDP (all subs
|
||||||
|
`ready`, docs flow; fire + falsePositives docs delivered for the seeded
|
||||||
|
fire). `falsePositivesMyloc` / `industriesMyloc` / `comments.forReference`
|
||||||
|
needed **no change** — they return bare `find()` cursors (still sync-safe on
|
||||||
|
Meteor 3). `subscriptions.*` methods were already `*Async`.
|
||||||
|
- Note: the module-level `new Counter(...)` (natestrauser:publish-performant-counts)
|
||||||
|
behind `falsePositivesTotal` boots fine; the publication itself is unused by
|
||||||
|
the current UI — watch it if re-enabled.
|
||||||
|
- ✅ **Comments methods → async** (`comments.insert/edit/remove/like/dislike`):
|
||||||
|
`findOne/insert/update/remove` → `*Async`, helpers (`requireOwner`, `toggle`)
|
||||||
|
async, `users.findOneAsync` for the username. `onCommentAdd` mail fan-out:
|
||||||
|
`users.find().forEach` → `forEachAsync`. Still fire-and-forget from the
|
||||||
|
insert. Comments UI staging verification pending (pre-existing note below).
|
||||||
|
- ✅ **fixtures.js re-enabled** — `@cleverbeagle/seeder` (sync Mongo, no Meteor
|
||||||
|
3 support) **removed from package.json** and replaced with a small in-repo
|
||||||
|
async seeder: idempotent, dev-only (staging opts in with `TCEF_SEED=1`);
|
||||||
|
same accounts as before (`admin@admin.com` / 5 test users / 5 Documents
|
||||||
|
each). Verified: `fixtures: 6 dev users ensured` on boot.
|
||||||
|
- ✅ **sitemaps.js re-enabled** — `gadicohen:sitemaps` (pre-0.9 API, dead on
|
||||||
|
Meteor 3) **removed from .meteor/packages**; `/sitemap.xml` is now a small
|
||||||
|
`WebApp.connectHandlers` handler serving the same static page list (the
|
||||||
|
per-fire section of the old handler was behind `firesMapEnabled = false`,
|
||||||
|
i.e. dead code, and was dropped). Verified serving on dev.
|
||||||
|
- ✅ **Dead client packages purged — the web UI now RENDERS on Meteor 3.**
|
||||||
|
First-ever browser verification of this branch (the smoke only covers REST)
|
||||||
|
found a chain of ancient Blaze-era packages whose client code threw at
|
||||||
|
bundle load, killing the whole app (`meteorInstall is not defined`):
|
||||||
|
- **alanning:roles 1.2.10** (client crash, and its server
|
||||||
|
`Roles.userIsInRole` is sync) → package removed; the app barely used it:
|
||||||
|
`facts.js` now checks the plain `user.roles` field (admin set cached at
|
||||||
|
startup) and App.js takes `roles` from the user doc (prop was unused).
|
||||||
|
Upgrading to roles v4 was rejected: needs a prod data migration.
|
||||||
|
- **selaias:cookie-consent** (its `Cookies` global is gone) → removed;
|
||||||
|
replaced by in-repo React `imports/ui/components/CookieConsent` reusing
|
||||||
|
the same i18n keys; `CookieConsent.init` dropped from `i18n.js`.
|
||||||
|
- **natestrauser:publish-performant-counts** server `Counter._publishCursor`
|
||||||
|
uses sync `cursor.count()` → **vendored** as
|
||||||
|
`packages/publish-performant-counts` (`tcef:publish-performant-counts`)
|
||||||
|
with `countAsync` and an async-aware interval; same public API.
|
||||||
|
- `Meteor.autorun` (removed in Meteor 3) → dropped (App.js debug) /
|
||||||
|
`Tracker.autorun` (FiresMap).
|
||||||
|
- Map/server publications not exercised by the REST smoke converted to
|
||||||
|
async: `activefiresmyloc`, `activefiresunionmyloc`, `fireAlerts`
|
||||||
|
(countAsync/fetchAsync/awaited firesUnion), and `oauth.verifyConfiguration`
|
||||||
|
(findOneAsync). `migrations.js`: all 18 historical `up()` bodies ported to
|
||||||
|
async (`fetchAsync`+`for..of`, `*Async` writes, `createIndexAsync`,
|
||||||
|
`Accounts.createUserAsync`; percolate:migrations 2.0.1 awaits async `up()`).
|
||||||
|
Verified: empty Mongo 7 DB migrates 0→18 with no errors (indexes +
|
||||||
|
industry registries created); pre-marking `version=18` is no longer needed.
|
||||||
|
Verified in a real browser: `/` and `/fires` render (map, search, layers,
|
||||||
|
cookie banner), zero uncaught exceptions; REST smoke byte-identical.
|
||||||
|
- ✅ **React 16 → 18.3** — `react`/`react-dom` bumped to `^18.3.1`
|
||||||
|
(`--legacy-peer-deps`: the ancient react-* libs below declare 15/16 peers),
|
||||||
|
client entry migrated to `createRoot` (`imports/startup/client/index.js`).
|
||||||
|
The legacy UI libs (react-bootstrap 0.31, reactstrap 5-alpha, react-leaflet
|
||||||
|
1.8, react-router-dom 4, react-i18next 7) stay pinned and WORK on 18, but
|
||||||
|
spam the console with legacy-context/defaultProps deprecation warnings and
|
||||||
|
will die on React 19. **Debt**: own front-end refresh project.
|
||||||
|
⚠️ npm gotcha: running `meteor npm install/uninstall` while the dev server
|
||||||
|
watches node_modules can crash the meteor-tool watcher mid-prune (ENOENT on
|
||||||
|
a watched file). Stop the server (or restart it after) when touching deps.
|
||||||
|
|
||||||
|
### Still TODO before the web (not just REST) is fully deployable on 3.x
|
||||||
|
- ✅ **React 16 → 18** render root + ancient react-* libs modernized: react-helmet→
|
||||||
|
react-helmet-async, i18next 10→23 + react-i18next 7→14, react-bootstrap 0.31→2,
|
||||||
|
react-router-dom 4→6. Dead deps (reactstrap, react-leaflet-sidebarv2,
|
||||||
|
react-router-hash-link, react-addons-pure-render-mixin) dropped. All
|
||||||
|
browser-verified, REST smoke byte-identical. **Remaining front debt:**
|
||||||
|
react-leaflet 1.8→4 (deferred — core map, 4 v1-only plugins) and the
|
||||||
|
Bootstrap 4→5 CSS/JS jump (deferred — jQuery carousel/navbar). Both are what's
|
||||||
|
left before a React 19 jump.
|
||||||
|
- ✅ Re-enabled dev fixtures / sitemaps / email default template with async ports.
|
||||||
|
- ✅ SCSS `@import`→`@use`, `lighten`/`darken`→`color.adjust` (dart-sass deprecations).
|
||||||
|
- **Production cutover** still needs: Mongo data migrated 3.2→7 (mongodump/restore),
|
||||||
|
the notifications service emitting in prod, and the Docker deployment (fase 3).
|
||||||
|
|
||||||
|
## Dependency debt (tracked, to resolve in the noted escala)
|
||||||
|
|
||||||
|
| Dep | From | Target | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| React / react-dom | 16.0 | 18 | ✅ done — 18.3.1 + createRoot. The peripheral react-* libs that only worked on 18 via legacy context are now all modernized (rows below) **except react-leaflet** (deferred). Our own React-19 blockers are also cleared: `defaultProps` on function components → default params; `UNSAFE_componentWillReceiveProps` → `getDerivedStateFromProps`/`componentDidUpdate`; the Blaze/findDOMNode `Reconnect` → native React; react-share 2→5 and react-progress-bar.js → progressbar.js (both dropped their findDOMNode/defaultProps). react-leaflet 1.8→4.2 done too (row below). **Dev console is now 0 React warnings** on the main routes. The only React-19 leftover is the Blaze `serverFacts` on the /status admin page (findDOMNode via gadicc:blaze-react-component). |
|
||||||
|
| react-share | 2.0 | 5.3 | ✅ done — v2 shipped function-component `defaultProps` (React-19 blocker). v5 is clean. Dropped the dead GooglePlus button (removed upstream in v4+); the other 6 share buttons are API-compatible. |
|
||||||
|
| react-progress-bar.js | 0.2.3 | — (progressbar.js 1.1) | ✅ removed — the wrapper used the deprecated `findDOMNode`. `LoadingBar` now drives `progressbar.js` (its own underlying dep, promoted to direct) through a ref. |
|
||||||
|
| gadicc:blaze-react-component (in Reconnect) | — | native React | ✅ Reconnect's Blaze `meteorStatus` bridge used `findDOMNode` and was always mounted → rewritten with `useTracker(Meteor.status)` + countdown effect, reusing 255kb:meteor-status's CSS. (Status.js's Blaze `serverFacts` kept — /status admin page only.) |
|
||||||
|
| Server async/await | Fibers | `*Async` APIs | ✅ done — REST, publications, methods, startup and migrations all on `*Async`. |
|
||||||
|
| bcrypt | 1.0.3 (broken binding) | 5.x | ✅ done — 1.0.3 didn't load on Node 22 (accounts-password silently fell back to pure JS) and wouldn't compile in the Docker build; 5.1.1 ships Node-22 prebuilds. |
|
||||||
|
| raven / flowkey:raven | 2.4 | @sentry/node + @sentry/browser 8.x | ✅ done — `flowkey:raven` (Sentry legacy SDK, dead on 3.x, spammed the boot log with 502s against the dead sentry.comunes.org) replaced by the modern SDKs behind the same `ravenLogger.log()` facade (6 call sites unchanged). Backend: **GlitchTip** self-hosted on `aaron` (Sentry-compatible DSN/API; Sentry proper needs 16 GB RAM, didn't fit), deployed via the Comunes ansible (`glitchtip.yml`, org "comunes" / project "tcef-web"), fronted at `https://sentry.comunes.org` (nginx on `assange` → `aaron:8000`). DSN set in `settings-development.json`; end-to-end verified (a test exception sent from the dev server showed up in GlitchTip within seconds). |
|
||||||
|
| nodemailer | 4 | 6.10 | ✅ done — drop-in for our usage: `email.js` only calls `nodemailer.createTransport(MAIL_URL)` and reads `transport.options.auth.user` (in the production-only `from()` branch); both verified unchanged in v6. Sending goes through `ostrio:mailer` (MailTime 2.5), which is v6-compatible. |
|
||||||
|
| Babel | 7 beta | 7 stable | ✅ done (escala 1) |
|
||||||
|
| react-meteor-data | 0.2.16 | 3.0.6 | ✅ done — 0.2.16 (React-15/16-era) looped `withTracker` on fire-detail pages under React 18 (never-ready → blank). `meteor add react-meteor-data@3.0.6`; no call-site changes (HOC API kept). Also dropped `tmeasday:check-npm-versions` (the constraint pinning 0.2.16). See section above. |
|
||||||
|
| i18next | 10.5 | 23.16 | ✅ done — with react-i18next 14. `whitelist`→`supportedLngs`, added `compatibilityJSON: 'v3'` (our locale JSON uses natural-language keys + v3 `_plural` layout, not the v4 `_one`/`_other` suffixes), custom separators `ß`/`ð`/`đ` kept. `sendMissing`→`saveMissing`, missingKeyHandler signature is `(lngs, ns, key, fallbackValue)` now (array first). See react-i18next row. |
|
||||||
|
| react-i18next | 7.4 | 14.1 | ✅ done — 48 `translate([], {wait:true})`/`translate()` HOCs → `withTranslation()`; `react.wait:true`→`react.useSuspense:false`. `<Trans>` unchanged (locale JSON already stores the indexed-tag format `<1><0>{{x}}</0></1>`). ReSendEmail: removed `<Interpolate>` (deleted in v10) → `<Trans i18nKey values={{email}}/>`, dropped the removed bare `t` export. This is what silenced the per-component `Translate`/`I18n` legacy-context console spam. Backends: xhr→`i18next-http-backend` (client), sync-fs→`i18next-fs-backend` (server); `i18next-localstorage-cache` dropped (was `enabled:false`); languagedetector 2→8. Browser-verified: es/en switch, `<Trans>` interpolation (`{{countTotal}}`+`<strong>`) renders on /fires. |
|
||||||
|
| react-leaflet + leaflet | 1.8 / 1.3.1 | 4.2.1 / 1.9.4 | ✅ done — the last and hardest lib; a ground-up hooks rewrite of the app's core map. `<Map>`→`<MapContainer>`; `.leafletElement` refs (6 files/13 sites) → the map/layer instances directly (via a `<MapReady>` child using `useMap`, and plain refs on Marker/Circle/GeoJSON); the controlled-viewport pattern (`onViewportChanged`+`state.center/zoom`) → `<MapEvents>` (`useMapEvents` moveend/zoomend) + imperative `setView`; `onClick`→`eventHandlers={{click}}`, path styling → `pathOptions`/`style`; `subsUnion` takes the `L.Map` directly. **The 4 v1-only plugins were reimplemented** with react-leaflet v4's factories / vanilla Leaflet plugins: `react-leaflet-control`→ in-repo `MapControl` (L.Control + createPortal), `react-leaflet-google`→ `GoogleMutantLayer` (`createLayerComponent` + `leaflet.gridlayer.googlemutant`; Google Maps API already loaded by Gkeys), `react-leaflet-fullscreen`→ `createControlComponent` + `leaflet.fullscreen`, `leaflet-sleep`/`leaflet-graphicscale` kept as vanilla (work on leaflet 1.9). Browser-verified: /fires (tiles, OSM+Google layer switch, custom control, fullscreen, graphic scale, pan/zoom→re-fetch pipeline, no NaN), fire detail (GeoJSON rect + fitBounds), home (all 3 maps coexist; SelectionMap draggable marker→updatePosition + distance circle). **Console now 0 warnings** across home/`/fires` (forced full re-render). REST smoke byte-identical. |
|
||||||
|
| arkham:comments-ui | 1.4.x | — | ✅ replaced with in-repo React feature |
|
||||||
|
| nimble:restivus | Atmosphere | vendored | ✅ local precompiled package, accounts-password 2.x |
|
||||||
|
| maximum:server-transform, meteorhacks:zones, less, markdown, test stack | — | — | ✅ removed (dead/unused) |
|
||||||
|
| fourseven:scss | 4.5.4 | 4.14.1 | ✅ node-12 compatible |
|
||||||
|
| node-gcm | 1.0.2 | — | ✅ removed (dead API, moved to microservice) |
|
||||||
|
| react-router-dom | 4.2.2 | 6.30 | ✅ done — with `history` 4→5 and `react-router-bootstrap` 0.24→0.26. Kills the `Router`/`Switch`/`Route`/`Link`/`LinkContainer` legacy-context warnings. Strategy: keep the shared `history` **singleton** (used outside React by NotificationsObserver and Utils/location) via `unstable_HistoryRouter`, and DON'T rewrite the ~20 class pages — a new `withRouterCompat` HOC bridges v6 hooks back to v4-shaped `history`/`match`/`location` props. `<Switch>`→`<Routes>`, `component=`→`element=`, `Authenticated`/`Public` become guard components rendering `children` or `<Navigate replace>`, `LocationListener` is now a `useLocation`+`useEffect` function. The regex route `/fire/:type(active\|archive\|alert)/:id` → `/fire/:type/:id` (type validated in the component; the only behavior delta is an invalid two-segment type now hits Fires instead of 404 — negligible). `history.listen` callback is `({location})` in v5. Browser-verified: SPA nav, `/subscriptions`→`/login` auth redirect, deep-link `/fire/archive/:id` (params flow), browser back/forward; REST smoke byte-identical. Gotcha hit & fixed: the rewritten guard files need `import React` for their `<Navigate>` JSX. |
|
||||||
|
| react-helmet | 5.2.0 | react-helmet-async 2.0.5 | ✅ done — react-helmet is unmaintained and misbehaves under React 18 StrictMode. Drop-in: same `<Helmet>` children API across the 15 pages; `<HelmetProvider>` wraps `<App/>` in `imports/startup/client/index.js`. Verified in browser: per-page titles, meta description and hreflang alternates injected (`data-rh`). Note: head updates are rAF-deferred (like react-helmet), so hidden/background tabs don't flush — irrelevant for real users. |
|
||||||
|
| react-bootstrap | 0.31.5 | 2.10 | ✅ done — kills the biggest batch of React-19-blocking warnings (Grid/FormGroup/ControlLabel/Navbar.Header/Navbar.Brand/Checkbox/SafeAnchor all used legacy context/defaultProps). 29 files: `Grid`→`Container`, `FormGroup/ControlLabel/FormControl/HelpBlock`→`Form.Group/Label/Control/Text`, `Checkbox`→`Form.Check` (label moves to a prop), `bsStyle`→`variant` (×23, `"default"`→`"secondary"`), `bsSize`→`size` (×2), `pull-right`→`float-end` (×7). Custom `Col.js` (used v0.31 `bootstrapUtils`/`StyleConfig` internals) now re-exports v2's `Col`; custom `NavItem.js` rewritten self-contained (dropped `SafeAnchor`+`createChainedFunction`); `Navigation.js` drops the react-bootstrap `Navbar` (was hand-rolled raw markup anyway). **CSS kept at Bootstrap 4** (see next row) — the only BS5-only class v2 emits that BS4 lacks is `.form-label`, shimmed in `forms.scss`. Browser-verified: home carousel+navbar, signup form + terms `Form.Check` (toggles submit), login form; REST smoke byte-identical. |
|
||||||
|
| Bootstrap CSS/JS | 4.1 (alexwine:bootstrap-4) | 5.x | ⏸️ **deferred as debt.** The app's CSS+JS still comes from the Atmosphere `alexwine:bootstrap-4` (Bootstrap 4 + jQuery). The BS4→5 jump is a separate invasive sub-project: the home **carousel** uses the jQuery `bootstrap-carousel-swipe` plugin (BS4-only), the **navbar collapse** and ~17 `data-toggle`/`data-slide` attributes across 6 files are BS4 jQuery behaviors, and BS5 is vanilla-JS with `data-bs-*`. react-bootstrap v2 runs fine on BS4 CSS for every component we use (Button/Alert/Row/Col/Table/ButtonGroup/Modal render identical classes; only `.form-label` needed a shim). Do this jump on its own: npm `bootstrap@5` SCSS+JS, rewrite carousel (BS5 has native swipe → drop the plugin), `data-toggle`→`data-bs-toggle`, `ml-auto`→`ms-auto`, etc. |
|
||||||
|
| reactstrap | 5.0.0-alpha.3 | — | ✅ removed — zero imports in the codebase (react-bootstrap covers all UI). |
|
||||||
|
| react-leaflet-sidebarv2 | 0.5.1 | — | ✅ removed — never imported. |
|
||||||
|
| react-router-hash-link | 1.1.1 | — | ✅ removed — only a commented-out import in Index.js. |
|
||||||
|
| react-addons-pure-render-mixin | 15.6.2 | — | ✅ removed — never imported (React-15 era leftover). |
|
||||||
|
| chimp | 0.51.1 | — | ✅ removed — dead e2e test runner (Selenium 2 / cucumber), sole remaining puller of native `fibers@1.x`, which cannot compile on Node 22 and broke the clean Docker build. Not imported anywhere. |
|
||||||
|
|
||||||
|
### Comments React feature — verification note
|
||||||
|
|
||||||
|
The server builds and the REST smoke test is green, and the client bundle
|
||||||
|
compiles, but the comments UI itself is **not exercised by the smoke test**
|
||||||
|
(it's not part of the Flutter REST contract). It needs manual staging
|
||||||
|
verification: on a fire archive page — logged-in post/edit/remove, like/dislike,
|
||||||
|
and image/YouTube embed.
|
||||||
|
|
||||||
|
### ✅ Fixed: fire-detail pages now render (react-meteor-data 0.2.16 → 3.0.6)
|
||||||
|
|
||||||
|
Browser-verifying the Comments feature surfaced a **pre-existing client bug**:
|
||||||
|
the fire-detail pages (`/fire/{active,archive,alert}/:id`, component
|
||||||
|
`imports/ui/pages/Fires/Fires.js`) stayed blank — the page's `withTracker`
|
||||||
|
subscription (`fireFromActiveId` / `fireFromId`) never reached `ready()`, so
|
||||||
|
`loading` stayed true and nothing rendered, pegging a CPU core in a
|
||||||
|
re-subscribe loop.
|
||||||
|
|
||||||
|
Diagnosis (isolated, not guessed):
|
||||||
|
- **The publications were fine.** Subscribing to `fireFromActiveId` /
|
||||||
|
`fireFromId` *standalone* from the browser console readied immediately and
|
||||||
|
delivered the doc — not a server/publication bug.
|
||||||
|
- **The bug was `react-meteor-data@0.2.16`** — a React-15/16-era version
|
||||||
|
(`componentWillMount` + legacy context, the source of the deprecation
|
||||||
|
warnings flooding the console) that misbehaves under React 18 for a component
|
||||||
|
gating its whole render on `subscription.ready()`.
|
||||||
|
|
||||||
|
Fix: **`meteor add react-meteor-data@3.0.6`** (the React-18-compatible line for
|
||||||
|
Meteor 3.1; 4.x targets Meteor 3.2+). No call-site changes were needed — 3.x
|
||||||
|
keeps the `withTracker` HOC API, and all 19 call sites work unchanged. The old
|
||||||
|
constraint that pinned 0.2.16 was **`tmeasday:check-npm-versions`**, pulled in
|
||||||
|
by 0.2.16 itself; the upgrade dropped it, which also silenced the
|
||||||
|
"npm peer requirements not installed (react@15-16)" boot/console warning.
|
||||||
|
|
||||||
|
Verified in a real browser at `/fire/archive/c0000000000000000000000c`: the
|
||||||
|
page renders fully (title, Leaflet + NASA/satellite map, the "not a wildfire"
|
||||||
|
FalsePositives widget, and the **Comments** box — all `withTracker`-driven),
|
||||||
|
zero console errors. REST smoke byte-identical (12/12).
|
||||||
156
bench/ddp-load.js
Normal file
156
bench/ddp-load.js
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* Carga de clientes DDP + REST (fase 12).
|
||||||
|
*
|
||||||
|
* Abre N conexiones DDP como las del navegador (WebSocket crudo contra
|
||||||
|
* /websocket, sin librería), cada una suscrita a lo que suscribe el mapa
|
||||||
|
* —`activefiresmyloc` y `activefiresunionmyloc`— y, con todas conectadas, mide
|
||||||
|
* la latencia de ida y vuelta de un método. Es la pregunta de la fase: con
|
||||||
|
* mucha gente mirando el mapa, ¿sigue respondiendo la web?
|
||||||
|
*
|
||||||
|
* En paralelo golpea los endpoints REST que usa la app Flutter.
|
||||||
|
*
|
||||||
|
* node bench/ddp-load.js --clients 50
|
||||||
|
* node bench/ddp-load.js --clients 200 --seconds 30
|
||||||
|
* BASE_URL=http://localhost:3100 node bench/ddp-load.js --clients 500
|
||||||
|
*
|
||||||
|
* Contra un servidor de desarrollo o el compose local. NUNCA contra staging o
|
||||||
|
* producción: son cientos de conexiones y de llamadas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = (process.env.BASE_URL || 'http://localhost:3100').replace(/\/$/, '');
|
||||||
|
if (/testfuegos|fuegos\.comunes|fires\.comunes/.test(BASE)) {
|
||||||
|
console.error(`Me niego a lanzar carga contra ${BASE}.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arg = (name, def) => {
|
||||||
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
|
return i === -1 ? def : Number(process.argv[i + 1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLIENTS = arg('clients', 50);
|
||||||
|
const SECONDS = arg('seconds', 20);
|
||||||
|
const WS_URL = `${BASE.replace(/^http/, 'ws')}/websocket`;
|
||||||
|
|
||||||
|
// El mismo recuadro que pide el mapa al abrirse sobre la península.
|
||||||
|
const BOX = [3.3, 43.7, -9.2, 36.0];
|
||||||
|
|
||||||
|
const percentile = (values, p) => {
|
||||||
|
if (values.length === 0) return NaN;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
|
||||||
|
};
|
||||||
|
|
||||||
|
const openClient = (id) => new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(WS_URL);
|
||||||
|
const state = {
|
||||||
|
id, ws, ready: 0, subs: 0, errors: 0, latencies: [], pending: new Map()
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => reject(new Error(`cliente ${id}: sin conectar en 30 s`)), 30000);
|
||||||
|
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
ws.send(JSON.stringify({ msg: 'connect', version: '1', support: ['1'] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('message', (event) => {
|
||||||
|
// Meteor manda varios mensajes DDP en frames separados, uno por frame.
|
||||||
|
let m;
|
||||||
|
try { m = JSON.parse(event.data); } catch (e) { return; }
|
||||||
|
|
||||||
|
if (m.msg === 'connected') {
|
||||||
|
state.connectedAt = Date.now();
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
msg: 'sub', id: `s${id}a`, name: 'activefiresmyloc', params: [...BOX, false]
|
||||||
|
}));
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
msg: 'sub', id: `s${id}b`, name: 'activefiresunionmyloc', params: [...BOX, false]
|
||||||
|
}));
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(state);
|
||||||
|
} else if (m.msg === 'ready') {
|
||||||
|
state.subs += (m.subs || []).length;
|
||||||
|
if (!state.ready) state.ready = Date.now() - state.connectedAt;
|
||||||
|
} else if (m.msg === 'nosub') {
|
||||||
|
state.errors += 1;
|
||||||
|
} else if (m.msg === 'result') {
|
||||||
|
const started = state.pending.get(m.id);
|
||||||
|
if (started) {
|
||||||
|
state.pending.delete(m.id);
|
||||||
|
state.latencies.push(Date.now() - started);
|
||||||
|
if (m.error) state.errors += 1;
|
||||||
|
}
|
||||||
|
} else if (m.msg === 'ping') {
|
||||||
|
ws.send(JSON.stringify({ msg: 'pong', id: m.id }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
state.errors += 1;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`cliente ${id}: error de websocket`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let callSeq = 0;
|
||||||
|
const callMethod = (state) => {
|
||||||
|
if (state.ws.readyState !== 1) return;
|
||||||
|
const id = `m${state.id}_${callSeq += 1}`;
|
||||||
|
state.pending.set(id, Date.now());
|
||||||
|
// Método barato y real: el que usa el cliente para pedir la clave de mapas.
|
||||||
|
state.ws.send(JSON.stringify({
|
||||||
|
msg: 'method', method: 'getMapKey', params: [], id
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const restRound = async () => {
|
||||||
|
const started = Date.now();
|
||||||
|
const res = await fetch(`${BASE}/api/v1/status/uptime`).catch(() => null);
|
||||||
|
return { ok: !!res && res.ok, ms: Date.now() - started };
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
console.log(`Abriendo ${CLIENTS} clientes DDP contra ${WS_URL} …`);
|
||||||
|
const started = Date.now();
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
Array.from({ length: CLIENTS }, (_, i) => openClient(i))
|
||||||
|
);
|
||||||
|
const clients = results.filter(r => r.status === 'fulfilled').map(r => r.value);
|
||||||
|
const failed = results.length - clients.length;
|
||||||
|
console.log(`Conectados ${clients.length}/${CLIENTS} en ${((Date.now() - started) / 1000).toFixed(1)} s${failed ? ` (${failed} fallos)` : ''}`);
|
||||||
|
|
||||||
|
// Un momento para que se completen las suscripciones antes de medir.
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
const readies = clients.map(c => c.ready).filter(Boolean);
|
||||||
|
console.log(`Suscripciones listas: p50 ${percentile(readies, 50)} ms · p95 ${percentile(readies, 95)} ms · sin ready ${clients.length - readies.length}`);
|
||||||
|
|
||||||
|
console.log(`Midiendo ${SECONDS} s de llamadas…`);
|
||||||
|
const restLatencies = [];
|
||||||
|
const methodTimer = setInterval(() => clients.forEach(callMethod), 1000);
|
||||||
|
const restTimer = setInterval(async () => {
|
||||||
|
const r = await restRound();
|
||||||
|
if (r.ok) restLatencies.push(r.ms);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, SECONDS * 1000));
|
||||||
|
clearInterval(methodTimer);
|
||||||
|
clearInterval(restTimer);
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
|
||||||
|
const latencies = clients.flatMap(c => c.latencies);
|
||||||
|
const errors = clients.reduce((a, c) => a + c.errors, 0);
|
||||||
|
const pending = clients.reduce((a, c) => a + c.pending.size, 0);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`Método DDP (getMapKey): ${latencies.length} respuestas · p50 ${percentile(latencies, 50)} ms · p95 ${percentile(latencies, 95)} ms · p99 ${percentile(latencies, 99)} ms · máx ${Math.max(...latencies, 0)} ms`);
|
||||||
|
console.log(`REST status/uptime : ${restLatencies.length} respuestas · p50 ${percentile(restLatencies, 50)} ms · p95 ${percentile(restLatencies, 95)} ms`);
|
||||||
|
console.log(`Errores: ${errors} · llamadas sin responder al cerrar: ${pending}`);
|
||||||
|
|
||||||
|
clients.forEach(c => c.ws.close());
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
49
bench/treeUnionWorker.js
Normal file
49
bench/treeUnionWorker.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// Variante SOLO PARA EL BANCO DE CARGA del worker de uniones.
|
||||||
|
//
|
||||||
|
// El worker de producción une en cadena: `u = union(u, c[i])` para cada círculo.
|
||||||
|
// Eso hace que cada llamada trabaje sobre un polígono que no para de crecer, así
|
||||||
|
// que el coste crece mucho más deprisa que el número de suscripciones. Esta
|
||||||
|
// variante une en árbol (pares, luego pares de pares…): el mismo resultado
|
||||||
|
// geométrico, pero la mayoría de las uniones son entre polígonos pequeños.
|
||||||
|
//
|
||||||
|
// Está aquí, y no en private/, para que no acabe en el bundle: es material de
|
||||||
|
// medición. Si los números lo justifican, se porta al worker de verdad.
|
||||||
|
|
||||||
|
const { parentPort, workerData } = require('worker_threads');
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-dynamic-require
|
||||||
|
const tcircle = require(workerData.turfPaths.circle).default;
|
||||||
|
// eslint-disable-next-line import/no-dynamic-require
|
||||||
|
const tunion = require(workerData.turfPaths.union).default;
|
||||||
|
// eslint-disable-next-line import/no-dynamic-require
|
||||||
|
const ttrunc = require(workerData.turfPaths.truncate).default;
|
||||||
|
|
||||||
|
const truncOptions = { precision: 6, coordinates: 2 };
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
const { subs, baseUnion, steps } = workerData;
|
||||||
|
let level = subs.map(s => ttrunc(tcircle(
|
||||||
|
[s.location.lon, s.location.lat],
|
||||||
|
s.distance,
|
||||||
|
{ units: 'kilometers', steps: steps || 144 }
|
||||||
|
), truncOptions));
|
||||||
|
|
||||||
|
if (level.length === 0) return baseUnion || null;
|
||||||
|
|
||||||
|
while (level.length > 1) {
|
||||||
|
const next = [];
|
||||||
|
for (let i = 0; i < level.length; i += 2) {
|
||||||
|
if (i + 1 < level.length) next.push(ttrunc(tunion(level[i], level[i + 1]), truncOptions));
|
||||||
|
else next.push(level[i]);
|
||||||
|
}
|
||||||
|
level = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUnion ? ttrunc(tunion(baseUnion, level[0]), truncOptions) : level[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
parentPort.postMessage({ union: run() });
|
||||||
|
} catch (e) {
|
||||||
|
parentPort.postMessage({ error: e.message });
|
||||||
|
}
|
||||||
217
bench/union-bench.js
Normal file
217
bench/union-bench.js
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* Banco de carga de la unión de suscripciones (fase 12).
|
||||||
|
*
|
||||||
|
* Ejecuta el MISMO worker que corre en producción
|
||||||
|
* (private/workers/unionWorker.js) contra suscripciones sintéticas realistas, y
|
||||||
|
* mide lo que la fase pide: cuánto tarda un recreate completo, cuánto un
|
||||||
|
* incrementalAdd, cuánto se atasca el event loop del proceso padre mientras
|
||||||
|
* tanto, cuánto ocupa el documento resultante frente al límite de 16 MiB de
|
||||||
|
* Mongo, y cuánta RAM se come el proceso.
|
||||||
|
*
|
||||||
|
* No necesita Meteor ni Mongo: la orquestación (cola, fast-path, guardado) ya
|
||||||
|
* está cubierta por test/server/subsUnion.test.js. Lo que aquí interesa es la
|
||||||
|
* parte cara, que es la geometría.
|
||||||
|
*
|
||||||
|
* node bench/union-bench.js # 1000 5000 10000
|
||||||
|
* node bench/union-bench.js --sizes 500,1000 # otros tamaños
|
||||||
|
* node bench/union-bench.js --steps 64 # círculos menos detallados
|
||||||
|
* node bench/union-bench.js --strategy tree # unión en árbol en vez de en cadena
|
||||||
|
* node bench/union-bench.js --markdown # tabla lista para pegar en la fase
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { Worker } = require('worker_threads');
|
||||||
|
const { monitorEventLoopDelay } = require('perf_hooks');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const WORKER_PATH = path.join(ROOT, 'private/workers/unionWorker.js');
|
||||||
|
const TREE_WORKER_PATH = path.join(__dirname, 'treeUnionWorker.js');
|
||||||
|
const NPM_MODULES = path.join(ROOT, 'node_modules');
|
||||||
|
const turfPaths = {
|
||||||
|
circle: path.join(NPM_MODULES, '@turf/circle'),
|
||||||
|
union: path.join(NPM_MODULES, '@turf/union'),
|
||||||
|
truncate: path.join(NPM_MODULES, '@turf/truncate')
|
||||||
|
};
|
||||||
|
|
||||||
|
const arg = (name, def) => {
|
||||||
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
|
return i === -1 ? def : process.argv[i + 1];
|
||||||
|
};
|
||||||
|
const flag = name => process.argv.includes(`--${name}`);
|
||||||
|
|
||||||
|
const SIZES = String(arg('sizes', '1000,5000,10000')).split(',').map(Number);
|
||||||
|
const STEPS = Number(arg('steps', 144));
|
||||||
|
const STRATEGY = arg('strategy', 'sequential');
|
||||||
|
const TIMEOUT_MS = Number(arg('timeout', 20 * 60 * 1000));
|
||||||
|
|
||||||
|
// Suscripciones sintéticas con la forma de las reales: la mayoría en la
|
||||||
|
// península, un puñado en Canarias y Baleares, radios de 5 a 100 km. Sembradas
|
||||||
|
// con un PRNG determinista para que dos ejecuciones sean comparables.
|
||||||
|
let seed = 20260801;
|
||||||
|
const rnd = () => {
|
||||||
|
seed = (seed * 1664525 + 1013904223) % 4294967296;
|
||||||
|
return seed / 4294967296;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REGIONS_ES = [
|
||||||
|
{ name: 'peninsula', weight: 0.88, lat: [36.0, 43.7], lon: [-9.2, 3.3] },
|
||||||
|
{ name: 'canarias', weight: 0.07, lat: [27.6, 29.4], lon: [-18.2, -13.3] },
|
||||||
|
{ name: 'baleares', weight: 0.05, lat: [38.6, 40.1], lon: [1.2, 4.3] }
|
||||||
|
];
|
||||||
|
|
||||||
|
// El peor caso para el TAMAÑO del documento: suscripciones repartidas por todo
|
||||||
|
// el mundo, que casi no se solapan, así que la unión conserva un polígono por
|
||||||
|
// suscripción en vez de fundirlas. Es el escenario contra el que existe
|
||||||
|
// unionSizeGuard.js, y el que hay que validar bajo carga.
|
||||||
|
const REGIONS_WORLD = [
|
||||||
|
{ name: 'mundo', weight: 1, lat: [-55, 65], lon: [-170, 170] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REGIONS = arg('spread', 'es') === 'world' ? REGIONS_WORLD : REGIONS_ES;
|
||||||
|
|
||||||
|
const pickRegion = () => {
|
||||||
|
const r = rnd();
|
||||||
|
let acc = 0;
|
||||||
|
for (const region of REGIONS) {
|
||||||
|
acc += region.weight;
|
||||||
|
if (r <= acc) return region;
|
||||||
|
}
|
||||||
|
return REGIONS[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeSubs = (n) => {
|
||||||
|
const subs = [];
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
const region = pickRegion();
|
||||||
|
const lat = region.lat[0] + (rnd() * (region.lat[1] - region.lat[0]));
|
||||||
|
const lon = region.lon[0] + (rnd() * (region.lon[1] - region.lon[0]));
|
||||||
|
// Radios: la mayoría pequeños, con cola larga hasta 100 km.
|
||||||
|
const distance = Math.round(5 + (rnd() ** 2) * 95);
|
||||||
|
subs.push({ location: { lat, lon }, distance });
|
||||||
|
}
|
||||||
|
return subs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runWorker = (subs, baseUnion, { steps, strategy }) => new Promise((resolve, reject) => {
|
||||||
|
const script = strategy === 'tree' ? TREE_WORKER_PATH : WORKER_PATH;
|
||||||
|
const worker = new Worker(script, { workerData: { subs, baseUnion, turfPaths, steps } });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
worker.terminate();
|
||||||
|
reject(new Error(`worker sin responder tras ${TIMEOUT_MS} ms`));
|
||||||
|
}, TIMEOUT_MS);
|
||||||
|
worker.once('message', (msg) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
worker.terminate();
|
||||||
|
if (msg.error) reject(new Error(msg.error));
|
||||||
|
else resolve(msg.union);
|
||||||
|
});
|
||||||
|
worker.once('error', (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
worker.terminate();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mide, mientras corre `fn`: duración, retraso máximo del event loop del PADRE
|
||||||
|
// (que es donde vive DDP: es la métrica que importa para "¿se congela la web?")
|
||||||
|
// y pico de RSS del proceso.
|
||||||
|
const measure = async (fn) => {
|
||||||
|
const loop = monitorEventLoopDelay({ resolution: 10 });
|
||||||
|
loop.enable();
|
||||||
|
let peakRss = process.memoryUsage().rss;
|
||||||
|
const sampler = setInterval(() => {
|
||||||
|
const { rss } = process.memoryUsage();
|
||||||
|
if (rss > peakRss) peakRss = rss;
|
||||||
|
}, 100);
|
||||||
|
// Un "latido" cada 20 ms: si el event loop se bloquea, se notan los saltos.
|
||||||
|
let beats = 0;
|
||||||
|
const beat = setInterval(() => { beats += 1; }, 20);
|
||||||
|
|
||||||
|
const started = process.hrtime.bigint();
|
||||||
|
let result;
|
||||||
|
let error = null;
|
||||||
|
try {
|
||||||
|
result = await fn();
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
const ms = Number(process.hrtime.bigint() - started) / 1e6;
|
||||||
|
|
||||||
|
clearInterval(sampler);
|
||||||
|
clearInterval(beat);
|
||||||
|
loop.disable();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ms,
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
maxLagMs: loop.max / 1e6,
|
||||||
|
p99LagMs: loop.percentile(99) / 1e6,
|
||||||
|
peakRssMb: peakRss / 1024 / 1024,
|
||||||
|
beats,
|
||||||
|
expectedBeats: Math.floor(ms / 20)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeOf = (union) => Buffer.byteLength(JSON.stringify(union), 'utf8');
|
||||||
|
const mb = n => (n / 1024 / 1024).toFixed(2);
|
||||||
|
const pointCount = (union) => {
|
||||||
|
if (!union) return 0;
|
||||||
|
const g = union.geometry || union;
|
||||||
|
const polys = g.type === 'MultiPolygon' ? g.coordinates : [g.coordinates];
|
||||||
|
return polys.reduce((acc, p) => acc + p.reduce((a, r) => a + r.length, 0), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const rows = [];
|
||||||
|
console.log(`Estrategia: ${STRATEGY} · steps por círculo: ${STEPS} · timeout: ${TIMEOUT_MS} ms\n`);
|
||||||
|
|
||||||
|
for (const n of SIZES) {
|
||||||
|
const subs = makeSubs(n);
|
||||||
|
process.stdout.write(`── ${n} suscripciones ─────────────────────────\n`);
|
||||||
|
|
||||||
|
const full = await measure(() => runWorker(subs, null, { steps: STEPS, strategy: STRATEGY }));
|
||||||
|
if (full.error) {
|
||||||
|
console.log(` recreate: FALLÓ tras ${(full.ms / 1000).toFixed(1)} s — ${full.error.message}\n`);
|
||||||
|
rows.push({ n, full, inc: null, bytes: null, points: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = sizeOf(full.result);
|
||||||
|
const points = pointCount(full.result);
|
||||||
|
console.log(` recreate : ${(full.ms / 1000).toFixed(1)} s`);
|
||||||
|
console.log(` doc unión : ${mb(bytes)} MB (${points} vértices) — ${((bytes / (16 * 1024 * 1024)) * 100).toFixed(1)}% del límite de Mongo`);
|
||||||
|
console.log(` event loop : max ${full.maxLagMs.toFixed(1)} ms · p99 ${full.p99LagMs.toFixed(1)} ms · latidos ${full.beats}/${full.expectedBeats}`);
|
||||||
|
console.log(` RSS pico : ${full.peakRssMb.toFixed(0)} MB`);
|
||||||
|
|
||||||
|
// El caso común en producción: una suscripción nueva sobre la unión ya
|
||||||
|
// guardada. Es el camino que decide si "suscribirse" es instantáneo o no.
|
||||||
|
const one = makeSubs(1);
|
||||||
|
const inc = await measure(() => runWorker(one, full.result, { steps: STEPS, strategy: STRATEGY }));
|
||||||
|
if (inc.error) {
|
||||||
|
console.log(` incrementalAdd: FALLÓ — ${inc.error.message}\n`);
|
||||||
|
} else {
|
||||||
|
console.log(` incrementalAdd: ${inc.ms.toFixed(0)} ms · event loop max ${inc.maxLagMs.toFixed(1)} ms\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({ n, full, inc, bytes, points });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flag('markdown')) {
|
||||||
|
console.log('\n| subs | recreate | incrementalAdd | doc unión | % de 16 MiB | lag máx. del event loop | RSS pico |');
|
||||||
|
console.log('|---:|---:|---:|---:|---:|---:|---:|');
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.bytes) {
|
||||||
|
console.log(`| ${r.n} | ✗ (${(r.full.ms / 1000).toFixed(0)} s) | — | — | — | — | — |`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(`| ${r.n} | ${(r.full.ms / 1000).toFixed(1)} s | ${r.inc && !r.inc.error ? `${r.inc.ms.toFixed(0)} ms` : '✗'} | ${mb(r.bytes)} MB | ${((r.bytes / (16 * 1024 * 1024)) * 100).toFixed(1)}% | ${r.full.maxLagMs.toFixed(1)} ms | ${r.full.peakRssMb.toFixed(0)} MB |`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
3
client/00-collection2-init.js
Normal file
3
client/00-collection2-init.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// See server/00-collection2-init.js. collection2 v4's static.js is the eager
|
||||||
|
// entry that runs main.js (patches attachSchema) before any collection loads.
|
||||||
|
import 'meteor/aldeed:collection2/static.js';
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// collection2 v4 is lazy with no main module: import the exported Collection2
|
||||||
|
// so main.js runs and patches attachSchema before any collection loads.
|
||||||
import '../imports/startup/client';
|
import '../imports/startup/client';
|
||||||
|
|
||||||
// https://github.com/GoogleChrome/rendertron#rendering-budget-timeout
|
// https://github.com/GoogleChrome/rendertron#rendering-budget-timeout
|
||||||
|
|
|
||||||
78
docker-compose.e2e.yml
Normal file
78
docker-compose.e2e.yml
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Stack EFÍMERO para la suite e2e (Playwright) y el smoke REST.
|
||||||
|
#
|
||||||
|
# ⚠️ Todo lo de aquí es desechable y se siembra BORRANDO colecciones
|
||||||
|
# (e2e/seed.js, smoke/seed.js). Por eso el Mongo va en tmpfs y sin volumen
|
||||||
|
# nombrado: no puede sobrevivir al `down`, y así no hay forma de que alguien lo
|
||||||
|
# confunda con el stack local (docker-compose.yml) ni con staging. NUNCA apuntes
|
||||||
|
# la suite a testfuegos.comunes.org.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# docker compose -f docker-compose.e2e.yml up -d --build
|
||||||
|
# E2E_BASE_URL=http://localhost:3300 npm --prefix e2e test
|
||||||
|
# docker compose -f docker-compose.e2e.yml down -v
|
||||||
|
#
|
||||||
|
# settings-ci.json se monta tal cual: no lleva secretos (ver el fichero).
|
||||||
|
|
||||||
|
name: tcef-e2e
|
||||||
|
|
||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
# replSet aunque sea de un nodo: sin oplog, Meteor cae a polling cada 10s y
|
||||||
|
# la unión de zonas tardaría eso en llegar al navegador — justo lo que los
|
||||||
|
# tests miden.
|
||||||
|
command: ["--replSet", "rs0", "--bind_ip_all"]
|
||||||
|
tmpfs:
|
||||||
|
- /data/db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
mongo-init:
|
||||||
|
image: mongo:7
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- ./e2e/seed.js:/seed/e2e-seed.js:ro
|
||||||
|
entrypoint:
|
||||||
|
- bash
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }'
|
||||||
|
for i in $$(seq 1 30); do
|
||||||
|
state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }')
|
||||||
|
[ "$$state" = "true" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
# Las migraciones históricas de Meteor (up() aún síncronos) se marcan
|
||||||
|
# como aplicadas, igual que en el compose local.
|
||||||
|
mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})'
|
||||||
|
mongosh --host mongo --quiet fuegos /seed/e2e-seed.js
|
||||||
|
echo "mongo-init: replica set + migraciones + seed OK"
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: tcef-web:e2e
|
||||||
|
depends_on:
|
||||||
|
mongo-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
ROOT_URL: http://localhost:3300
|
||||||
|
MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0
|
||||||
|
MONGO_OPLOG_URL: mongodb://mongo:27017/local?replicaSet=rs0
|
||||||
|
METEOR_SETTINGS_FILE: /run/settings-ci.json
|
||||||
|
# Usuarios de prueba (fixtures.js): el e2e crea los suyos, pero el smoke y
|
||||||
|
# la exploración manual esperan admin@admin.com / password.
|
||||||
|
TCEF_SEED: "1"
|
||||||
|
volumes:
|
||||||
|
- ./settings-ci.json:/run/settings-ci.json:ro
|
||||||
|
ports:
|
||||||
|
- "3300:3000"
|
||||||
172
docker-compose.staging.yml
Normal file
172
docker-compose.staging.yml
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# Stack de STAGING de "Todos contra el fuego" para testfuegos.comunes.org.
|
||||||
|
# Derivado de docker-compose.yml (validación local). Diferencias clave:
|
||||||
|
# - web sirve en https://testfuegos.comunes.org (tras el proxy assange), puerto host 8004
|
||||||
|
# - Mongo 7 AISLADO, sembrado con un volcado real de `fuegos` y contactos neutralizados
|
||||||
|
# (NUNCA apunta al rsmain de producción)
|
||||||
|
# - notificaciones en MODE=shadow / MATCHER_MODE=shadow: calcula y loguea, NO envía
|
||||||
|
# - mailhog captura TODO el correo (nada sale a un SMTP real)
|
||||||
|
# - node-red solo conversacional; si se prueba el bot, con un BOT DE PRUEBAS, nunca los reales
|
||||||
|
#
|
||||||
|
# Despliegue real: vía el role ansible roles/tcef_staging/ en el repo de Comunes
|
||||||
|
# (~/proyectos/sync/comunes/shared-l2/ansible/). Este compose es la fuente que ese role monta.
|
||||||
|
# Secretos: ./secrets/*.staging.* (gitignored). Uso: RUNBOOK.md.
|
||||||
|
|
||||||
|
name: tcef-staging
|
||||||
|
|
||||||
|
x-logging: &default-logging
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
container_name: tcef-staging-mongo
|
||||||
|
command: ["--replSet", "rs0", "--bind_ip_all"]
|
||||||
|
volumes:
|
||||||
|
- mongo-data:/data/db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
# One-shot: inicia el replica set de un nodo y marca las migraciones a v18
|
||||||
|
# (los datos reales llegan pre-migrados). Idéntico al compose de validación.
|
||||||
|
mongo-init:
|
||||||
|
image: mongo:7
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
entrypoint:
|
||||||
|
- bash
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }'
|
||||||
|
for i in $$(seq 1 30); do
|
||||||
|
state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }')
|
||||||
|
[ "$$state" = "true" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})'
|
||||||
|
echo "mongo-init: replica set + migrations OK"
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
web:
|
||||||
|
# La imagen se construye en el CI (runner de aaron) y se publica en el registry de
|
||||||
|
# Forgejo; aquí solo se descarga. groucho NO compila: un `meteor build` lo dejó sin ssh
|
||||||
|
# (5,9 GB compartidos con Mongo 7 + el resto del stack). Ver
|
||||||
|
# plan-modernizacion/fase-9-ci-imagenes.md y .forgejo/workflows/build-image.yml.
|
||||||
|
# Para fijar una versión concreta: TCEF_WEB_TAG=<sha12> docker compose up -d web
|
||||||
|
image: git.comunes.org/comunes/tcef-web:${TCEF_WEB_TAG:-meteor3-wip}
|
||||||
|
container_name: tcef-staging-web
|
||||||
|
depends_on:
|
||||||
|
mongo-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
ROOT_URL: https://testfuegos.comunes.org
|
||||||
|
MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0
|
||||||
|
METEOR_SETTINGS_FILE: /run/secrets/meteor-settings.json
|
||||||
|
volumes:
|
||||||
|
- ./secrets/meteor-settings.staging.json:/run/secrets/meteor-settings.json:ro
|
||||||
|
# Solo el proxy (assange) llega a este puerto; se publica en la red interna del host.
|
||||||
|
ports:
|
||||||
|
- "8004:3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: tcef-staging-redis
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
notifications:
|
||||||
|
build:
|
||||||
|
context: ../tcef-notifications
|
||||||
|
image: tcef-notifications:local
|
||||||
|
container_name: tcef-staging-notifications
|
||||||
|
depends_on:
|
||||||
|
mongo-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
# secrets/notifications.staging.env → MODE=shadow, MATCHER_MODE=shadow, sin FCM,
|
||||||
|
# MAIL_URL=smtp://mailhog:1025. No envía nada a usuarios reales.
|
||||||
|
env_file:
|
||||||
|
- ./secrets/notifications.staging.env
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
# Buzón SMTP de captura: TODO el correo (verificación/reset/notifs) aterriza aquí, nunca fuera.
|
||||||
|
# UI web en 127.0.0.1:8025 (acceder por túnel SSH). SMTP interno en mailhog:1025.
|
||||||
|
mailhog:
|
||||||
|
image: mailhog/mailhog:latest
|
||||||
|
container_name: tcef-staging-mailhog
|
||||||
|
environment:
|
||||||
|
MH_STORAGE: memory
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8025:8025"
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
node-red:
|
||||||
|
image: nodered/node-red:4.0
|
||||||
|
container_name: tcef-staging-node-red
|
||||||
|
volumes:
|
||||||
|
- nodered-data:/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:1880:1880"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1:1880/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
# Importador NASA FIRMS → escribe activefires en ESTE mongo (NUNCA en prod), alimentando al
|
||||||
|
# matcher en shadow. Proceso de larga vida: hace su ciclo cada INTERVAL_MINUTES (15, igual
|
||||||
|
# que el cron de shiva), no hace falta timer del host.
|
||||||
|
# El JWT de Earthdata va en secrets/importer.staging.env, nunca en la imagen ni en el repo.
|
||||||
|
importer:
|
||||||
|
build:
|
||||||
|
context: ../todos-contra-el-fuego/nasa-importer
|
||||||
|
image: tcef-nasa-importer:local
|
||||||
|
container_name: tcef-staging-importer
|
||||||
|
depends_on:
|
||||||
|
mongo-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0
|
||||||
|
INTERVAL_MINUTES: "15"
|
||||||
|
# Reproduce el `when` de producción (acq_time UTC leído como hora local). Ver
|
||||||
|
# nasa-importer/README.md § "Zona horaria" antes de cambiarlo a UTC.
|
||||||
|
FIRE_TIME_TZ: Europe/Madrid
|
||||||
|
env_file:
|
||||||
|
- ./secrets/importer.staging.env
|
||||||
|
volumes:
|
||||||
|
- importer-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo-data:
|
||||||
|
redis-data:
|
||||||
|
nodered-data:
|
||||||
|
importer-data:
|
||||||
125
docker-compose.yml
Normal file
125
docker-compose.yml
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# Stack local modernizado de "Todos contra el fuego" (fase 3 — validación local).
|
||||||
|
# NO es el despliegue de producción: eso irá al ansible de Comunes (proxies,
|
||||||
|
# firewall, monitorización). Aquí se valida que todo el stack corre en Docker.
|
||||||
|
#
|
||||||
|
# Uso: ver RUNBOOK.md. Secretos: ./secrets/ (montados como ficheros, gitignored).
|
||||||
|
|
||||||
|
name: tcef-stack
|
||||||
|
|
||||||
|
x-logging: &default-logging
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
container_name: tcef-stack-mongo
|
||||||
|
command: ["--replSet", "rs0", "--bind_ip_all"]
|
||||||
|
volumes:
|
||||||
|
- mongo-data:/data/db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
# One-shot: inicia el replica set (un nodo) y marca las migraciones de Meteor
|
||||||
|
# como aplicadas (v18) para que los up() históricos (aún síncronos) no corran.
|
||||||
|
mongo-init:
|
||||||
|
image: mongo:7
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
entrypoint:
|
||||||
|
- bash
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }'
|
||||||
|
# espera a que el nodo sea PRIMARY antes de escribir
|
||||||
|
for i in $$(seq 1 30); do
|
||||||
|
state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }')
|
||||||
|
[ "$$state" = "true" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})'
|
||||||
|
echo "mongo-init: replica set + migrations OK"
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: tcef-web:meteor3
|
||||||
|
container_name: tcef-stack-web
|
||||||
|
depends_on:
|
||||||
|
mongo-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
ROOT_URL: http://localhost:3200
|
||||||
|
MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0
|
||||||
|
METEOR_SETTINGS_FILE: /run/secrets/meteor-settings.json
|
||||||
|
volumes:
|
||||||
|
- ./secrets/meteor-settings.json:/run/secrets/meteor-settings.json:ro
|
||||||
|
ports:
|
||||||
|
- "3200:3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: tcef-stack-redis
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
notifications:
|
||||||
|
build:
|
||||||
|
context: ../tcef-notifications
|
||||||
|
image: tcef-notifications:local
|
||||||
|
container_name: tcef-stack-notifications
|
||||||
|
depends_on:
|
||||||
|
mongo-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
# Copia secrets/notifications.env.example -> secrets/notifications.env.
|
||||||
|
# Por defecto MODE=dry-run: no envía nada aunque haya credenciales.
|
||||||
|
env_file:
|
||||||
|
- ./secrets/notifications.env
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
node-red:
|
||||||
|
image: nodered/node-red:4.0
|
||||||
|
container_name: tcef-stack-node-red
|
||||||
|
volumes:
|
||||||
|
- nodered-data:/data
|
||||||
|
ports:
|
||||||
|
- "1880:1880"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1:1880/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo-data:
|
||||||
|
redis-data:
|
||||||
|
nodered-data:
|
||||||
14
docker/web-entrypoint.sh
Normal file
14
docker/web-entrypoint.sh
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Carga METEOR_SETTINGS desde un fichero montado (secretos fuera de la imagen).
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ -n "${METEOR_SETTINGS_FILE:-}" ]; then
|
||||||
|
if [ ! -r "$METEOR_SETTINGS_FILE" ]; then
|
||||||
|
echo "ERROR: METEOR_SETTINGS_FILE=$METEOR_SETTINGS_FILE no existe o no es legible" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
METEOR_SETTINGS="$(cat "$METEOR_SETTINGS_FILE")"
|
||||||
|
export METEOR_SETTINGS
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
4
e2e/.gitignore
vendored
Normal file
4
e2e/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
qa-shots/
|
||||||
60
e2e/README.md
Normal file
60
e2e/README.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Suite e2e (Playwright)
|
||||||
|
|
||||||
|
Cubre en navegador los flujos que la suite de servidor no puede ver: registro y
|
||||||
|
acceso, alta y baja de una zona **desde el mapa**, que la unión de zonas se pinte
|
||||||
|
en `/subscriptions` y en `/zones`, comentario en el detalle de un fuego, y cambio
|
||||||
|
de idioma.
|
||||||
|
|
||||||
|
El motivo de que exista: el 2026-07-30 suscribirse a una zona congeló staging
|
||||||
|
(la unión bloqueaba el event loop y DDP dejaba de responder). Ningún test de
|
||||||
|
servidor habría visto eso — el navegador sí.
|
||||||
|
|
||||||
|
## ⚠️ Contra qué se puede correr
|
||||||
|
|
||||||
|
Solo contra un stack **desechable**: `docker-compose.e2e.yml` o un servidor de
|
||||||
|
desarrollo local. La siembra (`e2e/seed.js`) **borra colecciones**, y los tests
|
||||||
|
crean usuarios y zonas. `playwright.config.js` se niega a arrancar si
|
||||||
|
`E2E_BASE_URL` apunta a `testfuegos.comunes.org` o a los dominios de producción.
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
Stack efímero completo (lo mismo que hace el CI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.e2e.yml up -d --build
|
||||||
|
npm --prefix e2e ci
|
||||||
|
npx --prefix e2e playwright install --with-deps chromium
|
||||||
|
E2E_BASE_URL=http://localhost:3300 npm --prefix e2e test
|
||||||
|
docker compose -f docker-compose.e2e.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Contra un servidor de desarrollo (ciclo rápido mientras se escriben tests):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name tcef-e2e-mongo -p 27021:27017 --tmpfs /data/db mongo:7 --replSet rs0 --bind_ip_all
|
||||||
|
docker exec tcef-e2e-mongo mongosh --quiet --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]})'
|
||||||
|
docker cp e2e/seed.js tcef-e2e-mongo:/tmp/seed.js && docker exec tcef-e2e-mongo mongosh --quiet fuegos /tmp/seed.js
|
||||||
|
|
||||||
|
export PATH="$HOME/.meteor:$PATH"
|
||||||
|
MONGO_URL='mongodb://localhost:27021/fuegos?replicaSet=rs0&directConnection=true' \
|
||||||
|
MONGO_OPLOG_URL='mongodb://localhost:27021/local?replicaSet=rs0&directConnection=true' \
|
||||||
|
ROOT_URL=http://localhost:3100 meteor --settings settings-ci.json --port 3100
|
||||||
|
|
||||||
|
npm --prefix e2e test # E2E_BASE_URL por defecto: http://localhost:3100
|
||||||
|
```
|
||||||
|
|
||||||
|
Ver el informe del último fallo: `npm --prefix e2e run report`.
|
||||||
|
|
||||||
|
## Dependencias externas
|
||||||
|
|
||||||
|
El navegador carga las teselas de CartoDB y el script de Google Maps (el
|
||||||
|
autocompletado de lugares). El runner necesita salida a internet; sin ella los
|
||||||
|
mapas no llegan a pintarse y los tests de zona fallan. La clave de Google puede
|
||||||
|
ir vacía: `Gkeys` ya no se queda esperándola.
|
||||||
|
|
||||||
|
## Lo que la suite documenta como roto
|
||||||
|
|
||||||
|
`i18n.spec.js` deja un `test.fixme` para un bug real: el idioma elegido en el
|
||||||
|
perfil **no sobrevive a una recarga** (se guarda en la cuenta y se aplica al
|
||||||
|
iniciar sesión, pero al recargar vuelve al idioma del navegador). Está ahí para
|
||||||
|
que aparezca en cada ejecución en vez de perderse en una nota.
|
||||||
76
e2e/package-lock.json
generated
Normal file
76
e2e/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
{
|
||||||
|
"name": "tcef-e2e",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "tcef-e2e",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
e2e/package.json
Normal file
15
e2e/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "tcef-e2e",
|
||||||
|
"private": true,
|
||||||
|
"description": "Browser end-to-end suite for todos-contra-el-fuego-web. Kept out of the app's package.json on purpose: Meteor should never see these dependencies.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:headed": "playwright test --headed",
|
||||||
|
"report": "playwright show-report",
|
||||||
|
"install-browsers": "playwright install --with-deps chromium"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
e2e/playwright.config.js
Normal file
42
e2e/playwright.config.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
// E2E_BASE_URL points at a THROWAWAY stack — the compose in docker-compose.e2e.yml
|
||||||
|
// or a local dev server. Never at testfuegos.comunes.org or anything else with
|
||||||
|
// data in it: these tests create users and subscriptions, and the seed step wipes
|
||||||
|
// collections.
|
||||||
|
const baseURL = process.env.E2E_BASE_URL || 'http://localhost:3100';
|
||||||
|
|
||||||
|
if (/testfuegos|fuegos\.comunes|fires\.comunes/.test(baseURL)) {
|
||||||
|
throw new Error(`Refusing to run the e2e suite against ${baseURL}: it seeds and writes data.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
// Meteor's first page load compiles and ships a big bundle; the map then waits
|
||||||
|
// for tiles and DDP subscriptions.
|
||||||
|
timeout: 90 * 1000,
|
||||||
|
expect: { timeout: 20 * 1000 },
|
||||||
|
// Every spec creates its own user, but they share one Mongo and one union
|
||||||
|
// recompute queue; running them in parallel makes failures much harder to read
|
||||||
|
// for very little wall-clock on a runner that only has one job slot anyway.
|
||||||
|
workers: 1,
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: process.env.CI
|
||||||
|
? [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]]
|
||||||
|
: [['list']],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
// Both on first retry only: a trace per test would be gigabytes of artifacts
|
||||||
|
// for a suite that is green most of the time.
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
actionTimeout: 20 * 1000,
|
||||||
|
locale: 'es-ES'
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
|
||||||
|
]
|
||||||
|
});
|
||||||
61
e2e/seed.js
Normal file
61
e2e/seed.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* Deterministic seed for the browser e2e suite (mongosh script).
|
||||||
|
*
|
||||||
|
* ⚠️ DESTRUCTIVE: it empties the collections it seeds. It is only ever meant for
|
||||||
|
* the throwaway Mongo of docker-compose.e2e.yml (or a local scratch one). Never
|
||||||
|
* point it at staging or production — that is what e2e/seed.sh guards against.
|
||||||
|
*
|
||||||
|
* mongosh --host localhost:27021 fuegos e2e/seed.js
|
||||||
|
*
|
||||||
|
* The subscriptions collection is deliberately NOT seeded: the whole point of
|
||||||
|
* the zone spec is to create one through the UI and watch the union appear.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var FIRE_A = ObjectId('e2e0000000000000000000a1');
|
||||||
|
var FIRE_B = ObjectId('e2e0000000000000000000b2');
|
||||||
|
|
||||||
|
// Madrid, so a zone created around the map's default view sees them.
|
||||||
|
var LAT = 40.41;
|
||||||
|
var LON = -3.70;
|
||||||
|
var WHEN = ISODate('2026-07-30T05:30:00.000Z');
|
||||||
|
var WHEN_B = ISODate('2026-07-30T06:30:00.000Z');
|
||||||
|
var FIXED = ISODate('2026-07-30T00:00:00.000Z');
|
||||||
|
|
||||||
|
db.activefires.deleteMany({});
|
||||||
|
db.activefires.insertMany([
|
||||||
|
{
|
||||||
|
_id: FIRE_A,
|
||||||
|
ourid: { type: 'Point', coordinates: [LON, LAT] },
|
||||||
|
lat: LAT, lon: LON, type: 'viirs', when: WHEN,
|
||||||
|
scan: 0.5, track: 0.4, acq_date: '2026-07-30', acq_time: '05:30',
|
||||||
|
satellite: 'N', confidence: 50, version: '1.0NRT', frp: 4.3,
|
||||||
|
daynight: 'N', bright_ti4: 330.2, bright_ti5: 291,
|
||||||
|
createdAt: FIXED, updatedAt: FIXED
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: FIRE_B,
|
||||||
|
ourid: { type: 'Point', coordinates: [LON + 0.02, LAT + 0.02] },
|
||||||
|
lat: LAT + 0.02, lon: LON + 0.02, type: 'viirs', when: WHEN_B,
|
||||||
|
scan: 0.5, track: 0.4, acq_date: '2026-07-30', acq_time: '06:30',
|
||||||
|
satellite: 'N', confidence: 60, version: '1.0NRT', frp: 6.1,
|
||||||
|
daynight: 'N', bright_ti4: 331.0, bright_ti5: 292,
|
||||||
|
createdAt: FIXED, updatedAt: FIXED
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
db.activefires.createIndex({ ourid: '2dsphere' });
|
||||||
|
|
||||||
|
db.activefiresunion.deleteMany({});
|
||||||
|
db.activefiresunion.createIndex({ shape: '2dsphere' });
|
||||||
|
|
||||||
|
db.comments.deleteMany({});
|
||||||
|
db.falsePositives.deleteMany({});
|
||||||
|
db.industries.deleteMany({});
|
||||||
|
|
||||||
|
// Anything left over from a previous run of the suite: users it created, their
|
||||||
|
// zones, and the stored union computed from them.
|
||||||
|
db.subscriptions.deleteMany({});
|
||||||
|
db.users.deleteMany({ 'emails.address': /^e2e\+/ });
|
||||||
|
db.siteSettings.deleteMany({ name: /^subs-(public|private)-union/ });
|
||||||
|
db.siteSettings.deleteMany({ name: 'subs-union-count' });
|
||||||
|
|
||||||
|
print('e2e seed OK: ' + db.activefires.countDocuments({}) + ' active fires, 0 subscriptions');
|
||||||
295
e2e/staging-qa.mjs
Normal file
295
e2e/staging-qa.mjs
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* Checklist de QA de staging (fase 13), ejecutable y repetible.
|
||||||
|
*
|
||||||
|
* NO es la suite e2e: aquella siembra la base (borra colecciones) y por eso su
|
||||||
|
* configuración se niega a apuntar a staging. Esto es lo contrario — solo mira,
|
||||||
|
* y con `--write` además crea UNA cuenta y UNA zona, que es lo que la fase pide
|
||||||
|
* probar a mano. No toca datos de nadie más.
|
||||||
|
*
|
||||||
|
* node e2e/staging-qa.mjs # solo lectura
|
||||||
|
* node e2e/staging-qa.mjs --write # + registro y alta/baja de zona
|
||||||
|
* QA_URL=https://testfuegos.comunes.org node e2e/staging-qa.mjs
|
||||||
|
*
|
||||||
|
* Cada comprobación imprime OK/FALLO y, al final, un resumen. Las capturas de
|
||||||
|
* los fallos quedan en e2e/qa-shots/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = (process.env.QA_URL || 'https://testfuegos.comunes.org').replace(/\/$/, '');
|
||||||
|
const WRITE = process.argv.includes('--write');
|
||||||
|
// Id hexadecimal de un activefire para probar la página de detalle. Se saca en
|
||||||
|
// solo lectura del propio staging; sin él esa parte se salta.
|
||||||
|
const FIRE = process.env.QA_FIRE || '';
|
||||||
|
const SHOTS = path.resolve(import.meta.dirname, 'qa-shots');
|
||||||
|
|
||||||
|
// Comparación de host exacta, no un `endsWith`: testfuegos.comunes.org también
|
||||||
|
// termina en fuegos.comunes.org y es justo donde SÍ hay que ejecutar esto.
|
||||||
|
const PROD_HOSTS = ['fuegos.comunes.org', 'fires.comunes.org'];
|
||||||
|
if (PROD_HOSTS.includes(new URL(BASE).hostname)) {
|
||||||
|
console.error(`Me niego a hacer QA contra producción (${BASE}).`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ NADA de selectores por id: `imports/ui/components/Utils/TestUtils.js`
|
||||||
|
// devuelve el id solo en desarrollo, así que en staging y producción los
|
||||||
|
// elementos NO lo llevan. Aquí se va por href, rol y texto, que es lo que hay
|
||||||
|
// en los tres sitios.
|
||||||
|
const NAV = {
|
||||||
|
zonas: 'nav a[href="/zones"]',
|
||||||
|
fuegos: 'nav a[href="/fires"]',
|
||||||
|
perfil: 'nav a[href="/profile"]',
|
||||||
|
entrar: 'nav a[href="/login"]',
|
||||||
|
salir: 'nav a[href="/logout"]',
|
||||||
|
misZonas: 'nav a[href="/subscriptions"]'
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let page;
|
||||||
|
|
||||||
|
const check = async (name, fn) => {
|
||||||
|
try {
|
||||||
|
const detail = await fn();
|
||||||
|
results.push({ name, ok: true, detail });
|
||||||
|
console.log(` OK ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ name, ok: false, detail: e.message });
|
||||||
|
console.log(` FALLO ${name} — ${e.message}`);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(SHOTS, { recursive: true });
|
||||||
|
await page.screenshot({ path: path.join(SHOTS, `${name.replace(/[^a-z0-9]+/gi, '-')}.png`), fullPage: true });
|
||||||
|
} catch (_) { /* la captura es un extra */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||||
|
|
||||||
|
// La página es una SPA: tras `domcontentloaded` el body está casi vacío hasta
|
||||||
|
// que React pinta. Sin esperar a esto, medio checklist daba falsos fallos.
|
||||||
|
const rendered = async (minChars = 400) => {
|
||||||
|
await page.waitForFunction(
|
||||||
|
n => document.body && document.body.innerText.trim().length > n,
|
||||||
|
minChars,
|
||||||
|
{ timeout: 45000 }
|
||||||
|
).catch(() => { throw new Error('la página no llegó a pintarse'); });
|
||||||
|
return (await page.locator('body').innerText()).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visible = async (selector, msg) => {
|
||||||
|
const el = page.locator(selector).first();
|
||||||
|
await el.waitFor({ state: 'visible', timeout: 30000 }).catch(() => {
|
||||||
|
throw new Error(msg || `no aparece ${selector}`);
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapReady = async () => {
|
||||||
|
await visible('.leaflet-container', 'el mapa no llegó a montarse');
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const tiles = await page.locator('.leaflet-tile-loaded').count();
|
||||||
|
assert(tiles > 0, 'el mapa monta pero no carga ninguna tesela');
|
||||||
|
return `${tiles} teselas`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscriptionNames = () => page.evaluate(() => (window.Meteor
|
||||||
|
? Object.values(window.Meteor.connection._subscriptions).map(s => s.name)
|
||||||
|
: []));
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
console.log(`QA de ${BASE}${WRITE ? ' (con escritura)' : ' (solo lectura)'}\n`);
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext({ locale: 'es-ES' });
|
||||||
|
page = await context.newPage();
|
||||||
|
const consoleErrors = [];
|
||||||
|
page.on('pageerror', e => consoleErrors.push(String(e).slice(0, 200)));
|
||||||
|
|
||||||
|
console.log('— Portada y mapas');
|
||||||
|
await check('la portada carga', async () => {
|
||||||
|
const res = await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||||
|
assert(res.ok(), `HTTP ${res.status()}`);
|
||||||
|
await visible('nav');
|
||||||
|
await rendered();
|
||||||
|
return `HTTP ${res.status()}`;
|
||||||
|
});
|
||||||
|
await check('la portada monta sus mapas', mapReady);
|
||||||
|
|
||||||
|
await check('/fires pinta el mapa de fuegos activos', async () => {
|
||||||
|
await page.goto(`${BASE}/fires`, { waitUntil: 'domcontentloaded' });
|
||||||
|
return mapReady();
|
||||||
|
});
|
||||||
|
await check('/fires se suscribe a activefiresmyloc', async () => {
|
||||||
|
const names = await subscriptionNames();
|
||||||
|
assert(names.includes('activefiresmyloc'), `suscripciones presentes: ${names.join(', ') || 'ninguna'}`);
|
||||||
|
return names.filter(n => n.startsWith('activefires')).join(', ');
|
||||||
|
});
|
||||||
|
await check('el contador de fuegos activos no dice NaN', async () => {
|
||||||
|
const text = await page.locator('body').innerText();
|
||||||
|
assert(!/NaN/.test(text), 'aparece NaN en la página');
|
||||||
|
const m = text.match(/(\d+)\s+fuegos activos/i);
|
||||||
|
return m ? `${m[1]} fuegos` : 'sin contador visible';
|
||||||
|
});
|
||||||
|
await check('conmuta la capa base del mapa', async () => {
|
||||||
|
// Hover sobre el contenedor, no sobre el botón: al desplegarse, la lista se
|
||||||
|
// pone encima del botón y le roba el puntero.
|
||||||
|
await page.locator('.leaflet-control-layers').first().hover();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const options = await page.locator('.leaflet-control-layers-base label').count();
|
||||||
|
assert(options >= 2, `solo ${options} capa(s) base`);
|
||||||
|
await page.locator('.leaflet-control-layers-base input').nth(1).check({ force: true });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
return `${options} capas base`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('/zones pinta el mapa de zonas vigiladas', async () => {
|
||||||
|
await page.goto(`${BASE}/zones`, { waitUntil: 'domcontentloaded' });
|
||||||
|
return mapReady();
|
||||||
|
});
|
||||||
|
await check('/zones pinta la unión de suscripciones', async () => {
|
||||||
|
const paths = await page.locator('.leaflet-overlay-pane path').count();
|
||||||
|
assert(paths > 0, 'no hay ningún polígono en la capa de superposición');
|
||||||
|
return `${paths} polígonos`;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n— Detalle de fuego y comentarios');
|
||||||
|
// Por id, no clicando una marca del mapa: mientras /fires siga en gris, ese
|
||||||
|
// camino no existe, y el detalle del fuego se puede probar igualmente.
|
||||||
|
if (!FIRE) {
|
||||||
|
console.log(' (saltado) sin QA_FIRE=<id hex de un activefire> no se puede abrir un detalle');
|
||||||
|
} else {
|
||||||
|
await check('el detalle de un fuego abre con su mapa', async () => {
|
||||||
|
await page.goto(`${BASE}/fire/active/${FIRE}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await visible('h4.page-header');
|
||||||
|
await rendered();
|
||||||
|
await mapReady();
|
||||||
|
return page.url().replace(BASE, '');
|
||||||
|
});
|
||||||
|
await check('el detalle canonicaliza la URL al archivo', async () => {
|
||||||
|
assert(/\/fire\/archive\/[0-9a-f]{24}$/.test(page.url()), `URL inesperada: ${page.url()}`);
|
||||||
|
return page.url().replace(BASE, '');
|
||||||
|
});
|
||||||
|
await check('el detalle ofrece comentarios', async () => {
|
||||||
|
const text = await page.locator('body').innerText();
|
||||||
|
assert(/Comentarios/i.test(text), 'no aparece la sección de comentarios');
|
||||||
|
return 'sección presente';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n— Idioma y cookies');
|
||||||
|
await check('el banner de cookies aparece y se acepta', async () => {
|
||||||
|
const accept = page.getByRole('button', { name: /Aceptar/i }).first();
|
||||||
|
if (await accept.count() === 0) return 'ya aceptado en esta sesión';
|
||||||
|
await accept.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
return 'aceptado';
|
||||||
|
});
|
||||||
|
await check('un navegador en inglés ve la web en inglés', async () => {
|
||||||
|
const enContext = await browser.newContext({ locale: 'en-US' });
|
||||||
|
const enPage = await enContext.newPage();
|
||||||
|
await enPage.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||||
|
await enPage.waitForFunction((sel) => {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
return el && el.textContent.trim().length > 0;
|
||||||
|
}, NAV.zonas, { timeout: 45000 }).catch(() => {});
|
||||||
|
const label = await enPage.locator(NAV.zonas).innerText().catch(() => '');
|
||||||
|
await enContext.close();
|
||||||
|
assert(/Monitored/i.test(label), `la navegación sigue en español: "${label}"`);
|
||||||
|
return `enlace de zonas = "${label}"`;
|
||||||
|
});
|
||||||
|
await check('el mismo servidor sirve español a un navegador en español', async () => {
|
||||||
|
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||||
|
await rendered();
|
||||||
|
const label = await page.locator(NAV.zonas).innerText();
|
||||||
|
assert(/Zonas/i.test(label), `esperaba español, salió "${label}"`);
|
||||||
|
return `enlace de zonas = "${label}"`;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n— Páginas estáticas');
|
||||||
|
for (const p of ['/about', '/terms', '/privacy', '/license', '/credits']) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await check(`${p} responde`, async () => {
|
||||||
|
const res = await page.goto(BASE + p, { waitUntil: 'domcontentloaded' });
|
||||||
|
assert(res.ok(), `HTTP ${res.status()}`);
|
||||||
|
const chars = await rendered(600);
|
||||||
|
return `${chars} caracteres`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WRITE) {
|
||||||
|
console.log('\n— Registro y zona (escribe en staging)');
|
||||||
|
const email = `qa+${Date.now()}@invalid.test`;
|
||||||
|
await check('registro de una cuenta nueva', async () => {
|
||||||
|
await page.goto(`${BASE}/signup`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('input[name=firstName]', 'QA');
|
||||||
|
await page.fill('input[name=lastName]', 'Fase13');
|
||||||
|
await page.fill('input[name=emailAddress]', email);
|
||||||
|
await page.fill('input[name=password]', 'password-qa-13');
|
||||||
|
await page.check('input[name=tos]');
|
||||||
|
await page.getByRole('button', { name: 'Registrarse', exact: true }).click();
|
||||||
|
await page.waitForURL('**/subscriptions', { timeout: 60000 });
|
||||||
|
return email;
|
||||||
|
});
|
||||||
|
await check('alta de una zona desde el mapa', async () => {
|
||||||
|
await mapReady();
|
||||||
|
await page.getByRole('button', { name: 'Añadir zona' }).click();
|
||||||
|
await page.waitForURL('**/subscriptions/new', { timeout: 30000 });
|
||||||
|
await mapReady();
|
||||||
|
const box = await page.locator('.leaflet-container').first().boundingBox();
|
||||||
|
await page.mouse.click(box.x + (box.width / 2) + 20, box.y + (box.height / 2) + 20);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const started = Date.now();
|
||||||
|
await page.getByRole('button', { name: /Suscribirme/ }).click();
|
||||||
|
await page.waitForURL('**/subscriptions', { timeout: 120000 });
|
||||||
|
return `${((Date.now() - started) / 1000).toFixed(1)} s en responder`;
|
||||||
|
});
|
||||||
|
await check('la zona nueva aparece pintada', async () => {
|
||||||
|
await mapReady();
|
||||||
|
const paths = await page.locator('.leaflet-overlay-pane path').count();
|
||||||
|
assert(paths > 0, 'no se pinta ninguna zona tras el alta');
|
||||||
|
return `${paths} polígonos`;
|
||||||
|
});
|
||||||
|
await check('la web sigue respondiendo tras el alta', async () => {
|
||||||
|
const started = Date.now();
|
||||||
|
await page.goto(`${BASE}/fires`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await mapReady();
|
||||||
|
return `${((Date.now() - started) / 1000).toFixed(1)} s en cargar /fires`;
|
||||||
|
});
|
||||||
|
await check('baja de la zona', async () => {
|
||||||
|
await page.goto(`${BASE}/subscriptions`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await mapReady();
|
||||||
|
await page.getByRole('button', { name: /Editar/i }).click();
|
||||||
|
await page.locator('.leaflet-marker-icon').first().click();
|
||||||
|
await page.getByRole('button', { name: 'Sí', exact: true }).click();
|
||||||
|
await visible('text=No estás suscrito a fuegos en ninguna zona', 'la zona no llegó a borrarse');
|
||||||
|
return 'borrada';
|
||||||
|
});
|
||||||
|
await check('cierre de sesión', async () => {
|
||||||
|
await page.locator(NAV.salir).click();
|
||||||
|
await visible(NAV.entrar, 'no vuelve a aparecer el enlace de iniciar sesión');
|
||||||
|
return 'sesión cerrada';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n— Errores de JavaScript');
|
||||||
|
await check('ninguna página lanzó una excepción', async () => {
|
||||||
|
assert(consoleErrors.length === 0, `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}`);
|
||||||
|
return 'sin excepciones';
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
const failed = results.filter(r => !r.ok);
|
||||||
|
console.log(`\n${results.length - failed.length}/${results.length} comprobaciones en verde`);
|
||||||
|
if (failed.length) {
|
||||||
|
console.log('Fallos:');
|
||||||
|
failed.forEach(f => console.log(` - ${f.name}: ${f.detail}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
54
e2e/support/app.js
Normal file
54
e2e/support/app.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Shared helpers. Everything here drives the UI the way a person would: the
|
||||||
|
// point of this suite is the wiring between browser, DDP and Mongo, so reaching
|
||||||
|
// into Meteor.call() from the page would defeat it.
|
||||||
|
|
||||||
|
// A fresh address per test: the seed removes every e2e+* user, and reusing one
|
||||||
|
// across specs makes failures depend on the order they ran in.
|
||||||
|
export const newEmail = () => `e2e+${Date.now()}${Math.floor(Math.random() * 1000)}@test.com`;
|
||||||
|
|
||||||
|
export const signUp = async (page, email = newEmail()) => {
|
||||||
|
await page.goto('/signup');
|
||||||
|
await page.fill('input[name=firstName]', 'E2E');
|
||||||
|
await page.fill('input[name=lastName]', 'Tester');
|
||||||
|
await page.fill('input[name=emailAddress]', email);
|
||||||
|
await page.fill('input[name=password]', 'password');
|
||||||
|
await page.check('#tos');
|
||||||
|
await page.click('#signUpSubmit');
|
||||||
|
// Signing up lands on the zones page.
|
||||||
|
await page.waitForURL('**/subscriptions', { timeout: 30000 });
|
||||||
|
return email;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logIn = async (page, email, password = 'password') => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name=emailAddress]', email);
|
||||||
|
await page.fill('input[name=password]', password);
|
||||||
|
await page.click('#loginSubmit');
|
||||||
|
};
|
||||||
|
|
||||||
|
// The map only settles once leaflet has laid out its panes and the tiles for the
|
||||||
|
// current viewport are in.
|
||||||
|
export const waitForMap = async (page) => {
|
||||||
|
await page.locator('.leaflet-container').first().waitFor({ state: 'visible', timeout: 60000 });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Creates a zone through the real flow: the button on the zones map, the
|
||||||
|
// selection map, and the subscribe button. This is the flow that froze staging.
|
||||||
|
export const addZoneThroughTheMap = async (page) => {
|
||||||
|
await page.goto('/subscriptions');
|
||||||
|
await waitForMap(page);
|
||||||
|
await page.getByRole('button', { name: 'Añadir zona' }).click();
|
||||||
|
await page.waitForURL('**/subscriptions/new', { timeout: 30000 });
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
const map = page.locator('.leaflet-container').first();
|
||||||
|
const box = await map.boundingBox();
|
||||||
|
// Click slightly off-centre: the marker moves there, which also proves the
|
||||||
|
// map is interactive and not a dead tile layer.
|
||||||
|
await page.mouse.click(box.x + (box.width / 2) + 20, box.y + (box.height / 2) + 20);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Suscribirme/ }).click();
|
||||||
|
await page.waitForURL('**/subscriptions', { timeout: 60000 });
|
||||||
|
};
|
||||||
44
e2e/tests/auth.spec.js
Normal file
44
e2e/tests/auth.spec.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { signUp, logIn, newEmail } from '../support/app.js';
|
||||||
|
|
||||||
|
test.describe('registro y acceso', () => {
|
||||||
|
test('a new account can sign up, log out and log back in', async ({ page }) => {
|
||||||
|
const email = newEmail();
|
||||||
|
await signUp(page, email);
|
||||||
|
|
||||||
|
// Signed in: the navbar swaps the login links for the profile and logout ones.
|
||||||
|
await expect(page.locator('#logout')).toBeVisible();
|
||||||
|
await expect(page.locator('#profile')).toContainText('E2E Tester');
|
||||||
|
|
||||||
|
await page.click('#logout');
|
||||||
|
await expect(page.locator('#login')).toBeVisible();
|
||||||
|
|
||||||
|
await logIn(page, email);
|
||||||
|
await expect(page.locator('#logout')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signing up is refused without accepting the terms', async ({ page }) => {
|
||||||
|
await page.goto('/signup');
|
||||||
|
await page.fill('input[name=firstName]', 'E2E');
|
||||||
|
await page.fill('input[name=lastName]', 'Tester');
|
||||||
|
await page.fill('input[name=emailAddress]', newEmail());
|
||||||
|
await page.fill('input[name=password]', 'password');
|
||||||
|
|
||||||
|
await expect(page.locator('#signUpSubmit')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrong password does not sign anybody in', async ({ page }) => {
|
||||||
|
const email = newEmail();
|
||||||
|
await signUp(page, email);
|
||||||
|
await page.click('#logout');
|
||||||
|
|
||||||
|
await logIn(page, email, 'not-the-password');
|
||||||
|
await expect(page.locator('#loginSubmit')).toBeVisible();
|
||||||
|
await expect(page.locator('#logout')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the private zones page sends anonymous visitors to the login', async ({ page }) => {
|
||||||
|
await page.goto('/subscriptions');
|
||||||
|
await expect(page.locator('#loginSubmit')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
e2e/tests/fire-comment.spec.js
Normal file
40
e2e/tests/fire-comment.spec.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { signUp, waitForMap } from '../support/app.js';
|
||||||
|
|
||||||
|
// e2e/seed.js puts this one in activefires. Visiting an active fire archives it
|
||||||
|
// and canonicalizes the URL to /fire/archive/<other id>, which is itself worth
|
||||||
|
// pinning: it is the link every notification and every tweet points at.
|
||||||
|
const SEEDED_FIRE = '/fire/active/e2e0000000000000000000a1';
|
||||||
|
|
||||||
|
test.describe('detalle de un fuego y comentarios', () => {
|
||||||
|
test('a seeded fire opens, keeps its map and canonicalizes its URL', async ({ page }) => {
|
||||||
|
await page.goto(SEEDED_FIRE);
|
||||||
|
await expect(page.getByText(/Información adicional sobre fuego detectado/)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/fire\/archive\/[0-9a-f]{24}$/);
|
||||||
|
await waitForMap(page);
|
||||||
|
await expect(page.getByText('Coordenadas: 40.41, -3.7')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a signed-in user can comment on a fire and sees it appear', async ({ page }) => {
|
||||||
|
await signUp(page);
|
||||||
|
await page.goto(SEEDED_FIRE);
|
||||||
|
await expect(page.getByText('Comentarios')).toBeVisible();
|
||||||
|
|
||||||
|
const comment = `Ardió el pinar de la ladera norte ${Date.now()}`;
|
||||||
|
await page.locator('textarea.create-comment').fill(comment);
|
||||||
|
await page.getByRole('button', { name: 'Añadir comentario' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(comment)).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
|
// And it is really stored, not just echoed into the DOM.
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText(comment)).toBeVisible({ timeout: 30000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an anonymous visitor is asked to sign in instead of getting a comment box', async ({ page }) => {
|
||||||
|
await page.goto(SEEDED_FIRE);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Comentarios' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Necesitas iniciar sesión para añadir comentarios')).toBeVisible();
|
||||||
|
await expect(page.locator('textarea.create-comment')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
79
e2e/tests/fires-map.spec.js
Normal file
79
e2e/tests/fires-map.spec.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForMap } from '../support/app.js';
|
||||||
|
|
||||||
|
// ⛔ BUG ABIERTO: el mapa de /fires se queda EN GRIS cuando hay clave de Google
|
||||||
|
// Maps configurada — ni teselas, ni fuegos, ni suscripción por viewport, con el
|
||||||
|
// "Actualizando…" eterno. Es lo que se ve en staging.
|
||||||
|
//
|
||||||
|
// Lo que SÍ está establecido (2026-08-01):
|
||||||
|
//
|
||||||
|
// 1. NO es el navegador sin pantalla. El mismo Playwright headless pinta la
|
||||||
|
// portada y /zones de staging, y pinta /fires perfectamente contra un
|
||||||
|
// servidor local sin clave. La nota del plan que lo achacaba a eso es falsa.
|
||||||
|
// 2. Depende de la clave de Google. Basta con poner una clave (aunque sea
|
||||||
|
// falsa) en settings-ci.json para reproducirlo en local: 15 teselas sin
|
||||||
|
// clave, 0 con ella. Por eso settings-ci.json lleva una: para que esto se
|
||||||
|
// vea aquí y no solo en staging.
|
||||||
|
// 3. El control de capas se monta con dos capas base y, al resolverse la clave,
|
||||||
|
// se le añaden tres de Google. En esa segunda pasada react-leaflet se deja
|
||||||
|
// por el camino la capa base marcada.
|
||||||
|
// 4. Montar el control UNA sola vez, esperando a la clave, arregla el caso
|
||||||
|
// local pero ROMPE staging todavía más: allí la portada y /zones —que
|
||||||
|
// funcionaban— se quedan también sin teselas, porque entonces el control
|
||||||
|
// aparece después de que el mapa ya esté montado. Probado en staging,
|
||||||
|
// revertido: no basta con mover el momento del montaje.
|
||||||
|
// 5. Y el culpable NO es DefMapLayers: /zones (SubscriptionsMap) y /fires
|
||||||
|
// (FiresMap) usan el MISMO componente con las MISMAS props
|
||||||
|
// (`<DefMapLayers gray />`), y en staging /zones pinta 18 teselas con su
|
||||||
|
// capa en el pane mientras /fires se queda con CERO capas. En los dos casos
|
||||||
|
// el mapa está inicializado y con vista (el `.leaflet-proxy` está ahí y el
|
||||||
|
// pane tiene su transform): lo que falta en /fires es que se le añada la
|
||||||
|
// capa base.
|
||||||
|
//
|
||||||
|
// O sea que hay que mirar cómo FiresMap monta y re-renderiza los hijos del
|
||||||
|
// MapContainer (tiene shouldComponentUpdate y un manejo de viewport con
|
||||||
|
// debounce), no DefMapLayers. La página de detalle de fuego es un caso aparte y
|
||||||
|
// además pide `satellite`, que es una capa de Google y por tanto ni siquiera
|
||||||
|
// existe hasta que la clave se resuelve.
|
||||||
|
//
|
||||||
|
// Quedan como test.fixme para que salgan como pendientes en cada ejecución en
|
||||||
|
// vez de desaparecer en una nota. Quitar el .fixme al arreglarlo.
|
||||||
|
test.describe('mapa de fuegos activos', () => {
|
||||||
|
test.fixme('paints its tiles', async ({ page }) => {
|
||||||
|
await page.goto('/fires');
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
await expect(page.locator('.leaflet-tile-loaded').first()).toBeVisible({ timeout: 60000 });
|
||||||
|
expect(await page.locator('.leaflet-tile').count()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.fixme('offers every base layer and keeps the checked one', async ({ page }) => {
|
||||||
|
await page.goto('/fires');
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
const layers = page.locator('.leaflet-control-layers-base input');
|
||||||
|
expect(await layers.count()).toBeGreaterThanOrEqual(2);
|
||||||
|
// Exactamente una marcada, y el mapa con teselas: si la marcada se hubiera
|
||||||
|
// perdido, seguiría marcada en el control pero no habría teselas.
|
||||||
|
const checked = await page.locator('.leaflet-control-layers-base input:checked').count();
|
||||||
|
expect(checked).toEqual(1);
|
||||||
|
expect(await page.locator('.leaflet-tile').count()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.fixme('subscribes to the fires of the current viewport', async ({ page }) => {
|
||||||
|
await page.goto('/fires');
|
||||||
|
await waitForMap(page);
|
||||||
|
|
||||||
|
const names = await page.evaluate(() => Object.values(window.Meteor.connection._subscriptions).map(s => s.name));
|
||||||
|
expect(names).toContain('activefiresmyloc');
|
||||||
|
expect(names).toContain('activefiresunionmyloc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.fixme('does not get stuck on "Actualizando…"', async ({ page }) => {
|
||||||
|
await page.goto('/fires');
|
||||||
|
await waitForMap(page);
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
await expect(page.getByText('Actualizando...', { exact: false })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
e2e/tests/i18n.spec.js
Normal file
54
e2e/tests/i18n.spec.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { signUp } from '../support/app.js';
|
||||||
|
|
||||||
|
// Through the navbar, not page.goto('/profile'): a cold load of that URL bounces
|
||||||
|
// to /subscriptions (the Authenticated wrapper decides before Meteor has resumed
|
||||||
|
// the login token). Worth its own fix, but it is not what this spec is about.
|
||||||
|
const openProfile = async (page) => {
|
||||||
|
await page.click('#profile');
|
||||||
|
await page.waitForURL('**/profile', { timeout: 30000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseLanguage = async (page, name) => {
|
||||||
|
await page.locator('.lang-selector').first().click();
|
||||||
|
await page.getByRole('button', { name }).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The site is bilingual (es/en) and the language belongs to the account, not to
|
||||||
|
// the browser session: notifications and emails go out in it too.
|
||||||
|
test.describe('cambio de idioma', () => {
|
||||||
|
test('the interface follows the language chosen in the profile', async ({ page }) => {
|
||||||
|
await signUp(page);
|
||||||
|
await expect(page.locator('#moniZones')).toHaveText('Zonas vigiladas');
|
||||||
|
|
||||||
|
await openProfile(page);
|
||||||
|
await chooseLanguage(page, 'English');
|
||||||
|
|
||||||
|
await expect(page.locator('#moniZones')).toHaveText('Monitored areas', { timeout: 30000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('going back to Spanish works too', async ({ page }) => {
|
||||||
|
await signUp(page);
|
||||||
|
await openProfile(page);
|
||||||
|
await chooseLanguage(page, 'English');
|
||||||
|
await expect(page.locator('#moniZones')).toHaveText('Monitored areas', { timeout: 30000 });
|
||||||
|
|
||||||
|
await chooseLanguage(page, 'Español');
|
||||||
|
await expect(page.locator('#moniZones')).toHaveText('Zonas vigiladas', { timeout: 30000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// KNOWN BUG, kept as a failing-by-design test instead of being quietly dropped:
|
||||||
|
// users.setLang does store the choice on the account, and Login.js applies it
|
||||||
|
// when you log in — but a plain reload of an already-logged-in session falls
|
||||||
|
// back to the browser's locale. Someone who picked English sees Spanish again
|
||||||
|
// on the next page load.
|
||||||
|
test.fixme('the chosen language survives a reload', async ({ page }) => {
|
||||||
|
await signUp(page);
|
||||||
|
await openProfile(page);
|
||||||
|
await chooseLanguage(page, 'English');
|
||||||
|
await expect(page.locator('#moniZones')).toHaveText('Monitored areas', { timeout: 30000 });
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('#moniZones')).toHaveText('Monitored areas', { timeout: 30000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
54
e2e/tests/zone-union.spec.js
Normal file
54
e2e/tests/zone-union.spec.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { signUp, addZoneThroughTheMap, waitForMap } from '../support/app.js';
|
||||||
|
|
||||||
|
// This is the flow that froze staging on 2026-07-30: subscribing to a zone makes
|
||||||
|
// the server recompute the union of every subscription, and while that ran on the
|
||||||
|
// main thread DDP stopped answering and every browser hung. A green run here means
|
||||||
|
// the page came back, the union reached the client, and the map drew it.
|
||||||
|
test.describe('suscripción a una zona y unión de zonas', () => {
|
||||||
|
test('creating a zone lists it and paints the union on both maps', async ({ page }) => {
|
||||||
|
await signUp(page);
|
||||||
|
await expect(page.getByText('No estás suscrito a fuegos en ninguna zona')).toBeVisible();
|
||||||
|
|
||||||
|
await addZoneThroughTheMap(page);
|
||||||
|
|
||||||
|
// Back on the zones page, the "you have no zones" warning is gone.
|
||||||
|
await expect(page.getByText('No estás suscrito a fuegos en ninguna zona')).toHaveCount(0);
|
||||||
|
|
||||||
|
// The union is a GeoJSON layer: leaflet draws it as an SVG path in the
|
||||||
|
// overlay pane. Nothing is drawn there until the server has recomputed and
|
||||||
|
// published it, so this also pins that the recompute finishes at all.
|
||||||
|
await waitForMap(page);
|
||||||
|
await expect(page.locator('.leaflet-overlay-pane path').first()).toBeVisible({ timeout: 60000 });
|
||||||
|
|
||||||
|
await page.goto('/zones');
|
||||||
|
await waitForMap(page);
|
||||||
|
await expect(page.locator('.leaflet-overlay-pane path').first()).toBeVisible({ timeout: 60000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the site keeps answering while the union is recomputed', async ({ page }) => {
|
||||||
|
await signUp(page);
|
||||||
|
await addZoneThroughTheMap(page);
|
||||||
|
|
||||||
|
// A DDP round trip right after the insert: if the union blocked the event
|
||||||
|
// loop again, this navigation would hang instead of rendering.
|
||||||
|
await page.goto('/fires');
|
||||||
|
await waitForMap(page);
|
||||||
|
await expect(page.locator('.leaflet-container').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Removing is the other half of the same server-side path: it schedules a full
|
||||||
|
// recompute instead of an incremental merge.
|
||||||
|
test('a zone can be removed again from the map', async ({ page }) => {
|
||||||
|
await signUp(page);
|
||||||
|
await addZoneThroughTheMap(page);
|
||||||
|
await expect(page.getByText('En verde, áreas de las que recibirás alertas de fuegos')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Editar/i }).click();
|
||||||
|
await page.locator('.leaflet-marker-icon').first().click();
|
||||||
|
await expect(page.getByText('¿Estás seguro/a?')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Sí', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('No estás suscrito a fuegos en ninguna zona')).toBeVisible({ timeout: 60000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -29,40 +29,41 @@ const findFiresInRegion = (zone) => {
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const countRealFires = (firesCursor) => {
|
export const countRealFires = async (firesCursor) => {
|
||||||
const realFires = [];
|
const realFires = [];
|
||||||
firesCursor.forEach((fire) => {
|
const fires = Array.isArray(firesCursor) ? firesCursor : await firesCursor.fetchAsync();
|
||||||
|
for (const fire of fires) {
|
||||||
if (debug) console.log(`${JSON.stringify(fire)} -----`);
|
if (debug) console.log(`${JSON.stringify(fire)} -----`);
|
||||||
const union = firesUnion([fire]);
|
const union = await firesUnion([fire]);
|
||||||
if (debug) console.log(`${JSON.stringify(union)} -----`);
|
if (debug) console.log(`${JSON.stringify(union)} -----`);
|
||||||
const falsePos = whichAreFalsePositives(FalsePositives, union);
|
const falsePos = whichAreFalsePositives(FalsePositives, union);
|
||||||
const industries = whichAreFalsePositives(Industries, union);
|
const industries = whichAreFalsePositives(Industries, union);
|
||||||
if (falsePos.count() === 0 && industries.count() === 0) {
|
if (await falsePos.countAsync() === 0 && await industries.countAsync() === 0) {
|
||||||
realFires.push(fire);
|
realFires.push(fire);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
// group fires
|
// group fires
|
||||||
const realUnion = firesUnion(realFires);
|
const realUnion = await firesUnion(realFires);
|
||||||
const unionCount = realUnion[0] &&
|
const unionCount = realUnion[0] &&
|
||||||
realUnion[0].geometry &&
|
realUnion[0].geometry &&
|
||||||
realUnion[0].geometry.coordinates ? realUnion[0].geometry.coordinates.length : 0;
|
realUnion[0].geometry.coordinates ? realUnion[0].geometry.coordinates.length : 0;
|
||||||
return unionCount;
|
return unionCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
const countFiresInRegions = (regions, stringsToRemove) => {
|
const countFiresInRegions = async (regions, stringsToRemove) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const fireStats = {};
|
const fireStats = {};
|
||||||
|
|
||||||
regions.features.forEach((region) => {
|
for (const region of regions.features) {
|
||||||
const regionName = cleanProv(region.properties.name, stringsToRemove);
|
const regionName = cleanProv(region.properties.name, stringsToRemove);
|
||||||
const regionCode = region.properties.iso_a2;
|
const regionCode = region.properties.iso_a2;
|
||||||
if (debug) console.log(`${regionName} -----`);
|
if (debug) console.log(`${regionName} -----`);
|
||||||
try {
|
try {
|
||||||
const fires = findFiresInRegion(region);
|
const fires = findFiresInRegion(region);
|
||||||
// TODO Also check neighbour fires (when better implementation of that part)
|
// TODO Also check neighbour fires (when better implementation of that part)
|
||||||
const initialCount = fires.count();
|
const initialCount = await fires.countAsync();
|
||||||
if (initialCount > 0) {
|
if (initialCount > 0) {
|
||||||
const unionCount = countRealFires(fires);
|
const unionCount = await countRealFires(fires);
|
||||||
if (debug) console.log(`${regionName} initial: ${initialCount}, union calc: ${unionCount}`);
|
if (debug) console.log(`${regionName} initial: ${initialCount}, union calc: ${unionCount}`);
|
||||||
if (unionCount > 0) {
|
if (unionCount > 0) {
|
||||||
total += unionCount;
|
total += unionCount;
|
||||||
|
|
@ -72,7 +73,7 @@ const countFiresInRegions = (regions, stringsToRemove) => {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ravenLogger.log(e);
|
ravenLogger.log(e);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
return { total, fires: fireStats };
|
return { total, fires: fireStats };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ Meteor.publish('activefirestotal', function total() {
|
||||||
return counter;
|
return counter;
|
||||||
});
|
});
|
||||||
|
|
||||||
const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => {
|
const activefires = async (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => {
|
||||||
const fires = ActiveFires.find({
|
const fires = ActiveFires.find({
|
||||||
ourid: {
|
ourid: {
|
||||||
$geoWithin: {
|
$geoWithin: {
|
||||||
|
|
@ -36,10 +36,10 @@ const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, wit
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Meteor.isDevelopment) console.log(`Active fires total: ${fires.count()}`);
|
if (Meteor.isDevelopment) console.log(`Active fires total: ${await fires.countAsync()}`);
|
||||||
|
|
||||||
if (withMarks && fires.fetch().length > 0) {
|
if (withMarks && (await fires.fetchAsync()).length > 0) {
|
||||||
const union = firesUnion(fires);
|
const union = await firesUnion(fires);
|
||||||
const falsePos = whichAreFalsePositives(FalsePositives, union);
|
const falsePos = whichAreFalsePositives(FalsePositives, union);
|
||||||
const industries = whichAreFalsePositives(Industries, union);
|
const industries = whichAreFalsePositives(Industries, union);
|
||||||
return [fires, falsePos, industries];
|
return [fires, falsePos, industries];
|
||||||
|
|
@ -48,7 +48,7 @@ const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, wit
|
||||||
return fires;
|
return fires;
|
||||||
};
|
};
|
||||||
|
|
||||||
Meteor.publish('activefiresmyloc', function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) {
|
Meteor.publish('activefiresmyloc', async function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) {
|
||||||
// latitude -90 and 90 and the longitude between -180 and 180
|
// latitude -90 and 90 and the longitude between -180 and 180
|
||||||
check(northEastLng, NumberBetween(-180, 180));
|
check(northEastLng, NumberBetween(-180, 180));
|
||||||
check(southWestLat, NumberBetween(-90, 90));
|
check(southWestLat, NumberBetween(-90, 90));
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ Meteor.publish('activefiresuniontotal', function total() {
|
||||||
return counter;
|
return counter;
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeFiresUnion = (b1, b2, c1, c2, withMarks) => {
|
const activeFiresUnion = async (b1, b2, c1, c2, withMarks) => {
|
||||||
// a --- b
|
// a --- b
|
||||||
// c --- d
|
// c --- d
|
||||||
const geometry = {
|
const geometry = {
|
||||||
|
|
@ -46,7 +46,7 @@ const activeFiresUnion = (b1, b2, c1, c2, withMarks) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Meteor.isDevelopment) console.log(`Active fires union total: ${fires.count()}`);
|
if (Meteor.isDevelopment) console.log(`Active fires union total: ${await fires.countAsync()}`);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
if (withMarks && fires.fetch().length > 0) {
|
if (withMarks && fires.fetch().length > 0) {
|
||||||
|
|
@ -59,7 +59,7 @@ const activeFiresUnion = (b1, b2, c1, c2, withMarks) => {
|
||||||
return fires;
|
return fires;
|
||||||
};
|
};
|
||||||
|
|
||||||
Meteor.publish('activefiresunionmyloc', function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) {
|
Meteor.publish('activefiresunionmyloc', async function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) {
|
||||||
// latitude -90 and 90 and the longitude between -180 and 180
|
// latitude -90 and 90 and the longitude between -180 and 180
|
||||||
check(northEastLng, NumberBetween(-180, 180));
|
check(northEastLng, NumberBetween(-180, 180));
|
||||||
check(southWestLat, NumberBetween(-90, 90));
|
check(southWestLat, NumberBetween(-90, 90));
|
||||||
|
|
|
||||||
29
imports/api/Comments/Comments.js
Normal file
29
imports/api/Comments/Comments.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
import { Mongo } from 'meteor/mongo';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Comments collection (fire-page comments).
|
||||||
|
*
|
||||||
|
* Replaces the abandoned `arkham:comments-ui` Atmosphere package (no Meteor 2.x
|
||||||
|
* build) with a small React + methods implementation. The collection name
|
||||||
|
* ('comments') and the core document shape are kept so existing production
|
||||||
|
* documents keep rendering:
|
||||||
|
*
|
||||||
|
* { referenceId: 'fire-<id>', content, userId, username,
|
||||||
|
* media: { type, content }, likes: [userId], dislikes: [userId],
|
||||||
|
* status: 'approved', createdAt, updatedAt }
|
||||||
|
*
|
||||||
|
* Legacy documents may also carry replies[]/starRatings[]/ratingScore/
|
||||||
|
* isAnonymous — those features were disabled in this app's config and are
|
||||||
|
* ignored by the new UI.
|
||||||
|
*/
|
||||||
|
const Comments = new Mongo.Collection('comments');
|
||||||
|
|
||||||
|
// Writes only through the vetted server methods.
|
||||||
|
Comments.deny({
|
||||||
|
insert: () => true,
|
||||||
|
update: () => true,
|
||||||
|
remove: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Comments;
|
||||||
44
imports/api/Comments/mediaAnalyzers.js
Normal file
44
imports/api/Comments/mediaAnalyzers.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Media analyzers ported from the old arkham:comments-ui package
|
||||||
|
* (lib/services/media-analyzers). Given a comment's text, detect an embeddable
|
||||||
|
* image or YouTube URL and return { type, content }. First match wins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const imageAnalyzer = {
|
||||||
|
name: 'image',
|
||||||
|
getMediaFromContent(content) {
|
||||||
|
if (content) {
|
||||||
|
const urls = content.match(/(\S+\.[^/\s]+(\/\S+|\/|))(.jpg|.png|.gif)/g);
|
||||||
|
if (urls && urls[0]) return urls[0];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const youtubeAnalyzer = {
|
||||||
|
name: 'youtube',
|
||||||
|
getMediaFromContent(content) {
|
||||||
|
const parts = (content || '').match(/(?:https?:\/\/)?(?:www\.youtube\.com|youtu\.?be)\/([\w=?]+)/);
|
||||||
|
let mediaContent = '';
|
||||||
|
if (parts && parts[1]) {
|
||||||
|
let id = parts[1];
|
||||||
|
if (id.indexOf('v=') > -1) {
|
||||||
|
const subParts = id.match(/v=([\w]+)/);
|
||||||
|
if (subParts && subParts[1]) id = subParts[1];
|
||||||
|
}
|
||||||
|
mediaContent = `https://www.youtube.com/embed/${id}`;
|
||||||
|
}
|
||||||
|
return mediaContent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyzers = [imageAnalyzer, youtubeAnalyzer];
|
||||||
|
|
||||||
|
// Returns { type, content } or {}.
|
||||||
|
export default function getMediaFromContent(content) {
|
||||||
|
for (let i = 0; i < analyzers.length; i += 1) {
|
||||||
|
const mediaContent = analyzers[i].getMediaFromContent(content);
|
||||||
|
if (mediaContent) return { type: analyzers[i].name, content: mediaContent };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
3
imports/api/Comments/server/index.js
Normal file
3
imports/api/Comments/server/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// Server-side registration for the Comments feature.
|
||||||
|
import './methods';
|
||||||
|
import './publications';
|
||||||
102
imports/api/Comments/server/methods.js
Normal file
102
imports/api/Comments/server/methods.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { check } from 'meteor/check';
|
||||||
|
import Comments from '/imports/api/Comments/Comments';
|
||||||
|
import getMediaFromContent from '/imports/api/Comments/mediaAnalyzers';
|
||||||
|
import rateLimit from '/imports/modules/rate-limit';
|
||||||
|
import onCommentAdd from '/imports/api/Comments/server/onCommentAdd';
|
||||||
|
|
||||||
|
const MAX_LEN = 5000;
|
||||||
|
|
||||||
|
function usernameOf(user) {
|
||||||
|
return (user && user.profile && user.profile.name && user.profile.name.first)
|
||||||
|
? user.profile.name.first
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireOwner(commentId, userId) {
|
||||||
|
const comment = await Comments.findOneAsync(commentId);
|
||||||
|
if (!comment) throw new Meteor.Error('404', 'Comment not found');
|
||||||
|
if (comment.userId !== userId) throw new Meteor.Error('403', 'Not your comment');
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(commentId, field, otherField, userId) {
|
||||||
|
check(commentId, String);
|
||||||
|
if (!userId) throw new Meteor.Error('403', 'Login required');
|
||||||
|
const comment = await Comments.findOneAsync(commentId);
|
||||||
|
if (!comment) throw new Meteor.Error('404', 'Comment not found');
|
||||||
|
const has = (comment[field] || []).includes(userId);
|
||||||
|
const modifier = has
|
||||||
|
? { $pull: { [field]: userId } }
|
||||||
|
: { $addToSet: { [field]: userId }, $pull: { [otherField]: userId } };
|
||||||
|
await Comments.updateAsync(commentId, modifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
Meteor.methods({
|
||||||
|
'comments.insert': async function commentsInsert(referenceId, content) {
|
||||||
|
check(referenceId, String);
|
||||||
|
check(content, String);
|
||||||
|
if (!this.userId) throw new Meteor.Error('403', 'Login required to comment');
|
||||||
|
const text = content.trim();
|
||||||
|
if (!text) throw new Meteor.Error('400', 'Empty comment');
|
||||||
|
if (text.length > MAX_LEN) throw new Meteor.Error('400', 'Comment too long');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const doc = {
|
||||||
|
referenceId,
|
||||||
|
content: text,
|
||||||
|
userId: this.userId,
|
||||||
|
username: usernameOf(await Meteor.users.findOneAsync(this.userId)),
|
||||||
|
media: getMediaFromContent(text),
|
||||||
|
likes: [],
|
||||||
|
dislikes: [],
|
||||||
|
status: 'approved',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
const _id = await Comments.insertAsync(doc);
|
||||||
|
// Fire-and-forget: notify other commenters of this fire. Never let a mail
|
||||||
|
// failure break the insert.
|
||||||
|
try {
|
||||||
|
onCommentAdd({ ...doc, _id });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`comments onCommentAdd failed: ${e}`);
|
||||||
|
}
|
||||||
|
return _id;
|
||||||
|
},
|
||||||
|
|
||||||
|
'comments.edit': async function commentsEdit(commentId, content) {
|
||||||
|
check(commentId, String);
|
||||||
|
check(content, String);
|
||||||
|
if (!this.userId) throw new Meteor.Error('403', 'Login required');
|
||||||
|
await requireOwner(commentId, this.userId);
|
||||||
|
const text = content.trim();
|
||||||
|
if (!text) throw new Meteor.Error('400', 'Empty comment');
|
||||||
|
if (text.length > MAX_LEN) throw new Meteor.Error('400', 'Comment too long');
|
||||||
|
await Comments.updateAsync(commentId, {
|
||||||
|
$set: { content: text, media: getMediaFromContent(text), updatedAt: new Date() }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'comments.remove': async function commentsRemove(commentId) {
|
||||||
|
check(commentId, String);
|
||||||
|
if (!this.userId) throw new Meteor.Error('403', 'Login required');
|
||||||
|
await requireOwner(commentId, this.userId);
|
||||||
|
await Comments.removeAsync(commentId);
|
||||||
|
},
|
||||||
|
|
||||||
|
'comments.like': async function commentsLike(commentId) {
|
||||||
|
await toggle(commentId, 'likes', 'dislikes', this.userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
'comments.dislike': async function commentsDislike(commentId) {
|
||||||
|
await toggle(commentId, 'dislikes', 'likes', this.userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rateLimit({
|
||||||
|
methods: ['comments.insert', 'comments.edit', 'comments.remove', 'comments.like', 'comments.dislike'],
|
||||||
|
limit: 5,
|
||||||
|
timeRange: 1000
|
||||||
|
});
|
||||||
42
imports/api/Comments/server/onCommentAdd.js
Normal file
42
imports/api/Comments/server/onCommentAdd.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import i18n from 'i18next';
|
||||||
|
import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email';
|
||||||
|
import getEmailOf from '/imports/modules/get-email-of-user';
|
||||||
|
import Comments from '/imports/api/Comments/Comments';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When a comment is added to a fire, email the other users who commented on the
|
||||||
|
* same fire (uniq, excluding the author). Ported from the old
|
||||||
|
* startup/server/comments.js `onEvent` handler.
|
||||||
|
*/
|
||||||
|
export default function onCommentAdd(payload) {
|
||||||
|
const { referenceId, userId } = payload;
|
||||||
|
const query = { referenceId, userId: { $ne: userId } };
|
||||||
|
|
||||||
|
Comments.rawCollection().distinct('userId', query).then(async (users) => {
|
||||||
|
const path = referenceId.replace(/fire-/, 'fire/archive/');
|
||||||
|
const fireUrl = Meteor.absoluteUrl(path);
|
||||||
|
await Meteor.users.find({ _id: { $in: users } }).forEachAsync((user) => {
|
||||||
|
const { firstName, emailAddress } = getEmailOf(user);
|
||||||
|
if (emailAddress) {
|
||||||
|
const emailOpts = {
|
||||||
|
to: emailAddress,
|
||||||
|
subject: subjectTruncate.apply(i18n.t('Hay más información sobre un fuego')),
|
||||||
|
lang: user.lang,
|
||||||
|
template: 'new-fire-comment',
|
||||||
|
templateVars: {
|
||||||
|
applicationName: i18n.t('AppName'),
|
||||||
|
firstName,
|
||||||
|
fireUrl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sendEmail(emailOpts).catch((error) => {
|
||||||
|
console.warn(`comments new-fire-comment mail failed: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn(`comments distinct() failed: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
14
imports/api/Comments/server/publications.js
Normal file
14
imports/api/Comments/server/publications.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
/* eslint-disable prefer-arrow-callback */
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { check } from 'meteor/check';
|
||||||
|
import Comments from '/imports/api/Comments/Comments';
|
||||||
|
|
||||||
|
// Comments for one reference (a fire page), oldest first.
|
||||||
|
Meteor.publish('comments.forReference', function commentsForReference(referenceId) {
|
||||||
|
check(referenceId, String);
|
||||||
|
return Comments.find(
|
||||||
|
{ referenceId },
|
||||||
|
{ sort: { createdAt: 1 } }
|
||||||
|
);
|
||||||
|
});
|
||||||
15
imports/api/Common/id.js
Normal file
15
imports/api/Common/id.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* Fire/collection docs use `idGeneration: 'MONGO'`, so their `_id` is a
|
||||||
|
* `Mongo.ObjectID`, whose `.toString()` yields `ObjectID("<hex>")` — NOT the
|
||||||
|
* bare hex. Interpolating an `_id` straight into a URL or any string therefore
|
||||||
|
* produces `.../fire/archive/ObjectID("...")`, which the routes can't match.
|
||||||
|
*
|
||||||
|
* Use `hexId()` wherever an id becomes part of a string (URLs, comment
|
||||||
|
* reference ids, DOM ids). It returns the 24-char hex for an ObjectID and
|
||||||
|
* passes plain-string ids through unchanged. Do NOT use it for DDP method
|
||||||
|
* args whose `check()` expects `Meteor.Collection.ObjectID` — pass the object.
|
||||||
|
*/
|
||||||
|
export const hexId = id =>
|
||||||
|
(id && typeof id.toHexString === 'function' ? id.toHexString() : id);
|
||||||
|
|
||||||
|
export default hexId;
|
||||||
|
|
@ -6,7 +6,7 @@ import FalsePositiveTypes from './FalsePositiveTypes';
|
||||||
import Fires from '../Fires/Fires';
|
import Fires from '../Fires/Fires';
|
||||||
import rateLimit from '../../modules/rate-limit';
|
import rateLimit from '../../modules/rate-limit';
|
||||||
|
|
||||||
export function upsertFalsePositive(keyType, owner, fire) {
|
export async function upsertFalsePositive(keyType, owner, fire) {
|
||||||
const fireType = fire.type;
|
const fireType = fire.type;
|
||||||
if (fireType === 'vecinal') {
|
if (fireType === 'vecinal') {
|
||||||
throw new Meteor.Error('500', 'No se puede marcar este tipo de fuego');
|
throw new Meteor.Error('500', 'No se puede marcar este tipo de fuego');
|
||||||
|
|
@ -22,7 +22,7 @@ export function upsertFalsePositive(keyType, owner, fire) {
|
||||||
fireId: fire._id,
|
fireId: fire._id,
|
||||||
geo: fire.ourid
|
geo: fire.ourid
|
||||||
};
|
};
|
||||||
return FalsePositives.upsert({ geo: fire.ourid, owner }, { $set: set }, { multi: false, upsert: true });
|
return await FalsePositives.upsertAsync({ geo: fire.ourid, owner }, { $set: set }, { multi: false, upsert: true });
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
console.log(exception);
|
console.log(exception);
|
||||||
throw new Meteor.Error('500', exception);
|
throw new Meteor.Error('500', exception);
|
||||||
|
|
@ -30,7 +30,7 @@ export function upsertFalsePositive(keyType, owner, fire) {
|
||||||
}
|
}
|
||||||
|
|
||||||
Meteor.methods({
|
Meteor.methods({
|
||||||
'falsePositives.insert': function falsePositivesInsert(fireId, keyType) {
|
'falsePositives.insert': async function falsePositivesInsert(fireId, keyType) {
|
||||||
check(fireId, Meteor.Collection.ObjectID);
|
check(fireId, Meteor.Collection.ObjectID);
|
||||||
check(keyType, String);
|
check(keyType, String);
|
||||||
const type = FalsePositiveTypes[keyType];
|
const type = FalsePositiveTypes[keyType];
|
||||||
|
|
@ -39,10 +39,10 @@ Meteor.methods({
|
||||||
throw new Meteor.Error('500', 'Regístrate o inicia sesión para aportar información sobre este fuego');
|
throw new Meteor.Error('500', 'Regístrate o inicia sesión para aportar información sobre este fuego');
|
||||||
}
|
}
|
||||||
check(this.userId, String);
|
check(this.userId, String);
|
||||||
const fire = Fires.findOne(fireId);
|
const fire = await Fires.findOneAsync(fireId);
|
||||||
const owner = this.userId;
|
const owner = this.userId;
|
||||||
if (fire) {
|
if (fire) {
|
||||||
upsertFalsePositive(keyType, owner, fire);
|
await upsertFalsePositive(keyType, owner, fire);
|
||||||
} else {
|
} else {
|
||||||
throw new Meteor.Error('500', 'Fuego no encontrado');
|
throw new Meteor.Error('500', 'Fuego no encontrado');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ Meteor.publish('falsePositivesTotal', function total() {
|
||||||
return counter;
|
return counter;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const firesUnion = (fires) => {
|
export const firesUnion = async (fires) => {
|
||||||
const firesArray = Array.isArray(fires) ? fires : fires.fetch(); // if not is a cursor
|
const firesArray = Array.isArray(fires) ? fires : await fires.fetchAsync(); // if not is a cursor
|
||||||
const remap = firesArray.map(function remap(doc) {
|
const remap = firesArray.map(function remap(doc) {
|
||||||
const isNASA = doc.type === 'modis' || doc.type === 'viirs';
|
const isNASA = doc.type === 'modis' || doc.type === 'viirs';
|
||||||
const pixelSize = doc.type === 'viirs' ? 0.375 : 1; // viirs has 375m pixel size, modis 1000m
|
const pixelSize = doc.type === 'viirs' ? 0.375 : 1; // viirs has 375m pixel size, modis 1000m
|
||||||
|
|
@ -84,7 +84,7 @@ const find = (collection, northEastLng, northEastLat, southWestLng, southWestLat
|
||||||
return fires;
|
return fires;
|
||||||
};
|
};
|
||||||
|
|
||||||
Meteor.publishTransformed('falsePositivesMyloc', function falsePositivesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) {
|
Meteor.publish('falsePositivesMyloc', function falsePositivesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) {
|
||||||
// latitude -90 and 90 and the longitude between -180 and 180
|
// latitude -90 and 90 and the longitude between -180 and 180
|
||||||
check(northEastLng, NumberBetween(-180, 180));
|
check(northEastLng, NumberBetween(-180, 180));
|
||||||
check(southWestLat, NumberBetween(-90, 90));
|
check(southWestLat, NumberBetween(-90, 90));
|
||||||
|
|
@ -94,7 +94,7 @@ Meteor.publishTransformed('falsePositivesMyloc', function falsePositivesInMyLoc(
|
||||||
return find(FalsePositives, northEastLng, northEastLat, southWestLng, southWestLat);
|
return find(FalsePositives, northEastLng, northEastLat, southWestLng, southWestLat);
|
||||||
});
|
});
|
||||||
|
|
||||||
Meteor.publishTransformed('industriesMyloc', function industriesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) {
|
Meteor.publish('industriesMyloc', function industriesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) {
|
||||||
// latitude -90 and 90 and the longitude between -180 and 180
|
// latitude -90 and 90 and the longitude between -180 and 180
|
||||||
check(northEastLng, NumberBetween(-180, 180));
|
check(northEastLng, NumberBetween(-180, 180));
|
||||||
check(southWestLat, NumberBetween(-90, 90));
|
check(southWestLat, NumberBetween(-90, 90));
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { check } from 'meteor/check';
|
||||||
import { NumberBetween } from '/imports/modules/server/other-checks';
|
import { NumberBetween } from '/imports/modules/server/other-checks';
|
||||||
import FireAlerts from '../FireAlerts';
|
import FireAlerts from '../FireAlerts';
|
||||||
|
|
||||||
Meteor.publish('fireAlerts', function fireAlerts(northEastLng, northEastLat, southWestLng, southWestLat) {
|
Meteor.publish('fireAlerts', async function fireAlerts(northEastLng, northEastLat, southWestLng, southWestLat) {
|
||||||
// latitude -90 and 90 and the longitude between -180 and 180
|
// latitude -90 and 90 and the longitude between -180 and 180
|
||||||
check(northEastLng, NumberBetween(-180, 180));
|
check(northEastLng, NumberBetween(-180, 180));
|
||||||
check(southWestLat, NumberBetween(-90, 90));
|
check(southWestLat, NumberBetween(-90, 90));
|
||||||
|
|
@ -37,6 +37,6 @@ Meteor.publish('fireAlerts', function fireAlerts(northEastLng, northEastLat, sou
|
||||||
track: 1
|
track: 1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (Meteor.isDevelopment) console.log(`Neighbour alerts total: ${fires.count()}`);
|
if (Meteor.isDevelopment) console.log(`Neighbour alerts total: ${await fires.countAsync()}`);
|
||||||
return fires;
|
return fires;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,12 @@ const fixConfidence = (obj) => {
|
||||||
return obj;
|
return obj;
|
||||||
};
|
};
|
||||||
|
|
||||||
const findOrCreateFire = (obj) => {
|
const findOrCreateFire = async (obj) => {
|
||||||
const fire = findFire(obj);
|
const fire = findFire(obj);
|
||||||
// console.log(`Found: ${fire.count()}`);
|
// console.log(`Found: ${await fire.countAsync()}`);
|
||||||
if (!obj.address) {
|
if (!obj.address) {
|
||||||
try {
|
try {
|
||||||
const rev = Promise.await(geocoder.reverse({ lat: obj.lat, lon: obj.lon }));
|
const rev = await geocoder.reverse({ lat: obj.lat, lon: obj.lon });
|
||||||
if (rev[0]) {
|
if (rev[0]) {
|
||||||
obj.address = rev[0].formattedAddress;
|
obj.address = rev[0].formattedAddress;
|
||||||
}
|
}
|
||||||
|
|
@ -67,23 +67,23 @@ const findOrCreateFire = (obj) => {
|
||||||
console.warn(reve);
|
console.warn(reve);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fire.count() === 0) {
|
if (await fire.countAsync() === 0) {
|
||||||
// const result =
|
// const result =
|
||||||
// console.log('Creating new fire');
|
// console.log('Creating new fire');
|
||||||
FiresCollection.upsert({ ourid: obj.ourid, when: obj.when, type: obj.type }, { $set: obj }, { multi: false, upsert: true });
|
await FiresCollection.upsertAsync({ ourid: obj.ourid, when: obj.when, type: obj.type }, { $set: obj }, { multi: false, upsert: true });
|
||||||
// console.log(JSON.stringify(result));
|
// console.log(JSON.stringify(result));
|
||||||
}
|
}
|
||||||
return findFire(obj);
|
return findFire(obj);
|
||||||
};
|
};
|
||||||
|
|
||||||
Meteor.publish('fireFromAlertId', function fireFromAlertId(_id) {
|
Meteor.publish('fireFromAlertId', async function fireFromAlertId(_id) {
|
||||||
try {
|
try {
|
||||||
check(_id, String);
|
check(_id, String);
|
||||||
// console.log(`Looking for alert fire ${_id}`);
|
// console.log(`Looking for alert fire ${_id}`);
|
||||||
const fire = FireAlertsCollection.findOne(new Meteor.Collection.ObjectID(_id));
|
const fire = await FireAlertsCollection.findOneAsync(new Meteor.Collection.ObjectID(_id));
|
||||||
if (fire) {
|
if (fire) {
|
||||||
// console.info(`Active fire found: ${_id}`);
|
// console.info(`Active fire found: ${_id}`);
|
||||||
return findOrCreateFire(fixConfidence(fire));
|
return await findOrCreateFire(fixConfidence(fire));
|
||||||
}
|
}
|
||||||
console.info(`Alert fire not found: ${_id}`);
|
console.info(`Alert fire not found: ${_id}`);
|
||||||
// Not found in active fires!
|
// Not found in active fires!
|
||||||
|
|
@ -94,14 +94,14 @@ Meteor.publish('fireFromAlertId', function fireFromAlertId(_id) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Meteor.publish('fireFromActiveId', function fireFromActiveId(_id) {
|
Meteor.publish('fireFromActiveId', async function fireFromActiveId(_id) {
|
||||||
try {
|
try {
|
||||||
check(_id, String);
|
check(_id, String);
|
||||||
// console.log(`Looking for active fire ${_id}`);
|
// console.log(`Looking for active fire ${_id}`);
|
||||||
const fire = ActiveFiresCollection.findOne(new Meteor.Collection.ObjectID(_id));
|
const fire = await ActiveFiresCollection.findOneAsync(new Meteor.Collection.ObjectID(_id));
|
||||||
if (fire) {
|
if (fire) {
|
||||||
// console.info(`Active fire found: ${_id}`);
|
// console.info(`Active fire found: ${_id}`);
|
||||||
return findOrCreateFire(fixConfidence(fire));
|
return await findOrCreateFire(fixConfidence(fire));
|
||||||
}
|
}
|
||||||
console.info(`Active fire not found: ${_id}`);
|
console.info(`Active fire not found: ${_id}`);
|
||||||
// Not found in active fires!
|
// Not found in active fires!
|
||||||
|
|
@ -112,14 +112,14 @@ Meteor.publish('fireFromActiveId', function fireFromActiveId(_id) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Meteor.publish('fireFromId', function fireFromId(_id) {
|
Meteor.publish('fireFromId', async function fireFromId(_id) {
|
||||||
try {
|
try {
|
||||||
check(_id, String);
|
check(_id, String);
|
||||||
// console.log(`Looking for archive fire ${_id}`);
|
// console.log(`Looking for archive fire ${_id}`);
|
||||||
const fire = FiresCollection.find(new Meteor.Collection.ObjectID(_id));
|
const fire = FiresCollection.find(new Meteor.Collection.ObjectID(_id));
|
||||||
if (fire.count() !== 0) {
|
if (await fire.countAsync() !== 0) {
|
||||||
// console.info(`Archive fire found: ${_id}`);
|
// console.info(`Archive fire found: ${_id}`);
|
||||||
const union = firesUnion(fire);
|
const union = await firesUnion(fire);
|
||||||
const falsePos = whichAreFalsePositives(FalsePositives, union);
|
const falsePos = whichAreFalsePositives(FalsePositives, union);
|
||||||
const industries = whichAreFalsePositives(Industries, union);
|
const industries = whichAreFalsePositives(Industries, union);
|
||||||
return [fire, falsePos, industries];
|
return [fire, falsePos, industries];
|
||||||
|
|
@ -139,13 +139,12 @@ function logUrl(fireEnc, params) {
|
||||||
ravenLogger.log(message);
|
ravenLogger.log(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fireFromHash(fireEnc, params) {
|
export async function fireFromHash(fireEnc, params) {
|
||||||
check(fireEnc, String);
|
check(fireEnc, String);
|
||||||
check(params, Object);
|
check(params, Object);
|
||||||
|
|
||||||
// console.log(fireEnc);
|
// console.log(fireEnc);
|
||||||
// const unsealed = Promise.await(urlEnc.decrypt(fireEnc));
|
const unsealed = await unsealW(fireEnc);
|
||||||
const unsealed = Promise.await(unsealW(fireEnc));
|
|
||||||
if (unsealed === undefined) {
|
if (unsealed === undefined) {
|
||||||
throw Error(`Fail to unseal '${fireEnc}'`);
|
throw Error(`Fail to unseal '${fireEnc}'`);
|
||||||
}
|
}
|
||||||
|
|
@ -159,16 +158,16 @@ export function fireFromHash(fireEnc, params) {
|
||||||
// FIXME:
|
// FIXME:
|
||||||
const unsealedFix = fixConfidence(unsealed);
|
const unsealedFix = fixConfidence(unsealed);
|
||||||
FiresCollection.schema.validate(unsealedFix);
|
FiresCollection.schema.validate(unsealedFix);
|
||||||
return findOrCreateFire(unsealedFix);
|
return await findOrCreateFire(unsealedFix);
|
||||||
/* console.log(`fires: ${fire.count()}`);
|
/* console.log(`fires: ${fire.count()}`);
|
||||||
* return fire; */
|
* return fire; */
|
||||||
}
|
}
|
||||||
|
|
||||||
Meteor.publish('fireFromHash', function fireFromHashFunc(fireEnc, params) {
|
Meteor.publish('fireFromHash', async function fireFromHashFunc(fireEnc, params) {
|
||||||
check(fireEnc, String);
|
check(fireEnc, String);
|
||||||
check(params, Object);
|
check(params, Object);
|
||||||
try {
|
try {
|
||||||
return fireFromHash(fireEnc, params);
|
return await fireFromHash(fireEnc, params);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
logUrl(fireEnc, params);
|
logUrl(fireEnc, params);
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,16 @@ import { ServiceConfiguration } from 'meteor/service-configuration';
|
||||||
import rateLimit from '../../../modules/rate-limit';
|
import rateLimit from '../../../modules/rate-limit';
|
||||||
|
|
||||||
Meteor.methods({
|
Meteor.methods({
|
||||||
'oauth.verifyConfiguration': function oauthVerifyConfiguration(services) {
|
'oauth.verifyConfiguration': async function oauthVerifyConfiguration(services) {
|
||||||
check(services, Array);
|
check(services, Array);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const verifiedServices = [];
|
const verifiedServices = [];
|
||||||
services.forEach((service) => {
|
for (const service of services) {
|
||||||
if (ServiceConfiguration.configurations.findOne({ service })) {
|
if (await ServiceConfiguration.configurations.findOneAsync({ service })) {
|
||||||
verifiedServices.push(service);
|
verifiedServices.push(service);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
return verifiedServices.sort();
|
return verifiedServices.sort();
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
throw new Meteor.Error('500', exception);
|
throw new Meteor.Error('500', exception);
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,8 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
},
|
},
|
||||||
endpoints: {
|
endpoints: {
|
||||||
get: {
|
get: {
|
||||||
action: function getFire() {
|
action: async function getFire() {
|
||||||
return Fires.findOne(new Meteor.Collection.ObjectID(this.urlParams.id));
|
return Fires.findOneAsync(new Meteor.Collection.ObjectID(this.urlParams.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -98,22 +98,22 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
|
|
||||||
// Maps to: /api/v1/status/last-fire-check
|
// Maps to: /api/v1/status/last-fire-check
|
||||||
apiV1.addRoute('status/last-fire-check', { authRequired: false }, {
|
apiV1.addRoute('status/last-fire-check', { authRequired: false }, {
|
||||||
get: function get() {
|
get: async function get() {
|
||||||
return SiteSettings.findOne({ name: 'last-fire-check' });
|
return SiteSettings.findOneAsync({ name: 'last-fire-check' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Maps to: /api/v1/status/last-fire-detected
|
// Maps to: /api/v1/status/last-fire-detected
|
||||||
apiV1.addRoute('status/last-fire-detected', { authRequired: false }, {
|
apiV1.addRoute('status/last-fire-detected', { authRequired: false }, {
|
||||||
get: function get() {
|
get: async function get() {
|
||||||
return ActiveFiresCollection.findOne({}, { sort: { when: -1 } });
|
return ActiveFiresCollection.findOneAsync({}, { sort: { when: -1 } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Maps to: /api/v1/status/active-fires-count
|
// Maps to: /api/v1/status/active-fires-count
|
||||||
apiV1.addRoute('status/active-fires-count', { authRequired: false }, {
|
apiV1.addRoute('status/active-fires-count', { authRequired: false }, {
|
||||||
get: function get() {
|
get: async function get() {
|
||||||
return { total: ActiveFiresCollection.find({}).count() };
|
return { total: await ActiveFiresCollection.find({}).countAsync() };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -124,7 +124,7 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function getFires(route, full) {
|
async function getFires(route, full) {
|
||||||
const lat = Number(route.urlParams.lat);
|
const lat = Number(route.urlParams.lat);
|
||||||
const lng = Number(route.urlParams.lng);
|
const lng = Number(route.urlParams.lng);
|
||||||
const km = Number(route.urlParams.km);
|
const km = Number(route.urlParams.km);
|
||||||
|
|
@ -163,7 +163,11 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = { total: fires.count(), real: countRealFires(fires) };
|
// Meteor 3 / mongodb driver 6: countAsync() on a $near cursor fails
|
||||||
|
// ($near/$geoNear not allowed inside the aggregation countDocuments uses).
|
||||||
|
// Fetch once and derive the total from the array length instead.
|
||||||
|
const firesA = await fires.fetchAsync();
|
||||||
|
const result = { total: firesA.length, real: await countRealFires(firesA) };
|
||||||
if (debug) console.log(`Query for fires in ${lat}, ${lng} in ${km} km radius ${result}`);
|
if (debug) console.log(`Query for fires in ${lat}, ${lng} in ${km} km radius ${result}`);
|
||||||
|
|
||||||
if (!full) {
|
if (!full) {
|
||||||
|
|
@ -172,19 +176,16 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
|
|
||||||
let union;
|
let union;
|
||||||
|
|
||||||
// TODO only get real
|
|
||||||
const firesA = fires.fetch();
|
|
||||||
|
|
||||||
if (firesA.length > 0) {
|
if (firesA.length > 0) {
|
||||||
union = firesUnion(fires);
|
union = await firesUnion(firesA);
|
||||||
} else {
|
} else {
|
||||||
union = zoneToUnion(lat, lng, km);
|
union = zoneToUnion(lat, lng, km);
|
||||||
}
|
}
|
||||||
const falsePos = whichAreFalsePositives(FalsePositives, union);
|
const falsePos = whichAreFalsePositives(FalsePositives, union);
|
||||||
const industries = whichAreFalsePositives(Industries, union);
|
const industries = whichAreFalsePositives(Industries, union);
|
||||||
result.fires = firesA;
|
result.fires = firesA;
|
||||||
result.industries = industries.fetch();
|
result.industries = await industries.fetchAsync();
|
||||||
result.falsePos = falsePos.fetch();
|
result.falsePos = await falsePos.fetchAsync();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,13 +193,13 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
// 100 km max
|
// 100 km max
|
||||||
// Ex: http://127.0.0.1:3000/api/v1/fires-in/token/38.736946/-9.142685/100
|
// Ex: http://127.0.0.1:3000/api/v1/fires-in/token/38.736946/-9.142685/100
|
||||||
apiV1.addRoute('fires-in/:token/:lat/:lng/:km', { authRequired: false }, {
|
apiV1.addRoute('fires-in/:token/:lat/:lng/:km', { authRequired: false }, {
|
||||||
get: function get() {
|
get: async function get() {
|
||||||
return getFires(this, false);
|
return getFires(this, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
apiV1.addRoute('fires-in-full/:token/:lat/:lng/:km', { authRequired: false }, {
|
apiV1.addRoute('fires-in-full/:token/:lat/:lng/:km', { authRequired: false }, {
|
||||||
get: function get() {
|
get: async function get() {
|
||||||
return getFires(this, true);
|
return getFires(this, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -209,7 +210,7 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
//
|
//
|
||||||
// https://docs.meteor.com/api/passwords.html#Accounts-createUser
|
// https://docs.meteor.com/api/passwords.html#Accounts-createUser
|
||||||
apiV1.addRoute('mobile/users', { authRequired: false }, {
|
apiV1.addRoute('mobile/users', { authRequired: false }, {
|
||||||
post: function post() {
|
post: async function post() {
|
||||||
const { token, mobileToken, lang } = this.bodyParams;
|
const { token, mobileToken, lang } = this.bodyParams;
|
||||||
try {
|
try {
|
||||||
check(token, String);
|
check(token, String);
|
||||||
|
|
@ -225,22 +226,23 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
let username;
|
let username;
|
||||||
|
|
||||||
const already = Meteor.users.find({ fireBaseToken: mobileToken });
|
const already = Meteor.users.find({ fireBaseToken: mobileToken });
|
||||||
|
const alreadyCount = await already.countAsync();
|
||||||
|
|
||||||
if (already.count() > 1) {
|
if (alreadyCount > 1) {
|
||||||
return restivusError(500, 'Unexpected error in REST call: several users with that mobile token?');
|
return restivusError(500, 'Unexpected error in REST call: several users with that mobile token?');
|
||||||
} else if (already.count() === 1) {
|
} else if (alreadyCount === 1) {
|
||||||
username = already.fetch()[0].username;
|
username = (await already.fetchAsync())[0].username;
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
username = Random.id(15);
|
username = Random.id(15);
|
||||||
} while (Meteor.users.find({ username }).count() !== 0);
|
} while (await Meteor.users.find({ username }).countAsync() !== 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME check valid lang
|
// FIXME check valid lang
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const result = Meteor.users.upsert({
|
const result = await Meteor.users.upsertAsync({
|
||||||
fireBaseToken: mobileToken
|
fireBaseToken: mobileToken
|
||||||
}, {
|
}, {
|
||||||
$set: {
|
$set: {
|
||||||
|
|
@ -260,7 +262,7 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
console.log(this.queryParams);
|
console.log(this.queryParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
const upsertUser = Meteor.users.findOne({ username });
|
const upsertUser = await Meteor.users.findOneAsync({ username });
|
||||||
|
|
||||||
if (debug) console.log(upsertUser);
|
if (debug) console.log(upsertUser);
|
||||||
|
|
||||||
|
|
@ -277,7 +279,7 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
|
|
||||||
// max sitance: 100km
|
// max sitance: 100km
|
||||||
apiV1.addRoute('mobile/subscriptions', { authRequired: false }, {
|
apiV1.addRoute('mobile/subscriptions', { authRequired: false }, {
|
||||||
post: function post() {
|
post: async function post() {
|
||||||
const {
|
const {
|
||||||
token,
|
token,
|
||||||
mobileToken,
|
mobileToken,
|
||||||
|
|
@ -305,7 +307,7 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
const failed = checkAuthToken(token);
|
const failed = checkAuthToken(token);
|
||||||
if (failed) return failed;
|
if (failed) return failed;
|
||||||
|
|
||||||
const user = Meteor.users.findOne({ fireBaseToken: mobileToken });
|
const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken });
|
||||||
if (!user) return failMsg('User not found');
|
if (!user) return failMsg('User not found');
|
||||||
|
|
||||||
const newSubs = {};
|
const newSubs = {};
|
||||||
|
|
@ -317,7 +319,7 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = subscriptionsInsert(newSubs, user._id, 'mobile');
|
result = await subscriptionsInsert(newSubs, user._id, 'mobile');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
|
|
@ -325,8 +327,57 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// NOTE: json-routes 3.0 (Meteor 3) matches routes in registration order, so
|
||||||
|
// the literal `all/:token/:mobileToken` route MUST be registered before the
|
||||||
|
// `:token/:mobileToken/:subsId` route — otherwise a GET to
|
||||||
|
// `.../all/<tok>/<mob>` matches the 3-param route (delete-only) and restivus
|
||||||
|
// replies "API endpoint does not exist".
|
||||||
|
apiV1.addRoute('mobile/subscriptions/all/:token/:mobileToken', { authRequired: false }, {
|
||||||
|
get: async function get() {
|
||||||
|
const { token, mobileToken } = this.urlParams;
|
||||||
|
try {
|
||||||
|
check(token, String);
|
||||||
|
check(mobileToken, String);
|
||||||
|
} catch (e) {
|
||||||
|
return defaultFailParams(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = checkAuthToken(token);
|
||||||
|
if (failed) return failed;
|
||||||
|
|
||||||
|
const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken });
|
||||||
|
if (!user) return failMsg('User not found');
|
||||||
|
|
||||||
|
const result = Subscriptions.find({ owner: user._id });
|
||||||
|
|
||||||
|
return jsend.success({ subscriptions: await result.fetchAsync(), count: await result.countAsync() });
|
||||||
|
},
|
||||||
|
delete: async function delAll() {
|
||||||
|
const { token, mobileToken } = this.urlParams;
|
||||||
|
try {
|
||||||
|
check(token, String);
|
||||||
|
check(mobileToken, String);
|
||||||
|
} catch (e) {
|
||||||
|
return defaultFailParams(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = checkAuthToken(token);
|
||||||
|
if (failed) return failed('Auth api check failed');
|
||||||
|
|
||||||
|
if (await Meteor.users.find({ fireBaseToken: mobileToken }).countAsync() !== 1) return fail;
|
||||||
|
|
||||||
|
const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken });
|
||||||
|
if (!user) return failMsg('User not found');
|
||||||
|
|
||||||
|
const toRemove = await Subscriptions.find({ owner: user._id }).countAsync();
|
||||||
|
await Subscriptions.removeAsync({ owner: user._id });
|
||||||
|
|
||||||
|
return jsend.success({ count: toRemove });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
apiV1.addRoute('mobile/subscriptions/:token/:mobileToken/:subsId', { authRequired: false }, {
|
apiV1.addRoute('mobile/subscriptions/:token/:mobileToken/:subsId', { authRequired: false }, {
|
||||||
delete: function del() {
|
delete: async function del() {
|
||||||
const {
|
const {
|
||||||
token,
|
token,
|
||||||
mobileToken,
|
mobileToken,
|
||||||
|
|
@ -343,11 +394,11 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
const failed = checkAuthToken(token);
|
const failed = checkAuthToken(token);
|
||||||
if (failed) return failed;
|
if (failed) return failed;
|
||||||
|
|
||||||
const user = Meteor.users.findOne({ fireBaseToken: mobileToken });
|
const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken });
|
||||||
if (!user) return failMsg('User not found');
|
if (!user) return failMsg('User not found');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Subscriptions.remove({ owner: user._id, _id: new Meteor.Collection.ObjectID(subsId) });
|
await Subscriptions.removeAsync({ owner: user._id, _id: new Meteor.Collection.ObjectID(subsId) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
|
|
@ -356,52 +407,8 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
apiV1.addRoute('mobile/subscriptions/all/:token/:mobileToken', { authRequired: false }, {
|
|
||||||
get: function get() {
|
|
||||||
const { token, mobileToken } = this.urlParams;
|
|
||||||
try {
|
|
||||||
check(token, String);
|
|
||||||
check(mobileToken, String);
|
|
||||||
} catch (e) {
|
|
||||||
return defaultFailParams(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const failed = checkAuthToken(token);
|
|
||||||
if (failed) return failed;
|
|
||||||
|
|
||||||
const user = Meteor.users.findOne({ fireBaseToken: mobileToken });
|
|
||||||
if (!user) return failMsg('User not found');
|
|
||||||
|
|
||||||
const result = Subscriptions.find({ owner: user._id });
|
|
||||||
|
|
||||||
return jsend.success({ subscriptions: result.fetch(), count: result.count() });
|
|
||||||
},
|
|
||||||
delete: function delAll() {
|
|
||||||
const { token, mobileToken } = this.urlParams;
|
|
||||||
try {
|
|
||||||
check(token, String);
|
|
||||||
check(mobileToken, String);
|
|
||||||
} catch (e) {
|
|
||||||
return defaultFailParams(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const failed = checkAuthToken(token);
|
|
||||||
if (failed) return failed('Auth api check failed');
|
|
||||||
|
|
||||||
if (Meteor.users.find({ fireBaseToken: mobileToken }).count() !== 1) return fail;
|
|
||||||
|
|
||||||
const user = Meteor.users.findOne({ fireBaseToken: mobileToken });
|
|
||||||
if (!user) return failMsg('User not found');
|
|
||||||
|
|
||||||
const toRemove = Subscriptions.find({ owner: user._id }).count();
|
|
||||||
Subscriptions.remove({ owner: user._id });
|
|
||||||
|
|
||||||
return jsend.success({ count: toRemove });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
apiV1.addRoute('status/subs-public-union/:token', { authRequired: false }, {
|
apiV1.addRoute('status/subs-public-union/:token', { authRequired: false }, {
|
||||||
get: function get() {
|
get: async function get() {
|
||||||
const { token } = this.urlParams;
|
const { token } = this.urlParams;
|
||||||
try {
|
try {
|
||||||
check(token, String);
|
check(token, String);
|
||||||
|
|
@ -412,15 +419,15 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
const failed = checkAuthToken(token);
|
const failed = checkAuthToken(token);
|
||||||
if (failed) return failed;
|
if (failed) return failed;
|
||||||
|
|
||||||
const currentUnion = SiteSettings.findOne({ name: 'subs-public-union' });
|
const currentUnion = await SiteSettings.findOneAsync({ name: 'subs-public-union' });
|
||||||
const userSubsBounds = SiteSettings.findOne({ name: 'subs-public-union-bounds' });
|
const userSubsBounds = await SiteSettings.findOneAsync({ name: 'subs-public-union-bounds' });
|
||||||
|
|
||||||
return jsend.success({ union: currentUnion, bounds: userSubsBounds });
|
return jsend.success({ union: currentUnion, bounds: userSubsBounds });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
apiV1.addRoute('mobile/falsepositive', { authRequired: false }, {
|
apiV1.addRoute('mobile/falsepositive', { authRequired: false }, {
|
||||||
post: function post() {
|
post: async function post() {
|
||||||
const {
|
const {
|
||||||
token,
|
token,
|
||||||
mobileToken,
|
mobileToken,
|
||||||
|
|
@ -440,15 +447,15 @@ if (!Meteor.settings.private.internalApiToken) {
|
||||||
const failed = checkAuthToken(token);
|
const failed = checkAuthToken(token);
|
||||||
if (failed) return failed;
|
if (failed) return failed;
|
||||||
|
|
||||||
const user = Meteor.users.findOne({ fireBaseToken: mobileToken });
|
const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken });
|
||||||
if (!user) return failMsg('User not found');
|
if (!user) return failMsg('User not found');
|
||||||
|
|
||||||
console.log(`Marking hash fire as '${type}' false positive: ${sealed}`);
|
console.log(`Marking hash fire as '${type}' false positive: ${sealed}`);
|
||||||
const fireC = fireFromHash(sealed, { id: sealed });
|
const fireC = await fireFromHash(sealed, { id: sealed });
|
||||||
if (fireC && typeof fireC === 'object') {
|
if (fireC && typeof fireC === 'object') {
|
||||||
const fire = fireC.fetch()[0];
|
const fire = (await fireC.fetchAsync())[0];
|
||||||
console.log(`Marking fire as false positive: ${fire._id}`);
|
console.log(`Marking fire as false positive: ${fire._id}`);
|
||||||
const result = upsertFalsePositive(type, user._id, fire);
|
const result = await upsertFalsePositive(type, user._id, fire);
|
||||||
return jsend.success({ upsert: result });
|
return jsend.success({ upsert: result });
|
||||||
}
|
}
|
||||||
return failMsg('Cannot mark fire as false positive');
|
return failMsg('Cannot mark fire as false positive');
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ function geo(doc) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscriptionsInsert(doc, userId, type) {
|
export async function subscriptionsInsert(doc, userId, type) {
|
||||||
check(doc, {
|
check(doc, {
|
||||||
_id: Match.Maybe(Meteor.Collection.ObjectID),
|
_id: Match.Maybe(Meteor.Collection.ObjectID),
|
||||||
location: Match.ObjectIncluding({ lat: Number, lon: Number }),
|
location: Match.ObjectIncluding({ lat: Number, lon: Number }),
|
||||||
|
|
@ -23,23 +23,23 @@ export function subscriptionsInsert(doc, userId, type) {
|
||||||
...doc
|
...doc
|
||||||
};
|
};
|
||||||
// console.log(newDoc);
|
// console.log(newDoc);
|
||||||
const already = Subscriptions.findOne(newDoc);
|
const already = await Subscriptions.findOneAsync(newDoc);
|
||||||
if (already) {
|
if (already) {
|
||||||
throw new Meteor.Error('on-already-subscribed', 'The user is already subscribed to this area');
|
throw new Meteor.Error('on-already-subscribed', 'The user is already subscribed to this area');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return Subscriptions.insert(newDoc);
|
return await Subscriptions.insertAsync(newDoc);
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
// console.error(exception);
|
// console.error(exception);
|
||||||
throw new Meteor.Error('500', exception);
|
throw new Meteor.Error('500', exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscriptionsRemove(subscriptionId) {
|
export async function subscriptionsRemove(subscriptionId) {
|
||||||
check(subscriptionId, Meteor.Collection.ObjectID);
|
check(subscriptionId, Meteor.Collection.ObjectID);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return Subscriptions.remove(subscriptionId);
|
return await Subscriptions.removeAsync(subscriptionId);
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
console.error(exception);
|
console.error(exception);
|
||||||
throw new Meteor.Error('500', exception);
|
throw new Meteor.Error('500', exception);
|
||||||
|
|
@ -54,7 +54,7 @@ Meteor.methods({
|
||||||
});
|
});
|
||||||
return subscriptionsInsert(doc, this.userId, 'web');
|
return subscriptionsInsert(doc, this.userId, 'web');
|
||||||
},
|
},
|
||||||
'subscriptions.update': function subscriptionsUpdate(doc) {
|
'subscriptions.update': async function subscriptionsUpdate(doc) {
|
||||||
check(doc, {
|
check(doc, {
|
||||||
_id: Meteor.Collection.ObjectID,
|
_id: Meteor.Collection.ObjectID,
|
||||||
location: Match.ObjectIncluding({ lat: Number, lon: Number }),
|
location: Match.ObjectIncluding({ lat: Number, lon: Number }),
|
||||||
|
|
@ -65,7 +65,7 @@ Meteor.methods({
|
||||||
const dup = doc;
|
const dup = doc;
|
||||||
const subscriptionId = doc._id;
|
const subscriptionId = doc._id;
|
||||||
dup.geo = geo(doc);
|
dup.geo = geo(doc);
|
||||||
Subscriptions.update(subscriptionId, { $set: dup });
|
await Subscriptions.updateAsync(subscriptionId, { $set: dup });
|
||||||
return subscriptionId; // Return _id so we can redirect to subscription after update.
|
return subscriptionId; // Return _id so we can redirect to subscription after update.
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
throw new Meteor.Error('500', exception);
|
throw new Meteor.Error('500', exception);
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import { Meteor } from 'meteor/meteor';
|
||||||
|
|
||||||
let action;
|
let action;
|
||||||
|
|
||||||
const updateUser = (userId, { emailAddress, profile }) => {
|
const updateUser = async (userId, { emailAddress, profile }) => {
|
||||||
try {
|
try {
|
||||||
Meteor.users.update(userId, {
|
await Meteor.users.updateAsync(userId, {
|
||||||
$set: {
|
$set: {
|
||||||
'emails.0.address': emailAddress,
|
'emails.0.address': emailAddress,
|
||||||
profile,
|
profile,
|
||||||
|
|
@ -17,10 +17,10 @@ const updateUser = (userId, { emailAddress, profile }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const editProfile = ({ userId, profile }, promise) => {
|
const editProfile = async ({ userId, profile }, promise) => {
|
||||||
try {
|
try {
|
||||||
action = promise;
|
action = promise;
|
||||||
updateUser(userId, profile);
|
await updateUser(userId, profile);
|
||||||
action.resolve();
|
action.resolve();
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
action.reject(`[editProfile.handler] ${exception}`);
|
action.reject(`[editProfile.handler] ${exception}`);
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,9 @@ Meteor.methods({
|
||||||
throw new Meteor.Error('500', exception);
|
throw new Meteor.Error('500', exception);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
'users.setLang': function userLang(lang) {
|
'users.setLang': async function userLang(lang) {
|
||||||
check(lang, validLangCode);
|
check(lang, validLangCode);
|
||||||
Meteor.users.update({ _id: this.userId }, { $set: { lang } });
|
await Meteor.users.updateAsync({ _id: this.userId }, { $set: { lang } });
|
||||||
},
|
},
|
||||||
'auth.getHash': async function getHash() {
|
'auth.getHash': async function getHash() {
|
||||||
const token = Accounts._generateStampedLoginToken();
|
const token = Accounts._generateStampedLoginToken();
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,15 @@ export default ({ methods, limit, timeRange }) => {
|
||||||
}, limit, timeRange);
|
}, limit, timeRange);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Same idea for subscriptions. DDPRateLimiter matches subscribe calls only when
|
||||||
|
// the rule pins `type: 'subscription'`, so this is a separate helper.
|
||||||
|
export const rateLimitSubscriptions = ({ subscriptions, limit, timeRange }) => {
|
||||||
|
if (Meteor.isServer) {
|
||||||
|
DDPRateLimiter.addRule({
|
||||||
|
type: 'subscription',
|
||||||
|
name(name) { return subscriptions.indexOf(name) > -1; },
|
||||||
|
connectionId() { return true; },
|
||||||
|
}, limit, timeRange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
/* eslint-disable import/no-absolute-path */
|
|
||||||
|
|
||||||
import { Meteor } from 'meteor/meteor';
|
|
||||||
import Notifications from '/imports/api/Notifications/Notifications';
|
|
||||||
import i18n from 'i18next';
|
|
||||||
import moment from 'moment';
|
|
||||||
import { dateLongFormat } from '/imports/api/Common/dates';
|
|
||||||
// import sendMail from '/imports/startup/server/email';
|
|
||||||
import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email';
|
|
||||||
import { isMailServerMaster } from '/imports/startup/server/email';
|
|
||||||
// import { hr } from '/imports/startup/server/email';
|
|
||||||
import getEmailOf from '/imports/modules/get-email-of-user';
|
|
||||||
import image from 'google-maps-image-api-url';
|
|
||||||
import gcm from 'node-gcm';
|
|
||||||
import { trim } from '/imports/ui/components/NotificationsObserver/util.js';
|
|
||||||
import ravenLogger from '/imports/startup/server/ravenLogger';
|
|
||||||
|
|
||||||
let validFcmSender = true;
|
|
||||||
|
|
||||||
if (!(Meteor.settings.private.fcmApiToken && Meteor.settings.private.fcmApiToken.length > 0)) {
|
|
||||||
console.warn('Missing settings.private.fcmApiToken key, mobile notifications will not work');
|
|
||||||
validFcmSender = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://www.npmjs.com/package/google-maps-image-api-url
|
|
||||||
// https://stackoverflow.com/questions/24355007/is-there-no-way-to-embed-a-google-map-into-an-html-email
|
|
||||||
// https://developers.google.com/maps/documentation/static-maps/intro
|
|
||||||
function imgUrl(lat, lng) {
|
|
||||||
return image({
|
|
||||||
key: Meteor.settings.gmaps.key,
|
|
||||||
type: 'staticmap',
|
|
||||||
center: `${lat},${lng}`,
|
|
||||||
size: '640x480',
|
|
||||||
zoom: 16,
|
|
||||||
maptype: 'hybrid',
|
|
||||||
language: 'es',
|
|
||||||
markers: `icon: ${Meteor.settings.private.fireIconUrl}|${lat},${lng}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
function imgEl(lat, lng) {
|
|
||||||
return `<img src="${imgUrl(lat, lng)}" width="640" height="480"/>`;
|
|
||||||
} */
|
|
||||||
|
|
||||||
// Cutover flag (fase 1a): channels listed in
|
|
||||||
// Meteor.settings.private.notifDisabledChannels (e.g. ['mobile','web']) are
|
|
||||||
// handled by the tcef-notifications microservice, so the old observer/cron must
|
|
||||||
// NOT send them. Mutual exclusion per channel — reversible by editing settings,
|
|
||||||
// no code deploy needed. ES5-compatible on purpose (Meteor 1.6 / Node 8).
|
|
||||||
const notifDisabledChannels = (Meteor.settings.private && Meteor.settings.private.notifDisabledChannels) || [];
|
|
||||||
|
|
||||||
const processNotif = (notif) => {
|
|
||||||
if (notifDisabledChannels.indexOf(notif.type) !== -1) {
|
|
||||||
// This channel was migrated to tcef-notifications; do nothing here.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isMailServerMaster && validFcmSender && notif.type === 'mobile' && notif.notified !== true) {
|
|
||||||
const fcmSender = new gcm.Sender(Meteor.settings.private.fcmApiToken);
|
|
||||||
const user = Meteor.users.findOne({ _id: notif.userId });
|
|
||||||
moment.locale(user.lang);
|
|
||||||
// duplicate code below
|
|
||||||
const body = `${trim(notif.content)}`;
|
|
||||||
|
|
||||||
// https://firebase.google.com/docs/cloud-messaging/concept-options
|
|
||||||
const msg = new gcm.Message();
|
|
||||||
|
|
||||||
msg.addNotification({
|
|
||||||
title: i18n.t('Alerta de fuego'),
|
|
||||||
body,
|
|
||||||
click_action: 'FLUTTER_NOTIFICATION_CLICK',
|
|
||||||
tag: notif._id, // prevent duplication of fire notifications
|
|
||||||
sound: 'default', // Indicates sound to be played. Supports only default currently.
|
|
||||||
icon: 'launch_image' // 'ic_launcher'
|
|
||||||
});
|
|
||||||
|
|
||||||
msg.addData('id', notif._id._str);
|
|
||||||
msg.addData('description', body);
|
|
||||||
msg.addData('lat', notif.geo.coordinates[1]);
|
|
||||||
msg.addData('lon', notif.geo.coordinates[0]);
|
|
||||||
msg.addData('when', notif.when);
|
|
||||||
msg.addData('subsId', notif.subsId._str);
|
|
||||||
msg.addData('sealed', notif.sealed);
|
|
||||||
|
|
||||||
const registrationTokens = [];
|
|
||||||
if (!user.fireBaseToken) {
|
|
||||||
console.warn('This mobile user doesn\'t have a firebase registration token');
|
|
||||||
} else {
|
|
||||||
registrationTokens.push(user.fireBaseToken);
|
|
||||||
// FIXME: better join users
|
|
||||||
if (validFcmSender) {
|
|
||||||
fcmSender.send(msg, { registrationTokens }, Meteor.bindEnvironment(function processResult(err, response) {
|
|
||||||
if (err) {
|
|
||||||
console.error(`FCM error: ${err}`);
|
|
||||||
// FIXME send to sentry
|
|
||||||
ravenLogger.log(err);
|
|
||||||
} else {
|
|
||||||
// console.log(`FCM response: ${response}`);
|
|
||||||
Notifications.update(notif._id, { $set: { notified: true, notifiedAt: new Date() } });
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isMailServerMaster && notif.type === 'web' && notif.emailNotified !== true) {
|
|
||||||
const user = Meteor.users.findOne({ _id: notif.userId });
|
|
||||||
const { firstName, emailAddress } = getEmailOf(user);
|
|
||||||
|
|
||||||
if (emailAddress) {
|
|
||||||
const img = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]);
|
|
||||||
// const url = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]);
|
|
||||||
const fireUrl = `${Meteor.absoluteUrl('fire/')}${notif.sealed}`;
|
|
||||||
// const fireHtmlUrl = `<a href="${fireUrl}">${i18n.t('Más información sobre este fuego')}</a>`;
|
|
||||||
// TODO get _id of fire
|
|
||||||
// const fireTextUrl = `${i18n.t('Más información sobre este fuego')}:\n${fireUrl}`;
|
|
||||||
// FIXME use our map as url and static map as img
|
|
||||||
moment.locale(user.lang);
|
|
||||||
// moment user tz ?
|
|
||||||
const message = `${trim(notif.content)} (${i18n.t('fireDetectedAt', { when: dateLongFormat(notif.when) })}).`;
|
|
||||||
|
|
||||||
// TODO Comunes Address
|
|
||||||
|
|
||||||
const emailOpts = {
|
|
||||||
to: emailAddress,
|
|
||||||
// userName: firstName,
|
|
||||||
// sendAt: new Date(),
|
|
||||||
subject: subjectTruncate.apply(message),
|
|
||||||
// text: `${message}\n\n${fireTextUrl}\n\n`,
|
|
||||||
// template: '<body><h2>{{appName}}</h2>{{{html}}}</body>',
|
|
||||||
lang: user.lang,
|
|
||||||
template: 'new-fire',
|
|
||||||
templateVars: {
|
|
||||||
applicationName: i18n.t('AppName'),
|
|
||||||
firstName,
|
|
||||||
message,
|
|
||||||
fireUrl,
|
|
||||||
img,
|
|
||||||
subsUrl: Meteor.absoluteUrl('subscriptions')
|
|
||||||
}
|
|
||||||
};
|
|
||||||
sendEmail(emailOpts).catch((error) => {
|
|
||||||
throw new Meteor.Error('500', `${error}`);
|
|
||||||
});
|
|
||||||
// sendMail(emailOpts, true);
|
|
||||||
Notifications.update(notif._id, { $set: { emailNotified: true, emailNotifiedAt: new Date() } });
|
|
||||||
} else {
|
|
||||||
// Not email or not verified -> remove notif so we don't retry to send it
|
|
||||||
Notifications.remove({ _id: notif._id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default processNotif;
|
|
||||||
|
|
@ -6,6 +6,13 @@ import GoogleMapsLoader from 'google-maps';
|
||||||
class GkeysC {
|
class GkeysC {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.gmapkey = new ReactiveVar(null);
|
this.gmapkey = new ReactiveVar(null);
|
||||||
|
// Separate from the key itself: a deployment with no gmaps key configured
|
||||||
|
// still loads the script and still has to render. Waiting on the key value
|
||||||
|
// meant that with an empty key every component mounted after the script had
|
||||||
|
// loaded (i.e. any client-side navigation) sat forever on "Waiting for the
|
||||||
|
// gkey" and rendered an empty <div> — /subscriptions/new was blank, with no
|
||||||
|
// error anywhere.
|
||||||
|
this.loaded = new ReactiveVar(false);
|
||||||
const self = this;
|
const self = this;
|
||||||
this.callbacks = [];
|
this.callbacks = [];
|
||||||
Meteor.startup(() => {
|
Meteor.startup(() => {
|
||||||
|
|
@ -13,8 +20,14 @@ class GkeysC {
|
||||||
// console.log(google.maps);
|
// console.log(google.maps);
|
||||||
GoogleMapsLoader.KEY = key;
|
GoogleMapsLoader.KEY = key;
|
||||||
GoogleMapsLoader.LIBRARIES = ['places'];
|
GoogleMapsLoader.LIBRARIES = ['places'];
|
||||||
|
// The google-maps npm package (v3.2.1) builds the script URL without
|
||||||
|
// `loading=async`, which Google now warns about. It exposes no hook for
|
||||||
|
// extra params, so wrap createUrl to append it (no node_modules edit).
|
||||||
|
const origCreateUrl = GoogleMapsLoader.createUrl;
|
||||||
|
GoogleMapsLoader.createUrl = () => `${origCreateUrl()}&loading=async`;
|
||||||
GoogleMapsLoader.load(() => {
|
GoogleMapsLoader.load(() => {
|
||||||
self.gmapkey.set(key);
|
self.gmapkey.set(key);
|
||||||
|
self.loaded.set(true);
|
||||||
console.log('GMaps script just loaded');
|
console.log('GMaps script just loaded');
|
||||||
self.doCallbacks(key);
|
self.doCallbacks(key);
|
||||||
});
|
});
|
||||||
|
|
@ -32,14 +45,12 @@ class GkeysC {
|
||||||
load(callback) {
|
load(callback) {
|
||||||
this.callbacks.push(callback);
|
this.callbacks.push(callback);
|
||||||
Tracker.autorun((computation) => {
|
Tracker.autorun((computation) => {
|
||||||
const key = this.gmapkey.get();
|
if (this.loaded.get()) {
|
||||||
if (key) {
|
// already loaded; the key may legitimately be empty
|
||||||
// already loaded
|
this.doCallbacks(this.gmapkey.get());
|
||||||
// console.log('GMaps already loaded');
|
|
||||||
this.doCallbacks(key);
|
|
||||||
computation.stop();
|
computation.stop();
|
||||||
} else {
|
} else {
|
||||||
console.log('Waiting for the gkey');
|
console.log('Waiting for the gmaps script');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
/* global Comments */
|
|
||||||
/* eslint-disable import/no-absolute-path */
|
|
||||||
|
|
||||||
import i18n from '/imports/startup/client/i18n';
|
|
||||||
import '/imports/startup/common/comments';
|
|
||||||
|
|
||||||
import './comments.scss';
|
|
||||||
|
|
||||||
i18n.init((err, t) => {
|
|
||||||
Comments.ui.setContent({
|
|
||||||
title: ' ', // i18n.t('Comentarios'),
|
|
||||||
save: t('Guardar'),
|
|
||||||
reply: t('Responder'),
|
|
||||||
edit: t('Editar'),
|
|
||||||
remove: t('Borrar'),
|
|
||||||
'placeholder-textarea': t('Añadir un comentario'),
|
|
||||||
'add-button-reply': t('Añadir una respuesta'),
|
|
||||||
'add-button': t('Añadir comentario'),
|
|
||||||
'you-need-to-login': t('Necesitas iniciar sesión para'),
|
|
||||||
'add comments': t('añadir comentarios'),
|
|
||||||
'like comments': t('puntuar comentarios'),
|
|
||||||
'rate comments': t('puntuar comentarios'),
|
|
||||||
'add replies': t('responder'),
|
|
||||||
'load-more': t('Más comentarios')
|
|
||||||
});
|
|
||||||
|
|
||||||
/* This in client side */
|
|
||||||
Comments.ui.config({
|
|
||||||
limit: 20, // default 10
|
|
||||||
loadMoreCount: 20, // default 20
|
|
||||||
/* generateAvatar: function genAvatar(user, isAnonymous) {
|
|
||||||
if (isAnonymous) {
|
|
||||||
return i18n.t('Anónimo');
|
|
||||||
}
|
|
||||||
return user.profile && user.profile.name && user.profile.name.first ? user.profile.name.first : null;
|
|
||||||
}, */
|
|
||||||
template: 'bootstrap', // default 'semantic-ui'
|
|
||||||
// default 'http://s3.amazonaws.com/37assets/svn/765-default-avatar.png'
|
|
||||||
defaultAvatar: '/default-avatar.png',
|
|
||||||
markdown: true
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
.comments-section {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments-box {
|
|
||||||
max-width: inherit;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.comment-content {
|
|
||||||
/* white-space: pre-line; */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* remove comment btn danger style */
|
|
||||||
|
|
||||||
div.media-body.comment> div> div> div.btn.btn-danger.remove-action {
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid rgb(204, 204, 204);
|
|
||||||
color: rgb(51, 51, 51);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* text-area height */
|
|
||||||
.form-control .create-comment {
|
|
||||||
height: 9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.img-avatar {
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
/* global CookieConsent Intl */
|
/* global Intl */
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
import { Tracker } from 'meteor/tracker';
|
import { Tracker } from 'meteor/tracker';
|
||||||
import { ReactiveVar } from 'meteor/reactive-var';
|
import { ReactiveVar } from 'meteor/reactive-var';
|
||||||
import backend from 'i18next-xhr-backend';
|
import backend from 'i18next-http-backend';
|
||||||
import LngDetector from 'i18next-browser-languagedetector';
|
import LngDetector from 'i18next-browser-languagedetector';
|
||||||
import Cache from 'i18next-localstorage-cache';
|
|
||||||
import { T9n } from 'meteor-accounts-t9n';
|
import { T9n } from 'meteor-accounts-t9n';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import 'moment/locale/es';
|
import 'moment/locale/es';
|
||||||
|
|
@ -31,21 +30,9 @@ const detectorOptions = {
|
||||||
|
|
||||||
export const i18nReady = new ReactiveVar(false);
|
export const i18nReady = new ReactiveVar(false);
|
||||||
|
|
||||||
const cacheOptions = {
|
|
||||||
// turn on or off
|
|
||||||
enabled: false,
|
|
||||||
// prefix for stored languages
|
|
||||||
prefix: 'i18next_res_',
|
|
||||||
// expiration
|
|
||||||
expirationTime: 7 * 24 * 60 * 60 * 1000,
|
|
||||||
// language versions
|
|
||||||
versions: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
i18nOpts.cache = cacheOptions;
|
|
||||||
i18nOpts.detection = detectorOptions;
|
i18nOpts.detection = detectorOptions;
|
||||||
i18nOpts.react = {
|
i18nOpts.react = {
|
||||||
wait: true,
|
useSuspense: false, // was `wait: true` pre-react-i18next-10
|
||||||
defaultTransParent: 'span'
|
defaultTransParent: 'span'
|
||||||
// https://react.i18next.com/components/i18next-instance.ht
|
// https://react.i18next.com/components/i18next-instance.ht
|
||||||
/* bindI18n: 'languageChanged loaded',
|
/* bindI18n: 'languageChanged loaded',
|
||||||
|
|
@ -55,13 +42,17 @@ i18nOpts.react = {
|
||||||
|
|
||||||
const sendMissing = true; // Meteor.isDevelopment;
|
const sendMissing = true; // Meteor.isDevelopment;
|
||||||
if (sendMissing && Meteor.isDevelopment) {
|
if (sendMissing && Meteor.isDevelopment) {
|
||||||
i18nOpts.sendMissing = true;
|
i18nOpts.saveMissing = true;
|
||||||
i18nOpts.missingKeyHandler = function miss(lng, ns, key, defaultValue) {
|
// Modern signature is (lngs, ns, key, fallbackValue, ...); key/fallback keep
|
||||||
|
// the same positions, so we still only need those two.
|
||||||
|
i18nOpts.missingKeyHandler = function miss(lngs, ns, key, defaultValue) {
|
||||||
Meteor.call('utility.saveMissingI18n', key, defaultValue);
|
Meteor.call('utility.saveMissingI18n', key, defaultValue);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setT9(lang) {
|
function setT9(lang) {
|
||||||
|
// meteor-accounts-t9n has no Galician build, so use Spanish for gl account
|
||||||
|
// error messages (the closest shipped language). See common/i18n.js.
|
||||||
if (lang === 'gl') {
|
if (lang === 'gl') {
|
||||||
T9n.setLanguage('es');
|
T9n.setLanguage('es');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -71,7 +62,6 @@ function setT9(lang) {
|
||||||
|
|
||||||
i18n.use(backend)
|
i18n.use(backend)
|
||||||
.use(LngDetector)
|
.use(LngDetector)
|
||||||
.use(Cache)
|
|
||||||
.init(i18nOpts, (err, t) => {
|
.init(i18nOpts, (err, t) => {
|
||||||
// initialized and ready to
|
// initialized and ready to
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
@ -89,23 +79,9 @@ i18n.use(backend)
|
||||||
|
|
||||||
i18nReady.set(true);
|
i18nReady.set(true);
|
||||||
|
|
||||||
// cookies eu consent
|
// Cookie consent banner: selaias:cookie-consent (Blaze, dead on Meteor 3)
|
||||||
const cookiesOpt = {
|
// replaced by the React component imports/ui/components/CookieConsent,
|
||||||
cookieTitle: t('Uso de Cookies'),
|
// which reuses the same i18n keys.
|
||||||
cookieMessage: t('Utilizamos cookies para asegurar un mejor uso de nuestra web. Si continúas navegando, consideramos que aceptas su uso'),
|
|
||||||
/* cookieMessage: t('Uso de Cookies'),
|
|
||||||
cookieMessageImply: t('Utilizamos cookies para asegurar un mejor uso de nuestra web. Si continúas navegando, consideramos que aceptas su uso'), */
|
|
||||||
showLink: false,
|
|
||||||
position: 'bottom',
|
|
||||||
linkText: 'Lee más',
|
|
||||||
linkRouteName: '/privacy',
|
|
||||||
acceptButtonText: t('Aceptar'),
|
|
||||||
html: false,
|
|
||||||
expirationInDays: 70,
|
|
||||||
forceShow: false
|
|
||||||
};
|
|
||||||
|
|
||||||
CookieConsent.init(cookiesOpt);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Meteor.subscribe('userData'); // lang is there
|
Meteor.subscribe('userData'); // lang is there
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
/* global */
|
/* global */
|
||||||
|
// Bootstrap 5 CSS (npm) replaces the old `alexwine:bootstrap-4` meteor package.
|
||||||
|
// Import it first so the app + component stylesheets below (and react-bootstrap,
|
||||||
|
// which targets BS5) override it.
|
||||||
|
import 'bootstrap/dist/css/bootstrap.css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render } from 'react-dom';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
import '/imports/startup/client/modernizr';
|
import '/imports/startup/client/modernizr';
|
||||||
import App from '../../ui/layouts/App/App';
|
import App from '../../ui/layouts/App/App';
|
||||||
|
|
@ -15,5 +20,9 @@ else {
|
||||||
Modernizr.on('webp', (result) => {
|
Modernizr.on('webp', (result) => {
|
||||||
if (Meteor.isDevelopment) console.log(`webp ${result ? '' : 'not '}supported`);
|
if (Meteor.isDevelopment) console.log(`webp ${result ? '' : 'not '}supported`);
|
||||||
});
|
});
|
||||||
Meteor.startup(() => render(<App />, document.getElementById('react-root')));
|
Meteor.startup(() => createRoot(document.getElementById('react-root')).render(
|
||||||
|
<HelmetProvider>
|
||||||
|
<App />
|
||||||
|
</HelmetProvider>
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,37 @@
|
||||||
import RavenLogger from 'meteor/flowkey:raven';
|
// Error reporter (client). Was flowkey:raven; replaced by @sentry/browser
|
||||||
|
// behind the same `.log()` facade (see the server counterpart). Enabled only
|
||||||
|
// when Meteor.settings.public.sentryPublicDSN is set; otherwise a plain console
|
||||||
|
// logger. ErrorBoundary calls `.log(error, info)` where info is React's
|
||||||
|
// { componentStack } — passed to Sentry as extra context.
|
||||||
|
import * as Sentry from '@sentry/browser';
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
|
||||||
const ravenOptions = {};
|
const dsn = Meteor.settings.public.sentryPublicDSN;
|
||||||
const publicDSN = Meteor.settings.public.sentryPublicDSN;
|
const enabled = !!dsn;
|
||||||
const enabled = !!publicDSN;
|
|
||||||
|
|
||||||
const ravenLogger = enabled ? new RavenLogger({
|
if (enabled) {
|
||||||
publicDSN, // will be used on the client
|
Sentry.init({
|
||||||
shouldCatchConsoleError: true, // default
|
dsn,
|
||||||
trackUser: true // default
|
environment: Meteor.isDevelopment ? 'development' : 'production',
|
||||||
}, ravenOptions) : { log: (error) => { console.log(error); } };
|
tracesSampleRate: 0,
|
||||||
|
// Send events same-origin to our own server, which forwards them to
|
||||||
|
// GlitchTip. GlitchTip is fronted by Cloudflare, whose bot protection
|
||||||
|
// returns 503 to the browser's cross-site ingest beacons (server-side
|
||||||
|
// requests pass fine). The tunnel sidesteps that entirely (and ad-blockers).
|
||||||
|
// See imports/startup/server/sentryTunnel.js.
|
||||||
|
tunnel: '/sentry-tunnel'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`ravenLogger ${enabled ? 'enabled' : 'disabled'}`);
|
const ravenLogger = {
|
||||||
|
log(error, info) {
|
||||||
|
console.log(error);
|
||||||
|
if (enabled) {
|
||||||
|
Sentry.captureException(error, info ? { extra: { info } } : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`sentryLogger (client) ${enabled ? 'enabled' : 'disabled'}`);
|
||||||
|
|
||||||
export default ravenLogger;
|
export default ravenLogger;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
/* global Comments */
|
|
||||||
|
|
||||||
// Client and Server
|
|
||||||
Comments.config({
|
|
||||||
rating: 'likes-and-dislikes',
|
|
||||||
allowAnonymous: () => false,
|
|
||||||
allowReplies: () => false, // disabled right now: I don't know hot to get referenceId of replies
|
|
||||||
anonymousSalt: 'klasddl3lala0l3lasdlas0ol3lasdlao3lasdoaslaldal3lasdclasdlal3lasdladlaq',
|
|
||||||
publishUserFields: {
|
|
||||||
profile: 1
|
|
||||||
},
|
|
||||||
mediaAnalyzers: [
|
|
||||||
Comments.analyzers.image,
|
|
||||||
Comments.analyzers.youtube
|
|
||||||
],
|
|
||||||
generateUsername: function genUser(user) {
|
|
||||||
// console.log(JSON.stringify(user));
|
|
||||||
// FIXME
|
|
||||||
return user.profile && user.profile.name && user.profile.name.first ? user.profile.name.first : '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -3,7 +3,9 @@ import moment from 'moment';
|
||||||
// Load the js langs
|
// Load the js langs
|
||||||
import es from 'meteor-accounts-t9n/build/es';
|
import es from 'meteor-accounts-t9n/build/es';
|
||||||
import en from 'meteor-accounts-t9n/build/en';
|
import en from 'meteor-accounts-t9n/build/en';
|
||||||
// TODO ask for translation of this
|
// meteor-accounts-t9n (2.6.0) ships no Galician build (build/gl.js), so account
|
||||||
|
// error messages fall back to Spanish for gl — see setT9() in client/i18n.js.
|
||||||
|
// Re-enable this import if the upstream package ever adds a gl translation.
|
||||||
// import gl from 'meteor-accounts-t9n/build/gl';
|
// import gl from 'meteor-accounts-t9n/build/gl';
|
||||||
|
|
||||||
const backOpts = {
|
const backOpts = {
|
||||||
|
|
@ -40,13 +42,17 @@ const i18nOpts = {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
whitelist: ['es', 'en', 'gl'], // allowed languages
|
supportedLngs: ['es', 'en', 'gl'], // allowed languages (was `whitelist` pre-i18next-21)
|
||||||
load: 'languageOnly', // 'es' o 'en', previously: 'all', // es-ES -> es, en-US -> en
|
load: 'languageOnly', // 'es' o 'en', previously: 'all', // es-ES -> es, en-US -> en
|
||||||
debug: shouldDebug,
|
debug: shouldDebug,
|
||||||
ns: 'common',
|
ns: 'common',
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
saveMissing: shouldDebug, // if true seems it's fails to getResourceBundle
|
saveMissing: shouldDebug, // if true seems it's fails to getResourceBundle
|
||||||
saveMissingTo: 'es',
|
saveMissingTo: 'es',
|
||||||
|
// Our locale JSON uses natural-language (Spanish) keys and the v3 plural
|
||||||
|
// layout (`key_plural`), so keep i18next on the v3 JSON format instead of
|
||||||
|
// the v4 suffix scheme (`key_one`/`key_other`).
|
||||||
|
compatibilityJSON: 'v3',
|
||||||
keySeparator: 'ß',
|
keySeparator: 'ß',
|
||||||
nsSeparator: 'ð',
|
nsSeparator: 'ð',
|
||||||
pluralSeparator: 'đ'
|
pluralSeparator: 'đ'
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,16 @@ function isPrivateIP(ip) {
|
||||||
const dbpath = '/usr/local/share/maxmind-geolite2/GeoLite2-City.mmdb';
|
const dbpath = '/usr/local/share/maxmind-geolite2/GeoLite2-City.mmdb';
|
||||||
// const dbpath = `${process.env.PWD}/private/GeoLite2-City.mmdb`;
|
// const dbpath = `${process.env.PWD}/private/GeoLite2-City.mmdb`;
|
||||||
|
|
||||||
if (!fs.existsSync(dbpath)) {
|
let IPGeocoder;
|
||||||
|
if (fs.existsSync(dbpath)) {
|
||||||
|
IPGeocoder = maxmind.openSync(dbpath);
|
||||||
|
} else {
|
||||||
|
// In production the DB is provisioned via cron; in local dev it may be
|
||||||
|
// absent. Degrade gracefully instead of crashing at boot: localize() below
|
||||||
|
// already falls back to a default location when no geo data is available.
|
||||||
console.error(`Maxmind db not found ${dbpath}, download via cron with https://www.npmjs.com/package/maxmind-geolite2-mirror`);
|
console.error(`Maxmind db not found ${dbpath}, download via cron with https://www.npmjs.com/package/maxmind-geolite2-mirror`);
|
||||||
|
IPGeocoder = { get() { return null; } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const IPGeocoder = maxmind.openSync(dbpath);
|
|
||||||
export default IPGeocoder;
|
export default IPGeocoder;
|
||||||
|
|
||||||
// Warning: Meteor cannot access to this.connection with arrow functions
|
// Warning: Meteor cannot access to this.connection with arrow functions
|
||||||
|
|
@ -47,7 +52,7 @@ export function localize() {
|
||||||
// http://dev.maxmind.com/geoip/geoip2/geolite2/
|
// http://dev.maxmind.com/geoip/geoip2/geolite2/
|
||||||
const geo = IPGeocoder.get(clientIP);
|
const geo = IPGeocoder.get(clientIP);
|
||||||
// console.warn(geo);
|
// console.warn(geo);
|
||||||
if (geo.location && geo.location.latitude && geo.location.longitude) {
|
if (geo && geo.location && geo.location.latitude && geo.location.longitude) {
|
||||||
return geo;
|
return geo;
|
||||||
}
|
}
|
||||||
// geoIP fallback, Madrid
|
// geoIP fallback, Madrid
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import { ServiceConfiguration } from 'meteor/service-configuration';
|
||||||
const OAuthSettings = Meteor.settings.private.OAuth;
|
const OAuthSettings = Meteor.settings.private.OAuth;
|
||||||
|
|
||||||
if (OAuthSettings) {
|
if (OAuthSettings) {
|
||||||
Object.keys(OAuthSettings).forEach((service) => {
|
// Meteor 3: sync Mongo write-methods are gone on the server; use *Async.
|
||||||
ServiceConfiguration.configurations.upsert(
|
// Top-level await is supported in eager server modules.
|
||||||
|
for (const service of Object.keys(OAuthSettings)) {
|
||||||
|
await ServiceConfiguration.configurations.upsertAsync(
|
||||||
{ service },
|
{ service },
|
||||||
{ $set: OAuthSettings[service] },
|
{ $set: OAuthSettings[service] },
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ import '../../api/FireAlerts/server/publications';
|
||||||
import '../../api/Subscriptions/methods';
|
import '../../api/Subscriptions/methods';
|
||||||
import '../../api/Subscriptions/server/publications';
|
import '../../api/Subscriptions/server/publications';
|
||||||
|
|
||||||
// TODO add rate-limit to these publications
|
|
||||||
|
|
||||||
import '../../api/Notifications/methods';
|
import '../../api/Notifications/methods';
|
||||||
import '../../api/Notifications/server/publications';
|
import '../../api/Notifications/server/publications';
|
||||||
|
|
||||||
|
|
@ -28,3 +26,26 @@ import '../../api/SiteSettings/server/publications';
|
||||||
|
|
||||||
import '../../api/FalsePositives/methods';
|
import '../../api/FalsePositives/methods';
|
||||||
import '../../api/FalsePositives/server/publications';
|
import '../../api/FalsePositives/server/publications';
|
||||||
|
|
||||||
|
import { rateLimitSubscriptions } from '../../modules/rate-limit';
|
||||||
|
|
||||||
|
// Rate-limit the abusable publications (the old TODO above). Single-fire
|
||||||
|
// lookups and the comments feed are cheap but brute-forceable, so keep them
|
||||||
|
// tight; the geo subs fire on every map pan/zoom, so give them more headroom.
|
||||||
|
rateLimitSubscriptions({
|
||||||
|
subscriptions: [
|
||||||
|
'fireFromHash', 'fireFromAlertId', 'fireFromActiveId', 'fireFromId',
|
||||||
|
'comments.forReference'
|
||||||
|
],
|
||||||
|
limit: 5,
|
||||||
|
timeRange: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
rateLimitSubscriptions({
|
||||||
|
subscriptions: [
|
||||||
|
'activefiresmyloc', 'activefiresunionmyloc', 'fireAlerts',
|
||||||
|
'falsePositivesMyloc', 'industriesMyloc'
|
||||||
|
],
|
||||||
|
limit: 10,
|
||||||
|
timeRange: 1000
|
||||||
|
});
|
||||||
|
|
|
||||||
99
imports/startup/server/calcUnionAsync.js
Normal file
99
imports/startup/server/calcUnionAsync.js
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import path from 'path';
|
||||||
|
import { Worker } from 'worker_threads';
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
|
||||||
|
const WORKER_PATH = path.resolve(process.cwd(), 'assets/app/workers/unionWorker.js');
|
||||||
|
|
||||||
|
// The app's own npm dependencies (installed by `meteor build`) live in
|
||||||
|
// programs/server/npm/node_modules — a directory that's a SIBLING of
|
||||||
|
// programs/server/assets, not an ancestor of it. Node's require() only walks
|
||||||
|
// up parent directories looking for node_modules, so a plain
|
||||||
|
// require('@turf/circle') from the worker script (a raw asset, not part of
|
||||||
|
// Meteor's own module system that knows about this layout) can never find it
|
||||||
|
// and fails with "Cannot find module '@turf/circle'". Resolving the absolute
|
||||||
|
// path here, where Meteor's own module system already knows how to find it,
|
||||||
|
// and handing it to the worker sidesteps that entirely.
|
||||||
|
const NPM_MODULES = path.resolve(process.cwd(), 'npm/node_modules');
|
||||||
|
const turfPaths = {
|
||||||
|
circle: path.join(NPM_MODULES, '@turf/circle'),
|
||||||
|
union: path.join(NPM_MODULES, '@turf/union'),
|
||||||
|
truncate: path.join(NPM_MODULES, '@turf/truncate')
|
||||||
|
};
|
||||||
|
|
||||||
|
// A worker that never answers used to hang the promise forever, and with it the
|
||||||
|
// queue in subsUnionLogic: `busy` stays true and no union is ever computed again
|
||||||
|
// until the process restarts — silently, because nothing throws. Measured on
|
||||||
|
// this hardware (bench/union-bench.js), a full recreate of 10.000 subscriptions
|
||||||
|
// takes ~40 s, so ten minutes is a very wide margin over anything healthy while
|
||||||
|
// still being a bound.
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
export const timeoutMs = () => (
|
||||||
|
(Meteor.settings.private && Meteor.settings.private.unionWorkerTimeoutMs) || DEFAULT_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
class UnionWorkerTimeout extends Error {
|
||||||
|
constructor(ms) {
|
||||||
|
super(`union worker did not answer in ${ms} ms`);
|
||||||
|
this.name = 'UnionWorkerTimeout';
|
||||||
|
this.isTimeout = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cota de heap del worker. No es para que quepa: es para que un caso patológico
|
||||||
|
// (miles de suscripciones que no se solapan) muera con ERR_WORKER_OUT_OF_MEMORY
|
||||||
|
// —que esta capa convierte en un rechazo y deja la unión anterior intacta— en
|
||||||
|
// vez de que el kernel se lleve por delante el proceso entero de Meteor. Medido:
|
||||||
|
// 10.000 suscripciones con 64 vértices por círculo son ~421 MB de RSS de todo el
|
||||||
|
// proceso, así que 1 GB de heap para el worker es holgado.
|
||||||
|
const DEFAULT_MAX_HEAP_MB = 1024;
|
||||||
|
const settingsOf = name => (Meteor.settings.private || {})[name];
|
||||||
|
|
||||||
|
const runOnce = (subs, baseUnion, ms) => new Promise((resolve, reject) => {
|
||||||
|
const worker = new Worker(WORKER_PATH, {
|
||||||
|
workerData: { subs, baseUnion, turfPaths, steps: settingsOf('unionCircleSteps') },
|
||||||
|
resourceLimits: { maxOldGenerationSizeMb: settingsOf('unionWorkerMaxHeapMb') || DEFAULT_MAX_HEAP_MB }
|
||||||
|
});
|
||||||
|
let settled = false;
|
||||||
|
const finish = (fn, arg) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
// terminate() is what actually stops the thread burning a core; without it a
|
||||||
|
// timed-out union would keep computing forever next to its replacement.
|
||||||
|
worker.terminate();
|
||||||
|
fn(arg);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish(reject, new UnionWorkerTimeout(ms)), ms);
|
||||||
|
|
||||||
|
worker.once('message', (msg) => {
|
||||||
|
if (msg.error) finish(reject, new Error(msg.error));
|
||||||
|
else finish(resolve, msg.union);
|
||||||
|
});
|
||||||
|
worker.once('error', err => finish(reject, err));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Circle-union of already-validated, already-decorated subscriptions, run in a
|
||||||
|
* worker thread so the main event loop (DDP/HTTP) stays free regardless of
|
||||||
|
* how long any single turf.union call takes. subs must be an array of plain
|
||||||
|
* { location: { lat, lon }, distance } objects. If baseUnion is given, subs
|
||||||
|
* are merged into it instead of a union computed from scratch.
|
||||||
|
*
|
||||||
|
* Fails after a timeout, and retries once on a genuine error — a failure to
|
||||||
|
* spawn the thread, or a crash, is usually transient. A TIMEOUT is not retried:
|
||||||
|
* whatever made it take longer than the bound will still be there, and a second
|
||||||
|
* attempt only doubles the time the union stays stale.
|
||||||
|
*/
|
||||||
|
const calcUnionAsync = async (subs, baseUnion = null) => {
|
||||||
|
const ms = timeoutMs();
|
||||||
|
try {
|
||||||
|
return await runOnce(subs, baseUnion, ms);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.isTimeout) throw e;
|
||||||
|
console.warn('union worker failed, retrying once', e.message);
|
||||||
|
return runOnce(subs, baseUnion, ms);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default calcUnionAsync;
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
/* global Comments */
|
|
||||||
/* eslint-disable import/no-absolute-path */
|
|
||||||
|
|
||||||
import { Meteor } from 'meteor/meteor';
|
|
||||||
import i18n from 'i18next';
|
|
||||||
import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email';
|
|
||||||
import getEmailOf from '/imports/modules/get-email-of-user';
|
|
||||||
import '../common/comments';
|
|
||||||
|
|
||||||
/* import i18n from 'i18next';
|
|
||||||
import moment from 'moment';
|
|
||||||
import { dateLongFormat } from '/imports/api/Common/dates';
|
|
||||||
import Notifications from '/imports/api/Notifications/Notifications';
|
|
||||||
// import sendMail from '/imports/startup/server/email';
|
|
||||||
// import { hr } from '/imports/startup/server/email';
|
|
||||||
import getOAuthProfile from '/imports/modules/get-oauth-profile';
|
|
||||||
import image from 'google-maps-image-api-url';
|
|
||||||
import { trim } from '/imports/ui/components/NotificationsObserver/util.js'; */
|
|
||||||
|
|
||||||
Meteor.startup(() => {
|
|
||||||
// On Server or Client (preferably on Server)
|
|
||||||
Comments.config({
|
|
||||||
onEvent: (name, action, payload) => {
|
|
||||||
// e.g send a mail
|
|
||||||
// console.log(`name: ${name}, action: ${action}, payload: ${JSON.stringify(payload)}`);
|
|
||||||
|
|
||||||
// Samples:
|
|
||||||
|
|
||||||
// name: comment, action: add, payload: {"referenceId":"fire-56a9cc5a5586879eb9cc6dd7","content":"asdfasdf asd fasd fas","userId":"tdK4e43ikjDXct7Gj","isAnonymous":false,"createdAt":"2018-03-04T14:53:40.319Z","likes":[],"dislikes":[],"replies":[],"media":{},"status":"approved","starRatings":[],"ratingScore":0,"_id":"mfAe3HL9qNofhiAnh"}
|
|
||||||
// name: reply, action: add, payload: {"replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":[],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":0,"_id":"DyHt28rF6rTDnC5fP","rootUserId":"tdK4e43ikjDXct7Gj"}
|
|
||||||
// name: reply, action: like, payload: {"replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":["tdK4e43ikjDXct7Gj"],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":1,"_id":"DyHt28rF6rTDnC5fP","ratedUserId":"tdK4e43ikjDXct7Gj","rootUserId":"tdK4e43ikjDXct7Gj"}
|
|
||||||
// name: comment, action: like, payload: {"_id":"mfAe3HL9qNofhiAnh","ratedUserId":"tdK4e43ikjDXct7Gj"}
|
|
||||||
// name: reply, action: edit, payload: {"_id":"DyHt28rF6rTDnC5fP","replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf dasd fadsfa","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":[],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":0,"starRatings":[],"ratedUserId":"tdK4e43ikjDXct7Gj","rootUserId":"tdK4e43ikjDXct7Gj"}
|
|
||||||
// name: reply, action: remove, payload: {"_id":"DyHt28rF6rTDnC5fP","rootUserId":"tdK4e43ikjDXct7Gj"}
|
|
||||||
|
|
||||||
// name: (comment|reply)
|
|
||||||
// action: (add|like|edit|remove)
|
|
||||||
|
|
||||||
if (name === 'comment' && action.match(/add|like|edit/)) {
|
|
||||||
if (action === 'add') {
|
|
||||||
// Check for other users that did comments and send an email (uniq users, and not this user)
|
|
||||||
// console.log(payload.referenceId);
|
|
||||||
const query = payload.isAnonymous ?
|
|
||||||
{ referenceId: payload.referenceId } :
|
|
||||||
{ referenceId: payload.referenceId, userId: { $ne: payload.userId } };
|
|
||||||
Comments.getCollection().rawCollection().distinct('userId', query).then((users) => {
|
|
||||||
// console.log(users);
|
|
||||||
const path = payload.referenceId.replace(/fire-/, 'fire/archive/');
|
|
||||||
const fireUrl = Meteor.absoluteUrl(path);
|
|
||||||
// console.log(fireUrl);
|
|
||||||
Meteor.users.find({ _id: { $in: users } }).forEach((user) => {
|
|
||||||
const { firstName, emailAddress } = getEmailOf(user);
|
|
||||||
// console.log(JSON.stringify(user));
|
|
||||||
if (emailAddress) {
|
|
||||||
const emailOpts = {
|
|
||||||
to: emailAddress,
|
|
||||||
subject: subjectTruncate.apply(i18n.t('Hay más información sobre un fuego')),
|
|
||||||
lang: user.lang,
|
|
||||||
template: 'new-fire-comment',
|
|
||||||
templateVars: {
|
|
||||||
applicationName: i18n.t('AppName'),
|
|
||||||
firstName,
|
|
||||||
fireUrl
|
|
||||||
}
|
|
||||||
};
|
|
||||||
sendEmail(emailOpts).catch((error) => {
|
|
||||||
throw new Meteor.Error('500', `${error}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (name === 'reply' && action.match(/add|like|edit/)) {
|
|
||||||
// Check for other previous users to this thread and send an email
|
|
||||||
// console.log('Reply op');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -4,8 +4,6 @@
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
import { tweetIberiaFires, tweetEuropeFires } from '/imports/api/ActiveFires/server/tweetFiresInZone';
|
import { tweetIberiaFires, tweetEuropeFires } from '/imports/api/ActiveFires/server/tweetFiresInZone';
|
||||||
import { isMailServerMaster, sendEmailsFromQueue } from '/imports/startup/server/email';
|
import { isMailServerMaster, sendEmailsFromQueue } from '/imports/startup/server/email';
|
||||||
import Notifications from '/imports/api/Notifications/Notifications';
|
|
||||||
import processNotif from '/imports/modules/server/notificationsProcess.js';
|
|
||||||
|
|
||||||
// https://github.com/thesaucecode/meteor-synced-cron/
|
// https://github.com/thesaucecode/meteor-synced-cron/
|
||||||
|
|
||||||
|
|
@ -55,29 +53,11 @@ Meteor.startup(() => {
|
||||||
job: () => sendEmailsFromQueue()
|
job: () => sendEmailsFromQueue()
|
||||||
});
|
});
|
||||||
|
|
||||||
SyncedCron.add({
|
// NOTE: the "Process pending notif" job (push via node-gcm + notification
|
||||||
name: 'Process pending notif',
|
// emails) was removed here — that responsibility now lives in the
|
||||||
timezone: 'Europe/Madrid',
|
// tcef-notifications microservice (fases 1a-1c). See UPGRADE.md for the
|
||||||
schedule: (parser) => {
|
// deploy-ordering dependency (the old code must not be shut off in
|
||||||
// http://bunkat.github.io/later/
|
// production until the new service is emitting).
|
||||||
const sched = parser.text('every 15 min');
|
|
||||||
if (sched.error !== -1) {
|
|
||||||
console.error(`Mail cron 'when' field parsed with errors: ${sched.error}`);
|
|
||||||
}
|
|
||||||
return sched;
|
|
||||||
},
|
|
||||||
job: () => {
|
|
||||||
const mobileNotif = Notifications.find({ nofitied: null, type: 'mobile' });
|
|
||||||
mobileNotif.forEach((notif) => {
|
|
||||||
processNotif(notif);
|
|
||||||
});
|
|
||||||
const emailNotif = Notifications.find({ emailNofitied: null, type: 'web' });
|
|
||||||
emailNotif.forEach((notif) => {
|
|
||||||
processNotif(notif);
|
|
||||||
});
|
|
||||||
return { mobile: mobileNotif.count(), web: emailNotif.count() };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const esEn = Meteor.settings.private.twitter.es.enabled;
|
const esEn = Meteor.settings.private.twitter.es.enabled;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { MailTime } from 'meteor/ostrio:mailer';
|
import MailTime from 'meteor/ostrio:mailer';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import isMaster from './isMaster';
|
import isMaster from './isMaster';
|
||||||
|
|
||||||
|
|
@ -48,7 +48,8 @@ if (isMailServerMaster) {
|
||||||
concatDelimiter: hr + '<h2>{{{subject}}}</h2>', // Start each concatenated email with it's own subject
|
concatDelimiter: hr + '<h2>{{{subject}}}</h2>', // Start each concatenated email with it's own subject
|
||||||
/* eslint-enable */
|
/* eslint-enable */
|
||||||
// concatThrottling: 30,
|
// concatThrottling: 30,
|
||||||
template: MailTime.Template // Use default template
|
// Mail-Time 2.x removed the static MailTime.Template; omitting `template`
|
||||||
|
// uses the built-in default ('{{{html}}}'). (debt: richer wrapper template)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log('I\'m a mail client');
|
console.log('I\'m a mail client');
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
/* global Facts */
|
|
||||||
import { Roles } from 'meteor/alanning:roles';
|
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { Facts } from 'meteor/facts-base';
|
||||||
|
|
||||||
Facts.setUserIdFilter((userId) => {
|
/*
|
||||||
const user = Meteor.users.findOne(userId);
|
* alanning:roles removed (1.x is dead on Meteor 3 — crashes the whole client
|
||||||
// console.log(`User roles: ${user.roles}`);
|
* bundle at load — and 4.x needs a data migration we don't want). Admin check
|
||||||
return Roles.userIsInRole(userId, ['admin']);
|
* against the plain `user.roles` field, cached at startup: facts are
|
||||||
|
* dev/admin instrumentation and the admin set essentially never changes.
|
||||||
|
*/
|
||||||
|
const adminIds = new Set();
|
||||||
|
|
||||||
|
Meteor.startup(async () => {
|
||||||
|
await Meteor.users
|
||||||
|
.find({ roles: 'admin' }, { fields: { _id: 1 } })
|
||||||
|
.forEachAsync(u => adminIds.add(u._id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Facts.setUserIdFilter(userId => adminIds.has(userId));
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// Workaround for: https://github.com/meteor/meteor/issues/9796
|
|
||||||
// https://github.com/meteor/meteor/issues/9796#issuecomment-381676326
|
|
||||||
// https://github.com/sandstorm-io/sandstorm/blob/0f1fec013fe7208ed0fd97eb88b31b77e3c61f42/shell/server/00-startup.js#L99-L129
|
|
||||||
|
|
||||||
import Fiber from 'fibers';
|
|
||||||
|
|
||||||
Fiber.poolSize = 1e9;
|
|
||||||
|
|
@ -1,54 +1,71 @@
|
||||||
import seeder from '@cleverbeagle/seeder';
|
/* eslint-disable no-await-in-loop */
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { Accounts } from 'meteor/accounts-base';
|
||||||
import Documents from '../../api/Documents/Documents';
|
import Documents from '../../api/Documents/Documents';
|
||||||
|
|
||||||
const documentsSeed = userId => ({
|
/*
|
||||||
collection: Documents,
|
* Dev/staging fixtures. Replaces @cleverbeagle/seeder (sync Mongo, no Meteor 3
|
||||||
environments: ['development', 'staging'],
|
* support) with a small async seeder: idempotent (find-before-create), only
|
||||||
noLimit: true,
|
* runs outside production.
|
||||||
modelCount: 5,
|
*/
|
||||||
model(dataIndex) {
|
|
||||||
return {
|
|
||||||
owner: userId,
|
|
||||||
title: `Document #${dataIndex + 1}`,
|
|
||||||
body: `This is the body of document #${dataIndex + 1}`,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
seeder(Meteor.users, {
|
const USERS = [
|
||||||
environments: ['development', 'staging'],
|
{
|
||||||
noLimit: true,
|
|
||||||
data: [{
|
|
||||||
email: 'admin@admin.com',
|
email: 'admin@admin.com',
|
||||||
password: 'password',
|
password: 'password',
|
||||||
profile: {
|
profile: { name: { first: 'Andy', last: 'Warhol' } },
|
||||||
name: {
|
roles: ['admin']
|
||||||
first: 'Andy',
|
|
||||||
last: 'Warhol',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
roles: ['admin'],
|
|
||||||
data(userId) {
|
|
||||||
return documentsSeed(userId);
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
modelCount: 5,
|
|
||||||
model(index, faker) {
|
|
||||||
const userCount = index + 1;
|
|
||||||
return {
|
|
||||||
email: `user+${userCount}@test.com`,
|
|
||||||
password: 'password',
|
|
||||||
profile: {
|
|
||||||
name: {
|
|
||||||
first: faker.name.firstName(),
|
|
||||||
last: faker.name.lastName(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
roles: ['user'],
|
|
||||||
data(userId) {
|
|
||||||
return documentsSeed(userId);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
...[
|
||||||
|
['Frida', 'Kahlo'],
|
||||||
|
['Joaquín', 'Sorolla'],
|
||||||
|
['Maruja', 'Mallo'],
|
||||||
|
['Remedios', 'Varo'],
|
||||||
|
['Pablo', 'Picasso']
|
||||||
|
].map(([first, last], i) => ({
|
||||||
|
email: `user+${i + 1}@test.com`,
|
||||||
|
password: 'password',
|
||||||
|
profile: { name: { first, last } },
|
||||||
|
roles: ['user']
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOCUMENTS_PER_USER = 5;
|
||||||
|
|
||||||
|
async function ensureUser({ email, password, profile, roles }) {
|
||||||
|
const existing = await Meteor.users.findOneAsync({ 'emails.address': email });
|
||||||
|
const userId = existing
|
||||||
|
? existing._id
|
||||||
|
: await Accounts.createUserAsync({ email, password, profile });
|
||||||
|
if (!existing && roles) {
|
||||||
|
await Meteor.users.updateAsync(userId, { $set: { roles } });
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDocuments(userId) {
|
||||||
|
const count = await Documents.find({ owner: userId }).countAsync();
|
||||||
|
if (count > 0) return;
|
||||||
|
for (let i = 0; i < DOCUMENTS_PER_USER; i += 1) {
|
||||||
|
await Documents.insertAsync({
|
||||||
|
owner: userId,
|
||||||
|
title: `Document #${i + 1}`,
|
||||||
|
body: `This is the body of document #${i + 1}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Meteor.startup(async () => {
|
||||||
|
// dev always; staging opts in with TCEF_SEED=1 (the old seeder's
|
||||||
|
// 'staging' environment had no reliable detection either).
|
||||||
|
if (!Meteor.isDevelopment && process.env.TCEF_SEED !== '1') return;
|
||||||
|
try {
|
||||||
|
for (const user of USERS) {
|
||||||
|
const userId = await ensureUser(user);
|
||||||
|
await ensureDocuments(userId);
|
||||||
|
}
|
||||||
|
console.log(`fixtures: ${USERS.length} dev users ensured`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`fixtures: seeding failed: ${e}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import backend from 'i18next-sync-fs-backend';
|
import backend from 'i18next-fs-backend';
|
||||||
import i18nOpts from '../common/i18n';
|
import i18nOpts from '../common/i18n';
|
||||||
// import moment from 'moment';
|
// import moment from 'moment';
|
||||||
// import { T9n } from 'meteor-accounts-t9n';
|
// import { T9n } from 'meteor-accounts-t9n';
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
import './ravenLogger';
|
import './ravenLogger';
|
||||||
|
import './sentryTunnel';
|
||||||
import './catchExceptions';
|
import './catchExceptions';
|
||||||
import './fibers';
|
|
||||||
import './i18n';
|
import './i18n';
|
||||||
import './accounts';
|
import './accounts';
|
||||||
import './api';
|
import './api';
|
||||||
import './fixtures';
|
import './fixtures';
|
||||||
import './email';
|
import './email';
|
||||||
|
import '/imports/api/Comments/server';
|
||||||
import './IPGeocoder';
|
import './IPGeocoder';
|
||||||
import './migrations';
|
import './migrations';
|
||||||
import './notificationsObserver';
|
|
||||||
import './facts';
|
import './facts';
|
||||||
import './comments';
|
|
||||||
import './sitemaps';
|
import './sitemaps';
|
||||||
import './subsUnion';
|
import './subsUnion';
|
||||||
import './prerender';
|
import './prerender';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
/* global Migrations, Comments */
|
/* global Migrations */
|
||||||
/* eslint-disable import/no-absolute-path */
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import Comments from '/imports/api/Comments/Comments';
|
||||||
import { Accounts } from 'meteor/accounts-base';
|
import { Accounts } from 'meteor/accounts-base';
|
||||||
import randomHex from 'crypto-random-hex';
|
import randomHex from 'crypto-random-hex';
|
||||||
import UserSubsToFiresCollection from '/imports/api/Subscriptions/Subscriptions';
|
import UserSubsToFiresCollection from '/imports/api/Subscriptions/Subscriptions';
|
||||||
|
|
@ -13,7 +15,7 @@ import Notifications from '/imports/api/Notifications/Notifications';
|
||||||
import ActiveFiresUnion from '/imports/api/ActiveFiresUnion/ActiveFiresUnion';
|
import ActiveFiresUnion from '/imports/api/ActiveFiresUnion/ActiveFiresUnion';
|
||||||
import { Mongo } from 'meteor/mongo';
|
import { Mongo } from 'meteor/mongo';
|
||||||
|
|
||||||
Meteor.startup(() => {
|
Meteor.startup(async () => {
|
||||||
// https://github.com/percolatestudio/meteor-migrations
|
// https://github.com/percolatestudio/meteor-migrations
|
||||||
|
|
||||||
Migrations.config({
|
Migrations.config({
|
||||||
|
|
@ -23,38 +25,40 @@ Meteor.startup(() => {
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 1,
|
version: 1,
|
||||||
up: function migrateIds() {
|
up: async function migrateIds() {
|
||||||
// https://docs.mongodb.com/manual/reference/operator/query/type/
|
// https://docs.mongodb.com/manual/reference/operator/query/type/
|
||||||
Meteor.users.find({ _id: { $type: 7 } }).forEach((user) => {
|
const users = await Meteor.users.find({ _id: { $type: 7 } }).fetchAsync();
|
||||||
|
for (const user of users) {
|
||||||
const migratedUser = user;
|
const migratedUser = user;
|
||||||
const id = user._id.valueOf();
|
const id = user._id.valueOf();
|
||||||
console.log(`Migrating id of user: ${JSON.stringify(user)}`);
|
console.log(`Migrating id of user: ${JSON.stringify(user)}`);
|
||||||
Meteor.users.remove({ _id: user._id });
|
await Meteor.users.removeAsync({ _id: user._id });
|
||||||
migratedUser._id = id;
|
migratedUser._id = id;
|
||||||
Meteor.users.insert(migratedUser);
|
await Meteor.users.insertAsync(migratedUser);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 2,
|
version: 2,
|
||||||
up: function migrateSubsForeignKey() {
|
up: async function migrateSubsForeignKey() {
|
||||||
UserSubsToFiresCollection.find({ owner: null }).forEach((sub) => {
|
const subs = await UserSubsToFiresCollection.find({ owner: null }).fetchAsync();
|
||||||
|
for (const sub of subs) {
|
||||||
console.log(`Migrating subs of chatId: ${sub.chatId}`);
|
console.log(`Migrating subs of chatId: ${sub.chatId}`);
|
||||||
const subsUser = Meteor.users.findOne({ telegramChatId: sub.chatId });
|
const subsUser = await Meteor.users.findOneAsync({ telegramChatId: sub.chatId });
|
||||||
if (subsUser) {
|
if (subsUser) {
|
||||||
console.log(`Migrating linking to user: ${JSON.stringify(subsUser)}`);
|
console.log(`Migrating linking to user: ${JSON.stringify(subsUser)}`);
|
||||||
UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { owner: subsUser._id } });
|
await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { owner: subsUser._id } });
|
||||||
} else {
|
} else {
|
||||||
// create user with chatId and def language
|
// create user with chatId and def language
|
||||||
const username = `tel${sub.chatId.toString().replace(/^-/, '')}`;
|
const username = `tel${sub.chatId.toString().replace(/^-/, '')}`;
|
||||||
console.log(`Linking to new user: ${username}`);
|
console.log(`Linking to new user: ${username}`);
|
||||||
const newUserId = Accounts.createUser({
|
const newUserId = await Accounts.createUserAsync({
|
||||||
username,
|
username,
|
||||||
password: randomHex(50),
|
password: randomHex(50),
|
||||||
profile: { name: {} }
|
profile: { name: {} }
|
||||||
});
|
});
|
||||||
Meteor.users.update({ _id: newUserId }, {
|
await Meteor.users.updateAsync({ _id: newUserId }, {
|
||||||
$set: {
|
$set: {
|
||||||
emails: [],
|
emails: [],
|
||||||
roles: ['user'],
|
roles: ['user'],
|
||||||
|
|
@ -62,87 +66,90 @@ Meteor.startup(() => {
|
||||||
lang: 'es'
|
lang: 'es'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { owner: newUserId } });
|
await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { owner: newUserId } });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 3,
|
version: 3,
|
||||||
up: function emptySubsTypes() {
|
up: async function emptySubsTypes() {
|
||||||
UserSubsToFiresCollection.find({ type: null }).forEach((sub) => {
|
const subs = await UserSubsToFiresCollection.find({ type: null }).fetchAsync();
|
||||||
UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { type: 'telegram' } });
|
for (const sub of subs) {
|
||||||
});
|
await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { type: 'telegram' } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 4,
|
version: 4,
|
||||||
up: function deleteOldAlertFiresAndIndexes() {
|
up: async function deleteOldAlertFiresAndIndexes() {
|
||||||
FireAlertsCollection.remove({ createdAd: null });
|
await FireAlertsCollection.removeAsync({ createdAd: null });
|
||||||
const raw = FireAlertsCollection.rawCollection();
|
const raw = FireAlertsCollection.rawCollection();
|
||||||
raw.createIndex({ ourid: '2dsphere' });
|
await raw.createIndex({ ourid: '2dsphere' });
|
||||||
raw.createIndex({ when: 1 });
|
await raw.createIndex({ when: 1 });
|
||||||
raw.createIndex({ updatedAt: 1 });
|
await raw.createIndex({ updatedAt: 1 });
|
||||||
raw.createIndex({ createdAt: 1 });
|
await raw.createIndex({ createdAt: 1 });
|
||||||
raw.createIndex({ ourid: 1, type: 1 });
|
await raw.createIndex({ ourid: 1, type: 1 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 5,
|
version: 5,
|
||||||
up: function siteSettingsIndex() {
|
up: async function siteSettingsIndex() {
|
||||||
// other way:
|
// other way:
|
||||||
SiteSettings._ensureIndex({ name: 1 }, { unique: 1 });
|
await SiteSettings.createIndexAsync({ name: 1 }, { unique: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 6,
|
version: 6,
|
||||||
up: function falsePositiveIndexes() {
|
up: async function falsePositiveIndexes() {
|
||||||
FalsePositives._ensureIndex({ chatId: 1 });
|
await FalsePositives.createIndexAsync({ chatId: 1 });
|
||||||
FalsePositives._ensureIndex({ owner: 1 });
|
await FalsePositives.createIndexAsync({ owner: 1 });
|
||||||
FalsePositives._ensureIndex({ fireId: 1 });
|
await FalsePositives.createIndexAsync({ fireId: 1 });
|
||||||
FalsePositives._ensureIndex({ type: 1 });
|
await FalsePositives.createIndexAsync({ type: 1 });
|
||||||
FalsePositives._ensureIndex({ geo: '2dsphere' });
|
await FalsePositives.createIndexAsync({ geo: '2dsphere' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 7,
|
version: 7,
|
||||||
up: function defLangIfNull() {
|
up: async function defLangIfNull() {
|
||||||
Meteor.users.find({ lang: null }).forEach((user) => {
|
const users = await Meteor.users.find({ lang: null }).fetchAsync();
|
||||||
Meteor.users.update({ _id: user._id }, {
|
for (const user of users) {
|
||||||
|
await Meteor.users.updateAsync({ _id: user._id }, {
|
||||||
$set: {
|
$set: {
|
||||||
lang: 'es'
|
lang: 'es'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 8,
|
version: 8,
|
||||||
up: function siteSettingsAddIndex() {
|
up: async function siteSettingsAddIndex() {
|
||||||
SiteSettings._ensureIndex({ isPublic: 1 });
|
await SiteSettings.createIndexAsync({ isPublic: 1 });
|
||||||
SiteSettings.find({ isPublic: null }).forEach((setting) => {
|
const settings = await SiteSettings.find({ isPublic: null }).fetchAsync();
|
||||||
SiteSettings.update({ _id: setting._id }, { $set: { isPublic: true } });
|
for (const setting of settings) {
|
||||||
});
|
await SiteSettings.updateAsync({ _id: setting._id }, { $set: { isPublic: true } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 9,
|
version: 9,
|
||||||
up: function siteSettingsAddIndex() {
|
up: async function industriesIndexesAndRegistries() {
|
||||||
Industries._ensureIndex({ registry: 1 });
|
await Industries.createIndexAsync({ registry: 1 });
|
||||||
Industries._ensureIndex({ geo: '2dsphere' });
|
await Industries.createIndexAsync({ geo: '2dsphere' });
|
||||||
// https://www.eea.europa.eu/data-and-maps/data/member-states-reporting-art-7-under-the-european-pollutant-release-and-transfer-register-e-prtr-regulation-16
|
// https://www.eea.europa.eu/data-and-maps/data/member-states-reporting-art-7-under-the-european-pollutant-release-and-transfer-register-e-prtr-regulation-16
|
||||||
IndustryRegistries.insert({
|
await IndustryRegistries.insertAsync({
|
||||||
_id: '1', name: 'E-PRTR', agency: 'EEA', region: 'EU'
|
_id: '1', name: 'E-PRTR', agency: 'EEA', region: 'EU'
|
||||||
});
|
});
|
||||||
// https://www.epa.gov/enviro/epa-frs-facilities-state-single-file-csv-download
|
// https://www.epa.gov/enviro/epa-frs-facilities-state-single-file-csv-download
|
||||||
IndustryRegistries.insert({
|
await IndustryRegistries.insertAsync({
|
||||||
_id: '2', name: 'FRS', agency: 'EPA', region: 'US'
|
_id: '2', name: 'FRS', agency: 'EPA', region: 'US'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -150,11 +157,11 @@ Meteor.startup(() => {
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 10,
|
version: 10,
|
||||||
up: function siteSettingsAddIndex() {
|
up: async function moreIndustryRegistries() {
|
||||||
IndustryRegistries.insert({
|
await IndustryRegistries.insertAsync({
|
||||||
_id: '3', name: 'NPRI', agency: 'GCODP', region: 'Canada'
|
_id: '3', name: 'NPRI', agency: 'GCODP', region: 'Canada'
|
||||||
});
|
});
|
||||||
IndustryRegistries.insert({
|
await IndustryRegistries.insertAsync({
|
||||||
_id: '4', name: 'NPI', agency: 'DEE', region: 'Australia'
|
_id: '4', name: 'NPI', agency: 'DEE', region: 'Australia'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -162,15 +169,15 @@ Meteor.startup(() => {
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 11,
|
version: 11,
|
||||||
up: function noAnonComments() {
|
up: async function noAnonComments() {
|
||||||
Comments.getCollection().remove({ isAnonymous: true });
|
await Comments.removeAsync({ isAnonymous: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 12,
|
version: 12,
|
||||||
up: function setTelegramUsersBotId() {
|
up: async function setTelegramUsersBotId() {
|
||||||
Meteor.users.update({ telegramChatId: { $ne: null }, telegramBot: null }, {
|
await Meteor.users.updateAsync({ telegramChatId: { $ne: null }, telegramBot: null }, {
|
||||||
$set: {
|
$set: {
|
||||||
telegramBot: 'es'
|
telegramBot: 'es'
|
||||||
}
|
}
|
||||||
|
|
@ -180,8 +187,8 @@ Meteor.startup(() => {
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 13,
|
version: 13,
|
||||||
up: function removeTelegramBotFromUsersId() {
|
up: async function removeTelegramBotFromUsersId() {
|
||||||
Meteor.users.update({}, {
|
await Meteor.users.updateAsync({}, {
|
||||||
$unset: {
|
$unset: {
|
||||||
telegramBot: ''
|
telegramBot: ''
|
||||||
}
|
}
|
||||||
|
|
@ -191,8 +198,8 @@ Meteor.startup(() => {
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 14,
|
version: 14,
|
||||||
up: function setTelegramUsersBotId() {
|
up: async function setTelegramSubsBotId() {
|
||||||
UserSubsToFiresCollection.update({ chatId: { $ne: null }, telegramBot: null }, {
|
await UserSubsToFiresCollection.updateAsync({ chatId: { $ne: null }, telegramBot: null }, {
|
||||||
$set: {
|
$set: {
|
||||||
telegramBot: 'es'
|
telegramBot: 'es'
|
||||||
}
|
}
|
||||||
|
|
@ -202,40 +209,41 @@ Meteor.startup(() => {
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 15,
|
version: 15,
|
||||||
up: function moveToFalsePositivesUppercase() {
|
up: async function moveToFalsePositivesUppercase() {
|
||||||
/* const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' });
|
/* const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' });
|
||||||
* falsepositiveslower.find({}).forEach((falseDoc) => {
|
* falsepositiveslower.find({}).forEach((falseDoc) => {
|
||||||
* FalsePositives.insert(falseDoc);
|
* FalsePositives.insert(falseDoc);
|
||||||
* });*/
|
* }); */
|
||||||
// TODO remove falsepositives lowercase collection
|
// TODO remove falsepositives lowercase collection
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 16,
|
version: 16,
|
||||||
up: function moveToFalsePositivesUppercaseWithUser() {
|
up: async function moveToFalsePositivesUppercaseWithUser() {
|
||||||
const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' });
|
const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' });
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
falsepositiveslower.find({}).forEach((falseDoc) => {
|
const falseDocs = await falsepositiveslower.find({}).fetchAsync();
|
||||||
const user = Meteor.users.findOne({ telegramChatId: falseDoc.chatId });
|
for (const falseDoc of falseDocs) {
|
||||||
|
const user = await Meteor.users.findOneAsync({ telegramChatId: falseDoc.chatId });
|
||||||
if (user) {
|
if (user) {
|
||||||
falseDoc.owner= user._id;
|
falseDoc.owner = user._id;
|
||||||
}
|
}
|
||||||
falseDoc.type = 'industry';
|
falseDoc.type = 'industry';
|
||||||
falseDoc.createdAt = now;
|
falseDoc.createdAt = now;
|
||||||
falseDoc.updatedAt = now;
|
falseDoc.updatedAt = now;
|
||||||
FalsePositives.insert(falseDoc);
|
await FalsePositives.insertAsync(falseDoc);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 17,
|
version: 17,
|
||||||
up: function renameWebNotifiedField() {
|
up: async function renameWebNotifiedField() {
|
||||||
Notifications.update({ webNotified: { $exists: true } }, {
|
await Notifications.updateAsync({ webNotified: { $exists: true } }, {
|
||||||
$rename: { webNotifiedAt: 'notifiedAt' }
|
$rename: { webNotifiedAt: 'notifiedAt' }
|
||||||
}, { upsert: false, multi: true });
|
}, { upsert: false, multi: true });
|
||||||
Notifications.update({ webNotified: { $exists: true } }, {
|
await Notifications.updateAsync({ webNotified: { $exists: true } }, {
|
||||||
$rename: { webNotified: 'notified' }
|
$rename: { webNotified: 'notified' }
|
||||||
}, { upsert: false, multi: true });
|
}, { upsert: false, multi: true });
|
||||||
}
|
}
|
||||||
|
|
@ -243,18 +251,19 @@ Meteor.startup(() => {
|
||||||
|
|
||||||
Migrations.add({
|
Migrations.add({
|
||||||
version: 18,
|
version: 18,
|
||||||
up: () => {
|
up: async () => {
|
||||||
const raw = ActiveFiresUnion.rawCollection();
|
const raw = ActiveFiresUnion.rawCollection();
|
||||||
raw.createIndex({ centerid: '2dsphere' });
|
await raw.createIndex({ centerid: '2dsphere' });
|
||||||
raw.createIndex({ shape: '2dsphere' });
|
await raw.createIndex({ shape: '2dsphere' });
|
||||||
raw.createIndex({ when: 1 });
|
await raw.createIndex({ when: 1 });
|
||||||
raw.createIndex({ createdAt: 1 });
|
await raw.createIndex({ createdAt: 1 });
|
||||||
raw.createIndex({ updatedAt: 1 });
|
await raw.createIndex({ updatedAt: 1 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set createdAt in users & subs
|
// Meteor 3: migrateTo is async and awaits each async up(), so a fresh DB can
|
||||||
Migrations.migrateTo('latest');
|
// migrate from 0 to latest without pre-marking the control version.
|
||||||
|
await Migrations.migrateTo('latest');
|
||||||
|
|
||||||
// Migrations.migrateTo('14,rerun');
|
// Migrations.migrateTo('14,rerun');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
/* eslint-disable import/no-absolute-path */
|
|
||||||
|
|
||||||
import { Meteor } from 'meteor/meteor';
|
|
||||||
import Notifications from '/imports/api/Notifications/Notifications';
|
|
||||||
import processNotif from '/imports/modules/server/notificationsProcess.js';
|
|
||||||
import { isMailServerMaster } from '/imports/startup/server/email';
|
|
||||||
|
|
||||||
Meteor.startup(() => {
|
|
||||||
if (isMailServerMaster) {
|
|
||||||
Notifications.find().observe({
|
|
||||||
added: function notifAdded(notif) {
|
|
||||||
processNotif(notif);
|
|
||||||
},
|
|
||||||
changed: function notifChanged(updatedNotif) { // , oldNotif) {
|
|
||||||
processNotif(updatedNotif);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,18 +1,35 @@
|
||||||
import RavenLogger from 'meteor/flowkey:raven';
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
// Error reporter (server). Was flowkey:raven (raven@2.4.1, Sentry legacy SDK),
|
||||||
|
// which is dead on Meteor 3 and, with a dead DSN, spammed the boot log with
|
||||||
|
// "failed to send exception to sentry: HTTP Error (502)". Replaced by the
|
||||||
|
// modern @sentry/node SDK behind the same `.log()` facade so the 6 call sites
|
||||||
|
// don't change. With no DSN configured it is a pure console logger (identical
|
||||||
|
// to the old "disabled" branch) — ready to point at a DSN when Sentry/GlitchTip
|
||||||
|
// is back, via Meteor.settings.sentryPrivateDSN.
|
||||||
|
import * as Sentry from '@sentry/node';
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
|
||||||
const ravenOptions = {};
|
const dsn = Meteor.settings.sentryPrivateDSN;
|
||||||
const publicDSN = Meteor.settings.public.sentryPublicDSN;
|
const enabled = !!dsn;
|
||||||
const privateDSN = Meteor.settings.sentryPrivateDSN;
|
|
||||||
const enabled = publicDSN && privateDSN;
|
|
||||||
|
|
||||||
const ravenLogger = enabled ? new RavenLogger({
|
if (enabled) {
|
||||||
publicDSN,
|
Sentry.init({
|
||||||
privateDSN,
|
dsn,
|
||||||
shouldCatchConsoleError: true, // default true
|
environment: Meteor.isDevelopment ? 'development' : 'production',
|
||||||
trackUser: true // default false
|
// errors only; no tracing/profiling (avoids the OTel auto-instrumentation)
|
||||||
}, ravenOptions) : { log: (error) => { console.log(error); } };
|
tracesSampleRate: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`ravenLogger ${enabled ? 'enabled' : 'disabled'}`);
|
const ravenLogger = {
|
||||||
|
log(error, extra) {
|
||||||
|
console.error(error);
|
||||||
|
if (enabled) {
|
||||||
|
Sentry.captureException(error, extra ? { extra: { info: extra } } : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`sentryLogger (server) ${enabled ? 'enabled' : 'disabled'}`);
|
||||||
|
|
||||||
export default ravenLogger;
|
export default ravenLogger;
|
||||||
|
|
|
||||||
127
imports/startup/server/sentryTunnel.js
Normal file
127
imports/startup/server/sentryTunnel.js
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
import https from 'https';
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { WebApp } from 'meteor/webapp';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sentry tunnel: /sentry-tunnel receives the browser SDK's envelopes
|
||||||
|
* same-origin and forwards them to GlitchTip server-side.
|
||||||
|
*
|
||||||
|
* Why: GlitchTip lives behind Cloudflare (sentry.comunes.org). Cloudflare's
|
||||||
|
* bot protection answers the browser's cross-site ingest POSTs with 503 (no
|
||||||
|
* CORS headers -> "Failed to fetch"), while identical server-side requests get
|
||||||
|
* 200. Routing events through our own origin avoids the cross-site request
|
||||||
|
* (and ad-blockers) completely. The browser SDK opts in via `tunnel` in
|
||||||
|
* imports/startup/client/ravenLogger.js.
|
||||||
|
*
|
||||||
|
* Single-DSN app: the target is derived from sentryPublicDSN, so we never have
|
||||||
|
* to decompress/parse the envelope body — we just relay the raw bytes to the
|
||||||
|
* right project's ingest endpoint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_BODY = 1024 * 1024; // 1 MB: Sentry envelopes are small; cap abuse.
|
||||||
|
|
||||||
|
function ingestTargetFromDsn(dsn) {
|
||||||
|
// DSN: https://<publicKey>@<host>/<projectId>
|
||||||
|
const u = new URL(dsn);
|
||||||
|
const projectId = u.pathname.replace(/^\//, '');
|
||||||
|
const publicKey = u.username;
|
||||||
|
if (!projectId || !publicKey) throw new Error('DSN missing project id or public key');
|
||||||
|
// GlitchTip authenticates the ingest by the `sentry_key` query param; when the
|
||||||
|
// SDK tunnels, that key lives inside the envelope header, which GlitchTip does
|
||||||
|
// not read here -> it would answer 403 "Denied". Pass the key explicitly.
|
||||||
|
const path = `/api/${projectId}/envelope/?sentry_key=${publicKey}&sentry_version=7`;
|
||||||
|
return { hostname: u.hostname, port: u.port || 443, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Forward the raw envelope to GlitchTip over IPv4 (`family: 4`). GlitchTip is
|
||||||
|
* behind Cloudflare, which is dual-stack; on IPv4-only hosts Node's fetch/
|
||||||
|
* Happy-Eyeballs may pick the unroutable IPv6 and fail. Cloudflare always has
|
||||||
|
* IPv4, so pinning the family is safe everywhere and avoids that flakiness.
|
||||||
|
*/
|
||||||
|
function forwardToIngest(target, headers, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const upstream = https.request({
|
||||||
|
hostname: target.hostname,
|
||||||
|
port: target.port,
|
||||||
|
path: target.path,
|
||||||
|
method: 'POST',
|
||||||
|
family: 4,
|
||||||
|
headers
|
||||||
|
}, (r) => {
|
||||||
|
r.resume(); // drain the response
|
||||||
|
if (r.statusCode >= 400) {
|
||||||
|
console.error(`sentry-tunnel: upstream returned ${r.statusCode}`);
|
||||||
|
}
|
||||||
|
resolve(r.statusCode);
|
||||||
|
});
|
||||||
|
upstream.on('error', reject);
|
||||||
|
upstream.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicDsn = Meteor.settings.public && Meteor.settings.public.sentryPublicDSN;
|
||||||
|
|
||||||
|
if (publicDsn) {
|
||||||
|
let ingestTarget;
|
||||||
|
try {
|
||||||
|
ingestTarget = ingestTargetFromDsn(publicDsn);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`sentry-tunnel: invalid sentryPublicDSN, tunnel disabled: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ingestTarget) {
|
||||||
|
WebApp.connectHandlers.use('/sentry-tunnel', (req, res) => {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.writeHead(405, { Allow: 'POST' });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
let aborted = false;
|
||||||
|
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
if (aborted) return;
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BODY) {
|
||||||
|
aborted = true;
|
||||||
|
res.writeHead(413);
|
||||||
|
res.end();
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('end', async () => {
|
||||||
|
if (aborted) return;
|
||||||
|
try {
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
const headers = { 'Content-Type': 'application/x-sentry-envelope' };
|
||||||
|
if (req.headers['content-encoding']) {
|
||||||
|
headers['Content-Encoding'] = req.headers['content-encoding'];
|
||||||
|
}
|
||||||
|
// Preserve the reporter's IP so GlitchTip attributes the event correctly.
|
||||||
|
const fwd = req.headers['x-forwarded-for'];
|
||||||
|
headers['X-Forwarded-For'] = fwd
|
||||||
|
? `${fwd}, ${req.socket.remoteAddress}`
|
||||||
|
: req.socket.remoteAddress;
|
||||||
|
|
||||||
|
const status = await forwardToIngest(ingestTarget, headers, body);
|
||||||
|
res.writeHead(status);
|
||||||
|
res.end();
|
||||||
|
} catch (err) {
|
||||||
|
// Never let a reporting failure surface to the user; just log it.
|
||||||
|
console.error(`sentry-tunnel: forward failed: ${err.message}`);
|
||||||
|
res.writeHead(502);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`sentry-tunnel enabled -> https://${ingestTarget.hostname}${ingestTarget.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,44 +1,37 @@
|
||||||
/* global sitemaps Comments */
|
|
||||||
/* eslint-disable import/no-absolute-path */
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { WebApp } from 'meteor/webapp';
|
||||||
|
|
||||||
import Fires from '/imports/api/Fires/Fires';
|
/*
|
||||||
// import Comments from '/imports/api/Comments/Comments';
|
* /sitemap.xml — own implementation replacing gadicohen:sitemaps (pre-0.9
|
||||||
|
* package API, dead on Meteor 3). Static pages only: the per-fire section of
|
||||||
|
* the old handler was behind `firesMapEnabled = false` (dead code) and was
|
||||||
|
* dropped; see git history if it's ever wanted back.
|
||||||
|
*/
|
||||||
|
|
||||||
const firesMapEnabled = false;
|
const PAGES = [
|
||||||
|
'/fires',
|
||||||
|
'/login',
|
||||||
|
'/signup',
|
||||||
|
'/recover-password',
|
||||||
|
'/credits',
|
||||||
|
'/terms',
|
||||||
|
'/license',
|
||||||
|
'/privacy',
|
||||||
|
'/about'
|
||||||
|
];
|
||||||
|
|
||||||
sitemaps.add('/sitemap.xml', () => {
|
function xmlEscape(s) {
|
||||||
const today = new Date();
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
const out = [];
|
WebApp.connectHandlers.use('/sitemap.xml', (req, res) => {
|
||||||
out.push({ page: '/fires', lastmod: today });
|
const lastmod = new Date().toISOString().slice(0, 10);
|
||||||
out.push({ page: '/login', lastmod: today });
|
const urls = PAGES.map((page) => {
|
||||||
out.push({ page: '/signup', lastmod: today });
|
const loc = xmlEscape(Meteor.absoluteUrl(page.replace(/^\//, '')));
|
||||||
out.push({ page: '/recover-password', lastmod: today });
|
return ` <url>\n <loc>${loc}</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`;
|
||||||
out.push({ page: '/credits', lastmod: today });
|
}).join('\n');
|
||||||
out.push({ page: '/terms', lastmod: today });
|
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
|
||||||
out.push({ page: '/license', lastmod: today });
|
res.writeHead(200, { 'Content-Type': 'application/xml; charset=utf-8' });
|
||||||
out.push({ page: '/privacy', lastmod: today });
|
res.end(body);
|
||||||
out.push({ page: '/about', lastmod: today });
|
|
||||||
|
|
||||||
// When user has public page
|
|
||||||
/* const users = Meteor.users.find().fetch();
|
|
||||||
_.each(users, function(user) {
|
|
||||||
out.push({
|
|
||||||
page: '/persona/' + user.username,
|
|
||||||
lastmod: user.updatedAt
|
|
||||||
});
|
|
||||||
}); */
|
|
||||||
|
|
||||||
if (firesMapEnabled) {
|
|
||||||
Fires.find({}, { limit: 100, sort: { createdAt: -1 } }).fetch().forEach((fire) => {
|
|
||||||
// Search the last comment of tha fire
|
|
||||||
const lastComment = Comments.getCollection().findOne({ referenceId: `fire-${fire._id}` }, { sort: { createdAt: -1 } });
|
|
||||||
out.push({
|
|
||||||
page: `/fire/archive/${fire._id._str}`,
|
|
||||||
lastmod: lastComment ? lastComment.createdAt : fire.updatedAt
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,123 +1,53 @@
|
||||||
/* eslint-disable import/no-absolute-path */
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
|
||||||
|
// Wiring only: what the union actually does lives in subsUnionLogic.js, where it
|
||||||
|
// can be tested without booting a server (see test/server/subsUnion.test.js).
|
||||||
|
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
import Subscriptions from '/imports/api/Subscriptions/Subscriptions';
|
import Subscriptions from '/imports/api/Subscriptions/Subscriptions';
|
||||||
import SiteSettings from '/imports/api/SiteSettings/SiteSettings';
|
import SiteSettings from '/imports/api/SiteSettings/SiteSettings';
|
||||||
import Perlin from 'loms.perlin';
|
import calcUnionAsync from '/imports/startup/server/calcUnionAsync';
|
||||||
import './leaflet-workaround';
|
import unionBounds from '/imports/startup/server/unionBounds';
|
||||||
import L from 'leaflet';
|
import createSubsUnion from '/imports/startup/server/subsUnionLogic';
|
||||||
import calcUnion from '/imports/ui/components/Maps/SubsUnion/Unify';
|
import unionTelemetry from '/imports/startup/server/unionTelemetry';
|
||||||
import { isMailServerMaster } from '/imports/startup/server/email';
|
import { isMailServerMaster } from '/imports/startup/server/email';
|
||||||
|
|
||||||
// sudo apt-get install libcairo2-dev libjpeg-dev libgif-dev
|
Meteor.startup(async () => {
|
||||||
|
|
||||||
Meteor.startup(() => {
|
|
||||||
if (!isMailServerMaster) {
|
if (!isMailServerMaster) {
|
||||||
console.log('We only process subsUnion in master');
|
console.log('We only process subsUnion in master');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const debug = true; // Meteor.isDevelopment;
|
|
||||||
Perlin.seed(Math.random());
|
|
||||||
|
|
||||||
const addNoisy = (osub) => {
|
const subsUnion = createSubsUnion({
|
||||||
const sub = osub;
|
calcUnion: calcUnionAsync,
|
||||||
let lat = Math.round(sub.location.lat * 10) / 10;
|
SiteSettings,
|
||||||
let lon = Math.round(sub.location.lon * 10) / 10;
|
Subscriptions,
|
||||||
const noiseBase = Perlin.perlin2(lat, lon);
|
boundsOf: unionBounds,
|
||||||
const noise = Math.abs(noiseBase / 3);
|
telemetry: unionTelemetry
|
||||||
lat += noise;
|
});
|
||||||
lon += noise;
|
|
||||||
sub.location.lat = lat;
|
|
||||||
sub.location.lon = lon;
|
|
||||||
sub.distance += noiseBase;
|
|
||||||
return sub;
|
|
||||||
};
|
|
||||||
|
|
||||||
const noNoisy = sub => sub;
|
if (await subsUnion.isUnionOutdated()) {
|
||||||
|
console.log('Subs union outdated');
|
||||||
const process = (isPublic) => {
|
subsUnion.scheduleRecreate();
|
||||||
const subscribers = Subscriptions.find().fetch();
|
} else {
|
||||||
const result = calcUnion(L, subscribers, isPublic ? addNoisy : noNoisy, true);
|
console.log('Subs union up-to-date');
|
||||||
const union = result[0];
|
|
||||||
const bounds = result[1];
|
|
||||||
|
|
||||||
const publicl = isPublic ? 'public' : 'private';
|
|
||||||
const Publicl = publicl.replace(/\b\w/g, l => l.toUpperCase());
|
|
||||||
|
|
||||||
if (typeof union === 'object') {
|
|
||||||
const unionSet = {
|
|
||||||
$set: {
|
|
||||||
name: `subs-${publicl}-union`,
|
|
||||||
value: JSON.stringify(union),
|
|
||||||
isPublic,
|
|
||||||
description: `${Publicl} subscriptions union`,
|
|
||||||
type: 'string'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const boundsSet = {
|
|
||||||
$set: {
|
|
||||||
name: `subs-${publicl}-union-bounds`,
|
|
||||||
value: JSON.stringify(bounds),
|
|
||||||
isPublic,
|
|
||||||
description: `${Publicl} subscriptions union bounds`,
|
|
||||||
type: 'string'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const sizeSet = {
|
|
||||||
$set: {
|
|
||||||
name: 'subs-union-count',
|
|
||||||
value: subscribers.length,
|
|
||||||
isPublic: false,
|
|
||||||
description: 'Subscriptions count',
|
|
||||||
type: 'number'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// FIXME, take care of object size:
|
|
||||||
// https://stackoverflow.com/questions/10827812/what-is-the-length-maximum-for-a-string-data-type-in-mongodb-used-with-ruby
|
|
||||||
SiteSettings.upsert({ name: `subs-${publicl}-union` }, unionSet, { multi: false });
|
|
||||||
SiteSettings.upsert({ name: `subs-${publicl}-union-bounds` }, boundsSet, { multi: false });
|
|
||||||
SiteSettings.upsert({ name: 'subs-union-count' }, sizeSet, { multi: false });
|
|
||||||
if (debug) console.log(`${Publicl} subscription union calculated`);
|
|
||||||
} else {
|
|
||||||
console.log('Subscription union failed!');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// At startup, we check if it's necessary to calc subscriptions union again
|
|
||||||
const currentUnion = SiteSettings.findOne({ name: 'subs-public-union' });
|
|
||||||
const lastSubs = Subscriptions.findOne({}, { sort: { updatedAt: -1 } });
|
|
||||||
const countUnionSubs = SiteSettings.findOne({ name: 'subs-union-count' });
|
|
||||||
const countSubs = Subscriptions.find({}).count();
|
|
||||||
if (currentUnion && lastSubs) {
|
|
||||||
const lastUnionUpdated = currentUnion.updatedAt;
|
|
||||||
const lastSubsUpdated = lastSubs.updatedAt;
|
|
||||||
if (lastUnionUpdated > lastSubsUpdated || !countUnionSubs || countSubs !== countUnionSubs.value) {
|
|
||||||
console.log('Subs union outdated');
|
|
||||||
process(true);
|
|
||||||
process(false);
|
|
||||||
} else {
|
|
||||||
console.log('Subs union up-to-date');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Subscriptions.find({ createdAt: { $gt: new Date() } }).observe({
|
await Subscriptions.find({ createdAt: { $gt: new Date() } }).observeAsync({
|
||||||
added: function newSubAdded() { // doc) {
|
added: function newSubAdded(doc) {
|
||||||
if (debug) console.log('Subs added so recreate union');
|
console.log('Subs added, merging into union incrementally');
|
||||||
process(true);
|
subsUnion.enqueueAdd(doc);
|
||||||
process(false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Subscriptions.find().observe({
|
await Subscriptions.find().observeAsync({
|
||||||
changed: function subsChanged() { // updatedDoc, oldDoc) {
|
changed: function subsChanged() {
|
||||||
if (debug) console.log('Subs changed so recreate union');
|
console.log('Subs changed so recreate union');
|
||||||
process(true);
|
subsUnion.scheduleRecreate();
|
||||||
process(false);
|
|
||||||
},
|
},
|
||||||
removed: function subsRemoved() { // oldDoc) {
|
removed: function subsRemoved() {
|
||||||
if (debug) console.log('Subs removed so recreate union');
|
console.log('Subs removed so recreate union');
|
||||||
process(true);
|
subsUnion.scheduleRecreate();
|
||||||
process(false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
275
imports/startup/server/subsUnionLogic.js
Normal file
275
imports/startup/server/subsUnionLogic.js
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
|
||||||
|
// All of the subscriptions-union behaviour, with its collaborators injected.
|
||||||
|
//
|
||||||
|
// This used to live inside the Meteor.startup() callback of subsUnion.js, where
|
||||||
|
// nothing was reachable from a test: the queue, the incremental fast path and the
|
||||||
|
// worker-failure handling could only be exercised by starting a whole server and
|
||||||
|
// hoping. That is how a failed worker call ended up being stored as a valid empty
|
||||||
|
// union (50ca9cc) and how a full recompute ended up blocking DDP (b4e5511 /
|
||||||
|
// fbb746f) — three production incidents in code with zero test coverage.
|
||||||
|
// subsUnion.js is now only the wiring; everything below is a plain function you
|
||||||
|
// can call with fakes.
|
||||||
|
|
||||||
|
import Perlin from 'loms.perlin';
|
||||||
|
import limitUnionSize, { MAX_UNION_BYTES } from './unionSizeGuard';
|
||||||
|
|
||||||
|
// Telegram-era subscriptions (and anything else half-written) can be missing the
|
||||||
|
// location or the radius. They are skipped, not an error: they predate the web
|
||||||
|
// subscriptions and there is nothing sensible to draw for them.
|
||||||
|
export const isValidSub = (osub, log = console) => {
|
||||||
|
const valid = !!(osub && osub.location && osub.location.lat && osub.location.lon && osub.distance);
|
||||||
|
if (!valid) log.info(`Wrong element to do union ${JSON.stringify(osub)}`);
|
||||||
|
return valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {function} deps.calcUnion (subs, baseUnion) => Promise<union|null>, off-thread
|
||||||
|
* @param {object} deps.SiteSettings Mongo collection holding the stored union
|
||||||
|
* @param {object} deps.Subscriptions Mongo collection of subscriptions
|
||||||
|
* @param {function} [deps.boundsOf] union => leaflet-style bounds (or null)
|
||||||
|
* @param {number} [deps.maxUnionBytes]
|
||||||
|
* @param {number} [deps.perlinSeed] fixed seed makes the public "noise" reproducible in tests
|
||||||
|
*/
|
||||||
|
const noTelemetry = { computed: () => {}, failed: () => {} };
|
||||||
|
|
||||||
|
const createSubsUnion = ({
|
||||||
|
calcUnion,
|
||||||
|
SiteSettings,
|
||||||
|
Subscriptions,
|
||||||
|
boundsOf = () => null,
|
||||||
|
maxUnionBytes = MAX_UNION_BYTES,
|
||||||
|
perlinSeed = Math.random(),
|
||||||
|
debug = true,
|
||||||
|
log = console,
|
||||||
|
telemetry = noTelemetry
|
||||||
|
}) => {
|
||||||
|
Perlin.seed(perlinSeed);
|
||||||
|
|
||||||
|
// Public unions are fuzzed so that the map does not disclose where exactly
|
||||||
|
// somebody subscribed. Returns a new object: callers hand us documents straight
|
||||||
|
// out of Mongo and the private pass must not see the fuzzed values.
|
||||||
|
const addNoisy = (sub) => {
|
||||||
|
const lat = Math.round(sub.location.lat * 10) / 10;
|
||||||
|
const lon = Math.round(sub.location.lon * 10) / 10;
|
||||||
|
const noiseBase = Perlin.perlin2(lat, lon);
|
||||||
|
const noise = Math.abs(noiseBase / 3);
|
||||||
|
return {
|
||||||
|
...sub,
|
||||||
|
location: { ...sub.location, lat: lat + noise, lon: lon + noise },
|
||||||
|
distance: sub.distance + noiseBase
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const noNoisy = sub => ({ ...sub, location: { ...sub.location } });
|
||||||
|
|
||||||
|
const decoratorFor = isPublic => (isPublic ? addNoisy : noNoisy);
|
||||||
|
|
||||||
|
// Only called once calcUnion has actually resolved — union === null here means
|
||||||
|
// "zero valid subscriptions", a real result, not a failed computation. (A prior
|
||||||
|
// version guarded this with `typeof union === 'object'`, which is true for null
|
||||||
|
// too, so a failed worker call — caught upstream, leaving union as null — looked
|
||||||
|
// identical to a legitimate empty union and got silently stored as "null".
|
||||||
|
// Callers must catch calcUnion failures themselves and skip calling this.)
|
||||||
|
const storeUnion = async (isPublic, union, count) => {
|
||||||
|
const publicl = isPublic ? 'public' : 'private';
|
||||||
|
const Publicl = publicl.replace(/\b\w/g, l => l.toUpperCase());
|
||||||
|
// Mongo caps a document at 16 MiB and this value is also pushed to every
|
||||||
|
// browser: past a certain number of subscriptions the geometry has to be
|
||||||
|
// coarsened or the upsert simply starts failing. See unionSizeGuard.js.
|
||||||
|
const { union: safeUnion, json, degraded } = limitUnionSize(union, maxUnionBytes);
|
||||||
|
if (degraded) {
|
||||||
|
log.warn(`${Publicl} subscriptions union too big (${degraded.originalBytes} bytes), degraded via ${degraded.steps.join(' -> ')}`);
|
||||||
|
}
|
||||||
|
const bounds = boundsOf(safeUnion);
|
||||||
|
const unionSet = {
|
||||||
|
$set: {
|
||||||
|
name: `subs-${publicl}-union`,
|
||||||
|
value: json,
|
||||||
|
isPublic,
|
||||||
|
description: `${Publicl} subscriptions union`,
|
||||||
|
type: 'string',
|
||||||
|
degraded: degraded ? degraded.steps.join(' -> ') : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const boundsSet = {
|
||||||
|
$set: {
|
||||||
|
name: `subs-${publicl}-union-bounds`,
|
||||||
|
value: JSON.stringify(bounds),
|
||||||
|
isPublic,
|
||||||
|
description: `${Publicl} subscriptions union bounds`,
|
||||||
|
type: 'string'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sizeSet = {
|
||||||
|
$set: {
|
||||||
|
name: 'subs-union-count',
|
||||||
|
value: count,
|
||||||
|
isPublic: false,
|
||||||
|
description: 'Subscriptions count',
|
||||||
|
type: 'number'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await SiteSettings.upsertAsync({ name: `subs-${publicl}-union` }, unionSet, { multi: false });
|
||||||
|
await SiteSettings.upsertAsync({ name: `subs-${publicl}-union-bounds` }, boundsSet, { multi: false });
|
||||||
|
await SiteSettings.upsertAsync({ name: 'subs-union-count' }, sizeSet, { multi: false });
|
||||||
|
if (debug) log.log(`${Publicl} subscription union calculated`);
|
||||||
|
return { degraded, bytes: Buffer.byteLength(json, 'utf8') };
|
||||||
|
};
|
||||||
|
|
||||||
|
const recomputeOne = async (isPublic) => {
|
||||||
|
const subscribers = await Subscriptions.find().fetchAsync();
|
||||||
|
const decorated = subscribers.filter(s => isValidSub(s, log)).map(decoratorFor(isPublic));
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
const union = await calcUnion(decorated);
|
||||||
|
const { degraded, bytes } = await storeUnion(isPublic, union, subscribers.length);
|
||||||
|
telemetry.computed({
|
||||||
|
kind: 'recreate',
|
||||||
|
isPublic,
|
||||||
|
subs: decorated.length,
|
||||||
|
ms: Date.now() - started,
|
||||||
|
bytes,
|
||||||
|
degraded: degraded ? degraded.steps.join(' -> ') : null
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log.error('subsUnion worker failed, union left unchanged', e);
|
||||||
|
telemetry.failed(e, { kind: 'recreate', isPublic, subs: decorated.length, ms: Date.now() - started });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recreate = async () => { await recomputeOne(true); await recomputeOne(false); };
|
||||||
|
|
||||||
|
// Single serialized worker over two kinds of work: incremental adds (a real
|
||||||
|
// FIFO queue — each must see the previous one's result, none get dropped) and
|
||||||
|
// full recomputes (coalesced — a burst of changed/removed events only needs one
|
||||||
|
// pass once things settle). Both touch the same stored union, so they share this
|
||||||
|
// one lock rather than racing each other.
|
||||||
|
let busy = false;
|
||||||
|
const addQueue = [];
|
||||||
|
let recomputePending = false;
|
||||||
|
let idleWaiters = [];
|
||||||
|
|
||||||
|
const flushIdleWaiters = () => {
|
||||||
|
const waiters = idleWaiters;
|
||||||
|
idleWaiters = [];
|
||||||
|
waiters.forEach(resolve => resolve());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fast path for the common case (a new subscription): merge just the new circle
|
||||||
|
// into the union already stored instead of redoing the full turf.union chain
|
||||||
|
// over every subscription again (that chain is what made a single recompute take
|
||||||
|
// ~20+ minutes with thousands of subscriptions). Falls back to a full recreate()
|
||||||
|
// if the stored state isn't exactly "one behind" what we expect — safer than
|
||||||
|
// merging into stale/inconsistent data. Sets recomputePending directly (rather
|
||||||
|
// than calling a schedule helper) since runLoop already re-checks it right after
|
||||||
|
// this returns.
|
||||||
|
const incrementalAdd = async (doc) => {
|
||||||
|
if (!isValidSub(doc, log)) return;
|
||||||
|
|
||||||
|
const countSetting = await SiteSettings.findOneAsync({ name: 'subs-union-count' });
|
||||||
|
const countSubs = await Subscriptions.find({}).countAsync();
|
||||||
|
if (!countSetting || countSetting.value !== countSubs - 1) {
|
||||||
|
if (debug) log.log('Subs union state not exactly one behind, doing a full recreate instead');
|
||||||
|
recomputePending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const isPublic of [true, false]) {
|
||||||
|
const decorated = decoratorFor(isPublic)(doc);
|
||||||
|
const publicl = isPublic ? 'public' : 'private';
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const current = await SiteSettings.findOneAsync({ name: `subs-${publicl}-union` });
|
||||||
|
const baseUnion = current && current.value ? JSON.parse(current.value) : null;
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const union = await calcUnion([decorated], baseUnion);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const { degraded, bytes } = await storeUnion(isPublic, union, countSubs);
|
||||||
|
telemetry.computed({
|
||||||
|
kind: 'incrementalAdd',
|
||||||
|
isPublic,
|
||||||
|
subs: countSubs,
|
||||||
|
ms: Date.now() - started,
|
||||||
|
bytes,
|
||||||
|
degraded: degraded ? degraded.steps.join(' -> ') : null
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log.error('subsUnion incremental worker failed, falling back to a full recreate', e);
|
||||||
|
telemetry.failed(e, { kind: 'incrementalAdd', isPublic, subs: countSubs, ms: Date.now() - started });
|
||||||
|
recomputePending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (debug) log.log('Subs union updated incrementally');
|
||||||
|
};
|
||||||
|
|
||||||
|
const runLoop = () => {
|
||||||
|
if (busy) return;
|
||||||
|
if (addQueue.length > 0) {
|
||||||
|
busy = true;
|
||||||
|
const doc = addQueue.shift();
|
||||||
|
incrementalAdd(doc)
|
||||||
|
.catch(e => log.error('subsUnion incremental add failed', e))
|
||||||
|
.finally(() => { busy = false; runLoop(); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (recomputePending) {
|
||||||
|
recomputePending = false;
|
||||||
|
busy = true;
|
||||||
|
recreate()
|
||||||
|
.catch(e => log.error('subsUnion recompute failed', e))
|
||||||
|
.finally(() => { busy = false; runLoop(); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushIdleWaiters();
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueueAdd = (doc) => { addQueue.push(doc); runLoop(); };
|
||||||
|
|
||||||
|
// Fire-and-forget on purpose: both recreate() and incrementalAdd() can take a
|
||||||
|
// while, and callers (Meteor.startup, observeAsync callbacks) must not block on it.
|
||||||
|
const scheduleRecreate = () => { recomputePending = true; runLoop(); };
|
||||||
|
|
||||||
|
// Resolves when the queue has drained and nothing is running. Only meant for
|
||||||
|
// tests and shutdown — production code must never await the union.
|
||||||
|
const idle = () => new Promise((resolve) => {
|
||||||
|
if (!busy && addQueue.length === 0 && !recomputePending) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
idleWaiters.push(resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
// At startup we check whether the stored union still matches the subscriptions.
|
||||||
|
const isUnionOutdated = async () => {
|
||||||
|
const currentUnion = await SiteSettings.findOneAsync({ name: 'subs-public-union' });
|
||||||
|
const lastSubs = await Subscriptions.findOneAsync({}, { sort: { updatedAt: -1 } });
|
||||||
|
if (!currentUnion || !lastSubs) return false;
|
||||||
|
const countUnionSubs = await SiteSettings.findOneAsync({ name: 'subs-union-count' });
|
||||||
|
const countSubs = await Subscriptions.find({}).countAsync();
|
||||||
|
return currentUnion.updatedAt > lastSubs.updatedAt
|
||||||
|
|| !countUnionSubs
|
||||||
|
|| countSubs !== countUnionSubs.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
addNoisy,
|
||||||
|
noNoisy,
|
||||||
|
storeUnion,
|
||||||
|
recreate,
|
||||||
|
incrementalAdd,
|
||||||
|
enqueueAdd,
|
||||||
|
scheduleRecreate,
|
||||||
|
isUnionOutdated,
|
||||||
|
idle,
|
||||||
|
stats: () => ({ busy, queued: addQueue.length, recomputePending })
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createSubsUnion;
|
||||||
11
imports/startup/server/unionBounds.js
Normal file
11
imports/startup/server/unionBounds.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
|
||||||
|
// Leaflet is a browser library; the workaround import fakes the bits of `window`
|
||||||
|
// it touches at load time so it can also run on the server. Kept apart from
|
||||||
|
// subsUnionLogic so that module (and its tests) stay free of that dependency.
|
||||||
|
import './leaflet-workaround';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
const unionBounds = union => (union === null ? null : L.geoJSON(union).getBounds());
|
||||||
|
|
||||||
|
export default unionBounds;
|
||||||
212
imports/startup/server/unionSizeGuard.js
Normal file
212
imports/startup/server/unionSizeGuard.js
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
// Keeps the stored subscriptions-union GeoJSON below a size that Mongo (and the
|
||||||
|
// DDP connection that ships it to every client) can actually take.
|
||||||
|
//
|
||||||
|
// The union of every subscription circle is stored as a JSON string inside a
|
||||||
|
// single `siteSettings` document. A BSON document is capped at 16 MiB, so past
|
||||||
|
// some number of subscriptions the upsert would simply start failing and the map
|
||||||
|
// would silently freeze at the last union that fit — which is exactly the kind of
|
||||||
|
// scale bug nobody notices until production. On top of the hard Mongo limit, that
|
||||||
|
// same string is published to browsers, so multi-megabyte unions are already a
|
||||||
|
// problem well before 16 MiB.
|
||||||
|
//
|
||||||
|
// The chosen fix is progressive degradation instead of failure: the geometry is
|
||||||
|
// coarsened, in visually-cheapest-first order, until it fits. The zone union is a
|
||||||
|
// decorative overlay ("roughly where people are watching"), never a source of
|
||||||
|
// truth for alerts, so losing precision is acceptable; losing the whole overlay is
|
||||||
|
// not. Every degradation is reported back so the caller can store it alongside the
|
||||||
|
// union and ops can see it happening.
|
||||||
|
//
|
||||||
|
// The ladder, in order:
|
||||||
|
// 1. coordinate precision 6 -> 5 -> 4 -> 3 decimals (~0.1 m -> ~100 m), dropping
|
||||||
|
// points that collapse onto their neighbour after rounding.
|
||||||
|
// 2. point decimation, keeping every k-th vertex of each ring (circles are
|
||||||
|
// generated with 144 steps, so there is a lot of slack here).
|
||||||
|
// 3. dropping holes (inner rings) — visually the least missed.
|
||||||
|
// 4. dropping the smallest polygons of the MultiPolygon until it fits.
|
||||||
|
// If even a single polygon does not fit, null is returned rather than storing
|
||||||
|
// something Mongo will reject.
|
||||||
|
|
||||||
|
// 16 MiB is Mongo's hard limit for the whole document; this cap is deliberately
|
||||||
|
// well below it because the same value travels over DDP to every connected
|
||||||
|
// browser. Raise it only together with a plan for the client side.
|
||||||
|
export const MAX_UNION_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
const MIN_RING_POINTS = 12;
|
||||||
|
const DECIMATION_FACTORS = [2, 3, 4, 6, 8];
|
||||||
|
const PRECISIONS = [5, 4, 3];
|
||||||
|
|
||||||
|
export const byteLength = json => Buffer.byteLength(json, 'utf8');
|
||||||
|
|
||||||
|
const geometryOf = union => (union && union.type === 'Feature' ? union.geometry : union);
|
||||||
|
|
||||||
|
// Both Polygon and MultiPolygon, seen as a list of polygons (a polygon being a
|
||||||
|
// list of rings, the first one the outer one).
|
||||||
|
const polygonsOf = (geometry) => {
|
||||||
|
if (!geometry || !geometry.coordinates) return [];
|
||||||
|
return geometry.type === 'MultiPolygon' ? geometry.coordinates : [geometry.coordinates];
|
||||||
|
};
|
||||||
|
|
||||||
|
const withPolygons = (union, polygons) => {
|
||||||
|
const geometry = geometryOf(union);
|
||||||
|
const coordinates = geometry.type === 'MultiPolygon' ? polygons : polygons[0];
|
||||||
|
const newGeometry = { ...geometry, coordinates };
|
||||||
|
return union.type === 'Feature' ? { ...union, geometry: newGeometry } : newGeometry;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A ring is only a ring if it closes and has at least three distinct corners.
|
||||||
|
const closeRing = (ring) => {
|
||||||
|
if (ring.length < 3) return null;
|
||||||
|
const [firstLon, firstLat] = ring[0];
|
||||||
|
const [lastLon, lastLat] = ring[ring.length - 1];
|
||||||
|
const closed = firstLon === lastLon && firstLat === lastLat ? ring : [...ring, [firstLon, firstLat]];
|
||||||
|
return closed.length >= 4 ? closed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundRing = (ring, factor) => {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < ring.length; i += 1) {
|
||||||
|
const lon = Math.round(ring[i][0] * factor) / factor;
|
||||||
|
const lat = Math.round(ring[i][1] * factor) / factor;
|
||||||
|
const prev = out[out.length - 1];
|
||||||
|
if (!prev || prev[0] !== lon || prev[1] !== lat) out.push([lon, lat]);
|
||||||
|
}
|
||||||
|
return closeRing(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decimateRing = (ring, keepEvery) => {
|
||||||
|
if (ring.length <= MIN_RING_POINTS) return ring;
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < ring.length - 1; i += keepEvery) out.push(ring[i]);
|
||||||
|
return closeRing(out) || ring;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rings that survive a transformation keep the polygon alive; a polygon whose
|
||||||
|
// outer ring collapsed is dropped entirely (its holes are meaningless then).
|
||||||
|
const mapPolygons = (polygons, mapRing) => polygons
|
||||||
|
.map(rings => rings.map(mapRing).filter(Boolean))
|
||||||
|
.filter(rings => rings.length > 0);
|
||||||
|
|
||||||
|
const ringBboxArea = (ring) => {
|
||||||
|
let minLon = Infinity;
|
||||||
|
let minLat = Infinity;
|
||||||
|
let maxLon = -Infinity;
|
||||||
|
let maxLat = -Infinity;
|
||||||
|
for (let i = 0; i < ring.length; i += 1) {
|
||||||
|
if (ring[i][0] < minLon) [minLon] = ring[i];
|
||||||
|
if (ring[i][0] > maxLon) [maxLon] = ring[i];
|
||||||
|
if (ring[i][1] < minLat) [, minLat] = ring[i];
|
||||||
|
if (ring[i][1] > maxLat) [, maxLat] = ring[i];
|
||||||
|
}
|
||||||
|
return (maxLon - minLon) * (maxLat - minLat);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the largest-fidelity version of `union` whose JSON fits in `maxBytes`,
|
||||||
|
* together with that JSON (callers always need the string anyway, and
|
||||||
|
* re-stringifying a multi-megabyte object on the main thread is not free).
|
||||||
|
*
|
||||||
|
* @returns {{ union: object|null, json: string, bytes: number,
|
||||||
|
* degraded: null | { originalBytes: number, steps: string[] } }}
|
||||||
|
*/
|
||||||
|
const limitUnionSize = (union, maxBytes = MAX_UNION_BYTES) => {
|
||||||
|
let json = JSON.stringify(union === undefined ? null : union);
|
||||||
|
let bytes = byteLength(json);
|
||||||
|
if (union === null || union === undefined || bytes <= maxBytes) {
|
||||||
|
return {
|
||||||
|
union: union === undefined ? null : union, json, bytes, degraded: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalBytes = bytes;
|
||||||
|
const steps = [];
|
||||||
|
let current = union;
|
||||||
|
|
||||||
|
const attempt = (step, transform) => {
|
||||||
|
const next = transform();
|
||||||
|
if (!next) return false;
|
||||||
|
current = next;
|
||||||
|
steps.push(step);
|
||||||
|
json = JSON.stringify(current);
|
||||||
|
bytes = byteLength(json);
|
||||||
|
return bytes <= maxBytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const done = () => ({
|
||||||
|
union: current, json, bytes, degraded: { originalBytes, steps }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < PRECISIONS.length; i += 1) {
|
||||||
|
const precision = PRECISIONS[i];
|
||||||
|
const factor = 10 ** precision;
|
||||||
|
const fits = attempt(`precision:${precision}`, () => {
|
||||||
|
const polygons = mapPolygons(polygonsOf(geometryOf(current)), ring => roundRing(ring, factor));
|
||||||
|
return polygons.length > 0 ? withPolygons(current, polygons) : null;
|
||||||
|
});
|
||||||
|
if (fits) return done();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < DECIMATION_FACTORS.length; i += 1) {
|
||||||
|
const keepEvery = DECIMATION_FACTORS[i];
|
||||||
|
const fits = attempt(`decimate:${keepEvery}`, () => {
|
||||||
|
const polygons = mapPolygons(polygonsOf(geometryOf(union)), ring => decimateRing(
|
||||||
|
roundRing(ring, 10 ** PRECISIONS[PRECISIONS.length - 1]) || ring,
|
||||||
|
keepEvery
|
||||||
|
));
|
||||||
|
return polygons.length > 0 ? withPolygons(current, polygons) : null;
|
||||||
|
});
|
||||||
|
if (fits) return done();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitsWithoutHoles = attempt('holes', () => {
|
||||||
|
const polygons = polygonsOf(geometryOf(current)).map(rings => [rings[0]]);
|
||||||
|
return polygons.length > 0 ? withPolygons(current, polygons) : null;
|
||||||
|
});
|
||||||
|
if (fitsWithoutHoles) return done();
|
||||||
|
|
||||||
|
// Last resort: drop the smallest polygons first, so what remains is the part of
|
||||||
|
// the map a user is most likely to be looking at. Binary search for how many
|
||||||
|
// survive — trying one fewer at a time means re-serializing a multi-megabyte
|
||||||
|
// object hundreds of times, which is precisely the kind of main-thread stall
|
||||||
|
// this whole area is trying to get rid of.
|
||||||
|
const ordered = polygonsOf(geometryOf(current))
|
||||||
|
.map(rings => ({ rings, area: ringBboxArea(rings[0]) }))
|
||||||
|
.sort((a, b) => b.area - a.area)
|
||||||
|
.map(p => p.rings);
|
||||||
|
|
||||||
|
const fitsWith = (keep) => {
|
||||||
|
const candidate = withPolygons(current, ordered.slice(0, keep));
|
||||||
|
const candidateJson = JSON.stringify(candidate);
|
||||||
|
return byteLength(candidateJson) <= maxBytes ? { candidate, candidateJson } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let low = 1;
|
||||||
|
let high = ordered.length - 1;
|
||||||
|
let best = null;
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
const fit = fitsWith(mid);
|
||||||
|
if (fit) {
|
||||||
|
best = { keep: mid, ...fit };
|
||||||
|
low = mid + 1;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
current = best.candidate;
|
||||||
|
json = best.candidateJson;
|
||||||
|
bytes = byteLength(json);
|
||||||
|
steps.push(`polygons:${best.keep}`);
|
||||||
|
return done();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single polygon that still does not fit means something is very wrong
|
||||||
|
// upstream; storing nothing beats an upsert that Mongo rejects on every run.
|
||||||
|
steps.push('dropped');
|
||||||
|
current = null;
|
||||||
|
json = 'null';
|
||||||
|
bytes = byteLength(json);
|
||||||
|
return done();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default limitUnionSize;
|
||||||
51
imports/startup/server/unionTelemetry.js
Normal file
51
imports/startup/server/unionTelemetry.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
|
||||||
|
// Telemetría de la unión de suscripciones (fase 12).
|
||||||
|
//
|
||||||
|
// Hasta ahora la única señal de que la unión iba mal era que el mapa se quedaba
|
||||||
|
// viejo: los fallos del worker se registraban con console.error y ahí morían. Lo
|
||||||
|
// que se manda a GlitchTip es deliberadamente poco: un aviso cuando una unión
|
||||||
|
// tarda más de lo que el banco de carga considera sano, otro cuando hay que
|
||||||
|
// degradar la geometría por tamaño, y los fallos. Las uniones normales solo
|
||||||
|
// dejan una línea de log.
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/node';
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import ravenLogger from '/imports/startup/server/ravenLogger';
|
||||||
|
|
||||||
|
// Medido en bench/union-bench.js: 10.000 suscripciones repartidas por España son
|
||||||
|
// ~40 s. Pasar del minuto significa o mucha más gente, o una distribución que no
|
||||||
|
// se solapa (el caso caro), o algo que se ha torcido. En cualquiera de los tres
|
||||||
|
// casos queremos enterarnos.
|
||||||
|
const DEFAULT_SLOW_MS = 60 * 1000;
|
||||||
|
|
||||||
|
const slowMs = () => (
|
||||||
|
(Meteor.settings.private && Meteor.settings.private.unionSlowMs) || DEFAULT_SLOW_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
const unionTelemetry = {
|
||||||
|
computed({ kind, isPublic, subs, ms, bytes, degraded }) {
|
||||||
|
const scope = isPublic ? 'public' : 'private';
|
||||||
|
const line = `subsUnion ${kind} ${scope}: ${subs} subs, ${Math.round(ms)} ms, ${bytes} bytes${degraded ? ` (degradada: ${degraded})` : ''}`;
|
||||||
|
console.log(line);
|
||||||
|
|
||||||
|
if (ms > slowMs()) {
|
||||||
|
Sentry.captureMessage(`subsUnion lenta: ${Math.round(ms / 1000)} s con ${subs} suscripciones`, {
|
||||||
|
level: 'warning',
|
||||||
|
extra: { kind, scope, subs, ms, bytes }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (degraded) {
|
||||||
|
Sentry.captureMessage(`subsUnion degradada por tamaño: ${degraded}`, {
|
||||||
|
level: 'warning',
|
||||||
|
extra: { kind, scope, subs, ms, bytes, degraded }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
failed(error, context) {
|
||||||
|
ravenLogger.log(error, context);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default unionTelemetry;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../../stylesheets/colors';
|
@use '../../stylesheets/colors' as *;
|
||||||
|
|
||||||
.AccountPageFooter {
|
.AccountPageFooter {
|
||||||
margin: 25px 0 0;
|
margin: 25px 0 0;
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,16 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Route, Redirect } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
const Authenticated = ({ loggingIn, authenticated, component, path, exact, ...rest }) => (
|
// v6 route guard: rendered as a route `element` wrapping the real page.
|
||||||
<Route
|
// Renders its children when authenticated, otherwise redirects to /login.
|
||||||
path={path}
|
const Authenticated = ({ authenticated, children }) => (
|
||||||
exact={exact}
|
authenticated ? children : <Navigate to="/login" replace />
|
||||||
render={props => (
|
|
||||||
authenticated ?
|
|
||||||
(React.createElement(component, { ...props, ...rest, loggingIn, authenticated })) :
|
|
||||||
(<Redirect to="/login" />)
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Authenticated.propTypes = {
|
Authenticated.propTypes = {
|
||||||
loggingIn: PropTypes.bool.isRequired,
|
|
||||||
authenticated: PropTypes.bool.isRequired,
|
authenticated: PropTypes.bool.isRequired,
|
||||||
component: PropTypes.func.isRequired,
|
children: PropTypes.node.isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Authenticated;
|
export default Authenticated;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
/* eslint-disable import/no-absolute-path */
|
/* eslint-disable import/no-absolute-path */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { LinkContainer } from 'react-router-bootstrap';
|
import { LinkContainer } from 'react-router-bootstrap';
|
||||||
|
import withRouterCompat from '../withRouterCompat/withRouterCompat';
|
||||||
/* import { Nav, NavDropdown } from 'react-bootstrap'; */
|
/* import { Nav, NavDropdown } from 'react-bootstrap'; */
|
||||||
import { translate, Trans } from 'react-i18next';
|
import { withTranslation, Trans } from 'react-i18next';
|
||||||
import { testId } from '/imports/ui/components/Utils/TestUtils';
|
import { testId } from '/imports/ui/components/Utils/TestUtils';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -39,4 +39,4 @@ AuthenticatedNavigation.propTypes = {
|
||||||
name: PropTypes.string.isRequired
|
name: PropTypes.string.isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
export default translate([], { wait: true })(withRouter(AuthenticatedNavigation));
|
export default withTranslation()(withRouterCompat(AuthenticatedNavigation));
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { translate } from 'react-i18next';
|
import { withTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import './BetaRibbon.scss';
|
import './BetaRibbon.scss';
|
||||||
|
|
||||||
|
|
@ -30,4 +30,4 @@ BetaRibbon.propTypes = {
|
||||||
BetaRibbon.defaultProps = {
|
BetaRibbon.defaultProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default translate([], { wait: true })(BetaRibbon);
|
export default withTranslation()(BetaRibbon);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
||||||
import { Tracker } from 'meteor/tracker';
|
import { Tracker } from 'meteor/tracker';
|
||||||
import { ReactiveVar } from 'meteor/reactive-var';
|
import { ReactiveVar } from 'meteor/reactive-var';
|
||||||
import { Button } from 'react-bootstrap';
|
import { Button } from 'react-bootstrap';
|
||||||
import { translate } from 'react-i18next';
|
import { withTranslation } from 'react-i18next';
|
||||||
import { Bert } from 'meteor/themeteorchef:bert';
|
import { Bert } from 'meteor/themeteorchef:bert';
|
||||||
|
|
||||||
import './CenterInMyPosition.scss';
|
import './CenterInMyPosition.scss';
|
||||||
|
|
@ -48,7 +48,7 @@ class CenterInMyPosition extends React.Component {
|
||||||
const { onlyIcon, t } = this.props;
|
const { onlyIcon, t } = this.props;
|
||||||
const msg = t('Centrar en tu ubicación');
|
const msg = t('Centrar en tu ubicación');
|
||||||
return (
|
return (
|
||||||
<Button bsStyle="default" title={msg} onClick={() => this.onClick()}>
|
<Button variant="secondary" title={msg} onClick={() => this.onClick()}>
|
||||||
<i className="fa fa-crosshairs" />{!onlyIcon ? ` ${msg}` : ''}
|
<i className="fa fa-crosshairs" />{!onlyIcon ? ` ${msg}` : ''}
|
||||||
</Button>);
|
</Button>);
|
||||||
}
|
}
|
||||||
|
|
@ -63,4 +63,4 @@ CenterInMyPosition.propTypes = {
|
||||||
onlyIcon: PropTypes.bool
|
onlyIcon: PropTypes.bool
|
||||||
};
|
};
|
||||||
|
|
||||||
export default translate([], { wait: true })(CenterInMyPosition);
|
export default withTranslation()(CenterInMyPosition);
|
||||||
|
|
|
||||||
|
|
@ -1,111 +1,6 @@
|
||||||
import classNames from 'classnames';
|
// The old custom Col hand-rolled Bootstrap-4/5 grid classes on top of
|
||||||
import React from 'react';
|
// react-bootstrap 0.31 internals (`bootstrapUtils`, `StyleConfig`), which no
|
||||||
import PropTypes from 'prop-types';
|
// longer exist in react-bootstrap v2. v2's own Col produces the exact same
|
||||||
import elementType from 'prop-types-extra/lib/elementType';
|
// `col`/`col-{size}-{n}` output for the xs/sm/md/lg/xl props we use, so we
|
||||||
|
// simply re-export it and keep every importer unchanged.
|
||||||
import { bsClass, prefix, splitBsProps } from 'react-bootstrap/lib/utils/bootstrapUtils';
|
export { Col as default } from 'react-bootstrap';
|
||||||
import { DEVICE_SIZES } from 'react-bootstrap/lib/utils/StyleConfig';
|
|
||||||
|
|
||||||
const column = PropTypes.oneOfType([
|
|
||||||
PropTypes.oneOf(['auto']),
|
|
||||||
PropTypes.number,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
componentClass: elementType,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of columns you wish to span
|
|
||||||
*
|
|
||||||
* for Extra small devices Phones (<576px)
|
|
||||||
*
|
|
||||||
* class-prefix `col-`
|
|
||||||
*/
|
|
||||||
xs: column,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of columns you wish to span
|
|
||||||
*
|
|
||||||
* for Small devices Tablets (≥576px)
|
|
||||||
*
|
|
||||||
* class-prefix `col-sm-`
|
|
||||||
*/
|
|
||||||
sm: column,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of columns you wish to span
|
|
||||||
*
|
|
||||||
* for Medium devices Desktops (≥768px)
|
|
||||||
*
|
|
||||||
* class-prefix `col-md-`
|
|
||||||
*/
|
|
||||||
md: column,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of columns you wish to span
|
|
||||||
*
|
|
||||||
* for Large devices Desktops (≥992px)
|
|
||||||
*
|
|
||||||
* class-prefix `col-lg-`
|
|
||||||
*/
|
|
||||||
lg: column,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of columns you wish to span
|
|
||||||
*
|
|
||||||
* for Large devices Desktops (≥1200px)
|
|
||||||
*
|
|
||||||
* class-prefix `col-xl-`
|
|
||||||
*/
|
|
||||||
xl: column,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
componentClass: 'div',
|
|
||||||
};
|
|
||||||
|
|
||||||
class Col extends React.Component {
|
|
||||||
render() {
|
|
||||||
const { componentClass: Component, className, ...props } = this.props;
|
|
||||||
const [bsProps, elementProps] = splitBsProps(props);
|
|
||||||
|
|
||||||
const classes = [];
|
|
||||||
|
|
||||||
DEVICE_SIZES.forEach((size) => {
|
|
||||||
const propValue = elementProps[size];
|
|
||||||
|
|
||||||
if (propValue == null) return;
|
|
||||||
|
|
||||||
if (size === 'xs') {
|
|
||||||
// col and col-4
|
|
||||||
classes.push(propValue === true ?
|
|
||||||
bsProps.bsClass :
|
|
||||||
prefix(bsProps, `${propValue}`),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// col-md-3
|
|
||||||
classes.push(
|
|
||||||
prefix(bsProps, `${size}-${propValue}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
delete elementProps[size];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!classes.length) {
|
|
||||||
classes.push(bsProps.bsClass); // plain 'col'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Component
|
|
||||||
{...elementProps}
|
|
||||||
className={classNames(className, classes)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Col.propTypes = propTypes;
|
|
||||||
Col.defaultProps = defaultProps;
|
|
||||||
|
|
||||||
export default bsClass('col', Col);
|
|
||||||
|
|
|
||||||
76
imports/ui/components/Comments/Comments.scss
Normal file
76
imports/ui/components/Comments/Comments.scss
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
.comments-section {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-box {
|
||||||
|
max-width: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
padding: 0.75em 0;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.comment-content {
|
||||||
|
white-space: pre-line;
|
||||||
|
margin: 0.4em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-media-image {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-media-youtube iframe {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 240px;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions .btn {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions .btn.active {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* keep the previous "remove" button neutral (not danger red) */
|
||||||
|
.comment-owner-actions .remove-action {
|
||||||
|
color: rgb(51, 51, 51);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form .create-comment {
|
||||||
|
height: 9em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-login-hint {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-avatar {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
152
imports/ui/components/Comments/CommentsBox.js
Normal file
152
imports/ui/components/Comments/CommentsBox.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
/* eslint-disable import/no-absolute-path */
|
||||||
|
/* eslint-disable jsx-a11y/media-has-caption */
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { withTracker } from 'meteor/react-meteor-data';
|
||||||
|
import { withTranslation } from 'react-i18next';
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import moment from 'moment';
|
||||||
|
import CommentsCollection from '/imports/api/Comments/Comments';
|
||||||
|
import './Comments.scss';
|
||||||
|
|
||||||
|
function MediaEmbed({ media }) {
|
||||||
|
if (!media || !media.content) return null;
|
||||||
|
if (media.type === 'image') {
|
||||||
|
return <img className="comment-media-image" src={media.content} alt="" />;
|
||||||
|
}
|
||||||
|
if (media.type === 'youtube') {
|
||||||
|
return (
|
||||||
|
<div className="comment-media-youtube">
|
||||||
|
<iframe title="youtube" src={media.content} frameBorder="0" allowFullScreen />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MediaEmbed.propTypes = { media: PropTypes.object };
|
||||||
|
MediaEmbed.defaultProps = { media: null };
|
||||||
|
|
||||||
|
class CommentsBox extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { draft: '', editingId: null, editDraft: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
call(method, ...args) {
|
||||||
|
Meteor.call(method, ...args, (err) => {
|
||||||
|
if (err) console.warn(`${method}: ${err.reason || err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const content = this.state.draft.trim();
|
||||||
|
if (!content) return;
|
||||||
|
this.setState({ draft: '' });
|
||||||
|
this.call('comments.insert', this.props.referenceId, content);
|
||||||
|
};
|
||||||
|
|
||||||
|
saveEdit = (id) => {
|
||||||
|
const content = this.state.editDraft.trim();
|
||||||
|
if (!content) return;
|
||||||
|
this.setState({ editingId: null, editDraft: '' });
|
||||||
|
this.call('comments.edit', id, content);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComment(c) {
|
||||||
|
const { t } = this.props;
|
||||||
|
const currentUserId = Meteor.userId();
|
||||||
|
const isOwner = currentUserId && c.userId === currentUserId;
|
||||||
|
const likes = (c.likes || []).length;
|
||||||
|
const dislikes = (c.dislikes || []).length;
|
||||||
|
const liked = currentUserId && (c.likes || []).includes(currentUserId);
|
||||||
|
const disliked = currentUserId && (c.dislikes || []).includes(currentUserId);
|
||||||
|
const author = c.username || t('Anónimo');
|
||||||
|
return (
|
||||||
|
<li key={c._id} className="comment">
|
||||||
|
<div className="comment-header">
|
||||||
|
<span className="comment-author">{author}</span>
|
||||||
|
<span className="comment-date">{moment(c.createdAt).format('LLL')}</span>
|
||||||
|
</div>
|
||||||
|
{this.state.editingId === c._id ? (
|
||||||
|
<div className="comment-edit">
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
value={this.state.editDraft}
|
||||||
|
onChange={ev => this.setState({ editDraft: ev.target.value })}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={() => this.saveEdit(c._id)}>{t('Guardar')}</button>
|
||||||
|
<button type="button" className="btn btn-link btn-sm" onClick={() => this.setState({ editingId: null })}>{t('Borrar')}</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Plain text (whitespace preserved via CSS). Rendered as text — not
|
||||||
|
// HTML — so user content can never inject markup (safer than the old
|
||||||
|
// markdown-to-HTML comments package).
|
||||||
|
<p className="comment-content">{c.content}</p>
|
||||||
|
)}
|
||||||
|
<MediaEmbed media={c.media} />
|
||||||
|
<div className="comment-actions">
|
||||||
|
<button type="button" className={`btn btn-link btn-sm${liked ? ' active' : ''}`} disabled={!currentUserId} onClick={() => this.call('comments.like', c._id)}>
|
||||||
|
{'👍'} {likes}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`btn btn-link btn-sm${disliked ? ' active' : ''}`} disabled={!currentUserId} onClick={() => this.call('comments.dislike', c._id)}>
|
||||||
|
{'👎'} {dislikes}
|
||||||
|
</button>
|
||||||
|
{isOwner && (
|
||||||
|
<span className="comment-owner-actions">
|
||||||
|
<button type="button" className="btn btn-link btn-sm" onClick={() => this.setState({ editingId: c._id, editDraft: c.content })}>{t('Editar')}</button>
|
||||||
|
<button type="button" className="btn btn-link btn-sm remove-action" onClick={() => this.call('comments.remove', c._id)}>{t('Borrar')}</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { t, comments, ready } = this.props;
|
||||||
|
const loggedIn = !!Meteor.userId();
|
||||||
|
return (
|
||||||
|
<div className="comments-box">
|
||||||
|
<ul className="comments-list">
|
||||||
|
{comments.map(c => this.renderComment(c))}
|
||||||
|
</ul>
|
||||||
|
{ready && comments.length === 0 && (
|
||||||
|
<p className="comments-empty">{t('Añadir un comentario')}</p>
|
||||||
|
)}
|
||||||
|
{loggedIn ? (
|
||||||
|
<form className="comment-form" onSubmit={this.submit}>
|
||||||
|
<textarea
|
||||||
|
className="form-control create-comment"
|
||||||
|
placeholder={t('Añadir un comentario')}
|
||||||
|
value={this.state.draft}
|
||||||
|
onChange={ev => this.setState({ draft: ev.target.value })}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary">{t('Añadir comentario')}</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="comment-login-hint">
|
||||||
|
{t('Necesitas iniciar sesión para')} {t('añadir comentarios')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CommentsBox.propTypes = {
|
||||||
|
t: PropTypes.func.isRequired,
|
||||||
|
referenceId: PropTypes.string.isRequired,
|
||||||
|
comments: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
ready: PropTypes.bool.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommentsBoxContainer = withTracker(({ referenceId }) => {
|
||||||
|
const handle = Meteor.subscribe('comments.forReference', referenceId);
|
||||||
|
return {
|
||||||
|
ready: handle.ready(),
|
||||||
|
comments: CommentsCollection.find({ referenceId }, { sort: { createdAt: 1 } }).fetch()
|
||||||
|
};
|
||||||
|
})(CommentsBox);
|
||||||
|
|
||||||
|
export default withTranslation()(CommentsBoxContainer);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../../stylesheets/mixins';
|
@use '../../stylesheets/mixins' as *;
|
||||||
|
|
||||||
.Content {
|
.Content {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
|
|
|
||||||
45
imports/ui/components/CookieConsent/CookieConsent.js
Normal file
45
imports/ui/components/CookieConsent/CookieConsent.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import i18n from 'i18next';
|
||||||
|
import './CookieConsent.scss';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Minimal cookie-consent banner replacing selaias:cookie-consent (Blaze,
|
||||||
|
* dead on Meteor 3: its `Cookies` global is gone and it crashed the whole
|
||||||
|
* client bundle at load). Same behavior: informative banner, dismissed once.
|
||||||
|
*/
|
||||||
|
const STORAGE_KEY = 'tcef-cookie-consent';
|
||||||
|
|
||||||
|
class CookieConsent extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
let accepted = false;
|
||||||
|
try {
|
||||||
|
accepted = window.localStorage.getItem(STORAGE_KEY) === 'yes';
|
||||||
|
} catch (e) { /* storage disabled: show banner every time */ }
|
||||||
|
this.state = { accepted };
|
||||||
|
this.accept = this.accept.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
accept() {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, 'yes');
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
this.setState({ accepted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.accepted) return null;
|
||||||
|
return (
|
||||||
|
<div className="CookieConsent">
|
||||||
|
<span>
|
||||||
|
{i18n.t('Utilizamos cookies para asegurar un mejor uso de nuestra web. Si continúas navegando, consideramos que aceptas su uso')}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={this.accept}>
|
||||||
|
{i18n.t('Aceptar')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CookieConsent;
|
||||||
20
imports/ui/components/CookieConsent/CookieConsent.scss
Normal file
20
imports/ui/components/CookieConsent/CookieConsent.scss
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
.CookieConsent {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1em;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9em;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-left: 1em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
/* eslint-disable import/no-absolute-path */
|
/* eslint-disable import/no-absolute-path */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Trans, translate } from 'react-i18next';
|
import { Trans, withTranslation } from 'react-i18next';
|
||||||
import Tooltip from 'rc-tooltip';
|
import Tooltip from 'rc-tooltip';
|
||||||
import Slider from 'rc-slider';
|
import Slider from 'rc-slider';
|
||||||
import 'rc-slider/assets/index.css';
|
import 'rc-slider/assets/index.css';
|
||||||
|
|
@ -107,4 +107,4 @@ DistanceSlider.propTypes = {
|
||||||
onChange: PropTypes.func
|
onChange: PropTypes.func
|
||||||
};
|
};
|
||||||
|
|
||||||
export default translate([], { wait: true })(DistanceSlider);
|
export default withTranslation()(DistanceSlider);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../../stylesheets/mixins';
|
@use '../../stylesheets/mixins' as *;
|
||||||
|
|
||||||
.dist-slider {
|
.dist-slider {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue