diff --git a/.meteor/packages b/.meteor/packages
index c723518..befa8a2 100644
--- a/.meteor/packages
+++ b/.meteor/packages
@@ -37,7 +37,7 @@ alexwine:bootstrap-4
selaias:cookie-consent # Cookie consent
gadicc:blaze-react-component # Add braze to react
255kb:meteor-status # Connect status
-mixmax:smart-disconnect # Disconnect on lost focus
+# mixmax:smart-disconnect # Disconnect on lost focus (disabled to get notif)
flowkey:raven # js errors
vjrj:piwik # Stats
mdg:geolocation
@@ -45,7 +45,9 @@ natestrauser:publish-performant-counts
maximum:server-transform
mys:fonts # fonst in npm
ostrio:mailer # mailer
+mizzao:user-status # user status (online, etc)
+# babrahams:constellation # dev utility
+percolate:migrations # db migrations
ostrio:meteor-root
-mizzao:user-status
-babrahams:constellation
-percolate:migrations
+meteortesting:mocha
+practicalmeteor:chai
diff --git a/.meteor/versions b/.meteor/versions
index fd46a71..8a3258f 100644
--- a/.meteor/versions
+++ b/.meteor/versions
@@ -7,16 +7,12 @@ accounts-oauth@1.1.15
accounts-password@1.5.0
alanning:roles@1.2.16
aldeed:collection2-core@2.0.1
-aldeed:template-extension@4.0.0
alexwine:bootstrap-4@4.0.0-beta.2
allow-deny@1.1.0
audit-argument-checks@1.0.7
autoupdate@1.3.12
babel-compiler@6.24.7
babel-runtime@1.1.1
-babrahams:constellation@0.4.10
-babrahams:editable-json@0.6.4
-babrahams:temple@0.4.5
base64@1.0.10
binary-heap@1.0.10
blaze@2.3.2
@@ -27,13 +23,6 @@ caching-html-compiler@1.1.2
callback-hook@1.0.10
check@1.2.5
coffeescript@1.0.17
-constellation:autopublish@0.4.4
-constellation:console@1.4.5
-constellation:plugins@0.4.7
-constellation:position@0.4.4
-constellation:session@0.4.5
-constellation:subscriptions@0.4.5
-constellation:tiny@0.4.4
dburles:mongo-collection-instances@0.3.5
ddp@1.4.0
ddp-client@2.2.0
@@ -58,8 +47,6 @@ gadicc:blaze-react-component@1.4.0
geojson-utils@1.0.10
github-oauth@1.2.0
google-oauth@1.2.4
-gwendall:body-events@0.1.6
-gwendall:session-json@0.1.7
hot-code-push@1.0.4
html-tools@1.0.11
htmljs@1.0.11
@@ -76,10 +63,11 @@ maximum:server-transform@0.5.0
mdg:geolocation@1.3.0
meteor@1.8.0
meteor-base@1.2.0
+meteortesting:browser-tests@0.1.2
+meteortesting:mocha@0.5.0
minifier-css@1.2.16
minifier-js@2.2.1
minimongo@1.4.0
-mixmax:smart-disconnect@0.0.4
mizzao:timesync@0.3.4
mizzao:user-status@0.6.7
mobile-experience@1.0.5
@@ -105,6 +93,8 @@ 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.0
raix:eventemitter@0.1.3
random@1.0.10
diff --git a/imports/api/ActiveFires/ActiveFires.js b/imports/api/ActiveFires/ActiveFires.js
index 7bb71e3..79437af 100644
--- a/imports/api/ActiveFires/ActiveFires.js
+++ b/imports/api/ActiveFires/ActiveFires.js
@@ -2,6 +2,7 @@
import { Mongo } from 'meteor/mongo';
import SimpleSchema from 'simpl-schema';
+import firesCommonSchema from '../Common/FiresSchema';
const ActiveFires = new Mongo.Collection('activefires', { idGeneration: 'MONGO' });
@@ -17,6 +18,13 @@ ActiveFires.deny({
remove: () => true
});
+
+ActiveFires.schema = new SimpleSchema(firesCommonSchema);
+
+ActiveFires.attachSchema(ActiveFires.schema);
+
+export default ActiveFires;
+
/* Sample:
* {
* "_id" : ObjectId("5a208d7579095a1adba48191"),
@@ -46,19 +54,3 @@ ActiveFires.deny({
* "createdAt" : ISODate("2017-11-30T08:07:33.720Z")
* }
* */
-
-ActiveFires.schema = new SimpleSchema({
- lat: Number,
- lon: Number,
- scan: Number,
- type: String,
- acq_date: String,
- acq_time: String,
- when: Date,
- createdAt: Date,
- updatedAt: Date
-});
-
-ActiveFires.attachSchema(ActiveFires.schema);
-
-export default ActiveFires;
diff --git a/imports/api/Common/FiresSchema.js b/imports/api/Common/FiresSchema.js
new file mode 100644
index 0000000..cdaae88
--- /dev/null
+++ b/imports/api/Common/FiresSchema.js
@@ -0,0 +1,29 @@
+/* eslint-disable import/no-absolute-path */
+
+import { defaultCreatedAt, defaultUpdateAt } from '/imports/api/Utility/Utils.js';
+import LocationSchema from '/imports/api/Utility/LocationSchema.js';
+
+const firesCommonSchema = {
+ ourid: LocationSchema,
+ lat: Number,
+ lon: Number,
+ scan: Number,
+ type: String,
+ when: Date,
+ track: { type: Number, optional: true },
+ acq_date: { type: String, optional: true },
+ acq_time: { type: String, optional: true },
+ satellite: { type: String, optional: true },
+ confidence: { type: Number, optional: true },
+ version: { type: String, optional: true },
+ frp: { type: Number, optional: true },
+ daynight: { type: String, optional: true },
+ brightness: { type: Number, optional: true },
+ bright_t31: { type: Number, optional: true },
+ bright_ti4: { type: Number, optional: true },
+ bright_ti5: { type: Number, optional: true },
+ createdAt: defaultCreatedAt,
+ updatedAt: defaultUpdateAt
+};
+
+export default firesCommonSchema;
diff --git a/imports/api/Fires/Fires.js b/imports/api/Fires/Fires.js
new file mode 100644
index 0000000..12a87f5
--- /dev/null
+++ b/imports/api/Fires/Fires.js
@@ -0,0 +1,25 @@
+/* eslint-disable consistent-return */
+
+import { Mongo } from 'meteor/mongo';
+import SimpleSchema from 'simpl-schema';
+import firesCommonSchema from '../Common/FiresSchema';
+
+const Fires = new Mongo.Collection('fires', { idGeneration: 'MONGO' });
+
+Fires.allow({
+ insert: () => false,
+ update: () => false,
+ remove: () => false
+});
+
+Fires.deny({
+ insert: () => true,
+ update: () => true,
+ remove: () => true
+});
+
+Fires.schema = new SimpleSchema(firesCommonSchema);
+
+Fires.attachSchema(Fires.schema);
+
+export default Fires;
diff --git a/imports/api/Fires/methods.js b/imports/api/Fires/methods.js
new file mode 100644
index 0000000..1d7758d
--- /dev/null
+++ b/imports/api/Fires/methods.js
@@ -0,0 +1,22 @@
+/* eslint-disable import/no-absolute-path */
+
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import rateLimit from '/imports/modules/rate-limit';
+import urlEnc from '/imports/modules/url-encode';
+
+Meteor.methods({
+ 'fire.decrypt': async function fireDecode(fireEnc) {
+ check(fireEnc, String);
+ const unsealed = await urlEnc.decrypt(fireEnc);
+ return unsealed;
+ }
+});
+
+rateLimit({
+ methods: [
+ 'fire.decode'
+ ],
+ limit: 5,
+ timeRange: 1000
+});
diff --git a/imports/api/Fires/server/publications.js b/imports/api/Fires/server/publications.js
new file mode 100644
index 0000000..7e9be4d
--- /dev/null
+++ b/imports/api/Fires/server/publications.js
@@ -0,0 +1,37 @@
+/* eslint-disable import/no-absolute-path */
+/* eslint-disable prefer-arrow-callback */
+
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import urlEnc from '/imports/modules/url-encode';
+import { Promise } from 'meteor/promise';
+import FiresCollection from '../Fires';
+
+function findFire(unsealed) {
+ const fire = FiresCollection.find({ ourid: { type: 'Point', coordinates: [unsealed.lon, unsealed.lat] } });
+ return fire;
+}
+
+Meteor.publish('fireFromHash', function fireFromHash(fireEnc) {
+ check(fireEnc, String);
+ try {
+ // console.log(fireEnc);
+ const unsealed = Promise.await(urlEnc.decrypt(fireEnc));
+ const w = unsealed.when;
+ // console.log(w);
+ unsealed.when = new Date(w);
+ // console.log(unsealed);
+ FiresCollection.schema.validate(unsealed);
+ const fire = findFire(unsealed);
+ if (fire.count() === 0) {
+ const result = FiresCollection.upsert({ ourid: unsealed.ourid }, { $set: unsealed }, { multi: false, upsert: true });
+ console.log(JSON.stringify(result));
+ }
+ return findFire(unsealed);
+ /* console.log(`fires: ${fire.count()}`);
+ * return fire; */
+ } catch (e) {
+ console.error(e);
+ throw new Meteor.Error('500', e);
+ }
+});
diff --git a/imports/api/Notifications/Notifications.js b/imports/api/Notifications/Notifications.js
new file mode 100644
index 0000000..9ee90c7
--- /dev/null
+++ b/imports/api/Notifications/Notifications.js
@@ -0,0 +1,39 @@
+/* eslint-disable consistent-return */
+/* eslint-disable import/no-absolute-path */
+
+import { Mongo } from 'meteor/mongo';
+import SimpleSchema from 'simpl-schema';
+import { defaultCreatedAt, defaultUpdateAt } from '/imports/api/Utility/Utils.js';
+import LocationSchema from '/imports/api/Utility/LocationSchema.js';
+
+const Notifications = new Mongo.Collection('notifications', { idGeneration: 'MONGO' });
+
+Notifications.allow({
+ insert: () => false,
+ update: () => false,
+ remove: () => false
+});
+
+Notifications.deny({
+ insert: () => true,
+ update: () => true,
+ remove: () => true
+});
+
+Notifications.schema = new SimpleSchema({
+ userId: String,
+ content: String,
+ geo: LocationSchema,
+ type: String,
+ webNotified: { type: Boolean, optional: true },
+ webNotifiedAt: { type: Date, optional: true },
+ emailNotified: { type: Boolean, optional: true },
+ emailNotifiedAt: { type: Date, optional: true },
+ when: Date,
+ createdAt: defaultCreatedAt,
+ updatedAt: defaultUpdateAt
+});
+
+Notifications.attachSchema(Notifications.schema);
+
+export default Notifications;
diff --git a/imports/api/Notifications/methods.js b/imports/api/Notifications/methods.js
new file mode 100644
index 0000000..604c6e0
--- /dev/null
+++ b/imports/api/Notifications/methods.js
@@ -0,0 +1,25 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Notifications from './Notifications';
+import rateLimit from '../../modules/rate-limit';
+
+Meteor.methods({
+ 'notifications.sent': function notificationsUpdate(notifId) {
+ check(notifId, Meteor.Collection.ObjectID);
+
+ try {
+ Notifications.update(notifId, { $set: { webNotified: true, webNotifiedAt: new Date() } });
+ return notifId;
+ } catch (exception) {
+ throw new Meteor.Error('500', exception);
+ }
+ }
+});
+
+rateLimit({
+ methods: [
+ 'notifications.sent'
+ ],
+ limit: 5,
+ timeRange: 1000
+});
diff --git a/imports/api/Notifications/server/publications.js b/imports/api/Notifications/server/publications.js
new file mode 100644
index 0000000..a67cfa6
--- /dev/null
+++ b/imports/api/Notifications/server/publications.js
@@ -0,0 +1,10 @@
+/* eslint-disable prefer-arrow-callback */
+
+import { Meteor } from 'meteor/meteor';
+import Notifications from '../Notifications';
+
+Meteor.publish('mynotifications', function notifications() {
+ const notif = Notifications.find({ userId: this.userId, type: 'web', webNotified: null });
+ console.log(`Notifications for user ${this.userId}: ${notif.count()}`);
+ return notif;
+});
diff --git a/imports/api/Subscriptions/Subscriptions.js b/imports/api/Subscriptions/Subscriptions.js
index 9bc1d6f..69b7ff5 100644
--- a/imports/api/Subscriptions/Subscriptions.js
+++ b/imports/api/Subscriptions/Subscriptions.js
@@ -4,6 +4,7 @@
import { Mongo } from 'meteor/mongo';
import SimpleSchema from 'simpl-schema';
import { defaultCreatedAt, defaultUpdateAt } from '/imports/api/Utility/Utils.js';
+import LocationSchema from '/imports/api/Utility/LocationSchema.js';
const Subscriptions = new Mongo.Collection('subscriptions', { idGeneration: 'MONGO' });
@@ -39,36 +40,6 @@ Subscriptions.deny({
* }
* */
-// https://stackoverflow.com/questions/24492333/meteor-simple-schema-for-mongo-geo-location-data
-// https://github.com/aldeed/meteor-simple-schema/issues/606
-const LocationSchema = new SimpleSchema({
- type: {
- type: String,
- allowedValues: ['Point']
- },
- coordinates: {
- type: Array,
- minCount: 2,
- maxCount: 2,
- custom: function custom() {
- if (!(this.value[0] >= -90 && this.value[0] <= 90)) {
- return 'lngOutOfRange';
- }
- if (!(this.value[1] >= -180 && this.value[1] <= 180)) {
- return 'latOutOfRange';
- }
- return true;
- }
- },
- 'coordinates.$': {
- type: Number
- }
-});
-
-LocationSchema.messageBox.messages({
- lonOutOfRange: '[label] longitude should be between -90 and 90',
- latOutOfRange: '[label] latitude should be between -180 and 180'
-});
Subscriptions.schema = new SimpleSchema({
location: Object,
diff --git a/imports/api/Users/server/send-welcome-email.js b/imports/api/Users/server/send-welcome-email.js
index c28b205..6a19a1a 100644
--- a/imports/api/Users/server/send-welcome-email.js
+++ b/imports/api/Users/server/send-welcome-email.js
@@ -16,10 +16,10 @@ export default (options, user) => {
templateVars: {
applicationName,
firstName,
- welcomeUrl: Meteor.absoluteUrl('documents'), // e.g., returns http://localhost:3000/documents
- },
+ welcomeUrl: Meteor.absoluteUrl('subscriptions') // e.g., returns http://localhost:3000/documents
+ }
})
- .catch((error) => {
- throw new Meteor.Error('500', `${error}`);
- });
+ .catch((error) => {
+ throw new Meteor.Error('500', `${error}`);
+ });
};
diff --git a/imports/api/Utility/LocationSchema.js b/imports/api/Utility/LocationSchema.js
new file mode 100644
index 0000000..9cb603b
--- /dev/null
+++ b/imports/api/Utility/LocationSchema.js
@@ -0,0 +1,34 @@
+import SimpleSchema from 'simpl-schema';
+
+// https://stackoverflow.com/questions/24492333/meteor-simple-schema-for-mongo-geo-location-data
+// https://github.com/aldeed/meteor-simple-schema/issues/606
+const LocationSchema = new SimpleSchema({
+ type: {
+ type: String,
+ allowedValues: ['Point']
+ },
+ coordinates: {
+ type: Array,
+ minCount: 2,
+ maxCount: 2,
+ custom: function custom() {
+ if (!(this.value[0] >= -90 && this.value[0] <= 90)) {
+ return 'lngOutOfRange';
+ }
+ if (!(this.value[1] >= -180 && this.value[1] <= 180)) {
+ return 'latOutOfRange';
+ }
+ return true;
+ }
+ },
+ 'coordinates.$': {
+ type: Number
+ }
+});
+
+LocationSchema.messageBox.messages({
+ lonOutOfRange: '[label] longitude should be between -90 and 90',
+ latOutOfRange: '[label] latitude should be between -180 and 180'
+});
+
+export default LocationSchema;
diff --git a/imports/modules/server/send-email.js b/imports/modules/server/send-email.js
index 08b731d..79e8d85 100644
--- a/imports/modules/server/send-email.js
+++ b/imports/modules/server/send-email.js
@@ -7,6 +7,8 @@ import templateToHTML from './handlebars-email-to-html';
const sendEmail = (options, { resolve, reject }) => {
try {
Meteor.defer(() => {
+ // TODO: replace with import sendMail from '/imports/startup/server/email';
+ console.log(`Email options: ${options}`);
Email.send(options);
resolve();
});
@@ -15,13 +17,15 @@ const sendEmail = (options, { resolve, reject }) => {
}
};
-export default ({ text, html, template, templateVars, ...rest }) => {
+export default ({
+ text, html, template, templateVars, ...rest
+}) => {
if (text || html || template) {
return new Promise((resolve, reject) => {
sendEmail({
...rest,
text: template ? templateToText(getPrivateFile(`email-templates/${template}.txt`), (templateVars || {})) : text,
- html: template ? templateToHTML(getPrivateFile(`email-templates/${template}.html`), (templateVars || {})) : html,
+ html: template ? templateToHTML(getPrivateFile(`email-templates/${template}.html`), (templateVars || {})) : html
}, { resolve, reject });
});
}
diff --git a/imports/modules/url-encode.js b/imports/modules/url-encode.js
new file mode 100644
index 0000000..291514a
--- /dev/null
+++ b/imports/modules/url-encode.js
@@ -0,0 +1,6 @@
+import { Meteor } from 'meteor/meteor';
+import Iron from 'iron';
+
+exports.encrypt = obj => Iron.seal(obj, Meteor.settings.private.ironPassword, Iron.defaults);
+
+exports.decrypt = obj => Iron.unseal(obj, Meteor.settings.private.ironPassword, Iron.defaults);
diff --git a/imports/startup/server/api.js b/imports/startup/server/api.js
index 53d263a..bfb830e 100644
--- a/imports/startup/server/api.js
+++ b/imports/startup/server/api.js
@@ -13,4 +13,11 @@ 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';
+
+import '../../api/Fires/methods';
+import '../../api/Fires/server/publications';
diff --git a/imports/startup/server/email.js b/imports/startup/server/email.js
index c0728e6..08826c4 100644
--- a/imports/startup/server/email.js
+++ b/imports/startup/server/email.js
@@ -18,7 +18,7 @@ const db = Meteor.users.rawDatabase(); // new Mongo.Collection('__mailTimeQueue_
// db.getCollection("__mailTimeQueue__").count()
// https://litmus.com/community/discussions/4633-is-there-a-reliable-1px-horizontal-rule-method
-const hr = `
+export const hr = `
|
@@ -32,7 +32,11 @@ const MailQueue = new MailTime({
from(transport) {
// To pass spam-filters `from` field should be correctly set
// for each transport, check `transport` object for more options
- return `"${i18n.t('AppName')}" <${transport.options.auth.user}>`;
+ if (!Meteor.isProduction) {
+ // only for test purposes
+ return `${i18n.t('AppName')} `;
+ }
+ return `${i18n.t('AppName')} <${transport.options.auth.user}>`;
},
debug: true,
concatEmails: true, // Concatenate emails to the same addressee
diff --git a/imports/startup/server/i18n.js b/imports/startup/server/i18n.js
index f2652b4..46a2f88 100644
--- a/imports/startup/server/i18n.js
+++ b/imports/startup/server/i18n.js
@@ -5,6 +5,7 @@ import i18nOpts from '../common/i18n';
// import moment from 'moment';
// import { T9n } from 'meteor-accounts-t9n';
+// vi ostrio:meteor-root
i18nOpts.backend.loadPath = `${Meteor.absolutePath}/public${i18nOpts.backend.loadPath}`;
i18nOpts.backend.addPath = `${Meteor.absolutePath}/public${i18nOpts.backend.addPath}`;
diff --git a/imports/startup/server/index.js b/imports/startup/server/index.js
index 51cb2cb..8dcf2fc 100644
--- a/imports/startup/server/index.js
+++ b/imports/startup/server/index.js
@@ -5,3 +5,4 @@ import './fixtures';
import './email';
import './IPGeocoder';
import './migrations';
+import './notificationsObserver';
diff --git a/imports/startup/server/notificationsObserver.js b/imports/startup/server/notificationsObserver.js
new file mode 100644
index 0000000..d8d329e
--- /dev/null
+++ b/imports/startup/server/notificationsObserver.js
@@ -0,0 +1,89 @@
+/* eslint-disable import/no-absolute-path */
+
+import i18n from 'i18next';
+import { Meteor } from 'meteor/meteor';
+import moment from 'moment';
+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(() => {
+ // https://stackoverflow.com/questions/1199352/smart-way-to-shorten-long-strings-with-javascript
+ function truncate(n, useWordBoundary) {
+ if (this.length <= n) {
+ return this;
+ }
+ const subString = this.substr(0, n - 1);
+ return `${useWordBoundary ?
+ subString.substr(0, subString.lastIndexOf(' ')) :
+ subString}...`;
+ }
+
+ // 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 `
`;
+ }
+
+ function process(notif) {
+ if (notif.type === 'web' && !notif.emailNotified) {
+ const user = Meteor.users.findOne({ _id: notif.userId });
+ const hasPassword = user.services && user.services.password && user.services.password.bcrypt;
+
+ // console.log(`Has password: ${hasPassword}`);
+ const OAuthProfile = getOAuthProfile({
+ password: hasPassword,
+ profile: user.profile
+ }, user);
+ const firstName = OAuthProfile ? OAuthProfile.name.first : user.profile.name.first;
+ const emailAddress = OAuthProfile ? OAuthProfile.email :
+ user && user.emails[0] && user.emails[0].verified ? user.emails[0].address : null;
+ if (emailAddress) {
+ const img = imgEl(notif.geo.coordinates[1], notif.geo.coordinates[0]);
+ const url = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]);
+ // FIXME use our map as url and static map as img
+ moment.locale(user.lang);
+ const message = `${trim(notif.content)} (${i18n.t('fireDetectedAt', { when: moment(notif.when).format('LLL') })}).`;
+ const emailOpts = {
+ to: emailAddress,
+ userName: firstName,
+ sendAt: new Date(),
+ subject: truncate.apply(message, [50, true]),
+ text: `${message} ${url}`,
+ template: '{{appName}}
{{{html}}}',
+ appName: i18n.t('AppName'),
+ html: `${message}
${hr}${img}
`
+ };
+ sendMail(emailOpts, true);
+ Notifications.update(notif._id, { $set: { emailNotified: true, emailNotifiedAt: new Date() } });
+ }
+ }
+ }
+
+ Notifications.find().observe({
+ added: function notifAdded(notif) {
+ process(notif);
+ },
+ changed: function notifChanged(updatedNotif, oldNotif) {
+ process(updatedNotif);
+ }
+ });
+});
diff --git a/imports/ui/components/History/History.js b/imports/ui/components/History/History.js
new file mode 100644
index 0000000..f72523c
--- /dev/null
+++ b/imports/ui/components/History/History.js
@@ -0,0 +1,12 @@
+import { Meteor } from 'meteor/meteor';
+import createHistory from 'history/createBrowserHistory';
+
+const history = createHistory();
+
+history.listen((location) => { // , action ) => {
+ // console.log(location.pathname);
+ // console.log(action); // PUSH, etc
+ Meteor.Piwik.trackPage(location.pathname);
+});
+
+export default history;
diff --git a/imports/ui/components/NotificationsObserver/NotificationsObserver.js b/imports/ui/components/NotificationsObserver/NotificationsObserver.js
new file mode 100644
index 0000000..dc6b6cf
--- /dev/null
+++ b/imports/ui/components/NotificationsObserver/NotificationsObserver.js
@@ -0,0 +1,70 @@
+/* eslint-disable import/no-absolute-path */
+import { Meteor } from 'meteor/meteor';
+import { Tracker } from 'meteor/tracker';
+import { Bert } from 'meteor/themeteorchef:bert';
+import moment from 'moment';
+import Notifications from '/imports/api/Notifications/Notifications';
+import Push from 'push.js/bin/push.min.js';
+import i18n from '/imports/startup/client/i18n';
+import history from '../../components/History/History';
+import { trim } from './util.js';
+
+function process(notif) {
+ // No already notified
+ if (Push.Permission.has()) {
+ if (!notif.webNotified) {
+ Push.create(i18n.t('AppName'), {
+ body: `${trim(notif.content)} (${i18n.t('fireDetected', { when: moment(notif.when).fromNow() })})`,
+ icon: '/n-fire-marker.png',
+ requireInteraction: true,
+ onClick: function onClickFocus() {
+ window.focus();
+ this.close();
+ history.push('/fires');
+ }
+ });
+
+ Meteor.call('notifications.sent', notif._id, (error) => {
+ if (error) {
+ Bert.alert(error.reason, 'danger');
+ }
+ });
+ }
+ }
+}
+
+// Observe for new notifications
+Notifications.find().observe({
+ added: function notifAdded(notif) {
+ process(notif);
+ },
+ changed: function notifChanged(updatedNotif, oldNotif) {
+ process(updatedNotif);
+ }
+});
+
+Meteor.startup(() => {
+ if (Meteor.userId()) {
+ Meteor.subscribe('mynotifications');
+ // Check for notifications not processed at startup
+ Notifications.find({ webNotified: null }).forEach((notif) => {
+ process(notif);
+ });
+ }
+
+ Tracker.autorun(() => {
+ if (Meteor.userId()) {
+ Meteor.subscribe('mynotifications');
+ // console.log('Started notifications listener');
+ if (!Push.Permission.has()) {
+ Push.Permission.request(() => {
+ // on granted
+ Bert.alert(i18n.t('Perfecto, recibirás notificaciones de fuegos en este equipo y también por correo cuando no estés conectado'), 'success');
+ }, () => {
+ // on denied
+ Bert.alert(i18n.t('No recibirás notificaciones de fuegos en este equipo, solo por correo'), 'warning');
+ });
+ }
+ }
+ });
+});
diff --git a/imports/ui/components/NotificationsObserver/util.js b/imports/ui/components/NotificationsObserver/util.js
new file mode 100644
index 0000000..3a559be
--- /dev/null
+++ b/imports/ui/components/NotificationsObserver/util.js
@@ -0,0 +1,4 @@
+export const trim = function trimMess(message) {
+ // Removing utf8 suffix and prefix characters used in telegram but not useful here in browser
+ return message.replace(/^🔥 /, '').replace(/:$/, '');
+};
diff --git a/imports/ui/components/Reconnect/Reconnect.js b/imports/ui/components/Reconnect/Reconnect.js
index 114e238..3fa35b4 100644
--- a/imports/ui/components/Reconnect/Reconnect.js
+++ b/imports/ui/components/Reconnect/Reconnect.js
@@ -1,5 +1,7 @@
import React from 'react';
import { translate } from 'react-i18next';
+import { Meteor } from 'meteor/meteor';
+import { Tracker } from 'meteor/tracker';
import Blaze from 'meteor/gadicc:blaze-react-component';
const Reconnect = props => (
@@ -7,3 +9,12 @@ const Reconnect = props => (
);
export default translate([], { wait: true })(Reconnect);
+
+if (!Meteor.isProduction) {
+ // We clear the console on disconnect during development
+ Tracker.autorun(() => {
+ if (Meteor.status().status === 'waiting') {
+ // console.clear();
+ }
+ });
+}
diff --git a/imports/ui/layouts/App/App.js b/imports/ui/layouts/App/App.js
index 9343371..a2d84f1 100644
--- a/imports/ui/layouts/App/App.js
+++ b/imports/ui/layouts/App/App.js
@@ -4,7 +4,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Router, Switch, Route } from 'react-router-dom';
-import createHistory from 'history/createBrowserHistory';
import { Grid } from 'react-bootstrap';
import { I18nextProvider } from 'react-i18next';
import { Meteor } from 'meteor/meteor';
@@ -41,17 +40,13 @@ import Privacy from '../../pages/Privacy/Privacy';
import License from '../../pages/License/License';
import Credits from '../../pages/Credits/Credits';
import ReSendEmail from '../../components/ReSendEmail/ReSendEmail';
+import history from '../../components/History/History';
+import '../../components/NotificationsObserver/NotificationsObserver';
import FiresMap from '../../pages/FiresMap/FiresMap';
+import Fires from '../../pages/Fires/Fires';
import Sandbox from '../../pages/Sandbox/Sandbox';
import './App.scss';
-const history = createHistory();
-history.listen((location) => { // , action ) => {
- // console.log(location.pathname);
- // console.log(action); // PUSH, etc
- Meteor.Piwik.trackPage(location.pathname);
-});
-
const App = props => (
/* https://react.i18next.com/components/i18nextprovider.html */
@@ -72,9 +67,9 @@ const App = props => (
-
+
diff --git a/imports/ui/pages/Fires/Fires.js b/imports/ui/pages/Fires/Fires.js
new file mode 100644
index 0000000..8aeac94
--- /dev/null
+++ b/imports/ui/pages/Fires/Fires.js
@@ -0,0 +1,116 @@
+/* eslint-disable import/no-absolute-path */
+/* eslint-disable react/jsx-indent-props */
+/* eslint-disable react/jsx-indent */
+
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTracker } from 'meteor/react-meteor-data';
+import { translate } from 'react-i18next';
+import { Col, Row } from 'react-bootstrap';
+import { Meteor } from 'meteor/meteor';
+import { Map, CircleMarker, Circle } from 'react-leaflet';
+import DefMapLayers from '/imports/ui/components/Maps/DefMapLayers';
+import moment from 'moment';
+import FiresCollection from '/imports/api/Fires/Fires';
+
+class Fire extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ };
+ }
+
+ /* componentDidUpdate() {
+ * const map = this.firemap.leafletElement;
+ * map.invalidateSize();
+ * } */
+
+ handleLeafletLoad(map) {
+ console.log(map);
+ if (map) {
+ // map.leafletELement.invalidateSize();
+ // map.invalidateSize();
+ }
+ }
+
+ render() {
+ const { loading, fire, t } = this.props;
+ return (
+
+ {!loading &&
+
+
+ {t('Información sobre fuego detectado el día {{when}}', { when: moment(fire.when).format('LLLL') })}
+
+
+
+ {(fire.type === 'modis' || fire.type === 'virrs') &&
+ {t('Detectado por satélites de la NASA')}
+
+ }
+ Comentarios
+ {t('Añade un comentario si tienes información adicional sobre este fuego (por ejemplo, si está aún activo cómo acceder a él, o si conoces el motivo por el que comenzó el fuego, o si quieres denunciar algún tipo de ilegalidad relacionada con el fuego)')}
+
+
+
+
+
+
+
+ }
+
+ );
+ }
+}
+
+Fire.propTypes = {
+ t: PropTypes.func.isRequired,
+ loading: PropTypes.bool.isRequired,
+ fire: PropTypes.object // .isRequired
+};
+
+Fire.defaultProps = {
+};
+
+// export default translate([], { wait: true })(withTracker((props) => {
+
+const FireContainer = withTracker(({ match }) => {
+ const fireEncrypt = match.params.id;
+ const subscription = Meteor.subscribe('fireFromHash', fireEncrypt);
+ // console.log(`Subs ready: ${subscription.ready()}, fire: ${JSON.stringify(FiresCollection.findOne())}`);
+ return {
+ loading: !subscription.ready(),
+ fire: FiresCollection.findOne()
+ };
+})(Fire);
+
+// export default FireContainer;
+export default translate([], { wait: true })(FireContainer);
diff --git a/imports/ui/pages/ResetPassword/ResetPassword.js b/imports/ui/pages/ResetPassword/ResetPassword.js
index 9789b8b..dad8f1b 100644
--- a/imports/ui/pages/ResetPassword/ResetPassword.js
+++ b/imports/ui/pages/ResetPassword/ResetPassword.js
@@ -53,7 +53,7 @@ class ResetPassword extends React.Component {
if (error) {
Bert.alert(T9n.get(`error.accounts.${error.reason}`), 'danger');
} else {
- history.push('/documents');
+ history.push('/subscriptions');
}
});
}
diff --git a/imports/ui/pages/VerifyEmail/VerifyEmail.js b/imports/ui/pages/VerifyEmail/VerifyEmail.js
index 4db8bc1..db3dc91 100644
--- a/imports/ui/pages/VerifyEmail/VerifyEmail.js
+++ b/imports/ui/pages/VerifyEmail/VerifyEmail.js
@@ -19,12 +19,12 @@ class VerifyEmail extends React.Component {
Accounts.verifyEmail(match.params.token, (error) => {
if (error) {
Bert.alert(T9n.get(`error.accounts.${error.reason}`), 'danger');
- this.setState({ error: T9n.get(`error.accounts.${error.reason}`) + ". " + this.t("Por favor, inténtalo otra vez.")});
+ this.setState({ error: `${T9n.get(`error.accounts.${error.reason}`)}. ${this.t('Por favor, inténtalo otra vez.')}` });
// this.setState({ error: `${error.reason}. Please try again.` });
} else {
setTimeout(() => {
- Bert.alert(this.t("¡Listo, gracias!"), 'success');
- history.push('/documents');
+ Bert.alert(this.t('¡Listo, gracias!'), 'success');
+ history.push('/subscriptions');
}, 1500);
}
});
@@ -41,7 +41,7 @@ class VerifyEmail extends React.Component {
VerifyEmail.propTypes = {
match: PropTypes.object.isRequired,
- history: PropTypes.object.isRequired,
+ history: PropTypes.object.isRequired
};
export default translate([], { wait: true })(VerifyEmail);
diff --git a/package-lock.json b/package-lock.json
index 7cb53d5..2266327 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4741,6 +4741,11 @@
"resolved": "https://registry.npmjs.org/google-maps/-/google-maps-3.2.1.tgz",
"integrity": "sha1-wHWPKxdaSo0RDBq7H8TMOMEXbgY="
},
+ "google-maps-image-api-url": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/google-maps-image-api-url/-/google-maps-image-api-url-1.0.3.tgz",
+ "integrity": "sha1-2ezc4OnvS+pIGZD05ZC0slg8jbo="
+ },
"graceful-fs": {
"version": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
"integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg="
@@ -5175,6 +5180,39 @@
"integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=",
"dev": true
},
+ "iron": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/iron/-/iron-5.0.4.tgz",
+ "integrity": "sha512-7iQ5/xFMIYaNt9g2oiNiWdhrOTdRUMFaWENUd0KghxwPUhrIH8DUY8FEyLNTTzf75jaII+jMexLdY/2HfV61RQ==",
+ "requires": {
+ "boom": "7.1.1",
+ "cryptiles": "4.1.1",
+ "hoek": "5.0.2"
+ },
+ "dependencies": {
+ "boom": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/boom/-/boom-7.1.1.tgz",
+ "integrity": "sha512-qwEARHTliqgEQiVkzKkkbLt3q0vRPIW60VRZ8zRnbjsm7INkPe9NxfAYDDYLZOdhxyUHa1gIe639Cx7t6RH/4A==",
+ "requires": {
+ "hoek": "5.0.2"
+ }
+ },
+ "cryptiles": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-4.1.1.tgz",
+ "integrity": "sha512-YuQUPbcOmaZsdvxJZ25DCA1W+lLIRoPJKBDKin+St1RCYEERSfoe1d25B1MvWNHN3e8SpFSVsqYvEUjp8J9H2w==",
+ "requires": {
+ "boom": "7.1.1"
+ }
+ },
+ "hoek": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/hoek/-/hoek-5.0.2.tgz",
+ "integrity": "sha512-NA10UYP9ufCtY2qYGkZktcQXwVyYK4zK0gkaFSB96xhtlo6V8tKXdQgx8eHolQTRemaW0uLn8BhjhwqrOU+QLQ=="
+ }
+ }
+ },
"is-arrayish": {
"version": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=",
@@ -9607,6 +9645,11 @@
"version": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4="
},
+ "push.js": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/push.js/-/push.js-1.0.5.tgz",
+ "integrity": "sha512-RPnZ7tc6oTPWf2g4dCIxIRgaJUENjor9CbyDg/o0JlsYqLOM0AjwHOndT9woZkt+7KI1srB9J70d5i6xiPd/HQ=="
+ },
"qs": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz",
diff --git a/package.json b/package.json
index 7543af6..e233ae2 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"crypto-random-hex": "^1.0.0",
"fs": "0.0.1-security",
"google-maps": "^3.2.1",
+ "google-maps-image-api-url": "^1.0.3",
"handlebars": "^4.0.11",
"history": "^4.7.2",
"html5-device-mockups": "^3.2.0",
@@ -26,6 +27,7 @@
"i18next-xhr-backend": "^1.5.0",
"immutability-helper": "^2.5.1",
"indexof": "0.0.1",
+ "iron": "^5.0.4",
"jquery": "^2.2.4",
"jquery-validation": "^1.17.0",
"juice": "^4.2.2",
@@ -43,6 +45,7 @@
"nodemailer": "^4.4.1",
"popper.js": "^1.12.7",
"prop-types": "^15.6.0",
+ "push.js": "^1.0.5",
"rc-slider": "^8.5.0",
"rc-tooltip": "^3.7.0",
"react": "^16.0.0",
diff --git a/public/locales/es/common.json b/public/locales/es/common.json
index 6eb2925..43fc194 100644
--- a/public/locales/es/common.json
+++ b/public/locales/es/common.json
@@ -185,5 +185,9 @@
"Iniciar sesión con Telegram":
"Iniciar sesión con Telegram",
"Idioma":
- "Idioma"
+ "Idioma",
+ "fireDetected":
+ "fuego detectado {{when}}",
+ "fireDetectedAt":
+ "fuego detectado el {{when}}"
}
diff --git a/test/encode.test.js b/test/encode.test.js
new file mode 100644
index 0000000..4f29a2c
--- /dev/null
+++ b/test/encode.test.js
@@ -0,0 +1,62 @@
+/* eslint-env mocha */
+/* eslint-disable func-names, prefer-arrow-callback */
+
+import { chai } from 'meteor/practicalmeteor:chai';
+import ActiveFiresCollection from '/imports/api/ActiveFires/ActiveFires';
+import urlEnc from '/imports/modules/url-encode';
+
+describe('url encoding', () => {
+ it('should encrypt and dcrypt basic objects', async () => {
+ const obj = { lat: 2 };
+ const sealed = await urlEnc.encrypt(obj);
+ const unsealed = await urlEnc.decrypt(sealed);
+ chai.expect(unsealed).to.deep.equal(obj);
+ });
+
+ it('should encrypt and dcrypt objects with date', async () => {
+ const obj = { lat: 40.234503, lon: -3.350386, when: (new Date()).toString() };
+ const sealed = await urlEnc.encrypt(obj);
+ const unsealed = await urlEnc.decrypt(sealed);
+ chai.expect(unsealed).to.deep.equal(obj);
+ });
+
+ it('should encrypt complete objects', async () => {
+ const obj = {
+ ourid: {
+ type: 'Point',
+ coordinates: [
+ 24.813,
+ 9.223
+ ]
+ },
+ type: 'modis',
+ lat: 9.223,
+ lon: 24.813,
+ when: new Date('2017-12-13T00:00:00.000Z').toISOString(),
+ scan: 1,
+ track: 1,
+ satellite: 'A',
+ confidence: 60,
+ version: '6.0NRT',
+ frp: 6.5,
+ daynight: null,
+ brightness: 305.9,
+ bright_t31: 292.6
+ };
+
+ const sealed = await urlEnc.encrypt(obj);
+ console.log(encodeURI(sealed));
+
+ const unsealed = await urlEnc.decrypt(sealed);
+ chai.expect(unsealed).to.deep.equal(obj);
+ chai.expect(sealed).to.equal(encodeURI(sealed));
+ });
+
+ // This fails because Date is return as String (not as Date)
+ /* it('should encrypt and dcrypt collection objects', async () => {
+ const obj = ActiveFiresCollection.findOne();
+ const sealed = await urlEnc.encrypt(obj);
+ const unsealed = await urlEnc.decrypt(sealed);
+ chai.expect(unsealed).to.deep.equal(obj);
+ }); */
+});