Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F3525495
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
16 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/keyserver/src/push/utils.js b/keyserver/src/push/utils.js
index 448f03e4d..0c5ab92b7 100644
--- a/keyserver/src/push/utils.js
+++ b/keyserver/src/push/utils.js
@@ -1,483 +1,405 @@
// @flow
import type { ResponseFailure } from '@parse/node-apn';
-import crypto from 'crypto';
import type { FirebaseApp, FirebaseError } from 'firebase-admin';
import invariant from 'invariant';
import nodeFetch from 'node-fetch';
import type { Response } from 'node-fetch';
-import uuid from 'uuid';
import webpush from 'web-push';
-import blobService from 'lib/facts/blob-service.js';
import type { PlatformDetails } from 'lib/types/device-types.js';
import { threadSubscriptions } from 'lib/types/subscription-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
-import { toBase64URL } from 'lib/utils/base64.js';
-import { makeBlobServiceEndpointURL } from 'lib/utils/blob-service.js';
-import { getMessageForException } from 'lib/utils/errors.js';
import {
getAPNPushProfileForCodeVersion,
getFCMPushProfileForCodeVersion,
getAPNProvider,
getFCMProvider,
ensureWebPushInitialized,
getWNSToken,
} from './providers.js';
import type {
TargetedAPNsNotification,
TargetedAndroidNotification,
TargetedWebNotification,
TargetedWNSNotification,
} from './types.js';
import { dbQuery, SQL } from '../database/database.js';
-import { generateKey, encrypt } from '../utils/aes-crypto-utils.js';
+import { upload } from '../services/blob.js';
const fcmTokenInvalidationErrors = new Set([
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
]);
const fcmMaxNotificationPayloadByteSize = 4000;
const apnTokenInvalidationErrorCode = 410;
const apnBadRequestErrorCode = 400;
const apnBadTokenErrorString = 'BadDeviceToken';
const apnMaxNotificationPayloadByteSize = 4096;
const webInvalidTokenErrorCodes = [404, 410];
const wnsInvalidTokenErrorCodes = [404, 410];
const wnsMaxNotificationPayloadByteSize = 5000;
type APNPushResult =
| { +success: true }
| {
+errors: $ReadOnlyArray<ResponseFailure>,
+invalidTokens?: $ReadOnlyArray<string>,
};
async function apnPush({
targetedNotifications,
platformDetails,
}: {
+targetedNotifications: $ReadOnlyArray<TargetedAPNsNotification>,
+platformDetails: PlatformDetails,
}): Promise<APNPushResult> {
const pushProfile = getAPNPushProfileForCodeVersion(platformDetails);
const apnProvider = await getAPNProvider(pushProfile);
if (!apnProvider && process.env.NODE_ENV === 'development') {
console.log(`no keyserver/secrets/${pushProfile}.json so ignoring notifs`);
return { success: true };
}
invariant(apnProvider, `keyserver/secrets/${pushProfile}.json should exist`);
const results = await Promise.all(
targetedNotifications.map(({ notification, deviceToken }) => {
return apnProvider.send(notification, deviceToken);
}),
);
const mergedResults = { sent: [], failed: [] };
for (const result of results) {
mergedResults.sent.push(...result.sent);
mergedResults.failed.push(...result.failed);
}
const errors = [];
const invalidTokens = [];
for (const error of mergedResults.failed) {
errors.push(error);
/* eslint-disable eqeqeq */
if (
error.status == apnTokenInvalidationErrorCode ||
(error.status == apnBadRequestErrorCode &&
error.response.reason === apnBadTokenErrorString)
) {
invalidTokens.push(error.device);
}
/* eslint-enable eqeqeq */
}
if (invalidTokens.length > 0) {
return { errors, invalidTokens };
} else if (errors.length > 0) {
return { errors };
} else {
return { success: true };
}
}
type FCMPushResult = {
+success?: true,
+fcmIDs?: $ReadOnlyArray<string>,
+errors?: $ReadOnlyArray<FirebaseError>,
+invalidTokens?: $ReadOnlyArray<string>,
};
async function fcmPush({
targetedNotifications,
collapseKey,
codeVersion,
}: {
+targetedNotifications: $ReadOnlyArray<TargetedAndroidNotification>,
+codeVersion: ?number,
+collapseKey?: ?string,
}): Promise<FCMPushResult> {
const pushProfile = getFCMPushProfileForCodeVersion(codeVersion);
const fcmProvider = await getFCMProvider(pushProfile);
if (!fcmProvider && process.env.NODE_ENV === 'development') {
console.log(`no keyserver/secrets/${pushProfile}.json so ignoring notifs`);
return { success: true };
}
invariant(fcmProvider, `keyserver/secrets/${pushProfile}.json should exist`);
const options: Object = {
priority: 'high',
};
if (collapseKey) {
options.collapseKey = collapseKey;
}
// firebase-admin is extremely barebones and has a lot of missing or poorly
// thought-out functionality. One of the issues is that if you send a
// multicast messages and one of the device tokens is invalid, the resultant
// won't explain which of the device tokens is invalid. So we're forced to
// avoid the multicast functionality and call it once per deviceToken.
const promises = [];
for (const { notification, deviceToken } of targetedNotifications) {
promises.push(
fcmSinglePush(fcmProvider, notification, deviceToken, options),
);
}
const pushResults = await Promise.all(promises);
const errors = [];
const ids = [];
const invalidTokens = [];
for (let i = 0; i < pushResults.length; i++) {
const pushResult = pushResults[i];
for (const error of pushResult.errors) {
errors.push(error);
if (fcmTokenInvalidationErrors.has(error.errorInfo.code)) {
invalidTokens.push(targetedNotifications[i].deviceToken);
}
}
for (const id of pushResult.fcmIDs) {
ids.push(id);
}
}
const result = {};
if (ids.length > 0) {
result.fcmIDs = ids;
}
if (errors.length > 0) {
result.errors = errors;
} else {
result.success = true;
}
if (invalidTokens.length > 0) {
result.invalidTokens = invalidTokens;
}
return { ...result };
}
async function fcmSinglePush(
provider: FirebaseApp,
notification: Object,
deviceToken: string,
options: Object,
) {
try {
const deliveryResult = await provider
.messaging()
.sendToDevice(deviceToken, notification, options);
const errors = [];
const ids = [];
for (const fcmResult of deliveryResult.results) {
if (fcmResult.error) {
errors.push(fcmResult.error);
} else if (fcmResult.messageId) {
ids.push(fcmResult.messageId);
}
}
return { fcmIDs: ids, errors };
} catch (e) {
return { fcmIDs: [], errors: [e] };
}
}
async function getUnreadCounts(
userIDs: string[],
): Promise<{ [userID: string]: number }> {
const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`;
const notificationExtractString = `$.${threadSubscriptions.home}`;
const query = SQL`
SELECT user, COUNT(thread) AS unread_count
FROM memberships
WHERE user IN (${userIDs}) AND last_message > last_read_message
AND role > 0
AND JSON_EXTRACT(permissions, ${visPermissionExtractString})
AND JSON_EXTRACT(subscription, ${notificationExtractString})
GROUP BY user
`;
const [result] = await dbQuery(query);
const usersToUnreadCounts = {};
for (const row of result) {
usersToUnreadCounts[row.user.toString()] = row.unread_count;
}
for (const userID of userIDs) {
if (usersToUnreadCounts[userID] === undefined) {
usersToUnreadCounts[userID] = 0;
}
}
return usersToUnreadCounts;
}
export type WebPushError = {
+statusCode: number,
+headers: { +[string]: string },
+body: string,
};
type WebPushResult = {
+success?: true,
+errors?: $ReadOnlyArray<WebPushError>,
+invalidTokens?: $ReadOnlyArray<string>,
};
async function webPush(
targetedNotifications: $ReadOnlyArray<TargetedWebNotification>,
): Promise<WebPushResult> {
await ensureWebPushInitialized();
const pushResults = await Promise.all(
targetedNotifications.map(
async ({ notification, deviceToken: deviceTokenString }) => {
const deviceToken: PushSubscriptionJSON = JSON.parse(deviceTokenString);
const notificationString = JSON.stringify(notification);
try {
await webpush.sendNotification(deviceToken, notificationString);
} catch (error) {
return { error };
}
return {};
},
),
);
const errors = [];
const invalidTokens = [];
const deviceTokens = targetedNotifications.map(
({ deviceToken }) => deviceToken,
);
for (let i = 0; i < pushResults.length; i++) {
const pushResult = pushResults[i];
if (pushResult.error) {
errors.push(pushResult.error);
if (webInvalidTokenErrorCodes.includes(pushResult.error.statusCode)) {
invalidTokens.push(deviceTokens[i]);
}
}
}
const result = {};
if (errors.length > 0) {
result.errors = errors;
} else {
result.success = true;
}
if (invalidTokens.length > 0) {
result.invalidTokens = invalidTokens;
}
return { ...result };
}
export type WNSPushError = any | string | Response;
type WNSPushResult = {
+success?: true,
+wnsIDs?: $ReadOnlyArray<string>,
+errors?: $ReadOnlyArray<WNSPushError>,
+invalidTokens?: $ReadOnlyArray<string>,
};
async function wnsPush(
targetedNotifications: $ReadOnlyArray<TargetedWNSNotification>,
): Promise<WNSPushResult> {
const token = await getWNSToken();
if (!token && process.env.NODE_ENV === 'development') {
console.log(`no keyserver/secrets/wns_config.json so ignoring notifs`);
return { success: true };
}
invariant(token, `keyserver/secrets/wns_config.json should exist`);
const pushResults = targetedNotifications.map(async targetedNotification => {
const notificationString = JSON.stringify(
targetedNotification.notification,
);
try {
return await wnsSinglePush(
token,
notificationString,
targetedNotification.deviceToken,
);
} catch (error) {
return { error };
}
});
const errors = [];
const notifIDs = [];
const invalidTokens = [];
const deviceTokens = targetedNotifications.map(
({ deviceToken }) => deviceToken,
);
for (let i = 0; i < pushResults.length; i++) {
const pushResult = await pushResults[i];
if (pushResult.error) {
errors.push(pushResult.error);
if (
pushResult.error === 'invalidDomain' ||
wnsInvalidTokenErrorCodes.includes(pushResult.error?.status)
) {
invalidTokens.push(deviceTokens[i]);
}
} else {
notifIDs.push(pushResult.wnsID);
}
}
const result = {};
if (notifIDs.length > 0) {
result.wnsIDs = notifIDs;
}
if (errors.length > 0) {
result.errors = errors;
} else {
result.success = true;
}
if (invalidTokens.length > 0) {
result.invalidTokens = invalidTokens;
}
return { ...result };
}
async function wnsSinglePush(token: string, notification: string, url: string) {
const parsedURL = new URL(url);
const domain = parsedURL.hostname.split('.').slice(-3);
if (
domain[0] !== 'notify' ||
domain[1] !== 'windows' ||
domain[2] !== 'com'
) {
return { error: 'invalidDomain' };
}
try {
const result = await nodeFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'X-WNS-Type': 'wns/raw',
'Authorization': `Bearer ${token}`,
},
body: notification,
});
if (!result.ok) {
return { error: result };
}
const wnsID = result.headers.get('X-WNS-MSG-ID');
invariant(wnsID, 'Missing WNS ID');
return { wnsID };
} catch (err) {
return { error: err };
}
}
async function blobServiceUpload(payload: string): Promise<
| {
+blobHash: string,
+encryptionKey: string,
}
| { +blobUploadError: string },
> {
- const encryptionKey = await generateKey();
- const encryptedPayloadBuffer = Buffer.from(
- await encrypt(encryptionKey, new TextEncoder().encode(payload)),
- );
-
- const blobHolder = uuid.v4();
- const blobHashBase64 = await crypto
- .createHash('sha256')
- .update(encryptedPayloadBuffer)
- .digest('base64');
-
- const blobHash = toBase64URL(blobHashBase64);
-
- const formData = new FormData();
- const payloadBlob = new Blob([encryptedPayloadBuffer]);
-
- formData.append('blob_hash', blobHash);
- formData.append('blob_data', payloadBlob);
-
- const assignHolderPromise = fetch(
- makeBlobServiceEndpointURL(blobService.httpEndpoints.ASSIGN_HOLDER),
- {
- method: blobService.httpEndpoints.ASSIGN_HOLDER.method,
- body: JSON.stringify({
- holder: blobHolder,
- blob_hash: blobHash,
- }),
- headers: {
- 'content-type': 'application/json',
- },
- },
- );
-
- const uploadHolderPromise = fetch(
- makeBlobServiceEndpointURL(blobService.httpEndpoints.UPLOAD_BLOB),
- {
- method: blobService.httpEndpoints.UPLOAD_BLOB.method,
- body: formData,
- },
- );
-
- try {
- const [assignHolderResponse, uploadBlobResponse] = await Promise.all([
- assignHolderPromise,
- uploadHolderPromise,
- ]);
-
- if (!assignHolderResponse.ok) {
- const { status, statusText } = assignHolderResponse;
- return {
- blobUploadError: `Holder assignment failed with HTTP ${status}: ${statusText}`,
- };
- }
-
- if (!uploadBlobResponse.ok) {
- const { status, statusText } = uploadBlobResponse;
- return {
- blobUploadError: `Payload upload failed with HTTP ${status}: ${statusText}`,
- };
- }
- } catch (e) {
- return {
- blobUploadError: `Payload upload failed with: ${
- getMessageForException(e) ?? 'unknown error'
- }`,
- };
- }
-
- const encryptionKeyString = Buffer.from(encryptionKey).toString('base64');
- return {
- blobHash,
- encryptionKey: encryptionKeyString,
- };
+ return upload(payload);
}
export {
apnPush,
blobServiceUpload,
fcmPush,
webPush,
wnsPush,
getUnreadCounts,
apnMaxNotificationPayloadByteSize,
fcmMaxNotificationPayloadByteSize,
wnsMaxNotificationPayloadByteSize,
};
diff --git a/keyserver/src/services/blob.js b/keyserver/src/services/blob.js
new file mode 100644
index 000000000..976eb0f0f
--- /dev/null
+++ b/keyserver/src/services/blob.js
@@ -0,0 +1,95 @@
+// @flow
+
+import crypto from 'crypto';
+import uuid from 'uuid';
+
+import blobService from 'lib/facts/blob-service.js';
+import { toBase64URL } from 'lib/utils/base64.js';
+import { makeBlobServiceEndpointURL } from 'lib/utils/blob-service.js';
+import { getMessageForException } from 'lib/utils/errors.js';
+
+import { encrypt, generateKey } from '../utils/aes-crypto-utils.js';
+
+async function upload(payload: string): Promise<
+ | {
+ +blobHash: string,
+ +encryptionKey: string,
+ }
+ | { +blobUploadError: string },
+> {
+ const encryptionKey = await generateKey();
+ const encryptedPayloadBuffer = Buffer.from(
+ await encrypt(encryptionKey, new TextEncoder().encode(payload)),
+ );
+
+ const blobHolder = uuid.v4();
+ const blobHashBase64 = await crypto
+ .createHash('sha256')
+ .update(encryptedPayloadBuffer)
+ .digest('base64');
+
+ const blobHash = toBase64URL(blobHashBase64);
+
+ const formData = new FormData();
+ const payloadBlob = new Blob([encryptedPayloadBuffer]);
+
+ formData.append('blob_hash', blobHash);
+ formData.append('blob_data', payloadBlob);
+
+ const assignHolderPromise = fetch(
+ makeBlobServiceEndpointURL(blobService.httpEndpoints.ASSIGN_HOLDER),
+ {
+ method: blobService.httpEndpoints.ASSIGN_HOLDER.method,
+ body: JSON.stringify({
+ holder: blobHolder,
+ blob_hash: blobHash,
+ }),
+ headers: {
+ 'content-type': 'application/json',
+ },
+ },
+ );
+
+ const uploadHolderPromise = fetch(
+ makeBlobServiceEndpointURL(blobService.httpEndpoints.UPLOAD_BLOB),
+ {
+ method: blobService.httpEndpoints.UPLOAD_BLOB.method,
+ body: formData,
+ },
+ );
+
+ try {
+ const [assignHolderResponse, uploadBlobResponse] = await Promise.all([
+ assignHolderPromise,
+ uploadHolderPromise,
+ ]);
+
+ if (!assignHolderResponse.ok) {
+ const { status, statusText } = assignHolderResponse;
+ return {
+ blobUploadError: `Holder assignment failed with HTTP ${status}: ${statusText}`,
+ };
+ }
+
+ if (!uploadBlobResponse.ok) {
+ const { status, statusText } = uploadBlobResponse;
+ return {
+ blobUploadError: `Payload upload failed with HTTP ${status}: ${statusText}`,
+ };
+ }
+ } catch (e) {
+ return {
+ blobUploadError: `Payload upload failed with: ${
+ getMessageForException(e) ?? 'unknown error'
+ }`,
+ };
+ }
+
+ const encryptionKeyString = Buffer.from(encryptionKey).toString('base64');
+ return {
+ blobHash,
+ encryptionKey: encryptionKeyString,
+ };
+}
+
+export { upload };
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Wed, Dec 25, 5:17 PM (6 h, 38 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2695108
Default Alt Text
(16 KB)
Attached To
Mode
rCOMM Comm
Attached
Detach File
Event Timeline
Log In to Comment