Skip to content
Snippets Groups Projects
link-channel.js 4.62 KiB
Newer Older
Greg Fodor's avatar
Greg Fodor committed
import { generatePublicKeyAndEncryptedObject, generateKeys, decryptObject } from "./crypto";

Greg Fodor's avatar
Greg Fodor committed
const LINK_ACTION_TIMEOUT = 10000;

export default class LinkChannel {
  constructor(store) {
    this.store = store;
  }

  setSocket = socket => {
    this.socket = socket;
  };

  // Returns a promise that, when resolved, will forward an object with three keys:
  //
  // code: The code that was made available to use for link.
  //
  // cancel: A function that the caller can call to cancel the use of the code.
  //
Greg Fodor's avatar
Greg Fodor committed
  // onFinished: A promise that, when resolved, indicates the code is no longer usable,
  // because it was either successfully used by the remote device or it has expired
Greg Fodor's avatar
Greg Fodor committed
  // ("used" or "expired" is passed to the callback).
  generateCode = () => {
    return new Promise(resolve => {
      const onFinished = new Promise(finished => {
        const step = () => {
Greg Fodor's avatar
Greg Fodor committed
          const getLetter = () => "ABCDEFGHI"[Math.floor(Math.random() * 9)];
          const code = `${getLetter()}${getLetter()}${getLetter()}${getLetter()}`;
          // Only respond to one link_request in this channel.
          let readyToSend = false;
          let leftChannel = false;
Greg Fodor's avatar
Greg Fodor committed
          const channel = this.socket.channel(`link:${code}`, { timeout: LINK_ACTION_TIMEOUT });

          const leave = () => {
            if (!leftChannel) channel.leave();
            leftChannel = true;
          };

          const cancel = () => leave();
          channel.on("link_expired", () => finished("expired"));

          channel.on("presence_state", state => {
            if (Object.keys(state).length > 0) {
              // Code is in use by someone else, try a new one
              step();
            } else {
              readyToSend = true;
              resolve({ code, cancel, onFinished });
            }
          });

          channel.on("link_request", incoming => {
            if (readyToSend) {
Greg Fodor's avatar
Greg Fodor committed
              const data = { path: location.pathname };
              // Copy profile data to link'ed device if it's been set.
              if (this.store.state.activity.hasChangedName) {
Greg Fodor's avatar
Greg Fodor committed
                data.profile = { ...this.store.state.profile };
              generatePublicKeyAndEncryptedObject(incoming.public_key, data).then(
Greg Fodor's avatar
Greg Fodor committed
                ({ publicKeyString, encryptedData }) => {
                  const payload = {
                    target_session_id: incoming.reply_to_session_id,
                    public_key: publicKeyString,
                    data: encryptedData
                  };

                  if (!leftChannel) {
                    channel.push("link_response", payload);
                  }

                  leave();

Greg Fodor's avatar
Greg Fodor committed
                  finished("used");
                  readyToSend = false;
                }
              );
Greg Fodor's avatar
Greg Fodor committed
          channel.join().receive("error", r => console.error(r));
Greg Fodor's avatar
Greg Fodor committed

Greg Fodor's avatar
Greg Fodor committed
  // Attempts to receive a link payload from a remote device using the given code.
Greg Fodor's avatar
Greg Fodor committed
  //
  // Promise rejects if the code is invalid or there is a problem with the channel.
  // Promise resolves and passes payload of link source on successful link.
  attemptLink = code => {
Greg Fodor's avatar
Greg Fodor committed
    return new Promise((resolve, reject) => {
Greg Fodor's avatar
Greg Fodor committed
      const channel = this.socket.channel(`link:${code}`, { timeout: LINK_ACTION_TIMEOUT });
Greg Fodor's avatar
Greg Fodor committed
      let finished = false;

Greg Fodor's avatar
Greg Fodor committed
      generateKeys().then(({ publicKeyString, privateKey }) => {
        channel.on("presence_state", state => {
          const numOccupants = Object.keys(state).length;
Greg Fodor's avatar
Greg Fodor committed

Greg Fodor's avatar
Greg Fodor committed
          if (numOccupants === 1) {
            // Great, only sender is in topic, request link
            channel.push("link_request", {
              reply_to_session_id: this.socket.params().session_id,
Greg Fodor's avatar
Greg Fodor committed
              public_key: publicKeyString
            });
Greg Fodor's avatar
Greg Fodor committed

Greg Fodor's avatar
Greg Fodor committed
            setTimeout(() => {
              if (finished) return;
              channel.leave();
Greg Fodor's avatar
Greg Fodor committed
              reject(new Error("no_response"));
            }, LINK_ACTION_TIMEOUT);
Greg Fodor's avatar
Greg Fodor committed
          } else if (numOccupants === 0) {
            // Nobody in this channel, probably a bad code.
Greg Fodor's avatar
Greg Fodor committed
            channel.leave();
Greg Fodor's avatar
Greg Fodor committed
            reject(new Error("failed"));
Greg Fodor's avatar
Greg Fodor committed
          } else {
            console.warn("link code channel already has 2 or more occupants, something fishy is going on.");
Greg Fodor's avatar
Greg Fodor committed
            channel.leave();
Greg Fodor's avatar
Greg Fodor committed
            reject(new Error("in_use"));
Greg Fodor's avatar
Greg Fodor committed
          }
        });

        channel.on("link_response", payload => {
Greg Fodor's avatar
Greg Fodor committed
          finished = true;
          channel.leave();

          decryptObject(payload.public_key, privateKey, payload.data).then(resolve);
Greg Fodor's avatar
Greg Fodor committed
        });

        channel.join().receive("error", r => console.error(r));
      });
Greg Fodor's avatar
Greg Fodor committed
    });
  };