Compare commits

..

No commits in common. "meteor3-wip" and "tcef-master" have entirely different histories.

214 changed files with 21298 additions and 19392 deletions

View file

@ -1,15 +0,0 @@
# 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.*

View file

@ -1,111 +0,0 @@
# 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.
name: build-image
on:
push:
branches: [meteor3-wip, tcef-master]
paths-ignore: ['**.md']
workflow_dispatch:
jobs:
build:
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"

5
.gitignore vendored
View file

@ -17,8 +17,3 @@ 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

View file

@ -15,5 +15,3 @@ 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

View file

@ -4,55 +4,66 @@
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.
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
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
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
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
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
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
themeteorchef:bert
fortawesome:fontawesome
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
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
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
tcef:publish-performant-counts
natestrauser:publish-performant-counts
maximum:server-transform
mys:fonts # fonst in npm
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
# 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
barbatus:stars-rating
arkham:comments-ui
facts@1.0.9
gadicohen:sitemaps
nspangler:autoreconnect
quave:synced-cron
saucecode:timezoned-synced-cron
# lmachens:kadira
nimble:restivus@0.8.12
underscore@1.6.4
meteortesting:mocha # test driver (see `npm test` / `npm run test-watch`)
meteorhacks:zones
nimble:restivus
xolvio:cleaner

View file

@ -1 +1 @@
METEOR@3.1
METEOR@1.6.1.1

View file

@ -1,124 +1,155 @@
255kb:meteor-status@1.5.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
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
fortawesome:fontawesome@4.7.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
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
jquery@1.11.11
launch-screen@2.0.1
localstorage@1.2.1
logging@1.3.5
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
mdg:geolocation@1.3.0
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
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
nimble:restivus@0.8.12
npm-mongo@6.10.0
npm-bcrypt@0.9.3
npm-mongo@2.2.34
nspangler:autoreconnect@0.0.1
oauth@3.0.0
oauth2@1.3.3
observe-sequence@2.0.1
ordered-dict@1.2.0
ostrio:mailer@2.5.0
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
ostrio:meteor-root@1.0.7
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-var@1.0.13
reload@1.3.2
retry@1.1.1
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
reywood:publish-composite@1.6.0
routepolicy@1.1.2
service-configuration@1.3.5
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
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
vjrj:piwik@0.3.1
webapp@2.0.4
webapp-hashing@1.1.2
zodern:types@1.0.13
webapp@1.5.0
webapp-hashing@1.0.9
xolvio:cleaner@0.3.3

View file

@ -1,7 +1,3 @@
# 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

5
.npmrc
View file

@ -1,5 +0,0 @@
# 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

View file

@ -1,47 +0,0 @@
# 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"]

View file

@ -1,129 +0,0 @@
# Pendiente — web "Todos contra el fuego" (rama `meteor3-wip`)
Estado a 2026-07-18. La migración **Meteor 1.6 → 3.1 + MongoDB 7** está completa
y verificada (server async, React 18, web renderiza, smoke REST byte-idéntico).
Lo que falta, agrupado. Ver `UPGRADE.md` para el detalle de lo ya hecho.
---
## 1. Modernización de librerías react-* antiguas (deuda de front grande)
Funcionan en React 18 por compatibilidad heredada, pero ensucian la consola en
desarrollo (no en producción) y **bloquean el salto a React 19**. Estado:
| Lib | De → A | Estado |
|---|---|---|
| react-helmet | 5.2 → react-helmet-async 2 | ✅ hecho |
| react-i18next + i18next | 7.4/10.5 → 14/23 | ✅ hecho |
| react-bootstrap | 0.31 → 2 | ✅ hecho (CSS sigue en Bootstrap 4) |
| reactstrap + 3 deps muertas | — | ✅ eliminadas (0 usos) |
| react-router-dom | 4.2 → 6.30 | ✅ hecho (history 5, HOC `withRouterCompat`) |
| react-leaflet + leaflet | 1.8/1.3 → 4.2/1.9 | ✅ hecho (Map→MapContainer, refs directas, useMap/useMapEvents; 4 plugins reimplementados: MapControl portal, GoogleMutantLayer, fullscreen, sleep/graphicscale vanilla; ver UPGRADE.md) |
| **Bootstrap CSS/JS** | 4.1 → 5 | ⏸️ **diferida** (carrusel/navbar jQuery BS4; ver UPGRADE.md) |
Lo verificado en navegador tras cada lib: `/`, `/fires`, `/fire/archive/<hex>`,
`/login`, `/subscriptions` + consola. Smoke REST byte-idéntico en cada paso.
Limpieza de warnings restantes YA hecha: `defaultProps` en componentes función →
parámetros por defecto; `UNSAFE_componentWillReceiveProps` (FromNow, FireStats,
Fires, SelectionMap) → `getDerivedStateFromProps`/`componentDidUpdate`; Reconnect
(banner Blaze `meteorStatus` con `findDOMNode`) → React nativo con
`useTracker(Meteor.status)`; react-share 2→5 y react-progress-bar.js → progressbar.js
(ambos tiraban `findDOMNode`/`defaultProps`); y react-leaflet 1.8→4.2 (mapa central,
4 plugins reimplementados). **Tras esto la consola tiene 0 warnings de React en las
rutas principales** (verificado forzando re-render en / y /fires). Único resto para
React 19: el `<Blaze serverFacts>` de Status.js (`findDOMNode`), solo en `/status` (admin).
---
## 2. Deuda funcional / de calidad de la web (independiente de prod)
- **Comentarios (feature React nueva): verificación manual en staging.** El smoke
no llega a la UI. Los **métodos** (insert/edit/remove/like/dislike) y el embed
(`mediaAnalyzers`) ya están cubiertos por mocha (`test/server/comments.test.js`),
pero quedan por probar a mano en una página de fuego:
- publicar / editar / borrar un comentario (UI React)
- like / dislike (toggle)
- render del embed de imagen y de YouTube
- email a otros comentaristas (`onCommentAdd.js`, entrega real de correo)
- ✅ **`FireContainer` endurecido** (`imports/ui/pages/Fires/Fires.js`): el
`findOne()` sin selector ahora se acota por el `_id` de la URL en la ruta
`archive` (`new Meteor.Collection.ObjectID(id)`); en active/alert/hash cada
publicación deja un único fire en minimongo, así que el read vacío es correcto.
- ✅ **Suite de tests con runner (`meteortesting:mocha`).** `npm test` corre
`meteor test … --driver-package meteortesting:mocha` (watch: `npm run test-watch`).
Tests reescritos a `chai` + APIs async de Meteor 3 en `test/server/*.test.js`
(server-only), incluyendo cobertura nueva de los métodos de comentarios. Jest y
`rest.test.js` (ya cubierto por `smoke/`) eliminados. **36 passing.** Pendiente
menor: portar los casos de token inválido (401/400) de `rest.test.js` al smoke.
- ✅ **Rate-limit de publications** (`imports/startup/server/api.js`): reglas
`type: 'subscription'` vía `rateLimitSubscriptions` — 5/1000ms en `fireFrom*` y
`comments.forReference`; 10/1000ms en las subs geo (map pan/zoom).
- ✅ **Google Maps con `loading=async`** (`imports/startup/client/Gkeys.js`):
se envuelve `GoogleMapsLoader.createUrl` para añadir el parámetro (el paquete
`google-maps` npm no expone hook). *Verificar en staging que el mapa carga y el
aviso de consola desaparece.*
- ✅ **`meteor-accounts-t9n` actualizado** a `^2.6.0` (es/en OK). El paquete sigue
sin build gallego (`gl`), así que se mantiene el fallback gl→es documentado en
`i18n.js`. Traducción gallega de la app (`gl/common.json`): 0 claves faltantes.
- ✅ **Salto Bootstrap 4→5 (CSS/JS) hecho** — react-bootstrap v2 ahora corre sobre
su CSS nativo (BS5). **Compila y arranca; falta SOLO verificación visual en
staging** (BS4→5 cambia sutilezas de grid/gutters/tipografía en todas las páginas).
- Widgets jQuery/BS4 migrados a React (los blockers): navbar collapse
(`Navigation.js`/`NavItem.js`), carrusel del home (`Index.js``<Carousel>`,
conserva `.lazy` vía `onSlide`), dropdowns de idioma/tipo (`Profile.js`/
`Fires.js``<Dropdown>`), y toggle del feedback (`Feedback.js` → estado React).
Deps `bootstrap-carousel-swipe` y `alexwine:bootstrap-4` eliminadas; jQuery
global ya no se usa (solo `jquery`+`jquery-validation` vía npm en `validate.js`).
- CSS: `bootstrap@5` npm importado en `imports/startup/client/index.js` (antes
que `app.scss`, para que los overrides ganen). Renombres de utilidades:
`ml-auto``ms-auto`, `float-right``float-end`, `btn-block``w-100`,
`data-toggle``data-bs-toggle`, `sr-only``.visually-hidden` (shim).
- **QA visual pendiente en staging (todas las páginas):** formularios (react-
bootstrap ya emite markup BS5 → deberían mejorar), navbar, botones, modales,
cards, grid/gutters, y los widgets migrados (menú móvil, carrusel, dropdowns,
feedback). Alertas de `themeteorchef:bert` (comprobar que siguen bien sin el
jQuery de alexwine).
- Menor: `popper.js@1` en `package.json` es legacy sin uso (react-bootstrap trae
`@popperjs/core@2`) → se puede quitar. El bloque `.form-label` de `forms.scss`
ya es redundante (BS5 lo trae) salvo por el `display:block` explícito.
- **Limpieza menor:** migración 217 `// TODO remove falsepositives lowercase
collection`; `prerender.js` gating comentado; sección per-fire del sitemap
eliminada (era código muerto tras `firesMapEnabled=false`).
---
## 3. Observabilidad (Sentry/GlitchTip) — casi cerrado
- ✅ Server-side reporta a GlitchTip; ✅ cliente vía **túnel** `/sentry-tunnel`
(esquiva el 503 de Cloudflare). DSN en `settings-development.json` (gitignored).
- Pendiente: poner el DSN en el `METEOR_SETTINGS` de **producción** y confirmar
que el servidor de prod alcanza GlitchTip (IPv4). Nota: el túnel es un relay
acotado al proyecto propio (aceptable, documentado).
- Consola de dev: warnings de librería restantes solo se van con el punto 1.
---
## 4. Cutover a producción (infra — fase 3, es lo grande)
- **Migrar datos Mongo 3.2 → 7** (mongodump/restore) en el servidor real.
- **Desplegar el stack** (Docker) al servidor limpio vía el **ansible de
Comunes** — NO al viejo shiva. Ver `RUNBOOK.md` (stack compose local validado).
- **Coordinar orden con notificaciones:** la web NO debe desplegarse antes de que
el microservicio `tcef-notifications` emita en prod (si no, los usuarios dejan
de recibir avisos). Ver dependencia en `UPGRADE.md`.
- Secretos de prod (`METEOR_SETTINGS`, no el fichero gitignored), cron del
GeoLite2 City (MaxMind) en el nuevo host, proxy/TLS por el ansible compartido.
---
## 5. Fuera del repo web (bloquean el "hecho" global, pistas aparte)
- **tcef-notifications:** rollout por canal shadow→canary→full (FCM en canary
superado; email y telegram pendientes) y redeploy del servicio actualizado.
- **App móvil `fires_flutter`:** republicación en Play Store.
---
Regla de trabajo en todo lo anterior: commits locales atómicos en `meteor3-wip`,
**smoke REST byte-idéntico** tras cada cambio, verificación visual en navegador,
y NUNCA `git push`.

View file

@ -1,117 +0,0 @@
# RUNBOOK — stack local dockerizado (fase 3, validación)
Stack Docker Compose del sistema modernizado de "Todos contra el fuego":
web Meteor 3.1 (Node 22), MongoDB 7 (replica set de un nodo), Redis (AOF),
`tcef-notifications` y node-red. **Es el entorno de validación local previo a
producción** — el despliegue real irá al ansible de Comunes (fase 3 del plan);
este compose no toca el proxy ni la infra compartida.
## Requisitos
- Docker + Docker Compose v2.
- El repo hermano `../tcef-notifications` (el compose construye su imagen desde ahí).
## Primer arranque
```bash
# 1. Secretos (gitignorados; ver secrets/README.md)
cp settings-development.json secrets/meteor-settings.json
cp secrets/notifications.env.example secrets/notifications.env # MODE=dry-run
# 2. Construir y levantar (el primer build de la web tarda: descarga meteor-tool
# y compila el bundle; ~10-20 min en frío)
docker compose build
docker compose up -d
# 3. Comprobar salud
docker compose ps # todo "healthy" / mongo-init "exited (0)"
```
Servicios y puertos en el host:
| Servicio | Contenedor | URL / puerto |
|---|---|---|
| web Meteor | `tcef-stack-web` | http://localhost:3200 |
| node-red | `tcef-stack-node-red` | http://localhost:1880 |
| MongoDB 7 | `tcef-stack-mongo` | solo red interna (usar `docker exec`) |
| Redis | `tcef-stack-redis` | solo red interna |
| notificaciones | `tcef-stack-notifications` | sin puerto (worker) |
`mongo-init` es un one-shot: inicia el replica set `rs0` y fija
`db.migrations` a la versión 18 (los `up()` históricos ya son async, pero se
pinta v18 porque los datos reales de producción llegan pre-migrados).
> Nota healthcheck: los checks usan `127.0.0.1`, no `localhost`. El `wget` de
> busybox (alpine) resuelve `localhost` a `::1` (IPv6) primero, pero los
> servidores Meteor/node-red escuchan solo en IPv4 → un `localhost` daría
> unhealthy aunque la app responda perfectamente por el puerto mapeado.
## Smoke test REST (red de seguridad del contrato Flutter)
Debe ser **byte-idéntico** a los snapshots committeados:
```bash
MONGO_CONTAINER=tcef-stack-mongo MONGO_SHELL=mongosh MONGO_PORT=27017 \
BASE_URL=http://localhost:3200 ./smoke/smoke.sh
```
## Operación diaria
```bash
docker compose logs -f web # logs de un servicio (rotados: 3×10MB)
docker compose ps # estado + healthchecks
docker compose restart notifications # reiniciar un servicio
docker compose down # parar todo (los volúmenes persisten)
docker compose down -v # ⚠️ borra también mongo/redis/node-red
```
## Desplegar una nueva versión
```bash
# web (tras cambios en el repo)
docker compose build web && docker compose up -d web
# notificaciones (tras cambios en ../tcef-notifications)
docker compose build notifications && docker compose up -d notifications
```
Verificar tras cada despliegue de la web: `docker compose ps` healthy + smoke
REST byte-idéntico (arriba).
## Backups y restauración (volúmenes locales)
```bash
# Mongo: dump / restore de la db fuegos
docker exec tcef-stack-mongo mongodump --db fuegos --archive > fuegos.dump
docker exec -i tcef-stack-mongo mongorestore --archive --drop < fuegos.dump
# node-red: tar del volumen de datos
docker run --rm -v tcef-stack_nodered-data:/data -v "$PWD:/backup" alpine \
tar czf /backup/nodered-data.tgz -C /data .
```
## Rollback
- **web**: `git checkout <commit-anterior>` + `docker compose build web && docker compose up -d web`.
(En producción se conservará la imagen anterior etiquetada; aquí basta git.)
- **notificaciones**: igual, desde `../tcef-notifications`. `KILL_SWITCH=1` en
`secrets/notifications.env` + `docker compose up -d notifications` detiene
todo envío al instante sin parar el servicio.
- **datos**: restaurar el último dump (arriba).
## Salvaguardas de notificaciones
`secrets/notifications.env` arranca en `MODE=dry-run` (no envía nada). La
progresión dry-run → shadow → canary → full está documentada en
`../tcef-notifications/docs/rollout.md` y **nunca** se salta pasos. En este
stack local no hay credenciales reales salvo que las pongas tú.
## Deuda conocida de este compose
- `tcef-notifications` no expone endpoint HTTP de salud → healthcheck por
proceso (`pgrep`). Añadir `/healthz` al servicio cuando toque.
- node-red corre la imagen oficial vacía (los flows de los bots viven en
shiva; su migración de datos es parte del cutover real de fase 3).
- El driver mongodb 3.7 de `tcef-notifications` (elegido por el Mongo 3.2 de
producción) conecta con Mongo 7 en pruebas locales, pero al hacer el cutover
real a Mongo 7 debe subirse a driver 6.x (y habilitar change streams).

View file

@ -1,375 +0,0 @@
# Meteor upgrade — 1.6.1.1 → 2.5 (Mongo 3.2) → 3.1 (Mongo 7, on `meteor3-wip`)
Incremental, verified upgrade of `todos-contra-el-fuego-web`. Branch:
`meteor3-upgrade` (base `tcef-master`). Local commits only — no push until
authorized.
## ⚠️ Hard blocker for the final 3.x jump: MongoDB 3.2
Production runs against the shared replica set **rsmain on MongoDB 3.2.11**.
Meteor 3 ships the modern `mongodb` Node driver, which requires a server
**≥ 4.2/4.4**. Meteor **2.x still works against Mongo 3.2**.
Therefore this branch lands on **Meteor 2.5** — empirically the highest release
whose bundled `mongodb` driver still connects to Mongo 3.2 (2.6 bumps the driver
to 4.x and fails; see "Escala ceiling" below) — with everything green. The
`meteor update --release 3.x` step (and even 2.6+) is **deliberately not taken**
until the Comunes infra team upgrades rsmain (shared infra — coordinated in a
separate phase, not unilaterally). Server code should still be migrated to
async/await before the final 3.x jump so it is mechanical (tracked as debt).
## ⚠️ Deploy-ordering dependency: notifications cutover
The old push/email notification code was removed from the web (see the
notifications commit) because it now lives in the **tcef-notifications**
microservice. **Production must not run this build until tcef-notifications is
emitting in prod** — otherwise users stop receiving notifications. The web
deploy and the notifications cutover must be coordinated.
## Safety net: REST smoke test
`smoke/` is a standalone Node harness that calls every REST endpoint the Flutter
app consumes, against a seeded local dev server, and diffs the responses against
committed snapshots (`smoke/snapshots/`). See `smoke/README.md`.
**Run after every escala — it must stay byte-identical:**
```bash
docker run -d --name tcef-mongo32 -p 27018:27017 mongo:3.2 # once
export PATH="$HOME/.meteor:$PATH" MONGO_URL="mongodb://localhost:27018/fuegos"
meteor --settings settings-development.json --port 3100 & # dev server
./smoke/smoke.sh # seed + compare
```
## Escalas
### Baseline — Meteor 1.6.1.1 (starting point)
- Boots against Mongo 3.2 with `settings-development.json`.
- Dev-enabling changes (do not affect production behavior):
- `IPGeocoder.js`: degrade gracefully when the MaxMind GeoLite2 DB is absent
(provisioned by cron in prod) instead of crashing at boot.
- `settings-development.json`: added dev-only `private.internalApiToken`
(`dev-smoke-token`) — the REST API only registers its routes when this is
set.
- **Notifications code removed** (migrated to tcef-notifications): deleted
`notificationsObserver.js`, `notificationsProcess.js`, the "Process pending
notif" SyncedCron job, and the `node-gcm` dependency. `subsUnion.js` kept (it
feeds `subs-public-union`, consumed by the app).
- REST smoke baseline captured here. ✅ green.
### Escala 1 — 1.6.1.1 → 1.8.3 ✅ green
```bash
meteor update --release 1.8.3
meteor npm install --save @babel/runtime@^7.26.0
```
Core packages bumped (highlights): `meteor` 1.8.6→1.9.3, `ecmascript`
0.10.7→0.13.2, `modules` 0.11.6→0.14.0, `mongo` 1.4.7→1.7.0, `npm-mongo`
2.2.34→**3.2.0** (mongodb driver 3.x — still fine against Mongo 3.2 server),
`webapp` 1.5.0→1.7.5, `standard-minifier-js` →2.5.2. `underscore` auto-added
(1.7 dropped it from meteor-base), `fetch` + `modern-browsers` added.
**Breaking change — Babel beta → stable.** Boot failed with
`Cannot find module '@babel/runtime/helpers/objectSpread2'`: the pinned
`@babel/runtime@7.0.0-beta.44` predates helpers that 1.8's `ecmascript` emits.
Fixed by upgrading to stable **7.x** (`@babel/runtime@^7.29.7`). Note: `@latest`
resolves to `8.x`, which is too new for Meteor's 7-style helper layout — pin to
`^7`.
REST smoke test: **byte-identical to baseline.**
### Escala 2 — 1.8.3 → 1.11 (version resolution only)
```bash
meteor update --release 1.11
```
Resolution reached 1.11 (`ecmascript` 0.14.3, `mongo` 1.10.0, `npm-mongo`
**3.8.0** — still Mongo-3.2-compatible, `accounts-*` 1.x, `email` 2.0). But this
release surfaced the two big blockers below. The app was ultimately driven to
**2.3** (see next section); 1.11 was a transit point, not a landing.
### Escala 3 — → Meteor 2.3 ✅ green (builds + runs against Mongo 3.2, smoke byte-identical)
`.meteor/release = METEOR@2.3`. `npm-mongo` **3.9.0** (mongodb driver 3.x) — the
last driver line that still speaks to a Mongo 3.2 server. Core bumps:
`accounts-*` 2.0, `ecmascript` 0.15.2, `mongo` 1.12.0, HMR added.
This escala was dominated by **dead Atmosphere packages** (the plan's #1 risk).
Root causes and fixes, in the order they were hit:
1. **`arkham:comments-ui` — no Meteor 2.x build.** The vendored local package
used `Npm.depends`, and rebuilding a *local* package's npm deps crashes
meteor-tool ≥1.11 on this host (`node_contextify.cc … Assertion
args[1]->IsString()` → SIGABRT; reproducible with any `Npm.depends`, even
`is-number`). Switching to the Atmosphere isopack avoided the local rebuild
but the package is abandoned: its newest 2.x-solvable version (0.2.15) pins
`accounts-password@1.0.1`, conflicting with Meteor 2.0's accounts-password 2.0.
**Decision (user-approved): reimplement comments as a small React feature**
(`imports/api/Comments/*`, `imports/ui/components/Comments/CommentsBox.js`),
dropping the package entirely. Fire-page comments only (the sole usage), with
likes/dislikes, image/YouTube embed, owner edit/remove, and the
"email other commenters" side-effect preserved. Comment text is now rendered
as **safe plain text** (+ media embed) instead of markdown-to-HTML — a small
scope reduction and a security improvement (no raw-HTML injection of user
content). The old local fork is preserved in its nested git repo
(commit `4761da9`).
2. **`coffeescript@1.0.17` build plugin — crashes the build.** The same
`node_contextify` assertion fired at *build start* on this host whenever the
coffeescript compiler plugin loaded. It was pulled transitively by the dead
**test stack** (`meteortesting:mocha`, `practicalmeteor:chai`,
`xolvio:cleaner`) and by the old auto-downgraded `nimble:restivus@0.6.6`
(which needs `iron:router`). Removing those removed coffeescript and
unblocked the build. A trivial `meteor create` app builds fine on the same
tool, confirming the crash is package-specific, not a broken tool.
3. **`nimble:restivus` — the REST API package — pins `accounts-password@1.3.3`**
(incompatible with 2.x) and is CoffeeScript (needs the crashing plugin).
**Vendored** as `packages/nimble-restivus`: the `.coffee` sources were
precompiled to plain JS (`lib/restivus-all.js`, single translation unit so
the `Auth`/`Route`/`Restivus` classes share scope; `Restivus` left
un-`var`'d so Meteor's `api.export` picks it up), and `package.js` loosens
`accounts-password` to 2.x and drops the coffeescript dependency. This also
let restivus resolve to 0.8.12 (json-routes based), which **removed the whole
`iron:router` stack**. REST behavior unchanged (smoke byte-identical).
4. **`maximum:server-transform` (unused) removal** broke
`Meteor.publishTransformed` in `FalsePositives/server/publications.js`; no
transform was actually configured, so replaced with plain `Meteor.publish`.
5. **`fourseven:scss` 4.5.4 → 4.14.1** — `node-sass@4.5.3` won't build on node 12
(Meteor ≥1.9); 4.14.1 uses a node-12-compatible node-sass with prebuilt
binaries. (Meteor 1.8 built it on node 8.) `4.15.0` needs ecmascript ≥ 0.15.1
→ too new for 1.11.
6. **`less`, `markdown` removed** — no `.less`/`.md` source files; their build
plugins were dead weight.
### ⚠️ Local build-host caveat
Meteor-tool's bundled node (12.x/14.x) SIGABRTs (`node_contextify` assertion) on
this machine (kernel 6.12) when certain old build plugins load — this is an
environment interaction, not a code defect (the deploy host built these fine
historically). It was fully worked around by removing the dead build plugins
above. If a future escala hits it again, build in a container matching the
deploy target (Debian) rather than on this host.
### Escalas 2.4 & 2.5 — ✅ green
`meteor update --release 2.4`, then `--release 2.5`. Both trivial; `npm-mongo`
stays at **3.9.1** (mongodb driver 3.x). REST smoke byte-identical against
`mongo:3.2` on both.
## Escala ceiling vs Mongo 3.2 — **landing on Meteor 2.5** (empirically verified)
Meteor bumps the bundled `mongodb` Node driver to **4.3.1 at Meteor 2.6**, which
requires server wire version ≥ 6 (**MongoDB ≥ 3.6**). Production's rsmain is
**Mongo 3.2** (wire version 4). Verified directly by booting 2.6 against a real
`mongo:3.2`:
```
MongoCompatibilityError: Server at localhost:27018 reports maximum wire
version 4, but this version of the Node.js Driver requires at least 6
(MongoDB 3.6)
=> Exited with code: 1
```
So **Meteor 2.5 is the highest release that runs against the production Mongo
3.2** (npm-mongo 3.9.1). That is where this branch lands: everything green, REST
smoke byte-identical. Going past 2.5 (to 2.16 or 3.x) is gated on upgrading the
shared rsmain replica set to Mongo ≥ 4.4 first — the same DB blocker as 3.x.
## ✅ Meteor 3.1 + MongoDB 7 — REST smoke GREEN (branch `meteor3-wip`)
**The 3.x jump works end-to-end for the REST/Flutter contract.** On branch
`meteor3-wip`, the web runs on **Meteor 3.1** (Node 22, async/await, no Fibers)
against a **dockerized MongoDB 7** (npm-mongo 6.10 / mongodb driver 6), and the
REST smoke test is **byte-identical to the 1.6.1.1 baseline** (all 12 endpoints).
This is the "mongo superior" decision realized: give fuegos its own Mongo 7
instead of the shared rsmain 3.2 — which is what unblocks Meteor 3's driver.
`meteor3-upgrade` stays at the deployable **Meteor 2.5** (Mongo 3.2). The 3.x
work lives on `meteor3-wip` until (a) production Mongo is migrated to 7 and
(b) the web-UI async migration below is finished.
### Reproduce the 3.1 dev stack
```bash
docker run -d --name tcef-mongo7 -p 27019:27019 mongo:7 --replSet rs0 --port 27019 --bind_ip_all
docker exec tcef-mongo7 mongosh --port 27019 --quiet --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27019"}]})'
# (migrations run from scratch on an empty DB — all up() bodies are async now,
# no need to pre-mark db.migrations at version 18)
export MONGO_URL="mongodb://localhost:27019/fuegos?replicaSet=rs0"
export NODE_OPTIONS="--dns-result-order=ipv4first --no-network-family-autoselection" # reach warehouse.meteor.com on Node 22
meteor --settings settings-development.json --port 3100
MONGO_CONTAINER=tcef-mongo7 MONGO_SHELL=mongosh MONGO_PORT=27019 ./smoke/smoke.sh
```
### Key build/runtime unblocks (Meteor 3.1)
- **underscore linker crash** (`Runtime is not available … underscore`): resolved
`underscore@1.6.2` had a runtime-less `web.browser` unibuild → pin
**`underscore@1.6.4`** (diagnosed by instrumenting the tool's `linker.js`).
- **collection2 v4** is fully lazy with no main module → import its eager entry
`meteor/aldeed:collection2/static.js` from `server|client/00-collection2-init.js`
so `attachSchema`/autoValues are patched before any collection loads.
- **Fibers gone**: removed `fibers.js`; server sync Mongo → `*Async` throughout
(Rest.js + helpers + methods + startup: oauth/subsUnion/migrations/email/facts).
- **vendored restivus** patched to `await` async endpoint handlers.
- **`$near` + `countAsync()`**: driver 6 runs `count` via aggregation where
`$near` is illegal → derive total from `fetchAsync().length`.
- **json-routes 3.0** matches routes in registration order → reordered the
`mobile/subscriptions/all/...` route before `.../:subsId` in Rest.js.
- **fixtures.js / sitemaps.js** disabled (sync-only `@cleverbeagle/seeder` /
pre-0.9 `gadicohen:sitemaps`) — not on the REST path. Debt.
### Web-UI async migration (beyond the REST contract)
- ✅ **Fires publications → async** (`fireFromId`, `fireFromAlertId`,
`fireFromActiveId`, `fireFromHash`): `findOne``findOneAsync`,
`count()``countAsync()`, `firesUnion` awaited, handlers `async` so the
try/catch actually catches rejections. Verified over raw DDP (all subs
`ready`, docs flow; fire + falsePositives docs delivered for the seeded
fire). `falsePositivesMyloc` / `industriesMyloc` / `comments.forReference`
needed **no change** — they return bare `find()` cursors (still sync-safe on
Meteor 3). `subscriptions.*` methods were already `*Async`.
- Note: the module-level `new Counter(...)` (natestrauser:publish-performant-counts)
behind `falsePositivesTotal` boots fine; the publication itself is unused by
the current UI — watch it if re-enabled.
- ✅ **Comments methods → async** (`comments.insert/edit/remove/like/dislike`):
`findOne/insert/update/remove``*Async`, helpers (`requireOwner`, `toggle`)
async, `users.findOneAsync` for the username. `onCommentAdd` mail fan-out:
`users.find().forEach``forEachAsync`. Still fire-and-forget from the
insert. Comments UI staging verification pending (pre-existing note below).
- ✅ **fixtures.js re-enabled**`@cleverbeagle/seeder` (sync Mongo, no Meteor
3 support) **removed from package.json** and replaced with a small in-repo
async seeder: idempotent, dev-only (staging opts in with `TCEF_SEED=1`);
same accounts as before (`admin@admin.com` / 5 test users / 5 Documents
each). Verified: `fixtures: 6 dev users ensured` on boot.
- ✅ **sitemaps.js re-enabled**`gadicohen:sitemaps` (pre-0.9 API, dead on
Meteor 3) **removed from .meteor/packages**; `/sitemap.xml` is now a small
`WebApp.connectHandlers` handler serving the same static page list (the
per-fire section of the old handler was behind `firesMapEnabled = false`,
i.e. dead code, and was dropped). Verified serving on dev.
- ✅ **Dead client packages purged — the web UI now RENDERS on Meteor 3.**
First-ever browser verification of this branch (the smoke only covers REST)
found a chain of ancient Blaze-era packages whose client code threw at
bundle load, killing the whole app (`meteorInstall is not defined`):
- **alanning:roles 1.2.10** (client crash, and its server
`Roles.userIsInRole` is sync) → package removed; the app barely used it:
`facts.js` now checks the plain `user.roles` field (admin set cached at
startup) and App.js takes `roles` from the user doc (prop was unused).
Upgrading to roles v4 was rejected: needs a prod data migration.
- **selaias:cookie-consent** (its `Cookies` global is gone) → removed;
replaced by in-repo React `imports/ui/components/CookieConsent` reusing
the same i18n keys; `CookieConsent.init` dropped from `i18n.js`.
- **natestrauser:publish-performant-counts** server `Counter._publishCursor`
uses sync `cursor.count()`**vendored** as
`packages/publish-performant-counts` (`tcef:publish-performant-counts`)
with `countAsync` and an async-aware interval; same public API.
- `Meteor.autorun` (removed in Meteor 3) → dropped (App.js debug) /
`Tracker.autorun` (FiresMap).
- Map/server publications not exercised by the REST smoke converted to
async: `activefiresmyloc`, `activefiresunionmyloc`, `fireAlerts`
(countAsync/fetchAsync/awaited firesUnion), and `oauth.verifyConfiguration`
(findOneAsync). `migrations.js`: all 18 historical `up()` bodies ported to
async (`fetchAsync`+`for..of`, `*Async` writes, `createIndexAsync`,
`Accounts.createUserAsync`; percolate:migrations 2.0.1 awaits async `up()`).
Verified: empty Mongo 7 DB migrates 0→18 with no errors (indexes +
industry registries created); pre-marking `version=18` is no longer needed.
Verified in a real browser: `/` and `/fires` render (map, search, layers,
cookie banner), zero uncaught exceptions; REST smoke byte-identical.
- ✅ **React 16 → 18.3**`react`/`react-dom` bumped to `^18.3.1`
(`--legacy-peer-deps`: the ancient react-* libs below declare 15/16 peers),
client entry migrated to `createRoot` (`imports/startup/client/index.js`).
The legacy UI libs (react-bootstrap 0.31, reactstrap 5-alpha, react-leaflet
1.8, react-router-dom 4, react-i18next 7) stay pinned and WORK on 18, but
spam the console with legacy-context/defaultProps deprecation warnings and
will die on React 19. **Debt**: own front-end refresh project.
⚠️ npm gotcha: running `meteor npm install/uninstall` while the dev server
watches node_modules can crash the meteor-tool watcher mid-prune (ENOENT on
a watched file). Stop the server (or restart it after) when touching deps.
### Still TODO before the web (not just REST) is fully deployable on 3.x
- ✅ **React 16 → 18** render root + ancient react-* libs modernized: react-helmet→
react-helmet-async, i18next 10→23 + react-i18next 7→14, react-bootstrap 0.31→2,
react-router-dom 4→6. Dead deps (reactstrap, react-leaflet-sidebarv2,
react-router-hash-link, react-addons-pure-render-mixin) dropped. All
browser-verified, REST smoke byte-identical. **Remaining front debt:**
react-leaflet 1.8→4 (deferred — core map, 4 v1-only plugins) and the
Bootstrap 4→5 CSS/JS jump (deferred — jQuery carousel/navbar). Both are what's
left before a React 19 jump.
- ✅ Re-enabled dev fixtures / sitemaps / email default template with async ports.
- ✅ SCSS `@import``@use`, `lighten`/`darken``color.adjust` (dart-sass deprecations).
- **Production cutover** still needs: Mongo data migrated 3.2→7 (mongodump/restore),
the notifications service emitting in prod, and the Docker deployment (fase 3).
## Dependency debt (tracked, to resolve in the noted escala)
| Dep | From | Target | Status |
|---|---|---|---|
| React / react-dom | 16.0 | 18 | ✅ done — 18.3.1 + createRoot. The peripheral react-* libs that only worked on 18 via legacy context are now all modernized (rows below) **except react-leaflet** (deferred). Our own React-19 blockers are also cleared: `defaultProps` on function components → default params; `UNSAFE_componentWillReceiveProps``getDerivedStateFromProps`/`componentDidUpdate`; the Blaze/findDOMNode `Reconnect` → native React; react-share 2→5 and react-progress-bar.js → progressbar.js (both dropped their findDOMNode/defaultProps). react-leaflet 1.8→4.2 done too (row below). **Dev console is now 0 React warnings** on the main routes. The only React-19 leftover is the Blaze `serverFacts` on the /status admin page (findDOMNode via gadicc:blaze-react-component). |
| react-share | 2.0 | 5.3 | ✅ done — v2 shipped function-component `defaultProps` (React-19 blocker). v5 is clean. Dropped the dead GooglePlus button (removed upstream in v4+); the other 6 share buttons are API-compatible. |
| react-progress-bar.js | 0.2.3 | — (progressbar.js 1.1) | ✅ removed — the wrapper used the deprecated `findDOMNode`. `LoadingBar` now drives `progressbar.js` (its own underlying dep, promoted to direct) through a ref. |
| gadicc:blaze-react-component (in Reconnect) | — | native React | ✅ Reconnect's Blaze `meteorStatus` bridge used `findDOMNode` and was always mounted → rewritten with `useTracker(Meteor.status)` + countdown effect, reusing 255kb:meteor-status's CSS. (Status.js's Blaze `serverFacts` kept — /status admin page only.) |
| Server async/await | Fibers | `*Async` APIs | ✅ done — REST, publications, methods, startup and migrations all on `*Async`. |
| bcrypt | 1.0.3 (broken binding) | 5.x | ✅ done — 1.0.3 didn't load on Node 22 (accounts-password silently fell back to pure JS) and wouldn't compile in the Docker build; 5.1.1 ships Node-22 prebuilds. |
| raven / flowkey:raven | 2.4 | @sentry/node + @sentry/browser 8.x | ✅ done — `flowkey:raven` (Sentry legacy SDK, dead on 3.x, spammed the boot log with 502s against the dead sentry.comunes.org) replaced by the modern SDKs behind the same `ravenLogger.log()` facade (6 call sites unchanged). Backend: **GlitchTip** self-hosted on `aaron` (Sentry-compatible DSN/API; Sentry proper needs 16 GB RAM, didn't fit), deployed via the Comunes ansible (`glitchtip.yml`, org "comunes" / project "tcef-web"), fronted at `https://sentry.comunes.org` (nginx on `assange``aaron:8000`). DSN set in `settings-development.json`; end-to-end verified (a test exception sent from the dev server showed up in GlitchTip within seconds). |
| nodemailer | 4 | 6.10 | ✅ done — drop-in for our usage: `email.js` only calls `nodemailer.createTransport(MAIL_URL)` and reads `transport.options.auth.user` (in the production-only `from()` branch); both verified unchanged in v6. Sending goes through `ostrio:mailer` (MailTime 2.5), which is v6-compatible. |
| Babel | 7 beta | 7 stable | ✅ done (escala 1) |
| react-meteor-data | 0.2.16 | 3.0.6 | ✅ done — 0.2.16 (React-15/16-era) looped `withTracker` on fire-detail pages under React 18 (never-ready → blank). `meteor add react-meteor-data@3.0.6`; no call-site changes (HOC API kept). Also dropped `tmeasday:check-npm-versions` (the constraint pinning 0.2.16). See section above. |
| i18next | 10.5 | 23.16 | ✅ done — with react-i18next 14. `whitelist``supportedLngs`, added `compatibilityJSON: 'v3'` (our locale JSON uses natural-language keys + v3 `_plural` layout, not the v4 `_one`/`_other` suffixes), custom separators `ß`/`ð`/`đ` kept. `sendMissing``saveMissing`, missingKeyHandler signature is `(lngs, ns, key, fallbackValue)` now (array first). See react-i18next row. |
| react-i18next | 7.4 | 14.1 | ✅ done — 48 `translate([], {wait:true})`/`translate()` HOCs → `withTranslation()`; `react.wait:true``react.useSuspense:false`. `<Trans>` unchanged (locale JSON already stores the indexed-tag format `<1><0>{{x}}</0></1>`). ReSendEmail: removed `<Interpolate>` (deleted in v10) → `<Trans i18nKey values={{email}}/>`, dropped the removed bare `t` export. This is what silenced the per-component `Translate`/`I18n` legacy-context console spam. Backends: xhr→`i18next-http-backend` (client), sync-fs→`i18next-fs-backend` (server); `i18next-localstorage-cache` dropped (was `enabled:false`); languagedetector 2→8. Browser-verified: es/en switch, `<Trans>` interpolation (`{{countTotal}}`+`<strong>`) renders on /fires. |
| react-leaflet + leaflet | 1.8 / 1.3.1 | 4.2.1 / 1.9.4 | ✅ done — the last and hardest lib; a ground-up hooks rewrite of the app's core map. `<Map>``<MapContainer>`; `.leafletElement` refs (6 files/13 sites) → the map/layer instances directly (via a `<MapReady>` child using `useMap`, and plain refs on Marker/Circle/GeoJSON); the controlled-viewport pattern (`onViewportChanged`+`state.center/zoom`) → `<MapEvents>` (`useMapEvents` moveend/zoomend) + imperative `setView`; `onClick``eventHandlers={{click}}`, path styling → `pathOptions`/`style`; `subsUnion` takes the `L.Map` directly. **The 4 v1-only plugins were reimplemented** with react-leaflet v4's factories / vanilla Leaflet plugins: `react-leaflet-control`→ in-repo `MapControl` (L.Control + createPortal), `react-leaflet-google``GoogleMutantLayer` (`createLayerComponent` + `leaflet.gridlayer.googlemutant`; Google Maps API already loaded by Gkeys), `react-leaflet-fullscreen``createControlComponent` + `leaflet.fullscreen`, `leaflet-sleep`/`leaflet-graphicscale` kept as vanilla (work on leaflet 1.9). Browser-verified: /fires (tiles, OSM+Google layer switch, custom control, fullscreen, graphic scale, pan/zoom→re-fetch pipeline, no NaN), fire detail (GeoJSON rect + fitBounds), home (all 3 maps coexist; SelectionMap draggable marker→updatePosition + distance circle). **Console now 0 warnings** across home/`/fires` (forced full re-render). REST smoke byte-identical. |
| arkham:comments-ui | 1.4.x | — | ✅ replaced with in-repo React feature |
| nimble:restivus | Atmosphere | vendored | ✅ local precompiled package, accounts-password 2.x |
| maximum:server-transform, meteorhacks:zones, less, markdown, test stack | — | — | ✅ removed (dead/unused) |
| fourseven:scss | 4.5.4 | 4.14.1 | ✅ node-12 compatible |
| node-gcm | 1.0.2 | — | ✅ removed (dead API, moved to microservice) |
| react-router-dom | 4.2.2 | 6.30 | ✅ done — with `history` 4→5 and `react-router-bootstrap` 0.24→0.26. Kills the `Router`/`Switch`/`Route`/`Link`/`LinkContainer` legacy-context warnings. Strategy: keep the shared `history` **singleton** (used outside React by NotificationsObserver and Utils/location) via `unstable_HistoryRouter`, and DON'T rewrite the ~20 class pages — a new `withRouterCompat` HOC bridges v6 hooks back to v4-shaped `history`/`match`/`location` props. `<Switch>``<Routes>`, `component=``element=`, `Authenticated`/`Public` become guard components rendering `children` or `<Navigate replace>`, `LocationListener` is now a `useLocation`+`useEffect` function. The regex route `/fire/:type(active\|archive\|alert)/:id``/fire/:type/:id` (type validated in the component; the only behavior delta is an invalid two-segment type now hits Fires instead of 404 — negligible). `history.listen` callback is `({location})` in v5. Browser-verified: SPA nav, `/subscriptions``/login` auth redirect, deep-link `/fire/archive/:id` (params flow), browser back/forward; REST smoke byte-identical. Gotcha hit & fixed: the rewritten guard files need `import React` for their `<Navigate>` JSX. |
| react-helmet | 5.2.0 | react-helmet-async 2.0.5 | ✅ done — react-helmet is unmaintained and misbehaves under React 18 StrictMode. Drop-in: same `<Helmet>` children API across the 15 pages; `<HelmetProvider>` wraps `<App/>` in `imports/startup/client/index.js`. Verified in browser: per-page titles, meta description and hreflang alternates injected (`data-rh`). Note: head updates are rAF-deferred (like react-helmet), so hidden/background tabs don't flush — irrelevant for real users. |
| react-bootstrap | 0.31.5 | 2.10 | ✅ done — kills the biggest batch of React-19-blocking warnings (Grid/FormGroup/ControlLabel/Navbar.Header/Navbar.Brand/Checkbox/SafeAnchor all used legacy context/defaultProps). 29 files: `Grid``Container`, `FormGroup/ControlLabel/FormControl/HelpBlock``Form.Group/Label/Control/Text`, `Checkbox``Form.Check` (label moves to a prop), `bsStyle``variant` (×23, `"default"``"secondary"`), `bsSize``size` (×2), `pull-right``float-end` (×7). Custom `Col.js` (used v0.31 `bootstrapUtils`/`StyleConfig` internals) now re-exports v2's `Col`; custom `NavItem.js` rewritten self-contained (dropped `SafeAnchor`+`createChainedFunction`); `Navigation.js` drops the react-bootstrap `Navbar` (was hand-rolled raw markup anyway). **CSS kept at Bootstrap 4** (see next row) — the only BS5-only class v2 emits that BS4 lacks is `.form-label`, shimmed in `forms.scss`. Browser-verified: home carousel+navbar, signup form + terms `Form.Check` (toggles submit), login form; REST smoke byte-identical. |
| Bootstrap CSS/JS | 4.1 (alexwine:bootstrap-4) | 5.x | ⏸️ **deferred as debt.** The app's CSS+JS still comes from the Atmosphere `alexwine:bootstrap-4` (Bootstrap 4 + jQuery). The BS4→5 jump is a separate invasive sub-project: the home **carousel** uses the jQuery `bootstrap-carousel-swipe` plugin (BS4-only), the **navbar collapse** and ~17 `data-toggle`/`data-slide` attributes across 6 files are BS4 jQuery behaviors, and BS5 is vanilla-JS with `data-bs-*`. react-bootstrap v2 runs fine on BS4 CSS for every component we use (Button/Alert/Row/Col/Table/ButtonGroup/Modal render identical classes; only `.form-label` needed a shim). Do this jump on its own: npm `bootstrap@5` SCSS+JS, rewrite carousel (BS5 has native swipe → drop the plugin), `data-toggle``data-bs-toggle`, `ml-auto``ms-auto`, etc. |
| reactstrap | 5.0.0-alpha.3 | — | ✅ removed — zero imports in the codebase (react-bootstrap covers all UI). |
| react-leaflet-sidebarv2 | 0.5.1 | — | ✅ removed — never imported. |
| react-router-hash-link | 1.1.1 | — | ✅ removed — only a commented-out import in Index.js. |
| react-addons-pure-render-mixin | 15.6.2 | — | ✅ removed — never imported (React-15 era leftover). |
| chimp | 0.51.1 | — | ✅ removed — dead e2e test runner (Selenium 2 / cucumber), sole remaining puller of native `fibers@1.x`, which cannot compile on Node 22 and broke the clean Docker build. Not imported anywhere. |
### Comments React feature — verification note
The server builds and the REST smoke test is green, and the client bundle
compiles, but the comments UI itself is **not exercised by the smoke test**
(it's not part of the Flutter REST contract). It needs manual staging
verification: on a fire archive page — logged-in post/edit/remove, like/dislike,
and image/YouTube embed.
### ✅ Fixed: fire-detail pages now render (react-meteor-data 0.2.16 → 3.0.6)
Browser-verifying the Comments feature surfaced a **pre-existing client bug**:
the fire-detail pages (`/fire/{active,archive,alert}/:id`, component
`imports/ui/pages/Fires/Fires.js`) stayed blank — the page's `withTracker`
subscription (`fireFromActiveId` / `fireFromId`) never reached `ready()`, so
`loading` stayed true and nothing rendered, pegging a CPU core in a
re-subscribe loop.
Diagnosis (isolated, not guessed):
- **The publications were fine.** Subscribing to `fireFromActiveId` /
`fireFromId` *standalone* from the browser console readied immediately and
delivered the doc — not a server/publication bug.
- **The bug was `react-meteor-data@0.2.16`** — a React-15/16-era version
(`componentWillMount` + legacy context, the source of the deprecation
warnings flooding the console) that misbehaves under React 18 for a component
gating its whole render on `subscription.ready()`.
Fix: **`meteor add react-meteor-data@3.0.6`** (the React-18-compatible line for
Meteor 3.1; 4.x targets Meteor 3.2+). No call-site changes were needed — 3.x
keeps the `withTracker` HOC API, and all 19 call sites work unchanged. The old
constraint that pinned 0.2.16 was **`tmeasday:check-npm-versions`**, pulled in
by 0.2.16 itself; the upgrade dropped it, which also silenced the
"npm peer requirements not installed (react@15-16)" boot/console warning.
Verified in a real browser at `/fire/archive/c0000000000000000000000c`: the
page renders fully (title, Leaflet + NASA/satellite map, the "not a wildfire"
FalsePositives widget, and the **Comments** box — all `withTracker`-driven),
zero console errors. REST smoke byte-identical (12/12).

View file

@ -1,3 +0,0 @@
// 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';

View file

@ -1,5 +1,3 @@
// 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

View file

@ -1,172 +0,0 @@
# Stack de STAGING de "Todos contra el fuego" para testfuegos.comunes.org.
# Derivado de docker-compose.yml (validación local). Diferencias clave:
# - web sirve en https://testfuegos.comunes.org (tras el proxy assange), puerto host 8004
# - Mongo 7 AISLADO, sembrado con un volcado real de `fuegos` y contactos neutralizados
# (NUNCA apunta al rsmain de producción)
# - notificaciones en MODE=shadow / MATCHER_MODE=shadow: calcula y loguea, NO envía
# - mailhog captura TODO el correo (nada sale a un SMTP real)
# - node-red solo conversacional; si se prueba el bot, con un BOT DE PRUEBAS, nunca los reales
#
# Despliegue real: vía el role ansible roles/tcef_staging/ en el repo de Comunes
# (~/proyectos/sync/comunes/shared-l2/ansible/). Este compose es la fuente que ese role monta.
# Secretos: ./secrets/*.staging.* (gitignored). Uso: RUNBOOK.md.
name: tcef-staging
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
mongo:
image: mongo:7
container_name: tcef-staging-mongo
command: ["--replSet", "rs0", "--bind_ip_all"]
volumes:
- mongo-data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
restart: unless-stopped
logging: *default-logging
# One-shot: inicia el replica set de un nodo y marca las migraciones a v18
# (los datos reales llegan pre-migrados). Idéntico al compose de validación.
mongo-init:
image: mongo:7
depends_on:
mongo:
condition: service_healthy
restart: "no"
entrypoint:
- bash
- -ec
- |
mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }'
for i in $$(seq 1 30); do
state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }')
[ "$$state" = "true" ] && break
sleep 1
done
mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})'
echo "mongo-init: replica set + migrations OK"
logging: *default-logging
web:
# La imagen se construye en el CI (runner de aaron) y se publica en el registry de
# Forgejo; aquí solo se descarga. groucho NO compila: un `meteor build` lo dejó sin ssh
# (5,9 GB compartidos con Mongo 7 + el resto del stack). Ver
# plan-modernizacion/fase-9-ci-imagenes.md y .forgejo/workflows/build-image.yml.
# Para fijar una versión concreta: TCEF_WEB_TAG=<sha12> docker compose up -d web
image: git.comunes.org/comunes/tcef-web:${TCEF_WEB_TAG:-meteor3-wip}
container_name: tcef-staging-web
depends_on:
mongo-init:
condition: service_completed_successfully
environment:
PORT: "3000"
ROOT_URL: https://testfuegos.comunes.org
MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0
METEOR_SETTINGS_FILE: /run/secrets/meteor-settings.json
volumes:
- ./secrets/meteor-settings.staging.json:/run/secrets/meteor-settings.json:ro
# Solo el proxy (assange) llega a este puerto; se publica en la red interna del host.
ports:
- "8004:3000"
restart: unless-stopped
logging: *default-logging
redis:
image: redis:7-alpine
container_name: tcef-staging-redis
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 6
restart: unless-stopped
logging: *default-logging
notifications:
build:
context: ../tcef-notifications
image: tcef-notifications:local
container_name: tcef-staging-notifications
depends_on:
mongo-init:
condition: service_completed_successfully
redis:
condition: service_healthy
# secrets/notifications.staging.env → MODE=shadow, MATCHER_MODE=shadow, sin FCM,
# MAIL_URL=smtp://mailhog:1025. No envía nada a usuarios reales.
env_file:
- ./secrets/notifications.staging.env
restart: unless-stopped
logging: *default-logging
# Buzón SMTP de captura: TODO el correo (verificación/reset/notifs) aterriza aquí, nunca fuera.
# UI web en 127.0.0.1:8025 (acceder por túnel SSH). SMTP interno en mailhog:1025.
mailhog:
image: mailhog/mailhog:latest
container_name: tcef-staging-mailhog
environment:
MH_STORAGE: memory
ports:
- "127.0.0.1:8025:8025"
restart: unless-stopped
logging: *default-logging
node-red:
image: nodered/node-red:4.0
container_name: tcef-staging-node-red
volumes:
- nodered-data:/data
ports:
- "127.0.0.1:1880:1880"
healthcheck:
test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1:1880/"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
logging: *default-logging
# Importador NASA FIRMS → escribe activefires en ESTE mongo (NUNCA en prod), alimentando al
# matcher en shadow. Proceso de larga vida: hace su ciclo cada INTERVAL_MINUTES (15, igual
# que el cron de shiva), no hace falta timer del host.
# El JWT de Earthdata va en secrets/importer.staging.env, nunca en la imagen ni en el repo.
importer:
build:
context: ../todos-contra-el-fuego/nasa-importer
image: tcef-nasa-importer:local
container_name: tcef-staging-importer
depends_on:
mongo-init:
condition: service_completed_successfully
environment:
MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0
INTERVAL_MINUTES: "15"
# Reproduce el `when` de producción (acq_time UTC leído como hora local). Ver
# nasa-importer/README.md § "Zona horaria" antes de cambiarlo a UTC.
FIRE_TIME_TZ: Europe/Madrid
env_file:
- ./secrets/importer.staging.env
volumes:
- importer-data:/data
restart: unless-stopped
logging: *default-logging
volumes:
mongo-data:
redis-data:
nodered-data:
importer-data:

View file

@ -1,125 +0,0 @@
# 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:

View file

@ -1,14 +0,0 @@
#!/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 "$@"

View file

@ -29,41 +29,40 @@ const findFiresInRegion = (zone) => {
return result;
};
export const countRealFires = async (firesCursor) => {
export const countRealFires = (firesCursor) => {
const realFires = [];
const fires = Array.isArray(firesCursor) ? firesCursor : await firesCursor.fetchAsync();
for (const fire of fires) {
firesCursor.forEach((fire) => {
if (debug) console.log(`${JSON.stringify(fire)} -----`);
const union = await firesUnion([fire]);
const union = firesUnion([fire]);
if (debug) console.log(`${JSON.stringify(union)} -----`);
const falsePos = whichAreFalsePositives(FalsePositives, union);
const industries = whichAreFalsePositives(Industries, union);
if (await falsePos.countAsync() === 0 && await industries.countAsync() === 0) {
if (falsePos.count() === 0 && industries.count() === 0) {
realFires.push(fire);
}
}
});
// group fires
const realUnion = await firesUnion(realFires);
const realUnion = firesUnion(realFires);
const unionCount = realUnion[0] &&
realUnion[0].geometry &&
realUnion[0].geometry.coordinates ? realUnion[0].geometry.coordinates.length : 0;
return unionCount;
};
const countFiresInRegions = async (regions, stringsToRemove) => {
const countFiresInRegions = (regions, stringsToRemove) => {
let total = 0;
const fireStats = {};
for (const region of regions.features) {
regions.features.forEach((region) => {
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 = await fires.countAsync();
const initialCount = fires.count();
if (initialCount > 0) {
const unionCount = await countRealFires(fires);
const unionCount = countRealFires(fires);
if (debug) console.log(`${regionName} initial: ${initialCount}, union calc: ${unionCount}`);
if (unionCount > 0) {
total += unionCount;
@ -73,7 +72,7 @@ const countFiresInRegions = async (regions, stringsToRemove) => {
} catch (e) {
ravenLogger.log(e);
}
}
});
return { total, fires: fireStats };
};

View file

@ -16,7 +16,7 @@ Meteor.publish('activefirestotal', function total() {
return counter;
});
const activefires = async (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => {
const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => {
const fires = ActiveFires.find({
ourid: {
$geoWithin: {
@ -36,10 +36,10 @@ const activefires = async (northEastLng, northEastLat, southWestLng, southWestLa
}
});
if (Meteor.isDevelopment) console.log(`Active fires total: ${await fires.countAsync()}`);
if (Meteor.isDevelopment) console.log(`Active fires total: ${fires.count()}`);
if (withMarks && (await fires.fetchAsync()).length > 0) {
const union = await firesUnion(fires);
if (withMarks && fires.fetch().length > 0) {
const union = firesUnion(fires);
const falsePos = whichAreFalsePositives(FalsePositives, union);
const industries = whichAreFalsePositives(Industries, union);
return [fires, falsePos, industries];
@ -48,7 +48,7 @@ const activefires = async (northEastLng, northEastLat, southWestLng, southWestLa
return fires;
};
Meteor.publish('activefiresmyloc', async function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) {
Meteor.publish('activefiresmyloc', 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));

View file

@ -16,7 +16,7 @@ Meteor.publish('activefiresuniontotal', function total() {
return counter;
});
const activeFiresUnion = async (b1, b2, c1, c2, withMarks) => {
const activeFiresUnion = (b1, b2, c1, c2, withMarks) => {
// a --- b
// c --- d
const geometry = {
@ -46,7 +46,7 @@ const activeFiresUnion = async (b1, b2, c1, c2, withMarks) => {
}
});
if (Meteor.isDevelopment) console.log(`Active fires union total: ${await fires.countAsync()}`);
if (Meteor.isDevelopment) console.log(`Active fires union total: ${fires.count()}`);
/*
if (withMarks && fires.fetch().length > 0) {
@ -59,7 +59,7 @@ const activeFiresUnion = async (b1, b2, c1, c2, withMarks) => {
return fires;
};
Meteor.publish('activefiresunionmyloc', async function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) {
Meteor.publish('activefiresunionmyloc', 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));

View file

@ -1,29 +0,0 @@
/* eslint-disable import/no-absolute-path */
import { Mongo } from 'meteor/mongo';
/*
* Comments collection (fire-page comments).
*
* Replaces the abandoned `arkham:comments-ui` Atmosphere package (no Meteor 2.x
* build) with a small React + methods implementation. The collection name
* ('comments') and the core document shape are kept so existing production
* documents keep rendering:
*
* { referenceId: 'fire-<id>', content, userId, username,
* media: { type, content }, likes: [userId], dislikes: [userId],
* status: 'approved', createdAt, updatedAt }
*
* Legacy documents may also carry replies[]/starRatings[]/ratingScore/
* isAnonymous those features were disabled in this app's config and are
* ignored by the new UI.
*/
const Comments = new Mongo.Collection('comments');
// Writes only through the vetted server methods.
Comments.deny({
insert: () => true,
update: () => true,
remove: () => true
});
export default Comments;

View file

@ -1,44 +0,0 @@
/*
* 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 {};
}

View file

@ -1,3 +0,0 @@
// Server-side registration for the Comments feature.
import './methods';
import './publications';

View file

@ -1,102 +0,0 @@
/* 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
});

View file

@ -1,42 +0,0 @@
/* 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}`);
});
}

View file

@ -1,14 +0,0 @@
/* 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 } }
);
});

View file

@ -1,15 +0,0 @@
/*
* Fire/collection docs use `idGeneration: 'MONGO'`, so their `_id` is a
* `Mongo.ObjectID`, whose `.toString()` yields `ObjectID("<hex>")` NOT the
* bare hex. Interpolating an `_id` straight into a URL or any string therefore
* produces `.../fire/archive/ObjectID("...")`, which the routes can't match.
*
* Use `hexId()` wherever an id becomes part of a string (URLs, comment
* reference ids, DOM ids). It returns the 24-char hex for an ObjectID and
* passes plain-string ids through unchanged. Do NOT use it for DDP method
* args whose `check()` expects `Meteor.Collection.ObjectID` pass the object.
*/
export const hexId = id =>
(id && typeof id.toHexString === 'function' ? id.toHexString() : id);
export default hexId;

View file

@ -6,7 +6,7 @@ import FalsePositiveTypes from './FalsePositiveTypes';
import Fires from '../Fires/Fires';
import rateLimit from '../../modules/rate-limit';
export async function upsertFalsePositive(keyType, owner, fire) {
export 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 async function upsertFalsePositive(keyType, owner, fire) {
fireId: fire._id,
geo: fire.ourid
};
return await FalsePositives.upsertAsync({ geo: fire.ourid, owner }, { $set: set }, { multi: false, upsert: true });
return FalsePositives.upsert({ 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 async function upsertFalsePositive(keyType, owner, fire) {
}
Meteor.methods({
'falsePositives.insert': async function falsePositivesInsert(fireId, keyType) {
'falsePositives.insert': 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 = await Fires.findOneAsync(fireId);
const fire = Fires.findOne(fireId);
const owner = this.userId;
if (fire) {
await upsertFalsePositive(keyType, owner, fire);
upsertFalsePositive(keyType, owner, fire);
} else {
throw new Meteor.Error('500', 'Fuego no encontrado');
}

View file

@ -17,8 +17,8 @@ Meteor.publish('falsePositivesTotal', function total() {
return counter;
});
export const firesUnion = async (fires) => {
const firesArray = Array.isArray(fires) ? fires : await fires.fetchAsync(); // if not is a cursor
export const firesUnion = (fires) => {
const firesArray = Array.isArray(fires) ? fires : fires.fetch(); // 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.publish('falsePositivesMyloc', function falsePositivesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) {
Meteor.publishTransformed('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.publish('falsePositivesMyloc', function falsePositivesInMyLoc(northEastLn
return find(FalsePositives, northEastLng, northEastLat, southWestLng, southWestLat);
});
Meteor.publish('industriesMyloc', function industriesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) {
Meteor.publishTransformed('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));

View file

@ -6,7 +6,7 @@ import { check } from 'meteor/check';
import { NumberBetween } from '/imports/modules/server/other-checks';
import FireAlerts from '../FireAlerts';
Meteor.publish('fireAlerts', async function fireAlerts(northEastLng, northEastLat, southWestLng, southWestLat) {
Meteor.publish('fireAlerts', 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', async function fireAlerts(northEastLng, northEastLa
track: 1
}
});
if (Meteor.isDevelopment) console.log(`Neighbour alerts total: ${await fires.countAsync()}`);
if (Meteor.isDevelopment) console.log(`Neighbour alerts total: ${fires.count()}`);
return fires;
});

View file

@ -53,12 +53,12 @@ const fixConfidence = (obj) => {
return obj;
};
const findOrCreateFire = async (obj) => {
const findOrCreateFire = (obj) => {
const fire = findFire(obj);
// console.log(`Found: ${await fire.countAsync()}`);
// console.log(`Found: ${fire.count()}`);
if (!obj.address) {
try {
const rev = await geocoder.reverse({ lat: obj.lat, lon: obj.lon });
const rev = Promise.await(geocoder.reverse({ lat: obj.lat, lon: obj.lon }));
if (rev[0]) {
obj.address = rev[0].formattedAddress;
}
@ -67,23 +67,23 @@ const findOrCreateFire = async (obj) => {
console.warn(reve);
}
}
if (await fire.countAsync() === 0) {
if (fire.count() === 0) {
// const result =
// console.log('Creating new fire');
await FiresCollection.upsertAsync({ ourid: obj.ourid, when: obj.when, type: obj.type }, { $set: obj }, { multi: false, upsert: true });
FiresCollection.upsert({ 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', async function fireFromAlertId(_id) {
Meteor.publish('fireFromAlertId', function fireFromAlertId(_id) {
try {
check(_id, String);
// console.log(`Looking for alert fire ${_id}`);
const fire = await FireAlertsCollection.findOneAsync(new Meteor.Collection.ObjectID(_id));
const fire = FireAlertsCollection.findOne(new Meteor.Collection.ObjectID(_id));
if (fire) {
// console.info(`Active fire found: ${_id}`);
return await findOrCreateFire(fixConfidence(fire));
return findOrCreateFire(fixConfidence(fire));
}
console.info(`Alert fire not found: ${_id}`);
// Not found in active fires!
@ -94,14 +94,14 @@ Meteor.publish('fireFromAlertId', async function fireFromAlertId(_id) {
}
});
Meteor.publish('fireFromActiveId', async function fireFromActiveId(_id) {
Meteor.publish('fireFromActiveId', function fireFromActiveId(_id) {
try {
check(_id, String);
// console.log(`Looking for active fire ${_id}`);
const fire = await ActiveFiresCollection.findOneAsync(new Meteor.Collection.ObjectID(_id));
const fire = ActiveFiresCollection.findOne(new Meteor.Collection.ObjectID(_id));
if (fire) {
// console.info(`Active fire found: ${_id}`);
return await findOrCreateFire(fixConfidence(fire));
return findOrCreateFire(fixConfidence(fire));
}
console.info(`Active fire not found: ${_id}`);
// Not found in active fires!
@ -112,14 +112,14 @@ Meteor.publish('fireFromActiveId', async function fireFromActiveId(_id) {
}
});
Meteor.publish('fireFromId', async function fireFromId(_id) {
Meteor.publish('fireFromId', function fireFromId(_id) {
try {
check(_id, String);
// console.log(`Looking for archive fire ${_id}`);
const fire = FiresCollection.find(new Meteor.Collection.ObjectID(_id));
if (await fire.countAsync() !== 0) {
if (fire.count() !== 0) {
// console.info(`Archive fire found: ${_id}`);
const union = await firesUnion(fire);
const union = firesUnion(fire);
const falsePos = whichAreFalsePositives(FalsePositives, union);
const industries = whichAreFalsePositives(Industries, union);
return [fire, falsePos, industries];
@ -139,12 +139,13 @@ function logUrl(fireEnc, params) {
ravenLogger.log(message);
}
export async function fireFromHash(fireEnc, params) {
export function fireFromHash(fireEnc, params) {
check(fireEnc, String);
check(params, Object);
// console.log(fireEnc);
const unsealed = await unsealW(fireEnc);
// const unsealed = Promise.await(urlEnc.decrypt(fireEnc));
const unsealed = Promise.await(unsealW(fireEnc));
if (unsealed === undefined) {
throw Error(`Fail to unseal '${fireEnc}'`);
}
@ -158,16 +159,16 @@ export async function fireFromHash(fireEnc, params) {
// FIXME:
const unsealedFix = fixConfidence(unsealed);
FiresCollection.schema.validate(unsealedFix);
return await findOrCreateFire(unsealedFix);
return findOrCreateFire(unsealedFix);
/* console.log(`fires: ${fire.count()}`);
* return fire; */
}
Meteor.publish('fireFromHash', async function fireFromHashFunc(fireEnc, params) {
Meteor.publish('fireFromHash', function fireFromHashFunc(fireEnc, params) {
check(fireEnc, String);
check(params, Object);
try {
return await fireFromHash(fireEnc, params);
return fireFromHash(fireEnc, params);
} catch (e) {
console.warn(e);
logUrl(fireEnc, params);

View file

@ -4,16 +4,16 @@ import { ServiceConfiguration } from 'meteor/service-configuration';
import rateLimit from '../../../modules/rate-limit';
Meteor.methods({
'oauth.verifyConfiguration': async function oauthVerifyConfiguration(services) {
'oauth.verifyConfiguration': function oauthVerifyConfiguration(services) {
check(services, Array);
try {
const verifiedServices = [];
for (const service of services) {
if (await ServiceConfiguration.configurations.findOneAsync({ service })) {
services.forEach((service) => {
if (ServiceConfiguration.configurations.findOne({ service })) {
verifiedServices.push(service);
}
}
});
return verifiedServices.sort();
} catch (exception) {
throw new Meteor.Error('500', exception);

View file

@ -89,8 +89,8 @@ if (!Meteor.settings.private.internalApiToken) {
},
endpoints: {
get: {
action: async function getFire() {
return Fires.findOneAsync(new Meteor.Collection.ObjectID(this.urlParams.id));
action: function getFire() {
return Fires.findOne(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: async function get() {
return SiteSettings.findOneAsync({ name: 'last-fire-check' });
get: function get() {
return SiteSettings.findOne({ name: 'last-fire-check' });
}
});
// Maps to: /api/v1/status/last-fire-detected
apiV1.addRoute('status/last-fire-detected', { authRequired: false }, {
get: async function get() {
return ActiveFiresCollection.findOneAsync({}, { sort: { when: -1 } });
get: function get() {
return ActiveFiresCollection.findOne({}, { sort: { when: -1 } });
}
});
// Maps to: /api/v1/status/active-fires-count
apiV1.addRoute('status/active-fires-count', { authRequired: false }, {
get: async function get() {
return { total: await ActiveFiresCollection.find({}).countAsync() };
get: function get() {
return { total: ActiveFiresCollection.find({}).count() };
}
});
@ -124,7 +124,7 @@ if (!Meteor.settings.private.internalApiToken) {
}
});
async function getFires(route, full) {
function getFires(route, full) {
const lat = Number(route.urlParams.lat);
const lng = Number(route.urlParams.lng);
const km = Number(route.urlParams.km);
@ -163,11 +163,7 @@ if (!Meteor.settings.private.internalApiToken) {
}
});
// 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) };
const result = { total: fires.count(), real: countRealFires(fires) };
if (debug) console.log(`Query for fires in ${lat}, ${lng} in ${km} km radius ${result}`);
if (!full) {
@ -176,16 +172,19 @@ if (!Meteor.settings.private.internalApiToken) {
let union;
// TODO only get real
const firesA = fires.fetch();
if (firesA.length > 0) {
union = await firesUnion(firesA);
union = firesUnion(fires);
} else {
union = zoneToUnion(lat, lng, km);
}
const falsePos = whichAreFalsePositives(FalsePositives, union);
const industries = whichAreFalsePositives(Industries, union);
result.fires = firesA;
result.industries = await industries.fetchAsync();
result.falsePos = await falsePos.fetchAsync();
result.industries = industries.fetch();
result.falsePos = falsePos.fetch();
return result;
}
@ -193,13 +192,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: async function get() {
get: function get() {
return getFires(this, false);
}
});
apiV1.addRoute('fires-in-full/:token/:lat/:lng/:km', { authRequired: false }, {
get: async function get() {
get: function get() {
return getFires(this, true);
}
});
@ -210,7 +209,7 @@ if (!Meteor.settings.private.internalApiToken) {
//
// https://docs.meteor.com/api/passwords.html#Accounts-createUser
apiV1.addRoute('mobile/users', { authRequired: false }, {
post: async function post() {
post: function post() {
const { token, mobileToken, lang } = this.bodyParams;
try {
check(token, String);
@ -226,23 +225,22 @@ if (!Meteor.settings.private.internalApiToken) {
let username;
const already = Meteor.users.find({ fireBaseToken: mobileToken });
const alreadyCount = await already.countAsync();
if (alreadyCount > 1) {
if (already.count() > 1) {
return restivusError(500, 'Unexpected error in REST call: several users with that mobile token?');
} else if (alreadyCount === 1) {
username = (await already.fetchAsync())[0].username;
} else if (already.count() === 1) {
username = already.fetch()[0].username;
} else {
do {
username = Random.id(15);
} while (await Meteor.users.find({ username }).countAsync() !== 0);
} while (Meteor.users.find({ username }).count() !== 0);
}
// FIXME check valid lang
const now = new Date();
const result = await Meteor.users.upsertAsync({
const result = Meteor.users.upsert({
fireBaseToken: mobileToken
}, {
$set: {
@ -262,7 +260,7 @@ if (!Meteor.settings.private.internalApiToken) {
console.log(this.queryParams);
}
const upsertUser = await Meteor.users.findOneAsync({ username });
const upsertUser = Meteor.users.findOne({ username });
if (debug) console.log(upsertUser);
@ -279,7 +277,7 @@ if (!Meteor.settings.private.internalApiToken) {
// max sitance: 100km
apiV1.addRoute('mobile/subscriptions', { authRequired: false }, {
post: async function post() {
post: function post() {
const {
token,
mobileToken,
@ -307,7 +305,7 @@ if (!Meteor.settings.private.internalApiToken) {
const failed = checkAuthToken(token);
if (failed) return failed;
const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken });
const user = Meteor.users.findOne({ fireBaseToken: mobileToken });
if (!user) return failMsg('User not found');
const newSubs = {};
@ -319,7 +317,7 @@ if (!Meteor.settings.private.internalApiToken) {
let result;
try {
result = await subscriptionsInsert(newSubs, user._id, 'mobile');
result = subscriptionsInsert(newSubs, user._id, 'mobile');
} catch (e) {
return fail(e);
}
@ -327,57 +325,8 @@ if (!Meteor.settings.private.internalApiToken) {
}
});
// NOTE: json-routes 3.0 (Meteor 3) matches routes in registration order, so
// the literal `all/:token/:mobileToken` route MUST be registered before the
// `:token/:mobileToken/:subsId` route — otherwise a GET to
// `.../all/<tok>/<mob>` matches the 3-param route (delete-only) and restivus
// replies "API endpoint does not exist".
apiV1.addRoute('mobile/subscriptions/all/:token/:mobileToken', { authRequired: false }, {
get: async function get() {
const { token, mobileToken } = this.urlParams;
try {
check(token, String);
check(mobileToken, String);
} catch (e) {
return defaultFailParams(e);
}
const failed = checkAuthToken(token);
if (failed) return failed;
const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken });
if (!user) return failMsg('User not found');
const result = Subscriptions.find({ owner: user._id });
return jsend.success({ subscriptions: await result.fetchAsync(), count: await result.countAsync() });
},
delete: async function delAll() {
const { token, mobileToken } = this.urlParams;
try {
check(token, String);
check(mobileToken, String);
} catch (e) {
return defaultFailParams(e);
}
const failed = checkAuthToken(token);
if (failed) return failed('Auth api check failed');
if (await Meteor.users.find({ fireBaseToken: mobileToken }).countAsync() !== 1) return fail;
const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken });
if (!user) return failMsg('User not found');
const toRemove = await Subscriptions.find({ owner: user._id }).countAsync();
await Subscriptions.removeAsync({ owner: user._id });
return jsend.success({ count: toRemove });
}
});
apiV1.addRoute('mobile/subscriptions/:token/:mobileToken/:subsId', { authRequired: false }, {
delete: async function del() {
delete: function del() {
const {
token,
mobileToken,
@ -394,11 +343,11 @@ if (!Meteor.settings.private.internalApiToken) {
const failed = checkAuthToken(token);
if (failed) return failed;
const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken });
const user = Meteor.users.findOne({ fireBaseToken: mobileToken });
if (!user) return failMsg('User not found');
try {
await Subscriptions.removeAsync({ owner: user._id, _id: new Meteor.Collection.ObjectID(subsId) });
Subscriptions.remove({ owner: user._id, _id: new Meteor.Collection.ObjectID(subsId) });
} catch (e) {
return fail(e);
}
@ -407,8 +356,52 @@ 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: async function get() {
get: function get() {
const { token } = this.urlParams;
try {
check(token, String);
@ -419,15 +412,15 @@ if (!Meteor.settings.private.internalApiToken) {
const failed = checkAuthToken(token);
if (failed) return failed;
const currentUnion = await SiteSettings.findOneAsync({ name: 'subs-public-union' });
const userSubsBounds = await SiteSettings.findOneAsync({ name: 'subs-public-union-bounds' });
const currentUnion = SiteSettings.findOne({ name: 'subs-public-union' });
const userSubsBounds = SiteSettings.findOne({ name: 'subs-public-union-bounds' });
return jsend.success({ union: currentUnion, bounds: userSubsBounds });
}
});
apiV1.addRoute('mobile/falsepositive', { authRequired: false }, {
post: async function post() {
post: function post() {
const {
token,
mobileToken,
@ -447,15 +440,15 @@ if (!Meteor.settings.private.internalApiToken) {
const failed = checkAuthToken(token);
if (failed) return failed;
const user = await Meteor.users.findOneAsync({ fireBaseToken: mobileToken });
const user = Meteor.users.findOne({ fireBaseToken: mobileToken });
if (!user) return failMsg('User not found');
console.log(`Marking hash fire as '${type}' false positive: ${sealed}`);
const fireC = await fireFromHash(sealed, { id: sealed });
const fireC = fireFromHash(sealed, { id: sealed });
if (fireC && typeof fireC === 'object') {
const fire = (await fireC.fetchAsync())[0];
const fire = fireC.fetch()[0];
console.log(`Marking fire as false positive: ${fire._id}`);
const result = await upsertFalsePositive(type, user._id, fire);
const result = upsertFalsePositive(type, user._id, fire);
return jsend.success({ upsert: result });
}
return failMsg('Cannot mark fire as false positive');

View file

@ -10,7 +10,7 @@ function geo(doc) {
};
}
export async function subscriptionsInsert(doc, userId, type) {
export 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 async function subscriptionsInsert(doc, userId, type) {
...doc
};
// console.log(newDoc);
const already = await Subscriptions.findOneAsync(newDoc);
const already = Subscriptions.findOne(newDoc);
if (already) {
throw new Meteor.Error('on-already-subscribed', 'The user is already subscribed to this area');
}
try {
return await Subscriptions.insertAsync(newDoc);
return Subscriptions.insert(newDoc);
} catch (exception) {
// console.error(exception);
throw new Meteor.Error('500', exception);
}
}
export async function subscriptionsRemove(subscriptionId) {
export function subscriptionsRemove(subscriptionId) {
check(subscriptionId, Meteor.Collection.ObjectID);
try {
return await Subscriptions.removeAsync(subscriptionId);
return Subscriptions.remove(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': async function subscriptionsUpdate(doc) {
'subscriptions.update': 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);
await Subscriptions.updateAsync(subscriptionId, { $set: dup });
Subscriptions.update(subscriptionId, { $set: dup });
return subscriptionId; // Return _id so we can redirect to subscription after update.
} catch (exception) {
throw new Meteor.Error('500', exception);

View file

@ -9,15 +9,3 @@ 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);
}
};

View file

@ -0,0 +1,153 @@
/* eslint-disable import/no-absolute-path */
import { Meteor } from 'meteor/meteor';
import Notifications from '/imports/api/Notifications/Notifications';
import i18n from 'i18next';
import moment from 'moment';
import { dateLongFormat } from '/imports/api/Common/dates';
// import sendMail from '/imports/startup/server/email';
import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email';
import { isMailServerMaster } from '/imports/startup/server/email';
// import { hr } from '/imports/startup/server/email';
import getEmailOf from '/imports/modules/get-email-of-user';
import image from 'google-maps-image-api-url';
import gcm from 'node-gcm';
import { trim } from '/imports/ui/components/NotificationsObserver/util.js';
import ravenLogger from '/imports/startup/server/ravenLogger';
let validFcmSender = true;
if (!(Meteor.settings.private.fcmApiToken && Meteor.settings.private.fcmApiToken.length > 0)) {
console.warn('Missing settings.private.fcmApiToken key, mobile notifications will not work');
validFcmSender = false;
}
// https://www.npmjs.com/package/google-maps-image-api-url
// https://stackoverflow.com/questions/24355007/is-there-no-way-to-embed-a-google-map-into-an-html-email
// https://developers.google.com/maps/documentation/static-maps/intro
function imgUrl(lat, lng) {
return image({
key: Meteor.settings.gmaps.key,
type: 'staticmap',
center: `${lat},${lng}`,
size: '640x480',
zoom: 16,
maptype: 'hybrid',
language: 'es',
markers: `icon: ${Meteor.settings.private.fireIconUrl}|${lat},${lng}`
});
}
/*
function imgEl(lat, lng) {
return `<img src="${imgUrl(lat, lng)}" width="640" height="480"/>`;
} */
// Cutover flag (fase 1a): channels listed in
// Meteor.settings.private.notifDisabledChannels (e.g. ['mobile','web']) are
// handled by the tcef-notifications microservice, so the old observer/cron must
// NOT send them. Mutual exclusion per channel — reversible by editing settings,
// no code deploy needed. ES5-compatible on purpose (Meteor 1.6 / Node 8).
const notifDisabledChannels = (Meteor.settings.private && Meteor.settings.private.notifDisabledChannels) || [];
const processNotif = (notif) => {
if (notifDisabledChannels.indexOf(notif.type) !== -1) {
// This channel was migrated to tcef-notifications; do nothing here.
return;
}
if (isMailServerMaster && validFcmSender && notif.type === 'mobile' && notif.notified !== true) {
const fcmSender = new gcm.Sender(Meteor.settings.private.fcmApiToken);
const user = Meteor.users.findOne({ _id: notif.userId });
moment.locale(user.lang);
// duplicate code below
const body = `${trim(notif.content)}`;
// https://firebase.google.com/docs/cloud-messaging/concept-options
const msg = new gcm.Message();
msg.addNotification({
title: i18n.t('Alerta de fuego'),
body,
click_action: 'FLUTTER_NOTIFICATION_CLICK',
tag: notif._id, // prevent duplication of fire notifications
sound: 'default', // Indicates sound to be played. Supports only default currently.
icon: 'launch_image' // 'ic_launcher'
});
msg.addData('id', notif._id._str);
msg.addData('description', body);
msg.addData('lat', notif.geo.coordinates[1]);
msg.addData('lon', notif.geo.coordinates[0]);
msg.addData('when', notif.when);
msg.addData('subsId', notif.subsId._str);
msg.addData('sealed', notif.sealed);
const registrationTokens = [];
if (!user.fireBaseToken) {
console.warn('This mobile user doesn\'t have a firebase registration token');
} else {
registrationTokens.push(user.fireBaseToken);
// FIXME: better join users
if (validFcmSender) {
fcmSender.send(msg, { registrationTokens }, Meteor.bindEnvironment(function processResult(err, response) {
if (err) {
console.error(`FCM error: ${err}`);
// FIXME send to sentry
ravenLogger.log(err);
} else {
// console.log(`FCM response: ${response}`);
Notifications.update(notif._id, { $set: { notified: true, notifiedAt: new Date() } });
}
}));
}
}
}
if (isMailServerMaster && notif.type === 'web' && notif.emailNotified !== true) {
const user = Meteor.users.findOne({ _id: notif.userId });
const { firstName, emailAddress } = getEmailOf(user);
if (emailAddress) {
const img = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]);
// const url = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]);
const fireUrl = `${Meteor.absoluteUrl('fire/')}${notif.sealed}`;
// const fireHtmlUrl = `<a href="${fireUrl}">${i18n.t('Más información sobre este fuego')}</a>`;
// TODO get _id of fire
// const fireTextUrl = `${i18n.t('Más información sobre este fuego')}:\n${fireUrl}`;
// FIXME use our map as url and static map as img
moment.locale(user.lang);
// moment user tz ?
const message = `${trim(notif.content)} (${i18n.t('fireDetectedAt', { when: dateLongFormat(notif.when) })}).`;
// TODO Comunes Address
const emailOpts = {
to: emailAddress,
// userName: firstName,
// sendAt: new Date(),
subject: subjectTruncate.apply(message),
// text: `${message}\n\n${fireTextUrl}\n\n`,
// template: '<body><h2>{{appName}}</h2>{{{html}}}</body>',
lang: user.lang,
template: 'new-fire',
templateVars: {
applicationName: i18n.t('AppName'),
firstName,
message,
fireUrl,
img,
subsUrl: Meteor.absoluteUrl('subscriptions')
}
};
sendEmail(emailOpts).catch((error) => {
throw new Meteor.Error('500', `${error}`);
});
// sendMail(emailOpts, true);
Notifications.update(notif._id, { $set: { emailNotified: true, emailNotifiedAt: new Date() } });
} else {
// Not email or not verified -> remove notif so we don't retry to send it
Notifications.remove({ _id: notif._id });
}
}
};
export default processNotif;

View file

@ -13,11 +13,6 @@ 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);
console.log('GMaps script just loaded');

View file

@ -0,0 +1,42 @@
/* 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
});
});

View file

@ -0,0 +1,29 @@
.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;
}

View file

@ -1,10 +1,11 @@
/* global Intl */
/* global CookieConsent 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-http-backend';
import backend from 'i18next-xhr-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';
@ -30,9 +31,21 @@ 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 = {
useSuspense: false, // was `wait: true` pre-react-i18next-10
wait: true,
defaultTransParent: 'span'
// https://react.i18next.com/components/i18next-instance.ht
/* bindI18n: 'languageChanged loaded',
@ -42,17 +55,13 @@ i18nOpts.react = {
const sendMissing = true; // Meteor.isDevelopment;
if (sendMissing && Meteor.isDevelopment) {
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) {
i18nOpts.sendMissing = true;
i18nOpts.missingKeyHandler = function miss(lng, 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 {
@ -62,6 +71,7 @@ function setT9(lang) {
i18n.use(backend)
.use(LngDetector)
.use(Cache)
.init(i18nOpts, (err, t) => {
// initialized and ready to
if (err) {
@ -79,9 +89,23 @@ i18n.use(backend)
i18nReady.set(true);
// 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.
// 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);
});
Meteor.subscribe('userData'); // lang is there

View file

@ -1,11 +1,6 @@
/* 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 { createRoot } from 'react-dom/client';
import { HelmetProvider } from 'react-helmet-async';
import { render } from 'react-dom';
import { Meteor } from 'meteor/meteor';
import '/imports/startup/client/modernizr';
import App from '../../ui/layouts/App/App';
@ -20,9 +15,5 @@ else {
Modernizr.on('webp', (result) => {
if (Meteor.isDevelopment) console.log(`webp ${result ? '' : 'not '}supported`);
});
Meteor.startup(() => createRoot(document.getElementById('react-root')).render(
<HelmetProvider>
<App />
</HelmetProvider>
));
Meteor.startup(() => render(<App />, document.getElementById('react-root')));
}

View file

@ -1,37 +1,16 @@
// 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 RavenLogger from 'meteor/flowkey:raven';
import { Meteor } from 'meteor/meteor';
const dsn = Meteor.settings.public.sentryPublicDSN;
const enabled = !!dsn;
const ravenOptions = {};
const publicDSN = Meteor.settings.public.sentryPublicDSN;
const enabled = !!publicDSN;
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'
});
}
const ravenLogger = enabled ? new RavenLogger({
publicDSN, // will be used on the client
shouldCatchConsoleError: true, // default
trackUser: true // default
}, ravenOptions) : { log: (error) => { console.log(error); } };
const ravenLogger = {
log(error, info) {
console.log(error);
if (enabled) {
Sentry.captureException(error, info ? { extra: { info } } : undefined);
}
}
};
console.log(`sentryLogger (client) ${enabled ? 'enabled' : 'disabled'}`);
console.log(`ravenLogger ${enabled ? 'enabled' : 'disabled'}`);
export default ravenLogger;

View file

@ -0,0 +1,21 @@
/* 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 : '';
}
});

View file

@ -3,9 +3,7 @@ import moment from 'moment';
// Load the js langs
import es from 'meteor-accounts-t9n/build/es';
import en from 'meteor-accounts-t9n/build/en';
// 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.
// TODO ask for translation of this
// import gl from 'meteor-accounts-t9n/build/gl';
const backOpts = {
@ -42,17 +40,13 @@ const i18nOpts = {
return value;
}
},
supportedLngs: ['es', 'en', 'gl'], // allowed languages (was `whitelist` pre-i18next-21)
whitelist: ['es', 'en', 'gl'], // allowed languages
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: 'đ'

View file

@ -21,16 +21,11 @@ function isPrivateIP(ip) {
const dbpath = '/usr/local/share/maxmind-geolite2/GeoLite2-City.mmdb';
// const dbpath = `${process.env.PWD}/private/GeoLite2-City.mmdb`;
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.
if (!fs.existsSync(dbpath)) {
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
@ -52,7 +47,7 @@ export function localize() {
// http://dev.maxmind.com/geoip/geoip2/geolite2/
const geo = IPGeocoder.get(clientIP);
// console.warn(geo);
if (geo && geo.location && geo.location.latitude && geo.location.longitude) {
if (geo.location && geo.location.latitude && geo.location.longitude) {
return geo;
}
// geoIP fallback, Madrid

View file

@ -4,12 +4,10 @@ import { ServiceConfiguration } from 'meteor/service-configuration';
const OAuthSettings = Meteor.settings.private.OAuth;
if (OAuthSettings) {
// 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(
Object.keys(OAuthSettings).forEach((service) => {
ServiceConfiguration.configurations.upsert(
{ service },
{ $set: OAuthSettings[service] },
);
}
});
}

View file

@ -15,6 +15,8 @@ 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';
@ -26,26 +28,3 @@ 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
});

View file

@ -0,0 +1,80 @@
/* 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');
}
}
});
});

View file

@ -4,6 +4,8 @@
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/
@ -53,11 +55,29 @@ Meteor.startup(() => {
job: () => sendEmailsFromQueue()
});
// 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).
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() };
}
});
}
const esEn = Meteor.settings.private.twitter.es.enabled;

View file

@ -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,8 +48,7 @@ if (isMailServerMaster) {
concatDelimiter: hr + '<h2>{{{subject}}}</h2>', // Start each concatenated email with it's own subject
/* eslint-enable */
// concatThrottling: 30,
// Mail-Time 2.x removed the static MailTime.Template; omitting `template`
// uses the built-in default ('{{{html}}}'). (debt: richer wrapper template)
template: MailTime.Template // Use default template
});
} else {
console.log('I\'m a mail client');

View file

@ -1,18 +1,9 @@
/* global Facts */
import { Roles } from 'meteor/alanning:roles';
import { Meteor } from 'meteor/meteor';
import { Facts } from 'meteor/facts-base';
/*
* 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) => {
const user = Meteor.users.findOne(userId);
// console.log(`User roles: ${user.roles}`);
return Roles.userIsInRole(userId, ['admin']);
});
Facts.setUserIdFilter(userId => adminIds.has(userId));

View file

@ -0,0 +1,7 @@
// 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;

View file

@ -1,71 +1,54 @@
/* eslint-disable no-await-in-loop */
import seeder from '@cleverbeagle/seeder';
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
import Documents from '../../api/Documents/Documents';
/*
* 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.
*/
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}`,
};
},
});
const USERS = [
{
seeder(Meteor.users, {
environments: ['development', 'staging'],
noLimit: true,
data: [{
email: 'admin@admin.com',
password: 'password',
profile: { name: { first: 'Andy', last: 'Warhol' } },
roles: ['admin']
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);
},
};
},
...[
['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}`);
}
});

View file

@ -1,6 +1,6 @@
import { Meteor } from 'meteor/meteor';
import i18n from 'i18next';
import backend from 'i18next-fs-backend';
import backend from 'i18next-sync-fs-backend';
import i18nOpts from '../common/i18n';
// import moment from 'moment';
// import { T9n } from 'meteor-accounts-t9n';

View file

@ -1,15 +1,16 @@
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';

View file

@ -1,8 +1,6 @@
/* global Migrations */
/* global Migrations, Comments */
/* 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';
@ -15,7 +13,7 @@ import Notifications from '/imports/api/Notifications/Notifications';
import ActiveFiresUnion from '/imports/api/ActiveFiresUnion/ActiveFiresUnion';
import { Mongo } from 'meteor/mongo';
Meteor.startup(async () => {
Meteor.startup(() => {
// https://github.com/percolatestudio/meteor-migrations
Migrations.config({
@ -25,40 +23,38 @@ Meteor.startup(async () => {
Migrations.add({
version: 1,
up: async function migrateIds() {
up: function migrateIds() {
// https://docs.mongodb.com/manual/reference/operator/query/type/
const users = await Meteor.users.find({ _id: { $type: 7 } }).fetchAsync();
for (const user of users) {
Meteor.users.find({ _id: { $type: 7 } }).forEach((user) => {
const migratedUser = user;
const id = user._id.valueOf();
console.log(`Migrating id of user: ${JSON.stringify(user)}`);
await Meteor.users.removeAsync({ _id: user._id });
Meteor.users.remove({ _id: user._id });
migratedUser._id = id;
await Meteor.users.insertAsync(migratedUser);
}
Meteor.users.insert(migratedUser);
});
}
});
Migrations.add({
version: 2,
up: async function migrateSubsForeignKey() {
const subs = await UserSubsToFiresCollection.find({ owner: null }).fetchAsync();
for (const sub of subs) {
up: function migrateSubsForeignKey() {
UserSubsToFiresCollection.find({ owner: null }).forEach((sub) => {
console.log(`Migrating subs of chatId: ${sub.chatId}`);
const subsUser = await Meteor.users.findOneAsync({ telegramChatId: sub.chatId });
const subsUser = Meteor.users.findOne({ telegramChatId: sub.chatId });
if (subsUser) {
console.log(`Migrating linking to user: ${JSON.stringify(subsUser)}`);
await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { owner: subsUser._id } });
UserSubsToFiresCollection.update({ _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 = await Accounts.createUserAsync({
const newUserId = Accounts.createUser({
username,
password: randomHex(50),
profile: { name: {} }
});
await Meteor.users.updateAsync({ _id: newUserId }, {
Meteor.users.update({ _id: newUserId }, {
$set: {
emails: [],
roles: ['user'],
@ -66,90 +62,87 @@ Meteor.startup(async () => {
lang: 'es'
}
});
await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { owner: newUserId } });
UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { owner: newUserId } });
}
}
});
}
});
Migrations.add({
version: 3,
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' } });
}
up: function emptySubsTypes() {
UserSubsToFiresCollection.find({ type: null }).forEach((sub) => {
UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { type: 'telegram' } });
});
}
});
Migrations.add({
version: 4,
up: async function deleteOldAlertFiresAndIndexes() {
await FireAlertsCollection.removeAsync({ createdAd: null });
up: function deleteOldAlertFiresAndIndexes() {
FireAlertsCollection.remove({ createdAd: null });
const raw = FireAlertsCollection.rawCollection();
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 });
raw.createIndex({ ourid: '2dsphere' });
raw.createIndex({ when: 1 });
raw.createIndex({ updatedAt: 1 });
raw.createIndex({ createdAt: 1 });
raw.createIndex({ ourid: 1, type: 1 });
}
});
Migrations.add({
version: 5,
up: async function siteSettingsIndex() {
up: function siteSettingsIndex() {
// other way:
await SiteSettings.createIndexAsync({ name: 1 }, { unique: true });
SiteSettings._ensureIndex({ name: 1 }, { unique: 1 });
}
});
Migrations.add({
version: 6,
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' });
up: function falsePositiveIndexes() {
FalsePositives._ensureIndex({ chatId: 1 });
FalsePositives._ensureIndex({ owner: 1 });
FalsePositives._ensureIndex({ fireId: 1 });
FalsePositives._ensureIndex({ type: 1 });
FalsePositives._ensureIndex({ geo: '2dsphere' });
}
});
Migrations.add({
version: 7,
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 }, {
up: function defLangIfNull() {
Meteor.users.find({ lang: null }).forEach((user) => {
Meteor.users.update({ _id: user._id }, {
$set: {
lang: 'es'
}
});
}
});
}
});
Migrations.add({
version: 8,
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 } });
}
up: function siteSettingsAddIndex() {
SiteSettings._ensureIndex({ isPublic: 1 });
SiteSettings.find({ isPublic: null }).forEach((setting) => {
SiteSettings.update({ _id: setting._id }, { $set: { isPublic: true } });
});
}
});
Migrations.add({
version: 9,
up: async function industriesIndexesAndRegistries() {
await Industries.createIndexAsync({ registry: 1 });
await Industries.createIndexAsync({ geo: '2dsphere' });
up: function siteSettingsAddIndex() {
Industries._ensureIndex({ registry: 1 });
Industries._ensureIndex({ 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
await IndustryRegistries.insertAsync({
IndustryRegistries.insert({
_id: '1', name: 'E-PRTR', agency: 'EEA', region: 'EU'
});
// https://www.epa.gov/enviro/epa-frs-facilities-state-single-file-csv-download
await IndustryRegistries.insertAsync({
IndustryRegistries.insert({
_id: '2', name: 'FRS', agency: 'EPA', region: 'US'
});
}
@ -157,11 +150,11 @@ Meteor.startup(async () => {
Migrations.add({
version: 10,
up: async function moreIndustryRegistries() {
await IndustryRegistries.insertAsync({
up: function siteSettingsAddIndex() {
IndustryRegistries.insert({
_id: '3', name: 'NPRI', agency: 'GCODP', region: 'Canada'
});
await IndustryRegistries.insertAsync({
IndustryRegistries.insert({
_id: '4', name: 'NPI', agency: 'DEE', region: 'Australia'
});
}
@ -169,15 +162,15 @@ Meteor.startup(async () => {
Migrations.add({
version: 11,
up: async function noAnonComments() {
await Comments.removeAsync({ isAnonymous: true });
up: function noAnonComments() {
Comments.getCollection().remove({ isAnonymous: true });
}
});
Migrations.add({
version: 12,
up: async function setTelegramUsersBotId() {
await Meteor.users.updateAsync({ telegramChatId: { $ne: null }, telegramBot: null }, {
up: function setTelegramUsersBotId() {
Meteor.users.update({ telegramChatId: { $ne: null }, telegramBot: null }, {
$set: {
telegramBot: 'es'
}
@ -187,8 +180,8 @@ Meteor.startup(async () => {
Migrations.add({
version: 13,
up: async function removeTelegramBotFromUsersId() {
await Meteor.users.updateAsync({}, {
up: function removeTelegramBotFromUsersId() {
Meteor.users.update({}, {
$unset: {
telegramBot: ''
}
@ -198,8 +191,8 @@ Meteor.startup(async () => {
Migrations.add({
version: 14,
up: async function setTelegramSubsBotId() {
await UserSubsToFiresCollection.updateAsync({ chatId: { $ne: null }, telegramBot: null }, {
up: function setTelegramUsersBotId() {
UserSubsToFiresCollection.update({ chatId: { $ne: null }, telegramBot: null }, {
$set: {
telegramBot: 'es'
}
@ -209,41 +202,40 @@ Meteor.startup(async () => {
Migrations.add({
version: 15,
up: async function moveToFalsePositivesUppercase() {
up: 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: async function moveToFalsePositivesUppercaseWithUser() {
up: function moveToFalsePositivesUppercaseWithUser() {
const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' });
const now = new Date();
const falseDocs = await falsepositiveslower.find({}).fetchAsync();
for (const falseDoc of falseDocs) {
const user = await Meteor.users.findOneAsync({ telegramChatId: falseDoc.chatId });
falsepositiveslower.find({}).forEach((falseDoc) => {
const user = Meteor.users.findOne({ telegramChatId: falseDoc.chatId });
if (user) {
falseDoc.owner = user._id;
falseDoc.owner= user._id;
}
falseDoc.type = 'industry';
falseDoc.createdAt = now;
falseDoc.updatedAt = now;
await FalsePositives.insertAsync(falseDoc);
}
FalsePositives.insert(falseDoc);
});
}
});
Migrations.add({
version: 17,
up: async function renameWebNotifiedField() {
await Notifications.updateAsync({ webNotified: { $exists: true } }, {
up: function renameWebNotifiedField() {
Notifications.update({ webNotified: { $exists: true } }, {
$rename: { webNotifiedAt: 'notifiedAt' }
}, { upsert: false, multi: true });
await Notifications.updateAsync({ webNotified: { $exists: true } }, {
Notifications.update({ webNotified: { $exists: true } }, {
$rename: { webNotified: 'notified' }
}, { upsert: false, multi: true });
}
@ -251,19 +243,18 @@ Meteor.startup(async () => {
Migrations.add({
version: 18,
up: async () => {
up: () => {
const raw = ActiveFiresUnion.rawCollection();
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 });
raw.createIndex({ centerid: '2dsphere' });
raw.createIndex({ shape: '2dsphere' });
raw.createIndex({ when: 1 });
raw.createIndex({ createdAt: 1 });
raw.createIndex({ updatedAt: 1 });
}
});
// 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');
// Set createdAt in users & subs
Migrations.migrateTo('latest');
// Migrations.migrateTo('14,rerun');
});

View file

@ -0,0 +1,19 @@
/* 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);
}
});
}
});

View file

@ -1,35 +1,18 @@
/* 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 RavenLogger from 'meteor/flowkey:raven';
import { Meteor } from 'meteor/meteor';
const dsn = Meteor.settings.sentryPrivateDSN;
const enabled = !!dsn;
const ravenOptions = {};
const publicDSN = Meteor.settings.public.sentryPublicDSN;
const privateDSN = Meteor.settings.sentryPrivateDSN;
const enabled = publicDSN && privateDSN;
if (enabled) {
Sentry.init({
dsn,
environment: Meteor.isDevelopment ? 'development' : 'production',
// errors only; no tracing/profiling (avoids the OTel auto-instrumentation)
tracesSampleRate: 0
});
}
const ravenLogger = enabled ? new RavenLogger({
publicDSN,
privateDSN,
shouldCatchConsoleError: true, // default true
trackUser: true // default false
}, ravenOptions) : { log: (error) => { console.log(error); } };
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'}`);
console.log(`ravenLogger ${enabled ? 'enabled' : 'disabled'}`);
export default ravenLogger;

View file

@ -1,127 +0,0 @@
/* eslint-disable import/no-absolute-path */
import https from 'https';
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
/*
* Sentry tunnel: /sentry-tunnel receives the browser SDK's envelopes
* same-origin and forwards them to GlitchTip server-side.
*
* Why: GlitchTip lives behind Cloudflare (sentry.comunes.org). Cloudflare's
* bot protection answers the browser's cross-site ingest POSTs with 503 (no
* CORS headers -> "Failed to fetch"), while identical server-side requests get
* 200. Routing events through our own origin avoids the cross-site request
* (and ad-blockers) completely. The browser SDK opts in via `tunnel` in
* imports/startup/client/ravenLogger.js.
*
* Single-DSN app: the target is derived from sentryPublicDSN, so we never have
* to decompress/parse the envelope body we just relay the raw bytes to the
* right project's ingest endpoint.
*/
const MAX_BODY = 1024 * 1024; // 1 MB: Sentry envelopes are small; cap abuse.
function ingestTargetFromDsn(dsn) {
// DSN: https://<publicKey>@<host>/<projectId>
const u = new URL(dsn);
const projectId = u.pathname.replace(/^\//, '');
const publicKey = u.username;
if (!projectId || !publicKey) throw new Error('DSN missing project id or public key');
// GlitchTip authenticates the ingest by the `sentry_key` query param; when the
// SDK tunnels, that key lives inside the envelope header, which GlitchTip does
// not read here -> it would answer 403 "Denied". Pass the key explicitly.
const path = `/api/${projectId}/envelope/?sentry_key=${publicKey}&sentry_version=7`;
return { hostname: u.hostname, port: u.port || 443, path };
}
/*
* Forward the raw envelope to GlitchTip over IPv4 (`family: 4`). GlitchTip is
* behind Cloudflare, which is dual-stack; on IPv4-only hosts Node's fetch/
* Happy-Eyeballs may pick the unroutable IPv6 and fail. Cloudflare always has
* IPv4, so pinning the family is safe everywhere and avoids that flakiness.
*/
function forwardToIngest(target, headers, body) {
return new Promise((resolve, reject) => {
const upstream = https.request({
hostname: target.hostname,
port: target.port,
path: target.path,
method: 'POST',
family: 4,
headers
}, (r) => {
r.resume(); // drain the response
if (r.statusCode >= 400) {
console.error(`sentry-tunnel: upstream returned ${r.statusCode}`);
}
resolve(r.statusCode);
});
upstream.on('error', reject);
upstream.end(body);
});
}
const publicDsn = Meteor.settings.public && Meteor.settings.public.sentryPublicDSN;
if (publicDsn) {
let ingestTarget;
try {
ingestTarget = ingestTargetFromDsn(publicDsn);
} catch (e) {
console.error(`sentry-tunnel: invalid sentryPublicDSN, tunnel disabled: ${e.message}`);
}
if (ingestTarget) {
WebApp.connectHandlers.use('/sentry-tunnel', (req, res) => {
if (req.method !== 'POST') {
res.writeHead(405, { Allow: 'POST' });
res.end();
return;
}
const chunks = [];
let size = 0;
let aborted = false;
req.on('data', (chunk) => {
if (aborted) return;
size += chunk.length;
if (size > MAX_BODY) {
aborted = true;
res.writeHead(413);
res.end();
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', async () => {
if (aborted) return;
try {
const body = Buffer.concat(chunks);
const headers = { 'Content-Type': 'application/x-sentry-envelope' };
if (req.headers['content-encoding']) {
headers['Content-Encoding'] = req.headers['content-encoding'];
}
// Preserve the reporter's IP so GlitchTip attributes the event correctly.
const fwd = req.headers['x-forwarded-for'];
headers['X-Forwarded-For'] = fwd
? `${fwd}, ${req.socket.remoteAddress}`
: req.socket.remoteAddress;
const status = await forwardToIngest(ingestTarget, headers, body);
res.writeHead(status);
res.end();
} catch (err) {
// Never let a reporting failure surface to the user; just log it.
console.error(`sentry-tunnel: forward failed: ${err.message}`);
res.writeHead(502);
res.end();
}
});
});
console.log(`sentry-tunnel enabled -> https://${ingestTarget.hostname}${ingestTarget.path}`);
}
}

View file

@ -1,37 +1,44 @@
/* global sitemaps Comments */
/* eslint-disable import/no-absolute-path */
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
/*
* /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.
*/
import Fires from '/imports/api/Fires/Fires';
// import Comments from '/imports/api/Comments/Comments';
const PAGES = [
'/fires',
'/login',
'/signup',
'/recover-password',
'/credits',
'/terms',
'/license',
'/privacy',
'/about'
];
const firesMapEnabled = false;
function xmlEscape(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
sitemaps.add('/sitemap.xml', () => {
const today = new Date();
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 ` <url>\n <loc>${loc}</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`;
}).join('\n');
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
res.writeHead(200, { 'Content-Type': 'application/xml; charset=utf-8' });
res.end(body);
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;
});

View file

@ -11,7 +11,7 @@ import { isMailServerMaster } from '/imports/startup/server/email';
// sudo apt-get install libcairo2-dev libjpeg-dev libgif-dev
Meteor.startup(async () => {
Meteor.startup(() => {
if (!isMailServerMaster) {
console.log('We only process subsUnion in master');
return;
@ -35,8 +35,8 @@ Meteor.startup(async () => {
const noNoisy = sub => sub;
const process = async (isPublic) => {
const subscribers = await Subscriptions.find().fetchAsync();
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];
@ -74,9 +74,9 @@ Meteor.startup(async () => {
};
// 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
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 });
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!');
@ -84,39 +84,40 @@ Meteor.startup(async () => {
};
// At startup, we check if it's necessary to calc subscriptions union again
const currentUnion = await SiteSettings.findOneAsync({ name: 'subs-public-union' });
const lastSubs = await Subscriptions.findOneAsync({}, { sort: { updatedAt: -1 } });
const countUnionSubs = await SiteSettings.findOneAsync({ name: 'subs-union-count' });
const countSubs = await Subscriptions.find({}).countAsync();
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');
await process(true);
await process(false);
process(true);
process(false);
} else {
console.log('Subs union up-to-date');
}
}
const recreate = async () => { await process(true); await process(false); };
await Subscriptions.find({ createdAt: { $gt: new Date() } }).observeAsync({
added: async function newSubAdded() { // doc) {
Subscriptions.find({ createdAt: { $gt: new Date() } }).observe({
added: function newSubAdded() { // doc) {
if (debug) console.log('Subs added so recreate union');
await recreate();
process(true);
process(false);
}
});
await Subscriptions.find().observeAsync({
changed: async function subsChanged() { // updatedDoc, oldDoc) {
Subscriptions.find().observe({
changed: function subsChanged() { // updatedDoc, oldDoc) {
if (debug) console.log('Subs changed so recreate union');
await recreate();
process(true);
process(false);
},
removed: async function subsRemoved() { // oldDoc) {
removed: function subsRemoved() { // oldDoc) {
if (debug) console.log('Subs removed so recreate union');
await recreate();
process(true);
process(false);
}
});
});

View file

@ -1,4 +1,4 @@
@use '../../stylesheets/colors' as *;
@import '../../stylesheets/colors';
.AccountPageFooter {
margin: 25px 0 0;

View file

@ -1,16 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Navigate } from 'react-router-dom';
import { Route, Redirect } from 'react-router-dom';
// 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 : <Navigate to="/login" replace />
const Authenticated = ({ loggingIn, authenticated, component, path, exact, ...rest }) => (
<Route
path={path}
exact={exact}
render={props => (
authenticated ?
(React.createElement(component, { ...props, ...rest, loggingIn, authenticated })) :
(<Redirect to="/login" />)
)}
/>
);
Authenticated.propTypes = {
loggingIn: PropTypes.bool.isRequired,
authenticated: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired
component: PropTypes.func.isRequired,
};
export default Authenticated;

View file

@ -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 { withTranslation, Trans } from 'react-i18next';
import { translate, Trans } from 'react-i18next';
import { testId } from '/imports/ui/components/Utils/TestUtils';
/*
@ -39,4 +39,4 @@ AuthenticatedNavigation.propTypes = {
name: PropTypes.string.isRequired
};
export default withTranslation()(withRouterCompat(AuthenticatedNavigation));
export default translate([], { wait: true })(withRouter(AuthenticatedNavigation));

View file

@ -4,7 +4,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import { translate } from 'react-i18next';
import './BetaRibbon.scss';
@ -30,4 +30,4 @@ BetaRibbon.propTypes = {
BetaRibbon.defaultProps = {
};
export default withTranslation()(BetaRibbon);
export default translate([], { wait: true })(BetaRibbon);

View file

@ -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 { withTranslation } from 'react-i18next';
import { translate } 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 (
<Button variant="secondary" title={msg} onClick={() => this.onClick()}>
<Button bsStyle="default" title={msg} onClick={() => this.onClick()}>
<i className="fa fa-crosshairs" />{!onlyIcon ? ` ${msg}` : ''}
</Button>);
}
@ -63,4 +63,4 @@ CenterInMyPosition.propTypes = {
onlyIcon: PropTypes.bool
};
export default withTranslation()(CenterInMyPosition);
export default translate([], { wait: true })(CenterInMyPosition);

View file

@ -1,6 +1,111 @@
// 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';
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 (
<Component
{...elementProps}
className={classNames(className, classes)}
/>
);
}
}
Col.propTypes = propTypes;
Col.defaultProps = defaultProps;
export default bsClass('col', Col);

View file

@ -1,76 +0,0 @@
.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;
}

View file

@ -1,152 +0,0 @@
/* eslint-disable import/no-absolute-path */
/* eslint-disable jsx-a11y/media-has-caption */
import React from 'react';
import PropTypes from 'prop-types';
import { withTracker } from 'meteor/react-meteor-data';
import { withTranslation } from 'react-i18next';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import CommentsCollection from '/imports/api/Comments/Comments';
import './Comments.scss';
function MediaEmbed({ media }) {
if (!media || !media.content) return null;
if (media.type === 'image') {
return <img className="comment-media-image" src={media.content} alt="" />;
}
if (media.type === 'youtube') {
return (
<div className="comment-media-youtube">
<iframe title="youtube" src={media.content} frameBorder="0" allowFullScreen />
</div>
);
}
return null;
}
MediaEmbed.propTypes = { media: PropTypes.object };
MediaEmbed.defaultProps = { media: null };
class CommentsBox extends React.Component {
constructor(props) {
super(props);
this.state = { draft: '', editingId: null, editDraft: '' };
}
call(method, ...args) {
Meteor.call(method, ...args, (err) => {
if (err) console.warn(`${method}: ${err.reason || err.message}`);
});
}
submit = (e) => {
e.preventDefault();
const content = this.state.draft.trim();
if (!content) return;
this.setState({ draft: '' });
this.call('comments.insert', this.props.referenceId, content);
};
saveEdit = (id) => {
const content = this.state.editDraft.trim();
if (!content) return;
this.setState({ editingId: null, editDraft: '' });
this.call('comments.edit', id, content);
};
renderComment(c) {
const { t } = this.props;
const currentUserId = Meteor.userId();
const isOwner = currentUserId && c.userId === currentUserId;
const likes = (c.likes || []).length;
const dislikes = (c.dislikes || []).length;
const liked = currentUserId && (c.likes || []).includes(currentUserId);
const disliked = currentUserId && (c.dislikes || []).includes(currentUserId);
const author = c.username || t('Anónimo');
return (
<li key={c._id} className="comment">
<div className="comment-header">
<span className="comment-author">{author}</span>
<span className="comment-date">{moment(c.createdAt).format('LLL')}</span>
</div>
{this.state.editingId === c._id ? (
<div className="comment-edit">
<textarea
className="form-control"
value={this.state.editDraft}
onChange={ev => this.setState({ editDraft: ev.target.value })}
/>
<button type="button" className="btn btn-primary btn-sm" onClick={() => this.saveEdit(c._id)}>{t('Guardar')}</button>
<button type="button" className="btn btn-link btn-sm" onClick={() => this.setState({ editingId: null })}>{t('Borrar')}</button>
</div>
) : (
// Plain text (whitespace preserved via CSS). Rendered as text — not
// HTML — so user content can never inject markup (safer than the old
// markdown-to-HTML comments package).
<p className="comment-content">{c.content}</p>
)}
<MediaEmbed media={c.media} />
<div className="comment-actions">
<button type="button" className={`btn btn-link btn-sm${liked ? ' active' : ''}`} disabled={!currentUserId} onClick={() => this.call('comments.like', c._id)}>
{'👍'} {likes}
</button>
<button type="button" className={`btn btn-link btn-sm${disliked ? ' active' : ''}`} disabled={!currentUserId} onClick={() => this.call('comments.dislike', c._id)}>
{'👎'} {dislikes}
</button>
{isOwner && (
<span className="comment-owner-actions">
<button type="button" className="btn btn-link btn-sm" onClick={() => this.setState({ editingId: c._id, editDraft: c.content })}>{t('Editar')}</button>
<button type="button" className="btn btn-link btn-sm remove-action" onClick={() => this.call('comments.remove', c._id)}>{t('Borrar')}</button>
</span>
)}
</div>
</li>
);
}
render() {
const { t, comments, ready } = this.props;
const loggedIn = !!Meteor.userId();
return (
<div className="comments-box">
<ul className="comments-list">
{comments.map(c => this.renderComment(c))}
</ul>
{ready && comments.length === 0 && (
<p className="comments-empty">{t('Añadir un comentario')}</p>
)}
{loggedIn ? (
<form className="comment-form" onSubmit={this.submit}>
<textarea
className="form-control create-comment"
placeholder={t('Añadir un comentario')}
value={this.state.draft}
onChange={ev => this.setState({ draft: ev.target.value })}
/>
<button type="submit" className="btn btn-primary">{t('Añadir comentario')}</button>
</form>
) : (
<p className="comment-login-hint">
{t('Necesitas iniciar sesión para')} {t('añadir comentarios')}
</p>
)}
</div>
);
}
}
CommentsBox.propTypes = {
t: PropTypes.func.isRequired,
referenceId: PropTypes.string.isRequired,
comments: PropTypes.arrayOf(PropTypes.object).isRequired,
ready: PropTypes.bool.isRequired
};
const CommentsBoxContainer = withTracker(({ referenceId }) => {
const handle = Meteor.subscribe('comments.forReference', referenceId);
return {
ready: handle.ready(),
comments: CommentsCollection.find({ referenceId }, { sort: { createdAt: 1 } }).fetch()
};
})(CommentsBox);
export default withTranslation()(CommentsBoxContainer);

View file

@ -1,4 +1,4 @@
@use '../../stylesheets/mixins' as *;
@import '../../stylesheets/mixins';
.Content {
max-width: 700px;

View file

@ -1,45 +0,0 @@
import React, { Component } from 'react';
import i18n from 'i18next';
import './CookieConsent.scss';
/*
* Minimal cookie-consent banner replacing selaias:cookie-consent (Blaze,
* dead on Meteor 3: its `Cookies` global is gone and it crashed the whole
* client bundle at load). Same behavior: informative banner, dismissed once.
*/
const STORAGE_KEY = 'tcef-cookie-consent';
class CookieConsent extends Component {
constructor(props) {
super(props);
let accepted = false;
try {
accepted = window.localStorage.getItem(STORAGE_KEY) === 'yes';
} catch (e) { /* storage disabled: show banner every time */ }
this.state = { accepted };
this.accept = this.accept.bind(this);
}
accept() {
try {
window.localStorage.setItem(STORAGE_KEY, 'yes');
} catch (e) { /* ignore */ }
this.setState({ accepted: true });
}
render() {
if (this.state.accepted) return null;
return (
<div className="CookieConsent">
<span>
{i18n.t('Utilizamos cookies para asegurar un mejor uso de nuestra web. Si continúas navegando, consideramos que aceptas su uso')}
</span>
<button type="button" className="btn btn-primary btn-sm" onClick={this.accept}>
{i18n.t('Aceptar')}
</button>
</div>
);
}
}
export default CookieConsent;

View file

@ -1,20 +0,0 @@
.CookieConsent {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
gap: 1em;
padding: 0.75em 1em;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 0.9em;
button {
margin-left: 1em;
white-space: nowrap;
}
}

View file

@ -2,7 +2,7 @@
/* eslint-disable import/no-absolute-path */
import React from 'react';
import PropTypes from 'prop-types';
import { Trans, withTranslation } from 'react-i18next';
import { Trans, translate } from 'react-i18next';
import Tooltip from 'rc-tooltip';
import Slider from 'rc-slider';
import 'rc-slider/assets/index.css';
@ -107,4 +107,4 @@ DistanceSlider.propTypes = {
onChange: PropTypes.func
};
export default withTranslation()(DistanceSlider);
export default translate([], { wait: true })(DistanceSlider);

View file

@ -1,4 +1,4 @@
@use '../../stylesheets/mixins' as *;
@import '../../stylesheets/mixins';
.dist-slider {
width: 400px;

View file

@ -2,7 +2,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form, Button } from 'react-bootstrap';
import { FormGroup, ControlLabel, Button } from 'react-bootstrap';
import { Meteor } from 'meteor/meteor';
import { Bert } from 'meteor/themeteorchef:bert';
import validate from '../../../modules/validate';
@ -57,8 +57,8 @@ class DocumentEditor extends React.Component {
render() {
const { doc } = this.props;
return (<form ref={form => (this.form = form)} onSubmit={event => event.preventDefault()}>
<Form.Group>
<Form.Label>Title</Form.Label>
<FormGroup>
<ControlLabel>Title</ControlLabel>
<input
type="text"
className="form-control"
@ -67,9 +67,9 @@ class DocumentEditor extends React.Component {
defaultValue={doc && doc.title}
placeholder="Oh, The Places You'll Go!"
/>
</Form.Group>
<Form.Group>
<Form.Label>Body</Form.Label>
</FormGroup>
<FormGroup>
<ControlLabel>Body</ControlLabel>
<textarea
className="form-control"
name="body"
@ -77,8 +77,8 @@ class DocumentEditor extends React.Component {
defaultValue={doc && doc.body}
placeholder="Congratulations! Today is your day. You're off to Great Places! You're off and away!"
/>
</Form.Group>
<Button type="submit" variant="success">
</FormGroup>
<Button type="submit" bsStyle="success">
{doc && doc._id ? 'Save Changes' : 'Add Document'}
</Button>
</form>);

View file

@ -5,8 +5,8 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Meteor } from 'meteor/meteor';
import { withTranslation } from 'react-i18next';
import { Form, Button } from 'react-bootstrap';
import { translate } from 'react-i18next';
import { FormGroup, Button, FormControl } from 'react-bootstrap';
import { Bert } from 'meteor/themeteorchef:bert';
import { withTracker } from 'meteor/react-meteor-data';
import { isHome } from '/imports/ui/components/Utils/location';
@ -18,7 +18,6 @@ import './Feedback.scss';
class Feedback extends Component {
constructor(props) {
super(props);
this.state = { open: false };
this.handleSubmit = this.handleSubmit.bind(this);
this.onTabClick = this.onTabClick.bind(this);
}
@ -51,9 +50,7 @@ class Feedback extends Component {
}
onTabClick() {
// Was `$('#feedback-form').toggle('slide')` — global jQuery from the BS4
// meteor package. React state instead, so nothing depends on that global.
this.setState(s => ({ open: !s.open }));
$('#feedback-form').toggle('slide');
}
handleSubmit() {
@ -79,13 +76,13 @@ class Feedback extends Component {
<div>
{ !this.props.isHome &&
<div id="feedback">
<div id="feedback-form" ref={formdiv => (this.formdiv = formdiv)} style={{ display: this.state.open ? 'block' : 'none' }} className="card">
<div id="feedback-form" ref={formdiv => (this.formdiv = formdiv)} style={{ display: 'none' }} className="card">
<form
ref={form => (this.form = form)}
className="form card-body"
onSubmit={event => event.preventDefault()}
>
<Form.Group controlId="formEmail">
<FormGroup controlId="formEmail">
<input
id={testId('emailInput')}
onChange={this.handleChange}
@ -98,8 +95,8 @@ class Feedback extends Component {
defaultValue={disabled ? this.props.emailAddress : ''}
type="email"
/>
</Form.Group>
<Form.Group
</FormGroup>
<FormGroup
controlId="formFeedback"
>
<textarea
@ -110,9 +107,9 @@ class Feedback extends Component {
placeholder={t('Por favor, escribe aquí tu feedback...')}
rows="6"
/>
<Form.Control.Feedback />
</Form.Group>
<Button type="submit" variant="success" className="float-end" id={testId('sendFeedbackBtn')} >
<FormControl.Feedback />
</FormGroup>
<Button type="submit" bsStyle="success" className="float-right" id={testId('sendFeedbackBtn')} >
{t('Enviar')}
</Button>
</form>
@ -137,7 +134,7 @@ Feedback.propTypes = {
isHome: PropTypes.bool.isRequired
};
export default withTranslation()(withTracker(props => ({
export default translate()(withTracker(props => ({
emailAddress: props.emailAddress,
emailVerified: props.emailVerified,
isHome: isHome()

View file

@ -1,5 +1,5 @@
/* From: https://github.com/spektom/bootstrap-feedback-form */
@use '../../stylesheets/mixins' as *;
@import '../../stylesheets/mixins';
#feedback {
position: fixed;

View file

@ -3,8 +3,8 @@
import React from 'react';
import { year } from '@cleverbeagle/dates';
import { Link } from 'react-router-dom';
import { Container } from 'react-bootstrap';
import { withTranslation } from 'react-i18next';
import { Grid } from 'react-bootstrap';
import { translate } from 'react-i18next';
import { testId } from '/imports/ui/components/Utils/TestUtils';
import './Footer.scss';
@ -19,10 +19,10 @@ const Footer = (props) => {
const { t } = props;
return (
<div className="Footer">
<Container>
<Grid>
<p className="pull-left"><span className="reverse">&copy;</span><span className="d-none d-md-inline"> Copyleft</span> {copyrightYear()} <a href="https://comunes.org/"><span className="d-none d-md-inline">{t('OrgName')}</span><span className="d-inline d-md-none">{t('OrgName')}</span></a></p>
<ul className="float-end">
<ul className="pull-right">
<li>
<Link id={testId('about')} to="/about">
<span className="d-none d-lg-inline">{t('Sobre nosotr@s')}</span>
@ -44,11 +44,11 @@ const Footer = (props) => {
<li><span className="d-none d-md-inline"><Link id={testId('license')} to="/license">{t('Licencia')}</Link></span></li>
<li><span className="d-none d-md-inline"><Link id={testId('credits')} to="/credits">{t('Créditos')}</Link></span></li>
</ul>
</Container>
</Grid>
</div>
);
};
Footer.propTypes = {};
export default withTranslation()(Footer);
export default translate([], { wait: true })(Footer);

View file

@ -1,5 +1,5 @@
@use '../../stylesheets/mixins' as *;
@use '../../stylesheets/colors' as *;
@import '../../stylesheets/mixins';
@import '../../stylesheets/colors';
html {
position: relative;

View file

@ -5,7 +5,7 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withTracker } from 'meteor/react-meteor-data';
import Chronos from '/imports/ui/components/Chronos/Chronos';
import { withTranslation } from 'react-i18next';
import { translate } from 'react-i18next';
import { dateLongFormat } from '/imports/api/Common/dates';
import './FromNow.scss';
@ -18,9 +18,13 @@ class FromNow extends Component {
};
}
// state.when just mirrors props.when (was UNSAFE_componentWillReceiveProps)
static getDerivedStateFromProps(props, state) {
return props.when !== state.when ? { when: props.when } : null;
componentWillReceiveProps(nextProps) {
if (this.props.when !== nextProps.when) {
// console.log(`Next when ${nextProps.when}`);
this.setState({
when: nextProps.when
});
}
}
shouldComponentUpdate(nextProps, nextState) {
@ -32,8 +36,9 @@ class FromNow extends Component {
}
render() {
console.log(`Render from now ${this.state.when}`);
return (
<span data-bs-toggle="tooltip" className="from-now" title={this.thatIs()}>{this.state.when}</span>
<span data-toggle="tooltip" className="from-now" title={this.thatIs()}>{this.state.when}</span>
);
}
}
@ -44,6 +49,9 @@ FromNow.propTypes = {
whenDate: PropTypes.instanceOf(Date)
};
FromNow.defaultProps = {
};
/*
* const FromNowContainer = withTracker((props) => {
*
@ -52,7 +60,7 @@ FromNow.propTypes = {
* export default FromNowContainer;
* */
export default withTranslation()(withTracker((props) => {
export default translate([], { wait: true })(withTracker((props) => {
const whenDate = props.when;
return {
when: whenDate ? Chronos.moment(whenDate).fromNow() : null,

View file

@ -1,8 +1,5 @@
import { createBrowserHistory } from 'history';
import createHistory from 'history/createBrowserHistory';
// Shared history singleton. It backs react-router's <HistoryRouter> (App.js)
// AND is used outside the React tree (NotificationsObserver push, Utils/location
// listen), so it must be the same instance the router navigates.
const history = createBrowserHistory();
const history = createHistory();
export default history;

View file

@ -1,4 +1,4 @@
@use '../../stylesheets/colors' as *;
@import '../../stylesheets/colors';
.InputHint {
display: block;

View file

@ -1,46 +1,25 @@
/* eslint-disable react/jsx-indent */
import React, { useRef, useEffect } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { Meteor } from 'meteor/meteor';
import ProgressBar from 'progressbar.js';
import { Line } from 'react-progress-bar.js';
import './LoadingBar.scss';
// Uses progressbar.js directly through a ref, replacing the react-progress-bar.js
// wrapper (which relied on the deprecated findDOMNode).
const LoadingBar = ({ progress }) => {
const elRef = useRef(null);
const barRef = useRef(null);
// Check: https://github.com/kimmobrunfeldt/react-progressbar.js/pull/26
const LoadingBar = ({ progress }) => (
<div className="loading-bar">
<Line
progress={Meteor.status().status !== 'connected' ? Meteor.status().retryCount / 10 : progress}
options={{ strokeWidth: 2, color: '#5A7636' }}
initialAnimate
/>
</div>
);
const status = Meteor.status();
const target = status.status !== 'connected' ? status.retryCount / 10 : progress;
const clamped = Math.max(0, Math.min(1, target));
useEffect(() => {
barRef.current = new ProgressBar.Line(elRef.current, {
strokeWidth: 2,
color: '#5A7636'
});
return () => {
if (barRef.current) barRef.current.destroy();
barRef.current = null;
};
}, []);
useEffect(() => {
if (barRef.current) barRef.current.animate(clamped);
}, [clamped]);
return (
<div className="loading-bar">
<div ref={elRef} />
</div>
);
};
export default LoadingBar;
LoadingBar.propTypes = {
progress: PropTypes.number.isRequired
};
export default LoadingBar;

View file

@ -1,9 +1,9 @@
/* eslint-disable react/jsx-indent-props */
import React from 'react';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import { translate } from 'react-i18next';
import PlacesAutocomplete, { geocodeByAddress, getLatLng } from 'react-places-autocomplete';
import { Form } from 'react-bootstrap';
import { FormGroup, ControlLabel, HelpBlock } from 'react-bootstrap';
import { Bert } from 'meteor/themeteorchef:bert';
@ -82,13 +82,13 @@ class LocationAutocomplete extends React.Component {
return (
<form>
<Form.Group>
{ label.length > 0 && <Form.Label>{t(label)}</Form.Label> }
<FormGroup>
{ label.length > 0 && <ControlLabel>{t(label)}</ControlLabel> }
<PlacesAutocomplete
styles={myStyles}
autocompleteItem={AutocompleteItem}
classNames={{
root: 'mb-3', // was BS4 `.form-group` (removed in BS5); mb-3 keeps the bottom margin
root: 'form-group',
input: 'form-control',
autocompleteContainer: 'autocomplete-container'
}}
@ -114,8 +114,8 @@ class LocationAutocomplete extends React.Component {
autoFocus: this.props.focusInput
}}
/>
{ helpText.length > 0 && <Form.Text>{t(helpText)}</Form.Text> }
</Form.Group>
{ helpText.length > 0 && <HelpBlock>{t(helpText)}</HelpBlock> }
</FormGroup>
</form>
);
}
@ -130,4 +130,4 @@ LocationAutocomplete.propTypes = {
i18n: PropTypes.object.isRequired
};
export default withTranslation()(LocationAutocomplete);
export default translate([], { wait: true })(LocationAutocomplete);

View file

@ -4,8 +4,8 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import GoogleMutantLayer from '/imports/ui/components/Maps/GoogleMutantLayer';
import { translate } from 'react-i18next';
import { GoogleLayer } from 'react-leaflet-google/lib/';
import Gkeys from '/imports/startup/client/Gkeys';
import { TileLayer, LayersControl } from 'react-leaflet';
@ -54,15 +54,15 @@ class DefMapLayers extends Component {
{/* React.Fragment does not work here */}
{ this.state.gkey &&
<BaseLayer name={t('Mapa de carreteras de Google')}>
<GoogleMutantLayer opacity={defOpacity} maptype="ROADMAP" />
<GoogleLayer opacity={defOpacity} googlekey={this.state.gkey} maptype="ROADMAP" />
</BaseLayer> }
{ this.state.gkey &&
<BaseLayer name={t('Mapa de terreno de Google')} checked={this.props.terrain}>
<GoogleMutantLayer opacity={defOpacity} maptype="TERRAIN" />
<GoogleLayer opacity={defOpacity} googlekey={this.state.gkey} maptype="TERRAIN" />
</BaseLayer> }
{ this.state.gkey &&
<BaseLayer name={t('Mapa de satélite de Google')} checked={this.props.satellite}>
<GoogleMutantLayer opacity={defOpacity} maptype="SATELLITE" />
<GoogleLayer opacity={defOpacity} googlekey={this.state.gkey} maptype="SATELLITE" />
</BaseLayer> }
</LayersControl>
);
@ -85,4 +85,4 @@ DefMapLayers.defaultProps = {
satellite: false
};
export default withTranslation()(DefMapLayers);
export default translate([], { wait: true })(DefMapLayers);

View file

@ -31,7 +31,7 @@ class FireCircleMark extends Component {
} = this.props;
const rect = rectangleAround({ lat, lon }, track, track);
return (
<GeoJSON data={rect} style={{ color: 'red', stroke: true, weight: 1, opacity: 0.4, fillOpacity: 1 }}>
<GeoJSON data={rect} color="red" stroke width="1" opacity=".4" fillOpacity="1">
<FirePopup t={t} history={history} id={id} nasa={nasa} lat={lat} lon={lon} when={when} />
</GeoJSON>
);

View file

@ -3,7 +3,7 @@ import React, { Component, Fragment } from 'react';
import { CircleMarker, Marker, Tooltip } from 'react-leaflet';
import PropTypes from 'prop-types';
import { fireIconS, fireIconM, fireIconL, nFireIcon, industryIcon, regIndustryIcon } from '/imports/ui/components/Maps/Icons';
import { withTranslation } from 'react-i18next';
import { translate } from 'react-i18next';
import { onMarkClick } from './MarkListeners';
import FirePopup from './FirePopup';
@ -33,9 +33,9 @@ class FireIconMark extends Component {
lat, lon, scan, track, nasa, id, history, falsePositives, industries, neighbour, when, t
} = this.props;
return (
<Fragment>
<div>
{ !falsePositives && !industries &&
<Marker position={[lat, lon]} icon={nasa ? this.getIcon(scan) : nFireIcon} eventHandlers={{ click: this.onClick }} >
<Marker position={[lat, lon]} icon={nasa ? this.getIcon(scan) : nFireIcon} onClick={this.onClick} >
<FirePopup t={t} history={history} id={id} nasa={nasa} lat={lat} lon={lon} when={when} />
</Marker> }
{ industries && <Marker position={[lat, lon]} icon={regIndustryIcon}>
@ -43,20 +43,23 @@ class FireIconMark extends Component {
</Marker> }
{ /* disabled */ industries && false && <CircleMarker
center={[lat, lon]}
color="violet"
stroke={false}
fillOpacity="1"
fill
radius={1}
pathOptions={{ color: 'violet', stroke: false, fillOpacity: 1, fill: true }}
/>}
{ falsePositives && !industries &&
<Marker position={[lat, lon]} icon={industryIcon} eventHandlers={{ click: this.onClick }}>
<Marker position={[lat, lon]} icon={industryIcon} onClick={this.onClick}>
<Tooltip><Fragment>{t('Es una industria (fuente: nuestros usuarios/as)')}</Fragment></Tooltip>
{ /* disabled because was a past fire (and can be marked multiple times) */ false && <FirePopup t={t} history={history} id={id} lat={lat} lon={lon} /> }
</Marker>
}
{ !falsePositives && !industries &&
<CircleMarker center={[lat, lon]} radius={1} pathOptions={{ color: nasa ? 'red' : '#D35400', stroke: false, fillOpacity: 1, fill: true }} eventHandlers={{ click: this.onClick }}>
<CircleMarker center={[lat, lon]} color={nasa ? 'red' : '#D35400'} stroke={false} fillOpacity="1" fill radius={1} onClick={this.onClick}>
<FirePopup t={t} history={history} id={id} nasa={nasa} lat={lat} lon={lon} when={when} />
</CircleMarker> }
</Fragment>
</div>
);
}
}
@ -77,4 +80,4 @@ FireIconMark.propTypes = {
t: PropTypes.func.isRequired
};
export default withTranslation()(FireIconMark);
export default translate([], { wait: true })(FireIconMark);

View file

@ -3,7 +3,7 @@
import React from 'react';
import { CircleMarker } from 'react-leaflet';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import { translate } from 'react-i18next';
import FirePopup from './FirePopup';
/* Less acurate (1 pixel per fire) but faster */
@ -12,8 +12,11 @@ const FirePixel = ({
}) => (
<CircleMarker
center={[lat, lon]}
color={nasa ? 'red' : '#D35400'}
stroke={false}
fillOpacity="1"
fill
radius={nasa ? 1 : 2}
pathOptions={{ color: nasa ? 'red' : '#D35400', stroke: false, fillOpacity: 1, fill: true }}
>
<FirePopup t={t} history={history} id={id} nasa={nasa} lat={lat} lon={lon} when={when} />
</CircleMarker>
@ -31,4 +34,4 @@ FirePixel.propTypes = {
t: PropTypes.func.isRequired
};
export default withTranslation()(FirePixel);
export default translate([], { wait: true })(FirePixel);

View file

@ -29,7 +29,7 @@ class FirePolygonMark extends Component {
const lon = centerid.coordinates[0];
const lat = centerid.coordinates[1];
return (
<GeoJSON data={shape} style={{ color: 'orange', stroke: true, weight: 1, opacity: 0.2, fillOpacity: 0 }} />
<GeoJSON data={shape} color="orange" stroke width="1" opacity=".2" fillOpacity=".0" />
);
/* <FirePopup t={t} history={history} id={id} nasa={nasa} lat={lat} lon={lon} when={when} /> */
}

View file

@ -2,7 +2,7 @@
import React, { Fragment } from 'react';
import { Tooltip } from 'react-leaflet';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import { translate } from 'react-i18next';
const FirePopup = ({
lat,
@ -29,4 +29,4 @@ FirePopup.propTypes = {
t: PropTypes.func.isRequired
};
export default withTranslation()(FirePopup);
export default translate([], { wait: true })(FirePopup);

View file

@ -1,30 +1,28 @@
/* eslint-disable react/jsx-indent-props */
/* eslint-disable import/no-absolute-path */
/* eslint-disable import/no-absolute-path */
import React from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import { createControlComponent } from '@react-leaflet/core';
import L from 'leaflet';
import 'leaflet.fullscreen';
import 'leaflet.fullscreen/Control.FullScreen.css';
import { translate } from 'react-i18next';
import FullscreenControl from 'react-leaflet-fullscreen';
import 'react-leaflet-fullscreen/dist/styles.css';
// Replacement for react-leaflet-fullscreen (v1-only): wrap the vanilla
// leaflet.fullscreen control with react-leaflet v4's control factory.
const FullscreenControl = createControlComponent(
({ position = 'topleft', title, titleCancel }) => L.control.fullscreen({ position, title, titleCancel })
);
const FullScreenMap = ({ t }) => (
<FullscreenControl
position="topleft"
title={t('Pantalla completa')}
titleCancel={t('Salir de pantalla completa')}
/>
);
class FullScreenMap extends Component {
render() {
const { t } = this.props;
return (
<FullscreenControl
position="topleft"
title={t('Pantalla completa')}
titleCancel={t('Salir de pantalla completa')}
/>
);
}
}
FullScreenMap.propTypes = {
t: PropTypes.func.isRequired
};
export default withTranslation()(FullScreenMap);
export default translate([], { wait: true })(FullScreenMap);

View file

@ -1,25 +0,0 @@
import { createLayerComponent } from '@react-leaflet/core';
import L from 'leaflet';
import 'leaflet.gridlayer.googlemutant';
// Replacement for react-leaflet-google's GoogleLayer (v1-only). The Google Maps
// JS API is already loaded (with the places library) by Gkeys before any Google
// BaseLayer renders, so googlemutant can use window.google.maps directly.
function createGoogleMutant({
maptype = 'roadmap', opacity, googlekey, ...options
}, ctx) {
const instance = L.gridLayer.googleMutant({
type: String(maptype).toLowerCase(),
...(opacity != null ? { opacity } : {}),
...options
});
return { instance, context: { ...ctx } };
}
function updateGoogleMutant(instance, props, prevProps) {
if (props.opacity != null && props.opacity !== prevProps.opacity) {
instance.setOpacity(props.opacity);
}
}
export default createLayerComponent(createGoogleMutant, updateGoogleMutant);

View file

@ -1,42 +0,0 @@
import { useEffect } from 'react';
import { useMap, useMapEvents } from 'react-leaflet';
// v4 replaces the v1 `ref`/`.leafletElement` + handleLeafletLoad pattern: this
// child runs inside <MapContainer> and hands the ready Leaflet map to a class
// parent once, so the parent can drive it imperatively (setView, fitBounds,
// graphicScale, subsUnion, …).
export const MapReady = ({ onReady }) => {
const map = useMap();
useEffect(() => {
if (!onReady) return undefined;
let cancelled = false;
let delivered = false;
// El efecto de montaje puede correr antes de que el contenedor tenga tamaño.
// Si entregamos el mapa entonces, un `getBounds()` del padre lanza y se queda
// sin bounds: en /fires eso dejaba la suscripcion por viewport sin crear y el
// mapa vacio (0 capas) pese a haber 7470 fuegos. Esperamos a que el mapa este
// listo Y tenga tamaño real; entregamos una sola vez.
const deliver = () => {
if (cancelled || delivered) return;
map.invalidateSize();
const size = map.getSize();
if (!size || size.x === 0 || size.y === 0) return;
delivered = true;
onReady(map);
};
map.whenReady(deliver);
map.on('resize', deliver);
return () => {
cancelled = true;
map.off('resize', deliver);
};
}, [map]); // eslint-disable-line react-hooks/exhaustive-deps
return null;
};
// Bridges Leaflet map events to callbacks (replaces the v1 onMoveend /
// onViewportChanged / onZoomend props on <Map>).
export const MapEvents = ({ handlers }) => {
useMapEvents(handlers || {});
return null;
};

View file

@ -1,32 +0,0 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
// Replacement for react-leaflet-control (v1-only): mounts a Leaflet control at
// the given corner and portals arbitrary React children into it. Click/scroll
// on the control no longer pans/zooms the map underneath.
const MapControl = ({ position = 'topright', children }) => {
const map = useMap();
const [container, setContainer] = useState(null);
useEffect(() => {
const ReactControl = L.Control.extend({
onAdd: () => {
const div = L.DomUtil.create('div', 'leaflet-control leaflet-control-react');
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
setContainer(div);
return div;
},
onRemove: () => setContainer(null)
});
const control = new ReactControl({ position });
control.addTo(map);
return () => control.remove();
}, [map, position]);
return container ? createPortal(children, container) : null;
};
export default MapControl;

View file

@ -1,7 +1,4 @@
import { hexId } from '/imports/api/Common/id';
export const onMarkClick = (history, nasa, id) => {
// console.log('onClick fired');
// hexId: `id` is a Mongo.ObjectID whose toString() is `ObjectID("...")`.
history.push(`/fire/${nasa ? 'active' : 'alert'}/${hexId(id)}`);
history.push(`/fire/${nasa ? 'active' : 'alert'}/${id}`);
};

View file

@ -12,8 +12,7 @@ const subsUnion = (union, options) => {
const interactive = options.interactive || false;
if (options.subs) {
// v4: options.map is the Leaflet map instance directly (no .leafletElement)
const lmap = options.map;
const lmap = options.map.leafletElement;
if (union) {
lmap.removeLayer(union);
}
@ -31,7 +30,7 @@ const subsUnion = (union, options) => {
if (options.fit && options.bounds) {
// console.log(options.bounds);
const bounds = JSON.parse(options.bounds);
lmap.fitBounds(L.latLngBounds(bounds._northEast, bounds._southWest));
options.map.leafletElement.fitBounds(L.latLngBounds(bounds._northEast, bounds._southWest));
}
} else if (options.subs.length > 0) {
const result = calcUnion(L, options.subs, sub => sub, true);
@ -44,7 +43,7 @@ const subsUnion = (union, options) => {
});
union.addTo(lmap);
if (options.fit) {
lmap.fitBounds(bounds);
options.map.leafletElement.fitBounds(bounds);
}
}
}

View file

@ -2,11 +2,9 @@ import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
// Self-contained NavItem. The old one wrapped react-bootstrap 0.31's
// `SafeAnchor` + `createChainedFunction` (both gone in v2); the markup this
// navbar needs is just an <li><a>. The mobile menu is now collapsed from React
// state in Navigation (the parent <ul onClick>), so the old jQuery
// `data-toggle="collapse"` / `data-target` hooks were dropped here.
import { SafeAnchor } from 'react-bootstrap';
import createChainedFunction from 'react-bootstrap/lib/utils/createChainedFunction';
const propTypes = {
active: PropTypes.bool,
disabled: PropTypes.bool,
@ -14,11 +12,7 @@ const propTypes = {
href: PropTypes.string,
onClick: PropTypes.func,
onSelect: PropTypes.func,
eventKey: PropTypes.any,
className: PropTypes.string,
anchorClassName: PropTypes.string,
style: PropTypes.object,
children: PropTypes.node
eventKey: PropTypes.any
};
const defaultProps = {
@ -27,49 +21,56 @@ const defaultProps = {
};
class NavItem extends React.Component {
constructor(props) {
super(props);
constructor(props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
handleClick(e) {
const { disabled, onClick, onSelect, eventKey } = this.props;
if (disabled) {
if (this.props.onSelect) {
e.preventDefault();
return;
}
if (onClick) onClick(e);
if (onSelect) {
e.preventDefault();
onSelect(eventKey, e);
if (!this.props.disabled) {
this.props.onSelect(this.props.eventKey, e);
}
}
}
render() {
const {
active, disabled, role, href, onClick, onSelect, eventKey,
className, anchorClassName, style, children, ...props
} = this.props;
active, disabled, onClick, className, anchorClassName, style, ...props
} =
this.props;
const anchorProps = { ...props };
if (href) anchorProps.href = href;
anchorProps.role = role || (href === '#' ? 'button' : undefined);
if (role === 'tab') anchorProps['aria-selected'] = active;
delete props.onSelect;
delete props.eventKey;
// These are injected down by `<Nav>` for building `<SubNav>`s.
delete props.activeKey;
delete props.activeHref;
if (!props.role) {
if (props.href === '#') {
props.role = 'button';
}
} else if (props.role === 'tab') {
props['aria-selected'] = active;
}
return (
<li
role="presentation"
data-toggle="collapse"
data-target=".navbar-collapse.show"
className={classNames(className, { active, disabled })}
style={style}
>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
{...anchorProps}
<SafeAnchor
{...props}
disabled={disabled}
className={anchorClassName}
onClick={this.handleClick}
>
{children}
</a>
onClick={createChainedFunction(onClick, this.handleClick)}
/>
</li>
);
}

View file

@ -2,9 +2,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Navbar } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { LinkContainer } from 'react-router-bootstrap';
import { Trans, withTranslation } from 'react-i18next';
import { Trans, translate } from 'react-i18next';
import { testId } from '/imports/ui/components/Utils/TestUtils';
/* import BetaRibbon from '../../components/BetaRibbon/BetaRibbon'; */
import PublicNavigation from '../PublicNavigation/PublicNavigation';
@ -15,50 +16,54 @@ import './Navigation.scss';
// removed class: fixed-top
// md instead of lg: no menu in medium
//
// Bootstrap 4→5 prep: the collapse used to be driven by Bootstrap's jQuery JS
// (`data-toggle="collapse"`), which goes away when we drop `alexwine:bootstrap-4`
// for `bootstrap@5`. Toggling the `.show` class from React state removes that
// dependency and keeps working on the current BS4 CSS (`.collapse.show` is pure
// CSS; jQuery only added the height animation). Clicking a nav link collapses the
// mobile menu again — the old per-NavItem `data-target=".navbar-collapse.show"`.
const Navigation = ({ name = '', ...props }) => {
const [open, setOpen] = React.useState(false);
const close = () => setOpen(false);
return (
<nav className="navbar navbar-expand-lg navbar-dark bg-dark">
<div style={{ overflow: 'hidden' } /* for ribbon */} className="container">
{/* <BetaRibbon /> */}
<Link to="/" className={`navbar-brand ${window.location.pathname === '/' ? 'hide-brand' : ''}`} >
{props.t('AppNameFull')}
</Link>
<button className="navbar-toggler" type="button" onClick={() => setOpen(o => !o)} aria-controls="navbarNavDropdown" aria-expanded={open} aria-label="Toggle navigation">
const Navigation = props => (
<nav className="navbar navbar-expand-lg navbar-dark bg-dark">
<div style={{ overflow: 'hidden' } /* for ribbon */} className="container">
{/* <BetaRibbon /> */}
{/* <Navbar bsClass="navbar navbar-dark bg-dark"> */}
{/* https://github.com/react-bootstrap/react-bootstrap/blob/master/src/Navbar.js */}
<Navbar.Header>
<Navbar.Brand>
<Link to="/" className={window.location.pathname === '/' ? 'hide-brand' : ''} >
{props.t('AppNameFull')}
</Link>
</Navbar.Brand>
{/* <Navbar.Toggle/> */}
<button className="sr-only navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span className="navbar-toggler-icon" />
</button>
<div className={`collapse navbar-collapse${open ? ' show' : ''}`} id="navbarNavDropdown">
{/* onClick closes the mobile menu when a main nav link is tapped */}
<ul className="navbar-nav ms-auto " onClick={close}>
{/* <LinkContainer className="nav-item" anchorClassName="nav-link" to="/sandbox">
<NavItem eventKey={1.1} href="/sandbox">Sandbox</NavItem>
</LinkContainer> */}
<LinkContainer id={testId('subscriptions')} className="nav-item" anchorClassName="nav-link" to="/subscriptions">
<NavItem eventKey={1.2} href="/subscriptions">
{props.authenticated ? <Trans>Mis zonas</Trans> : <Trans>Participar</Trans>}
</NavItem>
</LinkContainer>
<LinkContainer id={testId('moniZones')} className="nav-item" anchorClassName="nav-link" to="/zones">
<NavItem eventKey={2} href="/zones">{props.t('Zonas vigiladas')}</NavItem>
</LinkContainer>
<LinkContainer id={testId('activeFires')} className="nav-item" anchorClassName="nav-link" to="/fires">
<NavItem eventKey={2.1} href="/fires">{props.t('activeFires')}</NavItem>
</LinkContainer>
</Navbar.Header>
<button className="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span className="navbar-toggler-icon" />
</button>
{/* <Navbar.Collapse> */}
<div className="collapse navbar-collapse" id="navbarNavDropdown">
<ul className="navbar-nav ml-auto ">
{/* <LinkContainer className="nav-item" anchorClassName="nav-link" to="/sandbox">
<NavItem eventKey={1.1} href="/sandbox">Sandbox</NavItem>
</LinkContainer> */}
<LinkContainer id={testId('subscriptions')} className="nav-item" anchorClassName="nav-link" to="/subscriptions">
<NavItem eventKey={1.2} href="/subscriptions">
{props.authenticated ? <Trans>Mis zonas</Trans> : <Trans>Participar</Trans>}
</NavItem>
</LinkContainer>
<LinkContainer id={testId('moniZones')} className="nav-item" anchorClassName="nav-link" to="/zones">
<NavItem eventKey={2} href="/zones">{props.t('Zonas vigiladas')}</NavItem>
</LinkContainer>
<LinkContainer id={testId('activeFires')} className="nav-item" anchorClassName="nav-link" to="/fires">
<NavItem eventKey={2.1} href="/fires">{props.t('activeFires')}</NavItem>
</LinkContainer>
</ul>
{!props.authenticated ? <PublicNavigation /> : <AuthenticatedNavigation name={name} {...props} />}
</div>
</ul>
{!props.authenticated ? <PublicNavigation /> : <AuthenticatedNavigation {...props} />}
{/* </Navbar.Collapse> */}
</div>
</nav>
);
</div>
</nav>
);
Navigation.defaultProps = {
name: ''
};
Navigation.propTypes = {
@ -66,4 +71,4 @@ Navigation.propTypes = {
authenticated: PropTypes.bool.isRequired
};
export default withTranslation()(Navigation);
export default translate([], { wait: true })(Navigation);

View file

@ -1,4 +1,4 @@
@use '../../stylesheets/mixins' as *;
@import '../../stylesheets/mixins';
.navbar {
border-radius: 0;

Some files were not shown because too many files have changed in this diff Show more