├── server ├── .nvmrc ├── db │ ├── sqlite │ │ └── .keep │ ├── init.js │ └── models.js ├── .env ├── views │ ├── messages │ │ ├── none.ejs │ │ ├── _ogp.ejs │ │ ├── _yt_embed.ejs │ │ ├── _spotify_embed.ejs │ │ ├── _twitch_embed.ejs │ │ └── messages.ejs │ ├── mcu │ │ ├── favicon.ico │ │ └── index.ejs │ ├── static │ │ ├── favicon.ico │ │ ├── audio │ │ │ ├── mute.mp3 │ │ │ ├── unmute.mp3 │ │ │ ├── connect.mp3 │ │ │ ├── mute_mic.mp3 │ │ │ ├── disconnect.mp3 │ │ │ └── unmute_mic.mp3 │ │ ├── img │ │ │ ├── logo.png │ │ │ ├── logo-white.png │ │ │ ├── logo-white-bg.png │ │ │ └── logo.svg │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── webfonts │ │ │ ├── fa-brands-400.eot │ │ │ ├── fa-brands-400.ttf │ │ │ ├── fa-solid-900.eot │ │ │ ├── fa-solid-900.ttf │ │ │ ├── fa-solid-900.woff │ │ │ ├── fa-brands-400.woff │ │ │ ├── fa-brands-400.woff2 │ │ │ ├── fa-regular-400.eot │ │ │ ├── fa-regular-400.ttf │ │ │ ├── fa-regular-400.woff │ │ │ ├── fa-regular-400.woff2 │ │ │ └── fa-solid-900.woff2 │ │ ├── browserconfig.xml │ │ ├── js │ │ │ ├── email-pass.js │ │ │ ├── admin.js │ │ │ ├── smartscroll.js │ │ │ ├── client-edit_channels.js │ │ │ ├── mcu.js │ │ │ └── MultiStreamsMixer.js │ │ ├── css │ │ │ ├── normalize.css │ │ │ └── openchat.css │ │ └── safari-pinned-tab.svg │ ├── client │ │ ├── _text_channel.ejs │ │ ├── _voice_channel.ejs │ │ ├── _head.ejs │ │ └── index.ejs │ └── auth │ │ ├── anon │ │ ├── _head.ejs │ │ └── login.ejs │ │ ├── email-pass │ │ ├── _head.ejs │ │ ├── login.ejs │ │ └── register.ejs │ │ └── index.ejs ├── scripts │ ├── create_cert │ │ ├── package.json │ │ ├── script.js │ │ └── package-lock.json │ ├── secret.js │ └── config.js ├── controllers │ ├── mcu │ │ ├── mcu.js │ │ └── mcu_launcher.js │ ├── client │ │ └── client.js │ ├── auth │ │ ├── auth.js │ │ ├── methods │ │ │ ├── anon.js │ │ │ ├── pubkey.js │ │ │ └── email-pass.js │ │ └── init.js │ ├── admin │ │ └── admin.js │ ├── messages │ │ └── messages.js │ └── signalling │ │ └── signalling.js ├── helpers │ └── expressfunctions.js ├── package.json └── server.js ├── client ├── client │ ├── css │ │ ├── sizes.css │ │ ├── colors.css │ │ ├── openchat.css │ │ └── normalize.css │ ├── logo.png │ ├── webfonts │ │ ├── fa-solid-900.eot │ │ ├── fa-solid-900.ttf │ │ ├── fa-brands-400.eot │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff │ │ ├── fa-regular-400.eot │ │ ├── fa-regular-400.ttf │ │ ├── fa-solid-900.woff │ │ ├── fa-solid-900.woff2 │ │ ├── fa-brands-400.woff2 │ │ ├── fa-regular-400.woff │ │ └── fa-regular-400.woff2 │ ├── js │ │ ├── livevar.js │ │ ├── bridge.js │ │ └── client.js │ └── index.html ├── build │ └── icon.png ├── resources │ └── prefs_default.json ├── package.json └── main.js ├── assets ├── logo.ai ├── logo.png ├── logo-large.png ├── logo-white.png ├── client-dark.png ├── client-light.png └── logo.svg ├── .dockerignore ├── .gitignore ├── Dockerfile ├── .github └── workflows │ └── build.yml └── README.md /server/.nvmrc: -------------------------------------------------------------------------------- 1 | 14.15.0 -------------------------------------------------------------------------------- /server/db/sqlite/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/.env: -------------------------------------------------------------------------------- 1 | # Set environment variables here e.g: 2 | # PORT=8080 -------------------------------------------------------------------------------- /client/client/css/sizes.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --nav-thumb-size: 15px; 3 | } -------------------------------------------------------------------------------- /assets/logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/assets/logo.ai -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/logo-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/assets/logo-large.png -------------------------------------------------------------------------------- /assets/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/assets/logo-white.png -------------------------------------------------------------------------------- /client/build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/build/icon.png -------------------------------------------------------------------------------- /server/views/messages/none.ejs: -------------------------------------------------------------------------------- 1 |
2 | No messages 3 |
4 | -------------------------------------------------------------------------------- /assets/client-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/assets/client-dark.png -------------------------------------------------------------------------------- /assets/client-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/assets/client-light.png -------------------------------------------------------------------------------- /client/client/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/logo.png -------------------------------------------------------------------------------- /server/views/mcu/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/mcu/favicon.ico -------------------------------------------------------------------------------- /server/views/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/favicon.ico -------------------------------------------------------------------------------- /server/views/static/audio/mute.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/audio/mute.mp3 -------------------------------------------------------------------------------- /server/views/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/img/logo.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | */node_modules 2 | */npm-debug.log 3 | .github 4 | /assets/ 5 | /client/ 6 | README.md 7 | LICENSE 8 | .gitignore -------------------------------------------------------------------------------- /server/views/static/audio/unmute.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/audio/unmute.mp3 -------------------------------------------------------------------------------- /client/client/webfonts/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-solid-900.eot -------------------------------------------------------------------------------- /client/client/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /server/views/static/audio/connect.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/audio/connect.mp3 -------------------------------------------------------------------------------- /server/views/static/audio/mute_mic.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/audio/mute_mic.mp3 -------------------------------------------------------------------------------- /server/views/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/favicon-16x16.png -------------------------------------------------------------------------------- /server/views/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/favicon-32x32.png -------------------------------------------------------------------------------- /server/views/static/img/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/img/logo-white.png -------------------------------------------------------------------------------- /server/views/static/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/mstile-150x150.png -------------------------------------------------------------------------------- /client/client/webfonts/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-brands-400.eot -------------------------------------------------------------------------------- /client/client/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /client/client/webfonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-brands-400.woff -------------------------------------------------------------------------------- /client/client/webfonts/fa-regular-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-regular-400.eot -------------------------------------------------------------------------------- /client/client/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /client/client/webfonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-solid-900.woff -------------------------------------------------------------------------------- /client/client/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /server/views/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/apple-touch-icon.png -------------------------------------------------------------------------------- /server/views/static/audio/disconnect.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/audio/disconnect.mp3 -------------------------------------------------------------------------------- /server/views/static/audio/unmute_mic.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/audio/unmute_mic.mp3 -------------------------------------------------------------------------------- /server/views/static/img/logo-white-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/img/logo-white-bg.png -------------------------------------------------------------------------------- /client/client/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /client/client/webfonts/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-regular-400.woff -------------------------------------------------------------------------------- /client/client/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/client/client/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /server/views/static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /server/views/static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-brands-400.eot -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-solid-900.eot -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-solid-900.woff -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.sqlite 3 | *.sqlite-journal 4 | node_modules/ 5 | *.cert 6 | *.key 7 | secret.txt 8 | config.json 9 | */dist 10 | .vscode/ -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-brands-400.woff -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-regular-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-regular-400.eot -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-regular-400.woff -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /server/views/static/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/OpenChat/HEAD/server/views/static/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /server/views/messages/_ogp.ejs: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | <%= ogp.siteName %><%= ogp.title %> 5 |
6 | <%= ogp.desc %> 7 |

8 |
-------------------------------------------------------------------------------- /server/scripts/create_cert/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create_cert", 3 | "version": "1.0.0", 4 | "description": "Script to create certificate using forge", 5 | "main": "script.js", 6 | "author": "RV", 7 | "license": "ISC", 8 | "dependencies": { 9 | "mkcert": "^1.4.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/views/messages/_yt_embed.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 |
7 |
8 | -------------------------------------------------------------------------------- /server/views/static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /server/views/messages/_spotify_embed.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
-------------------------------------------------------------------------------- /server/views/client/_text_channel.ejs: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | <% if(req.user.permissions.permission_edit_channels){ %> 4 | 5 | <% } %> 6 | 7 | <%= data.name %> 8 | 9 |
    10 | 11 |
  • 12 | -------------------------------------------------------------------------------- /server/scripts/secret.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto'); 2 | var fs = require('fs'); 3 | 4 | module.exports = ()=>{ 5 | var secret; 6 | if (fs.existsSync('./secret.txt')) { 7 | secret = fs.readFileSync('./secret.txt', 'utf8'); 8 | return secret; 9 | } else { 10 | secret = crypto.randomBytes(128).toString('hex'); 11 | fs.writeFileSync('./secret.txt', secret, 'utf8'); 12 | return secret; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/scripts/config.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | module.exports = ()=>{ 4 | var config; 5 | try{ 6 | config = require('../config.json'); 7 | } catch(err){ 8 | console.log('Creating Config...') 9 | config = { 10 | name: "OpenChat Server" 11 | } 12 | fs.writeFile('./config.json', JSON.stringify(config), 'utf8', (err) => { 13 | if (err) throw err; 14 | }); 15 | } 16 | return config; 17 | } 18 | -------------------------------------------------------------------------------- /server/views/client/_voice_channel.ejs: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | <% if(req.user.permissions.permission_edit_channels){ %> 4 | 5 | <% } %> 6 | 7 | <%= data.name %> 8 | 9 |
    10 | 11 |
  • -------------------------------------------------------------------------------- /client/resources/prefs_default.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName" : "Openchat User", 3 | "audioSource": "default", 4 | "audioOutput": "default", 5 | "inputConstraints": { 6 | "sampleRate": 64000, 7 | "volume": 1.0, 8 | "noiseSuppression": false, 9 | "echoCancellation": false, 10 | "autoGainControl": true 11 | }, 12 | "outputConstraints": { 13 | "volume_call" : 1.0, 14 | "volume_misc" : 0.5 15 | }, 16 | "servers": [] 17 | } -------------------------------------------------------------------------------- /server/views/mcu/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /server/views/static/js/email-pass.js: -------------------------------------------------------------------------------- 1 | function hash(salt, message) { 2 | var hash = forge.md.sha256.create(); 3 | hash.update(salt + message); 4 | return hash.digest().toHex(); 5 | }; 6 | 7 | $( document ).ready(function() { 8 | $('#login-form').submit(function() { 9 | $('#login-form').hide(); 10 | var pass = $('#password').val(); 11 | var salt = $('#salt').val(); 12 | $('#password').val(hash(salt, pass)); 13 | $('#logo').hide(); 14 | $('#loading_spinner').show(); 15 | return true; 16 | }); 17 | }); -------------------------------------------------------------------------------- /server/controllers/mcu/mcu.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var express = require('express'); 3 | var router = express.Router(); 4 | 5 | 6 | module.exports = function({secret}){ 7 | router.use((req, res, next) => { 8 | if (req.connection.localAddress === req.connection.remoteAddress){ 9 | next(); 10 | } else { 11 | res.status(400).send('MCU needs to be ran locally'); 12 | } 13 | }, express.static('./views/static')); 14 | 15 | router.get('/', function(req, res) { 16 | res.render('mcu/index', {mcu_secret: secret}); 17 | }); 18 | 19 | return router; 20 | }; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | WORKDIR /usr/src/openchat 3 | 4 | COPY server/package*.json ./ 5 | 6 | COPY server/ . 7 | 8 | RUN apt-get update \ 9 | && apt-get install -y wget gnupg ca-certificates \ 10 | && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 11 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 12 | && apt-get update \ 13 | && apt-get install -y libxss1 google-chrome-stable \ 14 | && rm -rf /var/lib/apt/lists/* 15 | 16 | RUN npm install 17 | 18 | EXPOSE 443 19 | CMD [ "node", "server.js" ] 20 | 21 | -------------------------------------------------------------------------------- /server/views/messages/_twitch_embed.ejs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 | 5 | 10 |
    11 |
    12 |
    -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | Asset 1 -------------------------------------------------------------------------------- /server/helpers/expressfunctions.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | checkAuth: (req, res, next)=> { 3 | if (req.isAuthenticated()) { 4 | return next(); 5 | } 6 | res.redirect("/auth"); 7 | }, 8 | 9 | checkNotAuth: (req, res, next)=> { 10 | if (!req.isAuthenticated()) { 11 | return next(); 12 | } 13 | res.redirect("/"); 14 | }, 15 | 16 | hasPermission: (perm)=>{ 17 | return function(req, res, next){ 18 | if (req.isAuthenticated()){ 19 | if(req.user.permissions[perm] === true){ 20 | return next(); 21 | } 22 | res.redirect('/'); 23 | } else { 24 | res.redirect('/admin/login') 25 | }; 26 | } 27 | } 28 | }; -------------------------------------------------------------------------------- /server/views/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | Asset 1 -------------------------------------------------------------------------------- /server/controllers/mcu/mcu_launcher.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | var browser; 3 | var page; 4 | 5 | async function startMCU({isHeadless, port}) { 6 | try { 7 | browser = await puppeteer.launch( 8 | { 9 | headless: isHeadless, 10 | args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required', '--disable-dev-shm-usage', '--disable-gpu', '--disable-setuid-sandbox'], 11 | ignoreHTTPSErrors: true 12 | } 13 | ); 14 | page = await browser.newPage(); 15 | await page.setDefaultNavigationTimeout(0); 16 | await page.goto(`https://localhost:${port}/mcu`, {"waitUntil" : "networkidle0"}); //load local page with JS for MCU 17 | await page.click('#button'); 18 | } catch (err) { 19 | if(browser !== undefined) browser.close(); 20 | console.log(err) 21 | startMCU({isHeadless, port}); 22 | } 23 | }; 24 | 25 | module.exports = startMCU; -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openchat", 3 | "version": "1.1.1", 4 | "description": "Free, Open Source Communication", 5 | "main": "main.js", 6 | "scripts": { 7 | "start": "electron . --dev", 8 | "nodev": "electron .", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "repository": "https://github.com/reesvarney/OpenChat.git", 12 | "author": "RV", 13 | "license": "GPL-3.0-or-later", 14 | "devDependencies": { 15 | "electron": "^13.6.6", 16 | "electron-builder": "^22.9.1" 17 | }, 18 | "build": { 19 | "extraResources": [ 20 | { 21 | "from": "./resources/", 22 | "to": "./", 23 | "filter": [ 24 | "**/*" 25 | ] 26 | } 27 | ] 28 | }, 29 | "dependencies": { 30 | "electron-titlebar": "0.0.3", 31 | "electron-updater": "^4.3.5", 32 | "handlebars": "^4.7.7", 33 | "jquery": "^3.5.1", 34 | "keypair": "^1.0.4", 35 | "peerjs-client": "^0.3.15" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/scripts/create_cert/script.js: -------------------------------------------------------------------------------- 1 | const mkcert = require('mkcert'); 2 | const fs = require('fs'); 3 | 4 | module.exports = async()=>{ 5 | console.log('SSL: Generating self signed certificates...') 6 | 7 | if (!fs.existsSync('./ssl')){ 8 | fs.mkdirSync('./ssl'); 9 | } 10 | 11 | const ca = await mkcert.createCA({ 12 | organization: 'OpenChat', 13 | countryCode: 'US', 14 | state: 'Virginia', 15 | locality: 'Blacksburgh', 16 | validityDays: 365 17 | }); 18 | 19 | const cert = await mkcert.createCert({ 20 | domains: ['127.0.0.1', 'localhost'], 21 | validityDays: 365, 22 | caKey: ca.key, 23 | caCert: ca.cert 24 | }); 25 | 26 | fs.writeFile('./ssl/server.key', cert.key, 'utf8', (err) => { 27 | if (err) throw err; 28 | console.log('SSL: Key Saved'); 29 | }); 30 | 31 | fs.writeFile('./ssl/server.cert', cert.cert, 'utf8', (err) => { 32 | if (err) throw err; 33 | console.log('SSL: Certificate Saved'); 34 | }); 35 | 36 | return cert; 37 | } 38 | -------------------------------------------------------------------------------- /client/client/js/livevar.js: -------------------------------------------------------------------------------- 1 | module.exports = class{ 2 | constructor(val){ 3 | var innerValue = undefined || val; 4 | this.initVal = val; 5 | this.listeners = { 6 | update: [], 7 | change: [] 8 | }; 9 | Object.defineProperty(this, "value", { 10 | get: function () { 11 | return innerValue; 12 | }, 13 | set: function (v) { 14 | var ov = innerValue; 15 | innerValue = v; 16 | this.listeners.update.forEach((l)=>{ 17 | l(v, ov); 18 | }); 19 | if (ov !== v){ 20 | this.listeners.change.forEach((l)=>{ 21 | l(v, ov); 22 | }); 23 | } 24 | } 25 | }); 26 | }; 27 | 28 | onUpdate(f){ 29 | this.listeners.update.push(f); 30 | } 31 | 32 | onChange(f){ 33 | this.listeners.change.push(f); 34 | } 35 | 36 | reset(){ 37 | this.value = this.initVal; 38 | } 39 | 40 | syncDOM(el, prop="innerText"){ 41 | this.listeners.push((v)=>{ 42 | el[prop] = v; 43 | }) 44 | } 45 | } -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openchat", 3 | "version": "1.1.1", 4 | "description": "Free and open source communication platform", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server", 8 | "showmcu": "node server showmcu" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/reesvarney/OpenChat.git" 13 | }, 14 | "author": "RV", 15 | "license": "GPL-3.0-or-later", 16 | "dependencies": { 17 | "anchorme": "^2.1.2", 18 | "connect-session-sequelize": "^7.0.1", 19 | "cookie-parser": "^1.4.5", 20 | "crypto": "^1.0.1", 21 | "dotenv": "^8.2.0", 22 | "ejs": "^3.1.5", 23 | "express": "^4.17.1", 24 | "express-rate-limit": "^5.1.3", 25 | "express-session": "^1.17.1", 26 | "jsdom": "^16.4.0", 27 | "open-graph-scraper": "^4.5.1", 28 | "passport": "^0.4.1", 29 | "passport-local": "^1.0.0", 30 | "peer": "^0.5.3", 31 | "puppeteer": "^5.2.1", 32 | "sequelize": "^6.3.3", 33 | "socket.io": "^2.4.1", 34 | "sqlite3": "^5.0.0", 35 | "uuid": "^8.3.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/controllers/client/client.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | 4 | module.exports = function ({ config, db, expressFunctions }) { 5 | router.use(express.static("./views/static")); 6 | 7 | router.get("/", expressFunctions.checkAuth, async(req, res)=>{ 8 | var viewData = { config, db, req }; 9 | 10 | // Customise data according to permissions 11 | if(req.user.permissions.permission_edit_roles){ 12 | viewData["roles"] = await db.models.Role.findAll(); 13 | } else { 14 | viewData["roles"] = {}; 15 | }; 16 | 17 | // Render the view 18 | res.render("client/index", viewData); 19 | }); 20 | 21 | router.get("/channels/:id", expressFunctions.checkAuth, async(req, res)=>{ 22 | var channel = await db.models.Channel.findByPk(req.params.id); 23 | if(channel === null){ 24 | res.status(400).send("Channel does not exist"); 25 | } else { 26 | var viewData = { req, data: channel}; 27 | res.render(`client/_${channel.type}_channel`, viewData); 28 | } 29 | }); 30 | 31 | return router; 32 | }; 33 | -------------------------------------------------------------------------------- /client/client/css/colors.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* COLORS */ 3 | /* BACKGROUND */ 4 | --bg-h: 0; 5 | --bg-s: 0%; 6 | --bg-l: 100%; 7 | 8 | /* ACCENT */ 9 | --accent-h: 150; 10 | --accent-s: 60%; 11 | --accent-l: 75%; 12 | 13 | /* CALCULATED VALUES*/ 14 | --bg: hsl(var(--bg-h), var(--bg-s), var(--bg-l)); 15 | --bg-darker-5: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .95)); 16 | --bg-darker-10: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .9)); 17 | --bg-darker-15: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .85)); 18 | --bg-darker-20: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .8)); 19 | --bg-darker-40: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .6)); 20 | --bg-darker-60: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .4)); 21 | --bg-darker-80: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .2)); 22 | 23 | --accent: hsl(var(--accent-h), var(--accent-s), var(--accent-l)); 24 | --accent-contrast: hsl(var(--accent-h), calc(var(--accent-s) / 0.8), calc(var(--accent-l) * 0.8)); 25 | }; 26 | 27 | .dark *{ 28 | color: white !important 29 | } -------------------------------------------------------------------------------- /server/views/static/js/admin.js: -------------------------------------------------------------------------------- 1 | function addChannel(type){ 2 | $.ajax({ 3 | async: true, 4 | type: 'GET', 5 | url: "channel/template", 6 | data: { "type": type }, 7 | success: ( function( result ){ 8 | $(`#${type}_channels`).append(result); 9 | }) 10 | }); 11 | $(`.add-channel`).hide(); 12 | $(`.update-btn`).hide(); 13 | $(`.delete-btn`).hide(); 14 | }; 15 | 16 | function cancelChannel(uuid){ 17 | $(`#new_channel`).parent().remove(); 18 | $(`.add-channel`).show(); 19 | $(`.update-btn`).show(); 20 | $(`.delete-btn`).show(); 21 | } 22 | 23 | function deleteChannel(uuid){ 24 | $.ajax({ 25 | async: true, 26 | type: 'DELETE', 27 | url: `channel/${uuid}`, 28 | success: ( function( result ){ 29 | location.reload(); 30 | }) 31 | }); 32 | } 33 | 34 | function removeFromBlacklist(ip){ 35 | $.ajax({ 36 | async: true, 37 | type: 'DELETE', 38 | url: `users/blacklist`, 39 | data: {ip: ip}, 40 | success: ( function( result ){ 41 | location.reload(); 42 | }) 43 | }); 44 | } -------------------------------------------------------------------------------- /server/views/static/js/smartscroll.js: -------------------------------------------------------------------------------- 1 | class smartScroll{ 2 | constructor(element){ 3 | this.element = element; 4 | this.scrolling = false; 5 | this.scrollDestination; 6 | } 7 | 8 | //returns whether user is near bottom of element 9 | doesScroll(range){ 10 | var source = (this.scrolling) ? this.scrollDestination : $(this.element).scrollTop(); 11 | if(($(this.element).prop('scrollHeight') - (source + $(this.element).height())) < range + 100 ) { 12 | return true; 13 | } else { 14 | return false; 15 | } 16 | }; 17 | 18 | goToChild(child){ 19 | this.scrollDestination = child.offset().top; 20 | this.element.scrollTop(this.scrollDestination); 21 | } 22 | 23 | goToBottom(smooth=true){ 24 | this.scrollDestination = this.element[0].scrollHeight; 25 | if(smooth){ 26 | this.scrolling = true; 27 | this.element.animate({ 28 | scrollTop: this.scrollDestination 29 | }, 500, ()=>{ 30 | this.scrolling = false; 31 | }); 32 | } else { 33 | this.element.scrollTop(this.scrollDestination); 34 | } 35 | } 36 | 37 | 38 | } -------------------------------------------------------------------------------- /server/views/auth/anon/_head.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | OpenChat Login 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /server/views/auth/email-pass/_head.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | OpenChat Login 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /server/controllers/auth/auth.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | var authMethods = []; 6 | 7 | module.exports = function (controllerParams) { 8 | fs.readdir(path.join(__dirname, './methods'), (err, files) => { 9 | files.forEach((file) => { 10 | var method = require(path.join(__dirname, './methods', file)); 11 | controllerParams.passport.use(method.name, method.strategy(controllerParams)); 12 | if ("models" in method) { 13 | controllerParams.addModels(method.models); 14 | }; 15 | if (!method.hidden) authMethods.push({ 16 | path: method.name, 17 | name: method.displayName, 18 | icon: method.icon 19 | }); 20 | var methodRouter = method.router(method.name, controllerParams); 21 | methodRouter.use(express.static("./views/static")); 22 | router.use(`/${method.name}`, methodRouter); 23 | }); 24 | }); 25 | 26 | // TODO - Create page to select method. Methods should have a selectable attribute designating whether they show on this page 27 | router.get("/", controllerParams.expressFunctions.checkNotAuth, (req, res) => { 28 | res.render('auth/index', {authMethods}) 29 | }); 30 | 31 | router.get('/logout', function(req, res){ 32 | req.logout(); 33 | res.redirect('/'); 34 | }); 35 | 36 | router.use(express.static("./views/static")); 37 | 38 | return router; 39 | }; -------------------------------------------------------------------------------- /server/controllers/auth/methods/anon.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var LocalStrategy = require("passport-local").Strategy; 4 | const { v4: uuidv4 } = require("uuid"); 5 | function validateName(username){ 6 | if (username !== undefined && username.length >= 2 && username.length <= 32) { 7 | return true; 8 | } else { 9 | return false; 10 | } 11 | }; 12 | 13 | module.exports = { 14 | name: "anon", 15 | displayName: "Anonymous Login", 16 | icon: "user-secret", 17 | hidden: false, 18 | router: (name, {expressFunctions, passport})=>{ 19 | router.get("/", expressFunctions.checkNotAuth, (req, res) => { 20 | res.render('auth/anon/login'); 21 | }); 22 | 23 | router.post("/", passport.authenticate(name, { failureRedirect: "/auth/anon?failed" }), (req, res) => { 24 | res.redirect('/'); 25 | }); 26 | 27 | return router; 28 | }, 29 | 30 | strategy: ({temp_users})=>{ 31 | return new LocalStrategy( 32 | { 33 | usernameField: "name", 34 | passwordField: "name", 35 | passReqToCallback: true, 36 | }, 37 | function (req, username, password, done) { 38 | var userID = `t::-${uuidv4()}`; 39 | var data = { 40 | id: userID, 41 | name: username, 42 | }; 43 | if (validateName(username)) { 44 | temp_users[userID] = data; 45 | return done(null, data); 46 | } else { 47 | return done(null, false); 48 | } 49 | } 50 | ) 51 | } 52 | } -------------------------------------------------------------------------------- /server/views/messages/messages.ejs: -------------------------------------------------------------------------------- 1 | <% for(i = 0; i < data.length; i++){ 2 | var message = data[i]; 3 | %> 4 |
    5 |

    6 | <%= message.sender %> 7 | 8 | 19 | 20 |
    21 | <%- message.content %> 22 |

    23 | 24 | <% if("ogp" in message){ %> 25 | <%- include('_ogp', {ogp: message.ogp}) %> 26 | <% } %> 27 | 28 | <% if("yt" in message){ %> 29 | <%- include('_yt_embed', {id: message.id, url: message.yt}) %> 30 | <% } %> 31 | 32 | <% if("twitch" in message){ %> 33 | <%- include('_twitch_embed', {id: message.id, url: message.twitch}) %> 34 | <% } %> 35 | 36 | <% if("spotify" in message){ %> 37 | <%- include('_spotify_embed', {id: message.id, url: message.spotify}) %> 38 | <% } %> 39 |
    40 | <% } %> -------------------------------------------------------------------------------- /server/views/client/_head.ejs: -------------------------------------------------------------------------------- 1 | 2 | OpenChat 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | <% if(req.user.permissions.permission_edit_channels){%> 32 | 33 | <% } %> 34 | -------------------------------------------------------------------------------- /server/views/auth/anon/login.ejs: -------------------------------------------------------------------------------- 1 | <%- include('_head') %> 2 | 3 | 4 |
    5 |
    6 |
    7 | 8 |
    9 |
    10 |

    Choose a name

    11 |
    12 |
    13 | 14 |
    15 | As an anonymous user you may not be able to access to the same features as others, such as messaging. If you would like access to this please select another method. 16 | 17 |
    18 |
    19 | 20 |
    21 |
    22 | 23 | 24 |
    25 |
    26 |
    27 | 28 |
    29 |

    OR

    30 |

    Select a different method

    31 |
    32 |
    33 | 34 |
    35 | -------------------------------------------------------------------------------- /server/scripts/create_cert/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create_cert", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "commander": { 8 | "version": "6.1.0", 9 | "resolved": "https://registry.npmjs.org/commander/-/commander-6.1.0.tgz", 10 | "integrity": "sha512-wl7PNrYWd2y5mp1OK/LhTlv8Ff4kQJQRXXAvF+uU/TPNiVJUxZLRYGj/B0y/lPGAVcSbJqH2Za/cvHmrPMC8mA==" 11 | }, 12 | "ip-regex": { 13 | "version": "4.2.0", 14 | "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.2.0.tgz", 15 | "integrity": "sha512-n5cDDeTWWRwK1EBoWwRti+8nP4NbytBBY0pldmnIkq6Z55KNFmWofh4rl9dPZpj+U/nVq7gweR3ylrvMt4YZ5A==" 16 | }, 17 | "is-ip": { 18 | "version": "3.1.0", 19 | "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-3.1.0.tgz", 20 | "integrity": "sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==", 21 | "requires": { 22 | "ip-regex": "^4.0.0" 23 | } 24 | }, 25 | "mkcert": { 26 | "version": "1.4.0", 27 | "resolved": "https://registry.npmjs.org/mkcert/-/mkcert-1.4.0.tgz", 28 | "integrity": "sha512-0cQXdsoOKq7EHS4Jkxnj16JA4eTt/noXUcaFr44aFAlqfgdCmIGqfGcGoosdXf46YzbaEfEQmrsHGYFV9XvpmA==", 29 | "requires": { 30 | "commander": "^6.1.0", 31 | "is-ip": "^3.1.0", 32 | "node-forge": "^0.10.0", 33 | "random-int": "^2.0.1" 34 | } 35 | }, 36 | "node-forge": { 37 | "version": "0.10.0", 38 | "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", 39 | "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" 40 | }, 41 | "random-int": { 42 | "version": "2.0.1", 43 | "resolved": "https://registry.npmjs.org/random-int/-/random-int-2.0.1.tgz", 44 | "integrity": "sha512-YALjWK2Rt9EMIv9BF/3mvlzFWQathsvb5UZmN1QmhfIOfcQYXc/UcLzg0ablqesSBpBVLt2Tlwv/eTuBh4LXUQ==" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/db/init.js: -------------------------------------------------------------------------------- 1 | const { Sequelize } = require("sequelize"); 2 | const sequelize = new Sequelize({ 3 | dialect: "sqlite", 4 | storage: "./db/sqlite/db.sqlite", 5 | logging: false 6 | }); 7 | 8 | const relations = { 9 | belongsTo: function (model, target, opts) { 10 | sequelize.models[model].belongsTo(sequelize.models[target], opts); 11 | }, 12 | hasMany: function (model, target, opts) { 13 | sequelize.models[model].hasMany(sequelize.models[target], opts); 14 | }, 15 | belongsToMany: function (model, target, opts) { 16 | sequelize.models[model].belongsToMany(sequelize.models[target], opts); 17 | }, 18 | hasOne: function (model, target, opts) { 19 | sequelize.models[model].hasOne(sequelize.models[target], opts); 20 | }, 21 | }; 22 | 23 | // TODO: Switch to ts and expand documentation 24 | /** 25 | * Adds a set of models into the database 26 | * @param {Object} model_data - The database model to be added. There is not currently any documentation but models.js should contain a good enough example 27 | * @returns {Promise} Returns a promise which resolves to a sequelize object when the sync has completed. This is the same as `db` so will probably not be needed directly. 28 | */ 29 | 30 | function addModels(model_data){ 31 | for (const [name, model] of Object.entries(model_data)) { 32 | var tmodel = sequelize.define(name, model.attributes, model.options); 33 | }; 34 | 35 | for (const [name, model] of Object.entries(model_data)) { 36 | if("relations" in model){ 37 | model.relations.forEach( (relationship) => { 38 | if (relationship.options === undefined){ relationship.options = {}} 39 | relations[relationship.relation](name, relationship.model, relationship.options); 40 | }); 41 | }; 42 | }; 43 | 44 | return new Promise((resolve, reject) => { 45 | sequelize.sync({alter: {drop: false}}).then(() => { 46 | resolve(sequelize); 47 | }); 48 | }) 49 | }; 50 | 51 | module.exports = { 52 | dbPromise: addModels(require("./models.js")), 53 | addModels 54 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build/Release 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [created] 7 | 8 | jobs: 9 | build-windows: 10 | name: Electron Build Windows 11 | runs-on: windows-latest 12 | 13 | env: 14 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | EP_PRE_RELEASE: true 16 | 17 | steps: 18 | - name: Check out Git repository 19 | uses: actions/checkout@v1 20 | 21 | - name: Install Node.js, NPM and Yarn 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: 10 25 | 26 | - name: Install dependencies 27 | working-directory: client 28 | run: npm i --dev 29 | 30 | - name: Electron Build 31 | working-directory: client 32 | run: npx electron-builder build --publish always --win nsis portable 33 | 34 | build-macos: 35 | name: Electron Build macOS 36 | 37 | runs-on: macos-latest 38 | 39 | env: 40 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | EP_PRE_RELEASE: true 42 | 43 | steps: 44 | - name: Check out Git repository 45 | uses: actions/checkout@v1 46 | 47 | - name: Install Node.js, NPM and Yarn 48 | uses: actions/setup-node@v1 49 | with: 50 | node-version: 10 51 | 52 | - name: Install dependencies 53 | working-directory: client 54 | run: npm i --dev 55 | 56 | - name: Electron Build 57 | working-directory: client 58 | run: npx electron-builder build --publish always --macos dmg 59 | 60 | build-linux: 61 | name: Electron Build Linux 62 | 63 | runs-on: ubuntu-latest 64 | 65 | env: 66 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | EP_PRE_RELEASE: true 68 | 69 | steps: 70 | 71 | - name: Install prequisite packages 72 | run: sudo snap install snapcraft --classic 73 | 74 | - name: Check out Git repository 75 | uses: actions/checkout@v1 76 | 77 | - name: Install Node.js, NPM and Yarn 78 | uses: actions/setup-node@v1 79 | with: 80 | node-version: 10 81 | 82 | - name: Install dependencies 83 | working-directory: client 84 | run: npm i --dev 85 | 86 | - name: Electron Build 87 | working-directory: client 88 | run: npx electron-builder build --publish always --linux AppImage -------------------------------------------------------------------------------- /server/views/static/js/client-edit_channels.js: -------------------------------------------------------------------------------- 1 | $( document ).ready(()=>{ 2 | $("#add_channel_btn").click(function () { 3 | overlay.show("add_channel"); 4 | }); 5 | 6 | $("#channels").on('click', '.edit_channel_btn',function(){ 7 | overlay.show('edit_channel'); 8 | $('#edit_channel form').attr('action', `admin/channel/edit/${$(this).parent().find('.channel')[0].id}`) 9 | $('#edit_channel form input[name="name"]').val($(this).parent().find('.channel')[0].innerText) 10 | }) 11 | 12 | $("#edit_channel_actions>#channel_delete_btn").on('click', function(){ 13 | var action = $('#edit_channel form').attr('action'); 14 | $.ajax({ 15 | async: true, 16 | type: 'DELETE', 17 | url: action, 18 | data: {}, 19 | timeout: 10000, 20 | success: ((result)=>{ 21 | overlay.hide() 22 | }) 23 | }); 24 | }) 25 | 26 | $(".role_edit_form").on( 'submit', function (e) { 27 | e.preventDefault(); 28 | var data = {}; 29 | $(this).serializeArray().forEach((perm)=>{ 30 | if(!(perm.name in data && data[perm.name] == "true")){ 31 | data[perm.name] = perm.value 32 | }; 33 | }); 34 | $.ajax({ 35 | async: true, 36 | type: 'POST', 37 | url: this.action, 38 | data: data, 39 | timeout: 10000, 40 | success: ((result)=>{ 41 | }) 42 | }); 43 | }); 44 | 45 | $("#edit_channel form").on( 'submit', function (e) { 46 | e.preventDefault(); 47 | $.ajax({ 48 | async: true, 49 | type: 'POST', 50 | url: this.action, 51 | data: $(this).serialize(), 52 | timeout: 10000, 53 | success: ((result)=>{ 54 | console.log(result) 55 | overlay.hide(); 56 | }) 57 | }); 58 | }); 59 | 60 | $("#add_channel form").on( 'submit', function (e) { 61 | e.preventDefault(); 62 | $.ajax({ 63 | async: true, 64 | type: 'POST', 65 | url: this.action, 66 | data: $(this).serialize(), 67 | timeout: 10000, 68 | success: ((result)=>{ 69 | overlay.hide() 70 | }) 71 | }); 72 | }); 73 | 74 | $("#edit_channel_actions>#channel_save_btn").on('click', function(){ 75 | $('#edit_channel form').submit(); 76 | }) 77 | }); -------------------------------------------------------------------------------- /server/controllers/auth/methods/pubkey.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var crypto = require("crypto"); 4 | var LocalStrategy = require("passport-local").Strategy; 5 | 6 | function encrypt(pub_key, data) { 7 | return crypto.publicEncrypt(pub_key, Buffer.from(data, "utf-8")); 8 | } 9 | 10 | module.exports = { 11 | name: "pubkey", 12 | hidden: true, 13 | router: (name, {expressFunctions, passport})=>{ 14 | router.get("/", expressFunctions.checkNotAuth, function (req, res) { 15 | var pub_key = req.query.public_key; 16 | req.session.authData = crypto.randomBytes(64); 17 | req.session.publicKey = pub_key; 18 | var enc_data = encrypt(pub_key, req.session.authData); 19 | res.send({ encoded_data: enc_data }); 20 | }); 21 | 22 | router.post("/", passport.authenticate(name, { failureRedirect: "/auth" }), function (req, res) { 23 | res.send("success"); 24 | }); 25 | 26 | return router; 27 | }, 28 | strategy: ({db, addModels})=>{ 29 | return new LocalStrategy( 30 | { 31 | usernameField: "name", 32 | passwordField: "decrypted", 33 | passReqToCallback: true, 34 | }, 35 | function (req, username, password, done) { 36 | if ( 37 | Buffer.from(JSON.parse(password).data).equals( 38 | Buffer.from(req.session.authData.data) 39 | ) 40 | ) { 41 | var key_raw = req.session.publicKey; 42 | var publicKey = key_raw.replace(/(?:\r\n|\r|\n)/g, ""); 43 | db.models.Pubkey.findOrCreate({ 44 | where: { 45 | pub_key: publicKey, 46 | }, 47 | include: db.models.User, 48 | defaults: { 49 | pub_key: publicKey, 50 | }, 51 | }).then((result) => { 52 | if( !("User" in result[0]) || result[0].User === null){ 53 | result[0].createUser({name: username}).then((user)=>{ 54 | return done(null, user.dataValues); 55 | }) 56 | } else { 57 | if (result[0].dataValues.name != username) { 58 | result[0].User.update({ 59 | name: username, 60 | }).then((user)=>{ 61 | return done(null, user.dataValues); 62 | }); 63 | } else { 64 | console.log(result[0].User.dataValues) 65 | return done(null, result[0].User.dataValues); 66 | } 67 | } 68 | }); 69 | } else { 70 | return done(null, false); 71 | } 72 | req.session.authData = null; 73 | } 74 | ) 75 | }, 76 | } 77 | 78 | -------------------------------------------------------------------------------- /server/views/auth/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | OpenChat Login 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
    27 |
    28 |
    29 | 30 |
    31 |

    Welcome

    32 |

    33 | Please select a method to login 34 |

    35 |
    36 | <% authMethods.forEach(method => { %> 37 | 38 | 39 | 40 | <%=method.name%> 41 | 42 | 43 |
    44 | <% }); %> 45 |
    46 | 47 |
    48 | Alternatively, download the client. 49 |
    50 |
    51 | 52 | -------------------------------------------------------------------------------- /server/views/auth/email-pass/login.ejs: -------------------------------------------------------------------------------- 1 | <%- include('_head') %> 2 | 3 | 4 |
    5 |
    6 |
    7 | 8 | 9 |
    10 | <% if (step == 2){ %> 11 | 28 | <% } else { %> 29 | 52 | 53 | <% } %> 54 |
    55 | -------------------------------------------------------------------------------- /server/views/auth/email-pass/register.ejs: -------------------------------------------------------------------------------- 1 | <%- include('_head') %> 2 | 3 | 4 |
    5 |
    6 |
    7 | 8 |
    9 | 58 |
    59 | 60 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow, ipcMain, Menu, shell } = require("electron"); 2 | const crypto = require('crypto'); 3 | var keypair = require('keypair'); 4 | const { URL } = require('url'); 5 | const path = require('path'); 6 | const fs = require('fs'); 7 | app.commandLine.appendSwitch('ignore-certificate-errors', 'true'); 8 | app.commandLine.appendSwitch('allow-insecure-localhost', 'true'); 9 | const prefsPath = path.join(app.getPath('userData'), "./prefs.json"); 10 | var userPrefs; 11 | 12 | try { 13 | userPrefs = JSON.parse(fs.readFileSync(prefsPath)); 14 | } catch(error) { 15 | try { 16 | userPrefs = JSON.parse(fs.readFileSync('./resources/prefs_default.json')); 17 | } catch (err) { 18 | userPrefs = JSON.parse(fs.readFileSync(path.join(process.resourcesPath, './prefs_default.json'))); 19 | } 20 | } 21 | 22 | function savePrefs(){ 23 | fs.writeFileSync(prefsPath, JSON.stringify(userPrefs)); 24 | } 25 | 26 | function isDev() { 27 | return process.argv[2] == '--dev'; 28 | } 29 | 30 | function createWindow() { 31 | const win = new BrowserWindow({ 32 | width: 800, 33 | height: 600, 34 | minHeight: 600, 35 | minWidth: 470, 36 | webPreferences: { 37 | nodeIntegration: true, 38 | webviewTag: true, 39 | enableRemoteModule: true 40 | }, 41 | titleBarStyle: 'hidden', 42 | frame: (!isDev())? false : true 43 | }); 44 | win.loadFile("./client/index.html"); 45 | if(!isDev()) win.setMenuBarVisibility(false) 46 | } 47 | 48 | app.whenReady().then(createWindow); 49 | 50 | // Handle external links 51 | app.on('web-contents-created', (e, contents) => { 52 | if (contents.getType() == 'webview') { 53 | contents.on('new-window', (e, loc) => { 54 | e.preventDefault() 55 | shell.openExternal(loc) 56 | }) 57 | } 58 | }) 59 | 60 | ipcMain.on("getUserPrefs", (event) => { 61 | event.returnValue = userPrefs; 62 | }); 63 | 64 | ipcMain.on("setPrefs", (event, prefs) => { 65 | Object.assign(userPrefs, prefs); 66 | savePrefs(); 67 | }); 68 | 69 | ipcMain.on("addServer", (event, url) => { 70 | try{ 71 | var url_normal = new URL(url); 72 | } 73 | catch(err){ 74 | event.returnValue = false; 75 | return false; 76 | } 77 | if (!userPrefs.servers.includes(url_normal.origin)){ 78 | userPrefs.servers.push(url_normal.origin); 79 | savePrefs(); 80 | event.returnValue = true; 81 | } else { 82 | event.returnValue = false; 83 | }; 84 | }); 85 | 86 | const pub_key_path = path.join(app.getPath('userData'), "./identity/public.pem"); 87 | const priv_key_path = path.join(app.getPath('userData'), "./identity/private.pem"); 88 | 89 | if(!(fs.existsSync(pub_key_path) && fs.existsSync(priv_key_path))){ 90 | var pair = keypair(); 91 | 92 | var id_dir = path.join(app.getPath('userData'), "./identity"); 93 | 94 | if (!fs.existsSync(id_dir)){ 95 | fs.mkdirSync(id_dir); 96 | }; 97 | 98 | fs.writeFileSync(pub_key_path, pair.public); 99 | fs.writeFileSync(priv_key_path, pair.private); 100 | } 101 | 102 | var keys = { 103 | public: fs.readFileSync(pub_key_path), 104 | private: fs.readFileSync(priv_key_path) 105 | } 106 | 107 | global.pub_key = keys.public.toString('utf8'); 108 | 109 | ipcMain.on("decrypt", (event, data) => { 110 | var buffer = Buffer.from(data.data, 'utf-8'); 111 | var decoded = crypto.privateDecrypt(keys.private.toString('utf8'), buffer); 112 | event.returnValue = JSON.stringify(decoded); 113 | }); -------------------------------------------------------------------------------- /server/controllers/auth/init.js: -------------------------------------------------------------------------------- 1 | function initialize(passport, db, temp_users) { 2 | var setup_mode = false; 3 | db.models.Role.findOrCreate({ 4 | where: { 5 | name: "default" 6 | }, 7 | defaults: { 8 | name: "default" 9 | } 10 | }).then((defaultPermissions) =>{ 11 | db.models.Role.findOne({ 12 | where: { 13 | name: "owner" 14 | }, 15 | include: [db.models.User] 16 | }).then((result)=>{ 17 | if(result === null || result.Users.length == 0){ 18 | console.log('No owners found, entering setup mode - first client to join will be given owner role'); 19 | setup_mode = true; 20 | } 21 | }) 22 | 23 | passport.serializeUser(function (user, done) { 24 | return done(null, user.id); 25 | }); 26 | 27 | passport.deserializeUser(function (id, done) { 28 | // Check if the id starts with `t::` which is used to denote temporary users. Else they are a normal user 29 | if (id.startsWith("t::")) { 30 | if (temp_users[id] === undefined) return done(null, false); 31 | // In future, check for default role 32 | temp_users[id]["permissions"] = defaultPermissions; 33 | temp_users[id]["permissions"].permission_send_message = false; 34 | return done(null, temp_users[id]); 35 | } else { 36 | db.models.User.findOne({ 37 | where: { 38 | id: id, 39 | }, 40 | include: [db.models.Role], 41 | }).then(function (result) { 42 | if(setup_mode){ 43 | db.models.Role.findOrCreate({ 44 | where: { 45 | name: "owner" 46 | }, 47 | defaults: { 48 | name: "owner", 49 | isAdmin: true 50 | } 51 | }).then((adminRole)=>{ 52 | result.addRole(adminRole[0]) 53 | console.log(`User ${result.id} was assigned to owner role, leaving setup mode`) 54 | setup_mode = false; 55 | }) 56 | } 57 | if (result === null) return done(null, false); 58 | var user = result.dataValues; 59 | var permissions = {}; 60 | if(user.Roles.length === 0){ 61 | result.addRole(defaultPermissions[0]).catch(err=>{console.log('Unique constraint exists')}) 62 | for (const [key, value] of Object.entries(defaultPermissions[0].dataValues)) { 63 | if (~key.indexOf("permission")) { 64 | permissions[key] = value ? true : false; 65 | } 66 | } 67 | } else { 68 | user.Roles.some((data) => { 69 | var role = data.dataValues; 70 | if (role.isAdmin) { 71 | for (const [key, value] of Object.entries(role)) { 72 | if (~key.indexOf("permission")) { 73 | permissions[key] = true; 74 | } 75 | } 76 | return true; 77 | } else { 78 | for (const [key, value] of Object.entries(role)) { 79 | if (~key.indexOf("permission")) { 80 | permissions[key] = value ? true : false; 81 | } 82 | } 83 | return false; 84 | } 85 | }); 86 | } 87 | user.permissions = permissions; 88 | return done(null, user); 89 | }); 90 | } 91 | }); 92 | }); 93 | 94 | } 95 | 96 | module.exports = initialize; 97 | -------------------------------------------------------------------------------- /server/controllers/auth/methods/email-pass.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | var crypto = require("crypto"); 4 | var LocalStrategy = require("passport-local").Strategy; 5 | 6 | function encrypt(pub_key, data) { 7 | return crypto.publicEncrypt(pub_key, Buffer.from(data, "utf-8")); 8 | } 9 | 10 | module.exports = { 11 | name: "email-pass", 12 | displayName: "Email/ Password Login", 13 | icon: "lock", 14 | hidden: false, 15 | router: (name, { 16 | expressFunctions, 17 | passport, 18 | db 19 | }) => { 20 | router.get('/', expressFunctions.checkNotAuth, function (req, res) { 21 | if ('email' in req.query && req.query.email.length != 0) { 22 | db.models.EmailPass.findOne({ 23 | where: { 24 | email: req.query.email 25 | } 26 | }).then(function (result) { 27 | var salt = (result === null) ? crypto.randomBytes(128).toString('base64') : result.salt; 28 | res.render('auth/email-pass/login', { 29 | salt: salt, 30 | email: req.query.email, 31 | step: 2 32 | }); 33 | }); 34 | } else { 35 | res.render('auth/email-pass/login', { 36 | step: 1, 37 | failed: ("failed" in req.query) ? true : false 38 | }); 39 | } 40 | }); 41 | 42 | router.post('/', passport.authenticate(name, { 43 | failureRedirect: "/auth/email-pass?failed" 44 | }), function (req, res) { 45 | res.redirect("/"); 46 | }); 47 | 48 | router.get('/register', expressFunctions.checkNotAuth, function (req, res) { 49 | var salt = crypto.randomBytes(128).toString('base64'); 50 | req.session.salt = salt; 51 | res.render('auth/email-pass/register', { 52 | salt: salt 53 | }); 54 | }) 55 | 56 | router.post('/register', expressFunctions.checkNotAuth, function (req, res) { 57 | if (req.session.salt == req.body.salt) { 58 | var private_salt = crypto.randomBytes(128).toString('base64'); 59 | var hash = crypto.createHash('sha256'); 60 | hash.update(req.body.password); 61 | hash.update(private_salt); 62 | var pass_hashed = hash.digest('hex'); 63 | db.models.EmailPass.create({ 64 | email: req.body.email, 65 | salt: req.body.salt, 66 | private_salt: private_salt, 67 | pass_hashed: pass_hashed 68 | }).then((authMethod) => { 69 | authMethod.createUser({ 70 | name: req.body.username 71 | }).then((user) => { 72 | res.redirect(`/auth/email-pass?email=${req.body.email}`) 73 | }) 74 | }); 75 | }; 76 | }); 77 | 78 | return router; 79 | }, 80 | strategy: ({ 81 | db 82 | }) => { 83 | return new LocalStrategy(function (username, password, done) { 84 | var hash = crypto.createHash('sha256'); 85 | hash.update(password); 86 | db.models.EmailPass.findOne({ 87 | where: { 88 | email: username, 89 | }, 90 | include: db.models.User 91 | }).then(function (result) { 92 | if (result === null) return done(null, false); 93 | hash.update(result.dataValues.private_salt); 94 | var pass_hashed = hash.digest('hex'); 95 | if (result.dataValues.pass_hashed != pass_hashed) return done(null, false); 96 | return done(null, result.User.dataValues); 97 | }) 98 | }) 99 | }, 100 | } -------------------------------------------------------------------------------- /client/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | OpenChat 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 33 | 38 | 39 | 40 |
    41 |
    42 | OpenChat 43 |
    44 |
    45 |
    46 | 53 | 54 | 82 |
    83 | 84 | 99 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /client/client/js/bridge.js: -------------------------------------------------------------------------------- 1 | const {ipcRenderer,remote} = require("electron"); 2 | const { resolve } = require("path"); 3 | const {URL} = require('url'); 4 | var url = new URL(window.location.href); 5 | var userPrefs = ipcRenderer.sendSync("getUserPrefs"); 6 | 7 | //passthrough for socket events 8 | global.standalone = true; 9 | async function findDevice(label){ 10 | var devices = await navigator.mediaDevices.enumerateDevices(); 11 | var found = devices.find(device => device.label == label); 12 | console.log(found) 13 | if(found !== undefined){ 14 | return found.deviceId; 15 | } else { 16 | return 'default' 17 | } 18 | } 19 | 20 | (async()=>{ 21 | global.bridge = { 22 | getDark: ()=>{ 23 | return ipcRenderer.sendSync("getUserPrefs").darkMode; 24 | }, 25 | registerSocket: (socket) => { 26 | var onevent = socket.onevent; 27 | 28 | function sendSocketEvent(e, d) { 29 | ipcRenderer.sendToHost('socket_event', { 30 | event: e, 31 | data: d 32 | }); 33 | } 34 | 35 | ipcRenderer.on('socket_event', (e, d) => { 36 | socket.emit(d.event, d.data) 37 | }) 38 | 39 | socket.onevent = function (packet) { 40 | var args = packet.data || []; 41 | onevent.call(this, packet); // original call 42 | packet.data = ["*"].concat(args); 43 | onevent.call(this, packet); // additional call to catch-all 44 | }; 45 | 46 | socket.on('connect', (d) => { 47 | sendSocketEvent('connect', d); 48 | }); 49 | 50 | socket.on('disconnect', (d) => { 51 | sendSocketEvent('disconnect', d); 52 | }); 53 | 54 | socket.on("*", function (event, data) { 55 | sendSocketEvent(event, data); 56 | }); 57 | 58 | socket.emit('updateInfo', { 59 | name: ipcRenderer.sendSync("getUserPrefs").name, 60 | }) 61 | }, 62 | outputDevice: await findDevice(userPrefs.audioOutput), 63 | constraints:{ 64 | audio: { 65 | sampleRate: 64000, 66 | volume: 1.0, 67 | noiseSuppression: false, 68 | echoCancellation: false, 69 | autoGainControl: true, 70 | deviceId: {exact: await findDevice(userPrefs.audioSource)} 71 | }, 72 | video: false 73 | } 74 | } 75 | })() 76 | 77 | 78 | window.addEventListener('DOMContentLoaded', () => { 79 | const $ = require("jquery"); 80 | 81 | function sendClientEvent(e, d = {}) { 82 | ipcRenderer.sendToHost('client_event', { 83 | event: e, 84 | data: d 85 | }); 86 | }; 87 | 88 | global.bridge.startCall = ()=>{ 89 | sendClientEvent('startCall', {source: url.href}) 90 | } 91 | 92 | $("#logout_button").hide(); 93 | 94 | $("#disconnect_button").on('click', () => { 95 | sendClientEvent('disconnectCall'); 96 | }); 97 | 98 | $("#mute_microphone").on('click', () => { 99 | if (client.call.stream.getAudioTracks()[0].enabled) { 100 | sendClientEvent('muteAllMic', false); 101 | } else { 102 | sendClientEvent('muteAllMic', true); 103 | } 104 | }); 105 | 106 | $("#mute_audio").on('click', () => { 107 | if (client.audioOut.muted == true) { 108 | sendClientEvent('muteAllAudio', false); 109 | } else { 110 | sendClientEvent('muteAllAudio', true); 111 | } 112 | }); 113 | 114 | ipcRenderer.on('client_event', (e, d) => { 115 | switch (d.event) { 116 | case "disconnectCall": 117 | if (client.call.connected) { 118 | client.call.end(); 119 | } 120 | break; 121 | case "muteAllMic": 122 | client.muteMic(d.data); 123 | break; 124 | case "muteAllAudio": 125 | client.muteAudio(d.data); 126 | break; 127 | case "startCall": 128 | if(d.data.source !== url.href){ 129 | if (client.call.connected){ 130 | client.call.end(); 131 | } 132 | $("#disconnect_call").show(); 133 | } 134 | break; 135 | case "setDark": 136 | client.darkMode.setDark(d.data); 137 | break 138 | default: 139 | console.log("Unhandled client event:", d) 140 | } 141 | }) 142 | }); -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const global_args = process.argv.slice(2); 2 | require('dotenv').config(); 3 | const fs = require('fs'); 4 | const {dbPromise, addModels} = require('./db/init.js'); 5 | const secret = require('./scripts/secret.js')(); 6 | const port = process.env.PORT || 443; 7 | var config = require('./scripts/config.js')(); 8 | 9 | //DB Ready 10 | dbPromise.then((db)=> { 11 | console.log('Database ✔'); 12 | 13 | //HELPERS 14 | var expressFunctions = require('./helpers/expressfunctions.js'); 15 | 16 | //HTTP SERVER 17 | var https = require('https'); 18 | const express = require('express'); 19 | var bodyParser = require('body-parser'); 20 | var cookieParser = require('cookie-parser')(); 21 | var session = require('express-session'); 22 | var SequelizeStore = require("connect-session-sequelize")(session.Store); 23 | 24 | var sessionStore = new SequelizeStore({ 25 | db: db, 26 | checkExpirationInterval: 5 * 60 * 1000, 27 | expiration: 24 * 60 * 60 * 1000 28 | }); 29 | 30 | var sessionMiddleware = session({ 31 | name: 'middleware', 32 | secure: true, 33 | secret: secret, 34 | store: sessionStore, 35 | resave: false, 36 | saveUninitialized: false, 37 | cookie: { secure: true }, 38 | }); 39 | 40 | sessionStore.sync(); 41 | 42 | var app = express(); 43 | 44 | app.disable('view cache'); 45 | app.set('view engine', 'ejs'); 46 | app.use(sessionMiddleware); 47 | app.use(cookieParser); 48 | app.use(bodyParser.urlencoded({ extended: false })); 49 | 50 | var options = {}; 51 | 52 | try { 53 | if (process.env.sslkey && process.env.sslcert){ 54 | options = { 55 | key: fs.readFileSync(process.env.sslkey, 'utf8'), 56 | cert: fs.readFileSync(process.env.sslcert, 'utf8'), 57 | } 58 | } else { 59 | options = { 60 | key: fs.readFileSync('ssl/server.key', 'utf8'), 61 | cert: fs.readFileSync('ssl/server.cert', 'utf8') 62 | }; 63 | } 64 | if("key" in options && "cert" in options && !(options.key == "" || options.cert == "")) startServer(); 65 | } catch (err) { 66 | //Generate a keypair to be used temporarily 67 | require("child_process").exec("npm list mkcert || npm i", {cwd: './scripts/create_cert'}, function(error, stdout, stderr) { 68 | (async()=>{ 69 | options = await require('./scripts/create_cert')(); 70 | startServer(); 71 | })() 72 | }); 73 | }; 74 | 75 | //If no cert exists we do not run this code 76 | function startServer(){ 77 | var server = https.createServer(options, app); 78 | server.listen(port, function(){ 79 | console.log("HTTPS Server ✔") 80 | }); 81 | 82 | //AUTH 83 | var temp_users = {}; 84 | var passport = require('passport'); 85 | require('./controllers/auth/init.js')(passport, db, temp_users); 86 | app.use(passport.initialize()); 87 | app.use(passport.session()); 88 | 89 | //PEER SERVER 90 | const { ExpressPeerServer } = require('peer'); 91 | const peerServer = ExpressPeerServer(server, { 92 | ssl: options 93 | }); 94 | 95 | app.use('/rtc', peerServer); 96 | 97 | //SIGNALLING 98 | var io = require('socket.io')(server, { 99 | pingTimeout: 0, // Removed timeout, it seemed to be causing issues 100 | pingInterval: 15000 101 | }); 102 | 103 | io.use(function(socket, next){ 104 | sessionMiddleware(socket.request, {}, next); 105 | }); 106 | 107 | var signallingServer = require('./controllers/signalling/signalling.js')({db, io, config, port, secret, temp_users }); 108 | 109 | 110 | // ROUTING // 111 | // Store routes here 112 | /** 113 | * These are the variables that can be accessed by any controllers/ extensions. 114 | */ 115 | var controllerParams = { 116 | db, 117 | io, 118 | passport, 119 | temp_users, 120 | config, 121 | secret, 122 | expressFunctions, 123 | addModels, 124 | port, 125 | signallingServer 126 | }; 127 | 128 | var clientController = require('./controllers/client/client.js')(controllerParams); 129 | var mcuController = require('./controllers/mcu/mcu.js')(controllerParams); 130 | var messageController = require('./controllers/messages/messages.js')(controllerParams); 131 | var authController = require('./controllers/auth/auth.js')(controllerParams); 132 | var adminController = require('./controllers/admin/admin.js')(controllerParams); 133 | 134 | app.use('/auth', authController); 135 | app.use('/admin', adminController); 136 | app.use("/", clientController); 137 | app.use("/messages", messageController); 138 | app.use('/mcu', mcuController); 139 | app.get('/coffee',(req, res)=>{res.sendStatus(418)}); // Why not? 140 | 141 | console.log("Controllers ✔") 142 | 143 | // MCU CLIENT // 144 | // Configure params for starting the MCU here 145 | var mcu_params = {port: port}; 146 | mcu_params.isHeadless = process.argv.includes("showmcu") ? false : true; 147 | require('./controllers/mcu/mcu_launcher.js')(mcu_params); 148 | }; 149 | }) 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![license](https://img.shields.io/github/license/reesvarney/OpenChat) 2 | ![Build/Release](https://github.com/reesvarney/OpenChat/workflows/Build/Release/badge.svg) 3 |

    4 | 5 | Logo 6 | 7 | 8 |

    OpenChat

    9 | 10 |

    11 | A free, open-source communications platform. Built to be modified. 12 |
    13 | View the Demo 14 |
    15 | Follow the progress 16 |
    17 |

    18 |

    19 | 20 | ## Overview 21 | This is a project that seeks to provide a communication service akin to discord or teamspeak completely built in the javascript stack to increase accessibility and ease of use. It features an MCU which means that clients use a more consistent amount of bandwidth compared to peer-to-peer. 22 | 23 | This project is currently in its infancy and is functional, however can have issues with stability, especially in terms of signalling. The current signalling system utilises peer.js and socket.io, however in the future should move to a fully socket.io based negotiation system with native WebRTC implementation. 24 | 25 | ![User Interface](https://github.com/reesvarney/OpenChat/raw/master/assets/client-light.png) 26 | 27 | ## Install/ Usage 28 | I hope to have server binaries potentially in the future to make things easier but in the meantime you will have to install node. 29 | 30 | ### Local 31 | 1. Install the latest version of Node/ NPM [https://nodejs.org/en/download/](https://nodejs.org/en/download/) 32 | 2. Download and unzip the `server.zip` from the [latest release](https://github.com/reesvarney/OpenChat/releases/latest) 33 | 3. In the unzipped folder, install the prequisite packages with npm. 34 | ```sh 35 | npm i 36 | ``` 37 | 4. Start the server 38 | ```sh 39 | npm start 40 | ``` 41 | 5. Connect to the server and authenticate yourself, either using the client or with email/password, as the first user to connect will be given administrative control over the server. 42 | 6. Port forward the port `443` in your router software to allow users to connect from outside the local network ([guide](https://www.noip.com/support/knowledgebase/general-port-forwarding-guide/)) 43 | 44 | If you already have a SSL key and certificate, you can place them in the `/ssl` directory, naming them `server.key` and `server.cert` respectively, otherwise they will be created automatically for you. 45 | 46 | ### Using Docker 47 | The current docker install is slightly 'hacky' however to maintain quick development, I do not want to have to make too many severe changes to the core functionality to facilitate it. 48 | 49 | #### Premade Images 50 | These are created with every release which should be reasonably stable. 51 | 52 | 1. Pull the docker image https://hub.docker.com/r/reesvarney02/openchat/ 53 | ```docker 54 | docker pull reesvarney02/openchat:latest 55 | ``` 56 | 2. Run the image 57 | ```sh 58 | docker run --detach -p [port to expose on]:443 --name [container name] reesvarney02/openchat:latest 59 | ``` 60 | 3. Start a bash terminal in the container 61 | ```docker 62 | docker exec -it [container name] /bin/bash 63 | ``` 64 | 4. Continue to follow steps 5-6 from the local method above. 65 | 66 | #### Build your own image 67 | You can build OpenChat straight from the repository however it could take several minutes (depending on hardware) as some packages require building (such as sqlite3). 68 | 1. Clone/Download the repository 69 | 2. Go to the directory that you installed it and build the image 70 | ```docker 71 | docker build -t [tag] . 72 | ``` 73 | 74 | #### Implementing letsencrypt SSL keys 75 | I recommend using certbot to auto renew ssl keys (https://certbot.eff.org/) 76 | 77 | Change the `docker run` command to: 78 | ```docker 79 | docker run -v /etc/letsencrypt/live/[URL_HERE]/:/etc/letsencrypt/live/[URL_HERE]/ -v /etc/letsencrypt/archive/[URL_HERE]/:/etc/letsencrypt/archive/[URL_HERE]/ -env sslkey=/etc/letsencrypt/live/[URL_HERE]/privkey.pem -env sslcert=sslkey=/etc/letsencrypt/live/[URL_HERE]/fullchain.pem --detach -p [port to expose on]:443 --name [container name] reesvarney02/openchat:latest 80 | ``` 81 | 82 | ### Further configuration 83 | - If you'd like to use a different port, change the PORT environment variable. To do this you can use the `.env` file by simply setting `PORT=x`. 84 | 85 | ## Plans for the future: 86 | - Add ability to upload and view files/ media. 87 | - Markdown support 88 | - Combine signalling into single system with native WebRTC implementation 89 | - Create a repository-based extension system to allow for easy server-level customisation for even those with little technical knowledge. This would also provide the benefit of providing a less disjointed product as there would be less need for individual versions/forks. It may feature things such as: 90 | - Bots 91 | - Front-end appearance modification 92 | - Completely additional functionality 93 | 94 | ## Bugs 95 | If you encounter any issues with OpenChat, please raise an issue so that it can be fixed as quickly as possible. 96 | 97 | ## Development/ Contribution 98 | Due to the current state of the application in terms of stability, changes will quickly be applied directly to the master branch. However, once stability improves and updates become more incremental, new features/ fixes will be added into their own branches with more extensive testing before being merged into the master branch. 99 | 100 | If you would like to contribute please use the following guide: https://github.com/firstcontributions/first-contributions/blob/master/README.md 101 | -------------------------------------------------------------------------------- /server/controllers/admin/admin.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var router = express.Router(); 3 | var fs = require('fs'); 4 | var {Op} = require('sequelize'); 5 | 6 | module.exports = function({ 7 | db, 8 | config, 9 | expressFunctions, 10 | signallingServer 11 | }) { 12 | 13 | function saveConf(){ 14 | fs.writeFileSync('./config.json', JSON.stringify(config, null, 2)); 15 | } 16 | 17 | // TODO: EXTEND THE RECORD PROTOTPYE 18 | async function insertBefore({model, id, index, params}){ 19 | if(![undefined, null].includes(db.models[model].rawAttributes.position)){ 20 | var item = await db.models[model].findByPk(id); 21 | var records = await db.models[model].findAll({ where: ("where" in params) ? params.where : {}}); 22 | 23 | if(index > records.length || index < 0 || index == item.position){ 24 | return {success: false, error: "Invalid index"}; 25 | } 26 | 27 | var newPos = index; 28 | var oldPos = item.position; 29 | 30 | if(oldPos > newPos){ 31 | var where = { 32 | id: { 33 | [Op.ne]: item.id 34 | }, 35 | position: { 36 | [Op.gte]: newPos, 37 | [Op.lt]: oldPos 38 | }, 39 | }; 40 | if('where' in params) {Object.assign(where, params.where)}; 41 | db.models[model].update({ 42 | position: db.literal('position + 1') 43 | }, 44 | { 45 | where: where 46 | }); 47 | } else if(oldPos < newPos){ 48 | var where = { 49 | id: { 50 | [Op.ne]: item.id 51 | }, 52 | position: { 53 | [Op.lte]: newPos, 54 | [Op.gt]: oldPos 55 | } 56 | }; 57 | if('where' in params) {Object.assign(where, params.where)}; 58 | db.models[model].update({ 59 | position: db.literal('position - 1') 60 | }, 61 | { 62 | where: where 63 | }); 64 | } 65 | 66 | var result = await item.update({ 67 | position: newPos 68 | }); 69 | return {success: true, record: result}; 70 | } 71 | } 72 | 73 | router.delete("/channel/edit/:uuid", expressFunctions.checkAuth, expressFunctions.hasPermission('permission_edit_channels'), function (req, res) { 74 | db.models.Channel.destroy({ 75 | where: { 76 | id: req.params.uuid 77 | } 78 | }).catch(err =>{ 79 | res.status(400).send('ERROR: Sequelize error', err) 80 | return false; 81 | });; 82 | res.sendStatus(200); 83 | signallingServer.updateChannels(); 84 | }); 85 | 86 | router.post("/channel/edit/:uuid", expressFunctions.checkAuth, expressFunctions.hasPermission('permission_edit_channels'), function (req, res) { 87 | var name = req.body.name; 88 | var description = req.body.description; 89 | 90 | db.models.Channel.findByPk(req.params.uuid).then((channel) => { 91 | channel.update({ 92 | name: name 93 | }); 94 | res.sendStatus(200); 95 | signallingServer.sendUpdate(); 96 | }); 97 | }); 98 | 99 | router.post("/channel/move/:uuid", expressFunctions.checkAuth, expressFunctions.hasPermission('permission_edit_channels'), function (req, res) { 100 | var id = req.params.uuid; 101 | var index = req.body.index; 102 | (async()=>{ 103 | var channel = await db.models.Channel.findByPk(id); 104 | var query = await insertBefore({model: "Channel", id: id, index: index, params: {where: {type: channel.type}}}) 105 | if(query.success){ 106 | res.status(200).send('success') 107 | signallingServer.updateChannels(); 108 | } else { 109 | res.status(400).send(query.error) 110 | }; 111 | })() 112 | }); 113 | 114 | router.post("/channel/new", expressFunctions.checkAuth, expressFunctions.hasPermission('permission_edit_channels'), function (req, res) { 115 | var name = req.body.name; 116 | var type = req.body.type; 117 | var isError = false; 118 | (async()=>{ 119 | var positionMax = await db.models.Channel.max('position', {where: {type: req.body.type}}); 120 | var position = (typeof positionMax !== 'number' || isNaN(positionMax)) ? 0 : positionMax + 1; 121 | await db.models.Channel.create({ 122 | name: name, 123 | type: type, 124 | position: position 125 | }).catch(err =>{ 126 | isError = true; 127 | res.status(400).send(err) 128 | }) 129 | // TODO: Use socketio to update dynamically without redirect and disconnect users if voice channel 130 | if(!isError){ 131 | signallingServer.updateChannels(); 132 | res.sendStatus(200); 133 | }; 134 | })() 135 | 136 | }); 137 | 138 | router.post("/server/edit", expressFunctions.checkAuth, expressFunctions.hasPermission('permission_edit_server'), function (req, res) { 139 | config.name = req.body.name; 140 | saveConf(); 141 | res.redirect('/'); 142 | }) 143 | 144 | router.post("/role/:uuid/edit", expressFunctions.checkAuth, expressFunctions.hasPermission('permission_edit_roles'), function(req,res){ 145 | (async()=>{ 146 | var role = await db.models.Role.findByPk(req.params.uuid); 147 | if(role === null){ 148 | res.status(400).send('ERROR: Role does not exist'); 149 | } else { 150 | Object.keys(req.body).forEach((perm)=>{ 151 | if(!(perm in role.dataValues)){ 152 | res.status(400).send('ERROR: Invalid Key') 153 | return false; 154 | } 155 | }); 156 | await role.update(req.body).catch(err =>{ 157 | res.status(400).send('ERROR: Sequelize error', err) 158 | return false; 159 | }); 160 | res.status(200).send('SUCCESS'); 161 | } 162 | })() 163 | }); 164 | 165 | return router; 166 | }; -------------------------------------------------------------------------------- /server/db/models.js: -------------------------------------------------------------------------------- 1 | const { DataTypes, Sequelize } = require("sequelize"); 2 | 3 | module.exports = { 4 | RoleAssignment: { 5 | attributes: { 6 | id: { 7 | type: DataTypes.UUIDV4, 8 | defaultValue: Sequelize.UUIDV4, 9 | unique: true, 10 | primaryKey: true 11 | } 12 | }, 13 | relations: [], 14 | options: {}, 15 | }, 16 | 17 | Message: { 18 | attributes: { 19 | id: { 20 | type: DataTypes.UUIDV4, 21 | defaultValue: Sequelize.UUIDV4, 22 | primaryKey: true, 23 | unique: true, 24 | }, 25 | content: { 26 | type: DataTypes.STRING, 27 | allowNull: false, 28 | validate: { 29 | len: [1, 2000], 30 | }, 31 | }, 32 | }, 33 | options: {}, 34 | relations: [ 35 | { 36 | relation: "belongsTo", 37 | model: "Channel" 38 | }, 39 | { 40 | relation: "belongsTo", 41 | model: "User" 42 | } 43 | ], 44 | }, 45 | 46 | Channel: { 47 | attributes: { 48 | id: { 49 | type: DataTypes.UUIDV4, 50 | defaultValue: Sequelize.UUIDV4, 51 | primaryKey: true, 52 | unique: true, 53 | }, 54 | type: { 55 | type: DataTypes.STRING, 56 | allowNull: false, 57 | validate: { 58 | isIn: [["voice", "text", "custom"]], 59 | }, 60 | }, 61 | name: { 62 | type: DataTypes.STRING, 63 | allowNull: false, 64 | validate: { 65 | len: [3, 32], 66 | }, 67 | }, 68 | position: { 69 | type: DataTypes.INTEGER, 70 | allowNull: false, 71 | defaultValue: 0 72 | } 73 | }, 74 | options: {}, 75 | relations: [ 76 | { 77 | relation: "hasMany", 78 | model: "Message", 79 | options: { 80 | onDelete: 'CASCADE' 81 | } 82 | } 83 | ], 84 | }, 85 | 86 | User: { 87 | attributes: { 88 | id: { 89 | type: DataTypes.UUIDV4, 90 | defaultValue: Sequelize.UUIDV4, 91 | unique: true, 92 | primaryKey: true, 93 | }, 94 | name: { 95 | type: DataTypes.STRING, 96 | allowNull: false, 97 | defaultValue: "New User", 98 | } 99 | }, 100 | options: {}, 101 | relations: [ 102 | { 103 | relation: "belongsToMany", 104 | model: "Role", 105 | options: { 106 | through: "RoleAssignment" 107 | }, 108 | }, 109 | ] 110 | }, 111 | 112 | //Store permissions here. Some permissions have been added which may not need to be used however it will allow for less migrations to be needed in the future 113 | Role: { 114 | attributes: { 115 | id: { 116 | type: DataTypes.UUIDV4, 117 | defaultValue: Sequelize.UUIDV4, 118 | unique: true, 119 | primaryKey: true, 120 | }, 121 | name: { 122 | type: DataTypes.STRING, 123 | allowNull: false, 124 | unique: true, 125 | defaultValue: "New Role", 126 | }, 127 | isAdmin: { 128 | type: DataTypes.BOOLEAN, 129 | defaultValue: false, 130 | }, 131 | permission_join: { 132 | type: DataTypes.BOOLEAN, 133 | defaultValue: true, 134 | }, 135 | permission_speak: { 136 | type: DataTypes.BOOLEAN, 137 | defaultValue: true, 138 | }, 139 | permission_listen: { 140 | type: DataTypes.BOOLEAN, 141 | defaultValue: true, 142 | }, 143 | permission_send_message: { 144 | type: DataTypes.BOOLEAN, 145 | defaultValue: true, 146 | }, 147 | permission_move: { 148 | type: DataTypes.BOOLEAN, 149 | defaultValue: false, 150 | }, 151 | permission_ban: { 152 | type: DataTypes.BOOLEAN, 153 | defaultValue: false, 154 | }, 155 | permission_kick: { 156 | type: DataTypes.BOOLEAN, 157 | defaultValue: false, 158 | }, 159 | permission_mute: { 160 | type: DataTypes.BOOLEAN, 161 | defaultValue: false, 162 | }, 163 | permission_deafen: { 164 | type: DataTypes.BOOLEAN, 165 | defaultValue: false, 166 | }, 167 | permission_delete_message: { 168 | type: DataTypes.BOOLEAN, 169 | defaultValue: false, 170 | }, 171 | permission_edit_channels: { 172 | type: DataTypes.BOOLEAN, 173 | defaultValue: false, 174 | }, 175 | permission_edit_server: { 176 | type: DataTypes.BOOLEAN, 177 | defaultValue: false, 178 | }, 179 | permission_edit_roles: { 180 | type: DataTypes.BOOLEAN, 181 | defaultValue: false, 182 | }, 183 | }, 184 | options: {}, 185 | relations: [ 186 | { 187 | relation: "belongsToMany", 188 | model: "User", 189 | options: { 190 | through: "RoleAssignment" 191 | }, 192 | }, 193 | ], 194 | }, 195 | 196 | Pubkey: { 197 | attributes: { 198 | id: { 199 | type: DataTypes.UUIDV4, 200 | defaultValue: Sequelize.UUIDV4, 201 | unique: true, 202 | primaryKey: true 203 | }, 204 | pub_key: { 205 | type: DataTypes.STRING, 206 | allowNull: false, 207 | unique: true, 208 | } 209 | }, 210 | options: {}, 211 | relations: [ 212 | { 213 | relation: "belongsTo", 214 | model: "User" 215 | } 216 | ] 217 | }, 218 | 219 | EmailPass: { 220 | attributes: { 221 | id: { 222 | type: DataTypes.UUIDV4, 223 | defaultValue: Sequelize.UUIDV4, 224 | unique: true, 225 | primaryKey: true 226 | }, 227 | email: { 228 | type: DataTypes.STRING, 229 | allowNull: false, 230 | unique: true, 231 | validate: { 232 | isEmail: true 233 | } 234 | }, 235 | pass_hashed: { 236 | type: DataTypes.STRING, 237 | allowNull: false 238 | }, 239 | salt: { 240 | type: DataTypes.STRING, 241 | allowNull: false 242 | }, 243 | private_salt: { 244 | type: DataTypes.STRING, 245 | allowNull: false 246 | } 247 | }, 248 | options: {}, 249 | relations: [ 250 | { 251 | relation: "belongsTo", 252 | model: "User" 253 | } 254 | ] 255 | }, 256 | }; 257 | -------------------------------------------------------------------------------- /client/client/css/openchat.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: 'Public Sans', sans-serif; 3 | height: 100vh; 4 | width: 100vw; 5 | height: -webkit-fill-available; 6 | background-color: var(--bg); 7 | } 8 | 9 | webview { 10 | display: none; 11 | border: none; 12 | height: 100%; 13 | } 14 | 15 | webview.active { 16 | display: block; 17 | } 18 | 19 | webview iframe { 20 | height: 100% !important; 21 | } 22 | 23 | body { 24 | display: flex; 25 | flex-direction: column; 26 | width:100vw; 27 | height: 100vh; 28 | margin:0px; 29 | } 30 | 31 | ol { 32 | padding: 0; 33 | margin: 0; 34 | } 35 | 36 | main { 37 | height: 100%; 38 | } 39 | 40 | /* TOPBAR */ 41 | nav { 42 | display: flex; 43 | flex-direction: row; 44 | justify-content: space-between; 45 | padding: 5px; 46 | box-sizing: content-box; 47 | min-height: 45px; 48 | background: #f5f5f5; 49 | overflow-x: auto; 50 | box-sizing: border-box; 51 | } 52 | 53 | iframe { 54 | border: none; 55 | } 56 | 57 | h4 { 58 | margin: 0px; 59 | } 60 | 61 | #server_container { 62 | display: flex; 63 | flex-direction: row; 64 | justify-content: start; 65 | min-height: var(--nav-thumb-size); 66 | margin-left: 20px; 67 | 68 | } 69 | 70 | #server_list { 71 | display: flex; 72 | flex-direction: row; 73 | } 74 | 75 | .server-thumbnail { 76 | display: block; 77 | border-radius: 25%; 78 | font-size: var(--nav-thumb-size); 79 | background-color: var(--bg-darker-10); 80 | min-height: var(--nav-thumb-size); 81 | min-width: var(--nav-thumb-size); 82 | overflow: hidden; 83 | box-sizing: content-box; 84 | margin-right: 10px; 85 | } 86 | 87 | .server-thumbnail img { 88 | width: calc(var(--nav-thumb-size) + 20px); 89 | height: calc(var(--nav-thumb-size) + 20px); 90 | object-fit: cover; 91 | } 92 | 93 | .server-thumbnail i { 94 | margin: 10px; 95 | text-align: center; 96 | text-decoration: none; 97 | min-height: var(--nav-thumb-size); 98 | min-width: var(--nav-thumb-size); 99 | color: var(--accent); 100 | } 101 | 102 | #settings_container { 103 | justify-self: flex-end; 104 | } 105 | 106 | #settings_button { 107 | display: block; 108 | border-radius: 25%; 109 | font-size: var(--nav-thumb-size); 110 | overflow: hidden; 111 | box-sizing: content-box; 112 | margin-right: 10px; 113 | } 114 | 115 | #settings_button i { 116 | margin: 10px; 117 | text-align: center; 118 | color: var(--bg-darker-20); 119 | } 120 | 121 | /* ALIGNMENT */ 122 | .center-self { 123 | text-align: center; 124 | } 125 | 126 | div.center-self { 127 | justify-self: center; 128 | } 129 | 130 | .flex-row { 131 | display: flex; 132 | flex-direction: row; 133 | } 134 | 135 | .flex-column { 136 | display: flex; 137 | flex-direction: column; 138 | } 139 | 140 | /* CONTROLS */ 141 | #control_area { 142 | padding: 10px; 143 | margin-top: 10px; 144 | } 145 | 146 | .control-button { 147 | padding: 10px; 148 | margin: 10px; 149 | } 150 | 151 | .control-button i { 152 | height: 0; 153 | width: 0; 154 | } 155 | 156 | .control-button:hover { 157 | cursor: pointer; 158 | } 159 | 160 | /* NOTIFICATION */ 161 | .hidden { 162 | display: none; 163 | } 164 | 165 | /* OVERLAY */ 166 | #overlay { 167 | position: absolute; 168 | height: -webkit-fill-available; 169 | width: 100vw; 170 | backdrop-filter: blur(5px); 171 | background: rgba(0,0,0, 0.2); 172 | z-index: 100; 173 | display: none; 174 | } 175 | 176 | .modal { 177 | border-radius: 3%; 178 | background: var(--bg-darker-5); 179 | position: absolute; 180 | left: 50%; 181 | top: 50%; 182 | min-height: 30%; 183 | min-width: 50vw; 184 | max-width: 80vw; 185 | transform: translate(-50%, -50%); 186 | border-radius: 20px; 187 | padding: 20px; 188 | } 189 | 190 | .modal>.option { 191 | display: block; 192 | min-height: 50px; 193 | border-style: solid; 194 | border-radius: 10px; 195 | color: var(--accent-contrast); 196 | padding: 20px; 197 | margin: 20px; 198 | text-decoration: none; 199 | } 200 | 201 | .option>div{ 202 | display: inline-block; 203 | vertical-align: top; 204 | } 205 | 206 | .option>.icon{ 207 | font-size: 5vh; 208 | } 209 | 210 | .option h3 { 211 | margin-top: 0px; 212 | } 213 | 214 | form>* { 215 | display: block; 216 | margin: 10px; 217 | } 218 | 219 | form>div{ 220 | width: 100%; 221 | display: flex; 222 | flex-direction: row; 223 | justify-content: space-between; 224 | } 225 | 226 | form label { 227 | display: inline-block; 228 | margin-right: auto 229 | } 230 | 231 | form input, form select { 232 | min-height: 20px; 233 | display: inline-block; 234 | background-color: var(--bg); 235 | outline: none; 236 | border-color: var(--bg-darker-5); 237 | border-radius: 5px; 238 | border-style: solid; 239 | padding: 10px; 240 | width: 30em; 241 | max-width: 80%; 242 | } 243 | 244 | form input[type="checkbox"] { 245 | width: 20px; 246 | height: 20px; 247 | margin: 10px; 248 | } 249 | 250 | button { 251 | color: var(--accent-contrast); 252 | border-color: var(--accent-contrast); 253 | background-color: var(--bg-darker-5); 254 | border-style: solid; 255 | border-radius: 10px; 256 | font-size: 20px; 257 | padding: 10px; 258 | font-weight: 600; 259 | cursor: pointer; 260 | } 261 | 262 | button:hover:focus{ 263 | background-color: var(--accent); 264 | } 265 | 266 | /* Title Bar */ 267 | #electron-titlebar{ 268 | height : 30px !important; 269 | } 270 | 271 | #titlebar { 272 | height: 30px; 273 | min-height: 30px; 274 | background-color: var(--bg-darker-20); 275 | } 276 | 277 | #titlebar .title { 278 | font-size: 14px; 279 | margin-left: 10px; 280 | line-height: 30px; 281 | color: #515151; 282 | font-weight: bold; 283 | } 284 | 285 | /* Dark Mode */ 286 | .dark #titlebar .title { 287 | color: var(--accent-contrast); 288 | } 289 | 290 | .dark #titlebar { 291 | background-color: rgb(24, 26, 27); 292 | } 293 | 294 | .dark { 295 | background-color: #313131; 296 | } 297 | 298 | .dark nav { 299 | background-color: rgb(30, 32, 33); 300 | } 301 | 302 | .dark #settings_button i{ 303 | color: #a1a1a1; 304 | } 305 | .dark #settings label, .dark #settings input, .dark #settings select{ 306 | color: #aaaaaa 307 | } 308 | .dark #settings h2{ 309 | color: #9a9a9a 310 | } 311 | 312 | .dark #electron-titlebar img{ 313 | filter: invert(1); 314 | } -------------------------------------------------------------------------------- /client/client/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } -------------------------------------------------------------------------------- /server/views/static/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } -------------------------------------------------------------------------------- /client/client/js/client.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer, remote } = require("electron"); 2 | const { URL } = require('url'); 3 | var userPrefs = ipcRenderer.sendSync("getUserPrefs"); 4 | const Handlebars = require("handlebars"); 5 | const $ = require("jquery"); 6 | const liveVar = require("./js/livevar.js"); 7 | require('electron-titlebar') 8 | 9 | var templates = {} 10 | var servers = {}; 11 | 12 | class server { 13 | constructor(url){ 14 | this.url = new URL(url); 15 | this.wv = undefined; 16 | this.socket = { 17 | listeners: {}, 18 | emit: (e, d={})=>{ 19 | if(this.connected.value){ 20 | this.wv[0].send("socket_event", {event: e, data: d}) 21 | } 22 | }, 23 | on: (e, f)=>{ 24 | if(this.socket.listeners[e] === undefined){this.socket.listeners[e] = []}; 25 | this.socket.listeners[e].push(f); 26 | } 27 | }; 28 | 29 | this.connected = new liveVar(false); 30 | this.connected.onChange((conn)=>{this._onConnection(conn)}); 31 | 32 | this.thumbnail = {el: $(templates.serverIcon({url: this.url.href})).appendTo("#server_list")}; 33 | this.thumbnail.image = $(this.thumbnail.el).find('img'); 34 | this.thumbnail.no_connection = $(this.thumbnail.el).find('i') 35 | 36 | servers[this.url.href] = this; 37 | 38 | this.authenticate(); 39 | this._socketListeners(); 40 | } 41 | 42 | _onConnection(conn){ 43 | if(conn){ 44 | this.addWebView(); 45 | this.thumbnail.image[0].style.display = "block"; 46 | this.thumbnail.no_connection[0].style.display = "none"; 47 | } else { 48 | this.removeWebView(); 49 | this.thumbnail.image[0].style.display = "none"; 50 | this.thumbnail.no_connection[0].style.display = "block"; 51 | this.thumbnail.el[0].title = `${this.url.href} - No Connection` 52 | }; 53 | }; 54 | 55 | _socketListeners(){ 56 | this.socket.on("disconnect", ()=>{ 57 | this.connected.value = false; 58 | }) 59 | 60 | this.socket.on("serverInfo", (data)=>{ 61 | this.thumbnail.el[0].title = `${data.name} (${this.url.href})`; 62 | }) 63 | 64 | this.socket.on("newMessage", (d)=>{ 65 | console.log(this.url.href, d) 66 | }) 67 | } 68 | 69 | authenticate(){ 70 | $.ajax({ 71 | async: true, 72 | type: 'GET', 73 | url: `${this.url.origin}/auth/pubkey`, 74 | data: { "public_key": remote.getGlobal("pub_key")}, 75 | error: ()=>{ 76 | this.connected.value = false; 77 | }, 78 | success: ( result ) => { 79 | if (result.encoded_data !== undefined){ 80 | var decrypted = ipcRenderer.sendSync("decrypt", result.encoded_data); 81 | $.ajax({ 82 | async: true, 83 | type: 'POST', 84 | url: `${this.url.origin}/auth/pubkey`, 85 | data: {decrypted, name: userPrefs.displayName}, 86 | success: ((r) => { 87 | this.connected.value = true; 88 | }) 89 | }); 90 | } else { 91 | //probably already connected - should probably add extra data to the response to be sure, will update info in case it has changed between being connected 92 | this.connected.value = true; 93 | } 94 | } 95 | }); 96 | }; 97 | 98 | addWebView(){ 99 | if(this.wv !== undefined){ 100 | //remove existing webview if connection has been reset 101 | this.wv.remove(); 102 | } 103 | this.wv = $(document.createElement("webview")).appendTo('body'); 104 | 105 | Object.assign(this.wv[0], { 106 | style: { 107 | height: "100%", 108 | width: "100%", 109 | }, 110 | src: this.url.href, 111 | preload: "./js/bridge.js", 112 | autosize: "on", 113 | minHeight: "100%" 114 | }); 115 | 116 | this.wv[0].addEventListener('ipc-message', (e)=>{ 117 | var d = e.args[0] 118 | switch( e.channel){ 119 | case "socket_event": 120 | if(this.socket.listeners[d.event] !== undefined){ 121 | this.socket.listeners[d.event].forEach((f)=>{ 122 | f(d.data) 123 | }); 124 | } 125 | break; 126 | case "client_event": 127 | for(const [url, server] of Object.entries(servers)){ 128 | if(server.wv !== undefined){server.wv[0].send("client_event", d)} 129 | } 130 | break; 131 | default: 132 | console.log("Unhandled IPC Message:", e) 133 | } 134 | }); 135 | 136 | this.thumbnail.el.on("click", ()=>{ 137 | if(this.connected.value){ 138 | this.showWebView(); 139 | } else { 140 | this.authenticate(); 141 | } 142 | }); 143 | }; 144 | 145 | removeWebView(){ 146 | this.wv.remove(); 147 | } 148 | 149 | showWebView(){ 150 | var active = $("webview.active"); 151 | if(active.length > 0 && active[0].src !== this.url.href){ 152 | active[0].style.display = "none"; 153 | active.removeClass("active"); 154 | } 155 | this.wv.addClass("active"); 156 | this.wv[0].style.display = "block"; 157 | this.wv[0].shadowRoot.childNodes[1].style.height = "100%"; 158 | } 159 | } 160 | 161 | var overlay = { 162 | show: function (div) { 163 | $("#overlay").show(); 164 | $(`#overlay>*`).hide(); 165 | $(`#overlay>#${div}`).show(); 166 | }, 167 | hide: function () { 168 | $("#overlay").hide(); 169 | }, 170 | }; 171 | 172 | $(window).on('load', function (e) { 173 | templates.serverIcon = Handlebars.compile($("#server-template").html()); 174 | var darkMode = { 175 | true: "30%", 176 | false: "100%" 177 | } 178 | 179 | function setDark(val){ 180 | document.documentElement.style.setProperty('--bg-l', darkMode[val]); 181 | $("body").toggleClass("dark", val); 182 | } 183 | 184 | setDark(userPrefs.darkMode); 185 | $("#overlay").on("click", function (e) { 186 | if (e.target !== this) return; 187 | overlay.hide(); 188 | }); 189 | 190 | $("#modal_close").on( 'click', function () { 191 | overlay.hide(); 192 | }); 193 | 194 | $("#add_server").on( 'click', function () { 195 | overlay.show("connect"); 196 | }); 197 | 198 | $("#settings_button").on( "click", function() { 199 | overlay.show("settings"); 200 | }); 201 | 202 | $("#connect_form").on( 'submit', function (e) { 203 | e.preventDefault(); 204 | var url = 'https://' + $("#connect_destination").val(); 205 | if(ipcRenderer.sendSync("addServer", url)){ 206 | new server(url); 207 | overlay.hide(); 208 | } 209 | }); 210 | 211 | $("#user_form input[type='text']").each(function(){ 212 | $(this).val(userPrefs[$(this).attr('name')]); 213 | }); 214 | 215 | $("#user_form input[type='checkbox']").each(function(){ 216 | $(this)[0].checked = userPrefs[$ (this).attr('id')]; 217 | }); 218 | 219 | $("#user_form").on( 'submit', function (e) { 220 | e.preventDefault(); 221 | $(this).serializeArray().forEach((pref)=>{ 222 | if(name.length !== 0){userPrefs[pref.name] = pref.value;} 223 | }); 224 | $("#user_form .device-select select").each((i, el)=>{ 225 | userPrefs[el.id] = $(el).val(); 226 | }); 227 | $("#user_form input[type='checkbox']").each((i, el)=>{ 228 | userPrefs[el.id] = $(el)[0].checked; 229 | }) 230 | ipcRenderer.send("setPrefs", userPrefs); 231 | Object.values(servers).forEach((server) => { 232 | server.socket.emit("updateInfo", { 233 | name: userPrefs.displayName 234 | }); 235 | if(server.wv !== undefined){server.wv[0].send("client_event", {event: "setDark", data: userPrefs.darkMode})}; 236 | }); 237 | setDark(userPrefs.darkMode) 238 | overlay.hide(); 239 | }); 240 | 241 | userPrefs.servers.forEach(function(url){ 242 | new server(url); 243 | }); 244 | 245 | navigator.mediaDevices.enumerateDevices().then((devices)=>{ 246 | var selected = {} 247 | devices.forEach((device)=>{ 248 | var option = document.createElement('option'); 249 | option.value = device.label; 250 | option.text = device.label; 251 | switch(device.kind){ 252 | case "audiooutput": 253 | if (option.value == userPrefs.audioOutput){ 254 | selected.output = option.value; 255 | } 256 | $(option).appendTo(`#user_form #audioOutput`) 257 | break; 258 | case "audioinput": 259 | if (option.value == userPrefs.audioSource){ 260 | selected.source = option.value; 261 | } 262 | $(option).appendTo(`#user_form #audioSource`) 263 | break; 264 | default: 265 | break; 266 | } 267 | }) 268 | if("output" in selected){$('#user_form #audioOutput').val(selected.output)}; 269 | if("source" in selected){$('#user_form #audioSource').val(selected.source)}; 270 | }); 271 | }); -------------------------------------------------------------------------------- /server/controllers/messages/messages.js: -------------------------------------------------------------------------------- 1 | const ogs = require("open-graph-scraper"); 2 | var express = require("express"); 3 | var router = express.Router(); 4 | var anchorme = require("anchorme").default; 5 | const jsdom = require("jsdom"); 6 | const { JSDOM } = jsdom; 7 | var moment = require("moment"); 8 | const rateLimit = require("express-rate-limit"); 9 | const autoLimit = rateLimit({ 10 | windowMs: 1 * 1000, 11 | max: 2 12 | }); 13 | const spamLimit = rateLimit({ 14 | windowMs: 15 * 1000, 15 | max: 15 16 | }); 17 | const spamLimit2 = rateLimit({ 18 | windowMs: 60 * 1000, 19 | max: 30 20 | }); 21 | const { render } = require("ejs"); 22 | const { User } = require("../../db/models"); 23 | 24 | function sanitize(str) { 25 | var document = new JSDOM("
    "); 26 | document.window.document.querySelector("div").textContent = str; 27 | return document.window.document.documentElement.querySelector("div") 28 | .innerHTML; 29 | } 30 | 31 | module.exports = function ({ db, io, expressFunctions }) { 32 | var ogpCache = {}; 33 | var messageCache = {}; 34 | 35 | router.get("/:channel", expressFunctions.checkAuth, (req, res) => { 36 | if (req.query.page == undefined) { 37 | req.query.page = 0; 38 | } 39 | 40 | var query; 41 | 42 | if (req.query.id != undefined) { 43 | query = { 44 | where: { 45 | id: req.query.id, 46 | }, 47 | include: db.models.User, 48 | limit: 1, 49 | }; 50 | } else { 51 | query = { 52 | where: { 53 | ChannelId: req.params.channel, 54 | }, 55 | order: [["createdAt", "DESC"]], 56 | limit: 50, 57 | include: db.models.User, 58 | offset: req.query.page * 50, 59 | }; 60 | } 61 | 62 | db.models.Message.findAll(query).then((messages) => { 63 | if (messages.length == 0) { 64 | res.render("messages/none"); 65 | } else { 66 | function getMessageData(i) { 67 | return new Promise((resolve) => { 68 | var currentMessage = messages[i].dataValues; 69 | //Just in case message has no user for whatever reason, 70 | var content_clean = sanitize(currentMessage.content); 71 | if(currentMessage.User === null){ 72 | currentMessage.sender = "Anonymous"; 73 | } else { 74 | currentMessage.sender = currentMessage.User.dataValues.name; 75 | } 76 | 77 | currentMessage.content = anchorme({ 78 | input: content_clean, 79 | options: { 80 | attributes: { 81 | class: "found-link", 82 | target: "_blank", 83 | }, 84 | }, 85 | }); 86 | 87 | var links = anchorme.list(content_clean); 88 | 89 | currentMessage.date = moment(currentMessage.createdAt); 90 | 91 | if (links.length != 0) { 92 | var linkHandled = false; 93 | 94 | var regEx = [ 95 | { 96 | name: "yt", 97 | expression: /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/gim, 98 | function: function (str) { 99 | return str.replace("watch", "embed").replace("?v=", "/"); 100 | }, 101 | }, 102 | { 103 | name: "twitch", 104 | expression: /^(?:https?:\/\/)?(?:www\.|go\.)?twitch\.tv\/([a-z0-9_]+)($|\?)/g, 105 | function: function (str) { 106 | return str 107 | .replace("twitch.tv/", "player.twitch.tv/?channel=") 108 | .replace("www.", "") 109 | .concat("&autoplay=false"); 110 | }, 111 | }, 112 | { 113 | name: "spotify", 114 | expression: /\.spotify\.com/, 115 | function: function (str) { 116 | return `https://open.spotify.com/embed/${str.split("spotify.com/").pop()}` 117 | }, 118 | }, 119 | ]; 120 | 121 | for (x = 0; x < links.length; x++) { 122 | for (z = 0; z < regEx.length; z++) { 123 | var currentRegEx = regEx[z]; 124 | if (currentRegEx.expression.test(links[x].string)) { 125 | currentMessage[currentRegEx.name] = currentRegEx.function( 126 | links[x].string 127 | ); 128 | messages[i] = currentMessage; 129 | messageCache[currentMessage.id] = currentMessage; 130 | resolve(i); 131 | linkHandled = true; 132 | } 133 | } 134 | } 135 | 136 | if (!linkHandled) { 137 | var firstLink = links[0].string; 138 | if (firstLink in ogpCache) { 139 | currentMessage["ogp"] = ogpCache[firstLink]; 140 | messages[i] = currentMessage; 141 | messageCache[currentMessage.id] = currentMessage; 142 | resolve(i); 143 | linkHandled = true; 144 | } else { 145 | ogs( 146 | { 147 | url: firstLink, 148 | }, 149 | (error, ogpResult, response) => { 150 | if ("ogTitle" in ogpResult) { 151 | var ogpData = {}; 152 | 153 | ogpData.imageSRC = ""; 154 | if ( 155 | "ogImage" in ogpResult && 156 | "url" in ogpResult.ogImage 157 | ) { 158 | ogpData.imageSRC = ogpResult.ogImage.url; 159 | if (ogpResult.ogImage.url.startsWith("/")) { 160 | ogpData.imageSRC = 161 | (ogpResult.requestUrl || ogpResult.ogUrl) + 162 | ogpResult.ogImage.url; 163 | } 164 | } 165 | 166 | ogpData.siteName = ""; 167 | if ("ogSiteName" in ogpResult) { 168 | ogpData.siteName = `${ogpResult.ogSiteName} - `; 169 | } 170 | 171 | ogpData.url = ogpResult.ogUrl || ogpResult.requestUrl; 172 | ogpData.title = ogpResult.ogTitle; 173 | ogpData.desc = ogpResult.ogDescription; 174 | 175 | ogpCache[firstLink] = ogpData; 176 | currentMessage["ogp"] = ogpData; 177 | messages[i] = currentMessage; 178 | messageCache[currentMessage.id] = currentMessage; 179 | resolve(i); 180 | linkHandled = true; 181 | } 182 | } 183 | ); 184 | } 185 | } 186 | } else { 187 | messages[i] = currentMessage; 188 | messageCache[currentMessage.id] = currentMessage; 189 | resolve(i); 190 | linkHandled = true; 191 | } 192 | }); 193 | } 194 | 195 | var messageStatuses = []; 196 | for (i = 0; i < messages.length; i++) { 197 | if (messages[i].dataValues.id in messageCache) { 198 | Object.assign(messageCache[messages[i].dataValues.id], { 199 | User: messages[i].User, 200 | sender: (messages[i].User !== null) ? messages[i].User.dataValues.name : "Anonymous" 201 | }); 202 | messages[i] = messageCache[messages[i].dataValues.id]; 203 | } else { 204 | messageStatuses.push(getMessageData(i)); 205 | } 206 | } 207 | 208 | Promise.all(messageStatuses) 209 | .then(function () { 210 | res.render("messages/messages", { 211 | data: messages, 212 | }); 213 | }) 214 | .catch((e) => { 215 | console.log(e); 216 | }); 217 | } 218 | }); 219 | }); 220 | 221 | router.post("/:channel", autoLimit, spamLimit, spamLimit2, expressFunctions.checkAuth, expressFunctions.hasPermission("permission_send_message"), (req, res) => { 222 | var error = false; 223 | if(req.user !== undefined){ 224 | db.models.Message.create({ 225 | ChannelId: req.params.channel, 226 | UserId: req.user.id, 227 | content: req.body.contents, 228 | }).catch((err)=>{ 229 | res.status(400).send(err); 230 | error = true; 231 | }).then((message, err) => { 232 | if(!error){ 233 | console.log(req.user.id, "=>", req.params.channel, "=", req.body.contents); 234 | res.sendStatus(200); 235 | io.emit("newMessage", { 236 | channel_id: message.dataValues.ChannelId, 237 | message_id: message.dataValues.id 238 | }); 239 | } 240 | }); 241 | } else { 242 | res.status(403).send("NO SESSION") 243 | } 244 | 245 | }); 246 | 247 | return router; 248 | }; 249 | -------------------------------------------------------------------------------- /server/views/static/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /server/views/client/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%- include('_head', {req}) %> 4 | 5 | 6 |
    7 | <% if(req.user.permissions.permission_edit_channels){%> 8 |
    9 |
    10 |

    New Channel

    11 |
    12 | 13 |
    14 | 15 | 16 |
    17 | 18 | 19 | 20 |
    21 | 22 | 23 | 24 | 25 | 26 |
    27 | 28 | 29 |
    30 |
    31 |
    32 | <% } %> 33 | 34 | 35 | <% if(req.user.permissions.permission_edit_channels){%> 36 |
    37 |
    38 |

    Edit Channel

    39 |
    40 |
    41 | 42 | 43 |
    44 |
    45 |
    46 | 47 | 48 |
    49 |
    50 |
    51 | <% } %> 52 | 53 | 54 | <% if(req.user.permissions.permission_edit_server || req.user.permissions.permission_edit_roles){%> 55 |
    56 |
    57 |

    Server Settings

    58 |
    59 | 60 | <% if(req.user.permissions.permission_edit_server){%> 61 |
    62 |

    Details

    63 |
    64 | 65 | 66 |
    67 | 68 |
    69 |
    70 | <% } %> 71 | 72 | <% if(req.user.permissions.permission_edit_roles && roles !== undefined){%> 73 |
    74 |

    Roles

    75 |
      76 | <% roles.forEach(role => { %> 77 | <% if(role.name !== "owner"){%> 78 | <% var permissions = Object.keys(role.dataValues).filter(function(k) { 79 | return k.indexOf('permission') == 0; 80 | }).reduce(function(newData, k) { 81 | newData[k] = role.dataValues[k]; 82 | newData['isAdmin'] = role.dataValues['isAdmin']; 83 | return newData; 84 | }, {}) %> 85 |
    • 86 | <%=role.name%> 87 |
      88 |
      89 | <% if(role.name !== "default"){ %> 90 |
      91 | 92 |
      93 | 94 |
      95 |
      96 | <% } %> 97 | 98 | 99 |
      100 | 101 | <% for( const [name, value] of Object.entries(permissions)){ %> 102 |
      103 | 104 | 105 | checked <% } %> 106 | <% if(permissions['isAdmin'] && name !== 'isAdmin'){ %> disabled <%} %> value="true"> 107 |
      108 | <% }; %> 109 | 110 | 111 | 112 | <% if(role.name !== "default"){ %> 113 | 114 | <% } %> 115 |
      116 | 117 |
      118 |
      119 | 120 |
    • 121 | <% }%> 122 | <% }); %> 123 |
    124 | 125 |
    126 | <% } %> 127 |
    128 |
    129 | <% } %> 130 |
    131 | 132 | 164 | 165 |
    166 | 167 | 168 | 169 |
    170 | 173 |
    174 |

    175 |

    176 |
    177 |
    178 |
    179 | 182 | 183 |
    184 |
      185 | 186 |
    187 |
    188 |
    189 | 190 | 201 | 202 | 203 | <% if(req.user.permissions.permission_send_message){ %> 204 |
    205 | 206 | 207 |
    208 | <% } else {%> 209 |
    210 |

    You do not have permission to send messages.

    211 |
    212 | <%} %> 213 | 214 |
    215 | 216 | 217 | 220 | 221 | -------------------------------------------------------------------------------- /server/controllers/signalling/signalling.js: -------------------------------------------------------------------------------- 1 | //WHEN CLIENT CONNECTS TO THE SIGNALLING SERVER 2 | const { v4: uuidv4 } = require('uuid'); 3 | 4 | function startServer({ db, io, config, secret, port, temp_users }) { 5 | 6 | // User Object 7 | class user{ 8 | constructor(socket, data){ 9 | 10 | // "Public" properties 11 | this.id = socket.request.session.passport.user; 12 | this.socket = socket; 13 | this.name = null; 14 | this.channel = null; 15 | this.data = {}; 16 | this.temp = false; 17 | 18 | // "Private" properties 19 | this._status = "online" 20 | 21 | // Getters/ setters 22 | this.publicData; 23 | this.status; 24 | 25 | // Get user data 26 | (async()=>{ 27 | var db_user = await db.models.User.findOne({where: { id: this.id }}); 28 | if (db_user !== null || socket.request.session.passport.user in temp_users) { 29 | // console.log(`User ${socket.id} Connected`); 30 | if(db_user !== null){ 31 | if (db_user.name !== data.name && ![null, undefined].includes(data.name)) db_user.update({ name: data.name }); 32 | this.temp = false; 33 | } else { 34 | db_user = temp_users[socket.request.session.passport.user]; 35 | this.temp = true; 36 | }; 37 | this.name = db_user.name; 38 | this.data = db_user; 39 | this.initSockets(); 40 | server.sendUpdate(); 41 | } else { 42 | this.socket.disconnect(true); 43 | server.deleteUser(this.id); 44 | }; 45 | })(); 46 | }; 47 | 48 | get publicData(){ 49 | return { 50 | name: this.name, 51 | channel: this.channel, 52 | status: this.status, 53 | temp: this.temp, 54 | socketID: this.socket.id 55 | } 56 | }; 57 | 58 | set status(status){ 59 | var status = status.toLowerCase(); 60 | switch (status){ 61 | case "online": 62 | this._status = "online"; 63 | this.channel = null; 64 | break; 65 | case "offline": 66 | // console.log(`User ${this.id} Disconnected`); 67 | this._status = "offline"; 68 | this.channel = null; 69 | break; 70 | default: 71 | console.log("Error: Invalid status"); 72 | } 73 | server.sendUpdate(); 74 | if(this.channel !== null) this.leaveChannel(); 75 | }; 76 | 77 | get status(){ 78 | return this._status; 79 | } 80 | 81 | initSockets(){ 82 | this.socket.emit("serverInfo", server.publicData); 83 | 84 | this.socket.on('updateInfo', (data)=>{ 85 | db.models.User.findOne({ 86 | where: { id: this.id }, 87 | }).then((db_user) => { 88 | if (db_user !== null) { 89 | if (db_user.dataValues.name !== data.name) { 90 | db_user.update({ name: data.name }); 91 | } 92 | } else { 93 | temp_users[this.id].name = data.name; 94 | } 95 | server.sendUpdate(); 96 | }); 97 | }); 98 | 99 | //INITIATE CHANNEL JOIN PROCESS 100 | this.socket.on("joinChannel", (id) => { 101 | this.joinChannel(id); 102 | }); 103 | 104 | this.socket.on("leaveChannel", ()=>{ 105 | this.leaveChannel(); 106 | }); 107 | 108 | this.socket.on("disconnect", (reason)=>{ 109 | // console.log("Disconnect:", reason) 110 | this.status = "offline"; 111 | }); 112 | } 113 | 114 | reconnect(socket, data){ 115 | this.socket = socket; 116 | if(this.name !== data.name && ![null, undefined].includes(data.name)) { 117 | (this.temp === false) ? this.data.update({ name: data.name }) : this.data.name = data.name; 118 | this.name = data.name; 119 | }; 120 | this.initSockets(); 121 | this.status = "online"; 122 | } 123 | 124 | async joinChannel(id){ 125 | var channel = await db.models.Channel.findOne({where: {id}}); 126 | if (channel !== undefined) { 127 | // console.log(`User ${this.id} changing to channel ${channel.id}`); 128 | server.mcu.emit("setChannel", { 129 | user: this.id, 130 | channel: channel.id 131 | }); 132 | this.channel = channel; 133 | } else { 134 | this.socket.emit("ocerror", "Channel is not valid"); 135 | } 136 | } 137 | 138 | leaveChannel(){ 139 | server.mcu.emit("closeCall", {user: this.id}); 140 | }; 141 | 142 | }; 143 | 144 | class mcu{ 145 | constructor(){ 146 | this.status = "offline"; 147 | this.id = null; 148 | this.socket = null; 149 | } 150 | 151 | initSockets(){ 152 | this.socket.emit("serverInfo", server.publicData); 153 | 154 | this.socket.on("peerReady", (id)=>{ 155 | server.users[id].socket.emit("canJoin", true); 156 | }); 157 | 158 | this.socket.on("peerConnected", (data)=>{ 159 | server.users[data.user].channel = data.channel; 160 | server.sendUpdate(); 161 | // console.log(`User ${data.user} has successfully joined channel ${server.users[data.user].channel}`); 162 | }); 163 | 164 | this.socket.on("mcu_error", (data)=>{ 165 | console.log(data) 166 | }) 167 | 168 | this.socket.on("callClosed", (userId)=>{ 169 | if (server.users[userId] !== undefined) { 170 | // console.log(`User ${user} has been disconnected from ${server.users[user].channel}`); 171 | server.users[userId].channel = null; 172 | server.sendUpdate('users'); 173 | }; 174 | }); 175 | 176 | this.socket.on("disconnect", () => { 177 | for(const temp_user of Object.values(server.users)){ 178 | temp_user.channel = null; 179 | } 180 | this.status = "offline"; 181 | server.sendUpdate(['users']); 182 | console.log("MCU LOST CONNECTION"); 183 | this.socket.disconnect(); //Just to make sure its completely disconnected 184 | }); 185 | } 186 | 187 | connect(socket){ 188 | this.socket = socket; 189 | this.id = socket.id; 190 | this.status = "online"; 191 | this.initSockets(); 192 | } 193 | 194 | emit(type, data){ 195 | if(this.socket !== null){ 196 | this.socket.emit(type, data) 197 | } 198 | } 199 | } 200 | 201 | // Server Object 202 | var server = new class{ 203 | constructor(){ 204 | this.name = config.name; 205 | this.users = {}; 206 | this.mcu = new mcu(); 207 | this.peerPort = port; 208 | this.publicData; 209 | this.updateChannels(); 210 | }; 211 | 212 | get publicData(){ 213 | var data = { 214 | name: this.name, 215 | users: {}, 216 | channels: this.channels 217 | }; 218 | 219 | for (const [id, temp_user] of Object.entries(this.users)) { 220 | data.users[id] = temp_user.publicData; 221 | }; 222 | 223 | return data 224 | }; 225 | 226 | async updateChannels(){ 227 | var channels = {}; 228 | var result = await db.models.Channel.aggregate("type", "DISTINCT", { plain: false }); 229 | for(const typeObj of result){ 230 | var type = typeObj.DISTINCT; 231 | channels[type] = []; 232 | 233 | var typeChannels = await db.models.Channel.findAll({ 234 | where: { 235 | type: type, 236 | }, 237 | order: [ 238 | ['position', 'ASC'] 239 | ] 240 | }); 241 | 242 | for(const channel of typeChannels){ 243 | channels[type].push(channel); 244 | }; 245 | }; 246 | 247 | this.channels = channels; 248 | 249 | this.sendUpdate("channels"); 250 | } 251 | 252 | addUser(socket, data){ 253 | this.users[socket.request.session.passport.user] = new user(socket, data); 254 | this.sendUpdate(); 255 | }; 256 | 257 | removeUser(id){ 258 | this.users[id].remove(); 259 | this.sendUpdate(); 260 | }; 261 | 262 | deleteUser(id){ 263 | delete this.users[id]; 264 | this.sendUpdate(); 265 | } 266 | 267 | sendUpdate(props){ 268 | var publicData = this.publicData; 269 | var data = {}; 270 | 271 | if(props === undefined || props === "all"){ 272 | data.updateData = Object.keys(publicData); 273 | } else if(typeof props === "string"){ 274 | data.updateData = [props]; 275 | } else if(Array.isArray(props)){ 276 | data.updateData = props; 277 | } 278 | 279 | for(const prop of data.updateData){ 280 | data[prop] = publicData[prop] 281 | }; 282 | 283 | io.emit("serverUpdate", data); 284 | } 285 | }; 286 | 287 | // Initialize user objects 288 | io.on("connection",(socket)=>{ 289 | socket.on("clientInfo", (data)=>{ 290 | if (data.type == "client") { 291 | if (server.mcu.status !== "offline") { 292 | // MCU is working, allow connections 293 | if(server.users[socket.request.session.passport.user] === undefined){ 294 | // Add a new user 295 | server.addUser(socket, data); 296 | } else { 297 | // Set existing user to be online 298 | if(server.users[socket.request.session.passport.user].status === "online"){ 299 | socket.disconnect(true); 300 | } else { 301 | server.users[socket.request.session.passport.user].reconnect(socket, data); 302 | } 303 | }; 304 | } else { 305 | // Disconnect as MCU is down 306 | socket.disconnect(true); 307 | } 308 | } else if (data.type == "mcu") { 309 | if (data.secret == secret) { 310 | // MCU has connected with the correct secret token 311 | console.log("MCU Client ✔"); 312 | server.mcu.connect(socket); 313 | } else { 314 | // There has been an error or somebody has connected trying to impersonate the MCU 315 | console.log("MCU WITH WRONG SECRET HAS TRIED TO CONNECT"); 316 | } 317 | } else { 318 | console.log('Unknown client type'); 319 | } 320 | }); 321 | }); 322 | 323 | console.log("Signalling ✔"); 324 | 325 | return server; 326 | } 327 | 328 | module.exports = startServer; -------------------------------------------------------------------------------- /server/views/static/js/mcu.js: -------------------------------------------------------------------------------- 1 | 2 | // SOCKET LISTENERS 3 | var mcu = new class{ 4 | constructor(){ 5 | this.users = {}; 6 | this.channels = {}; 7 | this.peer = null; 8 | this.socket = null; 9 | this.initSockets(); 10 | } 11 | 12 | initSockets(){ 13 | this.socket = null; // Just to make sure the listeners are removed 14 | this.socket = io.connect(); 15 | 16 | this.socket.on("connect", ()=>{ 17 | console.log('Connected to websocket') 18 | this.socket.emit("clientInfo", { 19 | type: "mcu", 20 | secret: server_secret 21 | }); 22 | 23 | this.peer = new Peer("server", { 24 | host: "localhost", 25 | path: '/rtc' 26 | }); 27 | 28 | this.peer.on("call", (call)=>{ 29 | var result = Object.assign({}, ...Object.entries(this.users).map(([a,b]) => ({ [b.socket_id]: a }))); 30 | if(call.peer in result){ 31 | this.users[result[call.peer]].setCall(call); 32 | } else { 33 | // Error - need to tell the server to resend the user data 34 | this.socket.emit("mcu_error", {"type": "user_not_exists", "data": call.peer}) 35 | } 36 | }); 37 | }); 38 | 39 | this.socket.on("serverInfo", (data)=>{ 40 | this.syncToServer(data); 41 | }); 42 | 43 | this.socket.on("serverUpdate", (data)=>{ 44 | this.syncToServer(data); 45 | }) 46 | 47 | this.socket.on("setChannel", (data)=>{ 48 | if(data.user in this.users){ 49 | // If user exists set their channel 50 | this.users[data.user].setChannel(data.channel) 51 | } else { 52 | // Error - need to tell the server to resend the user data 53 | this.socket.emit("mcu_error", {"type": "user_not_exists", "data": data.user}) 54 | } 55 | }); 56 | 57 | this.socket.on("closeCall", (data)=>{ 58 | if(data.user in this.users){ 59 | this.users[data.user].closeCall(); 60 | } else { 61 | this.socket.emit("callClosed", data.user) 62 | } 63 | }); 64 | 65 | this.socket.on("disconnect", ()=>{ 66 | console.log("Lost connection") 67 | // this.initSockets(); 68 | // Had issues with double responses to socket events when re-initiating the connection. 69 | // This should basically do the same thing but without risking any old data getting muddled up 70 | window.location.reload(); 71 | }); 72 | } 73 | 74 | syncToServer(data){ 75 | // Add any new users 76 | 77 | if("users" in data){ 78 | for(const [userID, user] of Object.entries(data.users)){ 79 | if(!(userID in this.users) && user.status === "online" ){ 80 | // Create the new user object, can set constraints etc in here 81 | user.id = userID; 82 | this.users[userID] = new client(user, {}); 83 | } 84 | if(user.status === "online") this.users[userID].socket_id = user.socketID; 85 | }; 86 | 87 | // Remove any disconnected users 88 | for(const user of Object.values(this.users)){ 89 | if(!(user.id in data.users) || data.users[user.id].status === "offline"){ 90 | this.users[user.id].closeCall(); 91 | delete this.users[user.id]; 92 | } 93 | } 94 | } 95 | 96 | if("channels" in data){ 97 | // Create channels if they don't exist 98 | if(data.channels.voice === undefined){ 99 | data.channels["voice"] = []; 100 | }; 101 | 102 | for(const vc of data.channels.voice){ 103 | if(!(vc.id in this.channels)){ 104 | this.channels[vc.id] = new channel(vc); 105 | } 106 | }; 107 | 108 | // Remove any channels that have been deleted 109 | for(const vc in this.channels){ 110 | var found = false; 111 | for(const serverChannel of data.channels.voice){ 112 | if(vc === serverChannel.id){ 113 | found = true; 114 | } 115 | }; 116 | 117 | if(!found){ 118 | this.channels[vc].getUsers().forEach(userID=>{ 119 | this.users[userID].closeCall(); 120 | }); 121 | this.channels[vc].remove(); 122 | delete this.channels[vc]; 123 | } 124 | }; 125 | } 126 | } 127 | } 128 | 129 | 130 | class client{ 131 | constructor(data, constraints={}){ 132 | // Set constraints such as muted users and other stuff 133 | // Then use in update streams 134 | this.id = data.id; 135 | this.constraints = constraints; 136 | this.call = null; 137 | this.mixer = null; 138 | this.channel = null; 139 | this.socket_id = data.socketID; 140 | } 141 | 142 | setCall(call){ 143 | this.call = call; 144 | this.call.answer(new MediaStream([createEmptyAudioTrack()])); 145 | 146 | this.call.on("stream", ()=>{ 147 | if(this.channel !== null && mcu.channels[this.channel] !== undefined){ 148 | // Update the streams for the user's channel 149 | mcu.channels[this.channel].updateStreams(); 150 | 151 | // Tell the server that they have connected 152 | mcu.socket.emit("peerConnected", { 153 | user: this.id, 154 | channel: this.channel, 155 | }); 156 | } 157 | }); 158 | 159 | this.call.on("error", ()=>{ 160 | this.closeCall(); 161 | }); 162 | }; 163 | 164 | closeCall(){ 165 | // Remove user from old channel if there is any 166 | if(this.channel !== null){ 167 | // Update the old channel 168 | if(mcu.channels[this.channel] !== undefined){ 169 | mcu.channels[this.channel].updateStreams(); 170 | }; 171 | 172 | // Close the call if it exists 173 | if (this.call !== null){ 174 | this.call.close(); 175 | }; 176 | 177 | // Get rid of the mixer if it exists 178 | if(this.mixer !== null){ 179 | this.mixer.end(); 180 | this.mixer = null; 181 | } 182 | 183 | // Set the channel value to null 184 | this.channel = null; 185 | 186 | // Tell the server that the call has been closed 187 | mcu.socket.emit("callClosed", this.id); 188 | }; 189 | }; 190 | 191 | setChannel(channelID){ 192 | // Close any existing calls first 193 | this.closeCall(); 194 | 195 | // Set the channel to the new ID 196 | this.channel = channelID; 197 | 198 | // Tell the server that it is ready 199 | mcu.socket.emit("peerReady", this.id); 200 | } 201 | } 202 | 203 | class channel{ 204 | constructor({id}){ 205 | this.id = id; 206 | // Keeping this here just in case the audio element is needed 207 | var audio = document.createElement("AUDIO"); 208 | audio.controls = true; 209 | audio.autoplay = true; 210 | audio.muted = true; 211 | this.audioOut = document.body.appendChild(audio); 212 | } 213 | 214 | getUsers(){ 215 | var connectedUsers = []; 216 | for(const user of Object.values(mcu.users)){ 217 | if(user.channel === this.id){ 218 | connectedUsers.push(user.id); 219 | } 220 | }; 221 | return connectedUsers 222 | } 223 | 224 | updateStreams(){ 225 | var connectedUsers = this.getUsers(); 226 | 227 | // Iterate through each connected user 228 | connectedUsers.forEach(currentID => { 229 | // check that the current user's call exists 230 | if(mcu.users[currentID]["call"] !== null){ 231 | 232 | // End the existing mixer if it already exists 233 | if (mcu.users[currentID].mixer !== null ){ 234 | mcu.users[currentID].mixer.end(); 235 | mcu.users[currentID].mixer = null; 236 | } 237 | 238 | // Duplicate the user array minus the current user, check that the user's call exists 239 | // TODO: Should also do checks for user constraints in here as well 240 | var peers_filtered = connectedUsers.filter((value, index, arr)=>{ 241 | return (value !== currentID && mcu.users[value].call !== null); 242 | }); 243 | 244 | if (peers_filtered.length > 1) { 245 | // If more than one other user, combine streams 246 | 247 | // Array to contain any streams that need to be combined by the stream mixer 248 | var inStreams = []; 249 | 250 | // Add remote stream to inStreams 251 | for (i = 0; i < peers_filtered.length; i++) { 252 | inStreams[i] = mcu.users[peers_filtered[i]]["call"]["peerConnection"].getRemoteStreams()[0]; 253 | } 254 | 255 | // Mix the inStreams together with MSM 256 | mcu.users[currentID]["mixer"] = new MultiStreamsMixer(inStreams); 257 | 258 | // Get the mixed streams/ tracks 259 | var mix_stream = mcu.users[currentid]["mixer"].getMixedStream(); 260 | var mix_track = mix_stream.getAudioTracks()[0]; 261 | 262 | // I can't remember what this did to be honest 263 | // I'll test it without for now 264 | this.audioOut.srcObject = instreams[0]; 265 | 266 | // Set the output track to the mixed audio 267 | mcu.users[currentID]["call"]["peerConnection"].getSenders()[0].replaceTrack(mix_track) 268 | 269 | } else if(peers_filtered.length === 1) { 270 | // Else just forward the incoming streams 271 | 272 | // Get peer stream 273 | var currentStream = mcu.users[peers_filtered[0]]["call"]["peerConnection"].getRemoteStreams()[0]; 274 | 275 | // See above ^13 276 | this.audioOut.srcObject = currentStream; 277 | 278 | // Replace the track with other user's 279 | mcu.users[currentID]["call"]["peerConnection"].getSenders()[0].replaceTrack(currentStream.getAudioTracks()[0]); 280 | 281 | } else { 282 | // No other users 283 | 284 | // Replace output with empty audio track 285 | mcu.users[currentID]["call"]["peerConnection"].getSenders()[0].replaceTrack(createEmptyAudioTrack()); 286 | } 287 | } 288 | }) 289 | }; 290 | 291 | remove(){ 292 | this.audioOut.remove() 293 | }; 294 | 295 | // TODO: Allow for streams to be appended/ removed 296 | addUser(){ 297 | } 298 | } 299 | 300 | const createEmptyAudioTrack = () => { 301 | const ctx = new AudioContext(); 302 | const oscillator = ctx.createOscillator(); 303 | const dst = oscillator.connect(ctx.createMediaStreamDestination()); 304 | oscillator.start(); 305 | const track = dst.stream.getAudioTracks()[0]; 306 | return Object.assign(track, { 307 | enabled: false 308 | }); 309 | }; -------------------------------------------------------------------------------- /server/views/static/css/openchat.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* COLORS */ 3 | /* BACKGROUND */ 4 | --bg-h: 0; 5 | --bg-s: 0%; 6 | --bg-l: 100%; 7 | 8 | /* ACCENT */ 9 | --accent-h: 150; 10 | --accent-s: 60%; 11 | --accent-l: 75%; 12 | 13 | /* CALCULATED VALUES*/ 14 | --bg: hsl(var(--bg-h), var(--bg-s), var(--bg-l)); 15 | --bg-darker-5: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .95)); 16 | --bg-darker-10: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .9)); 17 | --bg-darker-15: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .85)); 18 | --bg-darker-20: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .8)); 19 | --bg-darker-40: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .6)); 20 | --bg-darker-60: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .4)); 21 | --bg-darker-80: hsl(var(--bg-h), var(--bg-s), calc(var(--bg-l) * .2)); 22 | 23 | --accent: hsl(var(--accent-h), var(--accent-s), var(--accent-l)); 24 | --accent-contrast: hsl(var(--accent-h), calc(var(--accent-s) / 0.8), calc(var(--accent-l) * 0.8)); 25 | } 26 | 27 | html { 28 | height: 100vh; 29 | width: 100vw; 30 | height: -webkit-fill-available; 31 | } 32 | 33 | body { 34 | display: flex; 35 | flex-direction: row; 36 | height: 100%; 37 | width: 100%; 38 | } 39 | 40 | ol { 41 | padding: 0; 42 | margin: 0; 43 | } 44 | 45 | p { 46 | margin-block-start: 0px; 47 | margin-block-end: 0px; 48 | } 49 | 50 | h4 { 51 | margin-block-start: 0px; 52 | margin-block-end: 0px; 53 | } 54 | 55 | /* SIDEBAR */ 56 | nav { 57 | display: flex; 58 | flex-direction: column; 59 | padding: 10px; 60 | min-width: 270px; 61 | background-color: #f5f5f5; 62 | box-sizing: border-box; 63 | } 64 | 65 | .brand { 66 | margin-top: 5px; 67 | margin-bottom: 10px; 68 | } 69 | 70 | #oc_image { 71 | max-height: 40px; 72 | max-width: 100%; 73 | margin-top: auto; 74 | margin-bottom: auto; 75 | border-radius: 25%; 76 | } 77 | 78 | #server_title { 79 | font-weight: 500; 80 | font-family: 'Muli', sans-serif; 81 | font-size: 24px; 82 | display: inline-block; 83 | max-width: 10em; 84 | word-wrap: break-word; 85 | margin: 0px; 86 | } 87 | 88 | 89 | /* ALIGNMENT */ 90 | .center-self { 91 | text-align: center; 92 | } 93 | 94 | div.center-self { 95 | justify-self: center; 96 | } 97 | 98 | /* CHANNELS */ 99 | #channels { 100 | padding: 5px; 101 | padding-left: 10px; 102 | flex-grow: 1; 103 | display: flex; 104 | flex-direction: column; 105 | justify-content: flex-start; 106 | overflow-y: auto; 107 | overflow-x: hidden; 108 | } 109 | 110 | #channel_heading { 111 | display: flex; 112 | flex-direction: row; 113 | justify-content: space-between; 114 | } 115 | 116 | #add_channel_btn { 117 | color: #bbbbbb; 118 | } 119 | 120 | .channel-group { 121 | justify-self: flex-start; 122 | } 123 | 124 | .channel-group>h4 { 125 | font-weight: 500; 126 | margin-top: 5px; 127 | color: darkgrey; 128 | } 129 | 130 | .channel-group>ul { 131 | padding-left: 5px; 132 | } 133 | 134 | .channel-group>ul>li { 135 | display: flex; 136 | flex-direction: column; 137 | text-decoration: none; 138 | list-style: none; 139 | background: #ffffff; 140 | margin-right: 20px; 141 | margin-bottom: 10px; 142 | } 143 | 144 | .channel-group>ul>li:hover { 145 | background: #f5f5f5; 146 | } 147 | 148 | .channel-name { 149 | display: flex; 150 | flex-direction: row; 151 | justify-content: space-between; 152 | } 153 | 154 | .channel { 155 | display: inline-block; 156 | height: 100%; 157 | box-sizing: border-box; 158 | padding: 10px; 159 | position: relative; 160 | flex-grow: 2; 161 | color: black; 162 | } 163 | 164 | .edit_channel_btn { 165 | align-self: stretch; 166 | height: auto; 167 | padding: 0px 5px; 168 | } 169 | 170 | .edit_channel_btn * { 171 | display: inline-block; 172 | position: relative; 173 | color: #a1a1a1; 174 | } 175 | 176 | .edit_server_btn * { 177 | display: inline-block; 178 | position: relative; 179 | justify-self: flex-end; 180 | color: #a1a1a1; 181 | } 182 | 183 | .channel:hover { 184 | cursor: pointer; 185 | color: black; 186 | text-decoration: none; 187 | } 188 | 189 | #channels .active { 190 | box-shadow: 0px 0px 6px 1px rgba(22, 66, 199, 0.52); 191 | } 192 | 193 | #channels .active>a { 194 | color: rgba(22, 66, 199, 0.52); 195 | } 196 | 197 | #channels .active:hover { 198 | background: none; 199 | } 200 | 201 | #channels .active a.channel:hover { 202 | cursor: default; 203 | } 204 | 205 | .user-list { 206 | position: relative; 207 | list-style: none; 208 | height: auto; 209 | padding: 0px; 210 | } 211 | 212 | .user-list li { 213 | position: relative; 214 | margin-top: 10px; 215 | box-sizing: border-box; 216 | padding-left: 20px; 217 | } 218 | 219 | .user-list li:last-of-type { 220 | margin-bottom: 20px; 221 | } 222 | 223 | 224 | .user-list>li:first-of-type::before { 225 | content: ''; 226 | width: 100%; 227 | height: 1px; 228 | left: 0; 229 | top: -10px; 230 | background: linear-gradient(to right, hsla(0, 0%, 0%, 0.2) 0%, hsla(0, 0%, 0%, 0.2) 33%, hsla(0, 0%, 0%, 0) 90%); 231 | position: absolute; 232 | } 233 | 234 | /* CONTROLS */ 235 | #control_area { 236 | display: flex; 237 | flex-direction: row; 238 | flex-wrap: wrap; 239 | max-width: 100%; 240 | } 241 | 242 | .control-button { 243 | padding: 10px; 244 | margin: 1px; 245 | color: black; 246 | flex-basis: 10%; 247 | 248 | } 249 | 250 | .control-button i { 251 | height: 0px; 252 | width: 0px; 253 | } 254 | 255 | .control-button:hover { 256 | cursor: pointer; 257 | color: black; 258 | } 259 | 260 | /* NOTIFICATION */ 261 | .notification { 262 | position: absolute; 263 | bottom: 0%; 264 | margin-bottom: 10px; 265 | padding: 10px; 266 | background-color: #a6a6a6; 267 | border-radius: 5px; 268 | left: 50%; 269 | transform: translateX(-50%); 270 | color: white; 271 | width: 50%; 272 | display: flex; 273 | flex-direction: row; 274 | justify-content: space-between; 275 | font-weight: 550; 276 | margin-bottom: 100px; 277 | } 278 | 279 | .hidden { 280 | display: none; 281 | } 282 | 283 | /* MESSAGE AREA */ 284 | main { 285 | display: flex; 286 | flex-direction: column; 287 | flex-grow: 1; 288 | padding: 0px; 289 | max-height: 100vh; 290 | overflow: hidden; 291 | position: relative; 292 | } 293 | 294 | #channel_info { 295 | padding-left: 20px; 296 | position: relative; 297 | display: flex; 298 | flex-direction: row; 299 | } 300 | 301 | #channel_info h2 { 302 | display: inline-block; 303 | } 304 | 305 | #channel_info::after { 306 | content: ''; 307 | width: 100%; 308 | height: 1px; 309 | left: 0; 310 | bottom: 0px; 311 | background: linear-gradient(to right, hsla(0, 0%, 0%, 0.1) 0%, hsla(0, 0%, 0%, 0.1) 33%, hsla(0, 0%, 0%, 0) 90%); 312 | box-shadow: 0px 7px 14px rgba(0, 0, 0, 0.2); 313 | position: absolute; 314 | } 315 | 316 | #message_scroll { 317 | position: relative; 318 | } 319 | 320 | #messages { 321 | display: flex; 322 | flex-direction: column-reverse; 323 | justify-content: flex-start; 324 | padding-left: 10px; 325 | padding-right: 10px; 326 | margin: 0px; 327 | box-sizing: content-box; 328 | min-height: 100%; 329 | } 330 | 331 | .message-card { 332 | padding-top: 15px; 333 | padding-bottom: 15px; 334 | padding-left: 30px; 335 | padding-right: 40px; 336 | word-wrap: break-word; 337 | } 338 | 339 | .message-card:hover { 340 | background: #fafafa; 341 | } 342 | 343 | .ogp-card { 344 | display: flex; 345 | flex-direction: row; 346 | margin-left: 20px; 347 | margin-top: 10px; 348 | background: #f6f6f6; 349 | padding: 20px; 350 | align-items: flex-start; 351 | width: auto; 352 | max-width: 800px; 353 | } 354 | 355 | .ogp-card img { 356 | max-height: 50px; 357 | margin-right: 30px; 358 | } 359 | 360 | .ogp-card p { 361 | margin: 0px; 362 | } 363 | 364 | #no_messages { 365 | width: 100%; 366 | height: 200px; 367 | text-align: center; 368 | display: flex; 369 | flex-direction: column; 370 | justify-content: center; 371 | } 372 | 373 | #no_messages a { 374 | font-size: 36px; 375 | font-weight: 600; 376 | color: darkgrey; 377 | } 378 | 379 | #load_messages { 380 | width: 100%; 381 | text-align: center; 382 | margin-top: auto; 383 | padding-top: 30px; 384 | margin-bottom: 20px; 385 | } 386 | 387 | #load_messages a { 388 | text-decoration: none; 389 | border: #ebe9e9; 390 | border-style: solid; 391 | border-width: 1px; 392 | border-radius: 20px; 393 | padding: 10px; 394 | color: darkgrey; 395 | } 396 | 397 | #load_messages a:focus:hover { 398 | background-color: #f6f6f6; 399 | } 400 | 401 | #new_message>.content { 402 | display: flex; 403 | flex-direction: row; 404 | justify-content: space-between; 405 | } 406 | 407 | .yt-container { 408 | width: 100%; 409 | height: 100%; 410 | max-width: 520px; 411 | max-height: 292.5px; 412 | } 413 | 414 | .yt-player { 415 | position: relative; 416 | padding-bottom: 56.25%; 417 | /* 16:9 */ 418 | height: 0; 419 | 420 | box-sizing: border-box; 421 | } 422 | 423 | .yt-player iframe { 424 | position: absolute; 425 | top: 0; 426 | left: 0; 427 | width: 100%; 428 | height: 100%; 429 | } 430 | 431 | 432 | .spotify-embed { 433 | width: 100%; 434 | background: #282828; 435 | min-width: 80px; 436 | height: 400px; 437 | } 438 | 439 | /* MESSAGE INPUT */ 440 | #message_input_area { 441 | display: flex; 442 | flex-direction: row; 443 | border-radius: 0px; 444 | background: #fcfcfc; 445 | /* box-shadow: 0px -7px 14px #ededed; */ 446 | width: 100%; 447 | margin: 0; 448 | padding: 20px; 449 | box-sizing: border-box; 450 | } 451 | 452 | #message_box { 453 | flex-grow: 1; 454 | width: 100%; 455 | width: 100%; 456 | box-sizing: border-box; 457 | resize: none; 458 | overflow-y: auto; 459 | } 460 | 461 | /* detect sub 700 wide displays */ 462 | #nav-toggle, 463 | #nav-close { 464 | display: none; 465 | color: #444444; 466 | } 467 | 468 | #nav-toggle * { 469 | color: #444444; 470 | } 471 | 472 | @media screen and (max-width: 700px) { 473 | 474 | /* MAKE NAV 100% WIDTH, SWIPE BETWEEN NAV/ MESSAGES */ 475 | nav { 476 | overflow-x: auto; 477 | position: fixed; 478 | z-index: 99; 479 | display: flex; 480 | width: 100vw; 481 | height: 100vh; 482 | overflow-y: auto; 483 | } 484 | 485 | main { 486 | min-width: 100vw; 487 | } 488 | 489 | main>h2 { 490 | display: inline-block; 491 | } 492 | 493 | .modal { 494 | width: 80vw; 495 | } 496 | 497 | .login-form { 498 | margin-left: auto; 499 | margin-right: auto; 500 | } 501 | 502 | #messages { 503 | padding-left: 20px; 504 | padding-right: 20px; 505 | } 506 | 507 | .message-card { 508 | padding: 10px; 509 | } 510 | 511 | .ogp-card { 512 | flex-direction: column; 513 | padding: 5px; 514 | } 515 | 516 | #nav-toggle { 517 | display: inline-block; 518 | padding-right: 30px; 519 | } 520 | 521 | #nav-close { 522 | display: block; 523 | position: absolute; 524 | top: 20px; 525 | right: 40px; 526 | } 527 | } 528 | 529 | @media screen and (min-width: 700px) { 530 | nav { 531 | overflow-x: auto; 532 | position: relative; 533 | display: flex !important; 534 | width: auto; 535 | height: auto; 536 | } 537 | } 538 | 539 | @media screen and (max-width: 275px) { 540 | /* Too small, reject/ change orientation */ 541 | 542 | } 543 | 544 | @media screen and (max-height: 465px) { 545 | /* Too small, reject/ change orientation */ 546 | 547 | } -------------------------------------------------------------------------------- /server/views/static/js/MultiStreamsMixer.js: -------------------------------------------------------------------------------- 1 | // Last time updated: 2019-06-21 4:09:42 AM UTC 2 | 3 | // ________________________ 4 | // MultiStreamsMixer v1.2.2 5 | 6 | // Open-Sourced: https://github.com/muaz-khan/MultiStreamsMixer 7 | 8 | // -------------------------------------------------- 9 | // Muaz Khan - www.MuazKhan.com 10 | // MIT License - www.WebRTC-Experiment.com/licence 11 | // -------------------------------------------------- 12 | 13 | function MultiStreamsMixer(arrayOfMediaStreams, elementClass) { 14 | 15 | var browserFakeUserAgent = 'Fake/5.0 (FakeOS) AppleWebKit/123 (KHTML, like Gecko) Fake/12.3.4567.89 Fake/123.45'; 16 | 17 | (function(that) { 18 | if (typeof RecordRTC !== 'undefined') { 19 | return; 20 | } 21 | 22 | if (!that) { 23 | return; 24 | } 25 | 26 | if (typeof window !== 'undefined') { 27 | return; 28 | } 29 | 30 | if (typeof global === 'undefined') { 31 | return; 32 | } 33 | 34 | global.navigator = { 35 | userAgent: browserFakeUserAgent, 36 | getUserMedia: function() {} 37 | }; 38 | 39 | if (!global.console) { 40 | global.console = {}; 41 | } 42 | 43 | if (typeof global.console.log === 'undefined' || typeof global.console.error === 'undefined') { 44 | global.console.error = global.console.log = global.console.log || function() { 45 | console.log(arguments); 46 | }; 47 | } 48 | 49 | if (typeof document === 'undefined') { 50 | /*global document:true */ 51 | that.document = { 52 | documentElement: { 53 | appendChild: function() { 54 | return ''; 55 | } 56 | } 57 | }; 58 | 59 | document.createElement = document.captureStream = document.mozCaptureStream = function() { 60 | var obj = { 61 | getContext: function() { 62 | return obj; 63 | }, 64 | play: function() {}, 65 | pause: function() {}, 66 | drawImage: function() {}, 67 | toDataURL: function() { 68 | return ''; 69 | }, 70 | style: {} 71 | }; 72 | return obj; 73 | }; 74 | 75 | that.HTMLVideoElement = function() {}; 76 | } 77 | 78 | if (typeof location === 'undefined') { 79 | /*global location:true */ 80 | that.location = { 81 | protocol: 'file:', 82 | href: '', 83 | hash: '' 84 | }; 85 | } 86 | 87 | if (typeof screen === 'undefined') { 88 | /*global screen:true */ 89 | that.screen = { 90 | width: 0, 91 | height: 0 92 | }; 93 | } 94 | 95 | if (typeof URL === 'undefined') { 96 | /*global screen:true */ 97 | that.URL = { 98 | createObjectURL: function() { 99 | return ''; 100 | }, 101 | revokeObjectURL: function() { 102 | return ''; 103 | } 104 | }; 105 | } 106 | 107 | /*global window:true */ 108 | that.window = global; 109 | })(typeof global !== 'undefined' ? global : null); 110 | 111 | // requires: chrome://flags/#enable-experimental-web-platform-features 112 | 113 | elementClass = elementClass || 'multi-streams-mixer'; 114 | 115 | var videos = []; 116 | var isStopDrawingFrames = false; 117 | 118 | var canvas = document.createElement('canvas'); 119 | var context = canvas.getContext('2d'); 120 | canvas.style.opacity = 0; 121 | canvas.style.position = 'absolute'; 122 | canvas.style.zIndex = -1; 123 | canvas.style.top = '-1000em'; 124 | canvas.style.left = '-1000em'; 125 | canvas.className = elementClass; 126 | (document.body || document.documentElement).appendChild(canvas); 127 | 128 | this.disableLogs = false; 129 | this.frameInterval = 10; 130 | 131 | this.width = 360; 132 | this.height = 240; 133 | 134 | // use gain node to prevent echo 135 | this.useGainNode = true; 136 | 137 | var self = this; 138 | 139 | // _____________________________ 140 | // Cross-Browser-Declarations.js 141 | 142 | // WebAudio API representer 143 | var AudioContext = window.AudioContext; 144 | 145 | if (typeof AudioContext === 'undefined') { 146 | if (typeof webkitAudioContext !== 'undefined') { 147 | /*global AudioContext:true */ 148 | AudioContext = webkitAudioContext; 149 | } 150 | 151 | if (typeof mozAudioContext !== 'undefined') { 152 | /*global AudioContext:true */ 153 | AudioContext = mozAudioContext; 154 | } 155 | } 156 | 157 | /*jshint -W079 */ 158 | var URL = window.URL; 159 | 160 | if (typeof URL === 'undefined' && typeof webkitURL !== 'undefined') { 161 | /*global URL:true */ 162 | URL = webkitURL; 163 | } 164 | 165 | if (typeof navigator !== 'undefined' && typeof navigator.getUserMedia === 'undefined') { // maybe window.navigator? 166 | if (typeof navigator.webkitGetUserMedia !== 'undefined') { 167 | navigator.getUserMedia = navigator.webkitGetUserMedia; 168 | } 169 | 170 | if (typeof navigator.mozGetUserMedia !== 'undefined') { 171 | navigator.getUserMedia = navigator.mozGetUserMedia; 172 | } 173 | } 174 | 175 | var MediaStream = window.MediaStream; 176 | 177 | if (typeof MediaStream === 'undefined' && typeof webkitMediaStream !== 'undefined') { 178 | MediaStream = webkitMediaStream; 179 | } 180 | 181 | /*global MediaStream:true */ 182 | if (typeof MediaStream !== 'undefined') { 183 | // override "stop" method for all browsers 184 | if (typeof MediaStream.prototype.stop === 'undefined') { 185 | MediaStream.prototype.stop = function() { 186 | this.getTracks().forEach(function(track) { 187 | track.stop(); 188 | }); 189 | }; 190 | } 191 | } 192 | 193 | var Storage = {}; 194 | 195 | if (typeof AudioContext !== 'undefined') { 196 | Storage.AudioContext = AudioContext; 197 | } else if (typeof webkitAudioContext !== 'undefined') { 198 | Storage.AudioContext = webkitAudioContext; 199 | } 200 | 201 | function setSrcObject(stream, element) { 202 | if ('srcObject' in element) { 203 | element.srcObject = stream; 204 | } else if ('mozSrcObject' in element) { 205 | element.mozSrcObject = stream; 206 | } else { 207 | element.srcObject = stream; 208 | } 209 | } 210 | 211 | this.startDrawingFrames = function() { 212 | drawVideosToCanvas(); 213 | }; 214 | 215 | function drawVideosToCanvas() { 216 | if (isStopDrawingFrames) { 217 | return; 218 | } 219 | 220 | var videosLength = videos.length; 221 | 222 | var fullcanvas = false; 223 | var remaining = []; 224 | videos.forEach(function(video) { 225 | if (!video.stream) { 226 | video.stream = {}; 227 | } 228 | 229 | if (video.stream.fullcanvas) { 230 | fullcanvas = video; 231 | } else { 232 | // todo: video.stream.active or video.stream.live to fix blank frames issues? 233 | remaining.push(video); 234 | } 235 | }); 236 | 237 | if (fullcanvas) { 238 | canvas.width = fullcanvas.stream.width; 239 | canvas.height = fullcanvas.stream.height; 240 | } else if (remaining.length) { 241 | canvas.width = videosLength > 1 ? remaining[0].width * 2 : remaining[0].width; 242 | 243 | var height = 1; 244 | if (videosLength === 3 || videosLength === 4) { 245 | height = 2; 246 | } 247 | if (videosLength === 5 || videosLength === 6) { 248 | height = 3; 249 | } 250 | if (videosLength === 7 || videosLength === 8) { 251 | height = 4; 252 | } 253 | if (videosLength === 9 || videosLength === 10) { 254 | height = 5; 255 | } 256 | canvas.height = remaining[0].height * height; 257 | } else { 258 | canvas.width = self.width || 360; 259 | canvas.height = self.height || 240; 260 | } 261 | 262 | if (fullcanvas && fullcanvas instanceof HTMLVideoElement) { 263 | drawImage(fullcanvas); 264 | } 265 | 266 | remaining.forEach(function(video, idx) { 267 | drawImage(video, idx); 268 | }); 269 | 270 | setTimeout(drawVideosToCanvas, self.frameInterval); 271 | } 272 | 273 | function drawImage(video, idx) { 274 | if (isStopDrawingFrames) { 275 | return; 276 | } 277 | 278 | var x = 0; 279 | var y = 0; 280 | var width = video.width; 281 | var height = video.height; 282 | 283 | if (idx === 1) { 284 | x = video.width; 285 | } 286 | 287 | if (idx === 2) { 288 | y = video.height; 289 | } 290 | 291 | if (idx === 3) { 292 | x = video.width; 293 | y = video.height; 294 | } 295 | 296 | if (idx === 4) { 297 | y = video.height * 2; 298 | } 299 | 300 | if (idx === 5) { 301 | x = video.width; 302 | y = video.height * 2; 303 | } 304 | 305 | if (idx === 6) { 306 | y = video.height * 3; 307 | } 308 | 309 | if (idx === 7) { 310 | x = video.width; 311 | y = video.height * 3; 312 | } 313 | 314 | if (typeof video.stream.left !== 'undefined') { 315 | x = video.stream.left; 316 | } 317 | 318 | if (typeof video.stream.top !== 'undefined') { 319 | y = video.stream.top; 320 | } 321 | 322 | if (typeof video.stream.width !== 'undefined') { 323 | width = video.stream.width; 324 | } 325 | 326 | if (typeof video.stream.height !== 'undefined') { 327 | height = video.stream.height; 328 | } 329 | 330 | context.drawImage(video, x, y, width, height); 331 | 332 | if (typeof video.stream.onRender === 'function') { 333 | video.stream.onRender(context, x, y, width, height, idx); 334 | } 335 | } 336 | 337 | function getMixedStream() { 338 | isStopDrawingFrames = false; 339 | var mixedVideoStream = getMixedVideoStream(); 340 | 341 | var mixedAudioStream = getMixedAudioStream(); 342 | if (mixedAudioStream) { 343 | mixedAudioStream.getTracks().filter(function(t) { 344 | return t.kind === 'audio'; 345 | }).forEach(function(track) { 346 | mixedVideoStream.addTrack(track); 347 | }); 348 | } 349 | 350 | var fullcanvas; 351 | arrayOfMediaStreams.forEach(function(stream) { 352 | if (stream.fullcanvas) { 353 | fullcanvas = true; 354 | } 355 | }); 356 | 357 | // mixedVideoStream.prototype.appendStreams = appendStreams; 358 | // mixedVideoStream.prototype.resetVideoStreams = resetVideoStreams; 359 | // mixedVideoStream.prototype.clearRecordedData = clearRecordedData; 360 | 361 | return mixedVideoStream; 362 | } 363 | 364 | function getMixedVideoStream() { 365 | resetVideoStreams(); 366 | 367 | var capturedStream; 368 | 369 | if ('captureStream' in canvas) { 370 | capturedStream = canvas.captureStream(); 371 | } else if ('mozCaptureStream' in canvas) { 372 | capturedStream = canvas.mozCaptureStream(); 373 | } else if (!self.disableLogs) { 374 | console.error('Upgrade to latest Chrome or otherwise enable this flag: chrome://flags/#enable-experimental-web-platform-features'); 375 | } 376 | 377 | var videoStream = new MediaStream(); 378 | 379 | capturedStream.getTracks().filter(function(t) { 380 | return t.kind === 'video'; 381 | }).forEach(function(track) { 382 | videoStream.addTrack(track); 383 | }); 384 | 385 | canvas.stream = videoStream; 386 | 387 | return videoStream; 388 | } 389 | 390 | function getMixedAudioStream() { 391 | // via: @pehrsons 392 | if (!Storage.AudioContextConstructor) { 393 | Storage.AudioContextConstructor = new Storage.AudioContext(); 394 | } 395 | 396 | self.audioContext = Storage.AudioContextConstructor; 397 | 398 | self.audioSources = []; 399 | 400 | if (self.useGainNode === true) { 401 | self.gainNode = self.audioContext.createGain(); 402 | self.gainNode.connect(self.audioContext.destination); 403 | self.gainNode.gain.value = 0; // don't hear self 404 | } 405 | 406 | var audioTracksLength = 0; 407 | arrayOfMediaStreams.forEach(function(stream) { 408 | if (!stream.getTracks().filter(function(t) { 409 | return t.kind === 'audio'; 410 | }).length) { 411 | return; 412 | } 413 | 414 | audioTracksLength++; 415 | 416 | var audioSource = self.audioContext.createMediaStreamSource(stream); 417 | 418 | if (self.useGainNode === true) { 419 | audioSource.connect(self.gainNode); 420 | } 421 | 422 | self.audioSources.push(audioSource); 423 | }); 424 | 425 | if (!audioTracksLength) { 426 | // because "self.audioContext" is not initialized 427 | // that's why we've to ignore rest of the code 428 | return; 429 | } 430 | 431 | self.audioDestination = self.audioContext.createMediaStreamDestination(); 432 | self.audioSources.forEach(function(audioSource) { 433 | audioSource.connect(self.audioDestination); 434 | }); 435 | return self.audioDestination.stream; 436 | } 437 | 438 | function getVideo(stream) { 439 | var video = document.createElement('video'); 440 | 441 | setSrcObject(stream, video); 442 | 443 | video.className = elementClass; 444 | 445 | video.muted = true; 446 | video.volume = 0; 447 | 448 | video.width = stream.width || self.width || 360; 449 | video.height = stream.height || self.height || 240; 450 | 451 | video.play(); 452 | 453 | return video; 454 | } 455 | 456 | this.appendStreams = function(streams) { 457 | if (!streams) { 458 | throw 'First parameter is required.'; 459 | } 460 | 461 | if (!(streams instanceof Array)) { 462 | streams = [streams]; 463 | } 464 | 465 | streams.forEach(function(stream) { 466 | var newStream = new MediaStream(); 467 | 468 | if (stream.getTracks().filter(function(t) { 469 | return t.kind === 'video'; 470 | }).length) { 471 | var video = getVideo(stream); 472 | video.stream = stream; 473 | videos.push(video); 474 | 475 | newStream.addTrack(stream.getTracks().filter(function(t) { 476 | return t.kind === 'video'; 477 | })[0]); 478 | } 479 | 480 | if (stream.getTracks().filter(function(t) { 481 | return t.kind === 'audio'; 482 | }).length) { 483 | var audioSource = self.audioContext.createMediaStreamSource(stream); 484 | self.audioDestination = self.audioContext.createMediaStreamDestination(); 485 | audioSource.connect(self.audioDestination); 486 | 487 | newStream.addTrack(self.audioDestination.stream.getTracks().filter(function(t) { 488 | return t.kind === 'audio'; 489 | })[0]); 490 | } 491 | 492 | arrayOfMediaStreams.push(newStream); 493 | }); 494 | }; 495 | 496 | this.releaseStreams = function() { 497 | videos = []; 498 | isStopDrawingFrames = true; 499 | 500 | if (self.gainNode) { 501 | self.gainNode.disconnect(); 502 | self.gainNode = null; 503 | } 504 | 505 | if (self.audioSources.length) { 506 | self.audioSources.forEach(function(source) { 507 | source.disconnect(); 508 | }); 509 | self.audioSources = []; 510 | } 511 | 512 | if (self.audioDestination) { 513 | self.audioDestination.disconnect(); 514 | self.audioDestination = null; 515 | } 516 | 517 | if (self.audioContext) { 518 | self.audioContext.close(); 519 | } 520 | 521 | self.audioContext = null; 522 | 523 | context.clearRect(0, 0, canvas.width, canvas.height); 524 | 525 | if (canvas.stream) { 526 | canvas.stream.stop(); 527 | canvas.stream = null; 528 | }; 529 | }; 530 | 531 | this.end = function(){ 532 | this.releaseStreams(); 533 | canvas.parentNode.removeChild(canvas); 534 | } 535 | 536 | this.resetVideoStreams = function(streams) { 537 | if (streams && !(streams instanceof Array)) { 538 | streams = [streams]; 539 | } 540 | 541 | resetVideoStreams(streams); 542 | }; 543 | 544 | function resetVideoStreams(streams) { 545 | videos = []; 546 | streams = streams || arrayOfMediaStreams; 547 | 548 | // via: @adrian-ber 549 | streams.forEach(function(stream) { 550 | if (!stream.getTracks().filter(function(t) { 551 | return t.kind === 'video'; 552 | }).length) { 553 | return; 554 | } 555 | 556 | var video = getVideo(stream); 557 | video.stream = stream; 558 | videos.push(video); 559 | }); 560 | } 561 | 562 | // for debugging 563 | this.name = 'MultiStreamsMixer'; 564 | this.toString = function() { 565 | return this.name; 566 | }; 567 | 568 | this.getMixedStream = getMixedStream; 569 | 570 | } 571 | 572 | if (typeof RecordRTC === 'undefined') { 573 | if (typeof module !== 'undefined' /* && !!module.exports*/ ) { 574 | module.exports = MultiStreamsMixer; 575 | } 576 | 577 | if (typeof define === 'function' && define.amd) { 578 | define('MultiStreamsMixer', [], function() { 579 | return MultiStreamsMixer; 580 | }); 581 | } 582 | } 583 | --------------------------------------------------------------------------------