diff --git a/.github/workflows/socketio_e2e.yml b/.github/workflows/socketio_e2e.yml index 73d0954b3..cb1b237d6 100644 --- a/.github/workflows/socketio_e2e.yml +++ b/.github/workflows/socketio_e2e.yml @@ -7,10 +7,6 @@ on: branches: [ "main" ] paths: - 'sdk/**' - pull_request_target: - branches: [ "main" ] - paths: - - 'sdk/**' env: NODE_VERSION: '18.x' # set this to the node version to use jobs: @@ -49,4 +45,4 @@ jobs: SocketIoPort: 3000 run: | pushd sdk/webpubsub-socketio-extension - yarn run test \ No newline at end of file + yarn run test diff --git a/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/Readme.md b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/Readme.md new file mode 100644 index 000000000..2a52d5595 --- /dev/null +++ b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/Readme.md @@ -0,0 +1,44 @@ +# Create a chat app with Web PubSub for Socket.IO And GitHub OAuth + +## Prerequisites + +1. [Node.js](https://nodejs.org) +2. Create an Web PubSub For Socket.IO resource + +## Setup + +```bash +npm install +``` + +## Get Github ClientID + +1. Go to https://www.github.com, open your profile -> Settings -> Developer settings +2. Go to OAuth Apps, click "New OAuth App" +3. Fill in application name, homepage URL (can be anything you like), and set Authorization callback URL to `http://localhost:3000/auth/github/callback` (which matches the callback API you exposed in the server) +4. After the application is registered, copy the **Client ID** and click "Generate a new client secret" to generate a new **client secret** + +## Start the app +Copy **Connection String** from **Keys** tab of the Web PubSub For Socket.IO resource, and replace the `` below with the value of your **Connection String**. + +Linux: + +```bash +export WebPubSubConnectionString="" +export GitHubClientId="" +export GitHubClientSecret="" +npm run start +``` + +Windows: + +```cmd +SET WebPubSubConnectionString= +SET GitHubClientId= +SET GitHubClientSecret= +npm run start +``` + +## Start the chat + +Open http://localhost:3000, after authenticated by GibHub, you could start your chat with others with your GitHub username. \ No newline at end of file diff --git a/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/index.js b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/index.js new file mode 100644 index 000000000..5a8db7ce3 --- /dev/null +++ b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/index.js @@ -0,0 +1,143 @@ +const express = require('express'); +const session = require("express-session"); +const bodyParser = require("body-parser"); +const passport = require("passport"); +const GitHubStrategy = require('passport-github2').Strategy; +const azure = require("@azure/web-pubsub-socket.io"); +const wrap = middleware => (socket, next) => middleware(socket.request, {}, next); + +const app = express(); +const server = require("http").createServer(app); +const store = new session.MemoryStore(); +const sessionMiddleware = session({ store: store, secret: "changeit", resave: false, saveUninitialized: false }); + +app.use(sessionMiddleware); +app.use(bodyParser.urlencoded({ extended: false })); +app.use(passport.initialize()); +app.use(passport.session()); +app.get('/auth/github', passport.authenticate('github', { scope: ['user:email'] })); +app.get('/auth/github/callback', passport.authenticate('github', { successRedirect: '/' })); + +var users = []; + +passport.use( + new GitHubStrategy({ + clientID: process.env.GitHubClientId, + clientSecret: process.env.GitHubClientSecret + }, + (accessToken, refreshToken, profile, done) => { + console.log(`${profile.username}(${profile.displayName}) authenticated`); + users[profile.id] = profile; + return done(null, profile); + } +)); + +passport.serializeUser((user, done) => { + console.log(`serializeUser ${user.id}`); + done(null, user.id); +}); + +passport.deserializeUser((id, done) => { + console.log(`deserializeUser ${id}`); + if (users[id]) return done(null, users[id]); + return done(`invalid user id: ${id}`); +}); + +app.post("/login", passport.authenticate("local", { + successRedirect: "/", + failureRedirect: "/", +}) +); + + +async function main() { + const wpsOptions = { + hub: "eio_hub", + connectionString: process.argv[2] || process.env.WebPubSubConnectionString, + }; + + const io = require('socket.io')(server); + + await azure.useAzureSocketIO(io, wpsOptions); + + app.get("/negotiate", azure.negotiate(io, azure.usePassport())); + io.use(wrap(azure.restorePassport())); + + io.use(wrap(passport.initialize())); + io.use(wrap(passport.session())); + + // Now `socket.request.user` is available. While `req.request.session` is not. + io.use((socket, next) => { + if (socket.request.user) { + next(); + } else { + next(new Error('unauthorized')) + } + }); + + io.on('connect', (socket) => { + console.log(`new connection ${socket.id}`); + + console.log(`socket.request.user.id = ${socket.request.user.id}`); + + socket.on('whoami', (cb) => { + console.log(`${socket.request.user.username}`); + cb(`${socket.request.user.username}`); + }); + }); + + let numUsers = 0; + + io.on('connection', socket => { + let addedUser = false; + + // when the client emits 'new message', this listens and executes + socket.on('new message', (data) => { + // we tell the client to execute 'new message' + socket.broadcast.emit('new message', { + username: socket.username, + message: data + }); + }); + + // when the client emits 'add user', this listens and executes + socket.on('add user', (username) => { + if (addedUser) return; + + // we store the username in the socket session for this client + socket.username = username; + ++numUsers; + addedUser = true; + socket.emit('login', { + numUsers: numUsers + }); + // echo globally (all clients) that a person has connected + socket.broadcast.emit('user joined', { + username: socket.username, + numUsers: numUsers + }); + }); + + // when the user disconnects.. perform this + socket.on('disconnect', () => { + if (addedUser) { + --numUsers; + + // echo globally that this client has left + socket.broadcast.emit('user left', { + username: socket.username, + numUsers: numUsers + }); + } + }); + }); + + app.use(express.static('public')); + + const port = 3000; + server.listen(port, () => { + console.log(`application is running at: http://localhost:${port}`); + }); +} + +main(); \ No newline at end of file diff --git a/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/package.json b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/package.json new file mode 100644 index 000000000..e1819c996 --- /dev/null +++ b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/package.json @@ -0,0 +1,19 @@ +{ + "name": "chat-with-auth-github", + "version": "1.0.0", + "description": "", + "main": "server.js", + "scripts": { + "start": "node index.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@azure/web-pubsub-socket.io": "1.0.0-beta.6", + "express": "^4.17.1", + "express-session": "^1.17.1", + "passport": "^0.4.1", + "passport-github2": "^0.1.12" + } +} diff --git a/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/public/index.html b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/public/index.html new file mode 100644 index 000000000..06ffafdd5 --- /dev/null +++ b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/public/index.html @@ -0,0 +1,28 @@ + + + + + Socket.IO Chat Example + + + +
    +
  • +
    +
      +
      + +
    • +
    + + + + + + \ No newline at end of file diff --git a/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/public/main.js b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/public/main.js new file mode 100644 index 000000000..795f32e5f --- /dev/null +++ b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/public/main.js @@ -0,0 +1,265 @@ +const FADE_TIME = 150; // ms +const TYPING_TIMER_LENGTH = 400; // ms +const COLORS = [ + '#e21400', '#91580f', '#f8a700', '#f78b00', + '#58dc00', '#287b00', '#a8f07a', '#4ae8c4', + '#3b88eb', '#3824aa', '#a700ff', '#d300e7' +]; + +// Initialize variables +const $window = $(window); +const $usernameInput = $('.usernameInput'); // Input for username +const $messages = $('.messages'); // Messages area +const $inputMessage = $('.inputMessage'); // Input message input box + +const $chatPage = $('.chat.page'); // The chatroom page +let $currentInput = $usernameInput.focus(); + +// Prevents input from having injected markup +const cleanInput = (input) => { + return $('
    ').text(input).html(); +} + +async function main(username) { + + $chatPage.show(); + $currentInput = $inputMessage.focus(); + + const negotiateResponse = await fetch(`/negotiate/?expirationMinutes=600`); + if (!negotiateResponse.ok) { + let m = document.createElement('p'); + m.innerHTML = 'Not authorized, click here to login'; + $messages.append(m); + return; + } + const json = await negotiateResponse.json(); + console.log("endpoint=", json.endpoint); + + var socket = io(json.endpoint, { + path: json.path, + query: { + access_token: json.token + } + }); + + // Prompt for setting a username + let connected = false; + + // Tell the server your username + socket.emit("whoami", (username) => { + socket.emit('add user', username); + }); + + + const addParticipantsMessage = (data) => { + let message = ''; + if (data.numUsers === 1) { + message += `there's 1 participant`; + } else { + message += `there are ${data.numUsers} participants`; + } + log(message); + } + + // Sends a chat message + const sendMessage = () => { + let message = $inputMessage.val(); + // Prevent markup from being injected into the message + message = cleanInput(message); + // if there is a non-empty message and a socket connection + if (message && connected) { + $inputMessage.val(''); + addChatMessage({ username, message }); + // tell server to execute 'new message' and send along one parameter + socket.emit('new message', message); + } + } + + // Log a message + const log = (message, options) => { + const $el = $('
  • ').addClass('log').text(message); + addMessageElement($el, options); + } + + // Adds the visual chat message to the message list + const addChatMessage = (data, options = {}) => { + // Don't fade the message in if there is an 'X was typing' + const $typingMessages = getTypingMessages(data); + if ($typingMessages.length !== 0) { + options.fade = false; + $typingMessages.remove(); + } + + const $usernameDiv = $('') + .text(data.username) + .css('color', getUsernameColor(data.username)); + const $messageBodyDiv = $('') + .text(data.message); + + const typingClass = data.typing ? 'typing' : ''; + const $messageDiv = $('
  • ') + .data('username', data.username) + .addClass(typingClass) + .append($usernameDiv, $messageBodyDiv); + + addMessageElement($messageDiv, options); + } + + // Adds the visual chat typing message + const addChatTyping = (data) => { + data.typing = true; + data.message = 'is typing'; + addChatMessage(data); + } + + // Removes the visual chat typing message + const removeChatTyping = (data) => { + getTypingMessages(data).fadeOut(function () { + $(this).remove(); + }); + } + + // Adds a message element to the messages and scrolls to the bottom + // el - The element to add as a message + // options.fade - If the element should fade-in (default = true) + // options.prepend - If the element should prepend + // all other messages (default = false) + const addMessageElement = (el, options) => { + const $el = $(el); + // Setup default options + if (!options) { + options = {}; + } + if (typeof options.fade === 'undefined') { + options.fade = true; + } + if (typeof options.prepend === 'undefined') { + options.prepend = false; + } + + // Apply options + if (options.fade) { + $el.hide().fadeIn(FADE_TIME); + } + if (options.prepend) { + $messages.prepend($el); + } else { + $messages.append($el); + } + + $messages[0].scrollTop = $messages[0].scrollHeight; + } + + // Gets the 'X is typing' messages of a user + const getTypingMessages = (data) => { + return $('.typing.message').filter(function (i) { + return $(this).data('username') === data.username; + }); + } + + // Gets the color of a username through our hash function + const getUsernameColor = (username) => { + // Compute hash code + let hash = 7; + for (let i = 0; i < username.length; i++) { + hash = username.charCodeAt(i) + (hash << 5) - hash; + } + // Calculate color + const index = Math.abs(hash % COLORS.length); + return COLORS[index]; + } + + // Keyboard events + const keydownHandler = (event) => { + event.stopPropagation(); // override existing keydown event handler + // Auto-focus the current input when a key is typed + if (!(event.ctrlKey || event.metaKey || event.altKey)) { + $currentInput.focus(); + } + // When the client hits ENTER on their keyboard + if (event.which === 13) { + if (username) { + sendMessage(); + } + } + } + // options = true to make sure the existing keydown event handler will be skipped + window.addEventListener("keydown", keydownHandler, true); + + // Focus input when clicking on the message input's border + $inputMessage.click(() => { + $inputMessage.focus(); + }); + + // Socket events + + // Whenever the server emits 'login', log the login message + socket.on('login', (data) => { + connected = true; + // Display the welcome message + const message = 'Welcome to Socket.IO Chat – '; + log(message, { + prepend: true + }); + addParticipantsMessage(data); + }); + + // Whenever the server emits 'new message', update the chat body + socket.on('new message', (data) => { + addChatMessage(data); + }); + + // Whenever the server emits 'user joined', log it in the chat body + socket.on('user joined', (data) => { + log(`${data.username} joined`); + addParticipantsMessage(data); + }); + + // Whenever the server emits 'user left', log it in the chat body + socket.on('user left', (data) => { + log(`${data.username} left`); + addParticipantsMessage(data); + removeChatTyping(data); + }); + + socket.on('disconnect', () => { + log('you have been disconnected'); + }); + + socket.io.on('reconnect_attempt', async () => { + log('you are trying to reconnect'); + const negotiate = await fetch('/negotiate'); + if (!negotiate.ok) { + console.log("Failed to negotiate, status code =", negotiateResponse.status); + return; + } + const json = await negotiate.json(); + socket.io.opts.query['access_token'] = json.token; + }); + + socket.io.on('reconnect', () => { + log('you have been reconnected'); + if (username) { + socket.emit('add user', username); + } + }); + + socket.io.on('reconnect_error', () => { + log('attempt to reconnect has failed'); + }); +} + +// Add keydown event handler to get username from input +$window.keydown(event => { + // Auto-focus the current input when a key is typed + if (!(event.ctrlKey || event.metaKey || event.altKey)) { + $currentInput.focus(); + } + // // When the client hits ENTER on their keyboard + // if (event.which === 13) { + // const username = cleanInput($usernameInput.val().trim()); + // main(username); + // } +}); + +main(); \ No newline at end of file diff --git a/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/public/style.css b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/public/style.css new file mode 100644 index 000000000..cd0a59a65 --- /dev/null +++ b/sdk/webpubsub-socketio-extension/examples/chat-with-auth-github/public/style.css @@ -0,0 +1,150 @@ +/* Fix user-agent */ + +* { + box-sizing: border-box; +} + +html { + font-weight: 300; + -webkit-font-smoothing: antialiased; + background-color: rgb(62, 62, 62); +} + +html, input { + font-family: + "HelveticaNeue-Light", + "Helvetica Neue Light", + "Helvetica Neue", + Helvetica, + Arial, + "Lucida Grande", + sans-serif; +} + +html, body { + height: 100%; + margin: 0; + padding: 0; +} + +ul { + list-style: none; + word-wrap: break-word; +} + +/* Pages */ + +.pages { + height: 100%; + margin: 0; + padding: 0; + width: 100%; +} + +.page { + height: 100%; + position: absolute; + width: 100%; +} + +/* Login Page */ + +.login.page { + background-color: #000; +} + +.login.page .form { + height: 100px; + margin-top: -100px; + position: absolute; + + text-align: center; + top: 50%; + width: 100%; +} + +.login.page .form .usernameInput { + background-color: transparent; + border: none; + border-bottom: 2px solid #fff; + outline: none; + padding-bottom: 15px; + text-align: center; + width: 400px; +} + +.login.page .title { + font-size: 200%; +} + +.login.page .usernameInput { + font-size: 200%; + letter-spacing: 3px; +} + +.login.page .title, .login.page .usernameInput { + color: #fff; + font-weight: 100; +} + +/* Chat page */ + +.chat.page { + display: none; +} + +/* Font */ + +.messages { + font-size: 150%; +} + +.inputMessage { + font-size: 100%; +} + +.log { + color: gray; + font-size: 70%; + margin: 5px; + text-align: center; +} + +/* Messages */ + +.chatArea { + height: 100%; + padding-bottom: 60px; +} + +.messages { + height: 100%; + margin: 0; + overflow-y: scroll; + padding: 10px 20px 10px 20px; +} + +.message.typing .messageBody { + color: gray; +} + +.username { + font-weight: 700; + overflow: hidden; + padding-right: 15px; + text-align: right; +} + +/* Input */ + +.inputMessage { + border: 10px solid #000; + bottom: 0; + height: 60px; + left: 0; + outline: none; + padding-left: 10px; + position: absolute; + right: 0; + width: 100%; +}