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.
This commit is contained in:
vjrj 2026-07-13 22:24:09 +02:00
parent ccfc80547a
commit cd570b9d9c
24 changed files with 1638 additions and 312 deletions

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
: '';
}
function requireOwner(commentId, userId) {
const comment = Comments.findOne(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;
}
function toggle(commentId, field, otherField, userId) {
check(commentId, String);
if (!userId) throw new Meteor.Error('403', 'Login required');
const comment = Comments.findOne(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 } };
Comments.update(commentId, modifier);
}
Meteor.methods({
'comments.insert': 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(Meteor.users.findOne(this.userId)),
media: getMediaFromContent(text),
likes: [],
dislikes: [],
status: 'approved',
createdAt: now,
updatedAt: now
};
const _id = Comments.insert(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': function commentsEdit(commentId, content) {
check(commentId, String);
check(content, String);
if (!this.userId) throw new Meteor.Error('403', 'Login required');
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');
Comments.update(commentId, {
$set: { content: text, media: getMediaFromContent(text), updatedAt: new Date() }
});
},
'comments.remove': function commentsRemove(commentId) {
check(commentId, String);
if (!this.userId) throw new Meteor.Error('403', 'Login required');
requireOwner(commentId, this.userId);
Comments.remove(commentId);
},
'comments.like': function commentsLike(commentId) {
toggle(commentId, 'likes', 'dislikes', this.userId);
},
'comments.dislike': function commentsDislike(commentId) {
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((users) => {
const path = referenceId.replace(/fire-/, 'fire/archive/');
const fireUrl = Meteor.absoluteUrl(path);
Meteor.users.find({ _id: { $in: users } }).forEach((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 } }
);
});

View file

@ -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));