Coder Social home page Coder Social logo

saurabharch / rollout-notification Goto Github PK

View Code? Open in Web Editor NEW
0.0 2.0 0.0 283 KB

Push notifications for GCM, APNS, MPNS, AMZ (automatic detection from device token) Multiple Device Coverage

License: MIT License

Shell 0.05% JavaScript 99.95%

rollout-notification's Introduction

Node Push Notifications

A node.js module for interfacing with Apple Push Notification, Google Cloud Messaging, Windows Push Notification, Web-Push Notification and Amazon Device Messaging services.

License NPM version Downloads Build Status Coverage Status Dependencies code style: prettier

Installation

npm install rollout-notification --save

Requirements

Node version >= 12.x.x

Features

  • Powerful and intuitive.
  • Multi platform push notifications.
  • Automatically detects destination device type.
  • Unified error handling.
  • Written in ES6, compatible with ES5 through babel transpilation.

Usage

1. Import and setup push module

Include the settings for each device type. You should only include the settings for the devices that you expect to have. I.e. if your app is only available for android or for ios, you should only include gcm or apn respectively.

import PushNotifications from 'rollout-notification';

const settings = {
    gcm: {
        id: null,
        phonegap: false, // phonegap compatibility mode, see below (defaults to false)
        ...
    },
    apn: {
        token: {
            key: './certs/key.p8', // optionally: fs.readFileSync('./certs/key.p8')
            keyId: 'ABCD',
            teamId: 'EFGH',
        },
        production: false // true for APN production environment, false for APN sandbox environment,
        ...
    },
    adm: {
        client_id: null,
        client_secret: null,
        ...
    },
    wns: {
        client_id: null,
        client_secret: null,
        notificationMethod: 'sendTileSquareBlock',
        ...
    },
    web: {
        vapidDetails: {
            subject: '< \'mailto\' Address or URL >',
            publicKey: '< URL Safe Base64 Encoded Public Key >',
            privateKey: '< URL Safe Base64 Encoded Private Key >',
        },
        gcmAPIKey: 'gcmkey',
        TTL: 2419200,
        contentEncoding: 'aes128gcm',
        headers: {}
    },
    isAlwaysUseFCM: false, // true all messages will be sent through node-gcm (which actually uses FCM)
};
const push = new PushNotifications(settings);
  • isAlwaysUseFCM: use node-gcm to send notifications to GCM (by default), iOS, ADM and WNS.

iOS: It is recommended to use provider authentication tokens. You need the .p8 certificate that you can obtain in your account membership. You should ask for an Apple Push Notification Authentication Key (Sandbox & Production) or Apple Push Notification service SSL (Sandbox & Production). However, you can also use certificates. See node-apn to see how to prepare cert.pem and key.pem.

2. Define destination device ID

You can send to multiple devices, independently of platform, creating an array with different destination device IDs.

// Single destination
const registrationIds = 'INSERT_YOUR_DEVICE_ID';

// Multiple destinations
const registrationIds = [];
registrationIds.push('INSERT_YOUR_DEVICE_ID');
registrationIds.push('INSERT_OTHER_DEVICE_ID');

The PN.send() method later detects device type and therefore used push method, based on the id stucture. Check out the method PN.getPushMethodByRegId how this detection works.

Android:

  • If you provide more than 1.000 registration tokens, they will automatically be splitted in 1.000 chunks (see this issue in gcm repo)
  • You are able to send to device groups or other custom recipients instead of using a list of device tokens (see node-gcm docs). Documentation can be found in the GCM section..

Example:

const data = { ...data, recipients };

3. Send the notification

Create a JSON object with a title and message and send the notification.

const data = {
    title: 'New push notification', // REQUIRED for Android
    topic: 'topic', // REQUIRED for iOS (apn and gcm)
    /* The topic of the notification. When using token-based authentication, specify the bundle ID of the app.
     * When using certificate-based authentication, the topic is usually your app's bundle ID.
     * More details can be found under https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns
     */
    body: 'Powered by AppFeel',
    custom: {
        sender: 'AppFeel',
    },
    priority: 'high', // gcm, apn. Supported values are 'high' or 'normal' (gcm). Will be translated to 10 and 5 for apn. Defaults to 'high'
    collapseKey: '', // gcm for android, used as collapseId in apn
    contentAvailable: true, // gcm, apn. node-apn will translate true to 1 as required by apn.
    delayWhileIdle: true, // gcm for android
    restrictedPackageName: '', // gcm for android
    dryRun: false, // gcm for android
    icon: '', // gcm for android
    image: '', // gcm for android
    style: '', // gcm for android
    picture: '', // gcm for android
    tag: '', // gcm for android
    color: '', // gcm for android
    clickAction: '', // gcm for android. In ios, category will be used if not supplied
    locKey: '', // gcm, apn
    titleLocKey: '', // gcm, apn
    locArgs: undefined, // gcm, apn. Expected format: Stringified Array
    titleLocArgs: undefined, // gcm, apn. Expected format: Stringified Array
    retries: 1, // gcm, apn
    encoding: '', // apn
    badge: 2, // gcm for ios, apn
    sound: 'ping.aiff', // gcm, apn
    android_channel_id: '', // gcm - Android Channel ID
    notificationCount: 0, // fcm for android. badge can be used for both fcm and apn
    alert: { // apn, will take precedence over title and body
        title: 'title',
        body: 'body'
        // details: https://github.com/node-apn/node-apn/blob/master/doc/notification.markdown#convenience-setters
    },
    silent: false, // gcm, apn, will override badge, sound, alert and priority if set to true on iOS, will omit `notification` property and send as data-only on Android/GCM
    /*
     * A string is also accepted as a payload for alert
     * Your notification won't appear on ios if alert is empty object
     * If alert is an empty string the regular 'title' and 'body' will show in Notification
     */
    // alert: '',
    launchImage: '', // apn and gcm for ios
    action: '', // apn and gcm for ios
    category: '', // apn and gcm for ios
    // mdm: '', // apn and gcm for ios. Use this to send Mobile Device Management commands.
    // https://developer.apple.com/library/content/documentation/Miscellaneous/Reference/MobileDeviceManagementProtocolRef/3-MDM_Protocol/MDM_Protocol.html
    urlArgs: '', // apn and gcm for ios
    truncateAtWordEnd: true, // apn and gcm for ios
    mutableContent: 0, // apn
    threadId: '', // apn
    pushType: undefined, // apn. valid values are 'alert' and 'background' (https://github.com/parse-community/node-apn/blob/master/doc/notification.markdown#notificationpushtype)
    expiry: Math.floor(Date.now() / 1000) + 28 * 86400, // unit is seconds. if both expiry and timeToLive are given, expiry will take precedence
    timeToLive: 28 * 86400,
    headers: [], // wns
    launch: '', // wns
    duration: '', // wns
    consolidationKey: 'my notification', // ADM
};

// You can use it in node callback style
push.send(registrationIds, data, (err, result) => {
    if (err) {
        console.log(err);
    } else {
	    console.log(result);
    }
});

// Or you could use it as a promise:
push.send(registrationIds, data)
    .then((results) => { ... })
    .catch((err) => { ... });
  • err will be null if all went fine, otherwise will return the error from the respective provider module.
  • result will contain an array with the following objects (one object for each device type found in device registration id's):
[
    {
        method: 'gcm', // The method used send notifications and which this info is related to
        multicastId: [], // (only Android) Array with unique ID (number) identifying the multicast message, one identifier for each chunk of 1.000 notifications)
        success: 0, // Number of notifications that have been successfully sent. It does not mean that the notification has been deliveried.
        failure: 0, // Number of notifications that have been failed to be send.
        message: [{
            messageId: '', // (only for android) String specifying a unique ID for each successfully processed message or undefined if error
            regId: value, // The current registrationId (device token id). Beware: For Android this may change if Google invalidates the previous device token. Use "originalRegId" if you are interested in when this changed occurs.
            originalRegId: value, // (only for android) The registrationId that was sent to the push.send() method. Compare this with field "regId" in order to know when the original registrationId (device token id) gets changed.
            error: new Error('unknown'), // If any, there will be an Error object here for depuration purposes (when possible it will come form source libraries aka apn, node-gcm)
            errorMsg: 'some error', // If any, will include the error message from the respective provider module
        }],
    },
    {
        method: 'apn',
        ... // Same structure here, except for message.orignalRegId
    },
    {
        method: 'wns',
        ... // Same structure here, except for message.orignalRegId
    },
    {
        method: 'adm',
        ... // Same structure here, except for message.orignalRegId
    },
    {
        method: 'webPush',
        ... // Same structure here, except for message.orignalRegId
    },
]

GCM

NOTE: If you provide more than 1.000 registration tokens, they will automatically be splitted in 1.000 chunks (see this issue in gcm repo)

The following parameters are used to create a GCM message. See https://developers.google.com/cloud-messaging/http-server-ref#table5 for more info:

    // Set default custom data from data
    let custom;
    if (typeof data.custom === 'string') {
        custom = {
            message: data.custom,
        };
    } else if (typeof data.custom === 'object') {
        custom = Object.assign({}, data.custom);
    } else {
        custom = {
            data: data.custom,
        };
    }

    custom.title = custom.title || data.title;
    custom.message = custom.message || data.body;
    custom.sound = custom.sound || data.sound;
    custom.icon = custom.icon || data.icon;
    custom.msgcnt = custom.msgcnt || data.badge;
    if (opts.phonegap === true && data.contentAvailable) {
        custom['content-available'] = 1;
    }

    const message = new gcm.Message({ // See https://developers.google.com/cloud-messaging/http-server-ref#table5
        collapseKey: data.collapseKey,
        priority: data.priority === 'normal' ? data.priority : 'high',
        contentAvailable: data.contentAvailable || false,
        delayWhileIdle: data.delayWhileIdle || false, // Deprecated from Nov 15th 2016 (will be ignored)
        timeToLive: data.expiry - Math.floor(Date.now() / 1000) || data.timeToLive || 28 * 86400,
        restrictedPackageName: data.restrictedPackageName,
        dryRun: data.dryRun || false,
        data: data.custom,
        notification: {
            title: data.title, // Android, iOS (Watch)
            body: data.body, // Android, iOS
            icon: data.icon, // Android
            image: data.image, // Android
            style: data.style, // Android
            picture: data.picture, // Android
            sound: data.sound, // Android, iOS
            badge: data.badge, // iOS
            tag: data.tag, // Android
            color: data.color, // Android
            click_action: data.clickAction || data.category, // Android, iOS
            body_loc_key: data.locKey, // Android, iOS
            body_loc_args: data.locArgs, // Android, iOS
            title_loc_key: data.titleLocKey, // Android, iOS
            title_loc_args: data.titleLocArgs, // Android, iOS
	        android_channel_id: data.android_channel_id, // Android
        },
    }

data is the parameter in push.send(registrationIds, data)

Note: parameters are duplicated in data and in notification, so in fact they are being send as:

    data: {
        title: 'title',
        message: 'body',
        sound: 'mySound.aiff',
        icon: undefined,
        msgcnt: undefined
        // Any custom data
        sender: 'appfeel-test',
    },
    notification: {
        title: 'title',
        body: 'body',
        icon: undefined,
	image: undefined,
	style: undefined,
	picture: undefined,
        sound: 'mySound.aiff',
        badge: undefined,
        tag: undefined,
        color: undefined,
        click_action: undefined,
        body_loc_key: undefined,
        body_loc_args: undefined,
        title_loc_key: undefined,
        title_loc_args: undefined,
	android_channel_id: undefined
    }

In that way, they can be accessed in android in the following two ways:

    String title = extras.getString("title");
    title = title != null ? title : extras.getString("gcm.notification.title");

Silent push notifications

GCM supports silent push notifications which are not displayed to the user but only used to transmit data.

const silentPushData = {
    topic: 'yourTopic',
    silent: true,
    custom: {
        yourKey: 'yourValue',
        ...
    }
}

Internally, silent: true will tell node-gcm not to send the notification property and only send the custom property. If you don't specify silent: true then the push notifications will still be visible on the device. Note that this is nearly the same behavior as phoneGap: true and will set content-available to true.

Send to custom recipients (device groups or topics)

In order to override the default behaviour of sending the notifications to a list of device tokens, you can pass a recipients field with your desired recipients. Supported fields are to and condition as documented in the node-gcm docs.

Example:

const dataWithRecipientTo = { ...yourData, recipients: { to: 'topicName' } };
const dataWithRecipientCondition = { ...yourData, recipients: { condition: 'topicName' } };

push.send(registrationIds, dataWithRecipientTo)
    .then((results) => { ... })
    .catch((err) => { ... });

Be aware that the presence of a valid data.recipient field will take precendence over any Android device tokens passed with the registrationIds.

PhoneGap compatibility mode

In case your app is written with Cordova / Ionic and you are using the PhoneGap PushPlugin, you can use the phonegap setting in order to adapt to the recommended behaviour described in https://github.com/phonegap/phonegap-plugin-push/blob/master/docs/PAYLOAD.md#android-behaviour.

const settings = {
  gcm: {
    id: '<yourId>',
    phonegap: true,
  },
};

APN

The following parameters are used to create an APN message:

{
    retryLimit: data.retries || -1,
    expiry: data.expiry || ((data.timeToLive || 28 * 86400) + Math.floor(Date.now() / 1000)),
    priority: data.priority === 'normal' ? 5 : 10,
    encoding: data.encoding,
    payload: data.custom || {},
    badge: data.silent === true ? undefined : data.badge,
    badge: data.sound === true ? undefined : data.sound,
    alert: data.sound === true ? undefined : data.alert || {
        title: data.title,
        body: data.body,
        'title-loc-key': data.titleLocKey,
        'title-loc-args': data.titleLocArgs,
        'loc-key': data.locKey,
        'loc-args': data.locArgs,
        'launch-image': data.launchImage,
        action: data.action,
    },
    topic: data.topic, // Required
    category: data.category || data.clickAction,
    contentAvailable: data.contentAvailable,
    mdm: data.mdm,
    urlArgs: data.urlArgs,
    truncateAtWordEnd: data.truncateAtWordEnd,
    collapseId: data.collapseKey,
    mutableContent: data.mutableContent || 0,
    threadId: data.threadId,
    pushType: data.pushType
}

data is the parameter in push.send(registrationIds, data)

Silent push notifications

iOS supports silent push notifications which are not displayed to the user but only used to transmit data.

Silent push notifications must not include sound, badge or alert and have normal priority.

By setting the silent property to true the values for sound, badge and alert will be overridden to undefined.

Priority will be overridden to normal.

const silentPushData = {
    topic: 'yourTopic',
    contentAvailable: true,
    silent: true,
    custom: {
        yourKey: 'yourValue',
        ...
    }
}

WNS

The following fields are used to create a WNS message:

const notificationMethod = settings.wns.notificationMethod;
const opts = Object.assign({}, settings.wns);
opts.headers = data.headers || opts.headers;
opts.launch = data.launch || opts.launch;
opts.duration = data.duration || opts.duration;

delete opts.notificationMethod;
delete data.headers;
delete data.launch;
delete data.duration;

wns[notificationMethod](regId, data, opts, (err, response) => { ... });

data is the parameter in push.send(registrationIds, data)

Note: Please keep in mind that if data.accessToken is supplied, each push notification will be sent after the previous one has been responded. This is because Microsoft may send a new accessToken in the response and it should be used in successive requests. This can slow down the whole process depending on the number of devices to send.

ADM

The following parameters are used to create an ADM message:

const data = Object.assign({}, _data); // _data is the data passed as method parameter
const consolidationKey = data.consolidationKey;
const expiry = data.expiry;
const timeToLive = data.timeToLive;

delete data.consolidationKey;
delete data.expiry;
delete data.timeToLive;

const ADMmesssage = {
  expiresAfter:
    expiry - Math.floor(Date.now() / 1000) || timeToLive || 28 * 86400,
  consolidationKey,
  data,
};

data is the parameter in push.send(registrationIds, data)

Web-Push

Data can be passed as a simple string payload. If you do not pass a string, the parameter value will be stringified beforehand. Settings are directly forwarded to webPush.sendNotification.

const payload = typeof data === 'string' ? data : JSON.stringify(data);
webPush.sendNotification(regId, payload, settings.web);

A working server example implementation can be found at https://github.com/alex-friedl/webpush-example/blob/master/server/index.js

Resources

LICENSE

The MIT License (MIT)

Copyright (c) 2021 RaindigiIT

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Made in Barcelona n India with <3 and Code

rollout-notification's People

Contributors

saurabharch avatar renovate-bot avatar

Watchers

James Cloos avatar  avatar

rollout-notification's Issues

CVE-2021-23343 (High) detected in path-parse-1.0.6.tgz

CVE-2021-23343 - High Severity Vulnerability

Vulnerable Library - path-parse-1.0.6.tgz

Node.js path.parse() ponyfill

Library home page: https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz

Path to dependency file: rollout-notification/package.json

Path to vulnerable library: rollout-notification/node_modules/path-parse/package.json

Dependency Hierarchy:

  • eslint-plugin-import-2.22.1.tgz (Root Library)
    • resolve-1.20.0.tgz
      • โŒ path-parse-1.0.6.tgz (Vulnerable Library)

Found in HEAD commit: f114e985b905e99595f261565d3df7d9771b89e5

Found in base branch: master

Vulnerability Details

All versions of package path-parse are vulnerable to Regular Expression Denial of Service (ReDoS) via splitDeviceRe, splitTailRe, and splitPathRe regular expressions. ReDoS exhibits polynomial worst-case time complexity.

Publish Date: 2021-05-04

URL: CVE-2021-23343

CVSS 3 Score Details (7.5)

Base Score Metrics:

  • Exploitability Metrics:
    • Attack Vector: Network
    • Attack Complexity: Low
    • Privileges Required: None
    • User Interaction: None
    • Scope: Unchanged
  • Impact Metrics:
    • Confidentiality Impact: None
    • Integrity Impact: None
    • Availability Impact: High

For more information on CVSS3 Scores, click here.


Step up your Open Source Security Game with WhiteSource here

CVE-2020-7608 (Medium) detected in yargs-parser-10.1.0.tgz

CVE-2020-7608 - Medium Severity Vulnerability

Vulnerable Library - yargs-parser-10.1.0.tgz

the mighty option parser used by yargs

Library home page: https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz

Path to dependency file: rollout-notification/package.json

Path to vulnerable library: rollout-notification/node_modules/meow/node_modules/yargs-parser/package.json

Dependency Hierarchy:

  • opn-cli-5.0.0.tgz (Root Library)
    • meow-5.0.0.tgz
      • โŒ yargs-parser-10.1.0.tgz (Vulnerable Library)

Found in HEAD commit: f114e985b905e99595f261565d3df7d9771b89e5

Found in base branch: master

Vulnerability Details

yargs-parser could be tricked into adding or modifying properties of Object.prototype using a "proto" payload.

Publish Date: 2020-03-16

URL: CVE-2020-7608

CVSS 3 Score Details (5.3)

Base Score Metrics:

  • Exploitability Metrics:
    • Attack Vector: Local
    • Attack Complexity: Low
    • Privileges Required: Low
    • User Interaction: None
    • Scope: Unchanged
  • Impact Metrics:
    • Confidentiality Impact: Low
    • Integrity Impact: Low
    • Availability Impact: Low

For more information on CVSS3 Scores, click here.

Suggested Fix

Type: Upgrade version

Origin: yargs/yargs-parser@63810ca

Release Date: 2020-06-05

Fix Resolution: 5.0.1;13.1.2;15.0.1;18.1.1


Step up your Open Source Security Game with WhiteSource here

Dependency Dashboard

This issue lists Renovate updates and detected dependencies. Read the Dependency Dashboard docs to learn more.

Rate-Limited

These updates are currently rate-limited. Click on a checkbox below to force their creation now.

  • Update dependency eslint to v8.57.0
  • Update dependency eslint-config-prettier to v8.10.0
  • Update dependency eslint-plugin-prettier to v4.2.1
  • Update dependency node-gcm to v1.1.4
  • Update dependency pretty-quick to v3.3.1
  • Update dependency ramda to v0.30.1
  • Update actions/checkout action to v4
  • Update actions/setup-node action to v4
  • Update dependency @parse/node-apn to v6
  • Update dependency chai to v5
  • Update dependency eslint to v9
  • Update dependency eslint-config-prettier to v9
  • Update dependency eslint-plugin-prettier to v5
  • Update dependency husky to v9
  • Update dependency prettier to v3
  • Update dependency pretty-quick to v4
  • Update dependency release-it to v17
  • Update dependency semantic-release to v24
  • Update dependency sinon to v18
  • ๐Ÿ” Create all rate-limited PRs at once ๐Ÿ”

Edited/Blocked

These updates have been manually edited so Renovate will no longer make changes. To discard all commits and start over, click on a checkbox.

Open

These updates have all been created already. Click a checkbox below to force a retry/rebase of any.

Detected dependencies

github-actions
.github/workflows/ci.yml
  • actions/checkout v3
  • actions/setup-node v3
  • actions/checkout v2
  • actions/setup-node v2
npm
package.json
  • @parse/node-apn 5.1.3
  • node-adm 0.9.1
  • node-gcm 1.0.5
  • ramda 0.28.0
  • web-push 3.4.5
  • wns 0.5.4
  • @babel/cli 7.17.6
  • @babel/core 7.17.8
  • @babel/preset-env 7.16.11
  • @babel/register 7.17.7
  • chai 4.3.6
  • coveralls 3.1.1
  • cz-conventional-changelog 3.3.0
  • dirty-chai 2.0.1
  • eslint 8.12.0
  • eslint-config-airbnb-base 15.0.0
  • eslint-config-prettier 8.5.0
  • eslint-plugin-import 2.25.4
  • eslint-plugin-prettier 4.0.0
  • husky 7.0.4
  • mocha 9.2.2
  • nyc 15.1.0
  • opn-cli 5.0.0
  • prettier 2.6.1
  • pretty-quick 3.1.3
  • release-it 14.13.1
  • semantic-release 19.0.2
  • sinon 1.17.7
  • sinon-chai 3.5.0
  • node >=12.x.x
travis
.travis.yml
  • node 10

  • Check this box to trigger a request for Renovate to run again on this repository

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.