diff options
Diffstat (limited to 'vnext/server')
-rw-r--r-- | vnext/server/common/MessageUtils.js | 66 | ||||
-rw-r--r-- | vnext/server/common/MessageUtils.spec.js | 47 | ||||
-rw-r--r-- | vnext/server/common/__snapshots__/MessageUtils.spec.js.snap | 17 | ||||
-rw-r--r-- | vnext/server/http.js | 46 | ||||
-rw-r--r-- | vnext/server/index.js | 6 | ||||
-rw-r--r-- | vnext/server/middleware/event.js | 76 | ||||
-rw-r--r-- | vnext/server/middleware/oembed.js | 48 | ||||
-rw-r--r-- | vnext/server/middleware/urlexpand.js | 14 | ||||
-rw-r--r-- | vnext/server/sender.js | 136 |
9 files changed, 456 insertions, 0 deletions
diff --git a/vnext/server/common/MessageUtils.js b/vnext/server/common/MessageUtils.js new file mode 100644 index 00000000..f766faf2 --- /dev/null +++ b/vnext/server/common/MessageUtils.js @@ -0,0 +1,66 @@ +/** + * check if message is PM + * + * @param {import('../../src/api').Message} msg message + */ + export function isPM(msg) { + return !msg.mid; +} + +/** + * check if message is reply + * + * @param {import('../../src/api').Message} msg message + */ +export function isReply(msg) { + return msg.rid && msg.rid > 0; +} + +/** + * check if message is service one + * + * @param {import('../../src/api').Message} msg message + */ +export function isService(msg) { + return msg.service && msg.service; +} + +/** + * format notification title + * + * @param {import('../../src/api').Message} msg message + * @returns {string} formatted title + */ +export function formatTitle(msg) { + if (isReply(msg)) { + return `Reply by ${msg.user.uname}:`; + } else if (isPM(msg)) { + return `Private message from ${msg.user.uname}:`; + } + return `${msg.user.uname}`; +} + +/** + * format notification quote + * + * @param { import('../../src/api').Message } msg message + * @returns {string} formatted quote line + */ +export function formatQuote(msg) { + if (isReply(msg)) { + return msg.replyQuote || ''; + } else if (isPM(msg)) { + return ''; + } + return (msg.tags || []).map(t => `*${t}`).join(', '); +} + +/** + * format notification body + * + * @param {import('../../src/api').Message} msg message + * @returns {string} formatted body + */ +export function formatMessage(msg) { + return msg.body || 'Sent an image'; +} diff --git a/vnext/server/common/MessageUtils.spec.js b/vnext/server/common/MessageUtils.spec.js new file mode 100644 index 00000000..766b1882 --- /dev/null +++ b/vnext/server/common/MessageUtils.spec.js @@ -0,0 +1,47 @@ +import { formatTitle, formatMessage, formatQuote } from './MessageUtils'; + +describe('Message formatting', () => { + it('Blog message', () => { + let msg = { + 'mid': 1, + 'user': { + 'uid': 1, + 'uname': 'ugnich' + }, + 'tags': [ + 'yo', + 'people' + ], + 'body': 'The message' + }; + expect(formatTitle(msg)).toMatchSnapshot(); + expect(formatQuote(msg)).toMatchSnapshot(); + expect(formatMessage(msg)).toMatchSnapshot(); + }); + it('Reply message', () => { + let msg = { + 'mid': 1, + 'rid': 1, + 'user': { + 'uid': 1, + 'uname': 'ugnich' + }, + 'replyQuote': '> The message', + 'body': 'The reply' + }; + expect(formatTitle(msg)).toMatchSnapshot(); + expect(formatQuote(msg)).toMatchSnapshot(); + expect(formatMessage(msg)).toMatchSnapshot(); + }); + it('PM', () => { + let msg = { + 'user': { + 'uid': 1, + 'uname': 'ugnich' + }, + 'body': 'The PM' + }; + expect(formatTitle(msg)).toMatchSnapshot(); + expect(formatMessage(msg)).toMatchSnapshot(); + }); +}); diff --git a/vnext/server/common/__snapshots__/MessageUtils.spec.js.snap b/vnext/server/common/__snapshots__/MessageUtils.spec.js.snap new file mode 100644 index 00000000..ea58aebc --- /dev/null +++ b/vnext/server/common/__snapshots__/MessageUtils.spec.js.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Message formatting Blog message 1`] = `"ugnich"`; + +exports[`Message formatting Blog message 2`] = `"*yo, *people"`; + +exports[`Message formatting Blog message 3`] = `"The message"`; + +exports[`Message formatting PM 1`] = `"Private message from ugnich:"`; + +exports[`Message formatting PM 2`] = `"The PM"`; + +exports[`Message formatting Reply message 1`] = `"Reply by ugnich:"`; + +exports[`Message formatting Reply message 2`] = `"> The message"`; + +exports[`Message formatting Reply message 3`] = `"The reply"`; diff --git a/vnext/server/http.js b/vnext/server/http.js new file mode 100644 index 00000000..74c51b0a --- /dev/null +++ b/vnext/server/http.js @@ -0,0 +1,46 @@ +import axios from 'axios'; +var debug = require('debug')('http'); + + +/** + * @external Promise + * @see {@link http://api.jquery.com/Types/#Promise Promise} + */ +/** + * fetch message subscribers + * + * @param {URLSearchParams} params - request params + * @returns {Promise<import('../client').SecureUser[]>} subscribers list + */ +export function subscribers(params) { + return new Promise((resolve, reject) => { + client.get(`/notifications?${params.toString()}`) + .then(response => { + resolve(response.data); + }) + .catch(reason => { reject(reason); }); + }); +} + +/** + * delete invalid tokens + * + * @param {import('../client').Token[]} tokens tokens + */ +export const deleteSubscribers = async (tokens) => { + const response = await client.post('/notifications/delete', tokens); + return response.data; +}; + +const baseURL = process.env.JUICK_SERVICE_URL; +const user = process.env.JUICK_SERVICE_USER; +const password = process.env.JUICK_SERVICE_PASSWORD; + +/** @type { import('axios').AxiosInstance } */ +let client = axios.create({ + baseURL: baseURL, + headers: user ? { + 'Authorization': 'Basic ' + Buffer.from(user + ':' + password).toString('base64') + } : {} +}); +debug(`HTTP client initialized with base URL ${baseURL} ${ user? `and ${user} user` : ''}`); diff --git a/vnext/server/index.js b/vnext/server/index.js index 2b696977..278a4f4a 100644 --- a/vnext/server/index.js +++ b/vnext/server/index.js @@ -2,6 +2,9 @@ import express from 'express'; // we'll talk about this in a minute: import serverRenderer from './middleware/renderer'; +import event from './middleware/event'; +import oembed from './middleware/oembed'; +import urlExpand from './middleware/urlexpand'; const PORT = 3000; const path = require('path'); @@ -10,6 +13,9 @@ const path = require('path'); const app = express(); const router = express.Router(); +router.use('/api/sender', event); +router.use('/api/oembed', oembed); +router.use('/api/urlexpand', urlExpand); router.use('^/$', serverRenderer); // other static resources should just be served as they are diff --git a/vnext/server/middleware/event.js b/vnext/server/middleware/event.js new file mode 100644 index 00000000..29bede76 --- /dev/null +++ b/vnext/server/middleware/event.js @@ -0,0 +1,76 @@ +import { simpleParser } from 'mailparser'; +import { isPM, isReply, isService } from '../common/MessageUtils'; +import { subscribers } from '../http'; +import { sendNotification, buildNotification } from '../sender'; +var debug = require('debug')('event'); + +/** @type {number[]} */ +const allSandboxIds = []; + +/** + * handle message event + * + * @param {import('../../client').Message} msg message + */ +function processMessageEvent(msg) { + let params = {}; + params.uid = isPM(msg) ? msg.to.uid : msg.user.uid; + if (isReply(msg)) { + params.mid = msg.mid; + params.rid = msg.rid; + } else if (!isPM(msg) && !isService(msg)) { + params.mid = msg.mid; + } + subscribers(new URLSearchParams(JSON.parse(JSON.stringify(params)))).then(users => { + users.forEach(user => { + debug(`${user.uname}: ${user.unreadCount}`); + let [sandboxTokens, productionTokens] = (user.tokens || []) + .filter(t => ['mpns', 'apns', 'gcm'].includes(t.type)) + .map(t => t.token) + .reduce((result, element, i) => { + allSandboxIds.includes(user.uid) + ? result[0].push(element) + : result[1].push(element); + return result; + }, [[], []]); + sendNotification(productionTokens, sandboxTokens, buildNotification(user, msg)); + }); + }).catch(console.error); +} + +/** + * Handle new events + * + * @param {import("next").NextApiRequest} req + * @param {import("next").NextApiResponse} res + */ +export default function handleMessage(req, res) { + return simpleParser(req.body, {}) + .then(parsed => { + const new_version = parsed.headers.get('x-event-version') == '1.0'; + debug(`New event: ${parsed.text}, new version: ${new_version}`); + if (new_version) { + /** @type {import('../../client').SystemEvent} */ + const event = JSON.parse(parsed.text || ''); + if (event.type === 'message' && event.message) { + if (event.message.service) { + // TODO: remove + let msg = { ...event.message }; + if (event.from) { + msg.user = event.from; + } + processMessageEvent(msg); + } else { + processMessageEvent(event.message); + } + + } + } else { + /** @type {import('../../client').Message} */ + const msg = JSON.parse(parsed.text || ''); + processMessageEvent(msg); + } + res.end(); + }) + .catch(err => { console.error(err); res.status(400).send('Invalid request'); }); +} diff --git a/vnext/server/middleware/oembed.js b/vnext/server/middleware/oembed.js new file mode 100644 index 00000000..ad23d9e2 --- /dev/null +++ b/vnext/server/middleware/oembed.js @@ -0,0 +1,48 @@ +import { embeddedTweet } from '../../src/api'; +import Cors from 'cors'; + +// Initializing the cors middleware +// You can read more about the available options here: https://github.com/expressjs/cors#configuration-options +const cors = Cors({ + methods: ['POST', 'GET', 'HEAD'], +}); + +/** + * Helper method to wait for a middleware to execute before continuing + * And to throw an error when an error happens in a middleware + * + * @param {import("next").NextApiRequest} req + * @param {import("next").NextApiResponse} res + * @param { Function } fn + */ +function runMiddleware(req, res, fn) { + return new Promise((resolve, reject) => { + fn(req, res, (result) => { + if (result instanceof Error) { + return reject(result); + } + return resolve(result); + }); + }); +} +/** + * Return content for embedding. + * + * @param {import("next").NextApiRequest} req + * @param {import("next").NextApiResponse} res + */ +const oembed = async (req, res) => { + let url = (req.query.url || '').toString(); + await runMiddleware(req, res, cors); + return embeddedTweet(url).then(result => { + res.send(result); + res.end(); + }).catch(err => { + console.log(`Err: ${err.response.status}`); + res.status(err.response.status); + res.send({}); + res.end(); + }); +}; + +export default oembed; diff --git a/vnext/server/middleware/urlexpand.js b/vnext/server/middleware/urlexpand.js new file mode 100644 index 00000000..730e2e8f --- /dev/null +++ b/vnext/server/middleware/urlexpand.js @@ -0,0 +1,14 @@ +import { expandShortenedLink } from '../../src/api'; + +/** + * Expand URLs + * + * @param {import("next").NextApiRequest} req + * @param {import("next").NextApiResponse} res + */ +export default function urlExpand(req, res) { + let url = (req.query.url || '').toString(); + return expandShortenedLink(url).then(result => { + res.json(result); + }); +} diff --git a/vnext/server/sender.js b/vnext/server/sender.js new file mode 100644 index 00000000..6d88c4c0 --- /dev/null +++ b/vnext/server/sender.js @@ -0,0 +1,136 @@ +import PushNotifications from 'node-pushnotifications'; +var debug = require('debug')('sender'); +import { deleteSubscribers } from './http'; +import { formatMessage, formatTitle, formatQuote } from './common/MessageUtils'; + +const apnConfig = (production = true) => { + const apn = { + token: { + key: process.env.JUICK_APN_KEY, + keyId: process.env.JUICK_APN_KEY_ID, + teamId: process.env.JUICK_APN_TEAM_ID + }, + production: production + }; + return apn; +}; +const gcmConfig = { + id: process.env.JUICK_GCM_ID +}; + +const push = new PushNotifications({ + apn: apnConfig(true), + gcm: gcmConfig, +}); +const sandbox = new PushNotifications({ + apn: apnConfig(false), + gcm: gcmConfig +}); + +/** @type {string} */ +const application = process.env.JUICK_APN_APPLICATION || ''; + + +/** + * send notification + * + * @param {PushNotifications.RegistrationId[]} productionIds + * @param {PushNotifications.RegistrationId[]} sandboxIds + * @param {PushNotifications.Data} data + */ +export function sendNotification(productionIds, sandboxIds, data) { + [productionIds, sandboxIds].map((registrationIds, index) => { + let sender = index == 0 ? push : sandbox; + if (registrationIds && registrationIds.length) { + sender.send(registrationIds, data) + .then((results) => { + results.forEach(result => { + debug(`${result.method}: ${result.success} success, ${result.failure} failure`); + if (result.failure) { + console.error(`${result.method} failure: ${JSON.stringify(result)}`); + console.error(`Failed data: ${JSON.stringify(data)}`); + } + }); + results.filter(r => r.method === 'apn') + .forEach(async r => { + debug(`Response message: ${JSON.stringify(r.message)}`); + let badTokens = r.message.filter(m => m.errorMsg === 'BadDeviceToken').map(m => { + return { 'type': 'apns', 'token': m.regId }; + }); + if (badTokens.length > 0) { + await deleteSubscribers(badTokens); + debug(`${badTokens.length} APNS tokens deleted`); + } + }); + results.filter(r => r.method === 'gcm') + .forEach(async r => { + let badTokens = r.message.filter(m => m.errorMsg === 'NotRegistered' || m.errorMsg === 'MismatchSenderId').map(m => { + return { 'type': 'gcm', 'token': m.regId }; + }); + if (badTokens.length > 0) { + await deleteSubscribers(badTokens); + debug(`${badTokens.length} GCM tokens deleted`); + } + }); + results.filter(r => r.method === 'mpns') + .forEach(async r => { + let badTokens = r.message.filter(m => m.errorMsg === 'The channel expired.').map(m => { + return { 'type': 'mpns', 'token': m.regId }; + }); + if (badTokens.length > 0) { + await deleteSubscribers(badTokens); + debug(`${badTokens.length} MPNS tokens deleted`); + } + }); + }) + .catch((err) => { console.error(JSON.stringify(err)); }); + } + }); +} + +/** + * builds notification object + * + * @param {import('../client').SecureUser} user user + * @param {import('../client').Message} msg message + * @returns {PushNotifications.Data} notification template + */ +export function buildNotification(user, msg) { + let template = { + topic: application, + custom: { + message: msg + }, + timeToLive: 0 + }; + let { tokens, ...subscriber } = user; + if (msg.service) { + template.contentAvailable = true; + template.custom.service = true; + template.custom.user = subscriber; + } else { + const avatar = `https://i.juick.com/a/${msg.user.uid}.png`; + const title = formatTitle(msg); + const body = `${formatQuote(msg)}\n${formatMessage(msg)}`; + template.custom.mid = msg.mid; + template.custom.rid = msg.rid; + template.custom.uname = msg.user.uname; + template.custom.avatarUrl = avatar; + template.image1src = avatar; + template.text1 = title; + template.text2 = body; + template.title = title; + template.body = body; + template.badge = user.unreadCount || 0; + template.mutableContent = 1; + template.color = '#3c77aa'; + template.icon = 'ic_notification'; + template.clickAction = 'com.juick.NEW_EVENT_ACTION'; + const tag = msg.mid == 0 ? msg.user.uname : msg.mid; + template.tag = `${tag}`; + template.android_channel_id = 'default'; + } + // FIXME: wrong type definition in node-pushnotifications: title and body and not required for silent pushes + // @ts-ignore + return template; +} |