aboutsummaryrefslogtreecommitdiff
path: root/vnext/server
diff options
context:
space:
mode:
Diffstat (limited to 'vnext/server')
-rw-r--r--vnext/server/common/MessageUtils.js66
-rw-r--r--vnext/server/common/MessageUtils.spec.js47
-rw-r--r--vnext/server/common/__snapshots__/MessageUtils.spec.js.snap17
-rw-r--r--vnext/server/http.js46
-rw-r--r--vnext/server/index.js6
-rw-r--r--vnext/server/middleware/event.js76
-rw-r--r--vnext/server/middleware/oembed.js48
-rw-r--r--vnext/server/middleware/urlexpand.js14
-rw-r--r--vnext/server/sender.js136
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;
+}