Compare commits

..

61 commits

Author SHA1 Message Date
23e5f26239 staging: seguir la etiqueta de rama que publica el CI (meteor3-wip)
All checks were successful
build-image / build (push) Successful in 11m34s
El workflow etiqueta con GITHUB_REF_NAME, o sea `meteor3-wip`, pero el compose apuntaba a
`meteor3` (la etiqueta de la subida manual). Se alinean para que un build del CI se
despliegue con un `pull` sin tocar nada.
2026-07-28 15:12:01 +02:00
f3ea93fc6f ci: bajar el codigo con wget+tar; la imagen de kaniko no trae git
All checks were successful
build-image / build (push) Successful in 11m21s
Primera pasada con secretos: el guard paso en verde y reventó el checkout con
"/var/run/act/workflow/1.sh: line 4: git: not found". La imagen
kaniko-project/executor:debug solo trae busybox y los binarios de kaniko: ni git ni apk
para instalarlo (por eso tampoco valen las actions basadas en Node).

Se baja el tarball del commit por el API de Forgejo (wget + tar de busybox) a ./src y el
contexto de kaniko pasa a apuntar ahi.
2026-07-28 14:55:33 +02:00
6aa057f739 staging: la web se descarga del registry, no se compila en groucho
Some checks failed
build-image / build (push) Failing after 8s
Cloudflare ya no esta delante de git.comunes.org, asi que el registry de Forgejo acepta
la capa de 508 MB del bundle (comprobado: subida completa desde groucho, la imagen esta
en comunes/-/packages/container/tcef-web con los tags meteor3 y a24c7a567c).

- compose: `build:` -> `image: git.comunes.org/comunes/tcef-web:${TCEF_WEB_TAG:-meteor3}`.
  groucho pasa de 30 min de `meteor build` (que lo dejaron sin ssh) a un pull de segundos.
- workflow: se activa el disparo por push, y se anade una comprobacion de secretos como
  primer paso. Sin ella, faltar REGISTRY_TOKEN se descubria a los ~30 min, al intentar
  el push; ahora falla en 2 segundos diciendo exactamente que crear y donde.
2026-07-28 13:50:23 +02:00
72975e44ae ci: construir la imagen de la web en el runner, no en el host de despliegue
groucho (5,9 GB, compartidos con Mongo 7 + web + notifications + redis + node-red) se
quedo sin ssh compilando un `meteor build` y hubo que reiniciarlo. El host de despliegue
no debe compilar: debe recibir una imagen.

Workflow para el runner de Forgejo en aaron. Dos decisiones que conviene no deshacer sin
leer fase-9-ci-imagenes.md:

- Kaniko en vez de `docker build`: aaron es host COMPARTIDO (git.comunes.org, Jenkins,
  GlitchTip). `docker build` obligaria a montar /var/run/docker.sock en el job (root
  equivalente sobre aaron) y ademas el limite de memoria del job no serviria de nada,
  porque quien construye es el demonio, fuera del contenedor. Kaniko construye DENTRO
  del job: sin socket y con `--memory` que si acota al que come la RAM.
- `--memory=4g --memory-swap=4g`: swap a cero adrede. Preferimos que muera el build a
  que aaron pagine y se lleve la forja por delante.

De momento solo `workflow_dispatch`. El disparo por push queda comentado hasta que
git.comunes.org salga de detras de Cloudflare (la capa del bundle son 508 MB contra un
tope de 100 MB del plan gratuito: el push se queda en Retrying eterno) y existan los
secretos REGISTRY_USER/REGISTRY_TOKEN.
2026-07-28 12:28:29 +02:00
a24c7a567c fix(fires): "En rojo, 0 fuegos activos" con el mapa lleno de fuegos
La leyenda de /fires decidia por `activefires` pero contaba `activefiresunion`:

  (activefires.length + firealerts.length) === 0 ? "No hay fuegos..." :
     "En rojo, {activefiresunion.length + firealerts.length} fuegos activos"

`activefiresunion` no la escribe nadie. La union de fuegos es un WIP de
fires-csv-mongo-import que nunca llego a produccion (usa thelpers/tsimplify/tbuffer
sin require), y de hecho el <FireListUnion> que la dibujaria solo se renderiza bajo
`Meteor.isDevelopment`. Verificado en la base de datos de PRODUCCION: activefiresunion = 0
documentos, con 3608 en activefires. O sea que el contador lleva anos diciendo 0.

Ahora cuenta `activefires`, que es justo lo que pinta en rojo el FireList `nasa`.

No es una regresion de la modernizacion: produccion tiene el mismo fallo. Si algun dia se
reimplementa la union de fuegos habra que revisar este contador (y sacar FireListUnion de
isDevelopment).
2026-07-28 09:01:05 +02:00
03082a8d55 staging: conecta el importador NASA reimplementado al stack
El servicio `importer` apuntaba a fires-csv-mongo-import/ (el de 2018, sin Dockerfile) y
vivia tras el profile "importer", a la espera de un systemd timer que nunca se creo. Ahora
apunta a todos-contra-el-fuego/nasa-importer/, arranca con el stack y hace su ciclo cada
15 minutos por si mismo.

El JWT de NASA entra por secrets/importer.staging.env (gitignorado); se anade el .example.
Volumen importer-data para los CSV descargados: sin el, cada ciclo se creeria que NASA ha
publicado datos nuevos y reimportaria siempre.
2026-07-28 07:49:05 +02:00
e5f7a0f4be fix(maps): entregar el mapa solo cuando tiene tamano real (MapReady)
MapReady entregaba el mapa en el useEffect de montaje, cuando el contenedor
puede no tener tamano todavia. En FiresMap eso hacia que getBounds() lanzara;
el catch solo avisaba ("Failed to set map bounds and scale") y mapSize nunca
se seteaba, con lo que la suscripcion de fuegos por viewport NI SE CREABA:
loading eterno y tile-pane vacio (0 capas) pese a haber 7470 fuegos.

Evidencia: en Meteor.connection._subscriptions solo aparecian activefirestotal,
activefiresuniontotal, settings y userData — ninguna por localizacion.

Ahora se entrega via map.whenReady() + invalidateSize(), solo cuando getSize()
no es 0, con reintento en el evento resize y entrega unica (flag delivered).

Toca el componente compartido por todos los mapas: al desplegar hay que
re-verificar /fires, /zones, /subscriptions, home y detalle de fuego.
SIN DESPLEGAR todavia (la imagen viva es la de 01:33, sin este cambio).
2026-07-28 07:46:19 +02:00
c50690cc8a fix(maps): evitar carrera MapReady/DDP que dejaba el mapa en gris
En react-leaflet v4 la capa union no se dibujaba (mundo gris a zoom 0) en
/zones, /subscriptions y el bloque Participa cuando los datos DDP llegaban
despues de montar el mapa. Se anade reintento en componentDidUpdate y se
endurece la guarda, con flag de instancia para el ajuste de bounds.
2026-07-28 00:35:11 +02:00
07993ffb91 staging: neutralizar credenciales OAuth vivas, nombres reales y tokens de sesion
La primera version solo invalidaba emails, fireBaseToken, campos telegram* y
services.google.email. Auditando el staging aparecieron, sin neutralizar:
- 42 users con services.google.accessToken/refreshToken/idToken -> credenciales
  OAuth VIVAS que dan acceso a la cuenta de Google REAL del usuario
- 42 users con services.google.name/given_name/family_name/picture
- 99 users con profile.name.first/last (nombre y apellidos reales)
- tokens de sesion (services.resume.loginTokens) y verificationTokens

Pasaron desapercibidos porque el bloque de verificacion tampoco los comprobaba.
Se anaden los $unset y se amplia la verificacion para que falle si reaparecen.

services.password.bcrypt se CONSERVA a proposito (hashes, no reversibles
directamente, y son la unica forma de hacer QA de login: el volcado real no
trae la cuenta de fixtures). Documentado en el propio script.
2026-07-28 00:35:11 +02:00
2e298f9ee5 staging: artefactos de despliegue dockerizado + neutralizacion de datos
- .meteorignore excluye scripts/ del bundle Meteor (mongosh usa global db); docker-compose.staging.yml (mongo7/redis/notif-shadow/mailhog); scripts/neutralize-contacts.mongo.js (contactos + cola de correo); secrets/*.staging.*.example
2026-07-23 10:46:09 +02:00
1ef0012af9 bootstrap: BS5 sweep follow-ups (form-group, drop popper.js@1)
Post-swap hardening after a full BS4-removed-class sweep of the codebase:
- LocationAutocomplete passed `root: 'form-group'` to react-places-autocomplete;
  BS5 removed `.form-group` (it gave the bottom margin) -> use `mb-3`.
- Drop the unused `popper.js@1` dependency (react-bootstrap bundles
  @popperjs/core@2). No source imports it.

Sweep otherwise clean: no other BS4-only utility/component classes remain in JSX
or SCSS. Full-app build boots clean.
2026-07-22 10:12:49 +02:00
bddfb392c1 bootstrap: swap BS4 (alexwine) CSS for bootstrap@5 npm
Completes the Bootstrap 4->5 migration now that every jQuery/BS4 widget is React
(navbar, carousel, dropdowns) — plus the feedback toggle here (Feedback.js:
global `$('#feedback-form').toggle()` -> React state).

- Load Bootstrap 5 CSS from the `bootstrap` npm package in client/index.js
  (imported first so app + component styles and react-bootstrap override it).
- Remove the `alexwine:bootstrap-4` meteor package (BS4 CSS + jQuery + BS4 JS).
  jQuery for jquery-validation still comes from the npm `jquery` dep.
- Utility renames to BS5: ml-auto->ms-auto, float-right->float-end,
  btn-block->w-100, data-toggle->data-bs-toggle (FromNow tooltip).
- forms.scss `.form-label` is no longer a shim (BS5 ships it); comment updated.

Full-app build boots clean; server suite 36 passing. Needs a visual staging pass
across all pages (BS4->5 shifts grid gutters/typography); forms should improve
since react-bootstrap v2 already emitted BS5 markup.
2026-07-22 05:55:34 +02:00
9ff9abacc3 bootstrap: lang/type dropdowns jQuery -> react-bootstrap <Dropdown>
The false-positive-type selector (Fires.js) and the language selector
(Profile.js) used Bootstrap's jQuery dropdown (`data-toggle="dropdown"`), the
last widgets depending on the BS4 jQuery JS that goes away with the CSS swap.
Replace both with react-bootstrap <Dropdown>/<Dropdown.Toggle>/<Dropdown.Item>
(open/close in React, no jQuery). Keeps the `.lang-selector` hook and `.btn-group`
layout; works on the current BS4 CSS.

Full-app build boots clean.
2026-07-22 01:01:59 +02:00
f775d4ac26 bootstrap: home carousel jQuery plugin -> react-bootstrap <Carousel>
Second (last) jQuery/BS4 JS blocker to the BS5 CSS swap. Replaces the
`bootstrap-carousel-swipe` jQuery plugin + `$(...).carousel()` init with
react-bootstrap's <Carousel> (native swipe, no jQuery) for both home carousels.

- Preserves the progressive `.lazy` background mechanism (blur -> full image on
  slide-in): the old `slide.bs.carousel` handler becomes <Carousel onSlide>,
  which marks the incoming index in component state.
- Keeps all per-slide class hooks (carousel-item-N, carousel-snd-item-N).
- CSS ports in Index-custom.scss for react-bootstrap's BS5 markup: indicators
  render as <button> (not <li>), and prev/next labels use .visually-hidden
  (BS4 had .sr-only) -> add a shim so labels stay hidden on current BS4 CSS.
- Drops the now-unused bootstrap-carousel-swipe dependency.

Full-app build boots clean. Needs a visual staging check of the home page.
2026-07-22 00:13:10 +02:00
8dead27d22 bootstrap: navbar collapse jQuery -> React state (BS4->5 prep)
Bootstrap's jQuery collapse (`data-toggle="collapse"` in Navigation.js, plus the
per-NavItem `data-target=".navbar-collapse.show"` auto-close) disappears when we
drop `alexwine:bootstrap-4` for `bootstrap@5`. Replace it with a React `useState`
that toggles `.show`, and close the mobile menu via an onClick on the nav <ul>.

Works on the current BS4 CSS (`.collapse.show` is pure CSS; jQuery only added the
height animation) and removes one of the two jQuery/BS4 JS blockers to the CSS
swap (the home carousel is the other). Also drops the dead sr-only toggler button
that targeted a non-existent id. No CSS changes.
2026-07-21 23:04:02 +02:00
6b6f02d92d quality: web debt batch (tests runner, FireContainer, rate-limit, maps, i18n)
Independent-of-prod quality debt from PENDIENTE.md §2:

- test: migrate broken Jest -> meteortesting:mocha. Tests rewritten to chai +
  Meteor 3 async APIs, moved to test/server/ (server-only). test/server/
  00-setup.test.js re-runs the collection2/accounts init that `meteor test`
  skips (no server/main.js). New comments method + mediaAnalyzers coverage.
  Dropped rest.test.js (removed meteor/http; covered by smoke/). 36 passing.
- fix: scope FireContainer read by the URL _id on the archive route instead of
  a selector-less FiresCollection.findOne() (imports/ui/pages/Fires/Fires.js).
- feat: rate-limit abusable publications via rateLimitSubscriptions (fireFrom*
  and comments.forReference 5/1000ms; geo subs 10/1000ms).
- perf: append loading=async to the Google Maps loader URL (Gkeys.js).
- deps: meteor-accounts-t9n 2.0 -> 2.6 (no gl build -> keep gl->es fallback,
  documented); add 3 missing gl/common.json keys (0 missing now).
- deps: drop jest/babel/enzyme, add chai.
2026-07-21 22:59:06 +02:00
bc778bfd97 deps: react-leaflet 1.8 -> 4.2 (+ leaflet 1.9) — the last legacy react-* lib
Ground-up hooks rewrite of the app's core map. Removes the final batch of
React-19-blocking warnings (legacy context on Map/LayersControl/TileLayer/
Marker/CircleMarker/Circle/Tooltip + ReactDOM.render from the old controls).

Core: <Map> -> <MapContainer>; .leafletElement refs (6 files) -> the map/
layer instances directly, via a <MapReady> child (useMap) + plain refs on
Marker/Circle/GeoJSON; controlled viewport (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 reimplemented (new in-repo helpers under Maps/):
- react-leaflet-control -> 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, no NaN), fire detail
(GeoJSON rect + fitBounds), home (3 maps coexist; SelectionMap draggable
marker -> updatePosition + distance circle). Console now shows 0 React
warnings on home/fires. REST smoke byte-identical.
2026-07-21 18:45:15 +02:00
e61a3a9bcb docs: warning cleanup done — react-leaflet is the sole remaining source
Records the defaultProps/UNSAFE_/Reconnect-Blaze/react-share/progress-bar
cleanups and that, after them, react-leaflet + its 4 plugins are the only
dev-console warnings left (verified on the home page: 16 warnings, all
leaflet/plugins; zero from anything else).
2026-07-21 14:26:20 +02:00
b710388f50 refactor: LoadingBar via progressbar.js + ref (drops react-progress-bar.js findDOMNode)
react-progress-bar.js (unmaintained wrapper) rendered the progress line
through findDOMNode. Reimplemented LoadingBar with progressbar.js directly
(the wrapper's own underlying dep) via a ref + useEffect — same thin line
(strokeWidth 2, #5A7636), no findDOMNode. Promoted progressbar.js to a
direct dependency and removed react-progress-bar.js. Verified progressbar.js
draws its SVG in-browser; findDOMNode gone from /fires and home; REST smoke
byte-identical.
2026-07-21 14:24:32 +02:00
0abf5b4f02 deps: react-share 2 -> 5 (drops its defaultProps warnings)
react-share 2.0 shipped function components with defaultProps (CreatedButton,
Icon) -> React-19-blocking warnings on fire-detail pages. v5 is React-18-safe
and drops them. Removed the GooglePlusShareButton/Icon (Google+ is dead;
removed from react-share in v4+); the other 6 buttons (Facebook, Twitter,
Telegram, WhatsApp, Reddit, Email) are API-compatible. Browser-verified 6
buttons render, no defaultProps warnings. REST smoke byte-identical.
2026-07-21 14:14:48 +02:00
258723ea72 refactor: Reconnect banner Blaze -> native React (drops findDOMNode)
Reconnect rendered the Blaze meteorStatus template via
gadicc:blaze-react-component, whose bridge uses the deprecated findDOMNode
(warned on every page since it's always mounted). Reimplemented natively
with useTracker(Meteor.status) + a countdown effect, same behaviour and
reusing 255kb:meteor-status's .meteor-status CSS. Also dropped the dead
Blaze import from App.js.

Remaining findDOMNode warnings are third-party only: react-progress-bar.js
(LoadingBar) and Status.js's Blaze serverFacts (/status admin page).
Browser-verified home renders, no banner while connected, no new errors.
REST smoke byte-identical.
2026-07-21 14:08:52 +02:00
229b2cb233 cleanup: UNSAFE_componentWillReceiveProps -> modern lifecycles (React 19)
The 4 class components on UNSAFE_componentWillReceiveProps converted:
- FromNow, FireStats: state is a pure mirror of props -> getDerivedStateFromProps
- Fires: split the derived state (getDerivedStateFromProps) from the URL-
  canonicalization side effect (componentDidUpdate, guarded by prevProps) so
  the side effect never runs inside the pure gDSFP
- SelectionMap: marker is also locally draggable, so gDSFP would clobber a
  drag -> componentDidUpdate guarded on a real center/distance value change
  (no clobber, no setState loop), merged with the existing fit()

Browser-verified: /fires count, fire detail + FromNow, active-fire URL
canonicalizes to /fire/archive/:id, home SelectionMap renders stably (no
render loop). No UNSAFE_ warnings remain from our code. REST smoke
byte-identical.
2026-07-21 14:03:44 +02:00
4ddaa0d8ec cleanup: defaultProps -> JS default params on our function components
React 19 drops defaultProps on function components. Converted the 9 of
ours that used it (App, Navigation, ReSendEmail, Reconnect, OAuthLoginButton,
PageHeader, Page, EditDocument, EditSubscription) to destructured default
params; App defaults userId/emailAddress in its withTracker instead (it
spreads {...props} widely). Class components keep defaultProps (still
supported). The only defaultProps warnings left are from the react-share
npm package (CreatedButton/Icon), not our code. REST smoke byte-identical.
2026-07-21 13:54:02 +02:00
5661e129fb fix: NaN in /fires 'active fires in map' count (react-i18next 14 <Trans>)
react-i18next 14 reserves `count` as the pluralization variable and no
longer interpolates it from a <Trans> object child, so <strong>{{count:N}}</strong>
left {{count,number}} unresolved -> the number formatter ran on undefined ->
"NaN". (t() and the countTotal var were unaffected; regression from the
i18next 10->23 / react-i18next 7->14 bump.)

Renamed the variable count -> inMap in FiresMap.js and in the
activeFireInMapCount value of the es/en/gl locales (the string has no plural
forms, so count was only an unlucky name). Browser-verified: /fires now shows
the real count; home/zones/fire-detail render with no real errors. REST smoke
byte-identical.
2026-07-21 13:45:59 +02:00
6e28bf3545 docs: record react-* modernization (helmet/i18n/bootstrap/router) + deferred leaflet & BS5
UPGRADE.md dependency-debt table and PENDIENTE.md updated to reflect the
5 shipped phases and the two deferred sub-projects (react-leaflet 1.8->4
and the Bootstrap 4->5 CSS/JS jump), with rationale for each deferral.
Final state: helmet/i18next/react-bootstrap/react-router warnings all
gone from the console; only react-leaflet (deferred) and our own
defaultProps/findDOMNode remain. REST smoke byte-identical.
2026-07-21 13:05:05 +02:00
e2d13ca64c deps: react-router-dom 4 -> 6 (history 5, react-router-bootstrap 0.26)
Removes the Router/Switch/Route/Link/LinkContainer legacy-context
warnings. Keeps the shared history singleton (used outside React by
NotificationsObserver + Utils/location) via unstable_HistoryRouter, and
bridges v6 hooks back to v4-shaped history/match/location props with a
new withRouterCompat HOC so the ~20 class pages stay untouched.

- <Switch> -> <Routes>, component= -> element=
- Authenticated/Public -> guard components (children or <Navigate replace>)
- LocationListener -> useLocation + useEffect function
- regex route /fire/:type(active|archive|alert)/:id -> /fire/:type/:id
- history.listen callback is ({ location }) in history v5

Browser-verified: SPA nav, /subscriptions->/login auth redirect,
deep-link /fire/archive/:id (params), browser back/forward. REST smoke
byte-identical.
2026-07-21 13:00:15 +02:00
a1a1b9a801 deps: react-bootstrap 0.31 -> 2 (Bootstrap 5 CSS deferred as debt)
Removes the largest batch of React-19-blocking warnings: the 0.31
components (Grid, FormGroup, ControlLabel, Navbar.Header/Brand, Checkbox,
SafeAnchor) all leaned on legacy context / defaultProps.

29 files converted: Grid->Container, FormGroup/ControlLabel/FormControl/
HelpBlock -> Form.Group/Label/Control/Text, Checkbox -> Form.Check (label
as prop), bsStyle->variant (default->secondary), bsSize->size,
pull-right->float-end. Custom Col.js now re-exports v2's Col; custom
NavItem.js rewritten self-contained (no SafeAnchor/createChainedFunction);
Navigation.js drops react-bootstrap Navbar (raw markup anyway).

CSS stays on Bootstrap 4 (alexwine:bootstrap-4) for now: the BS4->5 jump
is entangled with the jQuery carousel/swipe + navbar-collapse and is
tracked as separate debt in UPGRADE.md. Only .form-label needed a shim
(forms.scss). Browser-verified home carousel+navbar, signup form + terms
checkbox, login form; REST smoke byte-identical.
2026-07-21 12:44:46 +02:00
12bdbe8fed deps: i18next 10 -> 23, react-i18next 7 -> 14
Modernizes the whole i18n stack:
- 48 translate([], {wait:true}) / translate() HOCs -> withTranslation()
- react.wait:true -> react.useSuspense:false
- whitelist -> supportedLngs; added compatibilityJSON: 'v3' so our
  natural-language keys + v3 _plural layout keep working (custom
  separators ss/eth/dj preserved)
- backends: xhr -> i18next-http-backend (client), sync-fs ->
  i18next-fs-backend (server); dropped i18next-localstorage-cache
  (was enabled:false); languagedetector 2 -> 8
- ReSendEmail: <Interpolate> (removed in v10) -> <Trans values={{email}}/>,
  dropped the removed bare t export
- saveMissing handler signature (lngs, ns, key, fallbackValue)

Silences the per-component Translate/I18n legacy-context warnings.
Browser-verified es/en switch and <Trans> interpolation on /fires;
REST smoke byte-identical.
2026-07-18 06:28:41 +02:00
f1eae84e25 deps: react-helmet -> react-helmet-async 2
react-helmet 5 is unmaintained and breaks under React 18 StrictMode.
Same <Helmet> API in the 15 pages; HelmetProvider wraps <App/> in the
client entry. Browser-verified: per-page titles, meta description and
hreflang alternates all inject. REST smoke byte-identical.
2026-07-18 06:14:14 +02:00
f51acf4d78 deps: drop dead npm packages (reactstrap, sidebarv2, hash-link, pure-render-mixin)
None of them is imported anywhere: reactstrap 5.0.0-alpha.3 was never
adopted (react-bootstrap covers the UI), react-leaflet-sidebarv2 and
react-addons-pure-render-mixin were never used, and react-router-hash-link
only survived as a commented-out import in Index.js.
2026-07-18 06:02:49 +02:00
6e9c3a5570 docs: PENDIENTE.md — outstanding work beyond the Meteor 3 migration 2026-07-18 05:47:44 +02:00
a997aa7cae cleanup: silence our own React dev-console warnings
UNSAFE_ rename of our 4 componentWillReceiveProps (FireStats, Fires, FromNow,
SelectionMap) and Authenticated/Public component propType func -> elementType.
Rest of the console noise is legacy react-* dev-mode warnings, absent in prod.
2026-07-18 05:47:44 +02:00
9feaca9d0b fix: fire ids leaked into URLs as ObjectID("...") on Mongo 7
Fire collections use idGeneration:'MONGO', so _id is a Mongo.ObjectID whose
toString() is ObjectID("<hex>"), not the bare hex. Interpolating _id into a
URL produced /fire/archive/ObjectID("c0..."), which no route matches -> the
fire-detail page 404'd. New hexId() helper returns the 24-char hex for an
ObjectID (and passes plain strings through); applied at the three string-context
sites: the map-marker click URL (MarkListeners), the active->archive redirect,
and the comments referenceId (Fires.js). Left falsePositives.insert untouched —
its check() expects a Meteor.Collection.ObjectID, so it takes the object.

Verified in browser: /fire/archive/<hex> and /fire/active/<hex> render the
detail page (map + comments), URL stays clean hex, no ObjectID( anywhere.
2026-07-17 18:31:59 +02:00
c6c22643c1 scss: migrate @import -> @use, lighten/darken -> color.adjust (dart-sass)
Silences the dart-sass deprecation warnings that flooded every build:
- All @import of local partials (colors, mixins, and the CSS-emitting
  partials) -> @use '...' as * (partials are pure defs, so 'as *' keeps the
  global names and the ~25 call sites unchanged). Added explicit @use './colors'
  to bootstrap-overrides.scss and cookies-eu.scss, which used -palette*
  transitively (broke under @use's scoping).
- lighten(c, x%) -> color.adjust(c, $lightness: x%) and darken -> negative,
  with @use 'sass:color' in the 6 affected files.
Compiles clean (byte-for-byte CSS behavior preserved).
2026-07-17 18:18:04 +02:00
62ac2fbd9c sentry: tunnel client events through the app to dodge Cloudflare's 503
GlitchTip is behind Cloudflare, which answers the browser's cross-site ingest
POSTs with 503 (no CORS headers -> 'Failed to fetch'); server-side requests
pass. The browser SDK now posts envelopes same-origin to /sentry-tunnel, and
the server forwards them to GlitchTip:
- imports/startup/server/sentryTunnel.js: WebApp handler that relays the raw
  envelope to <dsn>/api/<project>/envelope/?sentry_key=<key> over IPv4
  (family:4 avoids Happy-Eyeballs picking Cloudflare's unroutable IPv6 on
  IPv4-only hosts). GlitchTip authenticates by the sentry_key query, so the
  key is derived from the DSN and passed explicitly.
- client ravenLogger: tunnel: '/sentry-tunnel'.
Verified: POST /sentry-tunnel -> 200 (envelope reaches GlitchTip).
2026-07-17 18:17:47 +02:00
696789e2b1 meteor3: react-meteor-data 0.2.16 -> 3.0.6 (fixes blank fire-detail pages)
0.2.16 (React-15/16-era withTracker) looped forever on fire-detail pages
under React 18: the withTracker subscription never reached ready() so the
page stayed blank and pegged a CPU core. Publications were already correct
(subscribing standalone from the console readied instantly). 3.0.6 (the
React-18 line for Meteor 3.1) fixes it with zero call-site changes. Also
drops tmeasday:check-npm-versions (the constraint that pinned 0.2.16).

Browser-verified at /fire/archive/<id>: full render (map, NASA/satellite,
FalsePositives widget, Comments box), zero console errors. REST smoke
byte-identical (12/12).
2026-07-17 13:03:54 +02:00
efa1e30bcb docs: record fire-detail page bug (react-meteor-data 0.2.16 vs React 18)
Browser verification of the Comments feature surfaced a pre-existing client
bug: fire-detail pages (/fire/*/:id) stay blank because the page's withTracker
subscription never reaches ready() and re-subscribes in a loop. Isolated it:
the publications ready fine when subscribed standalone, so it's client-side —
react-meteor-data@0.2.16 (React-15/16 era) misbehaving under React 18 for a
component that gates render on subscription.ready(). Fix = upgrade
react-meteor-data to 2.x (useTracker). Documented in UPGRADE.md; not yet done.
2026-07-16 15:57:20 +02:00
0711416300 meteor3: nodemailer 4 -> 6.10
Drop-in for our usage: email.js only does createTransport(MAIL_URL) and reads
transport.options.auth.user (production-only from() branch) — both verified
identical in v6 (createTransport from a URL still populates options.auth.user).
Actual sending goes through ostrio:mailer (MailTime 2.5), v6-compatible. Mail
server inits clean ('I'm the mail server'); REST smoke byte-identical (12/12).
2026-07-16 12:29:10 +02:00
4ca7e198d3 meteor3: raven -> @sentry/node + @sentry/browser
flowkey:raven (Sentry legacy SDK) is dead on Meteor 3 and, with the old
sentry.comunes.org DSN unreachable, spammed the boot log with 502s on every
exception. Replaced by the modern SDKs (8.x) behind the same ravenLogger.log()
facade so the 6 call sites are unchanged. Empty DSN -> plain console logger
(same behavior as before).

End-to-end verified against a real backend: GlitchTip deployed on aaron
(Sentry-compatible, fits in ~1.5GB vs Sentry's 16GB), fronted at
sentry.comunes.org. A test exception sent from the dev server appeared in
GlitchTip within seconds. REST smoke byte-identical (12/12).
2026-07-16 12:20:02 +02:00
197a59f641 fase 3: stack docker-compose local del sistema modernizado
Compose con los 5 servicios: mongo:7 (replica set rs0 + mongo-init one-shot),
redis (AOF), web Meteor 3.1 (Dockerfile multi-stage: builder debian con
meteor-tool 3.1 -> server-deps alpine -> runtime node:22-alpine), notifications
(dry-run) y node-red 4. Healthchecks (127.0.0.1, no localhost: busybox wget
prefiere IPv6 y Meteor escucha IPv4), restart unless-stopped, logging rotado
3x10MB, secretos montados desde ./secrets (gitignored, solo .example versionado).
.npmrc legacy-peer-deps para el ERESOLVE de las libs react viejas.

Validado: los 5 servicios healthy y smoke REST byte-identico (12/12) contra la
web dockerizada en :3200. RUNBOOK.md documenta arranque, smoke, backups y
rollback. No toca infra de Comunes.
2026-07-15 01:01:07 +02:00
e3f3f4aa20 meteor3: remove dead chimp e2e runner (pulled fibers, broke Docker build)
chimp 0.51.1 (Selenium 2 / cucumber e2e runner) was the last dependency
pulling native fibers@1.x, which cannot compile on Node 22 and failed the
clean Docker image build (node-gyp: no prebuilt, no python). Not imported
anywhere. Also add python-is-python3 to the builder stage so node-gyp can
find python for any other native dep. Boot + REST smoke green.
2026-07-15 00:39:48 +02:00
713f1346e7 meteor3: bcrypt 1.0.3 -> 5.1.1
The 1.x native binding was built for another Node ABI and failed to load
(accounts-password silently used the pure-JS fallback) and 1.x does not
compile on Node 22, which would break the Docker image build. 5.1.1 ships
prebuilt Node-22 binaries and loads natively. Boot + REST smoke green.
2026-07-14 11:43:37 +02:00
1f1f5c697d meteor3: port all 18 migration up() bodies to async
percolate:migrations 2.0.1 awaits async up(), so historical migrations
now run on Meteor 3: fetchAsync + for..of instead of cursor forEach,
insert/update/remove -> *Async, _ensureIndex -> createIndexAsync,
Accounts.createUser -> createUserAsync, awaited rawCollection indexes.

Verified: empty Mongo 7 DB migrates 0->18 cleanly (indexes + industry
registries created, control unlocked at v18); pre-marking version=18 is
no longer needed (UPGRADE.md updated). REST smoke stays byte-identical.
2026-07-14 11:36:30 +02:00
1892c0ead0 meteor3: React 16 -> 18.3 with createRoot
react/react-dom ^18.3.1 (--legacy-peer-deps for the pinned ancient react-*
libs, which still work on 18 via legacy context but gate React 19). Client
entry uses createRoot. Browser-verified on / and /fires; REST smoke
byte-identical (12/12).
2026-07-14 11:32:37 +02:00
eb3aec82ae meteor3: purge dead client packages — web UI renders on 3.1
alanning:roles removed (client crashed the whole bundle; plain user.roles
checks instead), selaias:cookie-consent replaced by in-repo React banner,
publish-performant-counts vendored with countAsync, Meteor.autorun dropped
(App)/Tracker.autorun (FiresMap), and the map publications not covered by
the REST smoke (activefiresmyloc, activefiresunionmyloc, fireAlerts,
oauth.verifyConfiguration) converted to async APIs.

Verified in browser: / and /fires render with map + cookie banner, zero
uncaught exceptions. REST smoke byte-identical (12/12).
2026-07-14 11:32:19 +02:00
0ff9848cd4 meteor3: re-enable fixtures and sitemaps with async/Meteor-3 implementations
fixtures: drop @cleverbeagle/seeder (sync Mongo, unmaintained) for a small
idempotent async seeder (same dev accounts). sitemaps: drop dead
gadicohen:sitemaps for a WebApp.connectHandlers /sitemap.xml with the same
static page list (the per-fire section was already disabled). Verified on
dev boot: 6 users ensured, sitemap serving; REST smoke byte-identical.
2026-07-14 07:06:07 +02:00
66bab7a43b meteor3: Comments methods and mail fan-out to async
comments.insert/edit/remove/like/dislike use *Async collection APIs;
requireOwner/toggle helpers are async; onCommentAdd iterates users with
forEachAsync. subscriptions.* methods were already async. REST smoke stays
byte-identical (12/12).
2026-07-14 06:52:32 +02:00
feaf66fe34 meteor3: web Fires publications to async (findOneAsync/countAsync/awaited firesUnion)
fireFromId, fireFromAlertId, fireFromActiveId and fireFromHash now use the
async collection APIs and async publish handlers (so try/catch catches
rejections). falsePositivesMyloc/industriesMyloc/comments.forReference are
cursor-only and needed no change. Verified over raw DDP against the seeded
dev Mongo 7; REST smoke stays byte-identical (12/12).
2026-07-14 06:52:08 +02:00
48d33c397b docs(UPGRADE): Meteor 3.1 + Mongo 7 REST smoke green; reproduce steps + remaining web-UI TODO 2026-07-14 06:23:52 +02:00
637a749e8d meteor3: REST API async migration COMPLETE — smoke green on Meteor 3.1 + Mongo 7
All 12 Flutter REST endpoints byte-identical to the 1.6.1.1 baseline, running on
Meteor 3.1 against dockerized MongoDB 7. The 3.x jump works end-to-end.

- Rest.js: every endpoint action -> async; all Mongo calls -> *Async
  (findOneAsync/countAsync/fetchAsync/upsertAsync/removeAsync).
- Helpers to async: countRealFires (accepts array|cursor, forEachAsync->for-of),
  firesUnion (fetchAsync), fireFromHash/findOrCreateFire (await, dropped Fibers
  Promise.await), subscriptionsInsert/subscriptionsRemove, upsertFalsePositive,
  falsePositives.insert, subscriptions.update method, countFiresInRegions.
- getFires: countAsync() on a $near cursor throws on the mongodb 6 driver
  ($near not allowed in the aggregation countDocuments uses) -> fetch once and
  use array length for total.
- Route order: json-routes 3.0 matches in registration order, so
  mobile/subscriptions/all/:token/:mobileToken is now registered BEFORE
  :token/:mobileToken/:subsId (else GET .../all/x/y hit the delete-only route).
2026-07-14 06:21:35 +02:00
b37069c3e5 WIP(meteor3): server BOOTS on 3.1 + Mongo 7; restivus async-patched
Major milestone: the Meteor 3.1 server boots and runs against dockerized
MongoDB 7, and the REST framework responds (status/uptime OK).

- collection2 v4: import its eager entry 'meteor/aldeed:collection2/static.js'
  from new eager files server|client/00-collection2-init.js so main.js runs and
  patches attachSchema before collections load (v4 is fully lazy, no main module).
- fixtures.js disabled: @cleverbeagle/seeder uses sync Mongo, no Meteor 3 support
  (dev fixtures, not needed for smoke). Debt.
- sitemaps.js disabled: gadicohen:sitemaps@0.0.17 pre-0.9 API, no 'sitemaps'
  global on Meteor 3. Debt (SEO).
- Vendored restivus patched for async endpoints: route handler is async and
  awaits _callEndpoint; _callEndpoint awaits the action + auth/role checks.

NEXT: Rest.js endpoints + helpers (countRealFires/firesUnion/whichAreFalsePositives/
fireFromHash/subscriptionsInsert/upsertFalsePositive) still use sync Mongo
(.count()/.findOne()/.fetch()/.insert()/.upsert()) -> convert to *Async, then
smoke test against Mongo 7.
2026-07-14 06:11:00 +02:00
25dc1b99c0 WIP(meteor3): boot progresses past linker + several async fixes
Past the client-bundle underscore linker (pin underscore@1.6.4) and now working
through the server async/await migration on Meteor 3.1 + Mongo 7:

- Removed fibers (imports/startup/server/fibers.js + import) — gone in Meteor 3.
- accounts/oauth.js: upsert -> upsertAsync (top-level await).
- migrations.js: startup async + await Migrations.migrateTo (async in 2.x).
  mongo7 marked at version 18 so historical up() bodies (still sync Mongo) don't
  run — prod data restores already-migrated. Documented.
- subsUnion.js: fully async (fetchAsync/findOneAsync/countAsync/upsertAsync,
  observeAsync with async callbacks).
- facts.js: dropped dead sync findOne.
- email.js: ostrio:mailer 2.5 is a default export (was named import); dropped
  removed static MailTime.Template (built-in default '{{{html}}}').

NEXT wall + remaining chain (multi-session):
1. aldeed:collection2@4.2.0 needs simpl-schema 3.x (app has 1.x) -> 'attachSchema
   is not a function'. Requires upgrading npm simpl-schema to 3.x and migrating
   all collection schemas (SimpleSchema.RegEx.Id etc.) across ~8 files.
2. Rest.js + helpers (countRealFires/firesUnion/whichAreFalsePositives/
   fireFromHash/subscriptionsInsert/upsertFalsePositive) -> *Async.
3. Patch vendored restivus to await async endpoint handlers.
4. methods/publications/comments to async; cron SyncedCron(quave)/Facts imports.
5. alanning:roles@1.2.10 + gadicohen:sitemaps@0.0.17 warn incompatible -> bump.
6. React 16 -> 18 render root.

Run with: NODE_OPTIONS='--dns-result-order=ipv4first --no-network-family-autoselection'
MONGO_URL='mongodb://localhost:27019/fuegos?replicaSet=rs0'
2026-07-13 23:46:45 +02:00
8fa10e47c7 WIP: Meteor 2.5 -> 3.1 migration (package resolution done; build blocked)
Reaches release METEOR@3.1 with all package-version conflicts resolved against a
modern dockerized MongoDB 7 (replica set). NOT yet building — parked here.

Done:
- MongoDB 7 single-node replica set (docker, port 27019) as the fuegos DB,
  decoupled from the shared rsmain 3.2 (unblocks Meteor 3's mongodb driver).
- meteor update --release 3.1 resolves after swapping dead packages:
  mongo-livedata removed; facts -> facts-base; saucecode:timezoned-synced-cron
  -> quave:synced-cron; aldeed:collection2-core -> aldeed:collection2@4.0.4;
  mys:fonts removed (pinned caching-compiler@1.0.0); fourseven:scss 4.14.1 ->
  5.0.0 (Dart Sass, no node-sass) + sass installed; nimble-restivus vendored
  package loosened to accounts-password 2.x||3.x.
- Network: meteor-tool node 22 needs NODE_OPTIONS='--dns-result-order=ipv4first
  --no-network-family-autoselection' to reach warehouse.meteor.com (Happy
  Eyeballs picks an unreachable IPv6 otherwise).
- smoke/smoke.sh generalized to mongosh + configurable port for Mongo 7.

Blocked on (remaining Meteor 3 work):
1. Client bundle linker: 'Runtime is not available, but it uses features needing
   the runtime: underscore' — an old client atmosphere package needs replacing.
2. Server async/await migration (Rest.js + helpers + methods + publications +
   comments + subsUnion + migrations to *Async; no Fibers).
3. Patch vendored restivus to await async endpoint handlers.
4. cron.js SyncedCron import (quave), facts.js Facts import (facts-base).
5. React 16 -> 18 render root (createRoot).
6. SCSS @import/@use + lighten/darken -> color.adjust (dart-sass deprecations).
2026-07-13 23:25:29 +02:00
bddeca79a0 docs: confirm Mongo 3.2 ceiling = Meteor 2.5 (2.6 driver 4.x rejects wire v4)
Empirically verified: 2.4 and 2.5 green against mongo:3.2 (npm-mongo 3.9.1);
2.6 bumps npm-mongo to 4.3.1 which refuses MongoDB < 3.6
(MongoCompatibilityError, wire version 4). Branch lands on Meteor 2.5.
2026-07-13 22:40:40 +02:00
208e250277 escala: Meteor 2.4 -> 2.5 (Mongo 3.2 compatible, smoke byte-identical)
npm-mongo still 3.9.1 (driver 3.x). All 12 REST endpoints identical against
mongo:3.2.
2026-07-13 22:34:14 +02:00
9b0c53fa4a escala: Meteor 2.3 -> 2.4 (Mongo 3.2 compatible, smoke byte-identical)
Trivial escala; npm-mongo still driver 3.x. All 12 REST endpoints identical
against mongo:3.2.
2026-07-13 22:30:32 +02:00
dbe2f3759f docs: UPGRADE.md — escalas 1.11/2.3, dead-package remediation, Mongo 3.2 ceiling, 3.x blockers 2026-07-13 22:26:50 +02:00
cd570b9d9c escala 2+3: Meteor 1.8.3 -> 2.3 (Mongo 3.2 compatible), dead-package remediation
Reaches Meteor 2.3 building + running against Mongo 3.2, REST smoke test
byte-identical to the 1.6.1.1 baseline (all 12 Flutter endpoints green).

Dead/abandoned Atmosphere packages removed or replaced (the upgrade blockers):
- arkham:comments-ui (no Meteor 2.x build; pinned accounts-password@1.x):
  reimplemented the fire-page comments as a React feature:
    imports/api/Comments/ (collection, server methods, publication, media
    analyzers, new-fire-comment email) + imports/ui/components/Comments/CommentsBox.
    Comment text now rendered as safe plain text + image/youtube embed (was
    markdown-to-HTML). Wired into Fires.js; sitemaps.js + migration v11 updated
    to the new collection. Old Blaze/startup comments files deleted.
- nimble:restivus (REST API; pinned accounts-password@1.3.3, CoffeeScript source
  that crashes this build host): vendored as a local package
  packages/nimble-restivus with the .coffee precompiled to plain JS and the
  accounts-password constraint loosened to 2.x. REST behavior unchanged
  (verified by smoke test). This also dropped the entire iron:router stack.
- maximum:server-transform (+ peerlibrary:*, meteorhacks:zones/inject-initial):
  unused; removal broke Meteor.publishTransformed in FalsePositives publications
  -> replaced with plain Meteor.publish (no transform was configured).
- less, markdown (no source files), and the dead test stack
  (meteortesting:mocha, practicalmeteor:chai, xolvio:cleaner) which transitively
  pulled coffeescript@1.0.17 — the build plugin that crashed meteor-tool
  (node_contextify assertion) on this machine. Removing it unblocked the build.
- fourseven:scss 4.5.4 -> 4.14.1 (node-sass 4.5.3 doesn't build on node 12).

npm-mongo driver at 2.3 is 3.9.x — still compatible with the production Mongo
3.2 replica set.
2026-07-13 22:24:09 +02:00
ccfc80547a escala 1: Meteor 1.6.1.1 -> 1.8.3
meteor update --release 1.8.3 + Babel beta->stable fix.

- Core packages bumped (meteor 1.9.3, ecmascript 0.13.2, mongo 1.7.0,
  npm-mongo 3.2.0 driver — still compatible with Mongo 3.2 server).
- Fix boot failure 'Cannot find @babel/runtime/helpers/objectSpread2':
  upgraded @babel/runtime 7.0.0-beta.44 -> ^7.29.7 (stable 7.x; not 8.x).
- underscore auto-added, fetch/modern-browsers added by the release.

REST smoke test byte-identical to baseline. See UPGRADE.md.
2026-07-13 19:59:58 +02:00
dc8a05a0d4 notif: remove code migrated to tcef-notifications microservice
The push/email notification processing now lives in the tcef-notifications
service (fases 1a-1c). Removed from the web:
- imports/startup/server/notificationsObserver.js (observe Notifications -> processNotif)
- imports/modules/server/notificationsProcess.js (node-gcm push + notif emails)
- the 'Process pending notif' SyncedCron job in cron.js
- node-gcm dependency (dead Google API since jun-2024)

subsUnion.js is KEPT: it feeds the subs-public-union SiteSettings that the REST
API (status/subs-public-union) serves to the Flutter app; it is not part of the
notifications pipeline.

DEPLOY ORDERING (see UPGRADE.md): production must not run this build until the
tcef-notifications service is emitting in prod — otherwise notifications stop.
REST smoke test green (byte-identical).
2026-07-13 19:45:13 +02:00
296dab4f38 smoke: REST API regression harness + baseline snapshots (Meteor 1.6.1.1)
Adds a standalone Node smoke test (smoke/) that exercises every REST endpoint
consumed by the Flutter app against a seeded local dev server and compares
responses to committed snapshots. This is the safety net for the Meteor upgrade:
it must stay byte-identical after every escala.

- smoke/seed.js: deterministic Mongo seed (fixed ObjectIds, geo indexes)
- smoke/run.js: calls endpoints, normalizes volatile fields, diffs snapshots
- smoke/smoke.sh: seed + run wrapper
- smoke/snapshots/: baseline captured on Meteor 1.6.1.1
- IPGeocoder.js: degrade gracefully when MaxMind DB absent (dev boot)
- settings-development.json: dev-only internalApiToken to enable the REST API
- .meteorignore: exclude smoke/ from the Meteor server build
2026-07-13 19:42:55 +02:00
214 changed files with 19869 additions and 21775 deletions

15
.dockerignore Normal file
View file

@ -0,0 +1,15 @@
# Contexto de build de la imagen web. El builder hace su propio
# `meteor npm install`; nada del host debe colarse.
.git
**/.git
.meteor/local
node_modules
smoke/node_modules
smoke/snapshots
plan-modernizacion
secrets
docker-compose.yml
RUNBOOK.md
*.log
.env
.env.*

View file

@ -0,0 +1,111 @@
# 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,3 +17,8 @@ output.json
settings-production-*.json
settings-tests.json
*.json~
# fase-3: secretos del stack docker-compose local (solo se versionan los .example)
secrets/*
!secrets/README.md
!secrets/*.example

View file

@ -15,3 +15,5 @@ notices-for-facebook-graph-api-2
1.4.1-add-shell-server-package
1.4.3-split-account-service-packages
1.5-add-dynamic-import-package
1.7-split-underscore-from-meteor-base
1.8.3-split-jquery-from-blaze

View file

@ -4,66 +4,55 @@
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.
meteor-base@1.3.0 # Packages every Meteor app needs to have
mobile-experience@1.0.5 # Packages for a great mobile UX
mongo@1.4.7 # The database Meteor supports right now
reactive-var@1.0.11 # Reactive variable for tracker
tracker@1.1.3 # Meteor's client-side reactive programming library
meteor-base@1.5.2 # Packages every Meteor app needs to have
mobile-experience@1.1.2 # Packages for a great mobile UX
mongo@2.0.3 # The database Meteor supports right now
reactive-var@1.0.13 # Reactive variable for tracker
tracker@1.3.4 # Meteor's client-side reactive programming library
standard-minifier-css@1.4.0 # CSS minifier run for production mode
standard-minifier-js@2.3.1 # JS minifier run for production mode
es5-shim@4.7.0 # ECMAScript 5 compatibility for older browsers.
ecmascript@0.10.6 # Enable ECMAScript2015+ syntax in app code
shell-server@0.3.1 # Server-side component of the `meteor shell` command
standard-minifier-css@1.9.3 # CSS minifier run for production mode
standard-minifier-js@3.0.0 # JS minifier run for production mode
ecmascript@0.16.10 # Enable ECMAScript2015+ syntax in app code
shell-server@0.6.1 # Server-side component of the `meteor shell` command
react-meteor-data
alanning:roles
fourseven:scss
accounts-base@1.4.2
accounts-password@1.5.0
service-configuration@1.0.11
accounts-facebook@1.3.1
accounts-github@1.4.1
accounts-google@1.3.1
react-meteor-data@3.0.6
fourseven:scss@5.0.0
accounts-base@3.0.3
accounts-password@3.0.3
service-configuration@1.3.5
accounts-facebook@1.3.4
accounts-github@1.5.1
accounts-google@1.4.1
themeteorchef:bert
fortawesome:fontawesome
aldeed:collection2-core@2.0.1
audit-argument-checks@1.0.7
ddp-rate-limiter@1.0.7
dynamic-import@0.3.0
static-html
alexwine:bootstrap-4
aldeed:collection2@4.0.4
audit-argument-checks@1.0.8
ddp-rate-limiter@1.2.2
dynamic-import@0.7.4
static-html@1.4.0
selaias:cookie-consent # Cookie consent
gadicc:blaze-react-component # Add braze to react
255kb:meteor-status # Connect status
# mixmax:smart-disconnect # Disconnect on lost focus (disabled to get notif)
flowkey:raven # js errors
vjrj:piwik # Stats
mdg:geolocation
natestrauser:publish-performant-counts
maximum:server-transform
mys:fonts # fonst in npm
tcef:publish-performant-counts
ostrio:mailer # mailer
# babrahams:constellation # dev utility
percolate:migrations # db migrations
ostrio:meteor-root
meteortesting:mocha
practicalmeteor:chai
aldeed:schema-deny
aldeed:template-extension
dburles:collection-helpers
less@2.7.11
markdown@1.0.12
mongo-livedata@1.0.12
reywood:publish-composite
barbatus:stars-rating
arkham:comments-ui
facts@1.0.9
gadicohen:sitemaps
# Bootstrap CSS/JS now comes from the `bootstrap` npm package (BS5), imported in
# imports/startup/client/index.js. The old alexwine:bootstrap-4 package (BS4 CSS +
# jQuery + BS4 JS) was removed after the navbar/carousel/dropdown/feedback widgets
# were migrated off jQuery.
facts-base@1.0.2
nspangler:autoreconnect
saucecode:timezoned-synced-cron
quave:synced-cron
# lmachens:kadira
meteorhacks:zones
nimble:restivus
xolvio:cleaner
nimble:restivus@0.8.12
underscore@1.6.4
meteortesting:mocha # test driver (see `npm test` / `npm run test-watch`)

View file

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

View file

@ -1,155 +1,124 @@
255kb:meteor-status@1.5.0
accounts-base@1.4.2
accounts-facebook@1.3.1
accounts-github@1.4.1
accounts-google@1.3.1
accounts-oauth@1.1.15
accounts-password@1.5.1
alanning:roles@1.2.16
aldeed:collection2-core@2.1.2
aldeed:schema-deny@2.0.1
aldeed:template-extension@4.1.0
alexwine:bootstrap-4@4.1.0
allow-deny@1.1.0
arkham:comments-ui@1.4.4
audit-argument-checks@1.0.7
autoupdate@1.4.0
babel-compiler@7.0.7
babel-runtime@1.2.2
barbatus:stars-rating@1.1.1
base64@1.0.11
binary-heap@1.0.10
blaze@2.3.2
blaze-tools@1.0.10
boilerplate-generator@1.4.0
browser-policy@1.1.0
browser-policy-common@1.0.11
browser-policy-content@1.1.0
browser-policy-framing@1.1.0
caching-compiler@1.1.11
caching-html-compiler@1.1.2
callback-hook@1.1.0
check@1.3.1
coffeescript@1.0.17
dburles:collection-helpers@1.1.0
dburles:mongo-collection-instances@0.3.5
ddp@1.4.0
ddp-client@2.3.2
ddp-common@1.4.0
ddp-rate-limiter@1.0.7
ddp-server@2.1.2
deps@1.0.12
diff-sequence@1.1.0
dynamic-import@0.3.0
ecmascript@0.10.7
ecmascript-runtime@0.5.0
ecmascript-runtime-client@0.6.2
ecmascript-runtime-server@0.5.0
ejson@1.1.0
email@1.2.3
es5-shim@4.7.3
facebook-oauth@1.4.0
facts@1.0.9
flowkey:raven@1.1.0
accounts-base@3.0.3
accounts-facebook@1.3.4
accounts-github@1.5.1
accounts-google@1.4.1
accounts-oauth@1.4.5
accounts-password@3.0.3
aldeed:collection2@4.2.0
aldeed:schema-deny@4.0.2
aldeed:simple-schema@1.13.1
allow-deny@2.0.0
audit-argument-checks@1.0.8
autoupdate@2.0.0
babel-compiler@7.11.2
babel-runtime@1.5.2
base64@1.0.13
binary-heap@1.0.12
blaze@3.0.3
blaze-tools@2.0.1
boilerplate-generator@2.0.0
caching-compiler@2.0.1
caching-html-compiler@2.0.1
callback-hook@1.6.0
check@1.4.4
core-runtime@1.0.0
dburles:collection-helpers@1.0.0
ddp@1.4.2
ddp-client@3.0.3
ddp-common@1.4.4
ddp-rate-limiter@1.2.2
ddp-server@3.0.3
diff-sequence@1.1.3
dynamic-import@0.7.4
ecmascript@0.16.10
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.12.2
ecmascript-runtime-server@0.11.1
ejson@1.1.4
email@3.1.1
es5-shim@4.8.1
facebook-oauth@1.11.4
facts-base@1.0.2
fetch@0.1.5
fortawesome:fontawesome@4.7.0
fourseven:scss@4.5.4
gadicc:blaze-react-component@1.4.0
gadicohen:robots-txt@0.0.10
gadicohen:sitemaps@0.0.26
geojson-utils@1.0.10
github-oauth@1.2.0
google-oauth@1.2.5
hot-code-push@1.0.4
html-tools@1.0.11
htmljs@1.0.11
http@1.4.0
id-map@1.1.0
fourseven:scss@5.0.0
gadicc:blaze-react-component@2.0.1
geojson-utils@1.0.12
github-oauth@1.4.2
google-oauth@1.4.5
hot-code-push@1.0.5
html-tools@2.0.1
htmljs@2.0.2
id-map@1.2.0
inter-process-messaging@0.1.2
jquery@1.11.11
lai:collection-extensions@0.2.1_1
launch-screen@1.1.1
less@2.7.12
livedata@1.0.18
localstorage@1.2.0
logging@1.1.20
markdown@1.0.12
maximum:package-base@1.1.2
maximum:server-transform@0.5.0
launch-screen@2.0.1
localstorage@1.2.1
logging@1.3.5
mdg:geolocation@1.3.0
meteor@1.8.6
meteor-base@1.3.0
meteorhacks:inject-initial@1.0.4
meteorhacks:zones@1.6.0
meteortesting:browser-tests@0.2.0
meteortesting:mocha@0.5.1
minifier-css@1.3.1
minifier-js@2.3.4
minimongo@1.4.4
mobile-experience@1.0.5
mobile-status-bar@1.0.14
modules@0.11.6
modules-runtime@0.9.2
mongo@1.4.7
mongo-dev-server@1.1.0
mongo-id@1.0.7
mongo-livedata@1.0.12
mys:fonts@0.0.2
natestrauser:publish-performant-counts@0.1.2
meteor@2.0.2
meteor-base@1.5.2
meteortesting:browser-tests@1.8.0
meteortesting:mocha@3.3.0
meteortesting:mocha-core@8.2.0
minifier-css@2.0.0
minifier-js@3.0.1
minimongo@2.0.2
mobile-experience@1.1.2
mobile-status-bar@1.1.1
modern-browsers@0.1.11
modules@0.20.3
modules-runtime@0.13.2
mongo@2.0.3
mongo-decimal@0.2.0
mongo-dev-server@1.1.1
mongo-id@1.0.9
mongo-livedata@1.0.13
nimble:restivus@0.8.12
npm-bcrypt@0.9.3
npm-mongo@2.2.34
npm-mongo@6.10.0
nspangler:autoreconnect@0.0.1
oauth@1.2.3
oauth2@1.2.0
observe-sequence@1.0.16
ordered-dict@1.1.0
ostrio:cookies@2.1.3
ostrio:mailer@2.1.0
oauth@3.0.0
oauth2@1.3.3
observe-sequence@2.0.1
ordered-dict@1.2.0
ostrio:mailer@2.5.0
ostrio:meteor-root@1.0.7
peerlibrary:assert@0.2.5
peerlibrary:fiber-utils@0.6.0
peerlibrary:reactive-mongo@0.1.1
peerlibrary:server-autorun@0.5.2
percolate:migrations@1.0.2
practicalmeteor:chai@2.1.0_1
practicalmeteor:mocha-core@1.0.1
promise@0.10.2
raix:eventemitter@0.1.3
random@1.1.0
rate-limit@1.0.9
react-meteor-data@0.2.16
reactive-dict@1.2.0
reactive-var@1.0.11
reload@1.2.0
retry@1.1.0
percolate:migrations@2.0.1
promise@1.0.0
quave:synced-cron@2.4.0
raix:eventemitter@1.0.0
random@1.2.2
rate-limit@1.1.2
react-fast-refresh@0.2.9
react-meteor-data@3.0.6
reactive-var@1.0.13
reload@1.3.2
retry@1.1.1
reywood:publish-composite@1.6.0
routepolicy@1.0.13
saucecode:timezoned-synced-cron@1.2.11
selaias:cookie-consent@0.4.0
server-render@0.3.0
service-configuration@1.0.11
session@1.1.7
sha@1.0.9
shell-server@0.3.1
shim-common@0.1.0
simple:json-routes@2.1.0
socket-stream-client@0.1.0
spacebars@1.0.15
spacebars-compiler@1.1.3
srp@1.0.10
standard-minifier-css@1.4.1
standard-minifier-js@2.3.3
static-html@1.2.2
templating@1.3.2
templating-compiler@1.3.3
templating-runtime@1.3.2
templating-tools@1.1.2
themeteorchef:bert@2.1.3
tmeasday:check-npm-versions@0.3.2
tracker@1.1.3
ui@1.0.13
underscore@1.0.10
url@1.2.0
routepolicy@1.1.2
service-configuration@1.3.5
sha@1.0.10
shell-server@0.6.1
simple:json-routes@3.0.0
socket-stream-client@0.5.3
spacebars@2.0.1
spacebars-compiler@2.0.1
standard-minifier-css@1.9.3
standard-minifier-js@3.0.0
static-html@1.4.0
static-html-tools@1.0.0
tcef:publish-performant-counts@0.2.0
templating@1.4.4
templating-compiler@2.0.1
templating-runtime@2.0.2
templating-tools@2.0.1
themeteorchef:bert@1.1.0
tracker@1.3.4
typescript@5.6.3
underscore@1.6.4
url@1.3.5
vjrj:piwik@0.3.1
webapp@1.5.0
webapp-hashing@1.0.9
xolvio:cleaner@0.3.3
webapp@2.0.4
webapp-hashing@1.1.2
zodern:types@1.0.13

View file

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

@ -0,0 +1,5 @@
# Las libs react-* antiguas (react-bootstrap 0.31, react-confirm 0.1.16, ...)
# declaran peers de react 15/16 pero funcionan con react 18 (ver UPGRADE.md).
# npm >= 7 fallaría con ERESOLVE sin esto — aplica igual en dev y en el build
# de la imagen Docker.
legacy-peer-deps=true

47
Dockerfile Normal file
View file

@ -0,0 +1,47 @@
# Web Meteor 3.1 — imagen de producción (fase 3, Docker Compose).
#
# Multi-stage:
# 1. meteor-builder (debian/glibc): instala el meteor-tool y construye el bundle.
# 2. server-deps (alpine/musl): npm install de programs/server con toolchain
# nativa, sobre la MISMA libc que el runtime.
# 3. runtime (node:22-alpine): solo el bundle listo.
#
# Secretos: NUNCA en la imagen. El entrypoint carga METEOR_SETTINGS desde el
# fichero apuntado por METEOR_SETTINGS_FILE (montado por compose).
FROM node:22-bookworm AS meteor-builder
ENV METEOR_ALLOW_SUPERUSER=1
# Node 22 + Happy Eyeballs elige IPv6 sin ruta contra warehouse.meteor.com
# (mismo workaround que en dev, ver UPGRADE.md).
ENV NODE_OPTIONS="--dns-result-order=ipv4first --no-network-family-autoselection"
# python-is-python3: node-gyp 3.x (pulled by some Atmosphere npm deps) invokes
# `python`, absent on bookworm by default.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates python3 python-is-python3 make g++ git \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://install.meteor.com/?release=3.1" | sh
WORKDIR /app
COPY . .
RUN meteor npm install
RUN meteor build /build --directory --server-only
FROM node:22-alpine AS server-deps
RUN apk add --no-cache python3 make g++
WORKDIR /bundle
COPY --from=meteor-builder /build/bundle .
RUN cd programs/server && npm install --omit=dev
FROM node:22-alpine
ENV NODE_ENV=production PORT=3000
WORKDIR /app
COPY --from=server-deps --chown=node:node /bundle .
COPY --chown=node:node docker/web-entrypoint.sh /web-entrypoint.sh
RUN chmod +x /web-entrypoint.sh
USER node
EXPOSE 3000
# 127.0.0.1 (not localhost): busybox wget resolves localhost to ::1 first, but
# the Meteor server listens on IPv4 only -> the check would false-negative.
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=5 \
CMD wget -qO /dev/null "http://127.0.0.1:${PORT}/" || exit 1
ENTRYPOINT ["/web-entrypoint.sh"]
CMD ["node", "main.js"]

129
PENDIENTE.md Normal file
View file

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

117
RUNBOOK.md Normal file
View file

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

375
UPGRADE.md Normal file
View file

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

View file

@ -0,0 +1,3 @@
// See server/00-collection2-init.js. collection2 v4's static.js is the eager
// entry that runs main.js (patches attachSchema) before any collection loads.
import 'meteor/aldeed:collection2/static.js';

View file

@ -1,3 +1,5 @@
// collection2 v4 is lazy with no main module: import the exported Collection2
// so main.js runs and patches attachSchema before any collection loads.
import '../imports/startup/client';
// https://github.com/GoogleChrome/rendertron#rendering-budget-timeout

172
docker-compose.staging.yml Normal file
View file

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

125
docker-compose.yml Normal file
View file

@ -0,0 +1,125 @@
# Stack local modernizado de "Todos contra el fuego" (fase 3 — validación local).
# NO es el despliegue de producción: eso irá al ansible de Comunes (proxies,
# firewall, monitorización). Aquí se valida que todo el stack corre en Docker.
#
# Uso: ver RUNBOOK.md. Secretos: ./secrets/ (montados como ficheros, gitignored).
name: tcef-stack
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
mongo:
image: mongo:7
container_name: tcef-stack-mongo
command: ["--replSet", "rs0", "--bind_ip_all"]
volumes:
- mongo-data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
restart: unless-stopped
logging: *default-logging
# One-shot: inicia el replica set (un nodo) y marca las migraciones de Meteor
# como aplicadas (v18) para que los up() históricos (aún síncronos) no corran.
mongo-init:
image: mongo:7
depends_on:
mongo:
condition: service_healthy
restart: "no"
entrypoint:
- bash
- -ec
- |
mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }'
# espera a que el nodo sea PRIMARY antes de escribir
for i in $$(seq 1 30); do
state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }')
[ "$$state" = "true" ] && break
sleep 1
done
mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})'
echo "mongo-init: replica set + migrations OK"
logging: *default-logging
web:
build:
context: .
dockerfile: Dockerfile
image: tcef-web:meteor3
container_name: tcef-stack-web
depends_on:
mongo-init:
condition: service_completed_successfully
environment:
PORT: "3000"
ROOT_URL: http://localhost:3200
MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0
METEOR_SETTINGS_FILE: /run/secrets/meteor-settings.json
volumes:
- ./secrets/meteor-settings.json:/run/secrets/meteor-settings.json:ro
ports:
- "3200:3000"
restart: unless-stopped
logging: *default-logging
redis:
image: redis:7-alpine
container_name: tcef-stack-redis
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 6
restart: unless-stopped
logging: *default-logging
notifications:
build:
context: ../tcef-notifications
image: tcef-notifications:local
container_name: tcef-stack-notifications
depends_on:
mongo-init:
condition: service_completed_successfully
redis:
condition: service_healthy
# Copia secrets/notifications.env.example -> secrets/notifications.env.
# Por defecto MODE=dry-run: no envía nada aunque haya credenciales.
env_file:
- ./secrets/notifications.env
restart: unless-stopped
logging: *default-logging
node-red:
image: nodered/node-red:4.0
container_name: tcef-stack-node-red
volumes:
- nodered-data:/data
ports:
- "1880:1880"
healthcheck:
test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1:1880/"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
logging: *default-logging
volumes:
mongo-data:
redis-data:
nodered-data:

14
docker/web-entrypoint.sh Normal file
View file

@ -0,0 +1,14 @@
#!/bin/sh
# Carga METEOR_SETTINGS desde un fichero montado (secretos fuera de la imagen).
set -e
if [ -n "${METEOR_SETTINGS_FILE:-}" ]; then
if [ ! -r "$METEOR_SETTINGS_FILE" ]; then
echo "ERROR: METEOR_SETTINGS_FILE=$METEOR_SETTINGS_FILE no existe o no es legible" >&2
exit 1
fi
METEOR_SETTINGS="$(cat "$METEOR_SETTINGS_FILE")"
export METEOR_SETTINGS
fi
exec "$@"

View file

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

View file

@ -16,7 +16,7 @@ Meteor.publish('activefirestotal', function total() {
return counter;
});
const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => {
const activefires = async (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => {
const fires = ActiveFires.find({
ourid: {
$geoWithin: {
@ -36,10 +36,10 @@ const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, wit
}
});
if (Meteor.isDevelopment) console.log(`Active fires total: ${fires.count()}`);
if (Meteor.isDevelopment) console.log(`Active fires total: ${await fires.countAsync()}`);
if (withMarks && fires.fetch().length > 0) {
const union = firesUnion(fires);
if (withMarks && (await fires.fetchAsync()).length > 0) {
const union = await firesUnion(fires);
const falsePos = whichAreFalsePositives(FalsePositives, union);
const industries = whichAreFalsePositives(Industries, union);
return [fires, falsePos, industries];
@ -48,7 +48,7 @@ const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, wit
return fires;
};
Meteor.publish('activefiresmyloc', function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) {
Meteor.publish('activefiresmyloc', async function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) {
// latitude -90 and 90 and the longitude between -180 and 180
check(northEastLng, NumberBetween(-180, 180));
check(southWestLat, NumberBetween(-90, 90));

View file

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

View file

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

View file

@ -0,0 +1,44 @@
/*
* Media analyzers ported from the old arkham:comments-ui package
* (lib/services/media-analyzers). Given a comment's text, detect an embeddable
* image or YouTube URL and return { type, content }. First match wins.
*/
const imageAnalyzer = {
name: 'image',
getMediaFromContent(content) {
if (content) {
const urls = content.match(/(\S+\.[^/\s]+(\/\S+|\/|))(.jpg|.png|.gif)/g);
if (urls && urls[0]) return urls[0];
}
return '';
}
};
const youtubeAnalyzer = {
name: 'youtube',
getMediaFromContent(content) {
const parts = (content || '').match(/(?:https?:\/\/)?(?:www\.youtube\.com|youtu\.?be)\/([\w=?]+)/);
let mediaContent = '';
if (parts && parts[1]) {
let id = parts[1];
if (id.indexOf('v=') > -1) {
const subParts = id.match(/v=([\w]+)/);
if (subParts && subParts[1]) id = subParts[1];
}
mediaContent = `https://www.youtube.com/embed/${id}`;
}
return mediaContent;
}
};
const analyzers = [imageAnalyzer, youtubeAnalyzer];
// Returns { type, content } or {}.
export default function getMediaFromContent(content) {
for (let i = 0; i < analyzers.length; i += 1) {
const mediaContent = analyzers[i].getMediaFromContent(content);
if (mediaContent) return { type: analyzers[i].name, content: mediaContent };
}
return {};
}

View file

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

View file

@ -0,0 +1,102 @@
/* eslint-disable import/no-absolute-path */
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import Comments from '/imports/api/Comments/Comments';
import getMediaFromContent from '/imports/api/Comments/mediaAnalyzers';
import rateLimit from '/imports/modules/rate-limit';
import onCommentAdd from '/imports/api/Comments/server/onCommentAdd';
const MAX_LEN = 5000;
function usernameOf(user) {
return (user && user.profile && user.profile.name && user.profile.name.first)
? user.profile.name.first
: '';
}
async function requireOwner(commentId, userId) {
const comment = await Comments.findOneAsync(commentId);
if (!comment) throw new Meteor.Error('404', 'Comment not found');
if (comment.userId !== userId) throw new Meteor.Error('403', 'Not your comment');
return comment;
}
async function toggle(commentId, field, otherField, userId) {
check(commentId, String);
if (!userId) throw new Meteor.Error('403', 'Login required');
const comment = await Comments.findOneAsync(commentId);
if (!comment) throw new Meteor.Error('404', 'Comment not found');
const has = (comment[field] || []).includes(userId);
const modifier = has
? { $pull: { [field]: userId } }
: { $addToSet: { [field]: userId }, $pull: { [otherField]: userId } };
await Comments.updateAsync(commentId, modifier);
}
Meteor.methods({
'comments.insert': async function commentsInsert(referenceId, content) {
check(referenceId, String);
check(content, String);
if (!this.userId) throw new Meteor.Error('403', 'Login required to comment');
const text = content.trim();
if (!text) throw new Meteor.Error('400', 'Empty comment');
if (text.length > MAX_LEN) throw new Meteor.Error('400', 'Comment too long');
const now = new Date();
const doc = {
referenceId,
content: text,
userId: this.userId,
username: usernameOf(await Meteor.users.findOneAsync(this.userId)),
media: getMediaFromContent(text),
likes: [],
dislikes: [],
status: 'approved',
createdAt: now,
updatedAt: now
};
const _id = await Comments.insertAsync(doc);
// Fire-and-forget: notify other commenters of this fire. Never let a mail
// failure break the insert.
try {
onCommentAdd({ ...doc, _id });
} catch (e) {
console.warn(`comments onCommentAdd failed: ${e}`);
}
return _id;
},
'comments.edit': async function commentsEdit(commentId, content) {
check(commentId, String);
check(content, String);
if (!this.userId) throw new Meteor.Error('403', 'Login required');
await requireOwner(commentId, this.userId);
const text = content.trim();
if (!text) throw new Meteor.Error('400', 'Empty comment');
if (text.length > MAX_LEN) throw new Meteor.Error('400', 'Comment too long');
await Comments.updateAsync(commentId, {
$set: { content: text, media: getMediaFromContent(text), updatedAt: new Date() }
});
},
'comments.remove': async function commentsRemove(commentId) {
check(commentId, String);
if (!this.userId) throw new Meteor.Error('403', 'Login required');
await requireOwner(commentId, this.userId);
await Comments.removeAsync(commentId);
},
'comments.like': async function commentsLike(commentId) {
await toggle(commentId, 'likes', 'dislikes', this.userId);
},
'comments.dislike': async function commentsDislike(commentId) {
await toggle(commentId, 'dislikes', 'likes', this.userId);
}
});
rateLimit({
methods: ['comments.insert', 'comments.edit', 'comments.remove', 'comments.like', 'comments.dislike'],
limit: 5,
timeRange: 1000
});

View file

@ -0,0 +1,42 @@
/* eslint-disable import/no-absolute-path */
import { Meteor } from 'meteor/meteor';
import i18n from 'i18next';
import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email';
import getEmailOf from '/imports/modules/get-email-of-user';
import Comments from '/imports/api/Comments/Comments';
/*
* When a comment is added to a fire, email the other users who commented on the
* same fire (uniq, excluding the author). Ported from the old
* startup/server/comments.js `onEvent` handler.
*/
export default function onCommentAdd(payload) {
const { referenceId, userId } = payload;
const query = { referenceId, userId: { $ne: userId } };
Comments.rawCollection().distinct('userId', query).then(async (users) => {
const path = referenceId.replace(/fire-/, 'fire/archive/');
const fireUrl = Meteor.absoluteUrl(path);
await Meteor.users.find({ _id: { $in: users } }).forEachAsync((user) => {
const { firstName, emailAddress } = getEmailOf(user);
if (emailAddress) {
const emailOpts = {
to: emailAddress,
subject: subjectTruncate.apply(i18n.t('Hay más información sobre un fuego')),
lang: user.lang,
template: 'new-fire-comment',
templateVars: {
applicationName: i18n.t('AppName'),
firstName,
fireUrl
}
};
sendEmail(emailOpts).catch((error) => {
console.warn(`comments new-fire-comment mail failed: ${error}`);
});
}
});
}).catch((error) => {
console.warn(`comments distinct() failed: ${error}`);
});
}

View file

@ -0,0 +1,14 @@
/* eslint-disable import/no-absolute-path */
/* eslint-disable prefer-arrow-callback */
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import Comments from '/imports/api/Comments/Comments';
// Comments for one reference (a fire page), oldest first.
Meteor.publish('comments.forReference', function commentsForReference(referenceId) {
check(referenceId, String);
return Comments.find(
{ referenceId },
{ sort: { createdAt: 1 } }
);
});

15
imports/api/Common/id.js Normal file
View file

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

View file

@ -6,7 +6,7 @@ import FalsePositiveTypes from './FalsePositiveTypes';
import Fires from '../Fires/Fires';
import rateLimit from '../../modules/rate-limit';
export function upsertFalsePositive(keyType, owner, fire) {
export async function upsertFalsePositive(keyType, owner, fire) {
const fireType = fire.type;
if (fireType === 'vecinal') {
throw new Meteor.Error('500', 'No se puede marcar este tipo de fuego');
@ -22,7 +22,7 @@ export function upsertFalsePositive(keyType, owner, fire) {
fireId: fire._id,
geo: fire.ourid
};
return FalsePositives.upsert({ geo: fire.ourid, owner }, { $set: set }, { multi: false, upsert: true });
return await FalsePositives.upsertAsync({ geo: fire.ourid, owner }, { $set: set }, { multi: false, upsert: true });
} catch (exception) {
console.log(exception);
throw new Meteor.Error('500', exception);
@ -30,7 +30,7 @@ export function upsertFalsePositive(keyType, owner, fire) {
}
Meteor.methods({
'falsePositives.insert': function falsePositivesInsert(fireId, keyType) {
'falsePositives.insert': async function falsePositivesInsert(fireId, keyType) {
check(fireId, Meteor.Collection.ObjectID);
check(keyType, String);
const type = FalsePositiveTypes[keyType];
@ -39,10 +39,10 @@ Meteor.methods({
throw new Meteor.Error('500', 'Regístrate o inicia sesión para aportar información sobre este fuego');
}
check(this.userId, String);
const fire = Fires.findOne(fireId);
const fire = await Fires.findOneAsync(fireId);
const owner = this.userId;
if (fire) {
upsertFalsePositive(keyType, owner, fire);
await upsertFalsePositive(keyType, owner, fire);
} else {
throw new Meteor.Error('500', 'Fuego no encontrado');
}

View file

@ -17,8 +17,8 @@ Meteor.publish('falsePositivesTotal', function total() {
return counter;
});
export const firesUnion = (fires) => {
const firesArray = Array.isArray(fires) ? fires : fires.fetch(); // if not is a cursor
export const firesUnion = async (fires) => {
const firesArray = Array.isArray(fires) ? fires : await fires.fetchAsync(); // if not is a cursor
const remap = firesArray.map(function remap(doc) {
const isNASA = doc.type === 'modis' || doc.type === 'viirs';
const pixelSize = doc.type === 'viirs' ? 0.375 : 1; // viirs has 375m pixel size, modis 1000m
@ -84,7 +84,7 @@ const find = (collection, northEastLng, northEastLat, southWestLng, southWestLat
return fires;
};
Meteor.publishTransformed('falsePositivesMyloc', function falsePositivesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) {
Meteor.publish('falsePositivesMyloc', function falsePositivesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) {
// latitude -90 and 90 and the longitude between -180 and 180
check(northEastLng, NumberBetween(-180, 180));
check(southWestLat, NumberBetween(-90, 90));
@ -94,7 +94,7 @@ Meteor.publishTransformed('falsePositivesMyloc', function falsePositivesInMyLoc(
return find(FalsePositives, northEastLng, northEastLat, southWestLng, southWestLat);
});
Meteor.publishTransformed('industriesMyloc', function industriesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) {
Meteor.publish('industriesMyloc', function industriesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) {
// latitude -90 and 90 and the longitude between -180 and 180
check(northEastLng, NumberBetween(-180, 180));
check(southWestLat, NumberBetween(-90, 90));

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', function fireAlerts(northEastLng, northEastLat, southWestLng, southWestLat) {
Meteor.publish('fireAlerts', async function fireAlerts(northEastLng, northEastLat, southWestLng, southWestLat) {
// latitude -90 and 90 and the longitude between -180 and 180
check(northEastLng, NumberBetween(-180, 180));
check(southWestLat, NumberBetween(-90, 90));
@ -37,6 +37,6 @@ Meteor.publish('fireAlerts', function fireAlerts(northEastLng, northEastLat, sou
track: 1
}
});
if (Meteor.isDevelopment) console.log(`Neighbour alerts total: ${fires.count()}`);
if (Meteor.isDevelopment) console.log(`Neighbour alerts total: ${await fires.countAsync()}`);
return fires;
});

View file

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

View file

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

View file

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

View file

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

View file

@ -9,3 +9,15 @@ export default ({ methods, limit, timeRange }) => {
}, limit, timeRange);
}
};
// Same idea for subscriptions. DDPRateLimiter matches subscribe calls only when
// the rule pins `type: 'subscription'`, so this is a separate helper.
export const rateLimitSubscriptions = ({ subscriptions, limit, timeRange }) => {
if (Meteor.isServer) {
DDPRateLimiter.addRule({
type: 'subscription',
name(name) { return subscriptions.indexOf(name) > -1; },
connectionId() { return true; },
}, limit, timeRange);
}
};

View file

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

View file

@ -13,6 +13,11 @@ 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

@ -1,42 +0,0 @@
/* global Comments */
/* eslint-disable import/no-absolute-path */
import i18n from '/imports/startup/client/i18n';
import '/imports/startup/common/comments';
import './comments.scss';
i18n.init((err, t) => {
Comments.ui.setContent({
title: ' ', // i18n.t('Comentarios'),
save: t('Guardar'),
reply: t('Responder'),
edit: t('Editar'),
remove: t('Borrar'),
'placeholder-textarea': t('Añadir un comentario'),
'add-button-reply': t('Añadir una respuesta'),
'add-button': t('Añadir comentario'),
'you-need-to-login': t('Necesitas iniciar sesión para'),
'add comments': t('añadir comentarios'),
'like comments': t('puntuar comentarios'),
'rate comments': t('puntuar comentarios'),
'add replies': t('responder'),
'load-more': t('Más comentarios')
});
/* This in client side */
Comments.ui.config({
limit: 20, // default 10
loadMoreCount: 20, // default 20
/* generateAvatar: function genAvatar(user, isAnonymous) {
if (isAnonymous) {
return i18n.t('Anónimo');
}
return user.profile && user.profile.name && user.profile.name.first ? user.profile.name.first : null;
}, */
template: 'bootstrap', // default 'semantic-ui'
// default 'http://s3.amazonaws.com/37assets/svn/765-default-avatar.png'
defaultAvatar: '/default-avatar.png',
markdown: true
});
});

View file

@ -1,29 +0,0 @@
.comments-section {
width: 100%;
}
.comments-box {
max-width: inherit;
padding: 0;
}
p.comment-content {
/* white-space: pre-line; */
}
/* remove comment btn danger style */
div.media-body.comment> div> div> div.btn.btn-danger.remove-action {
background-color: white;
border: 1px solid rgb(204, 204, 204);
color: rgb(51, 51, 51);
}
/* text-area height */
.form-control .create-comment {
height: 9em;
}
.img-avatar {
margin-right: 5px;
}

View file

@ -1,11 +1,10 @@
/* global CookieConsent Intl */
/* global Intl */
import i18n from 'i18next';
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { ReactiveVar } from 'meteor/reactive-var';
import backend from 'i18next-xhr-backend';
import backend from 'i18next-http-backend';
import LngDetector from 'i18next-browser-languagedetector';
import Cache from 'i18next-localstorage-cache';
import { T9n } from 'meteor-accounts-t9n';
import moment from 'moment';
import 'moment/locale/es';
@ -31,21 +30,9 @@ const detectorOptions = {
export const i18nReady = new ReactiveVar(false);
const cacheOptions = {
// turn on or off
enabled: false,
// prefix for stored languages
prefix: 'i18next_res_',
// expiration
expirationTime: 7 * 24 * 60 * 60 * 1000,
// language versions
versions: {}
};
i18nOpts.cache = cacheOptions;
i18nOpts.detection = detectorOptions;
i18nOpts.react = {
wait: true,
useSuspense: false, // was `wait: true` pre-react-i18next-10
defaultTransParent: 'span'
// https://react.i18next.com/components/i18next-instance.ht
/* bindI18n: 'languageChanged loaded',
@ -55,13 +42,17 @@ i18nOpts.react = {
const sendMissing = true; // Meteor.isDevelopment;
if (sendMissing && Meteor.isDevelopment) {
i18nOpts.sendMissing = true;
i18nOpts.missingKeyHandler = function miss(lng, ns, key, defaultValue) {
i18nOpts.saveMissing = true;
// Modern signature is (lngs, ns, key, fallbackValue, ...); key/fallback keep
// the same positions, so we still only need those two.
i18nOpts.missingKeyHandler = function miss(lngs, ns, key, defaultValue) {
Meteor.call('utility.saveMissingI18n', key, defaultValue);
};
}
function setT9(lang) {
// meteor-accounts-t9n has no Galician build, so use Spanish for gl account
// error messages (the closest shipped language). See common/i18n.js.
if (lang === 'gl') {
T9n.setLanguage('es');
} else {
@ -71,7 +62,6 @@ function setT9(lang) {
i18n.use(backend)
.use(LngDetector)
.use(Cache)
.init(i18nOpts, (err, t) => {
// initialized and ready to
if (err) {
@ -89,23 +79,9 @@ i18n.use(backend)
i18nReady.set(true);
// cookies eu consent
const cookiesOpt = {
cookieTitle: t('Uso de Cookies'),
cookieMessage: t('Utilizamos cookies para asegurar un mejor uso de nuestra web. Si continúas navegando, consideramos que aceptas su uso'),
/* cookieMessage: t('Uso de Cookies'),
cookieMessageImply: t('Utilizamos cookies para asegurar un mejor uso de nuestra web. Si continúas navegando, consideramos que aceptas su uso'), */
showLink: false,
position: 'bottom',
linkText: 'Lee más',
linkRouteName: '/privacy',
acceptButtonText: t('Aceptar'),
html: false,
expirationInDays: 70,
forceShow: false
};
CookieConsent.init(cookiesOpt);
// Cookie consent banner: selaias:cookie-consent (Blaze, dead on Meteor 3)
// replaced by the React component imports/ui/components/CookieConsent,
// which reuses the same i18n keys.
});
Meteor.subscribe('userData'); // lang is there

View file

@ -1,6 +1,11 @@
/* global */
// Bootstrap 5 CSS (npm) replaces the old `alexwine:bootstrap-4` meteor package.
// Import it first so the app + component stylesheets below (and react-bootstrap,
// which targets BS5) override it.
import 'bootstrap/dist/css/bootstrap.css';
import React from 'react';
import { render } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { HelmetProvider } from 'react-helmet-async';
import { Meteor } from 'meteor/meteor';
import '/imports/startup/client/modernizr';
import App from '../../ui/layouts/App/App';
@ -15,5 +20,9 @@ else {
Modernizr.on('webp', (result) => {
if (Meteor.isDevelopment) console.log(`webp ${result ? '' : 'not '}supported`);
});
Meteor.startup(() => render(<App />, document.getElementById('react-root')));
Meteor.startup(() => createRoot(document.getElementById('react-root')).render(
<HelmetProvider>
<App />
</HelmetProvider>
));
}

View file

@ -1,16 +1,37 @@
import RavenLogger from 'meteor/flowkey:raven';
// Error reporter (client). Was flowkey:raven; replaced by @sentry/browser
// behind the same `.log()` facade (see the server counterpart). Enabled only
// when Meteor.settings.public.sentryPublicDSN is set; otherwise a plain console
// logger. ErrorBoundary calls `.log(error, info)` where info is React's
// { componentStack } — passed to Sentry as extra context.
import * as Sentry from '@sentry/browser';
import { Meteor } from 'meteor/meteor';
const ravenOptions = {};
const publicDSN = Meteor.settings.public.sentryPublicDSN;
const enabled = !!publicDSN;
const dsn = Meteor.settings.public.sentryPublicDSN;
const enabled = !!dsn;
const ravenLogger = enabled ? new RavenLogger({
publicDSN, // will be used on the client
shouldCatchConsoleError: true, // default
trackUser: true // default
}, ravenOptions) : { log: (error) => { console.log(error); } };
if (enabled) {
Sentry.init({
dsn,
environment: Meteor.isDevelopment ? 'development' : 'production',
tracesSampleRate: 0,
// Send events same-origin to our own server, which forwards them to
// GlitchTip. GlitchTip is fronted by Cloudflare, whose bot protection
// returns 503 to the browser's cross-site ingest beacons (server-side
// requests pass fine). The tunnel sidesteps that entirely (and ad-blockers).
// See imports/startup/server/sentryTunnel.js.
tunnel: '/sentry-tunnel'
});
}
console.log(`ravenLogger ${enabled ? 'enabled' : 'disabled'}`);
const ravenLogger = {
log(error, info) {
console.log(error);
if (enabled) {
Sentry.captureException(error, info ? { extra: { info } } : undefined);
}
}
};
console.log(`sentryLogger (client) ${enabled ? 'enabled' : 'disabled'}`);
export default ravenLogger;

View file

@ -1,21 +0,0 @@
/* global Comments */
// Client and Server
Comments.config({
rating: 'likes-and-dislikes',
allowAnonymous: () => false,
allowReplies: () => false, // disabled right now: I don't know hot to get referenceId of replies
anonymousSalt: 'klasddl3lala0l3lasdlas0ol3lasdlao3lasdoaslaldal3lasdclasdlal3lasdladlaq',
publishUserFields: {
profile: 1
},
mediaAnalyzers: [
Comments.analyzers.image,
Comments.analyzers.youtube
],
generateUsername: function genUser(user) {
// console.log(JSON.stringify(user));
// FIXME
return user.profile && user.profile.name && user.profile.name.first ? user.profile.name.first : '';
}
});

View file

@ -3,7 +3,9 @@ import moment from 'moment';
// Load the js langs
import es from 'meteor-accounts-t9n/build/es';
import en from 'meteor-accounts-t9n/build/en';
// TODO ask for translation of this
// meteor-accounts-t9n (2.6.0) ships no Galician build (build/gl.js), so account
// error messages fall back to Spanish for gl — see setT9() in client/i18n.js.
// Re-enable this import if the upstream package ever adds a gl translation.
// import gl from 'meteor-accounts-t9n/build/gl';
const backOpts = {
@ -40,13 +42,17 @@ const i18nOpts = {
return value;
}
},
whitelist: ['es', 'en', 'gl'], // allowed languages
supportedLngs: ['es', 'en', 'gl'], // allowed languages (was `whitelist` pre-i18next-21)
load: 'languageOnly', // 'es' o 'en', previously: 'all', // es-ES -> es, en-US -> en
debug: shouldDebug,
ns: 'common',
defaultNS: 'common',
saveMissing: shouldDebug, // if true seems it's fails to getResourceBundle
saveMissingTo: 'es',
// Our locale JSON uses natural-language (Spanish) keys and the v3 plural
// layout (`key_plural`), so keep i18next on the v3 JSON format instead of
// the v4 suffix scheme (`key_one`/`key_other`).
compatibilityJSON: 'v3',
keySeparator: 'ß',
nsSeparator: 'ð',
pluralSeparator: 'đ'

View file

@ -21,11 +21,16 @@ function isPrivateIP(ip) {
const dbpath = '/usr/local/share/maxmind-geolite2/GeoLite2-City.mmdb';
// const dbpath = `${process.env.PWD}/private/GeoLite2-City.mmdb`;
if (!fs.existsSync(dbpath)) {
let IPGeocoder;
if (fs.existsSync(dbpath)) {
IPGeocoder = maxmind.openSync(dbpath);
} else {
// In production the DB is provisioned via cron; in local dev it may be
// absent. Degrade gracefully instead of crashing at boot: localize() below
// already falls back to a default location when no geo data is available.
console.error(`Maxmind db not found ${dbpath}, download via cron with https://www.npmjs.com/package/maxmind-geolite2-mirror`);
IPGeocoder = { get() { return null; } };
}
const IPGeocoder = maxmind.openSync(dbpath);
export default IPGeocoder;
// Warning: Meteor cannot access to this.connection with arrow functions
@ -47,7 +52,7 @@ export function localize() {
// http://dev.maxmind.com/geoip/geoip2/geolite2/
const geo = IPGeocoder.get(clientIP);
// console.warn(geo);
if (geo.location && geo.location.latitude && geo.location.longitude) {
if (geo && geo.location && geo.location.latitude && geo.location.longitude) {
return geo;
}
// geoIP fallback, Madrid

View file

@ -4,10 +4,12 @@ import { ServiceConfiguration } from 'meteor/service-configuration';
const OAuthSettings = Meteor.settings.private.OAuth;
if (OAuthSettings) {
Object.keys(OAuthSettings).forEach((service) => {
ServiceConfiguration.configurations.upsert(
// Meteor 3: sync Mongo write-methods are gone on the server; use *Async.
// Top-level await is supported in eager server modules.
for (const service of Object.keys(OAuthSettings)) {
await ServiceConfiguration.configurations.upsertAsync(
{ service },
{ $set: OAuthSettings[service] },
);
});
}
}

View file

@ -15,8 +15,6 @@ import '../../api/FireAlerts/server/publications';
import '../../api/Subscriptions/methods';
import '../../api/Subscriptions/server/publications';
// TODO add rate-limit to these publications
import '../../api/Notifications/methods';
import '../../api/Notifications/server/publications';
@ -28,3 +26,26 @@ import '../../api/SiteSettings/server/publications';
import '../../api/FalsePositives/methods';
import '../../api/FalsePositives/server/publications';
import { rateLimitSubscriptions } from '../../modules/rate-limit';
// Rate-limit the abusable publications (the old TODO above). Single-fire
// lookups and the comments feed are cheap but brute-forceable, so keep them
// tight; the geo subs fire on every map pan/zoom, so give them more headroom.
rateLimitSubscriptions({
subscriptions: [
'fireFromHash', 'fireFromAlertId', 'fireFromActiveId', 'fireFromId',
'comments.forReference'
],
limit: 5,
timeRange: 1000
});
rateLimitSubscriptions({
subscriptions: [
'activefiresmyloc', 'activefiresunionmyloc', 'fireAlerts',
'falsePositivesMyloc', 'industriesMyloc'
],
limit: 10,
timeRange: 1000
});

View file

@ -1,80 +0,0 @@
/* global Comments */
/* eslint-disable import/no-absolute-path */
import { Meteor } from 'meteor/meteor';
import i18n from 'i18next';
import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email';
import getEmailOf from '/imports/modules/get-email-of-user';
import '../common/comments';
/* import i18n from 'i18next';
import moment from 'moment';
import { dateLongFormat } from '/imports/api/Common/dates';
import Notifications from '/imports/api/Notifications/Notifications';
// import sendMail from '/imports/startup/server/email';
// import { hr } from '/imports/startup/server/email';
import getOAuthProfile from '/imports/modules/get-oauth-profile';
import image from 'google-maps-image-api-url';
import { trim } from '/imports/ui/components/NotificationsObserver/util.js'; */
Meteor.startup(() => {
// On Server or Client (preferably on Server)
Comments.config({
onEvent: (name, action, payload) => {
// e.g send a mail
// console.log(`name: ${name}, action: ${action}, payload: ${JSON.stringify(payload)}`);
// Samples:
// name: comment, action: add, payload: {"referenceId":"fire-56a9cc5a5586879eb9cc6dd7","content":"asdfasdf asd fasd fas","userId":"tdK4e43ikjDXct7Gj","isAnonymous":false,"createdAt":"2018-03-04T14:53:40.319Z","likes":[],"dislikes":[],"replies":[],"media":{},"status":"approved","starRatings":[],"ratingScore":0,"_id":"mfAe3HL9qNofhiAnh"}
// name: reply, action: add, payload: {"replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":[],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":0,"_id":"DyHt28rF6rTDnC5fP","rootUserId":"tdK4e43ikjDXct7Gj"}
// name: reply, action: like, payload: {"replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":["tdK4e43ikjDXct7Gj"],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":1,"_id":"DyHt28rF6rTDnC5fP","ratedUserId":"tdK4e43ikjDXct7Gj","rootUserId":"tdK4e43ikjDXct7Gj"}
// name: comment, action: like, payload: {"_id":"mfAe3HL9qNofhiAnh","ratedUserId":"tdK4e43ikjDXct7Gj"}
// name: reply, action: edit, payload: {"_id":"DyHt28rF6rTDnC5fP","replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf dasd fadsfa","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":[],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":0,"starRatings":[],"ratedUserId":"tdK4e43ikjDXct7Gj","rootUserId":"tdK4e43ikjDXct7Gj"}
// name: reply, action: remove, payload: {"_id":"DyHt28rF6rTDnC5fP","rootUserId":"tdK4e43ikjDXct7Gj"}
// name: (comment|reply)
// action: (add|like|edit|remove)
if (name === 'comment' && action.match(/add|like|edit/)) {
if (action === 'add') {
// Check for other users that did comments and send an email (uniq users, and not this user)
// console.log(payload.referenceId);
const query = payload.isAnonymous ?
{ referenceId: payload.referenceId } :
{ referenceId: payload.referenceId, userId: { $ne: payload.userId } };
Comments.getCollection().rawCollection().distinct('userId', query).then((users) => {
// console.log(users);
const path = payload.referenceId.replace(/fire-/, 'fire/archive/');
const fireUrl = Meteor.absoluteUrl(path);
// console.log(fireUrl);
Meteor.users.find({ _id: { $in: users } }).forEach((user) => {
const { firstName, emailAddress } = getEmailOf(user);
// console.log(JSON.stringify(user));
if (emailAddress) {
const emailOpts = {
to: emailAddress,
subject: subjectTruncate.apply(i18n.t('Hay más información sobre un fuego')),
lang: user.lang,
template: 'new-fire-comment',
templateVars: {
applicationName: i18n.t('AppName'),
firstName,
fireUrl
}
};
sendEmail(emailOpts).catch((error) => {
throw new Meteor.Error('500', `${error}`);
});
}
});
});
}
}
if (name === 'reply' && action.match(/add|like|edit/)) {
// Check for other previous users to this thread and send an email
// console.log('Reply op');
}
}
});
});

View file

@ -4,8 +4,6 @@
import { Meteor } from 'meteor/meteor';
import { tweetIberiaFires, tweetEuropeFires } from '/imports/api/ActiveFires/server/tweetFiresInZone';
import { isMailServerMaster, sendEmailsFromQueue } from '/imports/startup/server/email';
import Notifications from '/imports/api/Notifications/Notifications';
import processNotif from '/imports/modules/server/notificationsProcess.js';
// https://github.com/thesaucecode/meteor-synced-cron/
@ -55,29 +53,11 @@ Meteor.startup(() => {
job: () => sendEmailsFromQueue()
});
SyncedCron.add({
name: 'Process pending notif',
timezone: 'Europe/Madrid',
schedule: (parser) => {
// http://bunkat.github.io/later/
const sched = parser.text('every 15 min');
if (sched.error !== -1) {
console.error(`Mail cron 'when' field parsed with errors: ${sched.error}`);
}
return sched;
},
job: () => {
const mobileNotif = Notifications.find({ nofitied: null, type: 'mobile' });
mobileNotif.forEach((notif) => {
processNotif(notif);
});
const emailNotif = Notifications.find({ emailNofitied: null, type: 'web' });
emailNotif.forEach((notif) => {
processNotif(notif);
});
return { mobile: mobileNotif.count(), web: emailNotif.count() };
}
});
// NOTE: the "Process pending notif" job (push via node-gcm + notification
// emails) was removed here — that responsibility now lives in the
// tcef-notifications microservice (fases 1a-1c). See UPGRADE.md for the
// deploy-ordering dependency (the old code must not be shut off in
// production until the new service is emitting).
}
const esEn = Meteor.settings.private.twitter.es.enabled;

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

View file

@ -1,9 +1,18 @@
/* global Facts */
import { Roles } from 'meteor/alanning:roles';
import { Meteor } from 'meteor/meteor';
import { Facts } from 'meteor/facts-base';
Facts.setUserIdFilter((userId) => {
const user = Meteor.users.findOne(userId);
// console.log(`User roles: ${user.roles}`);
return Roles.userIsInRole(userId, ['admin']);
/*
* alanning:roles removed (1.x is dead on Meteor 3 crashes the whole client
* bundle at load and 4.x needs a data migration we don't want). Admin check
* against the plain `user.roles` field, cached at startup: facts are
* dev/admin instrumentation and the admin set essentially never changes.
*/
const adminIds = new Set();
Meteor.startup(async () => {
await Meteor.users
.find({ roles: 'admin' }, { fields: { _id: 1 } })
.forEachAsync(u => adminIds.add(u._id));
});
Facts.setUserIdFilter(userId => adminIds.has(userId));

View file

@ -1,7 +0,0 @@
// Workaround for: https://github.com/meteor/meteor/issues/9796
// https://github.com/meteor/meteor/issues/9796#issuecomment-381676326
// https://github.com/sandstorm-io/sandstorm/blob/0f1fec013fe7208ed0fd97eb88b31b77e3c61f42/shell/server/00-startup.js#L99-L129
import Fiber from 'fibers';
Fiber.poolSize = 1e9;

View file

@ -1,54 +1,71 @@
import seeder from '@cleverbeagle/seeder';
/* eslint-disable no-await-in-loop */
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
import Documents from '../../api/Documents/Documents';
const documentsSeed = userId => ({
collection: Documents,
environments: ['development', 'staging'],
noLimit: true,
modelCount: 5,
model(dataIndex) {
return {
owner: userId,
title: `Document #${dataIndex + 1}`,
body: `This is the body of document #${dataIndex + 1}`,
};
},
});
/*
* Dev/staging fixtures. Replaces @cleverbeagle/seeder (sync Mongo, no Meteor 3
* support) with a small async seeder: idempotent (find-before-create), only
* runs outside production.
*/
seeder(Meteor.users, {
environments: ['development', 'staging'],
noLimit: true,
data: [{
const USERS = [
{
email: 'admin@admin.com',
password: 'password',
profile: {
name: {
first: 'Andy',
last: 'Warhol',
profile: { name: { first: 'Andy', last: 'Warhol' } },
roles: ['admin']
},
},
roles: ['admin'],
data(userId) {
return documentsSeed(userId);
},
}],
modelCount: 5,
model(index, faker) {
const userCount = index + 1;
return {
email: `user+${userCount}@test.com`,
...[
['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: faker.name.firstName(),
last: faker.name.lastName(),
},
},
roles: ['user'],
data(userId) {
return documentsSeed(userId);
},
};
},
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-sync-fs-backend';
import backend from 'i18next-fs-backend';
import i18nOpts from '../common/i18n';
// import moment from 'moment';
// import { T9n } from 'meteor-accounts-t9n';

View file

@ -1,16 +1,15 @@
import './ravenLogger';
import './sentryTunnel';
import './catchExceptions';
import './fibers';
import './i18n';
import './accounts';
import './api';
import './fixtures';
import './email';
import '/imports/api/Comments/server';
import './IPGeocoder';
import './migrations';
import './notificationsObserver';
import './facts';
import './comments';
import './sitemaps';
import './subsUnion';
import './prerender';

View file

@ -1,6 +1,8 @@
/* global Migrations, Comments */
/* global Migrations */
/* eslint-disable import/no-absolute-path */
/* eslint-disable no-await-in-loop, no-restricted-syntax */
import { Meteor } from 'meteor/meteor';
import Comments from '/imports/api/Comments/Comments';
import { Accounts } from 'meteor/accounts-base';
import randomHex from 'crypto-random-hex';
import UserSubsToFiresCollection from '/imports/api/Subscriptions/Subscriptions';
@ -13,7 +15,7 @@ import Notifications from '/imports/api/Notifications/Notifications';
import ActiveFiresUnion from '/imports/api/ActiveFiresUnion/ActiveFiresUnion';
import { Mongo } from 'meteor/mongo';
Meteor.startup(() => {
Meteor.startup(async () => {
// https://github.com/percolatestudio/meteor-migrations
Migrations.config({
@ -23,38 +25,40 @@ Meteor.startup(() => {
Migrations.add({
version: 1,
up: function migrateIds() {
up: async function migrateIds() {
// https://docs.mongodb.com/manual/reference/operator/query/type/
Meteor.users.find({ _id: { $type: 7 } }).forEach((user) => {
const users = await Meteor.users.find({ _id: { $type: 7 } }).fetchAsync();
for (const user of users) {
const migratedUser = user;
const id = user._id.valueOf();
console.log(`Migrating id of user: ${JSON.stringify(user)}`);
Meteor.users.remove({ _id: user._id });
await Meteor.users.removeAsync({ _id: user._id });
migratedUser._id = id;
Meteor.users.insert(migratedUser);
});
await Meteor.users.insertAsync(migratedUser);
}
}
});
Migrations.add({
version: 2,
up: function migrateSubsForeignKey() {
UserSubsToFiresCollection.find({ owner: null }).forEach((sub) => {
up: async function migrateSubsForeignKey() {
const subs = await UserSubsToFiresCollection.find({ owner: null }).fetchAsync();
for (const sub of subs) {
console.log(`Migrating subs of chatId: ${sub.chatId}`);
const subsUser = Meteor.users.findOne({ telegramChatId: sub.chatId });
const subsUser = await Meteor.users.findOneAsync({ telegramChatId: sub.chatId });
if (subsUser) {
console.log(`Migrating linking to user: ${JSON.stringify(subsUser)}`);
UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { owner: subsUser._id } });
await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { owner: subsUser._id } });
} else {
// create user with chatId and def language
const username = `tel${sub.chatId.toString().replace(/^-/, '')}`;
console.log(`Linking to new user: ${username}`);
const newUserId = Accounts.createUser({
const newUserId = await Accounts.createUserAsync({
username,
password: randomHex(50),
profile: { name: {} }
});
Meteor.users.update({ _id: newUserId }, {
await Meteor.users.updateAsync({ _id: newUserId }, {
$set: {
emails: [],
roles: ['user'],
@ -62,87 +66,90 @@ Meteor.startup(() => {
lang: 'es'
}
});
UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { owner: newUserId } });
await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { owner: newUserId } });
}
}
});
}
});
Migrations.add({
version: 3,
up: function emptySubsTypes() {
UserSubsToFiresCollection.find({ type: null }).forEach((sub) => {
UserSubsToFiresCollection.update({ _id: sub._id }, { $set: { type: 'telegram' } });
});
up: async function emptySubsTypes() {
const subs = await UserSubsToFiresCollection.find({ type: null }).fetchAsync();
for (const sub of subs) {
await UserSubsToFiresCollection.updateAsync({ _id: sub._id }, { $set: { type: 'telegram' } });
}
}
});
Migrations.add({
version: 4,
up: function deleteOldAlertFiresAndIndexes() {
FireAlertsCollection.remove({ createdAd: null });
up: async function deleteOldAlertFiresAndIndexes() {
await FireAlertsCollection.removeAsync({ createdAd: null });
const raw = FireAlertsCollection.rawCollection();
raw.createIndex({ ourid: '2dsphere' });
raw.createIndex({ when: 1 });
raw.createIndex({ updatedAt: 1 });
raw.createIndex({ createdAt: 1 });
raw.createIndex({ ourid: 1, type: 1 });
await raw.createIndex({ ourid: '2dsphere' });
await raw.createIndex({ when: 1 });
await raw.createIndex({ updatedAt: 1 });
await raw.createIndex({ createdAt: 1 });
await raw.createIndex({ ourid: 1, type: 1 });
}
});
Migrations.add({
version: 5,
up: function siteSettingsIndex() {
up: async function siteSettingsIndex() {
// other way:
SiteSettings._ensureIndex({ name: 1 }, { unique: 1 });
await SiteSettings.createIndexAsync({ name: 1 }, { unique: true });
}
});
Migrations.add({
version: 6,
up: function falsePositiveIndexes() {
FalsePositives._ensureIndex({ chatId: 1 });
FalsePositives._ensureIndex({ owner: 1 });
FalsePositives._ensureIndex({ fireId: 1 });
FalsePositives._ensureIndex({ type: 1 });
FalsePositives._ensureIndex({ geo: '2dsphere' });
up: async function falsePositiveIndexes() {
await FalsePositives.createIndexAsync({ chatId: 1 });
await FalsePositives.createIndexAsync({ owner: 1 });
await FalsePositives.createIndexAsync({ fireId: 1 });
await FalsePositives.createIndexAsync({ type: 1 });
await FalsePositives.createIndexAsync({ geo: '2dsphere' });
}
});
Migrations.add({
version: 7,
up: function defLangIfNull() {
Meteor.users.find({ lang: null }).forEach((user) => {
Meteor.users.update({ _id: user._id }, {
up: async function defLangIfNull() {
const users = await Meteor.users.find({ lang: null }).fetchAsync();
for (const user of users) {
await Meteor.users.updateAsync({ _id: user._id }, {
$set: {
lang: 'es'
}
});
});
}
}
});
Migrations.add({
version: 8,
up: function siteSettingsAddIndex() {
SiteSettings._ensureIndex({ isPublic: 1 });
SiteSettings.find({ isPublic: null }).forEach((setting) => {
SiteSettings.update({ _id: setting._id }, { $set: { isPublic: true } });
});
up: async function siteSettingsAddIndex() {
await SiteSettings.createIndexAsync({ isPublic: 1 });
const settings = await SiteSettings.find({ isPublic: null }).fetchAsync();
for (const setting of settings) {
await SiteSettings.updateAsync({ _id: setting._id }, { $set: { isPublic: true } });
}
}
});
Migrations.add({
version: 9,
up: function siteSettingsAddIndex() {
Industries._ensureIndex({ registry: 1 });
Industries._ensureIndex({ geo: '2dsphere' });
up: async function industriesIndexesAndRegistries() {
await Industries.createIndexAsync({ registry: 1 });
await Industries.createIndexAsync({ geo: '2dsphere' });
// https://www.eea.europa.eu/data-and-maps/data/member-states-reporting-art-7-under-the-european-pollutant-release-and-transfer-register-e-prtr-regulation-16
IndustryRegistries.insert({
await IndustryRegistries.insertAsync({
_id: '1', name: 'E-PRTR', agency: 'EEA', region: 'EU'
});
// https://www.epa.gov/enviro/epa-frs-facilities-state-single-file-csv-download
IndustryRegistries.insert({
await IndustryRegistries.insertAsync({
_id: '2', name: 'FRS', agency: 'EPA', region: 'US'
});
}
@ -150,11 +157,11 @@ Meteor.startup(() => {
Migrations.add({
version: 10,
up: function siteSettingsAddIndex() {
IndustryRegistries.insert({
up: async function moreIndustryRegistries() {
await IndustryRegistries.insertAsync({
_id: '3', name: 'NPRI', agency: 'GCODP', region: 'Canada'
});
IndustryRegistries.insert({
await IndustryRegistries.insertAsync({
_id: '4', name: 'NPI', agency: 'DEE', region: 'Australia'
});
}
@ -162,15 +169,15 @@ Meteor.startup(() => {
Migrations.add({
version: 11,
up: function noAnonComments() {
Comments.getCollection().remove({ isAnonymous: true });
up: async function noAnonComments() {
await Comments.removeAsync({ isAnonymous: true });
}
});
Migrations.add({
version: 12,
up: function setTelegramUsersBotId() {
Meteor.users.update({ telegramChatId: { $ne: null }, telegramBot: null }, {
up: async function setTelegramUsersBotId() {
await Meteor.users.updateAsync({ telegramChatId: { $ne: null }, telegramBot: null }, {
$set: {
telegramBot: 'es'
}
@ -180,8 +187,8 @@ Meteor.startup(() => {
Migrations.add({
version: 13,
up: function removeTelegramBotFromUsersId() {
Meteor.users.update({}, {
up: async function removeTelegramBotFromUsersId() {
await Meteor.users.updateAsync({}, {
$unset: {
telegramBot: ''
}
@ -191,8 +198,8 @@ Meteor.startup(() => {
Migrations.add({
version: 14,
up: function setTelegramUsersBotId() {
UserSubsToFiresCollection.update({ chatId: { $ne: null }, telegramBot: null }, {
up: async function setTelegramSubsBotId() {
await UserSubsToFiresCollection.updateAsync({ chatId: { $ne: null }, telegramBot: null }, {
$set: {
telegramBot: 'es'
}
@ -202,7 +209,7 @@ Meteor.startup(() => {
Migrations.add({
version: 15,
up: function moveToFalsePositivesUppercase() {
up: async function moveToFalsePositivesUppercase() {
/* const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' });
* falsepositiveslower.find({}).forEach((falseDoc) => {
* FalsePositives.insert(falseDoc);
@ -213,29 +220,30 @@ Meteor.startup(() => {
Migrations.add({
version: 16,
up: function moveToFalsePositivesUppercaseWithUser() {
up: async function moveToFalsePositivesUppercaseWithUser() {
const falsepositiveslower = new Mongo.Collection('falsepositives', { idGeneration: 'MONGO' });
const now = new Date();
falsepositiveslower.find({}).forEach((falseDoc) => {
const user = Meteor.users.findOne({ telegramChatId: falseDoc.chatId });
const falseDocs = await falsepositiveslower.find({}).fetchAsync();
for (const falseDoc of falseDocs) {
const user = await Meteor.users.findOneAsync({ telegramChatId: falseDoc.chatId });
if (user) {
falseDoc.owner = user._id;
}
falseDoc.type = 'industry';
falseDoc.createdAt = now;
falseDoc.updatedAt = now;
FalsePositives.insert(falseDoc);
});
await FalsePositives.insertAsync(falseDoc);
}
}
});
Migrations.add({
version: 17,
up: function renameWebNotifiedField() {
Notifications.update({ webNotified: { $exists: true } }, {
up: async function renameWebNotifiedField() {
await Notifications.updateAsync({ webNotified: { $exists: true } }, {
$rename: { webNotifiedAt: 'notifiedAt' }
}, { upsert: false, multi: true });
Notifications.update({ webNotified: { $exists: true } }, {
await Notifications.updateAsync({ webNotified: { $exists: true } }, {
$rename: { webNotified: 'notified' }
}, { upsert: false, multi: true });
}
@ -243,18 +251,19 @@ Meteor.startup(() => {
Migrations.add({
version: 18,
up: () => {
up: async () => {
const raw = ActiveFiresUnion.rawCollection();
raw.createIndex({ centerid: '2dsphere' });
raw.createIndex({ shape: '2dsphere' });
raw.createIndex({ when: 1 });
raw.createIndex({ createdAt: 1 });
raw.createIndex({ updatedAt: 1 });
await raw.createIndex({ centerid: '2dsphere' });
await raw.createIndex({ shape: '2dsphere' });
await raw.createIndex({ when: 1 });
await raw.createIndex({ createdAt: 1 });
await raw.createIndex({ updatedAt: 1 });
}
});
// Set createdAt in users & subs
Migrations.migrateTo('latest');
// Meteor 3: migrateTo is async and awaits each async up(), so a fresh DB can
// migrate from 0 to latest without pre-marking the control version.
await Migrations.migrateTo('latest');
// Migrations.migrateTo('14,rerun');
});

View file

@ -1,19 +0,0 @@
/* eslint-disable import/no-absolute-path */
import { Meteor } from 'meteor/meteor';
import Notifications from '/imports/api/Notifications/Notifications';
import processNotif from '/imports/modules/server/notificationsProcess.js';
import { isMailServerMaster } from '/imports/startup/server/email';
Meteor.startup(() => {
if (isMailServerMaster) {
Notifications.find().observe({
added: function notifAdded(notif) {
processNotif(notif);
},
changed: function notifChanged(updatedNotif) { // , oldNotif) {
processNotif(updatedNotif);
}
});
}
});

View file

@ -1,18 +1,35 @@
import RavenLogger from 'meteor/flowkey:raven';
/* eslint-disable import/no-absolute-path */
// Error reporter (server). Was flowkey:raven (raven@2.4.1, Sentry legacy SDK),
// which is dead on Meteor 3 and, with a dead DSN, spammed the boot log with
// "failed to send exception to sentry: HTTP Error (502)". Replaced by the
// modern @sentry/node SDK behind the same `.log()` facade so the 6 call sites
// don't change. With no DSN configured it is a pure console logger (identical
// to the old "disabled" branch) — ready to point at a DSN when Sentry/GlitchTip
// is back, via Meteor.settings.sentryPrivateDSN.
import * as Sentry from '@sentry/node';
import { Meteor } from 'meteor/meteor';
const ravenOptions = {};
const publicDSN = Meteor.settings.public.sentryPublicDSN;
const privateDSN = Meteor.settings.sentryPrivateDSN;
const enabled = publicDSN && privateDSN;
const dsn = Meteor.settings.sentryPrivateDSN;
const enabled = !!dsn;
const ravenLogger = enabled ? new RavenLogger({
publicDSN,
privateDSN,
shouldCatchConsoleError: true, // default true
trackUser: true // default false
}, ravenOptions) : { log: (error) => { console.log(error); } };
if (enabled) {
Sentry.init({
dsn,
environment: Meteor.isDevelopment ? 'development' : 'production',
// errors only; no tracing/profiling (avoids the OTel auto-instrumentation)
tracesSampleRate: 0
});
}
console.log(`ravenLogger ${enabled ? 'enabled' : 'disabled'}`);
const ravenLogger = {
log(error, extra) {
console.error(error);
if (enabled) {
Sentry.captureException(error, extra ? { extra: { info: extra } } : undefined);
}
}
};
console.log(`sentryLogger (server) ${enabled ? 'enabled' : 'disabled'}`);
export default ravenLogger;

View file

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

View file

@ -1,44 +1,37 @@
/* global sitemaps Comments */
/* eslint-disable import/no-absolute-path */
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import Fires from '/imports/api/Fires/Fires';
// import Comments from '/imports/api/Comments/Comments';
/*
* /sitemap.xml own implementation replacing gadicohen:sitemaps (pre-0.9
* package API, dead on Meteor 3). Static pages only: the per-fire section of
* the old handler was behind `firesMapEnabled = false` (dead code) and was
* dropped; see git history if it's ever wanted back.
*/
const firesMapEnabled = false;
const PAGES = [
'/fires',
'/login',
'/signup',
'/recover-password',
'/credits',
'/terms',
'/license',
'/privacy',
'/about'
];
sitemaps.add('/sitemap.xml', () => {
const today = new Date();
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
});
});
function xmlEscape(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
return out;
WebApp.connectHandlers.use('/sitemap.xml', (req, res) => {
const lastmod = new Date().toISOString().slice(0, 10);
const urls = PAGES.map((page) => {
const loc = xmlEscape(Meteor.absoluteUrl(page.replace(/^\//, '')));
return ` <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);
});

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(() => {
Meteor.startup(async () => {
if (!isMailServerMaster) {
console.log('We only process subsUnion in master');
return;
@ -35,8 +35,8 @@ Meteor.startup(() => {
const noNoisy = sub => sub;
const process = (isPublic) => {
const subscribers = Subscriptions.find().fetch();
const process = async (isPublic) => {
const subscribers = await Subscriptions.find().fetchAsync();
const result = calcUnion(L, subscribers, isPublic ? addNoisy : noNoisy, true);
const union = result[0];
const bounds = result[1];
@ -74,9 +74,9 @@ Meteor.startup(() => {
};
// FIXME, take care of object size:
// https://stackoverflow.com/questions/10827812/what-is-the-length-maximum-for-a-string-data-type-in-mongodb-used-with-ruby
SiteSettings.upsert({ name: `subs-${publicl}-union` }, unionSet, { multi: false });
SiteSettings.upsert({ name: `subs-${publicl}-union-bounds` }, boundsSet, { multi: false });
SiteSettings.upsert({ name: 'subs-union-count' }, sizeSet, { multi: false });
await SiteSettings.upsertAsync({ name: `subs-${publicl}-union` }, unionSet, { multi: false });
await SiteSettings.upsertAsync({ name: `subs-${publicl}-union-bounds` }, boundsSet, { multi: false });
await SiteSettings.upsertAsync({ name: 'subs-union-count' }, sizeSet, { multi: false });
if (debug) console.log(`${Publicl} subscription union calculated`);
} else {
console.log('Subscription union failed!');
@ -84,40 +84,39 @@ Meteor.startup(() => {
};
// At startup, we check if it's necessary to calc subscriptions union again
const currentUnion = SiteSettings.findOne({ name: 'subs-public-union' });
const lastSubs = Subscriptions.findOne({}, { sort: { updatedAt: -1 } });
const countUnionSubs = SiteSettings.findOne({ name: 'subs-union-count' });
const countSubs = Subscriptions.find({}).count();
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();
if (currentUnion && lastSubs) {
const lastUnionUpdated = currentUnion.updatedAt;
const lastSubsUpdated = lastSubs.updatedAt;
if (lastUnionUpdated > lastSubsUpdated || !countUnionSubs || countSubs !== countUnionSubs.value) {
console.log('Subs union outdated');
process(true);
process(false);
await process(true);
await process(false);
} else {
console.log('Subs union up-to-date');
}
}
Subscriptions.find({ createdAt: { $gt: new Date() } }).observe({
added: function newSubAdded() { // doc) {
const recreate = async () => { await process(true); await process(false); };
await Subscriptions.find({ createdAt: { $gt: new Date() } }).observeAsync({
added: async function newSubAdded() { // doc) {
if (debug) console.log('Subs added so recreate union');
process(true);
process(false);
await recreate();
}
});
Subscriptions.find().observe({
changed: function subsChanged() { // updatedDoc, oldDoc) {
await Subscriptions.find().observeAsync({
changed: async function subsChanged() { // updatedDoc, oldDoc) {
if (debug) console.log('Subs changed so recreate union');
process(true);
process(false);
await recreate();
},
removed: function subsRemoved() { // oldDoc) {
removed: async function subsRemoved() { // oldDoc) {
if (debug) console.log('Subs removed so recreate union');
process(true);
process(false);
await recreate();
}
});
});

View file

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

View file

@ -1,23 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Route, Redirect } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
const Authenticated = ({ loggingIn, authenticated, component, path, exact, ...rest }) => (
<Route
path={path}
exact={exact}
render={props => (
authenticated ?
(React.createElement(component, { ...props, ...rest, loggingIn, authenticated })) :
(<Redirect to="/login" />)
)}
/>
// 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 />
);
Authenticated.propTypes = {
loggingIn: PropTypes.bool.isRequired,
authenticated: PropTypes.bool.isRequired,
component: PropTypes.func.isRequired,
children: PropTypes.node.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 { translate, Trans } from 'react-i18next';
import { withTranslation, Trans } from 'react-i18next';
import { testId } from '/imports/ui/components/Utils/TestUtils';
/*
@ -39,4 +39,4 @@ AuthenticatedNavigation.propTypes = {
name: PropTypes.string.isRequired
};
export default translate([], { wait: true })(withRouter(AuthenticatedNavigation));
export default withTranslation()(withRouterCompat(AuthenticatedNavigation));

View file

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

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 { translate } from 'react-i18next';
import { withTranslation } from 'react-i18next';
import { Bert } from 'meteor/themeteorchef:bert';
import './CenterInMyPosition.scss';
@ -48,7 +48,7 @@ class CenterInMyPosition extends React.Component {
const { onlyIcon, t } = this.props;
const msg = t('Centrar en tu ubicación');
return (
<Button bsStyle="default" title={msg} onClick={() => this.onClick()}>
<Button variant="secondary" title={msg} onClick={() => this.onClick()}>
<i className="fa fa-crosshairs" />{!onlyIcon ? ` ${msg}` : ''}
</Button>);
}
@ -63,4 +63,4 @@ CenterInMyPosition.propTypes = {
onlyIcon: PropTypes.bool
};
export default translate([], { wait: true })(CenterInMyPosition);
export default withTranslation()(CenterInMyPosition);

View file

@ -1,111 +1,6 @@
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import elementType from 'prop-types-extra/lib/elementType';
import { bsClass, prefix, splitBsProps } from 'react-bootstrap/lib/utils/bootstrapUtils';
import { DEVICE_SIZES } from 'react-bootstrap/lib/utils/StyleConfig';
const column = PropTypes.oneOfType([
PropTypes.oneOf(['auto']),
PropTypes.number,
]);
const propTypes = {
componentClass: elementType,
/**
* The number of columns you wish to span
*
* for Extra small devices Phones (<576px)
*
* class-prefix `col-`
*/
xs: column,
/**
* The number of columns you wish to span
*
* for Small devices Tablets (576px)
*
* class-prefix `col-sm-`
*/
sm: column,
/**
* The number of columns you wish to span
*
* for Medium devices Desktops (768px)
*
* class-prefix `col-md-`
*/
md: column,
/**
* The number of columns you wish to span
*
* for Large devices Desktops (992px)
*
* class-prefix `col-lg-`
*/
lg: column,
/**
* The number of columns you wish to span
*
* for Large devices Desktops (1200px)
*
* class-prefix `col-xl-`
*/
xl: column,
};
const defaultProps = {
componentClass: 'div',
};
class Col extends React.Component {
render() {
const { componentClass: Component, className, ...props } = this.props;
const [bsProps, elementProps] = splitBsProps(props);
const classes = [];
DEVICE_SIZES.forEach((size) => {
const propValue = elementProps[size];
if (propValue == null) return;
if (size === 'xs') {
// col and col-4
classes.push(propValue === true ?
bsProps.bsClass :
prefix(bsProps, `${propValue}`),
);
} else {
// col-md-3
classes.push(
prefix(bsProps, `${size}-${propValue}`),
);
}
delete elementProps[size];
});
if (!classes.length) {
classes.push(bsProps.bsClass); // plain 'col'
}
return (
<Component
{...elementProps}
className={classNames(className, classes)}
/>
);
}
}
Col.propTypes = propTypes;
Col.defaultProps = defaultProps;
export default bsClass('col', Col);
// The old custom Col hand-rolled Bootstrap-4/5 grid classes on top of
// react-bootstrap 0.31 internals (`bootstrapUtils`, `StyleConfig`), which no
// longer exist in react-bootstrap v2. v2's own Col produces the exact same
// `col`/`col-{size}-{n}` output for the xs/sm/md/lg/xl props we use, so we
// simply re-export it and keep every importer unchanged.
export { Col as default } from 'react-bootstrap';

View file

@ -0,0 +1,76 @@
.comments-section {
width: 100%;
}
.comments-box {
max-width: inherit;
padding: 0;
}
.comments-list {
list-style: none;
padding: 0;
margin: 0;
}
.comment {
padding: 0.75em 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.comment-header {
display: flex;
justify-content: space-between;
font-size: 0.9em;
}
.comment-author {
font-weight: bold;
}
.comment-date {
color: #888;
}
p.comment-content {
white-space: pre-line;
margin: 0.4em 0;
}
.comment-media-image {
max-width: 100%;
}
.comment-media-youtube iframe {
width: 100%;
min-height: 240px;
border: 0;
}
.comment-actions .btn {
padding-left: 0;
padding-right: 0.75em;
}
.comment-actions .btn.active {
font-weight: bold;
}
/* keep the previous "remove" button neutral (not danger red) */
.comment-owner-actions .remove-action {
color: rgb(51, 51, 51);
}
.comment-form .create-comment {
height: 9em;
margin-bottom: 0.5em;
}
.comment-login-hint {
color: #888;
font-style: italic;
}
.img-avatar {
margin-right: 5px;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
/* eslint-disable import/no-absolute-path */
import React from 'react';
import PropTypes from 'prop-types';
import { Trans, translate } from 'react-i18next';
import { Trans, withTranslation } 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 translate([], { wait: true })(DistanceSlider);
export default withTranslation()(DistanceSlider);

View file

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

View file

@ -2,7 +2,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormGroup, ControlLabel, Button } from 'react-bootstrap';
import { Form, 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()}>
<FormGroup>
<ControlLabel>Title</ControlLabel>
<Form.Group>
<Form.Label>Title</Form.Label>
<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!"
/>
</FormGroup>
<FormGroup>
<ControlLabel>Body</ControlLabel>
</Form.Group>
<Form.Group>
<Form.Label>Body</Form.Label>
<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!"
/>
</FormGroup>
<Button type="submit" bsStyle="success">
</Form.Group>
<Button type="submit" variant="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 { translate } from 'react-i18next';
import { FormGroup, Button, FormControl } from 'react-bootstrap';
import { withTranslation } from 'react-i18next';
import { Form, Button } 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,6 +18,7 @@ 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);
}
@ -50,7 +51,9 @@ class Feedback extends Component {
}
onTabClick() {
$('#feedback-form').toggle('slide');
// 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 }));
}
handleSubmit() {
@ -76,13 +79,13 @@ class Feedback extends Component {
<div>
{ !this.props.isHome &&
<div id="feedback">
<div id="feedback-form" ref={formdiv => (this.formdiv = formdiv)} style={{ display: 'none' }} className="card">
<div id="feedback-form" ref={formdiv => (this.formdiv = formdiv)} style={{ display: this.state.open ? 'block' : 'none' }} className="card">
<form
ref={form => (this.form = form)}
className="form card-body"
onSubmit={event => event.preventDefault()}
>
<FormGroup controlId="formEmail">
<Form.Group controlId="formEmail">
<input
id={testId('emailInput')}
onChange={this.handleChange}
@ -95,8 +98,8 @@ class Feedback extends Component {
defaultValue={disabled ? this.props.emailAddress : ''}
type="email"
/>
</FormGroup>
<FormGroup
</Form.Group>
<Form.Group
controlId="formFeedback"
>
<textarea
@ -107,9 +110,9 @@ class Feedback extends Component {
placeholder={t('Por favor, escribe aquí tu feedback...')}
rows="6"
/>
<FormControl.Feedback />
</FormGroup>
<Button type="submit" bsStyle="success" className="float-right" id={testId('sendFeedbackBtn')} >
<Form.Control.Feedback />
</Form.Group>
<Button type="submit" variant="success" className="float-end" id={testId('sendFeedbackBtn')} >
{t('Enviar')}
</Button>
</form>
@ -134,7 +137,7 @@ Feedback.propTypes = {
isHome: PropTypes.bool.isRequired
};
export default translate()(withTracker(props => ({
export default withTranslation()(withTracker(props => ({
emailAddress: props.emailAddress,
emailVerified: props.emailVerified,
isHome: isHome()

View file

@ -1,5 +1,5 @@
/* From: https://github.com/spektom/bootstrap-feedback-form */
@import '../../stylesheets/mixins';
@use '../../stylesheets/mixins' as *;
#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 { Grid } from 'react-bootstrap';
import { translate } from 'react-i18next';
import { Container } from 'react-bootstrap';
import { withTranslation } 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">
<Grid>
<Container>
<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="pull-right">
<ul className="float-end">
<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>
</Grid>
</Container>
</div>
);
};
Footer.propTypes = {};
export default translate([], { wait: true })(Footer);
export default withTranslation()(Footer);

View file

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

View file

@ -1,5 +1,8 @@
import createHistory from 'history/createBrowserHistory';
import { createBrowserHistory } from 'history';
const history = createHistory();
// 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();
export default history;

View file

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

View file

@ -1,25 +1,46 @@
/* eslint-disable react/jsx-indent */
import React from 'react';
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Meteor } from 'meteor/meteor';
import { Line } from 'react-progress-bar.js';
import ProgressBar from 'progressbar.js';
import './LoadingBar.scss';
// Check: https://github.com/kimmobrunfeldt/react-progressbar.js/pull/26
const LoadingBar = ({ progress }) => (
// 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);
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">
<Line
progress={Meteor.status().status !== 'connected' ? Meteor.status().retryCount / 10 : progress}
options={{ strokeWidth: 2, color: '#5A7636' }}
initialAnimate
/>
<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 { translate } from 'react-i18next';
import { withTranslation } from 'react-i18next';
import PlacesAutocomplete, { geocodeByAddress, getLatLng } from 'react-places-autocomplete';
import { FormGroup, ControlLabel, HelpBlock } from 'react-bootstrap';
import { Form } from 'react-bootstrap';
import { Bert } from 'meteor/themeteorchef:bert';
@ -82,13 +82,13 @@ class LocationAutocomplete extends React.Component {
return (
<form>
<FormGroup>
{ label.length > 0 && <ControlLabel>{t(label)}</ControlLabel> }
<Form.Group>
{ label.length > 0 && <Form.Label>{t(label)}</Form.Label> }
<PlacesAutocomplete
styles={myStyles}
autocompleteItem={AutocompleteItem}
classNames={{
root: 'form-group',
root: 'mb-3', // was BS4 `.form-group` (removed in BS5); mb-3 keeps the bottom margin
input: 'form-control',
autocompleteContainer: 'autocomplete-container'
}}
@ -114,8 +114,8 @@ class LocationAutocomplete extends React.Component {
autoFocus: this.props.focusInput
}}
/>
{ helpText.length > 0 && <HelpBlock>{t(helpText)}</HelpBlock> }
</FormGroup>
{ helpText.length > 0 && <Form.Text>{t(helpText)}</Form.Text> }
</Form.Group>
</form>
);
}
@ -130,4 +130,4 @@ LocationAutocomplete.propTypes = {
i18n: PropTypes.object.isRequired
};
export default translate([], { wait: true })(LocationAutocomplete);
export default withTranslation()(LocationAutocomplete);

View file

@ -4,8 +4,8 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate } from 'react-i18next';
import { GoogleLayer } from 'react-leaflet-google/lib/';
import { withTranslation } from 'react-i18next';
import GoogleMutantLayer from '/imports/ui/components/Maps/GoogleMutantLayer';
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')}>
<GoogleLayer opacity={defOpacity} googlekey={this.state.gkey} maptype="ROADMAP" />
<GoogleMutantLayer opacity={defOpacity} maptype="ROADMAP" />
</BaseLayer> }
{ this.state.gkey &&
<BaseLayer name={t('Mapa de terreno de Google')} checked={this.props.terrain}>
<GoogleLayer opacity={defOpacity} googlekey={this.state.gkey} maptype="TERRAIN" />
<GoogleMutantLayer opacity={defOpacity} maptype="TERRAIN" />
</BaseLayer> }
{ this.state.gkey &&
<BaseLayer name={t('Mapa de satélite de Google')} checked={this.props.satellite}>
<GoogleLayer opacity={defOpacity} googlekey={this.state.gkey} maptype="SATELLITE" />
<GoogleMutantLayer opacity={defOpacity} maptype="SATELLITE" />
</BaseLayer> }
</LayersControl>
);
@ -85,4 +85,4 @@ DefMapLayers.defaultProps = {
satellite: false
};
export default translate([], { wait: true })(DefMapLayers);
export default withTranslation()(DefMapLayers);

View file

@ -31,7 +31,7 @@ class FireCircleMark extends Component {
} = this.props;
const rect = rectangleAround({ lat, lon }, track, track);
return (
<GeoJSON data={rect} color="red" stroke width="1" opacity=".4" fillOpacity="1">
<GeoJSON data={rect} style={{ color: 'red', stroke: true, weight: 1, opacity: 0.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 { translate } from 'react-i18next';
import { withTranslation } 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 (
<div>
<Fragment>
{ !falsePositives && !industries &&
<Marker position={[lat, lon]} icon={nasa ? this.getIcon(scan) : nFireIcon} onClick={this.onClick} >
<Marker position={[lat, lon]} icon={nasa ? this.getIcon(scan) : nFireIcon} eventHandlers={{ click: 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,23 +43,20 @@ 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} onClick={this.onClick}>
<Marker position={[lat, lon]} icon={industryIcon} eventHandlers={{ click: 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]} color={nasa ? 'red' : '#D35400'} stroke={false} fillOpacity="1" fill radius={1} onClick={this.onClick}>
<CircleMarker center={[lat, lon]} radius={1} pathOptions={{ color: nasa ? 'red' : '#D35400', stroke: false, fillOpacity: 1, fill: true }} eventHandlers={{ click: this.onClick }}>
<FirePopup t={t} history={history} id={id} nasa={nasa} lat={lat} lon={lon} when={when} />
</CircleMarker> }
</div>
</Fragment>
);
}
}
@ -80,4 +77,4 @@ FireIconMark.propTypes = {
t: PropTypes.func.isRequired
};
export default translate([], { wait: true })(FireIconMark);
export default withTranslation()(FireIconMark);

View file

@ -3,7 +3,7 @@
import React from 'react';
import { CircleMarker } from 'react-leaflet';
import PropTypes from 'prop-types';
import { translate } from 'react-i18next';
import { withTranslation } from 'react-i18next';
import FirePopup from './FirePopup';
/* Less acurate (1 pixel per fire) but faster */
@ -12,11 +12,8 @@ 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>
@ -34,4 +31,4 @@ FirePixel.propTypes = {
t: PropTypes.func.isRequired
};
export default translate([], { wait: true })(FirePixel);
export default withTranslation()(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} color="orange" stroke width="1" opacity=".2" fillOpacity=".0" />
<GeoJSON data={shape} style={{ color: 'orange', stroke: true, weight: 1, opacity: 0.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 { translate } from 'react-i18next';
import { withTranslation } from 'react-i18next';
const FirePopup = ({
lat,
@ -29,4 +29,4 @@ FirePopup.propTypes = {
t: PropTypes.func.isRequired
};
export default translate([], { wait: true })(FirePopup);
export default withTranslation()(FirePopup);

View file

@ -1,28 +1,30 @@
/* eslint-disable react/jsx-indent-props */
/* eslint-disable import/no-absolute-path */
/* eslint-disable import/no-absolute-path */
import React, { Component } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { translate } from 'react-i18next';
import FullscreenControl from 'react-leaflet-fullscreen';
import 'react-leaflet-fullscreen/dist/styles.css';
import { withTranslation } from 'react-i18next';
import { createControlComponent } from '@react-leaflet/core';
import L from 'leaflet';
import 'leaflet.fullscreen';
import 'leaflet.fullscreen/Control.FullScreen.css';
class FullScreenMap extends Component {
render() {
const { t } = this.props;
return (
// 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')}
/>
);
}
}
FullScreenMap.propTypes = {
t: PropTypes.func.isRequired
};
export default translate([], { wait: true })(FullScreenMap);
export default withTranslation()(FullScreenMap);

View file

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

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

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

View file

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

View file

@ -2,9 +2,11 @@ import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import { SafeAnchor } from 'react-bootstrap';
import createChainedFunction from 'react-bootstrap/lib/utils/createChainedFunction';
// 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.
const propTypes = {
active: PropTypes.bool,
disabled: PropTypes.bool,
@ -12,7 +14,11 @@ const propTypes = {
href: PropTypes.string,
onClick: PropTypes.func,
onSelect: PropTypes.func,
eventKey: PropTypes.any
eventKey: PropTypes.any,
className: PropTypes.string,
anchorClassName: PropTypes.string,
style: PropTypes.object,
children: PropTypes.node
};
const defaultProps = {
@ -21,56 +27,49 @@ const defaultProps = {
};
class NavItem extends React.Component {
constructor(props, context) {
super(props, context);
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(e) {
if (this.props.onSelect) {
const { disabled, onClick, onSelect, eventKey } = this.props;
if (disabled) {
e.preventDefault();
if (!this.props.disabled) {
this.props.onSelect(this.props.eventKey, e);
return;
}
if (onClick) onClick(e);
if (onSelect) {
e.preventDefault();
onSelect(eventKey, e);
}
}
render() {
const {
active, disabled, onClick, className, anchorClassName, style, ...props
} =
this.props;
active, disabled, role, href, onClick, onSelect, eventKey,
className, anchorClassName, style, children, ...props
} = this.props;
delete props.onSelect;
delete props.eventKey;
const anchorProps = { ...props };
if (href) anchorProps.href = href;
anchorProps.role = role || (href === '#' ? 'button' : undefined);
if (role === 'tab') anchorProps['aria-selected'] = active;
// 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}
>
<SafeAnchor
{...props}
disabled={disabled}
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
{...anchorProps}
className={anchorClassName}
onClick={createChainedFunction(onClick, this.handleClick)}
/>
onClick={this.handleClick}
>
{children}
</a>
</li>
);
}

View file

@ -2,10 +2,9 @@
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, translate } from 'react-i18next';
import { Trans, withTranslation } from 'react-i18next';
import { testId } from '/imports/ui/components/Utils/TestUtils';
/* import BetaRibbon from '../../components/BetaRibbon/BetaRibbon'; */
import PublicNavigation from '../PublicNavigation/PublicNavigation';
@ -16,29 +15,29 @@ import './Navigation.scss';
// removed class: fixed-top
// md instead of lg: no menu in medium
const Navigation = props => (
//
// 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 /> */}
{/* <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' : ''} >
<Link to="/" className={`navbar-brand ${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">
<button className="navbar-toggler" type="button" onClick={() => setOpen(o => !o)} aria-controls="navbarNavDropdown" aria-expanded={open} aria-label="Toggle navigation">
<span className="navbar-toggler-icon" />
</button>
</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 ">
<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> */}
@ -55,15 +54,11 @@ const Navigation = props => (
</LinkContainer>
</ul>
{!props.authenticated ? <PublicNavigation /> : <AuthenticatedNavigation {...props} />}
{/* </Navbar.Collapse> */}
{!props.authenticated ? <PublicNavigation /> : <AuthenticatedNavigation name={name} {...props} />}
</div>
</div>
</nav>
);
Navigation.defaultProps = {
name: ''
};
Navigation.propTypes = {
@ -71,4 +66,4 @@ Navigation.propTypes = {
authenticated: PropTypes.bool.isRequired
};
export default translate([], { wait: true })(Navigation);
export default withTranslation()(Navigation);

View file

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

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