diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c6b42f --- /dev/null +++ b/.dockerignore @@ -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.* diff --git a/.forgejo/workflows/build-image.yml b/.forgejo/workflows/build-image.yml new file mode 100644 index 0000000..0ff52b1 --- /dev/null +++ b/.forgejo/workflows/build-image.yml @@ -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" diff --git a/.forgejo/workflows/e2e.yml b/.forgejo/workflows/e2e.yml new file mode 100644 index 0000000..dc3f766 --- /dev/null +++ b/.forgejo/workflows/e2e.yml @@ -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 diff --git a/.gitignore b/.gitignore index 603fb12..b48695c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ output.json settings-production-*.json settings-tests.json *.json~ + +# fase-3: secretos del stack docker-compose local (solo se versionan los .example) +secrets/* +!secrets/README.md +!secrets/*.example diff --git a/.meteor/.finished-upgraders b/.meteor/.finished-upgraders index 910574c..c07b6ff 100644 --- a/.meteor/.finished-upgraders +++ b/.meteor/.finished-upgraders @@ -15,3 +15,5 @@ notices-for-facebook-graph-api-2 1.4.1-add-shell-server-package 1.4.3-split-account-service-packages 1.5-add-dynamic-import-package +1.7-split-underscore-from-meteor-base +1.8.3-split-jquery-from-blaze diff --git a/.meteor/packages b/.meteor/packages index 5cca156..4516001 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -4,66 +4,56 @@ # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. -meteor-base@1.3.0 # Packages every Meteor app needs to have -mobile-experience@1.0.5 # Packages for a great mobile UX -mongo@1.4.7 # The database Meteor supports right now -reactive-var@1.0.11 # Reactive variable for tracker -tracker@1.1.3 # Meteor's client-side reactive programming library +meteor-base@1.5.2 # Packages every Meteor app needs to have +mobile-experience@1.1.2 # Packages for a great mobile UX +mongo@2.0.3 # The database Meteor supports right now +reactive-var@1.0.13 # Reactive variable for tracker +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-js@2.3.1 # JS minifier run for production mode -es5-shim@4.7.0 # ECMAScript 5 compatibility for older browsers. -ecmascript@0.10.6 # Enable ECMAScript2015+ syntax in app code -shell-server@0.3.1 # Server-side component of the `meteor shell` command +standard-minifier-css@1.9.3 # CSS minifier run for production mode +standard-minifier-js@3.0.0 # JS minifier run for production mode +ecmascript@0.16.10 # Enable ECMAScript2015+ syntax in app code +shell-server@0.6.1 # Server-side component of the `meteor shell` command -react-meteor-data -alanning:roles -fourseven:scss -accounts-base@1.4.2 -accounts-password@1.5.0 -service-configuration@1.0.11 -accounts-facebook@1.3.1 -accounts-github@1.4.1 -accounts-google@1.3.1 +react-meteor-data@3.0.6 +fourseven:scss@5.0.0 +accounts-base@3.0.3 +accounts-password@3.0.3 +service-configuration@1.3.5 +accounts-facebook@1.3.4 +accounts-github@1.5.1 +accounts-google@1.4.1 themeteorchef:bert fortawesome:fontawesome -aldeed:collection2-core@2.0.1 -audit-argument-checks@1.0.7 -ddp-rate-limiter@1.0.7 -dynamic-import@0.3.0 -static-html -alexwine:bootstrap-4 +aldeed:collection2@4.0.4 +audit-argument-checks@1.0.8 +ddp-rate-limiter@1.2.2 +dynamic-import@0.7.4 +static-html@1.4.0 -selaias:cookie-consent # Cookie consent gadicc:blaze-react-component # Add braze to react 255kb:meteor-status # Connect status # mixmax:smart-disconnect # Disconnect on lost focus (disabled to get notif) -flowkey:raven # js errors vjrj:piwik # Stats mdg:geolocation -natestrauser:publish-performant-counts -maximum:server-transform -mys:fonts # fonst in npm +tcef:publish-performant-counts ostrio:mailer # mailer # babrahams:constellation # dev utility percolate:migrations # db migrations ostrio:meteor-root -meteortesting:mocha -practicalmeteor:chai aldeed:schema-deny -aldeed:template-extension dburles:collection-helpers -less@2.7.11 -markdown@1.0.12 -mongo-livedata@1.0.12 reywood:publish-composite -barbatus:stars-rating -arkham:comments-ui -facts@1.0.9 -gadicohen:sitemaps +# Bootstrap CSS/JS now comes from the `bootstrap` npm package (BS5), imported in +# imports/startup/client/index.js. The old alexwine:bootstrap-4 package (BS4 CSS + +# jQuery + BS4 JS) was removed after the navbar/carousel/dropdown/feedback widgets +# were migrated off jQuery. +facts-base@1.0.2 nspangler:autoreconnect -saucecode:timezoned-synced-cron +quave:synced-cron # lmachens:kadira -meteorhacks:zones -nimble:restivus -xolvio:cleaner +nimble:restivus@0.8.12 +underscore@1.6.4 + +meteortesting:mocha # test driver (see `npm test` / `npm run test-watch`) diff --git a/.meteor/release b/.meteor/release index 011385b..8d20e1a 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@1.6.1.1 +METEOR@3.1 diff --git a/.meteor/versions b/.meteor/versions index 9cbf1c3..506bd9a 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -1,155 +1,126 @@ 255kb:meteor-status@1.5.0 -accounts-base@1.4.2 -accounts-facebook@1.3.1 -accounts-github@1.4.1 -accounts-google@1.3.1 -accounts-oauth@1.1.15 -accounts-password@1.5.1 -alanning:roles@1.2.16 -aldeed:collection2-core@2.1.2 -aldeed:schema-deny@2.0.1 -aldeed:template-extension@4.1.0 -alexwine:bootstrap-4@4.1.0 -allow-deny@1.1.0 -arkham:comments-ui@1.4.4 -audit-argument-checks@1.0.7 -autoupdate@1.4.0 -babel-compiler@7.0.7 -babel-runtime@1.2.2 -barbatus:stars-rating@1.1.1 -base64@1.0.11 -binary-heap@1.0.10 -blaze@2.3.2 -blaze-tools@1.0.10 -boilerplate-generator@1.4.0 -browser-policy@1.1.0 -browser-policy-common@1.0.11 -browser-policy-content@1.1.0 -browser-policy-framing@1.1.0 -caching-compiler@1.1.11 -caching-html-compiler@1.1.2 -callback-hook@1.1.0 -check@1.3.1 -coffeescript@1.0.17 -dburles:collection-helpers@1.1.0 -dburles:mongo-collection-instances@0.3.5 -ddp@1.4.0 -ddp-client@2.3.2 -ddp-common@1.4.0 -ddp-rate-limiter@1.0.7 -ddp-server@2.1.2 -deps@1.0.12 -diff-sequence@1.1.0 -dynamic-import@0.3.0 -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 +accounts-base@3.0.3 +accounts-facebook@1.3.4 +accounts-github@1.5.1 +accounts-google@1.4.1 +accounts-oauth@1.4.5 +accounts-password@3.0.3 +aldeed:collection2@4.2.0 +aldeed:schema-deny@4.0.2 +aldeed:simple-schema@1.13.1 +allow-deny@2.0.0 +audit-argument-checks@1.0.8 +autoupdate@2.0.0 +babel-compiler@7.11.2 +babel-runtime@1.5.2 +base64@1.0.13 +binary-heap@1.0.12 +blaze@3.0.3 +blaze-tools@2.0.1 +boilerplate-generator@2.0.0 +caching-compiler@2.0.1 +caching-html-compiler@2.0.1 +callback-hook@1.6.0 +check@1.4.4 +core-runtime@1.0.0 +dburles:collection-helpers@1.0.0 +ddp@1.4.2 +ddp-client@3.0.3 +ddp-common@1.4.4 +ddp-rate-limiter@1.2.2 +ddp-server@3.0.3 +diff-sequence@1.1.3 +dynamic-import@0.7.4 +ecmascript@0.16.10 +ecmascript-runtime@0.8.3 +ecmascript-runtime-client@0.12.2 +ecmascript-runtime-server@0.11.1 +ejson@1.1.4 +email@3.1.1 +es5-shim@4.8.1 +facebook-oauth@1.11.4 +facts-base@1.0.2 +fetch@0.1.5 fortawesome:fontawesome@4.7.0 -fourseven:scss@4.5.4 -gadicc:blaze-react-component@1.4.0 -gadicohen:robots-txt@0.0.10 -gadicohen:sitemaps@0.0.26 -geojson-utils@1.0.10 -github-oauth@1.2.0 -google-oauth@1.2.5 -hot-code-push@1.0.4 -html-tools@1.0.11 -htmljs@1.0.11 -http@1.4.0 -id-map@1.1.0 +fourseven:scss@5.0.0 +gadicc:blaze-react-component@2.0.1 +geojson-utils@1.0.12 +github-oauth@1.4.2 +google-oauth@1.4.5 +hot-code-push@1.0.5 +html-tools@2.0.1 +htmljs@2.0.2 +id-map@1.2.0 +inter-process-messaging@0.1.2 jquery@1.11.11 -lai:collection-extensions@0.2.1_1 -launch-screen@1.1.1 -less@2.7.12 -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 +launch-screen@2.0.1 +localstorage@1.2.1 +logging@1.3.5 mdg:geolocation@1.3.0 -meteor@1.8.6 -meteor-base@1.3.0 -meteorhacks:inject-initial@1.0.4 -meteorhacks:zones@1.6.0 -meteortesting:browser-tests@0.2.0 -meteortesting:mocha@0.5.1 -minifier-css@1.3.1 -minifier-js@2.3.4 -minimongo@1.4.4 -mobile-experience@1.0.5 -mobile-status-bar@1.0.14 -modules@0.11.6 -modules-runtime@0.9.2 -mongo@1.4.7 -mongo-dev-server@1.1.0 -mongo-id@1.0.7 -mongo-livedata@1.0.12 -mys:fonts@0.0.2 -natestrauser:publish-performant-counts@0.1.2 +meteor@2.0.2 +meteor-base@1.5.2 +meteortesting:browser-tests@1.8.0 +meteortesting:mocha@3.3.0 +meteortesting:mocha-core@8.2.0 +minifier-css@2.0.0 +minifier-js@3.0.1 +minimongo@2.0.2 +mobile-experience@1.1.2 +mobile-status-bar@1.1.1 +modern-browsers@0.1.11 +modules@0.20.3 +modules-runtime@0.13.2 +mongo@2.0.3 +mongo-decimal@0.2.0 +mongo-dev-server@1.1.1 +mongo-id@1.0.9 +mongo-livedata@1.0.13 nimble:restivus@0.8.12 -npm-bcrypt@0.9.3 -npm-mongo@2.2.34 +npm-mongo@6.10.0 nspangler:autoreconnect@0.0.1 -oauth@1.2.3 -oauth2@1.2.0 -observe-sequence@1.0.16 -ordered-dict@1.1.0 -ostrio:cookies@2.1.3 -ostrio:mailer@2.1.0 +oauth@3.0.0 +oauth2@1.3.3 +observe-sequence@2.0.1 +ordered-dict@1.2.0 +ostrio:mailer@2.5.0 ostrio:meteor-root@1.0.7 -peerlibrary:assert@0.2.5 -peerlibrary:fiber-utils@0.6.0 -peerlibrary:reactive-mongo@0.1.1 -peerlibrary:server-autorun@0.5.2 -percolate:migrations@1.0.2 -practicalmeteor:chai@2.1.0_1 -practicalmeteor:mocha-core@1.0.1 -promise@0.10.2 -raix:eventemitter@0.1.3 -random@1.1.0 -rate-limit@1.0.9 -react-meteor-data@0.2.16 -reactive-dict@1.2.0 -reactive-var@1.0.11 -reload@1.2.0 -retry@1.1.0 +percolate:migrations@2.0.1 +promise@1.0.0 +quave:synced-cron@2.4.0 +raix:eventemitter@1.0.0 +random@1.2.2 +rate-limit@1.1.2 +react-fast-refresh@0.2.9 +react-meteor-data@3.0.6 +reactive-dict@1.3.2 +reactive-var@1.0.13 +reload@1.3.2 +retry@1.1.1 reywood:publish-composite@1.6.0 -routepolicy@1.0.13 -saucecode:timezoned-synced-cron@1.2.11 -selaias:cookie-consent@0.4.0 -server-render@0.3.0 -service-configuration@1.0.11 -session@1.1.7 -sha@1.0.9 -shell-server@0.3.1 -shim-common@0.1.0 -simple:json-routes@2.1.0 -socket-stream-client@0.1.0 -spacebars@1.0.15 -spacebars-compiler@1.1.3 -srp@1.0.10 -standard-minifier-css@1.4.1 -standard-minifier-js@2.3.3 -static-html@1.2.2 -templating@1.3.2 -templating-compiler@1.3.3 -templating-runtime@1.3.2 -templating-tools@1.1.2 -themeteorchef:bert@2.1.3 -tmeasday:check-npm-versions@0.3.2 -tracker@1.1.3 -ui@1.0.13 -underscore@1.0.10 -url@1.2.0 +routepolicy@1.1.2 +service-configuration@1.3.5 +session@1.2.2 +sha@1.0.10 +shell-server@0.6.1 +simple:json-routes@3.0.0 +socket-stream-client@0.5.3 +spacebars@2.0.1 +spacebars-compiler@2.0.1 +standard-minifier-css@1.9.3 +standard-minifier-js@3.0.0 +static-html@1.4.0 +static-html-tools@1.0.0 +tcef:publish-performant-counts@0.2.0 +templating@1.4.4 +templating-compiler@2.0.1 +templating-runtime@2.0.2 +templating-tools@2.0.1 +themeteorchef:bert@1.1.0 +tracker@1.3.4 +typescript@5.6.3 +underscore@1.6.4 +url@1.3.5 vjrj:piwik@0.3.1 -webapp@1.5.0 -webapp-hashing@1.0.9 -xolvio:cleaner@0.3.3 +webapp@2.0.4 +webapp-hashing@1.1.2 +zodern:types@1.0.13 diff --git a/.meteorignore b/.meteorignore index b25101c..83c45df 100644 --- a/.meteorignore +++ b/.meteorignore @@ -1,3 +1,11 @@ # Don't reload meteor server when we modify or do test cucumber 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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d3d0113 --- /dev/null +++ b/.npmrc @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e8176ea --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/PENDIENTE.md b/PENDIENTE.md new file mode 100644 index 0000000..144e6a7 --- /dev/null +++ b/PENDIENTE.md @@ -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/`, +`/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 `` 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` → ``, + conserva `.lazy` vía `onSlide`), dropdowns de idioma/tipo (`Profile.js`/ + `Fires.js` → ``), 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`. diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..afa81e5 --- /dev/null +++ b/RUNBOOK.md @@ -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 ` + `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). diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..f71fdbb --- /dev/null +++ b/UPGRADE.md @@ -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`. `` unchanged (locale JSON already stores the indexed-tag format `<1><0>{{x}}`). ReSendEmail: removed `` (deleted in v10) → ``, 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, `` interpolation (`{{countTotal}}`+``) 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. ``→``; `.leafletElement` refs (6 files/13 sites) → the map/layer instances directly (via a `` child using `useMap`, and plain refs on Marker/Circle/GeoJSON); the controlled-viewport pattern (`onViewportChanged`+`state.center/zoom`) → `` (`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. ``→``, `component=`→`element=`, `Authenticated`/`Public` become guard components rendering `children` or ``, `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 `` 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 `` children API across the 15 pages; `` wraps `` 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). diff --git a/bench/ddp-load.js b/bench/ddp-load.js new file mode 100644 index 0000000..f8ad565 --- /dev/null +++ b/bench/ddp-load.js @@ -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); +}); diff --git a/bench/treeUnionWorker.js b/bench/treeUnionWorker.js new file mode 100644 index 0000000..138688f --- /dev/null +++ b/bench/treeUnionWorker.js @@ -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 }); +} diff --git a/bench/union-bench.js b/bench/union-bench.js new file mode 100644 index 0000000..63242b9 --- /dev/null +++ b/bench/union-bench.js @@ -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); +}); diff --git a/client/00-collection2-init.js b/client/00-collection2-init.js new file mode 100644 index 0000000..cf6a97e --- /dev/null +++ b/client/00-collection2-init.js @@ -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'; diff --git a/client/main.js b/client/main.js index f139266..7e98193 100644 --- a/client/main.js +++ b/client/main.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'; // https://github.com/GoogleChrome/rendertron#rendering-budget-timeout diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..74a986a --- /dev/null +++ b/docker-compose.e2e.yml @@ -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" diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..c4fca78 --- /dev/null +++ b/docker-compose.staging.yml @@ -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= 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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f83c85a --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/web-entrypoint.sh b/docker/web-entrypoint.sh new file mode 100644 index 0000000..8d3ff5a --- /dev/null +++ b/docker/web-entrypoint.sh @@ -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 "$@" diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..100897a --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +playwright-report/ +test-results/ +qa-shots/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..78f6606 --- /dev/null +++ b/e2e/README.md @@ -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. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..25508f8 --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..bb7aaac --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..d7e8e99 --- /dev/null +++ b/e2e/playwright.config.js @@ -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'] } } + ] +}); diff --git a/e2e/seed.js b/e2e/seed.js new file mode 100644 index 0000000..5d682b9 --- /dev/null +++ b/e2e/seed.js @@ -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'); diff --git a/e2e/staging-qa.mjs b/e2e/staging-qa.mjs new file mode 100644 index 0000000..d74b5f0 --- /dev/null +++ b/e2e/staging-qa.mjs @@ -0,0 +1,259 @@ +#!/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'); +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); +} + +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); }; + +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'); + 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 () => { + await page.locator('.leaflet-control-layers-toggle').first().hover(); + 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(); + 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'); + await check('hay un fuego al que entrar', async () => { + await page.goto(`${BASE}/fires`, { waitUntil: 'domcontentloaded' }); + await mapReady(); + const markers = await page.locator('.leaflet-marker-icon, .leaflet-interactive').count(); + assert(markers > 0, 'el mapa de fuegos no muestra ninguna marca'); + return `${markers} marcas`; + }); + await check('el detalle de un fuego abre con su mapa', async () => { + await page.locator('.leaflet-interactive').first().click(); + await page.waitForURL(/\/fire\//, { timeout: 30000 }); + await visible('h4.page-header'); + await mapReady(); + 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.waitForTimeout(3000); + const label = await enPage.locator('#moniZones').innerText().catch(() => ''); + await enContext.close(); + assert(/Monitored/i.test(label), `la navegación sigue en español: "${label}"`); + return `#moniZones = "${label}"`; + }); + await check('el mismo servidor sirve español a un navegador en español', async () => { + await page.goto(BASE, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + const label = await page.locator('#moniZones').innerText(); + assert(/Zonas/i.test(label), `esperaba español, salió "${label}"`); + return `#moniZones = "${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 text = await page.locator('body').innerText(); + assert(text.length > 200, 'la página viene casi vacía'); + return `${text.length} 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('#tos'); + await page.click('#signUpSubmit'); + 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.click('#logout'); + await visible('#login'); + 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); +}); diff --git a/e2e/support/app.js b/e2e/support/app.js new file mode 100644 index 0000000..0237de4 --- /dev/null +++ b/e2e/support/app.js @@ -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 }); +}; diff --git a/e2e/tests/auth.spec.js b/e2e/tests/auth.spec.js new file mode 100644 index 0000000..88d3b69 --- /dev/null +++ b/e2e/tests/auth.spec.js @@ -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(); + }); +}); diff --git a/e2e/tests/fire-comment.spec.js b/e2e/tests/fire-comment.spec.js new file mode 100644 index 0000000..98bd9b8 --- /dev/null +++ b/e2e/tests/fire-comment.spec.js @@ -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/, 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); + }); +}); diff --git a/e2e/tests/fires-map.spec.js b/e2e/tests/fires-map.spec.js new file mode 100644 index 0000000..92ec5dd --- /dev/null +++ b/e2e/tests/fires-map.spec.js @@ -0,0 +1,70 @@ +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. +// +// El arreglo bueno pasa por que el conjunto de capas base esté decidido ANTES de +// montar el mapa (resolver la clave más arriba, o que GoogleMutantLayer sepa no +// hacer nada sin clave). +// +// 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); + }); +}); diff --git a/e2e/tests/i18n.spec.js b/e2e/tests/i18n.spec.js new file mode 100644 index 0000000..8ae38b2 --- /dev/null +++ b/e2e/tests/i18n.spec.js @@ -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 }); + }); +}); diff --git a/e2e/tests/zone-union.spec.js b/e2e/tests/zone-union.spec.js new file mode 100644 index 0000000..4eb5cbf --- /dev/null +++ b/e2e/tests/zone-union.spec.js @@ -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 }); + }); +}); diff --git a/imports/api/ActiveFires/server/countFires.js b/imports/api/ActiveFires/server/countFires.js index b3b8511..116ae5b 100644 --- a/imports/api/ActiveFires/server/countFires.js +++ b/imports/api/ActiveFires/server/countFires.js @@ -29,40 +29,41 @@ const findFiresInRegion = (zone) => { return result; }; -export const countRealFires = (firesCursor) => { +export const countRealFires = async (firesCursor) => { 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)} -----`); - const union = firesUnion([fire]); + const union = await firesUnion([fire]); if (debug) console.log(`${JSON.stringify(union)} -----`); const falsePos = whichAreFalsePositives(FalsePositives, 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); } - }); + } // group fires - const realUnion = firesUnion(realFires); + const realUnion = await firesUnion(realFires); const unionCount = realUnion[0] && realUnion[0].geometry && realUnion[0].geometry.coordinates ? realUnion[0].geometry.coordinates.length : 0; return unionCount; }; -const countFiresInRegions = (regions, stringsToRemove) => { +const countFiresInRegions = async (regions, stringsToRemove) => { let total = 0; const fireStats = {}; - regions.features.forEach((region) => { + for (const region of regions.features) { const regionName = cleanProv(region.properties.name, stringsToRemove); const regionCode = region.properties.iso_a2; if (debug) console.log(`${regionName} -----`); try { const fires = findFiresInRegion(region); // TODO Also check neighbour fires (when better implementation of that part) - const initialCount = fires.count(); + const initialCount = await fires.countAsync(); if (initialCount > 0) { - const unionCount = countRealFires(fires); + const unionCount = await countRealFires(fires); if (debug) console.log(`${regionName} initial: ${initialCount}, union calc: ${unionCount}`); if (unionCount > 0) { total += unionCount; @@ -72,7 +73,7 @@ const countFiresInRegions = (regions, stringsToRemove) => { } catch (e) { ravenLogger.log(e); } - }); + } return { total, fires: fireStats }; }; diff --git a/imports/api/ActiveFires/server/publications.js b/imports/api/ActiveFires/server/publications.js index 03878b3..66c9991 100644 --- a/imports/api/ActiveFires/server/publications.js +++ b/imports/api/ActiveFires/server/publications.js @@ -16,7 +16,7 @@ Meteor.publish('activefirestotal', function total() { return counter; }); -const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => { +const activefires = async (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => { const fires = ActiveFires.find({ ourid: { $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) { - const union = firesUnion(fires); + if (withMarks && (await fires.fetchAsync()).length > 0) { + const union = await firesUnion(fires); const falsePos = whichAreFalsePositives(FalsePositives, union); const industries = whichAreFalsePositives(Industries, union); return [fires, falsePos, industries]; @@ -48,7 +48,7 @@ const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, wit 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 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); diff --git a/imports/api/ActiveFiresUnion/server/publications.js b/imports/api/ActiveFiresUnion/server/publications.js index 6a126d8..f3d8db7 100644 --- a/imports/api/ActiveFiresUnion/server/publications.js +++ b/imports/api/ActiveFiresUnion/server/publications.js @@ -16,7 +16,7 @@ Meteor.publish('activefiresuniontotal', function total() { return counter; }); -const activeFiresUnion = (b1, b2, c1, c2, withMarks) => { +const activeFiresUnion = async (b1, b2, c1, c2, withMarks) => { // a --- b // c --- d 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) { @@ -59,7 +59,7 @@ const activeFiresUnion = (b1, b2, c1, c2, withMarks) => { 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 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); diff --git a/imports/api/Comments/Comments.js b/imports/api/Comments/Comments.js new file mode 100644 index 0000000..a45a80f --- /dev/null +++ b/imports/api/Comments/Comments.js @@ -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-', 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; diff --git a/imports/api/Comments/mediaAnalyzers.js b/imports/api/Comments/mediaAnalyzers.js new file mode 100644 index 0000000..7b7a10b --- /dev/null +++ b/imports/api/Comments/mediaAnalyzers.js @@ -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 {}; +} diff --git a/imports/api/Comments/server/index.js b/imports/api/Comments/server/index.js new file mode 100644 index 0000000..40b4368 --- /dev/null +++ b/imports/api/Comments/server/index.js @@ -0,0 +1,3 @@ +// Server-side registration for the Comments feature. +import './methods'; +import './publications'; diff --git a/imports/api/Comments/server/methods.js b/imports/api/Comments/server/methods.js new file mode 100644 index 0000000..fd491cd --- /dev/null +++ b/imports/api/Comments/server/methods.js @@ -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 +}); diff --git a/imports/api/Comments/server/onCommentAdd.js b/imports/api/Comments/server/onCommentAdd.js new file mode 100644 index 0000000..d747707 --- /dev/null +++ b/imports/api/Comments/server/onCommentAdd.js @@ -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}`); + }); +} diff --git a/imports/api/Comments/server/publications.js b/imports/api/Comments/server/publications.js new file mode 100644 index 0000000..dbd2b0b --- /dev/null +++ b/imports/api/Comments/server/publications.js @@ -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 } } + ); +}); diff --git a/imports/api/Common/id.js b/imports/api/Common/id.js new file mode 100644 index 0000000..d419982 --- /dev/null +++ b/imports/api/Common/id.js @@ -0,0 +1,15 @@ +/* + * Fire/collection docs use `idGeneration: 'MONGO'`, so their `_id` is a + * `Mongo.ObjectID`, whose `.toString()` yields `ObjectID("")` — 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; diff --git a/imports/api/FalsePositives/methods.js b/imports/api/FalsePositives/methods.js index 85005ba..2caf2b9 100644 --- a/imports/api/FalsePositives/methods.js +++ b/imports/api/FalsePositives/methods.js @@ -6,7 +6,7 @@ import FalsePositiveTypes from './FalsePositiveTypes'; import Fires from '../Fires/Fires'; import rateLimit from '../../modules/rate-limit'; -export function upsertFalsePositive(keyType, owner, fire) { +export async function upsertFalsePositive(keyType, owner, fire) { const fireType = fire.type; if (fireType === 'vecinal') { 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, 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) { console.log(exception); throw new Meteor.Error('500', exception); @@ -30,7 +30,7 @@ export function upsertFalsePositive(keyType, owner, fire) { } Meteor.methods({ - 'falsePositives.insert': function falsePositivesInsert(fireId, keyType) { + 'falsePositives.insert': async function falsePositivesInsert(fireId, keyType) { check(fireId, Meteor.Collection.ObjectID); check(keyType, String); 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'); } check(this.userId, String); - const fire = Fires.findOne(fireId); + const fire = await Fires.findOneAsync(fireId); const owner = this.userId; if (fire) { - upsertFalsePositive(keyType, owner, fire); + await upsertFalsePositive(keyType, owner, fire); } else { throw new Meteor.Error('500', 'Fuego no encontrado'); } diff --git a/imports/api/FalsePositives/server/publications.js b/imports/api/FalsePositives/server/publications.js index a79ae1a..df6377d 100644 --- a/imports/api/FalsePositives/server/publications.js +++ b/imports/api/FalsePositives/server/publications.js @@ -17,8 +17,8 @@ Meteor.publish('falsePositivesTotal', function total() { return counter; }); -export const firesUnion = (fires) => { - const firesArray = Array.isArray(fires) ? fires : fires.fetch(); // if not is a cursor +export const firesUnion = async (fires) => { + const firesArray = Array.isArray(fires) ? fires : await fires.fetchAsync(); // if not is a cursor const remap = firesArray.map(function remap(doc) { const isNASA = doc.type === 'modis' || doc.type === 'viirs'; 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; }; -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 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); @@ -94,7 +94,7 @@ Meteor.publishTransformed('falsePositivesMyloc', function falsePositivesInMyLoc( 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 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); diff --git a/imports/api/FireAlerts/server/publications.js b/imports/api/FireAlerts/server/publications.js index 6642f35..a3d5350 100644 --- a/imports/api/FireAlerts/server/publications.js +++ b/imports/api/FireAlerts/server/publications.js @@ -6,7 +6,7 @@ import { check } from 'meteor/check'; import { NumberBetween } from '/imports/modules/server/other-checks'; 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 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); @@ -37,6 +37,6 @@ Meteor.publish('fireAlerts', function fireAlerts(northEastLng, northEastLat, sou 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; }); diff --git a/imports/api/Fires/server/publications.js b/imports/api/Fires/server/publications.js index 3862beb..2f56979 100644 --- a/imports/api/Fires/server/publications.js +++ b/imports/api/Fires/server/publications.js @@ -53,12 +53,12 @@ const fixConfidence = (obj) => { return obj; }; -const findOrCreateFire = (obj) => { +const findOrCreateFire = async (obj) => { const fire = findFire(obj); - // console.log(`Found: ${fire.count()}`); + // console.log(`Found: ${await fire.countAsync()}`); if (!obj.address) { 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]) { obj.address = rev[0].formattedAddress; } @@ -67,23 +67,23 @@ const findOrCreateFire = (obj) => { console.warn(reve); } } - if (fire.count() === 0) { + if (await fire.countAsync() === 0) { // const result = // 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)); } return findFire(obj); }; -Meteor.publish('fireFromAlertId', function fireFromAlertId(_id) { +Meteor.publish('fireFromAlertId', async function fireFromAlertId(_id) { try { check(_id, String); // 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) { // console.info(`Active fire found: ${_id}`); - return findOrCreateFire(fixConfidence(fire)); + return await findOrCreateFire(fixConfidence(fire)); } console.info(`Alert fire not found: ${_id}`); // 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 { check(_id, String); // 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) { // console.info(`Active fire found: ${_id}`); - return findOrCreateFire(fixConfidence(fire)); + return await findOrCreateFire(fixConfidence(fire)); } console.info(`Active fire not found: ${_id}`); // 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 { check(_id, String); // console.log(`Looking for archive fire ${_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}`); - const union = firesUnion(fire); + const union = await firesUnion(fire); const falsePos = whichAreFalsePositives(FalsePositives, union); const industries = whichAreFalsePositives(Industries, union); return [fire, falsePos, industries]; @@ -139,13 +139,12 @@ function logUrl(fireEnc, params) { ravenLogger.log(message); } -export function fireFromHash(fireEnc, params) { +export async function fireFromHash(fireEnc, params) { check(fireEnc, String); check(params, Object); // console.log(fireEnc); - // const unsealed = Promise.await(urlEnc.decrypt(fireEnc)); - const unsealed = Promise.await(unsealW(fireEnc)); + const unsealed = await unsealW(fireEnc); if (unsealed === undefined) { throw Error(`Fail to unseal '${fireEnc}'`); } @@ -159,16 +158,16 @@ export function fireFromHash(fireEnc, params) { // FIXME: const unsealedFix = fixConfidence(unsealed); FiresCollection.schema.validate(unsealedFix); - return findOrCreateFire(unsealedFix); + return await findOrCreateFire(unsealedFix); /* console.log(`fires: ${fire.count()}`); * return fire; */ } -Meteor.publish('fireFromHash', function fireFromHashFunc(fireEnc, params) { +Meteor.publish('fireFromHash', async function fireFromHashFunc(fireEnc, params) { check(fireEnc, String); check(params, Object); try { - return fireFromHash(fireEnc, params); + return await fireFromHash(fireEnc, params); } catch (e) { console.warn(e); logUrl(fireEnc, params); diff --git a/imports/api/OAuth/server/methods.js b/imports/api/OAuth/server/methods.js index 6696da4..3f1df74 100644 --- a/imports/api/OAuth/server/methods.js +++ b/imports/api/OAuth/server/methods.js @@ -4,16 +4,16 @@ import { ServiceConfiguration } from 'meteor/service-configuration'; import rateLimit from '../../../modules/rate-limit'; Meteor.methods({ - 'oauth.verifyConfiguration': function oauthVerifyConfiguration(services) { + 'oauth.verifyConfiguration': async function oauthVerifyConfiguration(services) { check(services, Array); try { const verifiedServices = []; - services.forEach((service) => { - if (ServiceConfiguration.configurations.findOne({ service })) { + for (const service of services) { + if (await ServiceConfiguration.configurations.findOneAsync({ service })) { verifiedServices.push(service); } - }); + } return verifiedServices.sort(); } catch (exception) { throw new Meteor.Error('500', exception); diff --git a/imports/api/Rest/Rest.js b/imports/api/Rest/Rest.js index ae5e383..d7eb83f 100644 --- a/imports/api/Rest/Rest.js +++ b/imports/api/Rest/Rest.js @@ -89,8 +89,8 @@ if (!Meteor.settings.private.internalApiToken) { }, endpoints: { get: { - action: function getFire() { - return Fires.findOne(new Meteor.Collection.ObjectID(this.urlParams.id)); + action: async function getFire() { + 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 apiV1.addRoute('status/last-fire-check', { authRequired: false }, { - get: function get() { - return SiteSettings.findOne({ name: 'last-fire-check' }); + get: async function get() { + return SiteSettings.findOneAsync({ name: 'last-fire-check' }); } }); // Maps to: /api/v1/status/last-fire-detected apiV1.addRoute('status/last-fire-detected', { authRequired: false }, { - get: function get() { - return ActiveFiresCollection.findOne({}, { sort: { when: -1 } }); + get: async function get() { + return ActiveFiresCollection.findOneAsync({}, { sort: { when: -1 } }); } }); // Maps to: /api/v1/status/active-fires-count apiV1.addRoute('status/active-fires-count', { authRequired: false }, { - get: function get() { - return { total: ActiveFiresCollection.find({}).count() }; + get: async function get() { + 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 lng = Number(route.urlParams.lng); 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 (!full) { @@ -172,19 +176,16 @@ if (!Meteor.settings.private.internalApiToken) { let union; - // TODO only get real - const firesA = fires.fetch(); - if (firesA.length > 0) { - union = firesUnion(fires); + union = await firesUnion(firesA); } else { union = zoneToUnion(lat, lng, km); } const falsePos = whichAreFalsePositives(FalsePositives, union); const industries = whichAreFalsePositives(Industries, union); result.fires = firesA; - result.industries = industries.fetch(); - result.falsePos = falsePos.fetch(); + result.industries = await industries.fetchAsync(); + result.falsePos = await falsePos.fetchAsync(); return result; } @@ -192,13 +193,13 @@ if (!Meteor.settings.private.internalApiToken) { // 100 km max // 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 }, { - get: function get() { + get: async function get() { return getFires(this, false); } }); apiV1.addRoute('fires-in-full/:token/:lat/:lng/:km', { authRequired: false }, { - get: function get() { + get: async function get() { return getFires(this, true); } }); @@ -209,7 +210,7 @@ if (!Meteor.settings.private.internalApiToken) { // // https://docs.meteor.com/api/passwords.html#Accounts-createUser apiV1.addRoute('mobile/users', { authRequired: false }, { - post: function post() { + post: async function post() { const { token, mobileToken, lang } = this.bodyParams; try { check(token, String); @@ -225,22 +226,23 @@ if (!Meteor.settings.private.internalApiToken) { let username; 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?'); - } else if (already.count() === 1) { - username = already.fetch()[0].username; + } else if (alreadyCount === 1) { + username = (await already.fetchAsync())[0].username; } else { do { username = Random.id(15); - } while (Meteor.users.find({ username }).count() !== 0); + } while (await Meteor.users.find({ username }).countAsync() !== 0); } // FIXME check valid lang const now = new Date(); - const result = Meteor.users.upsert({ + const result = await Meteor.users.upsertAsync({ fireBaseToken: mobileToken }, { $set: { @@ -260,7 +262,7 @@ if (!Meteor.settings.private.internalApiToken) { console.log(this.queryParams); } - const upsertUser = Meteor.users.findOne({ username }); + const upsertUser = await Meteor.users.findOneAsync({ username }); if (debug) console.log(upsertUser); @@ -277,7 +279,7 @@ if (!Meteor.settings.private.internalApiToken) { // max sitance: 100km apiV1.addRoute('mobile/subscriptions', { authRequired: false }, { - post: function post() { + post: async function post() { const { token, mobileToken, @@ -305,7 +307,7 @@ if (!Meteor.settings.private.internalApiToken) { const failed = checkAuthToken(token); 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'); const newSubs = {}; @@ -317,7 +319,7 @@ if (!Meteor.settings.private.internalApiToken) { let result; try { - result = subscriptionsInsert(newSubs, user._id, 'mobile'); + result = await subscriptionsInsert(newSubs, user._id, 'mobile'); } catch (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//` 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 }, { - delete: function del() { + delete: async function del() { const { token, mobileToken, @@ -343,11 +394,11 @@ if (!Meteor.settings.private.internalApiToken) { const failed = checkAuthToken(token); 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'); 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) { 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 }, { - get: function get() { + get: async function get() { const { token } = this.urlParams; try { check(token, String); @@ -412,15 +419,15 @@ if (!Meteor.settings.private.internalApiToken) { const failed = checkAuthToken(token); if (failed) return failed; - const currentUnion = SiteSettings.findOne({ name: 'subs-public-union' }); - const userSubsBounds = SiteSettings.findOne({ name: 'subs-public-union-bounds' }); + const currentUnion = await SiteSettings.findOneAsync({ name: 'subs-public-union' }); + const userSubsBounds = await SiteSettings.findOneAsync({ name: 'subs-public-union-bounds' }); return jsend.success({ union: currentUnion, bounds: userSubsBounds }); } }); apiV1.addRoute('mobile/falsepositive', { authRequired: false }, { - post: function post() { + post: async function post() { const { token, mobileToken, @@ -440,15 +447,15 @@ if (!Meteor.settings.private.internalApiToken) { const failed = checkAuthToken(token); 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'); 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') { - const fire = fireC.fetch()[0]; + const fire = (await fireC.fetchAsync())[0]; 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 failMsg('Cannot mark fire as false positive'); diff --git a/imports/api/Subscriptions/methods.js b/imports/api/Subscriptions/methods.js index cf927e0..63eaa17 100644 --- a/imports/api/Subscriptions/methods.js +++ b/imports/api/Subscriptions/methods.js @@ -10,7 +10,7 @@ function geo(doc) { }; } -export function subscriptionsInsert(doc, userId, type) { +export async function subscriptionsInsert(doc, userId, type) { check(doc, { _id: Match.Maybe(Meteor.Collection.ObjectID), location: Match.ObjectIncluding({ lat: Number, lon: Number }), @@ -23,23 +23,23 @@ export function subscriptionsInsert(doc, userId, type) { ...doc }; // console.log(newDoc); - const already = Subscriptions.findOne(newDoc); + const already = await Subscriptions.findOneAsync(newDoc); if (already) { throw new Meteor.Error('on-already-subscribed', 'The user is already subscribed to this area'); } try { - return Subscriptions.insert(newDoc); + return await Subscriptions.insertAsync(newDoc); } catch (exception) { // console.error(exception); throw new Meteor.Error('500', exception); } } -export function subscriptionsRemove(subscriptionId) { +export async function subscriptionsRemove(subscriptionId) { check(subscriptionId, Meteor.Collection.ObjectID); try { - return Subscriptions.remove(subscriptionId); + return await Subscriptions.removeAsync(subscriptionId); } catch (exception) { console.error(exception); throw new Meteor.Error('500', exception); @@ -54,7 +54,7 @@ Meteor.methods({ }); return subscriptionsInsert(doc, this.userId, 'web'); }, - 'subscriptions.update': function subscriptionsUpdate(doc) { + 'subscriptions.update': async function subscriptionsUpdate(doc) { check(doc, { _id: Meteor.Collection.ObjectID, location: Match.ObjectIncluding({ lat: Number, lon: Number }), @@ -65,7 +65,7 @@ Meteor.methods({ const dup = doc; const subscriptionId = doc._id; 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. } catch (exception) { throw new Meteor.Error('500', exception); diff --git a/imports/api/Users/server/edit-profile.js b/imports/api/Users/server/edit-profile.js index fc44093..2bdea94 100644 --- a/imports/api/Users/server/edit-profile.js +++ b/imports/api/Users/server/edit-profile.js @@ -4,9 +4,9 @@ import { Meteor } from 'meteor/meteor'; let action; -const updateUser = (userId, { emailAddress, profile }) => { +const updateUser = async (userId, { emailAddress, profile }) => { try { - Meteor.users.update(userId, { + await Meteor.users.updateAsync(userId, { $set: { 'emails.0.address': emailAddress, profile, @@ -17,10 +17,10 @@ const updateUser = (userId, { emailAddress, profile }) => { } }; -const editProfile = ({ userId, profile }, promise) => { +const editProfile = async ({ userId, profile }, promise) => { try { action = promise; - updateUser(userId, profile); + await updateUser(userId, profile); action.resolve(); } catch (exception) { action.reject(`[editProfile.handler] ${exception}`); diff --git a/imports/api/Users/server/methods.js b/imports/api/Users/server/methods.js index dc3c232..62f8fbd 100644 --- a/imports/api/Users/server/methods.js +++ b/imports/api/Users/server/methods.js @@ -33,9 +33,9 @@ Meteor.methods({ throw new Meteor.Error('500', exception); }); }, - 'users.setLang': function userLang(lang) { + 'users.setLang': async function userLang(lang) { 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() { const token = Accounts._generateStampedLoginToken(); diff --git a/imports/modules/rate-limit.js b/imports/modules/rate-limit.js index d98096f..dd88ce3 100644 --- a/imports/modules/rate-limit.js +++ b/imports/modules/rate-limit.js @@ -9,3 +9,15 @@ export default ({ methods, 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); + } +}; diff --git a/imports/modules/server/notificationsProcess.js b/imports/modules/server/notificationsProcess.js deleted file mode 100644 index 657aa2a..0000000 --- a/imports/modules/server/notificationsProcess.js +++ /dev/null @@ -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 ``; - } */ - -// 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 = `${i18n.t('Más información sobre este fuego')}`; - // 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: '

{{appName}}

{{{html}}}', - 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; diff --git a/imports/startup/client/Gkeys.js b/imports/startup/client/Gkeys.js index 0a1f83e..aa9325e 100644 --- a/imports/startup/client/Gkeys.js +++ b/imports/startup/client/Gkeys.js @@ -6,6 +6,13 @@ import GoogleMapsLoader from 'google-maps'; class GkeysC { constructor() { 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
— /subscriptions/new was blank, with no + // error anywhere. + this.loaded = new ReactiveVar(false); const self = this; this.callbacks = []; Meteor.startup(() => { @@ -13,8 +20,14 @@ class GkeysC { // console.log(google.maps); GoogleMapsLoader.KEY = key; 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(() => { self.gmapkey.set(key); + self.loaded.set(true); console.log('GMaps script just loaded'); self.doCallbacks(key); }); @@ -32,14 +45,12 @@ class GkeysC { load(callback) { this.callbacks.push(callback); Tracker.autorun((computation) => { - const key = this.gmapkey.get(); - if (key) { - // already loaded - // console.log('GMaps already loaded'); - this.doCallbacks(key); + if (this.loaded.get()) { + // already loaded; the key may legitimately be empty + this.doCallbacks(this.gmapkey.get()); computation.stop(); } else { - console.log('Waiting for the gkey'); + console.log('Waiting for the gmaps script'); } }); } diff --git a/imports/startup/client/comments.js b/imports/startup/client/comments.js deleted file mode 100644 index 94cbe90..0000000 --- a/imports/startup/client/comments.js +++ /dev/null @@ -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 - }); -}); diff --git a/imports/startup/client/comments.scss b/imports/startup/client/comments.scss deleted file mode 100644 index de93e65..0000000 --- a/imports/startup/client/comments.scss +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/imports/startup/client/i18n.js b/imports/startup/client/i18n.js index 384053f..1ddf4ed 100644 --- a/imports/startup/client/i18n.js +++ b/imports/startup/client/i18n.js @@ -1,11 +1,10 @@ -/* global CookieConsent Intl */ +/* global Intl */ import i18n from 'i18next'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; 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 Cache from 'i18next-localstorage-cache'; import { T9n } from 'meteor-accounts-t9n'; import moment from 'moment'; import 'moment/locale/es'; @@ -31,21 +30,9 @@ const detectorOptions = { 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.react = { - wait: true, + useSuspense: false, // was `wait: true` pre-react-i18next-10 defaultTransParent: 'span' // https://react.i18next.com/components/i18next-instance.ht /* bindI18n: 'languageChanged loaded', @@ -55,13 +42,17 @@ i18nOpts.react = { const sendMissing = true; // Meteor.isDevelopment; if (sendMissing && Meteor.isDevelopment) { - i18nOpts.sendMissing = true; - i18nOpts.missingKeyHandler = function miss(lng, ns, key, defaultValue) { + i18nOpts.saveMissing = true; + // 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); }; } 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') { T9n.setLanguage('es'); } else { @@ -71,7 +62,6 @@ function setT9(lang) { i18n.use(backend) .use(LngDetector) - .use(Cache) .init(i18nOpts, (err, t) => { // initialized and ready to if (err) { @@ -89,23 +79,9 @@ i18n.use(backend) i18nReady.set(true); - // cookies eu consent - const cookiesOpt = { - cookieTitle: t('Uso de Cookies'), - 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); + // Cookie consent banner: selaias:cookie-consent (Blaze, dead on Meteor 3) + // replaced by the React component imports/ui/components/CookieConsent, + // which reuses the same i18n keys. }); Meteor.subscribe('userData'); // lang is there diff --git a/imports/startup/client/index.js b/imports/startup/client/index.js index 147cf4f..4b474be 100644 --- a/imports/startup/client/index.js +++ b/imports/startup/client/index.js @@ -1,6 +1,11 @@ /* 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 { render } from 'react-dom'; +import { createRoot } from 'react-dom/client'; +import { HelmetProvider } from 'react-helmet-async'; import { Meteor } from 'meteor/meteor'; import '/imports/startup/client/modernizr'; import App from '../../ui/layouts/App/App'; @@ -15,5 +20,9 @@ else { Modernizr.on('webp', (result) => { if (Meteor.isDevelopment) console.log(`webp ${result ? '' : 'not '}supported`); }); - Meteor.startup(() => render(, document.getElementById('react-root'))); + Meteor.startup(() => createRoot(document.getElementById('react-root')).render( + + + + )); } diff --git a/imports/startup/client/ravenLogger.js b/imports/startup/client/ravenLogger.js index d678591..8e8efb7 100644 --- a/imports/startup/client/ravenLogger.js +++ b/imports/startup/client/ravenLogger.js @@ -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'; -const ravenOptions = {}; -const publicDSN = Meteor.settings.public.sentryPublicDSN; -const enabled = !!publicDSN; +const dsn = Meteor.settings.public.sentryPublicDSN; +const enabled = !!dsn; -const ravenLogger = enabled ? new RavenLogger({ - publicDSN, // will be used on the client - shouldCatchConsoleError: true, // default - trackUser: true // default -}, ravenOptions) : { log: (error) => { console.log(error); } }; +if (enabled) { + Sentry.init({ + dsn, + environment: Meteor.isDevelopment ? 'development' : 'production', + 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; diff --git a/imports/startup/common/comments.js b/imports/startup/common/comments.js deleted file mode 100644 index 7b9ab18..0000000 --- a/imports/startup/common/comments.js +++ /dev/null @@ -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 : ''; - } -}); diff --git a/imports/startup/common/i18n.js b/imports/startup/common/i18n.js index 218e46d..e9bb761 100644 --- a/imports/startup/common/i18n.js +++ b/imports/startup/common/i18n.js @@ -3,7 +3,9 @@ import moment from 'moment'; // Load the js langs import es from 'meteor-accounts-t9n/build/es'; 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'; const backOpts = { @@ -40,13 +42,17 @@ const i18nOpts = { 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 debug: shouldDebug, ns: 'common', defaultNS: 'common', saveMissing: shouldDebug, // if true seems it's fails to getResourceBundle 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: 'ß', nsSeparator: 'ð', pluralSeparator: 'đ' diff --git a/imports/startup/server/IPGeocoder.js b/imports/startup/server/IPGeocoder.js index 96c5ef1..59a99af 100644 --- a/imports/startup/server/IPGeocoder.js +++ b/imports/startup/server/IPGeocoder.js @@ -21,11 +21,16 @@ function isPrivateIP(ip) { const dbpath = '/usr/local/share/maxmind-geolite2/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`); + IPGeocoder = { get() { return null; } }; } - -const IPGeocoder = maxmind.openSync(dbpath); export default IPGeocoder; // Warning: Meteor cannot access to this.connection with arrow functions @@ -47,7 +52,7 @@ export function localize() { // http://dev.maxmind.com/geoip/geoip2/geolite2/ const geo = IPGeocoder.get(clientIP); // console.warn(geo); - if (geo.location && geo.location.latitude && geo.location.longitude) { + if (geo && geo.location && geo.location.latitude && geo.location.longitude) { return geo; } // geoIP fallback, Madrid diff --git a/imports/startup/server/accounts/oauth.js b/imports/startup/server/accounts/oauth.js index 545519a..bc541b4 100644 --- a/imports/startup/server/accounts/oauth.js +++ b/imports/startup/server/accounts/oauth.js @@ -4,10 +4,12 @@ import { ServiceConfiguration } from 'meteor/service-configuration'; const OAuthSettings = Meteor.settings.private.OAuth; if (OAuthSettings) { - Object.keys(OAuthSettings).forEach((service) => { - ServiceConfiguration.configurations.upsert( + // Meteor 3: sync Mongo write-methods are gone on the server; use *Async. + // Top-level await is supported in eager server modules. + for (const service of Object.keys(OAuthSettings)) { + await ServiceConfiguration.configurations.upsertAsync( { service }, { $set: OAuthSettings[service] }, ); - }); + } } diff --git a/imports/startup/server/api.js b/imports/startup/server/api.js index a6044b4..88b68b6 100644 --- a/imports/startup/server/api.js +++ b/imports/startup/server/api.js @@ -15,8 +15,6 @@ import '../../api/FireAlerts/server/publications'; import '../../api/Subscriptions/methods'; import '../../api/Subscriptions/server/publications'; -// TODO add rate-limit to these publications - import '../../api/Notifications/methods'; import '../../api/Notifications/server/publications'; @@ -28,3 +26,26 @@ import '../../api/SiteSettings/server/publications'; import '../../api/FalsePositives/methods'; 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 +}); diff --git a/imports/startup/server/calcUnionAsync.js b/imports/startup/server/calcUnionAsync.js new file mode 100644 index 0000000..a24868a --- /dev/null +++ b/imports/startup/server/calcUnionAsync.js @@ -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; diff --git a/imports/startup/server/comments.js b/imports/startup/server/comments.js deleted file mode 100644 index b010405..0000000 --- a/imports/startup/server/comments.js +++ /dev/null @@ -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'); - } - } - }); -}); diff --git a/imports/startup/server/cron.js b/imports/startup/server/cron.js index 843e55b..0004916 100644 --- a/imports/startup/server/cron.js +++ b/imports/startup/server/cron.js @@ -4,8 +4,6 @@ import { Meteor } from 'meteor/meteor'; import { tweetIberiaFires, tweetEuropeFires } from '/imports/api/ActiveFires/server/tweetFiresInZone'; 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/ @@ -55,29 +53,11 @@ Meteor.startup(() => { job: () => sendEmailsFromQueue() }); - SyncedCron.add({ - name: 'Process pending notif', - timezone: 'Europe/Madrid', - schedule: (parser) => { - // http://bunkat.github.io/later/ - 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() }; - } - }); + // NOTE: the "Process pending notif" job (push via node-gcm + notification + // emails) was removed here — that responsibility now lives in the + // tcef-notifications microservice (fases 1a-1c). See UPGRADE.md for the + // deploy-ordering dependency (the old code must not be shut off in + // production until the new service is emitting). } const esEn = Meteor.settings.private.twitter.es.enabled; diff --git a/imports/startup/server/email.js b/imports/startup/server/email.js index 42752cd..8caf2f3 100644 --- a/imports/startup/server/email.js +++ b/imports/startup/server/email.js @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; import nodemailer from 'nodemailer'; -import { MailTime } from 'meteor/ostrio:mailer'; +import MailTime from 'meteor/ostrio:mailer'; import i18n from 'i18next'; import isMaster from './isMaster'; @@ -48,7 +48,8 @@ if (isMailServerMaster) { concatDelimiter: hr + '

{{{subject}}}

', // Start each concatenated email with it's own subject /* eslint-enable */ // 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 { console.log('I\'m a mail client'); diff --git a/imports/startup/server/facts.js b/imports/startup/server/facts.js index 0c75843..ac3e9df 100644 --- a/imports/startup/server/facts.js +++ b/imports/startup/server/facts.js @@ -1,9 +1,18 @@ -/* global Facts */ -import { Roles } from 'meteor/alanning:roles'; import { Meteor } from 'meteor/meteor'; +import { Facts } from 'meteor/facts-base'; -Facts.setUserIdFilter((userId) => { - const user = Meteor.users.findOne(userId); - // console.log(`User roles: ${user.roles}`); - return Roles.userIsInRole(userId, ['admin']); +/* + * alanning:roles removed (1.x is dead on Meteor 3 — crashes the whole client + * bundle at load — and 4.x needs a data migration we don't want). Admin check + * 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)); diff --git a/imports/startup/server/fibers.js b/imports/startup/server/fibers.js deleted file mode 100644 index ff96265..0000000 --- a/imports/startup/server/fibers.js +++ /dev/null @@ -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; diff --git a/imports/startup/server/fixtures.js b/imports/startup/server/fixtures.js index d608ab1..18f2683 100644 --- a/imports/startup/server/fixtures.js +++ b/imports/startup/server/fixtures.js @@ -1,54 +1,71 @@ -import seeder from '@cleverbeagle/seeder'; +/* eslint-disable no-await-in-loop */ import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; import Documents from '../../api/Documents/Documents'; -const documentsSeed = userId => ({ - collection: Documents, - environments: ['development', 'staging'], - noLimit: true, - modelCount: 5, - model(dataIndex) { - return { - owner: userId, - title: `Document #${dataIndex + 1}`, - body: `This is the body of document #${dataIndex + 1}`, - }; - }, -}); +/* + * Dev/staging fixtures. Replaces @cleverbeagle/seeder (sync Mongo, no Meteor 3 + * support) with a small async seeder: idempotent (find-before-create), only + * runs outside production. + */ -seeder(Meteor.users, { - environments: ['development', 'staging'], - noLimit: true, - data: [{ +const USERS = [ + { email: 'admin@admin.com', password: 'password', - profile: { - name: { - 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); - }, - }; + profile: { name: { first: 'Andy', last: 'Warhol' } }, + roles: ['admin'] }, + ...[ + ['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}`); + } }); diff --git a/imports/startup/server/i18n.js b/imports/startup/server/i18n.js index 053d487..90ac39a 100644 --- a/imports/startup/server/i18n.js +++ b/imports/startup/server/i18n.js @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; import i18n from 'i18next'; -import backend from 'i18next-sync-fs-backend'; +import backend from 'i18next-fs-backend'; import i18nOpts from '../common/i18n'; // import moment from 'moment'; // import { T9n } from 'meteor-accounts-t9n'; diff --git a/imports/startup/server/index.js b/imports/startup/server/index.js index 7f84dde..b267122 100644 --- a/imports/startup/server/index.js +++ b/imports/startup/server/index.js @@ -1,16 +1,15 @@ import './ravenLogger'; +import './sentryTunnel'; import './catchExceptions'; -import './fibers'; import './i18n'; import './accounts'; import './api'; import './fixtures'; import './email'; +import '/imports/api/Comments/server'; import './IPGeocoder'; import './migrations'; -import './notificationsObserver'; import './facts'; -import './comments'; import './sitemaps'; import './subsUnion'; import './prerender'; diff --git a/imports/startup/server/migrations.js b/imports/startup/server/migrations.js index e3bfac9..e67bad2 100644 --- a/imports/startup/server/migrations.js +++ b/imports/startup/server/migrations.js @@ -1,6 +1,8 @@ -/* global Migrations, Comments */ +/* global Migrations */ /* eslint-disable import/no-absolute-path */ +/* eslint-disable no-await-in-loop, no-restricted-syntax */ import { Meteor } from 'meteor/meteor'; +import Comments from '/imports/api/Comments/Comments'; import { Accounts } from 'meteor/accounts-base'; import randomHex from 'crypto-random-hex'; 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 { Mongo } from 'meteor/mongo'; -Meteor.startup(() => { +Meteor.startup(async () => { // https://github.com/percolatestudio/meteor-migrations Migrations.config({ @@ -23,38 +25,40 @@ Meteor.startup(() => { Migrations.add({ version: 1, - up: function migrateIds() { + up: async function migrateIds() { // 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 id = user._id.valueOf(); 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; - Meteor.users.insert(migratedUser); - }); + await Meteor.users.insertAsync(migratedUser); + } } }); Migrations.add({ version: 2, - up: function migrateSubsForeignKey() { - UserSubsToFiresCollection.find({ owner: null }).forEach((sub) => { + up: async function migrateSubsForeignKey() { + const subs = await UserSubsToFiresCollection.find({ owner: null }).fetchAsync(); + for (const sub of subs) { 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) { 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 { // create user with chatId and def language const username = `tel${sub.chatId.toString().replace(/^-/, '')}`; console.log(`Linking to new user: ${username}`); - const newUserId = Accounts.createUser({ + const newUserId = await Accounts.createUserAsync({ username, password: randomHex(50), profile: { name: {} } }); - Meteor.users.update({ _id: newUserId }, { + await Meteor.users.updateAsync({ _id: newUserId }, { $set: { emails: [], roles: ['user'], @@ -62,87 +66,90 @@ Meteor.startup(() => { lang: 'es' } }); - UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { owner: newUserId } }); + await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { owner: newUserId } }); } - }); + } } }); Migrations.add({ version: 3, - up: function emptySubsTypes() { - UserSubsToFiresCollection.find({ type: null }).forEach((sub) => { - UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { type: 'telegram' } }); - }); + up: async function emptySubsTypes() { + const subs = await UserSubsToFiresCollection.find({ type: null }).fetchAsync(); + for (const sub of subs) { + await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { type: 'telegram' } }); + } } }); Migrations.add({ version: 4, - up: function deleteOldAlertFiresAndIndexes() { - FireAlertsCollection.remove({ createdAd: null }); + up: async function deleteOldAlertFiresAndIndexes() { + await FireAlertsCollection.removeAsync({ createdAd: null }); const raw = FireAlertsCollection.rawCollection(); - raw.createIndex({ ourid: '2dsphere' }); - raw.createIndex({ when: 1 }); - raw.createIndex({ updatedAt: 1 }); - raw.createIndex({ createdAt: 1 }); - raw.createIndex({ ourid: 1, type: 1 }); + await raw.createIndex({ ourid: '2dsphere' }); + await raw.createIndex({ when: 1 }); + await raw.createIndex({ updatedAt: 1 }); + await raw.createIndex({ createdAt: 1 }); + await raw.createIndex({ ourid: 1, type: 1 }); } }); Migrations.add({ version: 5, - up: function siteSettingsIndex() { + up: async function siteSettingsIndex() { // other way: - SiteSettings._ensureIndex({ name: 1 }, { unique: 1 }); + await SiteSettings.createIndexAsync({ name: 1 }, { unique: true }); } }); Migrations.add({ version: 6, - up: function falsePositiveIndexes() { - FalsePositives._ensureIndex({ chatId: 1 }); - FalsePositives._ensureIndex({ owner: 1 }); - FalsePositives._ensureIndex({ fireId: 1 }); - FalsePositives._ensureIndex({ type: 1 }); - FalsePositives._ensureIndex({ geo: '2dsphere' }); + up: async function falsePositiveIndexes() { + await FalsePositives.createIndexAsync({ chatId: 1 }); + await FalsePositives.createIndexAsync({ owner: 1 }); + await FalsePositives.createIndexAsync({ fireId: 1 }); + await FalsePositives.createIndexAsync({ type: 1 }); + await FalsePositives.createIndexAsync({ geo: '2dsphere' }); } }); Migrations.add({ version: 7, - up: function defLangIfNull() { - Meteor.users.find({ lang: null }).forEach((user) => { - Meteor.users.update({ _id: user._id }, { + up: async function defLangIfNull() { + const users = await Meteor.users.find({ lang: null }).fetchAsync(); + for (const user of users) { + await Meteor.users.updateAsync({ _id: user._id }, { $set: { lang: 'es' } }); - }); + } } }); Migrations.add({ version: 8, - up: function siteSettingsAddIndex() { - SiteSettings._ensureIndex({ isPublic: 1 }); - SiteSettings.find({ isPublic: null }).forEach((setting) => { - SiteSettings.update({ _id: setting._id }, { $set: { isPublic: true } }); - }); + up: async function siteSettingsAddIndex() { + await SiteSettings.createIndexAsync({ isPublic: 1 }); + const settings = await SiteSettings.find({ isPublic: null }).fetchAsync(); + for (const setting of settings) { + await SiteSettings.updateAsync({ _id: setting._id }, { $set: { isPublic: true } }); + } } }); Migrations.add({ version: 9, - up: function siteSettingsAddIndex() { - Industries._ensureIndex({ registry: 1 }); - Industries._ensureIndex({ geo: '2dsphere' }); + up: async function industriesIndexesAndRegistries() { + await Industries.createIndexAsync({ registry: 1 }); + 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 - IndustryRegistries.insert({ + await IndustryRegistries.insertAsync({ _id: '1', name: 'E-PRTR', agency: 'EEA', region: 'EU' }); // 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' }); } @@ -150,11 +157,11 @@ Meteor.startup(() => { Migrations.add({ version: 10, - up: function siteSettingsAddIndex() { - IndustryRegistries.insert({ + up: async function moreIndustryRegistries() { + await IndustryRegistries.insertAsync({ _id: '3', name: 'NPRI', agency: 'GCODP', region: 'Canada' }); - IndustryRegistries.insert({ + await IndustryRegistries.insertAsync({ _id: '4', name: 'NPI', agency: 'DEE', region: 'Australia' }); } @@ -162,15 +169,15 @@ Meteor.startup(() => { Migrations.add({ version: 11, - up: function noAnonComments() { - Comments.getCollection().remove({ isAnonymous: true }); + up: async function noAnonComments() { + await Comments.removeAsync({ isAnonymous: true }); } }); Migrations.add({ version: 12, - up: function setTelegramUsersBotId() { - Meteor.users.update({ telegramChatId: { $ne: null }, telegramBot: null }, { + up: async function setTelegramUsersBotId() { + await Meteor.users.updateAsync({ telegramChatId: { $ne: null }, telegramBot: null }, { $set: { telegramBot: 'es' } @@ -180,8 +187,8 @@ Meteor.startup(() => { Migrations.add({ version: 13, - up: function removeTelegramBotFromUsersId() { - Meteor.users.update({}, { + up: async function removeTelegramBotFromUsersId() { + await Meteor.users.updateAsync({}, { $unset: { telegramBot: '' } @@ -191,8 +198,8 @@ Meteor.startup(() => { Migrations.add({ version: 14, - up: function setTelegramUsersBotId() { - UserSubsToFiresCollection.update({ chatId: { $ne: null }, telegramBot: null }, { + up: async function setTelegramSubsBotId() { + await UserSubsToFiresCollection.updateAsync({ chatId: { $ne: null }, telegramBot: null }, { $set: { telegramBot: 'es' } @@ -202,40 +209,41 @@ Meteor.startup(() => { Migrations.add({ version: 15, - up: function moveToFalsePositivesUppercase() { + up: async function moveToFalsePositivesUppercase() { /* const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' }); * falsepositiveslower.find({}).forEach((falseDoc) => { * FalsePositives.insert(falseDoc); - * });*/ + * }); */ // TODO remove falsepositives lowercase collection } }); Migrations.add({ version: 16, - up: function moveToFalsePositivesUppercaseWithUser() { + up: async function moveToFalsePositivesUppercaseWithUser() { const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' }); const now = new Date(); - falsepositiveslower.find({}).forEach((falseDoc) => { - const user = Meteor.users.findOne({ telegramChatId: falseDoc.chatId }); + const falseDocs = await falsepositiveslower.find({}).fetchAsync(); + for (const falseDoc of falseDocs) { + const user = await Meteor.users.findOneAsync({ telegramChatId: falseDoc.chatId }); if (user) { - falseDoc.owner= user._id; + falseDoc.owner = user._id; } falseDoc.type = 'industry'; falseDoc.createdAt = now; falseDoc.updatedAt = now; - FalsePositives.insert(falseDoc); - }); + await FalsePositives.insertAsync(falseDoc); + } } }); Migrations.add({ version: 17, - up: function renameWebNotifiedField() { - Notifications.update({ webNotified: { $exists: true } }, { + up: async function renameWebNotifiedField() { + await Notifications.updateAsync({ webNotified: { $exists: true } }, { $rename: { webNotifiedAt: 'notifiedAt' } }, { upsert: false, multi: true }); - Notifications.update({ webNotified: { $exists: true } }, { + await Notifications.updateAsync({ webNotified: { $exists: true } }, { $rename: { webNotified: 'notified' } }, { upsert: false, multi: true }); } @@ -243,18 +251,19 @@ Meteor.startup(() => { Migrations.add({ version: 18, - up: () => { + up: async () => { const raw = ActiveFiresUnion.rawCollection(); - raw.createIndex({ centerid: '2dsphere' }); - raw.createIndex({ shape: '2dsphere' }); - raw.createIndex({ when: 1 }); - raw.createIndex({ createdAt: 1 }); - raw.createIndex({ updatedAt: 1 }); + await raw.createIndex({ centerid: '2dsphere' }); + await raw.createIndex({ shape: '2dsphere' }); + await raw.createIndex({ when: 1 }); + await raw.createIndex({ createdAt: 1 }); + await raw.createIndex({ updatedAt: 1 }); } }); - // Set createdAt in users & subs - Migrations.migrateTo('latest'); + // Meteor 3: migrateTo is async and awaits each async up(), so a fresh DB can + // migrate from 0 to latest without pre-marking the control version. + await Migrations.migrateTo('latest'); // Migrations.migrateTo('14,rerun'); }); diff --git a/imports/startup/server/notificationsObserver.js b/imports/startup/server/notificationsObserver.js deleted file mode 100644 index 70be8e0..0000000 --- a/imports/startup/server/notificationsObserver.js +++ /dev/null @@ -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); - } - }); - } -}); diff --git a/imports/startup/server/ravenLogger.js b/imports/startup/server/ravenLogger.js index b3b83a3..c1a3e5b 100644 --- a/imports/startup/server/ravenLogger.js +++ b/imports/startup/server/ravenLogger.js @@ -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'; -const ravenOptions = {}; -const publicDSN = Meteor.settings.public.sentryPublicDSN; -const privateDSN = Meteor.settings.sentryPrivateDSN; -const enabled = publicDSN && privateDSN; +const dsn = Meteor.settings.sentryPrivateDSN; +const enabled = !!dsn; -const ravenLogger = enabled ? new RavenLogger({ - publicDSN, - privateDSN, - shouldCatchConsoleError: true, // default true - trackUser: true // default false -}, ravenOptions) : { log: (error) => { console.log(error); } }; +if (enabled) { + Sentry.init({ + dsn, + environment: Meteor.isDevelopment ? 'development' : 'production', + // errors only; no tracing/profiling (avoids the OTel auto-instrumentation) + 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; diff --git a/imports/startup/server/sentryTunnel.js b/imports/startup/server/sentryTunnel.js new file mode 100644 index 0000000..9ffd5ba --- /dev/null +++ b/imports/startup/server/sentryTunnel.js @@ -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://@/ + 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}`); + } +} diff --git a/imports/startup/server/sitemaps.js b/imports/startup/server/sitemaps.js index 8026449..70cf766 100644 --- a/imports/startup/server/sitemaps.js +++ b/imports/startup/server/sitemaps.js @@ -1,44 +1,37 @@ -/* global sitemaps Comments */ /* 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', () => { - const today = new Date(); +function xmlEscape(s) { + return s.replace(/&/g, '&').replace(//g, '>'); +} - const out = []; - out.push({ page: '/fires', lastmod: today }); - out.push({ page: '/login', lastmod: today }); - out.push({ page: '/signup', lastmod: today }); - out.push({ page: '/recover-password', lastmod: today }); - out.push({ page: '/credits', lastmod: today }); - out.push({ page: '/terms', lastmod: today }); - out.push({ page: '/license', lastmod: today }); - out.push({ page: '/privacy', lastmod: today }); - 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; +WebApp.connectHandlers.use('/sitemap.xml', (req, res) => { + const lastmod = new Date().toISOString().slice(0, 10); + const urls = PAGES.map((page) => { + const loc = xmlEscape(Meteor.absoluteUrl(page.replace(/^\//, ''))); + return ` \n ${loc}\n ${lastmod}\n `; + }).join('\n'); + const body = `\n\n${urls}\n\n`; + res.writeHead(200, { 'Content-Type': 'application/xml; charset=utf-8' }); + res.end(body); }); diff --git a/imports/startup/server/subsUnion.js b/imports/startup/server/subsUnion.js index 2643359..8a2df27 100644 --- a/imports/startup/server/subsUnion.js +++ b/imports/startup/server/subsUnion.js @@ -1,123 +1,53 @@ /* 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 Subscriptions from '/imports/api/Subscriptions/Subscriptions'; import SiteSettings from '/imports/api/SiteSettings/SiteSettings'; -import Perlin from 'loms.perlin'; -import './leaflet-workaround'; -import L from 'leaflet'; -import calcUnion from '/imports/ui/components/Maps/SubsUnion/Unify'; +import calcUnionAsync from '/imports/startup/server/calcUnionAsync'; +import unionBounds from '/imports/startup/server/unionBounds'; +import createSubsUnion from '/imports/startup/server/subsUnionLogic'; +import unionTelemetry from '/imports/startup/server/unionTelemetry'; import { isMailServerMaster } from '/imports/startup/server/email'; -// sudo apt-get install libcairo2-dev libjpeg-dev libgif-dev - -Meteor.startup(() => { +Meteor.startup(async () => { if (!isMailServerMaster) { console.log('We only process subsUnion in master'); return; } - const debug = true; // Meteor.isDevelopment; - Perlin.seed(Math.random()); - const addNoisy = (osub) => { - const sub = osub; - let lat = Math.round(sub.location.lat * 10) / 10; - let lon = Math.round(sub.location.lon * 10) / 10; - const noiseBase = Perlin.perlin2(lat, lon); - const noise = Math.abs(noiseBase / 3); - lat += noise; - lon += noise; - sub.location.lat = lat; - sub.location.lon = lon; - sub.distance += noiseBase; - return sub; - }; + const subsUnion = createSubsUnion({ + calcUnion: calcUnionAsync, + SiteSettings, + Subscriptions, + boundsOf: unionBounds, + telemetry: unionTelemetry + }); - const noNoisy = sub => sub; - - const process = (isPublic) => { - const subscribers = Subscriptions.find().fetch(); - const result = calcUnion(L, subscribers, isPublic ? addNoisy : noNoisy, true); - 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'); - } + if (await subsUnion.isUnionOutdated()) { + console.log('Subs union outdated'); + subsUnion.scheduleRecreate(); + } else { + console.log('Subs union up-to-date'); } - Subscriptions.find({ createdAt: { $gt: new Date() } }).observe({ - added: function newSubAdded() { // doc) { - if (debug) console.log('Subs added so recreate union'); - process(true); - process(false); + await Subscriptions.find({ createdAt: { $gt: new Date() } }).observeAsync({ + added: function newSubAdded(doc) { + console.log('Subs added, merging into union incrementally'); + subsUnion.enqueueAdd(doc); } }); - Subscriptions.find().observe({ - changed: function subsChanged() { // updatedDoc, oldDoc) { - if (debug) console.log('Subs changed so recreate union'); - process(true); - process(false); + await Subscriptions.find().observeAsync({ + changed: function subsChanged() { + console.log('Subs changed so recreate union'); + subsUnion.scheduleRecreate(); }, - removed: function subsRemoved() { // oldDoc) { - if (debug) console.log('Subs removed so recreate union'); - process(true); - process(false); + removed: function subsRemoved() { + console.log('Subs removed so recreate union'); + subsUnion.scheduleRecreate(); } }); }); diff --git a/imports/startup/server/subsUnionLogic.js b/imports/startup/server/subsUnionLogic.js new file mode 100644 index 0000000..61d0661 --- /dev/null +++ b/imports/startup/server/subsUnionLogic.js @@ -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, 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; diff --git a/imports/startup/server/unionBounds.js b/imports/startup/server/unionBounds.js new file mode 100644 index 0000000..47e39cb --- /dev/null +++ b/imports/startup/server/unionBounds.js @@ -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; diff --git a/imports/startup/server/unionSizeGuard.js b/imports/startup/server/unionSizeGuard.js new file mode 100644 index 0000000..07cdb3c --- /dev/null +++ b/imports/startup/server/unionSizeGuard.js @@ -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; diff --git a/imports/startup/server/unionTelemetry.js b/imports/startup/server/unionTelemetry.js new file mode 100644 index 0000000..1209440 --- /dev/null +++ b/imports/startup/server/unionTelemetry.js @@ -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; diff --git a/imports/ui/components/AccountPageFooter/AccountPageFooter.scss b/imports/ui/components/AccountPageFooter/AccountPageFooter.scss index 0e6b0df..4088d82 100644 --- a/imports/ui/components/AccountPageFooter/AccountPageFooter.scss +++ b/imports/ui/components/AccountPageFooter/AccountPageFooter.scss @@ -1,4 +1,4 @@ -@import '../../stylesheets/colors'; +@use '../../stylesheets/colors' as *; .AccountPageFooter { margin: 25px 0 0; diff --git a/imports/ui/components/Authenticated/Authenticated.js b/imports/ui/components/Authenticated/Authenticated.js index f34da2f..80b0efe 100644 --- a/imports/ui/components/Authenticated/Authenticated.js +++ b/imports/ui/components/Authenticated/Authenticated.js @@ -1,23 +1,16 @@ import React from 'react'; 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 }) => ( - ( - authenticated ? - (React.createElement(component, { ...props, ...rest, loggingIn, authenticated })) : - () - )} - /> +// v6 route guard: rendered as a route `element` wrapping the real page. +// Renders its children when authenticated, otherwise redirects to /login. +const Authenticated = ({ authenticated, children }) => ( + authenticated ? children : ); Authenticated.propTypes = { - loggingIn: PropTypes.bool.isRequired, authenticated: PropTypes.bool.isRequired, - component: PropTypes.func.isRequired, + children: PropTypes.node.isRequired }; export default Authenticated; diff --git a/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js b/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js index e479f8f..eb102b9 100644 --- a/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js +++ b/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js @@ -1,10 +1,10 @@ /* eslint-disable import/no-absolute-path */ import React from 'react'; import PropTypes from 'prop-types'; -import { withRouter } from 'react-router-dom'; import { LinkContainer } from 'react-router-bootstrap'; +import withRouterCompat from '../withRouterCompat/withRouterCompat'; /* 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'; /* @@ -39,4 +39,4 @@ AuthenticatedNavigation.propTypes = { name: PropTypes.string.isRequired }; -export default translate([], { wait: true })(withRouter(AuthenticatedNavigation)); +export default withTranslation()(withRouterCompat(AuthenticatedNavigation)); diff --git a/imports/ui/components/BetaRibbon/BetaRibbon.js b/imports/ui/components/BetaRibbon/BetaRibbon.js index 584f38d..c00e853 100644 --- a/imports/ui/components/BetaRibbon/BetaRibbon.js +++ b/imports/ui/components/BetaRibbon/BetaRibbon.js @@ -4,7 +4,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { translate } from 'react-i18next'; +import { withTranslation } from 'react-i18next'; import './BetaRibbon.scss'; @@ -30,4 +30,4 @@ BetaRibbon.propTypes = { BetaRibbon.defaultProps = { }; -export default translate([], { wait: true })(BetaRibbon); +export default withTranslation()(BetaRibbon); diff --git a/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js b/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js index 6fde995..574da35 100644 --- a/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js +++ b/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { Tracker } from 'meteor/tracker'; import { ReactiveVar } from 'meteor/reactive-var'; import { Button } from 'react-bootstrap'; -import { translate } from 'react-i18next'; +import { withTranslation } from 'react-i18next'; import { Bert } from 'meteor/themeteorchef:bert'; import './CenterInMyPosition.scss'; @@ -48,7 +48,7 @@ class CenterInMyPosition extends React.Component { const { onlyIcon, t } = this.props; const msg = t('Centrar en tu ubicación'); return ( - ); } @@ -63,4 +63,4 @@ CenterInMyPosition.propTypes = { onlyIcon: PropTypes.bool }; -export default translate([], { wait: true })(CenterInMyPosition); +export default withTranslation()(CenterInMyPosition); diff --git a/imports/ui/components/Col/Col.js b/imports/ui/components/Col/Col.js index 2a20586..affc10d 100644 --- a/imports/ui/components/Col/Col.js +++ b/imports/ui/components/Col/Col.js @@ -1,111 +1,6 @@ -import classNames from 'classnames'; -import React from 'react'; -import PropTypes from 'prop-types'; -import elementType from 'prop-types-extra/lib/elementType'; - -import { bsClass, prefix, splitBsProps } from 'react-bootstrap/lib/utils/bootstrapUtils'; -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 ( - - ); - } -} - -Col.propTypes = propTypes; -Col.defaultProps = defaultProps; - -export default bsClass('col', Col); +// The old custom Col hand-rolled Bootstrap-4/5 grid classes on top of +// react-bootstrap 0.31 internals (`bootstrapUtils`, `StyleConfig`), which no +// longer exist in react-bootstrap v2. v2's own Col produces the exact same +// `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. +export { Col as default } from 'react-bootstrap'; diff --git a/imports/ui/components/Comments/Comments.scss b/imports/ui/components/Comments/Comments.scss new file mode 100644 index 0000000..c84305b --- /dev/null +++ b/imports/ui/components/Comments/Comments.scss @@ -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; +} diff --git a/imports/ui/components/Comments/CommentsBox.js b/imports/ui/components/Comments/CommentsBox.js new file mode 100644 index 0000000..9008351 --- /dev/null +++ b/imports/ui/components/Comments/CommentsBox.js @@ -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 ; + } + if (media.type === 'youtube') { + return ( +
+