From 852b4d626cddac12fa8097be6bcff183f228bf52 Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Thu, 18 May 2017 23:26:05 +0200 Subject: okpush --- index.js | 34 ++- lib/okpush/apn.js | 62 +++++ lib/okpush/db.js | 156 +++++++++++ lib/okpush/index.js | 136 ++++++++++ lib/okpush/package.json | 21 ++ lib/okpush/public/push.js | 34 +++ lib/okpush/templates/index.liquid | 76 ++++++ lib/okpush/templates/partials/flash.liquid | 20 ++ lib/okpush/templates/partials/head.liquid | 13 + lib/okpush/templates/partials/inputs.liquid | 395 ++++++++++++++++++++++++++++ lib/okpush/templates/partials/tail.liquid | 14 + 11 files changed, 957 insertions(+), 4 deletions(-) create mode 100644 lib/okpush/apn.js create mode 100644 lib/okpush/db.js create mode 100644 lib/okpush/index.js create mode 100644 lib/okpush/package.json create mode 100644 lib/okpush/public/push.js create mode 100644 lib/okpush/templates/index.liquid create mode 100644 lib/okpush/templates/partials/flash.liquid create mode 100644 lib/okpush/templates/partials/head.liquid create mode 100644 lib/okpush/templates/partials/inputs.liquid create mode 100644 lib/okpush/templates/partials/tail.liquid diff --git a/index.js b/index.js index 2423a3b..a7efbdb 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,6 @@ var okcms = require('okcms') -var isProduction = process.env.OK_PRODUCTION === 'true' - -var viewConfig = { - template: 'index', +var isProduction = process.env.OK_PRODUCTION === 'true' var viewConfig = { template: 'index', data: [ {type: 'timeline', query: '*'}, {type: 'page', query: '*'}, @@ -113,6 +110,35 @@ var app = okcms.createApp({ from: 'Hansel and Gretel ', subject: 'We Found Your Face', }, + push: { + lib: require("./lib/okpush"), + mongodbUrl: "mongodb://localhost/okpush_hga", + production: true, + apn_development: { + cert: path.join(__dirname, "./lib/okpush/certs/aps_development_cert.pem"), + key: path.join(__dirname, "./lib/okpush/certs/aps_development_key.pem"), + connection: { + gateway: "gateway.sandbox.push.apple.com", + } + }, + apn_production: { + cert: path.join(__dirname, "./lib/okpush/certs/aps_production_cert.pem"), + key: path.join(__dirname, "./lib/okpush/certs/aps_production_key.pem"), + connection: { + gateway: "gateway.push.apple.com", + } + }, + bundleId: "studio.undisclosed.hanselandgretel", + notifications: { + // expiry (in seconds) + // badge (int) + // alert (message) + // payload (raw json) + feed: { + alert: "Database updated.", + }, + } + }, }, }).listen(process.env.PORT || 1337) diff --git a/lib/okpush/apn.js b/lib/okpush/apn.js new file mode 100644 index 0000000..c143001 --- /dev/null +++ b/lib/okpush/apn.js @@ -0,0 +1,62 @@ + +var apn = require('apn') +var db = require('./db') +var apnProvider, apnFeedback + +function init (data) { + var apn_config = data.production ? data.apn_production : data.apn_development + config = {} + config.key = apn_config.key + config.cert = apn_config.cert + config.production = data.production + apnProvider = new apn.Provider(config) +} + +function push (channel, note) { + db.getAllIOSTokens(channel, function(err, tokens){ + if (err) { + console.error("Error fetching devices:", err) + return + } + // console.log(note, tokens) + apnProvider.send(note, tokens).then( function (response) { + // response.sent.forEach( function (token) { + // notificationSent(user, token) + // }) + response.failed.forEach( function (failure) { + if (failure.error) { + // A transport-level error occurred (e.g. network problem) + // notificationError(user, token, failure.error); + } else if (failure.status == 410) { + // `failure.status` is the HTTP status code + // `failure.response` is the JSON payload + // notificationFailed(token, failure.status, failure.response); + console.log(failure) + // db.removeToken(token) + } + }) + }) + }) +} + +function buildPayload (options, bundleId) { + var note = new apn.Notification() + note.topic = bundleId + if (options.expiry) + note.expiry = Math.floor(Date.now() / 1000) + options.expiry + if (options.alert) + note.alert = options.alert + if (options.badge) + note.badge = options.badge + if (options.payload) + note.payload = options.payload + if (options.sound) + note.sound = options.sound + return note +} + +module.exports = { + init: init, + push: push, + buildPayload: buildPayload +} diff --git a/lib/okpush/db.js b/lib/okpush/db.js new file mode 100644 index 0000000..09afdf3 --- /dev/null +++ b/lib/okpush/db.js @@ -0,0 +1,156 @@ +var mongoose = require('mongoose') +var findOrCreate = require('mongoose-findorcreate') +var _ = require('lodash') +var db, PushToken, Notification, Channel + +mongoose.Promise = require('bluebird') + +function init (config) { + db = mongoose.connect(config.mongodbUrl) + mongoose.connection.on('error', errorHandler) + + var pushTokenSchema = new db.Schema({ + platform: { + type: 'String', + required: true, + lowercase: true, + enum: ['ios', 'android'], + }, + token: { + type: 'String', + required: true, + unique: true, + }, + channel: { + type: 'String', + required: true, + }, + }) + + var channelSchema = new db.Schema({ + channel: { + type: 'String', + required: true, + lowercase: true, + }, + last_push: { + type: 'Date', + required: true, + } + }) + channelSchema.plugin(findOrCreate); + + var notificationSchema = new db.Schema({ + channel: { + type: 'String', + required: true, + lowercase: true, + }, + date: { + type: 'Date', + required: true, + } + }) + + module.exports.PushToken = PushToken = db.model('PushToken', pushTokenSchema) + module.exports.Notification = Notification = db.model('Notification', notificationSchema) + module.exports.Channel = Channel = db.model('Channel', channelSchema) +} + +function errorHandler (error) { + console.error('ERROR: ' + error) +} + +/* devices / tokens */ + +function addToken (data) { + return new PushToken(data).save() +} +function getAllIOSTokens (channel, cb) { + PushToken.distinct("token", { channel: channel, platform: 'ios' }, function (err, tokens) { + if (err) return cb(err, []) + return cb(null, tokens) + }) +} +function getAllAndroidTokens (channel, cb) { + PushToken.distinct("token", { channel: channel, platform: 'android' }, function (err, tokens) { + if (err) return cb(err, []) + return cb(null, tokens) + }) +} +function getAllTokens (channel, cb) { + PushToken.find({ channel: channel }, function (err, items) { + if (err) return cb(err, []) + var items = items.map(function (item) { + return { platform: item.get('platform'), token: item.get('token') } + }) + return cb(null, items) + }) +} +function removeToken (data) { + PushToken.find(data).remove().exec() +} +function getDeviceCount (channels, cb) { + var countz = {} + get_next() + function get_next() { + var channel = channels.pop() + PushToken.distinct("token", { channel: channel }, function(err, tokens){ + countz[channel] = tokens.length + if (channels.length) { + return get_next() + } + else { + cb(countz) + } + }) + } +} + +/* notifications */ + +function addNotification (channel, cb) { + var now = new Date + Channel.findOrCreate({channel: channel}, {last_push: now}, function(err, note, created) { + if (err) { + console.error("Error finding/creating notification", err) + cb(err, false) + return + } + else if (! created) { + note.last_push = now + note.save() + } + cb(null, note) + }) + new Notification ({ + channel: channel, + date: now, + }).save() +} + +function getNotifications (cb) { + Notification.find(function (err, items) { + if (err) return cb(err, null) + + var items = _.map(items, function (item) { + return _.pick(item, ['channel', 'last_push']) + }) + + return cb(null, items) + }) +} + +/* wrap functions for some reason */ + +module.exports = { + init: init, + addToken: addToken, + removeToken: removeToken, + getAllTokens: getAllTokens, + getAllIOSTokens: getAllIOSTokens, + getAllAndroidTokens: getAllAndroidTokens, + getDeviceCount: getDeviceCount, + addNotification: addNotification, + getNotifications: getNotifications, +} diff --git a/lib/okpush/index.js b/lib/okpush/index.js new file mode 100644 index 0000000..36588aa --- /dev/null +++ b/lib/okpush/index.js @@ -0,0 +1,136 @@ +/** + * OKPush - Handles basic broadcast push notifications, as well as keeping track of tokens. + */ + +var path = require('path') +var passport = require('passport') +var DigestStrategy = require('passport-http').DigestStrategy; +var bodyParser = require('body-parser') +var OKTemplate = require('../../node_modules/okcms/app/node_modules/oktemplate') +var apn = require('./apn') +var db = require('./db') + +passport.use(new DigestStrategy({qop: 'auth'}, function authenticate(username, done) { + if (!process.env.OK_USER || !process.env.OK_PASS) { + return done(new Error('No user or pass configured on server')) + } else { + return done(null, process.env.OK_USER, process.env.OK_PASS) + } +})) + +function OKPush (options) { + if (!(this instanceof OKPush)) return new OKPush(options) + options = options || {} + if (!options.express) + throw new Error('Express not provided to OKPush') + if (!options.config) + throw new Error('Configuration not provided to OKPush') + if (!options.config.notifications) + throw new Error('Notifications not provided to OKPush') + if (!options.config.bundleId) + throw new Error('bundleId not provided to OKPush') + if (!options.config.mongodbUrl) + throw new Error('mongodbUrl not provided to OKPush') + + var express = options.express + var router = express.Router() + var config = options.config + var meta = options.meta + var error = options.errorHandler + // var okcms_db = options.db + + var templateProvider = this._templateProvider = new OKTemplate({ + root: path.join(__dirname, './templates'), + debug: meta.debug + }) + + var templates = {} + templates['index'] = templateProvider.getTemplate('index') + + apn.init(config) + db.init(config) + + router.use('/admin/', passport.initialize()) + router.use('/public/', express.static(path.join(__dirname, './public'))); + + // monkeypatch because of app.use(router) .. obnoxious + router.all('/admin/(:path*)?', function (req, res, next) { + console.log(req.url) + req.newUrl = req.url + req.url = req.originalUrl + next() + }) + router.all('/admin/(:path*)?', passport.authenticate('digest', { + session: false + })) + router.all('/admin/(:path*)?', function (req, res, next) { + req.url = req.newUrl + next() + }) + + // pass in admin middleware! + router.get('/admin', function (req, res) { + db.getNotifications(function(err, notes){ + var channels = Object.keys(config.notifications) + db.getDeviceCount(channels, function(count){ + var data = { + meta: meta, + notifications: config.notifications, + } + notes.forEach(function(note){ + if (note.key in data.notifications) { + data.notifications[ note.key ].last_push = note.last_push + } + }) + Object.keys(count).forEach(function(key){ + data.notifications[ key ].count = count[key] + }) + templates['index'].render(data).then(function(rendered) { + res.send(rendered); + }).fail(error(req, res, 500)) + }) + }) + }) + + router.get('/list', function(req, res){ + db.getAllTokens("hub", function(err, hubz){ + res.json(hubz) + }) + }) + + router.post('/send', bodyParser.urlencoded({ extended: false }), function (req, res) { + var channel = req.body.channel + var opt = options.config.notifications[channel] + var note = apn.buildPayload(opt, options.config.bundleId) + apn.push(channel, note) + db.addNotification(channel, function(){ + res.sendStatus(200) + }) + }) + + // should work without middleware + router.post('/add', bodyParser.urlencoded({ extended: false }), function (req, res) { + db.addToken({ + token: req.body.registrationId, + channel: req.body.channel, + platform: req.body.platform, + }) + res.sendStatus(200) + }) + + router.post('/remove', bodyParser.urlencoded({ extended: false }), function (req, res) { + db.removeToken({ + token: req.body.registrationId, + channel: req.body.channel, + }) + res.sendStatus(200) + }) + + this._router = router +} + +OKPush.prototype.middleware = function () { + return this._router +} + +module.exports = OKPush diff --git a/lib/okpush/package.json b/lib/okpush/package.json new file mode 100644 index 0000000..87ca92c --- /dev/null +++ b/lib/okpush/package.json @@ -0,0 +1,21 @@ +{ + "name": "okpush", + "version": "1.0.0", + "description": "push notification service", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "okfocus ", + "license": "LNT", + "dependencies": { + "apn": "^2.1.1", + "bluebird": "^3.4.6", + "body-parser": "^1.15.2", + "lodash": "^4.16.3", + "mongoose": "^4.6.2", + "mongoose-findorcreate": "^0.1.2", + "passport": "^0.3.2", + "passport-http": "^0.3.0" + } +} diff --git a/lib/okpush/public/push.js b/lib/okpush/public/push.js new file mode 100644 index 0000000..e75fc7f --- /dev/null +++ b/lib/okpush/public/push.js @@ -0,0 +1,34 @@ +$(function(){ + var confirm_msg = "This will send the notification {{key}} to {{count}} people. Click OK to confirm."; + $(".notifications button").click(function(){ + var $el = $(this) + var data = $el.data() + var msg = confirm_msg.replace("{{key}}", capitalize(data.key)).replace("{{count}}", data.count) + if (! confirm(msg)) return + $.ajax({ + type: "POST", + url: "/_services/push/send", + data: { channel: data.key }, + success: function(){ + alert("Push notification sent.") + var now = new Date() + // "%a %d-%b-%Y %H:%M" + var months = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ") + var days = "Sun Mon Tue Wed Thu Fri Sat".split(" ") + var date = days[ now.getDay() ] + date += " " + now.getDate() + date += "-" + months[now.getMonth()] + date += "-" + now.getFullYear() + date += " " + now.getHours() + var mins = now.getMinutes() + if (mins < 10) mins = "0" + mins + date += ":" + mins + $el.closest("tr").find(".notification-date").html(date) + } + }) + }) +}) + +function capitalize (string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} \ No newline at end of file diff --git a/lib/okpush/templates/index.liquid b/lib/okpush/templates/index.liquid new file mode 100644 index 0000000..124fb4e --- /dev/null +++ b/lib/okpush/templates/index.liquid @@ -0,0 +1,76 @@ +{% include 'partials/head' %} + +{% include 'partials/flash' %} + + + + + +
+ +

Push Notifications

+ + + + + + + + + + {% for pair in notifications %} + {% assign name = pair[0] %} + {% assign spec = pair[1] %} + + + + + + + + {% endfor %} +
KeyMessageLast PushTokens
+ {{name | capitalize}} + + {{spec.alert}} + + {% unless spec.last_push %} + Never + {% else %} + {{ spec.last_push | date: "%a %d-%b-%Y %H:%M" }} + {% endunless %} + + {{spec.count}} + + +
+ +
+ +{% include 'partials/tail' %} + + \ No newline at end of file diff --git a/lib/okpush/templates/partials/flash.liquid b/lib/okpush/templates/partials/flash.liquid new file mode 100644 index 0000000..e51a86b --- /dev/null +++ b/lib/okpush/templates/partials/flash.liquid @@ -0,0 +1,20 @@ +{% if success.length > 0 %} +
+
Changes saved.
+ +
+{% endif %} + +{% if errors.length > 0 %} +
+ {% for error in errors %} +
+
{{error.message}}
+
+ {% endfor %} +
+{% endif %} \ No newline at end of file diff --git a/lib/okpush/templates/partials/head.liquid b/lib/okpush/templates/partials/head.liquid new file mode 100644 index 0000000..e9c27dc --- /dev/null +++ b/lib/okpush/templates/partials/head.liquid @@ -0,0 +1,13 @@ + + + + + {{meta.project}} Admin + + + +
+ {{meta.project}} Admin + View Site +
+
\ No newline at end of file diff --git a/lib/okpush/templates/partials/inputs.liquid b/lib/okpush/templates/partials/inputs.liquid new file mode 100644 index 0000000..60466de --- /dev/null +++ b/lib/okpush/templates/partials/inputs.liquid @@ -0,0 +1,395 @@ +{% for pair in resource.spec %} + {% assign name = pair[0] %} + {% assign spec = pair[1] %} + {% assign type = spec.type %} + +
+ + + {% if type == 'string' %} + + {% elsif type == 'text' %} + + {% elsif type == 'number' %} + + {% elsif type == 'enum' or type == 'foreign-key' %} + + {% elsif type == 'video' %} +
+ + + + + + + + + +
+ {% elsif type == 'image' %} +
+
+
+ + +
+ +
+
+ + + + + {{spec.value.caption | escape}} + +
+
+ + {% elsif type == 'date' %} + +
+ +
+ + {% elsif type == 'flag' %} + +
+ +
+ + {% elsif type == 'tag-list' %} +
+ +
+ + {% elsif type == 'link-list' %} + + + {% elsif type == 'media-list' or type == 'media' %} +
+
+
+ + +
+ +
+ + + + + + + + + +
    + {% for image in spec.value %} + {% if image.type and (image.type == "vimeo" or image.type == "youtube" or image.type == "video") %} +
  1. +
    + + + + + + + + + + + + + + + +
    + + +
  2. + {% elsif image.type and (image.type == "audio" or image.type == "soundcloud") %} +
  3. +
    + + + + + + + + +
    + + +
  4. + {% elsif image.type and image.type == "link" %} + + {% else %} +
  5. + + + + + + + {{image.caption | strip_html}} + +
  6. + {% endif %} + {% endfor %} +
+
+ {% elsif type == 'captioned-image-list' or type == 'gallery' %} +
+
+
+ + +
+ +
+ + + +
    + {% for image in spec.value %} +
  1. + + + + + {{image.caption | strip_html}} + +
  2. + {% endfor %} +
+
+ {% elsif type == 'double-captioned-image-list' %} +
+
+
+ + +
+ +
+ + + +
    + {% for image in spec.value %} +
  1. + {{image.caption | strip_html}} + + + + + + +
  2. + {% endfor %} +
+
+ {% elsif type == 'triple-captioned-image-list' %} +
+
+
+ + +
+ +
+ + + +
    + {% for image in spec.value %} +
  1. + {{image.caption | strip_html}} + + + + + + + +
  2. + {% endfor %} +
+
+ {% elsif type == 'meta' %} + + {% else %} +

Admin template doesn't support '{{type}}' properties!

+ {% endif %} +
+ +{% endfor %} diff --git a/lib/okpush/templates/partials/tail.liquid b/lib/okpush/templates/partials/tail.liquid new file mode 100644 index 0000000..522023b --- /dev/null +++ b/lib/okpush/templates/partials/tail.liquid @@ -0,0 +1,14 @@ +
{% comment %} closes container tag {% endcomment %} +
+
+ + + + + + + + -- cgit v1.2.3-70-g09d2