From 3bf21c8caf390b1c6e6085de597638ecc998c15d Mon Sep 17 00:00:00 2001 From: vjrj Date: Tue, 16 Jan 2018 16:19:29 +0100 Subject: [PATCH] Added web and email notifications. Tests. Fire page --- .meteor/packages | 10 +- .meteor/versions | 18 +-- imports/api/ActiveFires/ActiveFires.js | 24 ++-- imports/api/Common/FiresSchema.js | 29 +++++ imports/api/Fires/Fires.js | 25 ++++ imports/api/Fires/methods.js | 22 ++++ imports/api/Fires/server/publications.js | 37 ++++++ imports/api/Notifications/Notifications.js | 39 ++++++ imports/api/Notifications/methods.js | 25 ++++ .../api/Notifications/server/publications.js | 10 ++ imports/api/Subscriptions/Subscriptions.js | 31 +---- .../api/Users/server/send-welcome-email.js | 10 +- imports/api/Utility/LocationSchema.js | 34 +++++ imports/modules/server/send-email.js | 8 +- imports/modules/url-encode.js | 6 + imports/startup/server/api.js | 7 ++ imports/startup/server/email.js | 8 +- imports/startup/server/i18n.js | 1 + imports/startup/server/index.js | 1 + .../startup/server/notificationsObserver.js | 89 ++++++++++++++ imports/ui/components/History/History.js | 12 ++ .../NotificationsObserver.js | 70 +++++++++++ .../components/NotificationsObserver/util.js | 4 + imports/ui/components/Reconnect/Reconnect.js | 11 ++ imports/ui/layouts/App/App.js | 13 +- imports/ui/pages/Fires/Fires.js | 116 ++++++++++++++++++ .../ui/pages/ResetPassword/ResetPassword.js | 2 +- imports/ui/pages/VerifyEmail/VerifyEmail.js | 8 +- package-lock.json | 43 +++++++ package.json | 3 + public/locales/es/common.json | 6 +- test/encode.test.js | 62 ++++++++++ 32 files changed, 696 insertions(+), 88 deletions(-) create mode 100644 imports/api/Common/FiresSchema.js create mode 100644 imports/api/Fires/Fires.js create mode 100644 imports/api/Fires/methods.js create mode 100644 imports/api/Fires/server/publications.js create mode 100644 imports/api/Notifications/Notifications.js create mode 100644 imports/api/Notifications/methods.js create mode 100644 imports/api/Notifications/server/publications.js create mode 100644 imports/api/Utility/LocationSchema.js create mode 100644 imports/modules/url-encode.js create mode 100644 imports/startup/server/notificationsObserver.js create mode 100644 imports/ui/components/History/History.js create mode 100644 imports/ui/components/NotificationsObserver/NotificationsObserver.js create mode 100644 imports/ui/components/NotificationsObserver/util.js create mode 100644 imports/ui/pages/Fires/Fires.js create mode 100644 test/encode.test.js 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)')} +

+ + + { + this.fireMap = map; + this.handleLeafletLoad(map); + }} + animate + sleep={false} + center={[fire.lat, fire.lon]} + zoom={8} + > + + + + + + + + + + } + + ); + } +} + +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); + }); */ +});