├── .gitignore ├── modules ├── ImagePack │ ├── images │ │ ├── favicon.png │ │ ├── Social │ │ │ ├── GitHub.png │ │ │ ├── Reddit.png │ │ │ ├── Blogger.png │ │ │ ├── Facebook.png │ │ │ ├── Instagram.png │ │ │ ├── LinkedIn.png │ │ │ ├── Twitter.png │ │ │ └── YouTube.png │ │ ├── DesktopIcons │ │ │ ├── icon_linux.png │ │ │ ├── icon_mac.icns │ │ │ └── icon_windows.ico │ │ ├── default_profile.svg │ │ ├── send.svg │ │ ├── icon.svg │ │ └── icon_lightmode.svg │ ├── dependencies.json │ └── index.js ├── Sounds │ ├── dependencies.json │ └── index.js ├── Copyright │ ├── template.html │ ├── dependencies.json │ ├── style.css │ └── index.js ├── AddFriend │ ├── template_sidebar.html │ ├── dependencies.json │ ├── style.css │ ├── template_mainContent.html │ └── index.js ├── EditProfile │ ├── template_sidebar.html │ ├── template_iconbar.html │ ├── dependencies.json │ ├── style.css │ ├── template_mainContent.html │ └── index.js ├── I18n │ ├── dependencies.json │ ├── style.css │ ├── template.html │ ├── index.js │ └── lang.json ├── ViewProfile │ ├── template_sidebar.html │ ├── dependencies.json │ ├── style.css │ ├── template_mainContent.html │ └── index.js ├── Layout │ ├── template.html │ ├── dependencies.json │ ├── style.css │ └── index.js ├── Social │ ├── dependencies.json │ ├── style.css │ ├── template.html │ └── index.js ├── Logo │ ├── dependencies.json │ ├── template.html │ ├── style.css │ └── index.js ├── Login │ ├── dependencies.json │ ├── style.css │ ├── template.html │ └── index.js ├── Chat │ ├── template_sidebar.html │ ├── dependencies.json │ ├── template_mainContent.html │ ├── style.css │ └── index.js ├── DBMessenger │ ├── dependencies.json │ ├── Messenger.js │ ├── utils.js │ ├── DBWrapper.js │ ├── index.js │ └── IntranetMessenger.js └── Main │ ├── index.html │ ├── index.js │ ├── preload.js │ └── style.css ├── .clabot ├── eslint.config.js ├── test ├── Copyright │ ├── tCopyright.js │ └── tCheckAllCodeFiles.js ├── Social │ └── tSocial.js ├── Chat │ └── tChat.js ├── I18n │ └── tJSONProperties.js ├── Layout │ └── tLayout.js ├── General │ └── tPackage.js ├── utils.js ├── karma.conf.js ├── Login │ └── tLogin.js └── AddFriend │ └── tAddFriend.js ├── .github └── workflows │ ├── node.js.windows.yml │ └── node.js.mac_linux.yml ├── forge.config.js ├── package.json ├── makefile └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | package-lock.json 4 | .vscode 5 | */node_modules/* 6 | out/ 7 | appdmg.json 8 | -------------------------------------------------------------------------------- /modules/ImagePack/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/favicon.png -------------------------------------------------------------------------------- /modules/ImagePack/images/Social/GitHub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/Social/GitHub.png -------------------------------------------------------------------------------- /modules/ImagePack/images/Social/Reddit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/Social/Reddit.png -------------------------------------------------------------------------------- /modules/ImagePack/images/Social/Blogger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/Social/Blogger.png -------------------------------------------------------------------------------- /modules/ImagePack/images/Social/Facebook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/Social/Facebook.png -------------------------------------------------------------------------------- /modules/ImagePack/images/Social/Instagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/Social/Instagram.png -------------------------------------------------------------------------------- /modules/ImagePack/images/Social/LinkedIn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/Social/LinkedIn.png -------------------------------------------------------------------------------- /modules/ImagePack/images/Social/Twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/Social/Twitter.png -------------------------------------------------------------------------------- /modules/ImagePack/images/Social/YouTube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/Social/YouTube.png -------------------------------------------------------------------------------- /modules/ImagePack/images/DesktopIcons/icon_linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/DesktopIcons/icon_linux.png -------------------------------------------------------------------------------- /modules/ImagePack/images/DesktopIcons/icon_mac.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/DesktopIcons/icon_mac.icns -------------------------------------------------------------------------------- /modules/ImagePack/images/DesktopIcons/icon_windows.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenineasa/HexHoot/HEAD/modules/ImagePack/images/DesktopIcons/icon_windows.ico -------------------------------------------------------------------------------- /modules/Sounds/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [], 4 | "external": [] 5 | } 6 | -------------------------------------------------------------------------------- /modules/Copyright/template.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /modules/ImagePack/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [], 4 | "external": [ 5 | "path" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /modules/AddFriend/template_sidebar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /modules/EditProfile/template_sidebar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /modules/I18n/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [], 4 | "external": [ 5 | "require-text" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /modules/ViewProfile/template_sidebar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /modules/Copyright/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [], 4 | "external": [ 5 | "require-text" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /modules/Copyright/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2023 Zenin Easa Panthakkalakath */ 2 | 3 | #copyright { 4 | font-size: 0.75em; 5 | position: fixed; 6 | left: 0.5em; 7 | bottom: 0.5em; 8 | } 9 | -------------------------------------------------------------------------------- /modules/Layout/template.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 6 |
7 | -------------------------------------------------------------------------------- /modules/Layout/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [ 4 | "Logo" 5 | ], 6 | "external": [ 7 | "require-text" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /modules/Social/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [ 4 | "ImagePack" 5 | ], 6 | "external": [ 7 | "require-text" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /modules/Logo/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [ 4 | "ImagePack", 5 | "I18n" 6 | ], 7 | "external": [ 8 | "require-text" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /modules/EditProfile/template_iconbar.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | -------------------------------------------------------------------------------- /modules/Login/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [ 4 | "Logo", 5 | "DBMessenger", 6 | "I18n" 7 | ], 8 | "external": [ 9 | "require-text" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /modules/Logo/template.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
${i18n.getText('Logo.hexhoot')}
5 |
${i18n.getText('Logo.alpha')}
6 |
7 | -------------------------------------------------------------------------------- /modules/AddFriend/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [ 4 | "Layout", 5 | "DBMessenger", 6 | "I18n" 7 | ], 8 | "external": [ 9 | "require-text" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /modules/ViewProfile/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [ 4 | "Layout", 5 | "ImagePack", 6 | "I18n" 7 | ], 8 | "external": [ 9 | "require-text" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.clabot: -------------------------------------------------------------------------------- 1 | { 2 | "contributors": ["zenineasa"], 3 | "message": "We require contributors to sign our Contributor License Agreement. For more information, please click here.", 4 | "label": "cla-signed", 5 | "recheckComment": "Rechecking pull request" 6 | } 7 | -------------------------------------------------------------------------------- /modules/Login/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | #login { 4 | border-radius: 0.2em; 5 | background: rgba(0,0,0,0.8); 6 | } 7 | #login input, #login textarea { 8 | font-size: 1.5em; 9 | } 10 | #login #generatePrivateKey { 11 | width: 4em; 12 | } 13 | -------------------------------------------------------------------------------- /modules/Social/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2023 Zenin Easa Panthakkalakath */ 2 | 3 | #social { 4 | position: fixed; 5 | right: 0.5em; 6 | bottom: 0.5em; 7 | font-size: 0.75em; 8 | } 9 | #social img { 10 | width: 2em; 11 | } 12 | #social img:hover { 13 | opacity: 0.5; 14 | } 15 | -------------------------------------------------------------------------------- /modules/EditProfile/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [ 4 | "Layout", 5 | "DBMessenger", 6 | "ImagePack", 7 | "I18n" 8 | ], 9 | "external": [ 10 | "require-text" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /modules/ImagePack/images/default_profile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Layer 1 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /modules/Chat/template_sidebar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /modules/I18n/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2023 Zenin Easa Panthakkalakath */ 2 | 3 | #i18n { 4 | position: fixed; 5 | right: 0.5em; 6 | top: 0.5em; 7 | font-size: 0.75em; 8 | } 9 | 10 | #i18n select { 11 | background: rgba(0, 180, 255, 0.8); 12 | color: #ffffff; 13 | padding: 0 0.2em; 14 | border-radius: 0.2em; 15 | margin-left: 0.5em; 16 | } 17 | -------------------------------------------------------------------------------- /modules/DBMessenger/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [], 4 | "external": [ 5 | "indexeddb-export-import", 6 | "hyperswarm", 7 | "ecdh", 8 | "ip", 9 | "http", 10 | "arptable-js", 11 | "url", 12 | "@leichtgewicht/network-interfaces" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /modules/Chat/dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2023 Zenin Easa Panthakkalakath", 3 | "internal": [ 4 | "Layout", 5 | "DBMessenger", 6 | "AddFriend", 7 | "EditProfile", 8 | "ViewProfile", 9 | "ImagePack", 10 | "Sounds", 11 | "I18n" 12 | ], 13 | "external": [ 14 | "require-text" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /modules/ViewProfile/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2023 Zenin Easa Panthakkalakath */ 2 | 3 | #mainContent #title { 4 | height: 2.5em; 5 | padding: 1em; 6 | display: flex; 7 | } 8 | #mainContent #viewProfile input, #mainContent #viewProfile textarea { 9 | font-size: 1.5em; 10 | width: 90%; 11 | } 12 | #mainContent #viewProfile .value img { 13 | max-width: 300px; 14 | max-height: 300px; 15 | } 16 | -------------------------------------------------------------------------------- /modules/Chat/template_mainContent.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
${this.loadEmoticonsHTML()}
5 |
6 | 7 |
8 |
9 | -------------------------------------------------------------------------------- /modules/Logo/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2023 Zenin Easa Panthakkalakath */ 2 | 3 | #logoWithText { 4 | margin: 2em; 5 | } 6 | #logoWithText .logo { 7 | width: 6em; 8 | height: 6em; 9 | display: inline-block; 10 | vertical-align: middle; 11 | } 12 | #logoWithText .text { 13 | font-size: 4em; 14 | display: inline-block; 15 | vertical-align: middle; 16 | font-weight: bold; 17 | } 18 | #logoWithText .version { 19 | font-size: 1.5em; 20 | display: inline-block; 21 | vertical-align: bottom; 22 | } 23 | -------------------------------------------------------------------------------- /modules/ImagePack/images/send.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Layer 1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /modules/I18n/template.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | ${this.getText('I18n.chooseLanguage')}: 4 | 16 |
17 | -------------------------------------------------------------------------------- /modules/EditProfile/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2023 Zenin Easa Panthakkalakath */ 2 | 3 | #mainContent #title { 4 | height: 2.5em; 5 | padding: 1em; 6 | display: flex; 7 | } 8 | #mainContent #editProfile input, #mainContent #editProfile textarea { 9 | font-size: 1.5em; 10 | width: 90%; 11 | } 12 | #mainContent #editButton { 13 | margin-top: 2em; 14 | margin-bottom: 0.5em; 15 | background: rgba(0, 180, 255, 0.8); 16 | } 17 | #mainContent #downloadProfile { 18 | margin-bottom: 0.5em; 19 | background: rgba(0, 180, 255, 0.8); 20 | } 21 | #mainContent #logout { 22 | background: rgba(255, 180, 0, 0.8); 23 | } 24 | -------------------------------------------------------------------------------- /modules/Login/template.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | ${Logo.getHTML()} 4 |
5 | 6 | 7 | 8 |
${i18n.getText('Login.or')}
9 | 10 |
11 |
12 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const google = require('eslint-config-google'); 4 | 5 | module.exports = [{ 6 | 'files': ['**/*.js'], 7 | 'plugins': { 8 | 'google': google 9 | }, 10 | 'languageOptions': { 11 | 'ecmaVersion': 2022, 12 | 'sourceType': 'module', 13 | }, 14 | 'rules': { 15 | 'max-len': ["error", { "code": 80 }], 16 | 'indent': ['error', 4], 17 | 'linebreak-style': 0, 18 | 'no-unused-vars': [ 19 | "error", 20 | { 21 | 'args': 'none', 22 | 'caughtErrors': 'none' 23 | } 24 | ] 25 | } 26 | }]; 27 | -------------------------------------------------------------------------------- /modules/Main/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | HexHoot 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /test/Copyright/tCopyright.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const utils = require('./../../../test/utils'); 4 | const Copyright = require('./../../../modules/Copyright'); 5 | 6 | QUnit.test('Check if Copyright module is available', function(assert) { 7 | assert.ok(typeof(Copyright) !== 'undefined'); 8 | }); 9 | 10 | QUnit.test('Check the text in the copyright message', async function(assert) { 11 | Copyright.render(); 12 | 13 | await utils.waitAndTryAgain(async function(assert){ 14 | const copyrights = document.querySelectorAll('#copyright'); 15 | assert.strictEqual(copyrights.length, 1, 16 | 'There should be exactly one DIV with the id "copyright".'); 17 | 18 | assert.ok(copyrights[0].innerText.includes('Copyright')); 19 | }, assert); 20 | }); 21 | -------------------------------------------------------------------------------- /.github/workflows/node.js.windows.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI for Windows 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [windows-latest] 18 | node-version: [20.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm install 27 | - run: npm test 28 | - run: make lint 29 | -------------------------------------------------------------------------------- /modules/AddFriend/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2023 Zenin Easa Panthakkalakath */ 2 | 3 | #mainContent #title { 4 | height: 2.5em; 5 | padding: 1em; 6 | display: flex; 7 | } 8 | #mainContent #tabs { 9 | height: calc(100% - 2em - 5em); 10 | overflow-y: scroll; 11 | display: flex; 12 | flex-direction: column-reverse; 13 | } 14 | #mainContent .tabContainer { 15 | width: 100%; 16 | display: flex; 17 | } 18 | #mainContent .tab { 19 | width: 100%; 20 | padding: 0.5em; 21 | text-transform: uppercase; 22 | } 23 | #mainContent .tab:hover { 24 | background:rgba(255, 255, 255, 0.1); 25 | } 26 | #mainContent .tabContentContainer { 27 | width: 100%; 28 | height: 100%; 29 | } 30 | #mainContent .tabContent { 31 | background:rgba(255, 255, 255, 0.1); 32 | width: 100%; 33 | height: 100%; 34 | display: none; 35 | justify-content: center; 36 | align-items: center; 37 | flex-direction: column; 38 | } 39 | -------------------------------------------------------------------------------- /modules/Social/template.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | -------------------------------------------------------------------------------- /modules/ViewProfile/template_mainContent.html: -------------------------------------------------------------------------------- 1 | 2 |
${i18n.getText('ViewProfile.viewProfile')}
3 |
4 |
${i18n.getText('ViewProfile.publicKey')}
5 | 6 | 7 |
${i18n.getText('ViewProfile.displayName')}
8 | 9 | 10 |
${i18n.getText('ViewProfile.aboutPerson')}
11 | 12 | 13 |
${i18n.getText('ViewProfile.photo')}
14 |
15 |
16 | -------------------------------------------------------------------------------- /modules/Copyright/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const requireText = require('require-text'); 4 | 5 | /** 6 | * This class adds copyright message to the UI. This is one of the simplest 7 | * modules that we have, and hence, could be treated as a "Hello World!" 8 | * module. 9 | */ 10 | class Copyright { 11 | /** 12 | * This function renders the template into the UI. 13 | */ 14 | static render() { 15 | // Link css 16 | const link = document.createElement('link'); 17 | link.rel = 'stylesheet'; 18 | link.href = __dirname + '/style.css'; 19 | document.body.appendChild(link); 20 | 21 | // Ensure that the CSS is loaded before the HTML is 22 | link.addEventListener('load', function() { 23 | const elem = document.createElement('div'); 24 | elem.innerHTML = requireText('./template.html', require); 25 | document.body.appendChild(elem); 26 | }); 27 | } 28 | } 29 | 30 | module.exports = Copyright; 31 | -------------------------------------------------------------------------------- /test/Copyright/tCheckAllCodeFiles.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const path = require('path'); 4 | const glob = require('glob'); 5 | const requireText = require('require-text'); 6 | const regex = require('copyright-regex'); 7 | 8 | QUnit.test('Check code files for copyright message', async function(assert) { 9 | // All files to ignore in the following checks 10 | const options = { 11 | ignore: [ 12 | 'out/**/*', 13 | 'node_modules/**/*', 14 | 'package-lock.json', 15 | ], 16 | }; 17 | 18 | const files = await glob.glob('**/*.+(html|js|json)', options); 19 | assert.ok(files.length > 1); 20 | for (let i = 0; i < files.length; i++) { 21 | const fileContent = 22 | requireText(path.join('./../../../', files[i]), require); 23 | const matches = fileContent.match(regex()); 24 | assert.ok(matches[5].trim() == 'Zenin Easa Panthakkalakath', 25 | 'Copyright message not found in: ' + files[i], 26 | ); 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /modules/AddFriend/template_mainContent.html: -------------------------------------------------------------------------------- 1 | 2 |
Add friend
3 |
4 |
5 |
6 | 7 | 8 |
9 |
10 | 11 | 12 |
13 |
14 |
15 |
${i18n.getText('AddFriend.myCode')}
16 |
${i18n.getText('AddFriend.theirCode')}
17 |
18 |
19 | -------------------------------------------------------------------------------- /modules/Main/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const {app, BrowserWindow} = require('electron'); 4 | const path = require('path'); 5 | const imagePack = require('../ImagePack'); 6 | 7 | /** 8 | * Creates CEF window in which the app runs 9 | */ 10 | function createWindow() { 11 | const win = new BrowserWindow({ 12 | width: 800, 13 | height: 600, 14 | title: 'HexHoot', 15 | icon: imagePack.getPath('branding.favicon'), 16 | webPreferences: { 17 | preload: path.join(__dirname, 'preload.js'), 18 | nodeIntegration: true, 19 | contextIsolation: false, 20 | }, 21 | }); 22 | win.maximize(); 23 | win.loadFile('Modules/Main/index.html'); 24 | } 25 | 26 | app.whenReady().then(() => { 27 | createWindow(); 28 | 29 | app.on('activate', () => { 30 | if (BrowserWindow.getAllWindows().length === 0) { 31 | createWindow(); 32 | } 33 | }); 34 | }); 35 | 36 | app.on('window-all-closed', () => { 37 | if (process.platform !== 'darwin') { 38 | app.quit(); 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /.github/workflows/node.js.mac_linux.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI for macOS and Linux 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, macos-latest] 18 | node-version: [20.x, 22.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm install 27 | - run: | 28 | if [ "$RUNNER_OS" == "Linux" ]; then 29 | export DISPLAY=:99 30 | sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & # optional 31 | fi 32 | npm test 33 | - run: make lint 34 | -------------------------------------------------------------------------------- /modules/Main/preload.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const i18n = require('../I18n')(); 4 | const Social = require('../Social'); 5 | const Copyright = require('../Copyright'); 6 | const Login = require('../Login'); 7 | const Chat = require('../Chat'); 8 | 9 | window.addEventListener('DOMContentLoaded', function() { 10 | /** 11 | * The reload function; this was found to be not thread safe. For instance 12 | * if you run the following function multiple times, the rendering was 13 | * observed to be invoked multiple times parallely, resulting in unintended 14 | * behaviour. Therefore, a locking mechanism has been implemented here. 15 | */ 16 | async function reload() { 17 | i18n.render(); 18 | const login = new Login(); 19 | if (await login.isLoggedIn()) { 20 | const chat = new Chat(); 21 | chat.render(); 22 | } else { 23 | login.render(); 24 | } 25 | }; 26 | let queue = Promise.resolve(); 27 | window.reload = function() { 28 | const result = queue.then(function() { 29 | return reload(); 30 | }); 31 | queue = result.then(function() {}, function() {}); 32 | }; 33 | window.reload(); 34 | 35 | Social.render(); 36 | Copyright.render(); 37 | }); 38 | -------------------------------------------------------------------------------- /modules/EditProfile/template_mainContent.html: -------------------------------------------------------------------------------- 1 | 2 |
${i18n.getText('EditProfile.editProfile')}
3 |
4 |
${i18n.getText('EditProfile.privateKey')}   ${i18n.getText('EditProfile.warningNotToShare')}
5 | 6 | 7 |
${i18n.getText('EditProfile.displayName')}
8 | 9 | 10 |
${i18n.getText('EditProfile.aboutYou')}
11 | 12 | 13 |
${i18n.getText('EditProfile.photo')}
14 | 15 | 16 | 17 | 18 | 19 |
20 | -------------------------------------------------------------------------------- /forge.config.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2023-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const imagePack = require('./modules/ImagePack'); 4 | 5 | module.exports = { 6 | packagerConfig: {}, 7 | rebuildConfig: {}, 8 | makers: [ 9 | { 10 | // For windows: 11 | name: '@electron-forge/maker-squirrel', 12 | config: { 13 | // eslint-disable-next-line max-len 14 | iconUrl: 'https://raw.githubusercontent.com/zenineasa/HexHoot/main/modules/ImagePack/images/DesktopIcons/icon_windows.ico', 15 | setupIcon: imagePack.getPath('desktop.windows'), 16 | }, 17 | }, 18 | { 19 | // For mac: 20 | name: '@electron-forge/maker-dmg', 21 | config: { 22 | icon: imagePack.getPath('desktop.mac'), 23 | }, 24 | }, 25 | { 26 | // For debian: 27 | name: '@electron-forge/maker-deb', 28 | config: { 29 | options: { 30 | icon: imagePack.getPath('desktop.linux'), 31 | }, 32 | }, 33 | }, 34 | { 35 | // For other linux: 36 | name: '@electron-forge/maker-rpm', 37 | config: { 38 | options: { 39 | icon: imagePack.getPath('desktop.linux'), 40 | }, 41 | }, 42 | }, 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /modules/Social/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const requireText = require('require-text'); 4 | const shell = require('electron').shell; 5 | 6 | // The following is used by the template 7 | // eslint-disable-next-line no-unused-vars 8 | const imagePack = require('../ImagePack'); 9 | 10 | /** 11 | * This class helps in rendering the icons that link to our social media pages 12 | */ 13 | class Social { 14 | /** 15 | * This function returns a DOM element containing the logo 16 | */ 17 | static render() { 18 | // Link css 19 | const link = document.createElement('link'); 20 | link.rel = 'stylesheet'; 21 | link.href = __dirname + '/style.css'; 22 | document.body.appendChild(link); 23 | 24 | // Ensure that the CSS is loaded before the HTML is 25 | link.addEventListener('load', function() { 26 | const elem = document.createElement('div'); 27 | elem.innerHTML = 28 | eval('`' + requireText('./template.html', require) + '`'); 29 | document.body.appendChild(elem); 30 | 31 | const icons = elem.getElementsByTagName('img'); 32 | for (let i = 0; i < icons.length; i++) { 33 | icons[i].onclick = function() { 34 | shell.openExternal(this.getAttribute('href')); 35 | }; 36 | } 37 | }); 38 | } 39 | } 40 | 41 | module.exports = Social; 42 | -------------------------------------------------------------------------------- /test/Social/tSocial.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const utils = require('./../../../test/utils'); 4 | const Social = require('./../../../modules/Social'); 5 | 6 | QUnit.test('Check if Social module is available', function(assert) { 7 | assert.ok(typeof(Social) !== 'undefined'); 8 | }); 9 | 10 | QUnit.test('Check the icons and links', async function(assert) { 11 | Social.render(); 12 | 13 | await utils.waitAndTryAgain(async function(assert){ 14 | const socials = document.querySelectorAll('#social'); 15 | assert.strictEqual(socials.length, 1, 16 | 'There should be exactly one DIV with the id "social".'); 17 | 18 | const images = socials[0].querySelectorAll('img'); 19 | assert.ok(images.length > 1, 20 | 'There has to be at least one social button.'); 21 | 22 | // Confirm that the image files and the pages to which they are linked 23 | // to match 24 | images.forEach(function(image) { 25 | const filename = utils.getFileNameFromPath(image.src) 26 | .split('.png')[0].toLowerCase(); 27 | if (filename === 'blogger') { // Blogger is the only exception 28 | assert.equal(image.getAttribute('href'), 29 | 'https://blog.hexhoot.com'); 30 | } else { 31 | assert.ok(image.getAttribute('href').includes(filename)); 32 | } 33 | }); 34 | }, assert); 35 | }); 36 | -------------------------------------------------------------------------------- /test/Chat/tChat.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const utils = require('./../../../test/utils'); 4 | const Chat = require('./../../../modules/Chat'); 5 | 6 | const chat = new Chat(); 7 | 8 | QUnit.test('Check if Chat module is available', function(assert) { 9 | assert.ok(typeof(Chat) !== 'undefined'); 10 | }); 11 | 12 | QUnit.test('Render login and check the children', async function(assert) { 13 | await utils.setFixtureWithContainerDOMElemenent(); 14 | 15 | await chat.render(); 16 | 17 | await utils.waitAndTryAgain(async function(assert){ 18 | // Check the elements within 19 | const messageSenderInfos = document.querySelectorAll( 20 | '#messageSenderInfo'); 21 | assert.strictEqual(messageSenderInfos.length, 1, 22 | 'There should be exactly one DIV with id "messageSenderInfo".'); 23 | 24 | const messageReaders = document.querySelectorAll('#messageReader'); 25 | assert.strictEqual(messageReaders.length, 1, 26 | 'There should be exactly one DIV with id "messageReader".'); 27 | 28 | const messageComposers = document.querySelectorAll('#messageComposer'); 29 | assert.strictEqual(messageComposers.length, 1, 30 | 'There should be exactly one DIV with id "messageComposers".'); 31 | assert.strictEqual( 32 | messageComposers[0].getElementsByTagName('textarea').length, 1, 33 | 'There should be a textarea to type in the message.'); 34 | assert.strictEqual( 35 | messageComposers[0].getElementsByClassName('send').length, 1, 36 | 'There should be a send button.'); 37 | }, assert); 38 | }); 39 | -------------------------------------------------------------------------------- /modules/Logo/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const requireText = require('require-text'); 4 | const imagePack = require('../ImagePack'); 5 | // eslint-disable-next-line no-unused-vars 6 | const i18n = require('./../I18n')(); // used in template 7 | 8 | /** 9 | * This class implements the module to get the logo as a DOM element or as an 10 | * HTML string 11 | */ 12 | class Logo { 13 | /** 14 | * This function returns a DOM element containing the logo 15 | * @param {number} zoomVal scale the logo; value ranges between 0 and 1 16 | * @return {string} The DOM element which contains the logo 17 | */ 18 | static getDOMElement(zoomVal = 1) { 19 | // Link css 20 | const link = document.createElement('link'); 21 | link.rel = 'stylesheet'; 22 | link.href = __dirname + '/style.css'; 23 | document.body.appendChild(link); 24 | 25 | const elem = document.createElement('div'); 26 | elem.innerHTML = eval('`' + 27 | requireText('./template.html', require) + '`'); 28 | elem.getElementsByClassName('logo')[0].innerHTML = 29 | requireText(imagePack.getPath('branding.logoIcon'), require); 30 | 31 | elem.children[0].style.zoom = zoomVal.toString(); 32 | return elem; 33 | } 34 | 35 | /** 36 | * This function returns an HTML string containing the logo 37 | * @param {number} zoomVal scale the logo; value ranges between 0 and 1 38 | * @return {string} The HTML string which contains the logo 39 | */ 40 | static getHTML(zoomVal = 1) { 41 | return Logo.getDOMElement(zoomVal).innerHTML; 42 | } 43 | } 44 | 45 | module.exports = Logo; 46 | -------------------------------------------------------------------------------- /test/I18n/tJSONProperties.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2023-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const I18n = require('./../../../modules/I18n'); 4 | 5 | QUnit.test('Check if I18n module is available', function(assert) { 6 | assert.ok(typeof(I18n) !== 'undefined'); 7 | }); 8 | 9 | QUnit.test('Message for all languages', function(assert) { 10 | const i18n = new I18n(); 11 | 12 | // These variables shall be filled by the low-level object 13 | let numLanguages = -1; 14 | let languageNames = []; 15 | 16 | const topLevelKeys = Object.keys(i18n.texts); 17 | for (topLevelKey of topLevelKeys) { 18 | if (topLevelKey !== 'coprightMessage') { 19 | const midLevelKeys = Object.keys(i18n.texts[topLevelKey]); 20 | for (midLevelKey of midLevelKeys) { 21 | lowLevelKeys = Object.keys( 22 | i18n.texts[topLevelKey][midLevelKey]); 23 | 24 | if (numLanguages == -1) { 25 | numLanguages = lowLevelKeys.length; 26 | languageNames = lowLevelKeys; 27 | } else { 28 | let errorMessage = 'For \'' + midLevelKey + 29 | '\', expected ' + numLanguages + 30 | ' languages; found ' + lowLevelKeys.length + 31 | '.'; 32 | assert.ok( 33 | numLanguages === lowLevelKeys.length, 34 | errorMessage, 35 | ); 36 | 37 | errorMessage = 'For \'' + midLevelKey + 38 | ', found inconsistent language identifiers.'; 39 | assert.deepEqual( 40 | languageNames, lowLevelKeys, 41 | errorMessage, 42 | ); 43 | } 44 | } 45 | } 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2024 Zenin Easa Panthakkalakath", 3 | "name": "hexhoot", 4 | "version": "1.0.5", 5 | "description": "HexHoot: An Opensource Peer-to-peer Social Network with Zero-Knowledge-Proof based authentication.", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/zenineasa/hexhoot.git" 9 | }, 10 | "main": "modules/Main/index.js", 11 | "scripts": { 12 | "start": "electron-forge start", 13 | "test": "karma start ./test/karma.conf.js", 14 | "package": "electron-forge package", 15 | "make": "electron-forge make" 16 | }, 17 | "author": "Zenin Easa Panthakkalakath (@zenineasa)", 18 | "license": "AGPLV3", 19 | "devDependencies": { 20 | "@electron-forge/cli": "^7.4.0", 21 | "@electron-forge/maker-deb": "^7.4.0", 22 | "@electron-forge/maker-dmg": "^7.4.0", 23 | "@electron-forge/maker-rpm": "^7.4.0", 24 | "@electron-forge/maker-squirrel": "^7.4.0", 25 | "@electron-forge/maker-zip": "^7.4.0", 26 | "copyright-header": "^0.4.6", 27 | "electron": "^30.0.1", 28 | "eslint": "^9.1.1", 29 | "eslint-config-google": "^0.14.0", 30 | "glob": "^10.3.12", 31 | "karma-electron": "^7.3.0", 32 | "karma-qunit": "^4.2.0", 33 | "qunit": "^2.20.1" 34 | }, 35 | "dependencies": { 36 | "arptable-js": "^0.0.2", 37 | "copyright-regex": "^1.1.6", 38 | "ecdh": "^0.2.0", 39 | "electron-squirrel-startup": "^1.0.0", 40 | "hyperswarm": "^4.7.14", 41 | "indexeddb-export-import": "^2.1.5", 42 | "ip": "^2.0.1", 43 | "ipv4-range": "^0.0.0", 44 | "ping": "^0.4.4", 45 | "require-text": "^0.0.1" 46 | }, 47 | "optionalDependencies": { 48 | "appdmg": "^0.6.6" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /modules/Main/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2023 Zenin Easa Panthakkalakath */ 2 | 3 | textarea, input { 4 | font-family: inherit; 5 | color: inherit; 6 | } 7 | body { 8 | font-family: 'Noto Sans JP', sans-serif; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | background: #2b2e2f; 13 | color: #fff; 14 | width: 100%; 15 | height: 100%; 16 | user-select: none; 17 | margin: 0; 18 | } 19 | #container { 20 | display: flex; 21 | justify-content: center; 22 | align-items: center; 23 | width: 80%; 24 | height: 80%; 25 | text-align: center; 26 | } 27 | 28 | /** For scrollbar **/ 29 | 30 | ::-webkit-scrollbar { 31 | width: 0.5em; 32 | height: 0.5em; 33 | } 34 | ::-webkit-scrollbar-track { 35 | border-radius: 10px; 36 | } 37 | ::-webkit-scrollbar-thumb { 38 | background: rgba(0, 180, 255, 0.5); 39 | border-radius: 0.5em; 40 | } 41 | ::-webkit-scrollbar-thumb:hover { 42 | background: rgba(0, 180, 255, 0.8); 43 | } 44 | 45 | /** For Button **/ 46 | 47 | .HexHoot_Button { 48 | background: rgba(0, 180, 255, 0.8); 49 | padding: 0.5em 1em; 50 | width: 90%; 51 | border-radius: 0.2em; 52 | color: inherit; 53 | border: none; 54 | font: inherit; 55 | margin: 1em; 56 | } 57 | .HexHoot_Button:hover { 58 | opacity: 0.5; 59 | } 60 | 61 | /** For Input **/ 62 | 63 | .HexHoot_Input { 64 | border: 0; 65 | border-bottom: 1px solid #ccc; 66 | background: none; 67 | border-radius: 0; 68 | width: calc(100% - 1em); 69 | padding: 0.5em; 70 | margin: 0.5em auto; 71 | display: block; 72 | color: inherit; 73 | text-overflow: ellipsis; 74 | } 75 | .HexHoot_Input_label { 76 | font-size: 0.75em; 77 | text-align: left; 78 | padding-left: 2em; 79 | padding-top: 2em; 80 | } 81 | .HexHoot_Input_label span { 82 | color: #ff0000; 83 | } 84 | -------------------------------------------------------------------------------- /modules/ImagePack/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | /** 4 | * This file contains maps to all the images that are used in the UI and helper 5 | * functions that enable accessing them. 6 | */ 7 | const path = require('path'); 8 | 9 | imagePack = { 10 | 'branding': { 11 | 'logoIcon': 'icon.svg', 12 | 'favicon': 'favicon.png', 13 | }, 14 | 'desktop': { 15 | 'windows': 'DesktopIcons/icon_windows.ico', 16 | 'mac': 'DesktopIcons/icon_mac.icns', 17 | 'linux': 'DesktopIcons/icon_linux.png', 18 | }, 19 | 'interface': { 20 | 'sendButton': 'send.svg', 21 | 'defaultProfilePic': 'default_profile.svg', 22 | }, 23 | 'social': { 24 | 'facebook': 'Social/Facebook.png', 25 | 'github': 'Social/GitHub.png', 26 | 'instagram': 'Social/Instagram.png', 27 | 'linkedin': 'Social/LinkedIn.png', 28 | 'reddit': 'Social/Reddit.png', 29 | 'twitter': 'Social/Twitter.png', 30 | 'youtube': 'Social/YouTube.png', 31 | 'blogger': 'Social/Blogger.png', 32 | }, 33 | }; 34 | 35 | /** 36 | * A function that returns the full path of the requested image. 37 | * @param {String} hierarchy hierarchy to the image 38 | * @return {string} image path 39 | */ 40 | imagePack.getPath = function(hierarchy) { 41 | hierarchy = hierarchy.split('.'); // to hierarchy array 42 | 43 | let value = imagePack[hierarchy[0]]; 44 | for (let i = 1; i < hierarchy.length; i++) { 45 | value = value[hierarchy[i]]; 46 | } 47 | 48 | if (typeof(value) !== 'string') { 49 | console.log(new Error('Hierarchy does not exist')); 50 | } 51 | 52 | // Note: Even on windows, since we are loading this on chromium embedded 53 | // framework, we need to use a '/' and not '\'. 54 | return path.resolve(__dirname + '/images/' + value).replaceAll('\\', '/'); 55 | }; 56 | 57 | module.exports = imagePack; 58 | -------------------------------------------------------------------------------- /modules/Layout/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2023 Zenin Easa Panthakkalakath */ 2 | 3 | #iconbar { 4 | border-top-left-radius: 0.2em; 5 | border-bottom-left-radius: 0.2em; 6 | background: rgba(0, 180, 255, 0.8); 7 | width: 4em; 8 | height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | } 12 | #iconbar .icon { 13 | height: 4em; 14 | margin-bottom: 0.5em; 15 | background: #E6E7ED; 16 | border-radius: 50%; 17 | background-position: center; 18 | background-size: cover; 19 | background-repeat: no-repeat; 20 | } 21 | 22 | #sidebar { 23 | border-right: #666 1px solid; 24 | background: rgba(0,0,0,0.8); 25 | height: 100%; 26 | width: calc(30% - 2em); 27 | min-width: 10em; 28 | overflow-y: scroll; 29 | } 30 | #sidebar ul { 31 | list-style: none; 32 | margin: 0; 33 | padding: 0; 34 | background: rgba(255, 255, 255, 0.1); 35 | } 36 | #sidebar ul .notification { 37 | animation: notify 1s linear infinite; 38 | } 39 | @keyframes notify { 40 | 50% { background: rgba(0, 180, 255, 0.5); } 41 | } 42 | #sidebar li { 43 | display: flex; 44 | padding: 0.5em; 45 | border-bottom: #aaa 1px solid; 46 | } 47 | #sidebar li:hover { 48 | background: rgba(0, 180, 255, 0.8); 49 | } 50 | #sidebar li .photo { 51 | margin-left: 20px; 52 | display: block; 53 | width: 45px; 54 | height: 45px; 55 | background: #E6E7ED; 56 | border-radius: 50%; 57 | background-position: center; 58 | background-size: cover; 59 | background-repeat: no-repeat; 60 | } 61 | #sidebar li .name { 62 | padding: 0.5em; 63 | white-space: nowrap; 64 | overflow: hidden; 65 | text-overflow: ellipsis; 66 | } 67 | 68 | #mainContent { 69 | width: calc(70% - 2em); 70 | min-width: 10em; 71 | display: flex; 72 | flex-direction: column; 73 | border-top-right-radius: 0.2em; 74 | border-bottom-right-radius: 0.2em; 75 | background: rgba(0,0,0,0.8); 76 | height: 100%; 77 | } 78 | -------------------------------------------------------------------------------- /test/Layout/tLayout.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const utils = require('./../../../test/utils'); 4 | const Layout = require('./../../../modules/Layout'); 5 | 6 | QUnit.test('Check if Layout module is available', function(assert) { 7 | assert.ok(typeof(Layout) !== 'undefined'); 8 | }); 9 | 10 | QUnit.test('Render the layout', async function(assert) { 11 | await utils.setFixtureWithContainerDOMElemenent(); 12 | Layout.render(); 13 | 14 | await utils.waitAndTryAgain(async function(assert){ 15 | // Check if iconbar, sidebar and mainContent are available 16 | const iconbars = document.querySelectorAll('#iconbar'); 17 | assert.strictEqual(iconbars.length, 1, 18 | 'There should be exactly one DIV with the id "iconbar".'); 19 | 20 | const sidebars = document.querySelectorAll('#sidebar'); 21 | assert.strictEqual(sidebars.length, 1, 22 | 'There should be exactly one DIV with the id "sidebar".'); 23 | 24 | const mainContents = document.querySelectorAll('#mainContent'); 25 | assert.strictEqual(mainContents.length, 1, 26 | 'There should be exactly one DIV with the id "mainContent".'); 27 | }, assert); 28 | 29 | }); 30 | 31 | QUnit.test('Check if the logo is in the sidebar', async function(assert) { 32 | await utils.setFixtureWithContainerDOMElemenent(); 33 | Layout.render(); 34 | 35 | await utils.waitAndTryAgain(async function(assert){ 36 | const sidebars = document.querySelectorAll('#sidebar'); 37 | const logos = sidebars[0].querySelectorAll('#logoWithText'); 38 | assert.strictEqual(logos.length, 1, 39 | 'There should be exactly one DIV with the id "logoWithText".'); 40 | assert.strictEqual(logos[0].querySelectorAll('svg').length, 1, 41 | 'There should be an SVG icon in the logo.'); 42 | assert.ok(logos[0].innerText.includes('HexHoot'), 43 | 'The logo should have "HexHoot" text on it.'); 44 | }, assert); 45 | }); 46 | -------------------------------------------------------------------------------- /modules/Sounds/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | /** 4 | * This class implements the different sounds or tones used in the UI. 5 | */ 6 | class Sounds { 7 | /** This is the constructor (note the singleton implementation) */ 8 | constructor() { 9 | if (Sounds._instance) { 10 | return Sounds._instance; 11 | } 12 | Sounds._instance = this; 13 | Sounds._instance.initialize(); 14 | } 15 | 16 | /** 17 | * Initialize Sounds 18 | */ 19 | initialize() { 20 | const AudioContext = window.AudioContext || window.webkitAudioContext; 21 | this.audioContext = new AudioContext(); 22 | } 23 | 24 | /** 25 | * Play from an array of notes. 26 | * @param {Array} notes all the notes involed in making this tune 27 | * @param {Array} duration total amount of time for which this tune plays 28 | */ 29 | playFromArray(notes, duration) { 30 | const dt = duration / notes.length; 31 | const startTime = this.audioContext.currentTime; 32 | for (let i = 0; i < notes.length; i++) { 33 | const oscillator = this.audioContext.createOscillator(); 34 | oscillator.connect(this.audioContext.destination); 35 | oscillator.frequency.setValueAtTime( 36 | this.noteToFrequency(notes[i]), 37 | startTime + i * dt 38 | ); 39 | oscillator.start(startTime + i * dt); 40 | oscillator.stop(startTime + (i + 1) * dt); 41 | } 42 | } 43 | 44 | /** 45 | * Mapping frequency to notes 46 | */ 47 | noteToFrequency(note) { 48 | const notesMap = { 49 | 'C4': 261.63, 50 | 'D4': 293.66, 51 | 'E4': 329.63, 52 | 'F4': 349.23, 53 | 'G4': 392.00, 54 | 'A4': 440.00, 55 | 'B4': 493.88 56 | }; 57 | return notesMap[note] || 0; 58 | } 59 | 60 | /** 61 | * Sound to be played when you receive a message 62 | */ 63 | messageReceivedSound() { 64 | this.playFromArray(['G4', 'A4', 'G4', 'A4'], 0.5); 65 | } 66 | } 67 | 68 | module.exports = function() { 69 | return new Sounds(); 70 | }; 71 | -------------------------------------------------------------------------------- /modules/Layout/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const requireText = require('require-text'); 4 | 5 | // This is invoked from within template.html file 6 | const Logo = require('./../Logo'); // eslint-disable-line no-unused-vars 7 | 8 | /** 9 | * This class implements a skeleton UI that is used by most modules 10 | */ 11 | class Layout { 12 | /** 13 | * This function renders the template into the UI. 14 | */ 15 | static async render() { 16 | // Link css 17 | const link = document.createElement('link'); 18 | link.rel = 'stylesheet'; 19 | link.href = __dirname + '/style.css'; 20 | 21 | await new Promise(function(resolve, _reject) { 22 | document.body.appendChild(link); 23 | 24 | // Ensure that the CSS is loaded before the HTML is 25 | const linkOnLoadCallback = function() { 26 | // This should be invoked only once 27 | link.removeEventListener('load', linkOnLoadCallback); 28 | 29 | const container = document.getElementById('container'); 30 | 31 | // To ensure that innerHTML has changed before resolving 32 | const observer = new MutationObserver( 33 | function(_mutationsList, _observer) { 34 | if (container.hasChildNodes()) { 35 | resolve(); 36 | } 37 | } 38 | ); 39 | observer.observe(container, { 40 | characterData: false, childList: true, attributes: false 41 | }); 42 | 43 | // Load the HTML template and insert it to the UI 44 | container.innerHTML = Layout.loadTemplate('./template.html'); 45 | }; 46 | link.addEventListener('load', linkOnLoadCallback); 47 | }); 48 | } 49 | 50 | /** 51 | * Load template.html file and use template literals to assign all the 52 | * variable values 53 | * @param {string} filename name of the HTML file 54 | * @return {string} template string 55 | */ 56 | static loadTemplate(filename) { 57 | // Use Javascript's Template literals (Template strings) for easily 58 | // evaluating variables in the template.html file 59 | return eval('`' + requireText(filename, require) + '`'); 60 | } 61 | } 62 | 63 | module.exports = Layout; 64 | -------------------------------------------------------------------------------- /modules/ImagePack/images/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | image/svg+xml 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /modules/ImagePack/images/icon_lightmode.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | image/svg+xml 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /modules/ViewProfile/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const requireText = require('require-text'); 4 | const Layout = require('./../Layout'); 5 | const imagePack = require('../ImagePack'); 6 | // eslint-disable-next-line no-unused-vars 7 | const i18n = require('./../I18n')(); // used in template 8 | 9 | /** 10 | * This class implements the functionality for viewing profile information. 11 | */ 12 | class ViewProfile { 13 | /** This is the constructor (note the singleton implementation) */ 14 | constructor() { 15 | if (ViewProfile._instance) { 16 | return ViewProfile._instance; 17 | } 18 | ViewProfile._instance = this; 19 | } 20 | 21 | /** 22 | * This function renders the template into the UI. 23 | * @param {function} backCallback the callback function that is executed 24 | * when back button is clicked. 25 | * @param {function} userInfo Information about the user that is to be 26 | * displayed 27 | */ 28 | async render(backCallback, userInfo) { 29 | // Render the layout 30 | await Layout.render(); 31 | 32 | // Link css 33 | const link = document.createElement('link'); 34 | link.rel = 'stylesheet'; 35 | link.href = __dirname + '/style.css'; 36 | document.body.appendChild(link); 37 | 38 | // Ensure that the CSS is loaded before the HTML is 39 | link.addEventListener('load', function() { 40 | // Sidebar related 41 | const sidebarDOMNode = document.getElementById('sidebar'); 42 | sidebarDOMNode.innerHTML += eval('`' + 43 | requireText('./template_sidebar.html', require) + '`'); 44 | document.getElementById('backButton').onclick = backCallback; 45 | 46 | // Main content related 47 | const mainContentDOMNode = document.getElementById('mainContent'); 48 | mainContentDOMNode.innerHTML += eval('`' + 49 | requireText('./template_mainContent.html', require) + '`'); 50 | }); 51 | } 52 | 53 | /** 54 | * Get the profile pic 55 | * @param {string} photo either an empty string or a string that contains 56 | * the entire image (base64 encoded image, data URL) 57 | * @return {string} an image that can be used as CSS background URL 58 | */ 59 | getProfilePic(photo) { 60 | if (photo) { 61 | return photo; 62 | } else { 63 | return imagePack.getPath('interface.defaultProfilePic'); 64 | } 65 | } 66 | } 67 | 68 | module.exports = new ViewProfile(); 69 | -------------------------------------------------------------------------------- /modules/Chat/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | #mainContent #messageSenderInfo { 4 | height: 2.5em; 5 | padding: 1em; 6 | display: flex; 7 | background: rgba(255,255,255,0.1); 8 | } 9 | #mainContent #messageSenderInfo .photo { 10 | margin-left: 20px; 11 | display: block; 12 | width: 45px; 13 | height: 45px; 14 | background: #E6E7ED; 15 | border-radius: 50%; 16 | background-position: center; 17 | background-size: cover; 18 | background-repeat: no-repeat; 19 | } 20 | #mainContent #messageSenderInfo .name { 21 | padding: 0.5em; 22 | white-space: nowrap; 23 | overflow: hidden; 24 | text-overflow: ellipsis; 25 | } 26 | #mainContent #messageReader { 27 | height: calc(100% - 2em - 5em); 28 | overflow-y: scroll; 29 | display:flex; 30 | flex-direction:column-reverse; 31 | user-select: text; 32 | } 33 | #mainContent #messageReader .receivedMessage { 34 | background: rgba(0, 180, 255, 0.8); 35 | margin: 1em; 36 | padding: 0.5em; 37 | max-width: 80%; 38 | margin-left: 0.5em; 39 | margin-right: auto; 40 | } 41 | #mainContent #messageReader .sentMessage { 42 | position: relative; 43 | background-color: rgba(255,255,255,0.1); 44 | margin: 1em; 45 | padding: 0.5em; 46 | max-width: 80%; 47 | right: 0; 48 | margin-left: auto; 49 | margin-right: 0.5em; 50 | } 51 | #mainContent #messageReader span { 52 | display: block; 53 | font-size: 0.5em; 54 | width: 100%; 55 | text-align: right; 56 | opacity: 0.8; 57 | } 58 | 59 | #mainContent #messageEmojis { 60 | height: 2em; 61 | font-size: 2em; 62 | width: auto; 63 | overflow-x: scroll; 64 | overflow-y: hidden; 65 | white-space: nowrap; 66 | background: rgba(255,255,255,0.1); 67 | opacity: 0.5; 68 | display: none; /* Will be 'flex' when active */ 69 | } 70 | #mainContent #messageEmojis:hover { 71 | opacity: 1; 72 | } 73 | #mainContent #messageEmojis span { 74 | opacity: 0.8; 75 | } 76 | #mainContent #messageEmojis span:hover { 77 | opacity: 1; 78 | } 79 | 80 | #mainContent #messageComposer { 81 | height: 5em; 82 | background: rgba(255,255,255,0.1); 83 | display: none; /* Will be 'flex' when active */ 84 | } 85 | #mainContent #messageComposer .send { 86 | margin-left: 20px; 87 | display: block; 88 | width: 5em; 89 | height: 5em; 90 | background-position: center; 91 | background-size: 75%; 92 | background-repeat: no-repeat; 93 | } 94 | #mainContent #messageComposer .send:hover { 95 | background-color: #E6E7ED; 96 | } 97 | /* TODO: Update messageComposer; change it to div with content editable tag /* 98 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | ### Copyright (c) 2022-2024 Zenin Easa Panthakkalakath ### 2 | 3 | all: 4 | make clean 5 | make install 6 | make lint 7 | make build-mac-intel 8 | make build-mac-m1 9 | make build-windows 10 | make build-linux 11 | 12 | install: 13 | npm install 14 | 15 | clean: 16 | rm -rf node_modules bundle.js package-lock.json out 17 | 18 | lint: 19 | npx eslint --config eslint.config.js 20 | npx copyright-header --copyrightHolder "Zenin Easa Panthakkalakath" 21 | 22 | lintfix: 23 | npx eslint --config eslint.config.js --fix 24 | npx copyright-header --fix --copyrightHolder "Zenin Easa Panthakkalakath" --forceModificationYear 2024 25 | 26 | build-mac-intel: 27 | $(call BUILD_MAC_DMG,darwin,x64) 28 | 29 | build-mac-m1: 30 | $(call BUILD_MAC_DMG,darwin,arm64) 31 | 32 | build-windows: 33 | npx electron-forge make --platform=win32 --arch=x64 34 | 35 | build-linux: 36 | npx electron-forge make --platform=linux --arch=x64 37 | 38 | # To have HexHoot icon on the macOS APP-file and DMG-file 39 | MAC_ICON_PATH := modules/ImagePack/images/DesktopIcons/icon_mac.icns 40 | MAKE_PATH := out/make 41 | define BUILD_MAC_DMG 42 | $(eval VERSION := $(shell npm pkg get version --workspaces=false | tr -d \")) 43 | 44 | $(eval MAC_APP_PATH := out/hexhoot-$(1)-$(2)/hexhoot.app) 45 | $(eval MAC_APP_RENAMED_PATH := out/hexhoot-$(1)-$(2)/HexHoot-$(VERSION).app) 46 | $(eval MAC_DMG_PATH := $(MAKE_PATH)/HexHoot-$(VERSION)-$(2).dmg) 47 | 48 | $(eval APPDMG_JSON_PATH := appdmg.json) 49 | $(eval ICNS_PATH := out/icon_mac.icns) 50 | $(eval RSRC_PATH := out/icns.rsrc) 51 | 52 | npx electron-forge package --platform=$(1) --arch=$(2) 53 | 54 | cp $(MAC_ICON_PATH) $(MAC_APP_PATH)/Contents/Resources/electron.icns 55 | cp -rf $(MAC_APP_PATH) $(MAC_APP_RENAMED_PATH) 56 | rm -rf $(MAC_APP_PATH) 57 | touch $(MAC_APP_RENAMED_PATH) 58 | 59 | echo '{' > $(APPDMG_JSON_PATH) 60 | echo '"title": "HexHoot",' >> $(APPDMG_JSON_PATH) 61 | echo '"icon": "$(MAC_ICON_PATH)",' >> $(APPDMG_JSON_PATH) 62 | echo '"background-color": "#2b2e2f",' >> $(APPDMG_JSON_PATH) 63 | echo '"contents": [' >> $(APPDMG_JSON_PATH) 64 | echo '{ "x": 448, "y": 344, "type": "link", "path": "/Applications" },' >> $(APPDMG_JSON_PATH) 65 | echo '{ "x": 192, "y": 344, "type": "file", "path": "$(MAC_APP_RENAMED_PATH)" }' >> $(APPDMG_JSON_PATH) 66 | echo ']' >> $(APPDMG_JSON_PATH) 67 | echo '}' >> $(APPDMG_JSON_PATH) 68 | 69 | mkdir -p $(MAKE_PATH) 70 | rm -rf $(MAC_DMG_PATH) 71 | npx appdmg $(APPDMG_JSON_PATH) $(MAC_DMG_PATH) 72 | 73 | cp -rf $(MAC_ICON_PATH) $(ICNS_PATH) 74 | sips -i $(ICNS_PATH) 75 | DeRez -only icns $(ICNS_PATH) > $(RSRC_PATH) 76 | Rez -append $(RSRC_PATH) -o $(MAC_DMG_PATH) 77 | SetFile -a C $(MAC_DMG_PATH) 78 | 79 | rm $(ICNS_PATH) $(RSRC_PATH) $(APPDMG_JSON_PATH) 80 | endef 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HexHoot 2 |

3 | 4 | 5 | 6 | 7 | 8 | 9 |

10 | 11 | This is an attempt to create an Opensource Peer-to-peer communication platform with Zero-Knowledge-Proof based authentication. The objective is to democratize communication by eliminating any form of central servers. 12 | 13 | Currently, HexHoot's standalone desktop installers are available for Linux (Debian and RPM), macOS and Windows; users can chat with each other over the internet and the intranet. Download it from our release page, linked below. 14 | 15 | https://github.com/zenineasa/HexHoot/releases/latest 16 | 17 | ## Why HexHoot? 18 | 19 | The following question is something that people would ask me quite often when I talk to them about HexHoot. If you look around, you can see quite a lot of software, like WhatsApp, Slack, Microsoft Teams, Zoom, etc., that helps in communication. Why would one attempt to create yet another tool that solves the same problem? 20 | 21 | The thing is, we are not exactly trying to compete with the existing communication platforms head-on; rather, we are setting up a framework that would enable the concept of user authentication in applications that can run without any centralized servers. 22 | 23 | Much of the traditional softwares that enabled Peer-to-Peer communication relied on a centralized server to authenticate users, not because they wanted to do this, but because that was probably the only way at the time. We, on the other hand, have accomplished to solve this using Zero-knowledge-proof strategies. 24 | 25 | Internet is supposed to be free; free as in "libre" and not "gratis". There is a tendancy in the world that certain closed source algorithms are actively deciding what content the people must be exposed to. You can't view their source code, while they read each and everything about you. 26 | 27 | We would like to reverse that. HexHoot is an Open Source project that is aimed at creating a platform for communication between people, while all data is stored locally on the users' computers. 28 | 29 | 30 | ## Try the development version 31 | 32 | If you are someone who likes to run the most updated version that is still under development, you would not regret following the belowmentioned instructions. 33 | 34 | 1. Prerequisite: Download and install Node.js from the following link. 35 | https://nodejs.dev/en/download/ 36 | 2. Clone this repository to your computer. 37 | 3. Go to the cloned repository using your Terminal/CMD, and run the following commands to install the necessary packages. 38 | ``` 39 | make install 40 | ``` 41 | 4. Now, use the following command to start HexHoot Development version. 42 | ``` 43 | npm start 44 | ``` 45 | 46 | ## Read our blog 47 | 48 | Blog posts detailing the technical aspects and broader vision are published on our blog. 49 | https://blog.hexhoot.com/ 50 | 51 | ## 52 | 53 | Copyright © 2022-2024 Zenin Easa Panthakkalakath 54 | -------------------------------------------------------------------------------- /test/General/tPackage.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2023-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const path = require('path'); 4 | const requireText = require('require-text'); 5 | 6 | 7 | /** 8 | * Sometimes, the fetch doesn't happen properly on GitHub CI. This function may 9 | * help reduce sporadic failures caused by the same. 10 | * @param {string} url the address to fetch from 11 | * @param {number} retries the maximum number of times we retry 12 | * @return {Object} the response from fetch 13 | */ 14 | function fetchJSONWithRetry(url, retries = 10) { 15 | return fetch(url) 16 | .then(function(response) { 17 | if (response.ok) { 18 | return response; 19 | } 20 | throw new Error('Network response was not ok'); 21 | }) 22 | .catch(function(error) { 23 | if (retries <= 0) { 24 | throw error; 25 | } 26 | return fetchJSONWithRetry(url, retries - 1); 27 | }); 28 | } 29 | 30 | QUnit.test('Check package name', function(assert) { 31 | // Read package.json 32 | const packageJson = JSON.parse( 33 | requireText(path.resolve() + '/package.json', require), 34 | ); 35 | 36 | // Ensure that the package name in package.json is always 'hexhoot' 37 | assert.ok( 38 | packageJson.name === 'hexhoot', 39 | 'Package name should be "hexhoot"', 40 | ); 41 | }); 42 | 43 | QUnit.test('Version greater than in GitHub release', async function(assert) { 44 | // Read package.json 45 | const packageJson = JSON.parse( 46 | requireText(path.resolve() + '/package.json', require), 47 | ); 48 | 49 | // Get the GitHub API URL using information from package.json 50 | const githubRepo = packageJson.repository.url 51 | .split('git+https://github.com/')[1].split('.git')[0]; 52 | const apiURL = 'https://api.github.com/repos/' + githubRepo + 53 | '/releases/latest'; 54 | 55 | // Fetch inofmration using GitHub API 56 | let response = []; 57 | try { 58 | response = await fetchJSONWithRetry(apiURL); 59 | } catch (err) { 60 | console.warn('No response from GitHub API'); 61 | assert.ok(true); 62 | return; 63 | } 64 | 65 | const data = await response.json(); 66 | 67 | // The version names in package.json and latest GitHub release 68 | const packageVersion = packageJson.version.match(/\d+/g); 69 | const githubVersion = data.tag_name.match(/\d+/g); 70 | 71 | // Ensure that the number of numbers in both the version values are equal 72 | assert.ok(packageVersion.length == githubVersion.length); 73 | 74 | // Ensure that the version of the package is greater than the latest 75 | // release on GitHub. 76 | // NOTE: We are comparing with a release; not the repository. 77 | let allGoodFlag = false; 78 | for (let i = 0; i < packageVersion.length; i++) { 79 | if (packageVersion[i] > githubVersion[i]) { 80 | allGoodFlag = true; 81 | break; 82 | } else if (packageVersion[i] < githubVersion[i]) { 83 | break; 84 | } 85 | } 86 | assert.ok(allGoodFlag); 87 | }); 88 | -------------------------------------------------------------------------------- /modules/I18n/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const requireText = require('require-text'); 4 | const dbMessenger = require('./../DBMessenger')(); 5 | 6 | /** 7 | * This class helps in implementing support for multiple languages. 8 | */ 9 | class I18n { 10 | /** This is the constructor (note the singleton implementation) */ 11 | constructor() { 12 | if (I18n._instance) { 13 | return I18n._instance; 14 | } 15 | I18n._instance = this; 16 | I18n._instance.initialize(); 17 | } 18 | 19 | /** 20 | * Initialize I18n 21 | */ 22 | initialize() { 23 | this.texts = JSON.parse(requireText('./lang.json', require)); 24 | 25 | this.selectedLang = 'en'; // default language 26 | this.selectLanguageFromPreferences(); 27 | } 28 | 29 | /** 30 | * Chosen language in the preferences (database) 31 | */ 32 | async selectLanguageFromPreferences() { 33 | const langPref = await dbMessenger.getPreference('language'); 34 | if (langPref) { 35 | if (langPref.value !== '') { 36 | this.selectedLang = langPref.value; 37 | window.reload(); 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * This function renders the dropdown for choosing a language 44 | */ 45 | render() { 46 | // Link css 47 | const link = document.createElement('link'); 48 | link.rel = 'stylesheet'; 49 | link.href = __dirname + '/style.css'; 50 | document.body.appendChild(link); 51 | 52 | // Ensure that the CSS is loaded before the HTML is 53 | link.addEventListener('load', function() { 54 | const elem = document.createElement('div'); 55 | elem.innerHTML = 56 | eval('`' + requireText('./template.html', require) + '`'); 57 | 58 | // If div already exists, remove it. 59 | const checkElem = document.getElementById('i18n'); 60 | if (checkElem) { 61 | checkElem.remove(); 62 | } 63 | 64 | // Append the new element into the body 65 | document.body.appendChild(elem); 66 | 67 | // Language selection dropdown 68 | const languageSelector = document.getElementById('languages'); 69 | languageSelector.querySelector('[value=' + this.selectedLang + ']') 70 | .setAttribute('selected', ''); 71 | languageSelector.onchange = function() { 72 | this.languageSelectionCallback(languageSelector.value); 73 | }.bind(this); 74 | }.bind(this)); 75 | } 76 | 77 | /** 78 | * Language selection dropdown callback 79 | * @param {string} chosenLanguage the language chosen in the UI 80 | */ 81 | languageSelectionCallback(chosenLanguage) { 82 | this.selectedLang = chosenLanguage; 83 | dbMessenger.setPreference('language', chosenLanguage); 84 | window.reload(); 85 | } 86 | 87 | 88 | /** 89 | * Get text in selected language 90 | * @param {String} hierarchy path to the message in lang.json 91 | * @return {string} the requested text in the selected language 92 | */ 93 | getText(hierarchy) { 94 | hierarchy = hierarchy.split('.'); // to hierarchy array 95 | 96 | let value = this.texts; 97 | for (let i = 0; i < hierarchy.length; i++) { 98 | value = value[hierarchy[i]]; 99 | } 100 | 101 | return value[this.selectedLang]; 102 | } 103 | } 104 | 105 | module.exports = function() { 106 | return new I18n(); 107 | }; 108 | -------------------------------------------------------------------------------- /modules/Login/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const requireText = require('require-text'); 4 | const dbMessenger = require('./../DBMessenger')(); 5 | const i18n = require('./../I18n')(); 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | const Logo = require('./../Logo'); // used in template 9 | 10 | /** 11 | * This class implements the functionality to Login 12 | */ 13 | class Login { 14 | /** This is the constructor (note the singleton implementation) */ 15 | constructor() { 16 | if (Login._instance) { 17 | return Login._instance; 18 | } 19 | Login._instance = this; 20 | } 21 | 22 | /** 23 | * This function renders the template into the UI. 24 | */ 25 | render() { 26 | // Link css 27 | const link = document.createElement('link'); 28 | link.rel = 'stylesheet'; 29 | link.href = __dirname + '/style.css'; 30 | document.body.appendChild(link); 31 | 32 | // Ensure that the CSS is loaded before the HTML is 33 | link.addEventListener('load', function() { 34 | // Load the HTML template and insert it to the UI 35 | const containerDOM = document.getElementById('container'); 36 | containerDOM.innerHTML = eval('`' + 37 | requireText('./template.html', require) + '`'); 38 | 39 | // Login button 40 | document.getElementById('loginButton').onclick = function() { 41 | const info = {}; 42 | const inputs = containerDOM.querySelectorAll( 43 | 'input[type=text],input[type=password]'); 44 | for (let i = 0; i < inputs.length; i++) { 45 | info[inputs[i].name] = inputs[i].value; 46 | } 47 | this.validateLoginForm(info); 48 | this.doLogin(info); 49 | }.bind(this); 50 | 51 | // Login with JSON 52 | document.getElementById('loginWithJSONButton').onclick = 53 | function() { 54 | (async function() { 55 | await dbMessenger.uploadDBAsJSON(); 56 | window.reload(); 57 | })(); 58 | }; 59 | }.bind(this)); 60 | } 61 | 62 | /** 63 | * Check if the user is logged in. 64 | */ 65 | async isLoggedIn() { 66 | const userInfo = await dbMessenger.getLoggedInUserInfoPrivate(); 67 | if (userInfo) { 68 | return true; 69 | } 70 | return false; 71 | } 72 | 73 | /** 74 | * Validate the values entered in the login form. 75 | * @param {Object} info information extracted from the login form. 76 | */ 77 | validateLoginForm(info) { 78 | // Validate the password 79 | if (info.password.length <= 8) { 80 | const message = i18n.getText('Login.passwordLength'); 81 | alert(message); 82 | throw new Error(message); 83 | } 84 | 85 | // Validate the name 86 | info.displayName = info.displayName.trim(); 87 | if (info.displayName.length <= 4) { 88 | const message = i18n.getText('Login.minimumCharacters'); 89 | alert(message); 90 | throw new Error(message); 91 | } 92 | } 93 | 94 | /** 95 | * Log the user in! 96 | * @param {Object} info information extracted from the login form. 97 | */ 98 | async doLogin(info) { 99 | await dbMessenger.writeLoggedInUserInfo(info); 100 | window.reload(); 101 | } 102 | } 103 | 104 | module.exports = function() { 105 | return new Login(); 106 | }; 107 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | /** 4 | * This file contains the utility functions shared by different tests. 5 | */ 6 | 7 | const utils = []; 8 | 9 | /** 10 | * A function that makes sure that a DIV named 'container' is available for 11 | * the layout to render it's contents into. 12 | */ 13 | utils.setFixtureWithContainerDOMElemenent = async function() { 14 | const fixture = document.getElementById('qunit-fixture'); 15 | 16 | // Ensure that all children are removed 17 | while (fixture.firstChild) { 18 | fixture.removeChild(fixture.firstChild); 19 | } 20 | 21 | await new Promise(function(resolve, _reject) { 22 | // To ensure that innerHTML has changed before resolving 23 | const observer = new MutationObserver( 24 | function(_mutationsList, _observer) { 25 | resolve(); 26 | } 27 | ); 28 | observer.observe(fixture, { 29 | characterData: false, childList: true, attributes: false 30 | }); 31 | 32 | const div = document.createElement('div'); 33 | div.id = 'container'; 34 | fixture.innerHTML = div.outerHTML; 35 | }); 36 | }; 37 | 38 | /** 39 | * A function that returns filename from the path 40 | * @param {string} path the path of the file 41 | * @return {string} the filename 42 | */ 43 | utils.getFileNameFromPath = function(path) { 44 | return path.replace(/^.*[\\\/]/, ''); 45 | }; 46 | 47 | /** 48 | * A function that enables retrying a test if it failed the first time. 49 | * @param {function} callback a test with assertions 50 | * @param {function} assert the real assert function handle from QUnit 51 | * @param {Number} numTries the number of tries to attempt again 52 | * @param {Number} interval time interval to wait for before the next attempt 53 | */ 54 | utils.waitAndTryAgain = async function( 55 | callback, assert, numTries=3, interval=200 56 | ) { 57 | // NOTE: Implement other methods in assert as required 58 | var argAssert = { 59 | 'strictEqual': function(actual, expected, message="") { 60 | if (actual !== expected) { 61 | throw new Error('Assertion failed: ' + message); 62 | } 63 | assert.equal(actual, expected, message); 64 | }, 65 | 'notEqual': function(actual, expected, message="") { 66 | if (actual === expected) { 67 | throw new Error('Assertion failed: ' + message); 68 | } 69 | assert.notEqual(actual, expected, message); 70 | }, 71 | 'true': function(isTrue, message="") { 72 | if (!isTrue) { 73 | throw new Error('Assertion failed: ' + message); 74 | } 75 | assert.true(isTrue, message); 76 | }, 77 | 'ok': function(isOkay, message="") { 78 | if (!isOkay) { 79 | throw new Error('Assertion failed: ' + message); 80 | } 81 | assert.ok(isOkay, message); 82 | } // TODO: What is the difference between 'ok' and 'true'? 83 | }; 84 | 85 | if (numTries <= 0) { 86 | argAssert = assert; 87 | } 88 | 89 | try { 90 | await callback(argAssert); 91 | return; // Resolve the promise if the callback succeeds 92 | } catch (error) { 93 | if(numTries === 0) { 94 | assert.ok(false, error.message); // Fail the test 95 | throw error; // Reject the promise 96 | } else { 97 | // Wait for the interval 98 | await new Promise(resolve => setTimeout(resolve, interval)); 99 | // Retry the callback 100 | await utils.waitAndTryAgain( 101 | callback, assert, numTries - 1, interval); 102 | } 103 | } 104 | }; 105 | 106 | module.exports = utils; 107 | -------------------------------------------------------------------------------- /test/karma.conf.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | module.exports = function(config) { 4 | config.set({ 5 | // base path that will be used to resolve all patterns 6 | // (eg. files, exclude) 7 | basePath: '', 8 | 9 | // frameworks to use 10 | // available frameworks: 11 | // https://www.npmjs.com/search?q=keywords:karma-adapter 12 | frameworks: ['qunit'], 13 | 14 | // list of files / patterns to load in the browser 15 | files: [ 16 | { 17 | pattern: './**/t*.js', 18 | included: true, 19 | type: 'js', 20 | }, 21 | { 22 | pattern: './../modules/ImagePack/images/**/*.png', 23 | served: true, 24 | type: 'html', // To supress warning 25 | }, 26 | { 27 | pattern: './../modules/ImagePack/images/**/*.svg', 28 | served: true, 29 | type: 'html', // To supress warning 30 | }, 31 | { 32 | pattern: './../modules/**/*.css', 33 | served: true, 34 | type: 'css', 35 | }, 36 | ], 37 | 38 | // list of files / patterns to exclude 39 | exclude: [ 40 | ], 41 | 42 | // test results reporter to use 43 | // possible values: 'dots', 'progress' 44 | // available reporters: 45 | // https://www.npmjs.com/search?q=keywords:karma-reporter 46 | reporters: ['progress'], 47 | 48 | // web server port 49 | port: 9876, 50 | 51 | // enable / disable colors in the output (reporters and logs) 52 | colors: true, 53 | 54 | // level of logging 55 | // possible values: config.LOG_DISABLE, config.LOG_ERROR, 56 | // config.LOG_WARN, config.LOG_INFO, config.LOG_DEBUG 57 | logLevel: config.LOG_INFO, 58 | 59 | // Define our custom launcher for Node.js support 60 | customLaunchers: { 61 | CustomElectron: { 62 | base: 'Electron', 63 | browserWindowOptions: { 64 | // DEV: More preferentially, should link your own 65 | // 'webPreferences' from your Electron app instead 66 | webPreferences: { 67 | // Mechanism to expose 'require' 68 | nodeIntegration: true, 69 | contextIsolation: false, 70 | nativeWindowOpen: true, 71 | }, 72 | }, 73 | }, 74 | }, 75 | 76 | // start these browsers 77 | // available browser launchers: 78 | // https://www.npmjs.com/search?q=keywords:karma-launcher 79 | browsers: ['CustomElectron'], 80 | 81 | // preprocess matching files before serving them to the browser 82 | // available preprocessors: 83 | // https://www.npmjs.com/search?q=keywords:karma-preprocessor 84 | preprocessors: { 85 | './**/t*.js': ['electron'], 86 | }, 87 | 88 | // enable / disable watching file and executing tests whenever any file 89 | // changes 90 | autoWatch: false, 91 | 92 | // Continuous Integration mode 93 | // if true, Karma captures browsers, runs the tests and exits 94 | singleRun: true, 95 | 96 | // Concurrency level 97 | // how many browser instances should be started simultaneously 98 | concurrency: Infinity, 99 | 100 | // DEV: 'useIframe: false' is for launching a new window instead of 101 | // using an iframe. In Electron, iframes don't get 'nodeIntegration' 102 | // priveleges, but windows do. 103 | client: { 104 | useIframe: false, 105 | }, 106 | }); 107 | }; 108 | -------------------------------------------------------------------------------- /modules/AddFriend/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const requireText = require('require-text'); 4 | const Layout = require('./../Layout'); 5 | const dbMessenger = require('./../DBMessenger')(); 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | const i18n = require('./../I18n')(); // used in template 9 | 10 | /** 11 | * This class implements the functionality for adding a friend 12 | */ 13 | class AddFriend { 14 | /** This is the constructor (note the singleton implementation) */ 15 | constructor() { 16 | if (AddFriend._instance) { 17 | return AddFriend._instance; 18 | } 19 | AddFriend._instance = this; 20 | AddFriend._instance.initialize(); 21 | } 22 | 23 | /** 24 | * Initialize Add friend 25 | */ 26 | initialize() { 27 | // If there is something to do... 28 | this.numTimesInvoked = 0; 29 | } 30 | 31 | /** 32 | * This function renders the template into the UI. 33 | * @param {function} backCallback the callback function that is executed 34 | * when back button is clicked 35 | */ 36 | async render(backCallback) { 37 | // Render the layout 38 | await Layout.render(); 39 | 40 | // Get user info from database, which will be used by the template 41 | const publicKey = await dbMessenger.getPublicKeyOfLoggedInUser(); 42 | 43 | // Link css 44 | const link = document.createElement('link'); 45 | link.rel = 'stylesheet'; 46 | link.href = __dirname + '/style.css'; 47 | document.body.appendChild(link); 48 | 49 | // Ensure that the CSS is loaded before the HTML is 50 | const linkOnLoadCallback = function() { 51 | // This should be invoked only once 52 | link.removeEventListener('load', linkOnLoadCallback); 53 | 54 | this.numTimesInvoked += 1; 55 | console.log(this.numTimesInvoked); 56 | 57 | // Sidebar related 58 | const sidebarDOMNode = document.getElementById('sidebar'); 59 | sidebarDOMNode.innerHTML += eval('`' + 60 | requireText('./template_sidebar.html', require) + '`'); 61 | document.getElementById('backButton').onclick = backCallback; 62 | 63 | // Main content related 64 | const mainContentDOMNode = document.getElementById('mainContent'); 65 | mainContentDOMNode.innerHTML += eval('`' + 66 | requireText('./template_mainContent.html', require) + '`'); 67 | 68 | const tabDOMNodes = document.getElementById('mainContent') 69 | .getElementsByClassName('tab'); 70 | for (let i = 0; i < tabDOMNodes.length; i++) { 71 | tabDOMNodes[i].onclick = function() { 72 | const tabName = this.getAttribute('name'); 73 | const tabContents = document.getElementById('mainContent') 74 | .getElementsByClassName('tabContent'); 75 | for (let i = 0; i < tabContents.length; i++) { 76 | if (tabContents[i].id == tabName) { 77 | tabContents[i].style.display = 'flex'; 78 | } else { 79 | tabContents[i].style.display = 'none'; 80 | } 81 | } 82 | }; 83 | } 84 | 85 | // Button callback 86 | document.getElementById('copyToClipboardButton').onclick = 87 | function() { 88 | navigator.clipboard.writeText(publicKey); 89 | }; 90 | document.getElementById('addFriendButton').onclick = function() { 91 | const key = document.getElementById('theirPublicKey').value; 92 | const otherUserInfo = {key: key}; 93 | dbMessenger.sendRequestOrResponse( 94 | otherUserInfo, dbMessenger.messageType.friendRequest); 95 | dbMessenger.updateFriendInformation(otherUserInfo); 96 | backCallback(); 97 | }; 98 | }.bind(this); 99 | link.addEventListener('load', linkOnLoadCallback); 100 | } 101 | } 102 | 103 | module.exports = function() { 104 | return new AddFriend(); 105 | }; 106 | -------------------------------------------------------------------------------- /test/Login/tLogin.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const utils = require('./../../../test/utils'); 4 | const Login = require('./../../../modules/Login'); 5 | const dbMessenger = require('./../../../modules/DBMessenger')(); 6 | 7 | QUnit.test('Check if Login module is available', function(assert) { 8 | assert.ok(typeof(Login) !== 'undefined'); 9 | }); 10 | 11 | QUnit.test('Render login and check the forms', async function(assert) { 12 | await utils.setFixtureWithContainerDOMElemenent(); 13 | 14 | const login = new Login(); 15 | login.render(); 16 | 17 | await utils.waitAndTryAgain(async function(assert){ 18 | // Check if the login form is loaded 19 | const logins = document.querySelectorAll('#login'); 20 | assert.strictEqual(logins.length, 1, 21 | 'There should be exactly one DIV with the id "login".'); 22 | const inputs = document.querySelectorAll('input'); 23 | assert.strictEqual(inputs.length, 2, 24 | 'There should be exactly two input fields.'); 25 | const buttons = document.querySelectorAll('button'); 26 | assert.strictEqual(buttons.length, 2, 27 | 'There should be exactly two buttons.'); 28 | }, assert); 29 | }); 30 | 31 | QUnit.test('Check if the logo is rendered', async function(assert) { 32 | await utils.setFixtureWithContainerDOMElemenent(); 33 | 34 | const login = new Login(); 35 | login.render(); 36 | 37 | await utils.waitAndTryAgain(async function(assert){ 38 | const logos = document.querySelectorAll('#logoWithText'); 39 | assert.strictEqual(logos.length, 1, 40 | 'There should be exactly one DIV with the id "logoWithText".'); 41 | assert.strictEqual(logos[0].querySelectorAll('svg').length, 1, 42 | 'There should be an SVG icon in the logo.'); 43 | assert.ok(logos[0].innerText.includes('HexHoot'), 44 | 'The logo should have "HexHoot" text on it.'); 45 | }, assert); 46 | }); 47 | 48 | QUnit.test('Fill in the form and check', async function(assert) { 49 | await utils.setFixtureWithContainerDOMElemenent(); 50 | 51 | // Mocking window.reload() function; in the real implementation, preload 52 | // function defines this. 53 | window.reload = function() {}; 54 | 55 | // Ensure user is logged out 56 | await dbMessenger.deleteDatabaseContent(); 57 | 58 | const login = new Login(); 59 | login.render(); 60 | 61 | const testUserInfoValues = { 62 | 'displayName': 'ZatMan', 63 | 'password': 'ThisIsProbablyAGoodPassword', 64 | }; 65 | 66 | await utils.waitAndTryAgain(async function(assert){ 67 | const inputs = document.querySelectorAll( 68 | 'input[type=text],input[type=password]'); 69 | for (let i = 0; i < inputs.length; i++) { 70 | if (inputs[i].name === 'password') { 71 | inputs[i].value = testUserInfoValues.password; 72 | } else if (inputs[i].name === 'displayName') { 73 | inputs[i].value = testUserInfoValues.displayName; 74 | } else { 75 | assert.notOk(true, 'Form input name not defined'); 76 | } 77 | } 78 | document.getElementById('loginButton').click(); 79 | 80 | // Verify the data available in dbMessenger with the value provided in 81 | // the form; might need to wait for a short while for the information 82 | // to load 83 | let userInfoPrivate; 84 | for (let i = 0; i < 10 && !userInfoPrivate; i++) { 85 | userInfoPrivate = await dbMessenger.getLoggedInUserInfoPrivate(); 86 | if (!userInfoPrivate) { 87 | await new Promise(resolve => setTimeout(resolve, 200)); 88 | } 89 | } 90 | 91 | assert.strictEqual(testUserInfoValues.displayName, 92 | userInfoPrivate.displayName, 93 | 'Display names need to match.'); 94 | 95 | const hexRegex = /^[0-9a-fA-F]+$/; 96 | assert.ok( 97 | hexRegex.test(userInfoPrivate.privateKey), 98 | 'Private key is expected to be hexadecimal.' 99 | ); 100 | assert.ok( 101 | userInfoPrivate.privateKey.length == 32, 102 | 'Private key is expected to be 32 characters long.' 103 | ); 104 | }, assert); 105 | }); 106 | -------------------------------------------------------------------------------- /test/AddFriend/tAddFriend.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const utils = require('./../../../test/utils'); 4 | const addFriend = require('./../../../modules/AddFriend')(); 5 | const dbMessenger = require('./../../../modules/DBMessenger')(); 6 | 7 | /** 8 | * Mocking the user login functionality 9 | */ 10 | async function mockUserLogin() { 11 | // Add mock user information to the database 12 | const info = { 13 | 'privateKey': '11111111111111111111111111111111', 14 | 'displayName': 'Display Name', 15 | 'about': 'About the person!', 16 | 'key': 0, 17 | }; 18 | await dbMessenger.writeLoggedInUserInfo(info); 19 | } 20 | 21 | QUnit.test('Check if AddFriend module is available', function(assert) { 22 | assert.ok(typeof(addFriend) !== 'undefined'); 23 | }); 24 | 25 | QUnit.test('Attempting rendering', async function(assert) { 26 | await utils.setFixtureWithContainerDOMElemenent(); 27 | await mockUserLogin(); 28 | 29 | await addFriend.render(function() {}); 30 | 31 | await utils.waitAndTryAgain(function(assert){ 32 | // Check if the title and tabs are available 33 | const titles = document.querySelectorAll('#title'); 34 | assert.strictEqual(titles.length, 1, 35 | 'There should be exactly one DIV with the id "title".'); 36 | const tabsMain = document.querySelectorAll('#tabs'); 37 | assert.strictEqual(tabsMain.length, 1, 38 | 'There should be exactly one DIV with the id "tabs".'); 39 | 40 | const tabContents = document.querySelectorAll('.tabContent'); 41 | const tabs = document.querySelectorAll('.tab'); 42 | assert.strictEqual(tabContents.length, tabs.length, 43 | 'There should as many tabs as contents corresponding to it.'); 44 | }, assert); 45 | }); 46 | 47 | QUnit.test('Pressing back button', async function(assert) { 48 | await utils.setFixtureWithContainerDOMElemenent(); 49 | await mockUserLogin(); 50 | 51 | let backButtonPressed = false; 52 | await addFriend.render(function() { 53 | backButtonPressed = true; 54 | }); 55 | 56 | await utils.waitAndTryAgain(async function(assert){ 57 | // Press back button 58 | document.getElementById('backButton').click(); 59 | assert.true(backButtonPressed); 60 | }, assert); 61 | }); 62 | 63 | QUnit.test('Tabs and contents visibility', async function(assert) { 64 | await utils.setFixtureWithContainerDOMElemenent(); 65 | await mockUserLogin(); 66 | 67 | await addFriend.render(function() {}); 68 | 69 | await utils.waitAndTryAgain(async function(assert){ 70 | // TODO: Test visibility on click of tabs 71 | const tabs = document.querySelectorAll('.tab'); 72 | const tabContents = document.querySelectorAll('.tabContent'); 73 | 74 | assert.true(tabs !== undefined); 75 | assert.true(tabContents !== undefined); 76 | 77 | for (let i = 0; i < tabs.length; i++) { 78 | assert.strictEqual(getComputedStyle(tabContents[i]).display, 79 | 'none', 'All tab contents must be invisible initially'); 80 | } 81 | 82 | // Click on each tabs and see if only the corresponding tab content is 83 | // visible 84 | for (let i = 0; i < tabs.length; i++) { 85 | tabs[i].click(); 86 | for (let j = 0; j < tabs.length; j++) { 87 | if (i == j) { 88 | assert.strictEqual( 89 | tabs[i].getAttribute('name'), 90 | tabContents[i].id, 91 | 'Tabs and tab content should follow the same order.', 92 | ); 93 | assert.notEqual( 94 | getComputedStyle(tabContents[j]).display, 95 | 'none', 96 | 'Active tab is supposed to be visible.', 97 | ); 98 | } else { 99 | assert.strictEqual( 100 | getComputedStyle(tabContents[j]).display, 101 | 'none', 102 | 'All tab contents other than the active one must be ' + 103 | 'invisible.', 104 | ); 105 | } 106 | } 107 | } 108 | }, assert); 109 | 110 | // TODO: Figure out why this solves the issue and attempt to remove this 111 | await new Promise(resolve => setTimeout(resolve, 1000)); 112 | }); 113 | -------------------------------------------------------------------------------- /modules/EditProfile/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const requireText = require('require-text'); 4 | const Layout = require('./../Layout'); 5 | const dbMessenger = require('./../DBMessenger')(); 6 | // eslint-disable-next-line no-unused-vars 7 | const imagePack = require('../ImagePack'); // used in template 8 | const i18n = require('./../I18n')(); 9 | 10 | /** 11 | * This class implements the functionality for editing profile information. 12 | */ 13 | class EditProfile { 14 | /** This is the constructor (note the singleton implementation) */ 15 | constructor() { 16 | if (EditProfile._instance) { 17 | return EditProfile._instance; 18 | } 19 | EditProfile._instance = this; 20 | } 21 | 22 | /** 23 | * This function renders the template into the UI. 24 | * @param {function} backCallback the callback function that is executed 25 | * when back button is clicked. 26 | */ 27 | async render(backCallback) { 28 | // Render the layout 29 | await Layout.render(); 30 | 31 | // Get user info from database, which will be used by the template 32 | // eslint-disable-next-line no-unused-vars 33 | const userInfo = await dbMessenger.getLoggedInUserInfoPrivate(); 34 | 35 | // Link css 36 | const link = document.createElement('link'); 37 | link.rel = 'stylesheet'; 38 | link.href = __dirname + '/style.css'; 39 | document.body.appendChild(link); 40 | 41 | // Ensure that the CSS is loaded before the HTML is 42 | link.addEventListener('load', function() { 43 | // Sidebar related 44 | const sidebarDOMNode = document.getElementById('sidebar'); 45 | sidebarDOMNode.innerHTML += eval('`' + 46 | requireText('./template_sidebar.html', require) + '`'); 47 | document.getElementById('backButton').onclick = backCallback; 48 | 49 | // Main content related 50 | const mainContentDOMNode = document.getElementById('mainContent'); 51 | mainContentDOMNode.innerHTML += eval('`' + 52 | requireText('./template_mainContent.html', require) + '`'); 53 | 54 | // Button callbacks 55 | document.getElementById('editButton').onclick = async function() { 56 | const info = {}; 57 | const inputs = 58 | mainContentDOMNode.getElementsByClassName('HexHoot_Input'); 59 | for (let i = 0; i < inputs.length; i++) { 60 | if (inputs[i].name === 'photo') { 61 | // If there is a photo being uploaded 62 | if (inputs[i].files.length !== 0) { 63 | const reader = new FileReader(); 64 | reader.readAsDataURL(inputs[i].files[0]); 65 | 66 | await new Promise(function(resolve, reject) { 67 | reader.onload = function() { 68 | resolve('Image loaded'); 69 | }; 70 | reader.onerror = function() { 71 | reject(new Error('Error loading image')); 72 | }; 73 | }); 74 | 75 | info[inputs[i].name] = reader.result; 76 | } 77 | } else { 78 | info[inputs[i].name] = inputs[i].value; 79 | } 80 | } 81 | dbMessenger.writeLoggedInUserInfo(info); 82 | backCallback(); // Just to give the users a sense of feedback 83 | }; 84 | document.getElementById('downloadProfile').onclick = function() { 85 | dbMessenger.downloadDBAsJSON(); 86 | }; 87 | document.getElementById('logout').onclick = function() { 88 | const confirmMessage = 89 | i18n.getText('EditProfile.logoutConfirmation'); 90 | if (confirm(confirmMessage) == true) { 91 | dbMessenger.deleteDatabaseContent(); 92 | window.reload(); 93 | } 94 | }; 95 | 96 | // Privatekey toggle callbacks 97 | const privateKeyDOM = document.querySelector('[name=privateKey]'); 98 | privateKeyDOM.onfocus = function() { 99 | privateKeyDOM.type = 'text'; 100 | }; 101 | privateKeyDOM.onblur = function() { 102 | privateKeyDOM.type = 'password'; 103 | }; 104 | }); 105 | } 106 | 107 | /** 108 | * This function renders the profile icon to the icon bar. The profile icon 109 | * links to the page wherein the user can edit their own profile. 110 | * @param {function} clickCallback the callback function that is executed 111 | * when the icon is clicked. 112 | */ 113 | async renderToIconBar(clickCallback) { 114 | // For profile photo on the icon; used in the template file 115 | // eslint-disable-next-line no-unused-vars 116 | const info = await dbMessenger.getLoggedInUserInfoPrivate(); 117 | 118 | const holderDOM = document.createElement('div'); 119 | holderDOM.innerHTML = eval('`' + 120 | requireText('./template_iconbar.html', require) + '`'); 121 | 122 | const iconDOMNode = holderDOM.querySelector('#ownProfilePic'); 123 | iconDOMNode.onclick = function() { 124 | this.render(clickCallback); 125 | }.bind(this); 126 | 127 | const iconBarDOMNode = document.getElementById('iconbar'); 128 | iconBarDOMNode.appendChild(holderDOM); 129 | } 130 | } 131 | 132 | module.exports = new EditProfile(); 133 | -------------------------------------------------------------------------------- /modules/DBMessenger/Messenger.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const Hyperswarm = require('hyperswarm'); 4 | 5 | const utils = require('./utils'); 6 | const dbWrapper = require('./DBWrapper'); 7 | 8 | /** 9 | * This class contains methods to send and receive messages from peers 10 | */ 11 | class Messenger { 12 | /** This is the constructor (note the singleton implementation) */ 13 | constructor() { 14 | if (Messenger._instance) { 15 | return Messenger._instance; 16 | } 17 | Messenger._instance = this; 18 | 19 | this.subscribedSwarms = []; 20 | this.senderSwarms = []; 21 | this.listOfChannelsSubscribedTo = []; 22 | this.messageReceiveCallbackFunction = function() {}; 23 | this.connMap = []; 24 | 25 | Messenger._instance.initialize(); 26 | } 27 | 28 | /** 29 | * Initialize messenger 30 | */ 31 | async initialize() { 32 | this.userInfo = (await dbWrapper().getAll('LoggedInUserInfo'))[0]; 33 | if (this.userInfo) { 34 | this.userPublicKey = 35 | utils.getPublicKeyFromPrivateKey(this.userInfo.privateKey); 36 | this.subscribeToChannel( 37 | utils.stringToBuffer(this.userPublicKey), true); 38 | } 39 | } 40 | 41 | /** 42 | * Cleanup Messenger 43 | */ 44 | async cleanup() { 45 | } 46 | 47 | /** 48 | * Re-initialize Messenger 49 | */ 50 | async reInitialize() { 51 | await Messenger._instance.cleanup(); 52 | await Messenger._instance.initialize(); 53 | } 54 | 55 | /** 56 | * What to do when a channel receives a message 57 | * message arrives. 58 | * @param {*} message The message that was received in the channel 59 | */ 60 | messageReceivedCallback(message) { 61 | const messageObj = JSON.parse(message); 62 | 63 | const sharedKeyString = utils.getSharedKey( 64 | this.userInfo.privateKey, messageObj.senderPublicKey); 65 | 66 | messageObj.iv = Buffer.from(messageObj.iv); // Ensuring the type 67 | messageObj.message = JSON.parse(utils.decryptMessage( 68 | messageObj.message, sharedKeyString, messageObj.iv)); 69 | 70 | console.log('Message received:'); 71 | console.log(messageObj); 72 | 73 | this.messageReceiveCallbackFunction(messageObj); 74 | } 75 | 76 | /** 77 | * Subscribe for messages from a channel 78 | * @param {Buffer} channelName The channel name can be other users 79 | * @param {boolean} isServer whether it is joining as a server or not 80 | * public key or shared key between two (or more) users 81 | */ 82 | async subscribeToChannel(channelName, isServer) { 83 | let channelNameStr = ''; 84 | if (typeof(channelName) === 'string') { 85 | channelNameStr = channelName; 86 | channelName = utils.stringToBuffer(channelName); 87 | } else { 88 | channelNameStr = utils.bufferToString(channelName); 89 | } 90 | if (this.listOfChannelsSubscribedTo.includes(channelNameStr)) { 91 | // Already subscribed to the channel 92 | console.log('Already subscribed to the channel'); 93 | return; 94 | } 95 | 96 | const idx = this.subscribedSwarms.length; 97 | this.subscribedSwarms.push(new Hyperswarm()); 98 | 99 | this.subscribedSwarms[idx].on('connection', function(conn, peerInfo) { 100 | conn.on('error', this.errorCallback.bind(this)); 101 | conn.on('data', this.messageReceivedCallback.bind(this)); 102 | }.bind(this)); 103 | 104 | const discovery = this.subscribedSwarms[idx].join( 105 | channelName, {server: isServer, client: !isServer}); 106 | await discovery.flushed(); 107 | 108 | console.log('Subscribed to channel: ' + channelNameStr); 109 | this.listOfChannelsSubscribedTo.push(channelNameStr); 110 | } 111 | 112 | /** 113 | * Send a message to a channel 114 | * @param {Buffer} channelName is also the other user's public key 115 | * @param {string} message 116 | */ 117 | async sendMessageToChannel(channelName, message) { 118 | let channelNameStr = ''; 119 | if (typeof(channelName) === 'string') { 120 | channelNameStr = channelName; 121 | channelName = utils.stringToBuffer(channelName); 122 | } else { 123 | channelNameStr = utils.bufferToString(channelName); 124 | } 125 | 126 | // Encrypt the message using the shared key 127 | const sharedKeyString = 128 | utils.getSharedKey(this.userInfo.privateKey, channelName); 129 | 130 | let iv = []; 131 | [message, iv] = utils.encryptMessage( 132 | JSON.stringify(message), sharedKeyString); 133 | 134 | // Add sender and 'iv' informations to the message, which are needed to 135 | // decrypt the message 136 | message = JSON.stringify({ 137 | 'senderPublicKey': this.userPublicKey, 138 | 'iv': iv, 139 | 'message': message, 140 | }); 141 | 142 | console.log('Sending message to channel: ' + channelNameStr); 143 | 144 | if (typeof(this.connMap[channelNameStr]) === 'undefined') { 145 | const idx = this.senderSwarms.length; 146 | this.senderSwarms.push(new Hyperswarm()); 147 | this.senderSwarms[idx].on('connection', function(conn) { 148 | conn.on('error', this.errorCallback.bind(this)); 149 | conn.on('data', this.messageReceivedCallback.bind(this)); 150 | conn.write(message); 151 | this.connMap[channelNameStr] = conn; 152 | }.bind(this)); 153 | 154 | this.senderSwarms[idx].join( 155 | channelName, {server: false, client: true}, 156 | ); 157 | await this.senderSwarms[idx].flush(); 158 | // TODO: Perhaps for a more reliable communication, we should not 159 | // use a temporary connection. We could have a more premanent 160 | // connection. 161 | } else { 162 | this.connMap[channelNameStr].write(message); 163 | } 164 | } 165 | 166 | /** 167 | * Set callback function which gets triggered when new messages arrive in 168 | * subscribed channels. 169 | * @param {function} func callback function that gets invoked when a new 170 | * new message is received 171 | */ 172 | setMessageReceiveCallbackFunction(func) { 173 | this.messageReceiveCallbackFunction = func; 174 | } 175 | 176 | /** 177 | * Display error message in the console 178 | * @param {*} err Error message 179 | */ 180 | errorCallback(err) { 181 | console.log(err); 182 | } 183 | } 184 | 185 | module.exports = function() { 186 | return new Messenger(); 187 | }; 188 | -------------------------------------------------------------------------------- /modules/DBMessenger/utils.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const ecdh = require('ecdh'); 4 | const crypto = require('crypto'); 5 | 6 | // This shall be exported. 7 | const utils = []; 8 | 9 | /** 10 | * Define the curve that we are using for encryption 11 | * @return {*} the curve 12 | */ 13 | utils.getECDHCurve = function() { 14 | return ecdh.getCurve('secp128r1'); 15 | }; 16 | 17 | /** 18 | * Define the algorithm that we use to encrypt and decrypt messages, which is 19 | * available in 'crypto' library. 20 | * @return {string} the name of the algorithm 21 | */ 22 | utils.getEncryptionAlgorithm = function() { 23 | return 'aes-256-cbc'; 24 | }; 25 | 26 | /** 27 | * Generate IV, which is something like a salt that helps in preventing 28 | * dictionary attacks 29 | * @return {Buffer} iv 30 | */ 31 | utils.generateIV = function() { 32 | return crypto.randomBytes(16); 33 | }; 34 | 35 | /** 36 | * Generate a new private key. Generate a random one if no argument is given; 37 | * otherwise create something off of SHA256 hashing. 38 | * @param {object} info an optional structure containing username and password 39 | * @return {string} a 32 character string depicting a private key 40 | */ 41 | utils.generatePrivateKey = function(info) { 42 | // If no argument, return a random private key 43 | if (info === undefined) { 44 | const newKey = ecdh.generateKeys(utils.getECDHCurve()); 45 | return newKey.privateKey.buffer.toString('hex'); 46 | } 47 | 48 | // Create a SHA256 hash and take the first 32 elements 49 | const saltPrefix = 'ഉപ്പിലിട്ട'; 50 | const saltSuffix = 'പാസ്സ്‌വേർഡ്'; 51 | const separator = '√'; 52 | const hash = crypto.createHash('sha256'); 53 | hash.update( 54 | saltPrefix + info.password + separator + info.username + saltSuffix 55 | ); 56 | return hash.digest('hex').substring(32); 57 | }; 58 | 59 | /** 60 | * Convert the channel name from string format to buffer format 61 | * @param {string} channelName the channel name in string format 62 | * @return {Buffer} the channel name in buffer format 63 | */ 64 | utils.stringToBuffer = function(channelName) { 65 | return Buffer.from(channelName, 'hex'); 66 | }; 67 | 68 | /** 69 | * Convert the channel name from buffer format to string format 70 | * @param {Buffer} buffer the channel name in buffer format 71 | * @return {string} the channel name in string format 72 | */ 73 | utils.bufferToString = function(buffer) { 74 | return buffer.toString('hex'); 75 | }; 76 | 77 | /** 78 | * Get private key handle from string private key 79 | * @param {string} privateKeyString private key of a user 80 | * @return {PrivateKey} Private key object handle 81 | */ 82 | utils.getPrivateKeyHandle = function(privateKeyString) { 83 | return ecdh.PrivateKey.fromBuffer( 84 | utils.getECDHCurve(), 85 | Buffer.from(privateKeyString, 'hex'), 86 | ); 87 | }; 88 | 89 | /** 90 | * Get public key handle from string private key 91 | * @param {string} publicKeyString public key of a user 92 | * @return {PublicKey} Public key object handle 93 | */ 94 | utils.getPublicKeyHandle = function(publicKeyString) { 95 | return ecdh.PublicKey.fromBuffer( 96 | utils.getECDHCurve(), 97 | Buffer.from(publicKeyString, 'hex'), 98 | ); 99 | }; 100 | 101 | /** 102 | * Get public key string from private key string 103 | * @param {string} privateKeyString private key of a user 104 | * @return {string} public key corresponding to private key 105 | */ 106 | utils.getPublicKeyFromPrivateKey = function(privateKeyString) { 107 | return utils.getPrivateKeyHandle(privateKeyString) 108 | .derivePublicKey().buffer.toString('hex'); 109 | }; 110 | 111 | /** 112 | * Get shared key string from private key of one user and public key of another 113 | * user 114 | * @param {string} privateKeyString private key of a user 115 | * @param {string} publicKeyString public key of a user 116 | * @return {string} shared key (public type) from elliptic curve key exchange 117 | */ 118 | utils.getSharedKey = function(privateKeyString, publicKeyString) { 119 | const privateSharedKeyString = utils.getPrivateKeyHandle(privateKeyString) 120 | .deriveSharedSecret(utils.getPublicKeyHandle(publicKeyString)) 121 | .toString('hex'); 122 | return utils.getPublicKeyFromPrivateKey(privateSharedKeyString); 123 | }; 124 | 125 | /** 126 | * Encrypt a message using a shared key. 127 | * @param {string} messageString message in string format 128 | * @param {string} sharedKeyString shared key in string format 129 | * @return {string} encrypted message 130 | * @return {Buffer} iv 131 | */ 132 | utils.encryptMessage = function(messageString, sharedKeyString) { 133 | const algorithm = utils.getEncryptionAlgorithm(); 134 | const sharedKey = utils.stringToBuffer(sharedKeyString); 135 | const iv = utils.generateIV(); 136 | 137 | const cipher = crypto.createCipheriv(algorithm, sharedKey, iv); 138 | let encrypted = cipher.update(messageString); 139 | encrypted = Buffer.concat([encrypted, cipher.final()]); 140 | 141 | return [encrypted.toString('hex'), iv]; 142 | }; 143 | /** 144 | * Decrypt a message using a shared key. 145 | * @param {string} encryptedString message that is encrypted 146 | * @param {string} sharedKeyString shared key in string format 147 | * @param {Buffer} iv the 'iv' 148 | * @return {string} decrpyted message 149 | */ 150 | utils.decryptMessage = function(encryptedString, sharedKeyString, iv) { 151 | const algorithm = utils.getEncryptionAlgorithm(); 152 | const sharedKey = utils.stringToBuffer(sharedKeyString); 153 | 154 | const decipher = crypto.createDecipheriv(algorithm, sharedKey, iv); 155 | let decrypted = decipher.update(Buffer.from(encryptedString, 'hex')); 156 | decrypted = Buffer.concat([decrypted, decipher.final()]); 157 | 158 | return decrypted.toString(); 159 | }; 160 | 161 | /** 162 | * Sign a message using private key 163 | * @param {string} message message that need to be signed 164 | * @param {string} privateKeyString private key of a user 165 | * @return {string} signature 166 | */ 167 | utils.signMessage = function(message, privateKeyString) { 168 | const algorithm = 'sha512'; // utils.getEncryptionAlgorithm(); 169 | const privateKey = utils.getPrivateKeyHandle(privateKeyString); 170 | 171 | const hashedMessage = crypto.createHash(algorithm) 172 | .update(Buffer.from(message)).digest(); 173 | const signature = privateKey.sign(hashedMessage, algorithm); 174 | 175 | return signature.toString('hex'); 176 | }; 177 | /** 178 | * Verify the signature for a message using public key 179 | * @param {string} message message was signed 180 | * @param {string} signature signature corresponding to the message 181 | * @param {string} publicKeyString public key of a user 182 | * @return {boolean} whether the signature if valid or not 183 | */ 184 | utils.verifySignature = function(message, signature, publicKeyString) { 185 | const algorithm = 'sha512'; // utils.getEncryptionAlgorithm(); 186 | const publicKey = utils.getPublicKeyHandle(publicKeyString); 187 | 188 | const hashedMessage = crypto.createHash(algorithm) 189 | .update(Buffer.from(message)).digest(); 190 | return publicKey.verifySignature(hashedMessage, signature); 191 | }; 192 | 193 | module.exports = utils; 194 | -------------------------------------------------------------------------------- /modules/I18n/lang.json: -------------------------------------------------------------------------------- 1 | { 2 | "coprightMessage": "Copyright © 2022-2024 Zenin Easa Panthakkalakath", 3 | 4 | "I18n": { 5 | "language": { 6 | "en": "English", 7 | "ml": "മലയാളം", 8 | "de": "Deutsch", 9 | "fr": "Français", 10 | "it": "Italiano" 11 | }, 12 | "chooseLanguage": { 13 | "en": "Choose a Language", 14 | "ml": "ഭാഷ തിരഞ്ഞെടുക്കുക", 15 | "de": "Sprache auswählen", 16 | "fr": "Sélectionnez votre langue", 17 | "it": "Seleziona la lingua" 18 | } 19 | }, 20 | "Logo": { 21 | "hexhoot": { 22 | "en": "HexHoot", 23 | "ml": "ഹെക്സ്ഹൂട്ട്", 24 | "de": "HexHoot", 25 | "fr": "HexHoot", 26 | "it": "HexHoot" 27 | }, 28 | "alpha": { 29 | "en": "ALPHA", 30 | "ml": "ആൽഫ", 31 | "de": "ALPHA", 32 | "fr": "ALPHA", 33 | "it": "ALPHA" 34 | } 35 | }, 36 | "AddFriend": { 37 | "addFriend": { 38 | "en": "Add friend", 39 | "ml": "സുഹൃത്തിനെ ചേർക്കുക", 40 | "de": "Freund hinzufügen", 41 | "fr": "Ajouter un ami", 42 | "it": "Aggiungi un amico" 43 | }, 44 | "copyToClipboard": { 45 | "en": "Copy to clipboard", 46 | "ml": "ക്ലിപ്പ്ബോർഡിലേയ്ക്ക് കോപ്പി ചെയ്യുക", 47 | "de": "In die Zwischenablage kopieren", 48 | "fr": "Copier", 49 | "it": "Copia negli appunti" 50 | }, 51 | "publicKey": { 52 | "en": "Public key (32 characters)", 53 | "ml": "പബ്ലിക് കീ (32 പ്രതീകങ്ങൾ)", 54 | "de": "Öffentlicher Schlüssel (32 Zeichen)", 55 | "fr": "Clé publique (32 caractères)", 56 | "it": "Chiave pubblica (32 caratteri)" 57 | }, 58 | "add": { 59 | "en": "Add", 60 | "ml": "ചേർക്കുക", 61 | "de": "Hinzufügen", 62 | "fr": "Ajouter", 63 | "it": "Aggiungere" 64 | }, 65 | "myCode": { 66 | "en": "My code", 67 | "ml": "എന്റെ കോഡ്", 68 | "de": "Mein Code", 69 | "fr": "Mon code", 70 | "it": "Il mio codice" 71 | }, 72 | "theirCode": { 73 | "en": "Their code", 74 | "ml": "അവരുടെ കോഡ്", 75 | "de": "Ihr Code", 76 | "fr": "Leur code", 77 | "it": "Il suo codice" 78 | }, 79 | "back": { 80 | "en": "Back", 81 | "ml": "ബാക്ക്", 82 | "de": "Zurück", 83 | "fr": "Retour", 84 | "it": "Indietro" 85 | } 86 | }, 87 | "Chat": { 88 | "at": { 89 | "en": "at", 90 | "ml": "സമയം", 91 | "de": "um", 92 | "fr": "À", 93 | "it": "A" 94 | }, 95 | "addFriend": { 96 | "en": "Add friend", 97 | "ml": "സുഹൃത്തിനെ ചേർക്കുക", 98 | "de": "Freund hinzufügen", 99 | "fr": "Ajouter un ami", 100 | "it": "Aggiungi un amico" 101 | }, 102 | "search": { 103 | "en": "Search", 104 | "ml": "തിരയുക", 105 | "de": "Suchen", 106 | "fr": "Chercher", 107 | "it": "Cercare" 108 | }, 109 | "typeInYourMessage": { 110 | "en": "Type in you message here...", 111 | "ml": "താങ്കളുടെ സന്ദേശം ഇവിടെ എഴുതുക...", 112 | "de": "Deine Nachricht hier eintippen...", 113 | "fr": "Tapez votre message ici...", 114 | "it": "Digita qui il tuo messaggio..." 115 | } 116 | }, 117 | "EditProfile": { 118 | "editProfile": { 119 | "en": "Edit profile", 120 | "ml": "വ്യക്തിവിവരങ്ങൾ എഡിറ്റ് ചെയ്യുവാൻ", 121 | "de": "Profil anpassen", 122 | "fr": "Editer le profil", 123 | "it": "Modifica Profilo" 124 | }, 125 | "logoutConfirmation": { 126 | "en": "Logging out will delete all the data in your system. We recommend that you backup the same.", 127 | "ml": "ലോഗ് ഔട്ട് ചെയ്താൽ നിങ്ങളുടെ സിസ്റ്റത്തിലെ എല്ലാ ഡാറ്റയും ഡിലീറ്റ് ചെയ്യപ്പെടും. ആയതിനാൽ ലോഗൗട്ട് ചെയ്യുന്നതിന് മുന്നോടിയായി ഇതെല്ലാം ബാക്കപ്പ് ചെയ്യാൻ ഞങ്ങൾ ശുപാർശ ചെയ്യുന്നു.", 128 | "de": "Durch das Abmelden werden alle Daten in Ihrem System gelöscht. Wir empfehlen, einen backup zu kreieren.", 129 | "fr": "La déconnexion supprimera toutes les données de votre système. Nous vous recommandons de sauvegarder le même.", 130 | "it": "La disconnessione eliminerà tutti i dati nel sistema. Ti consigliamo di eseguire il backup dello stesso." 131 | }, 132 | "privateKey": { 133 | "en": "Private key (32 characters)", 134 | "ml": "പ്രൈവറ്റ് കീ (32 പ്രതീകങ്ങൾ)", 135 | "de": "Privater Schlüssel (32 Zeichen)", 136 | "fr": "Clé privée (32 caractères)", 137 | "it": "Chiave privata (32 caratteri)" 138 | }, 139 | "warningNotToShare": { 140 | "en": "Do not share it with anyone", 141 | "ml": "യാതൊരു കാരണവശയാലും ഇത് മറ്റൊരാൾക്ക് പറഞ്ഞുകൊടുക്കരുത്", 142 | "de": "Mit niemanden teilen", 143 | "fr": "Ne partagez avec personne", 144 | "it": "Non condividerla con nessuno" 145 | }, 146 | "displayName": { 147 | "en": "Display name", 148 | "ml": "പ്രദർശന നാമം", 149 | "de": "Anzeigename", 150 | "fr": "Nom public", 151 | "it": "Nome pubblico" 152 | }, 153 | "aboutYou": { 154 | "en": "About you", 155 | "ml": "നിങ്ങളെ കുറിച്ച്", 156 | "de": "Über dich", 157 | "fr": "Mes infos", 158 | "it": "Una tua descrizione" 159 | }, 160 | "photo": { 161 | "en": "Photo", 162 | "ml": "ഫോട്ടോ", 163 | "de": "Foto", 164 | "fr": "Photo", 165 | "it": "Foto" 166 | }, 167 | "applyEdit": { 168 | "en": "Apply edit", 169 | "ml": "എഡിറ്റ് ചെയ്യുക", 170 | "de": "Änderungen anwenden", 171 | "fr": "Appliquer la modification", 172 | "it": "Applica modifiche" 173 | }, 174 | "downloadProfile": { 175 | "en": "Download profile backup", 176 | "ml": "പ്രൊഫൈൽ ബാക്കപ്പ് ഡൗൺലോഡ് ചെയ്യുക", 177 | "de": "Profilsicherung herunterladen", 178 | "fr": "Télécharger la sauvegarde du profil", 179 | "it": "Scarica il file di backup del profilo" 180 | }, 181 | "logout": { 182 | "en": "Logout", 183 | "ml": "ലോഗ്ഔട്ട് ചെയ്യുക", 184 | "de": "Ausloggen", 185 | "fr": "Déconnecter", 186 | "it": "Disconnettersi" 187 | }, 188 | "back": { 189 | "en": "Back", 190 | "ml": "ബാക്ക്", 191 | "de": "Zurück", 192 | "fr": "Retour", 193 | "it": "Indietro" 194 | } 195 | }, 196 | "Login": { 197 | "minimumCharacters": { 198 | "en": "Display name should have atleast 5 characters.", 199 | "ml": "പ്രദർശന നാമത്തിൽ കുറഞ്ഞത് 5 പ്രതീകമെങ്കിലുമുണ്ടായിരിക്കണം.", 200 | "de": "Anzeigename muss mindestens 5 Zeichen enthalten", 201 | "fr": "Le nom à afficher doit comporter au moins 5 caractères.", 202 | "it": "Il nome visualizzato deve contenere almeno 5 caratteri." 203 | }, 204 | "password": { 205 | "en": "Password (minimum 8 characters)", 206 | "ml": "പാസ്സ്‌വേർഡ് (ചുരുങ്ങിയത് 8 പ്രതീകങ്ങൾ)", 207 | "de": "Passwort (mindestens 8 Zeichen)", 208 | "fr": "Mot de passe (minimum 8 caractères)", 209 | "it": "Password (minimo 8 caratteri)" 210 | }, 211 | "passwordLength": { 212 | "en": "Password must be at least 8 characters long.", 213 | "ml": "പാസ്സ്‌വേർഡിൽ ചുരുങ്ങിയത് 8 പ്രതീകങ്ങളെങ്കിലും ഉണ്ടായിരിക്കണം.", 214 | "de": "Das Passwort muss mindestens 8 Zeichen lang sein.", 215 | "fr": "Le mot de passe doit comporter au moins 8 caractères.", 216 | "it": "La password deve contenere almeno 8 caratteri." 217 | }, 218 | "displayName": { 219 | "en": "Display name (minimum 4 characters)", 220 | "ml": "പ്രദർശന നാമം (ചുരുങ്ങിയത് 4 പ്രതീകങ്ങൾ)", 221 | "de": "Anzeigename (mindestens 4 Zeichen)", 222 | "fr": "Afficher un nom (minimum 4 caractères)", 223 | "it": "Nome pubblico (minimo 4 caratteri)" 224 | }, 225 | "aboutYou": { 226 | "en": "About you", 227 | "ml": "നിങ്ങളെ കുറിച്ച്", 228 | "de": "Über dich", 229 | "fr": "Au propos de vous", 230 | "it": "Una tua descrizione" 231 | }, 232 | "login": { 233 | "en": "Login", 234 | "ml": "ലോഗിൻ ചെയ്യുക", 235 | "de": "Anmelden", 236 | "fr": "Me connecter", 237 | "it": "Accedi" 238 | }, 239 | "or": { 240 | "en": "OR", 241 | "ml": "അല്ലെങ്കിൽ", 242 | "de": "ODER", 243 | "fr": "OU", 244 | "it": "OPPURE" 245 | }, 246 | "loginUsingBackup": { 247 | "en": "Login using profile backup", 248 | "ml": "പ്രൊഫൈൽ ബാക്കപ്പ് ഉപയോഗിച്ച് ലോഗിൻ ചെയ്യുക", 249 | "de": "Anmeldung mit Profilsicherung", 250 | "fr": "Connectez-vous en utilisant la sauvegarde du profil", 251 | "it": "Accedi utilizzando il file di backup del profilo" 252 | } 253 | }, 254 | "ViewProfile": { 255 | "viewProfile": { 256 | "en": "View profile", 257 | "ml": "വ്യക്തിവിവരങ്ങൾ", 258 | "de": "Profil anzeigen", 259 | "fr": "Voir le profil", 260 | "it": "Vedi profilo" 261 | }, 262 | "publicKey": { 263 | "en": "Public key", 264 | "ml": "പബ്ലിക് കീ", 265 | "de": "Öffentlicher Schlüssel (32 Zeichen)", 266 | "fr": "Clé publique", 267 | "it": "Chiave pubblica" 268 | }, 269 | "displayName": { 270 | "en": "Display name", 271 | "ml": "പ്രദർശന നാമം", 272 | "de": "Anzeigename", 273 | "fr": "Afficher un nom", 274 | "it": "Nome pubblico" 275 | }, 276 | "aboutPerson": { 277 | "en": "About the person", 278 | "ml": "വ്യക്തിയെക്കുറിച്ച്", 279 | "de": "Über die Person", 280 | "fr": "Infos de la personne", 281 | "it": "La tua descrizione" 282 | }, 283 | "photo": { 284 | "en": "Photo", 285 | "ml": "ഫോട്ടോ", 286 | "de": "Foto", 287 | "fr": "Photo", 288 | "it": "Foto" 289 | }, 290 | "back": { 291 | "en": "Back", 292 | "ml": "ബാക്ക്", 293 | "de": "Zurück", 294 | "fr": "Retour", 295 | "it": "Indietro" 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /modules/DBMessenger/DBWrapper.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const IDBExportImport = require('indexeddb-export-import'); 4 | 5 | /** 6 | * This class contains a wrapper for IndexedDB in accordance with what we need 7 | */ 8 | class DBWrapper { 9 | /** This is the constructor (note the singleton implementation) */ 10 | constructor() { 11 | /** This is the constructor (note the singleton implementation) */ 12 | if (DBWrapper._instance) { 13 | return DBWrapper._instance; 14 | } 15 | DBWrapper._instance = this; 16 | DBWrapper._instance.initialize(); 17 | } 18 | 19 | /** 20 | * Open the connection to the database 21 | */ 22 | initialize() { 23 | this.dbName = 'HexhootDB'; 24 | this.dbVersion = 1; 25 | 26 | const request = indexedDB.open(this.dbName, this.dbVersion); 27 | request.onerror = this.error; 28 | request.onupgradeneeded = this.upgrade.bind(this); 29 | request.onsuccess = this.storeDBAsMemberVariable(request); 30 | } 31 | 32 | /** 33 | * Once the database opening request has been processed, we store the 34 | * handle to the database in a member variable 35 | * @param {IDBOpenDBRequest} request the open request for the database 36 | * @return {function} callback function that populates the 'db' member 37 | * variable 38 | */ 39 | storeDBAsMemberVariable(request) { 40 | return function(event) { 41 | this.db = request.result; 42 | }.bind(this); 43 | } 44 | 45 | /** 46 | * Wait for 'this.db' to be available by invoking the following function 47 | * with an await 48 | * @return {Promise} promise for whether the database is loaded 49 | */ 50 | promiseDBLoaded() { 51 | return new Promise(function(resolve, reject) { 52 | let count = 0; 53 | const interval = setInterval(function() { 54 | if (this.db) { 55 | clearInterval(interval); 56 | resolve('DB Loaded'); 57 | } else { 58 | count += 1; 59 | if (count % 5 == 0) { 60 | this.initialize(); 61 | } else if (count > 9) { 62 | reject(new Error('DB Not loaded after a long time')); 63 | } 64 | } 65 | }.bind(this), 200); 66 | }.bind(this)); 67 | } 68 | 69 | /** 70 | * Handle errors 71 | * @param {Event} event a javascript event 72 | */ 73 | error(event) { 74 | console.log(new Error('Error: ' + JSON.stringify(event))); 75 | } 76 | 77 | /** 78 | * When you open the database for the first time or when you open the 79 | * database with a higher version number for the first time, the 80 | * following callback is invoked. 81 | * @param {Event} event a javascript event that is a callback param of 82 | * 'onupgradeneeded' 83 | */ 84 | upgrade(event) { 85 | /** 86 | * Add table to the database 87 | * @param {string} db the database as event callback 88 | * @param {string} tableName the name of the table 89 | * @param {string} userKey the path of the key in the table; username of 90 | * the other person 91 | * @param {Object} columns An object with names corresponding to the 92 | * column name and the values corresponding to the configuration of the 93 | * columns 94 | */ 95 | function addTable(db, tableName, userKey, columns) { 96 | const store = 97 | db.createObjectStore(tableName, {'keyPath': userKey}); 98 | 99 | // Eg. columns = {'col1': {unique: false}, 'col2': {unique: false}; 100 | Object.entries(columns).forEach(function(column) { 101 | store.createIndex(column[0], column[0], columns[1]); 102 | }); 103 | } 104 | addTable( 105 | event.target.result, 106 | 'Friends', 107 | 'key', 108 | { 109 | 'name': {unique: false}, // string 110 | 'about': {unique: false}, // string 111 | 'photo': {}, // base64 string 112 | 'lastProfileUpdate': {}, // timestamp 113 | 'lastMessageTimestamp': {}, // timestamp 114 | 'isRead': {}, // boolean 115 | }, 116 | ); 117 | addTable( 118 | event.target.result, 119 | 'Chat', 120 | ['key', 'timestamp'], 121 | { 122 | 'messages': {unique: false}, 123 | }, 124 | ); 125 | addTable( 126 | event.target.result, 127 | 'LoggedInUserInfo', 128 | 'key', 129 | { 130 | 'privateKey': {}, 131 | 'displayName': {}, 132 | 'about': {}, 133 | 'photo': {}, 134 | }, 135 | ); 136 | addTable( 137 | event.target.result, 138 | 'Preferences', 139 | 'key', // name of the field is the key 140 | { 141 | 'value': {}, 142 | }, 143 | ); 144 | } 145 | 146 | /** 147 | * Delete table 148 | * @param {string} tableName 149 | */ 150 | async deleteTable(tableName) { 151 | await new Promise((resolve, reject) => { 152 | const transaction = this.db.transaction(tableName, 'readwrite'); 153 | const objectStore = transaction.objectStore(tableName); 154 | 155 | const clearRequest = objectStore.clear(); 156 | 157 | clearRequest.onsuccess = () => { 158 | console.log(`All entries removed from ${tableName} table`); 159 | resolve(); 160 | }; 161 | 162 | clearRequest.onerror = (event) => { 163 | console.error( 164 | `Error clearing entries from ${tableName} table:`, 165 | event.target.error 166 | ); 167 | reject(event.target.error); 168 | }; 169 | }); 170 | } 171 | 172 | /** 173 | * Add or edit (put) a data entry in a database table 174 | * @param {string} tableName name of the database table 175 | * @param {Object} data data to be added/edited 176 | */ 177 | async addOrEditEntry(tableName, data) { 178 | await this.promiseDBLoaded(); 179 | 180 | // Read existing data and update the fields that is available in 'data' 181 | // object. 182 | let dataToDB = await this.get(tableName, data.key); 183 | if (dataToDB) { 184 | data = Object.entries(data); 185 | for (let i = 0; i < data.length; i++) { 186 | dataToDB[data[i][0]] = data[i][1]; 187 | } 188 | } else { 189 | dataToDB = data; 190 | } 191 | 192 | // Write to the database. 193 | const txn = this.db.transaction(tableName, 'readwrite'); 194 | const store = txn.objectStore(tableName); 195 | const query = store.put(dataToDB); 196 | query.onerror = this.error; 197 | } 198 | 199 | /** 200 | * Get all entries from a database table 201 | * @param {string} tableName name of the database table 202 | */ 203 | async getAll(tableName) { 204 | await this.promiseDBLoaded(); 205 | 206 | const txn = this.db.transaction(tableName, 'readwrite'); 207 | const store = txn.objectStore(tableName); 208 | 209 | let ret = []; 210 | 211 | await new Promise(function(resolve, reject) { 212 | const getAll = store.getAll(); 213 | getAll.onsuccess = function(event) { 214 | ret = event.target.result; 215 | resolve('Login data retrieved from DB'); 216 | }; 217 | getAll.onerror = function(err) { 218 | this.error(err); 219 | reject(new Error('Error: retrieving login data from DB')); 220 | }.bind(this); 221 | }.bind(this)); 222 | 223 | return ret; 224 | } 225 | 226 | /** 227 | * Get entry from a database with a key 228 | * @param {string} tableName name of the database table 229 | * @param {string} key string key value 230 | */ 231 | async get(tableName, key) { 232 | await this.promiseDBLoaded(); 233 | 234 | const txn = this.db.transaction(tableName, 'readwrite'); 235 | const store = txn.objectStore(tableName); 236 | 237 | let ret = []; 238 | 239 | await new Promise(function(resolve, reject) { 240 | const getAll = store.get(key); 241 | getAll.onsuccess = function(event) { 242 | ret = event.target.result; 243 | resolve('Data retrieved from DB'); 244 | }; 245 | getAll.onerror = function(err) { 246 | this.error(err); 247 | reject(new Error('Error: retrieving data from DB')); 248 | }.bind(this); 249 | }.bind(this)); 250 | 251 | return ret; 252 | } 253 | 254 | /** 255 | * Get entries from a database within a key range 256 | * @param {string} tableName name of the database table 257 | * @param {Array} lowerKeyBound lower bound of the key 258 | * @param {Array} upperKeyBound upper bound of the key 259 | */ 260 | async getInKeyRange(tableName, lowerKeyBound, upperKeyBound) { 261 | await this.promiseDBLoaded(); 262 | 263 | const txn = this.db.transaction(tableName, 'readwrite'); 264 | const store = txn.objectStore(tableName); 265 | 266 | let ret = []; 267 | 268 | await new Promise(function(resolve, reject) { 269 | const keyRange = IDBKeyRange.bound(lowerKeyBound, upperKeyBound); 270 | const getAll = store.getAll(keyRange); 271 | getAll.onsuccess = function(event) { 272 | ret = event.target.result; 273 | resolve('Data retrieved from DB'); 274 | }; 275 | getAll.onerror = function(err) { 276 | this.error(err); 277 | reject(new Error('Error: retrieving data from DB')); 278 | }; 279 | }); 280 | 281 | return ret; 282 | } 283 | 284 | /** 285 | * Download database as JSON. 286 | */ 287 | async downloadDBAsJSON() { 288 | IDBExportImport.exportToJsonString(this.db, function(err, jsonString) { 289 | if (!err) { 290 | const element = document.createElement('a'); 291 | element.setAttribute( 292 | 'href', 293 | 'data:text/plain;charset=utf-8,' + 294 | encodeURIComponent(jsonString), 295 | ); 296 | element.setAttribute('download', 'hexhoot_backup.hexhootjson'); 297 | 298 | element.style.display = 'none'; 299 | element.click(); 300 | element.remove(); 301 | } else { 302 | this.error(err); 303 | } 304 | }.bind(this)); 305 | } 306 | 307 | /** 308 | * Upload database as JSON. 309 | */ 310 | async uploadDBAsJSON() { 311 | return new Promise(function(resolve, reject) { 312 | const element = document.createElement('input'); 313 | element.type = 'file'; 314 | element.accept = '.hexhootjson'; 315 | element.click(); 316 | element.onchange = function(event) { 317 | const reader = new FileReader(); 318 | reader.readAsText(event.target.files[0], 'UTF-8'); 319 | reader.onload = function(evt) { 320 | const jsonString = evt.target.result; 321 | IDBExportImport.importFromJsonString(this.db, jsonString, 322 | function(err) { 323 | if (err) { 324 | reject('Loaded JSON file can not be imported'); 325 | } 326 | resolve('JSON file loaded and imported'); 327 | }, 328 | ); 329 | }.bind(this); 330 | reader.onerror = function(err) { 331 | reject('JSON file not loaded'); 332 | }; 333 | element.remove(); 334 | }.bind(this); 335 | }.bind(this)); 336 | } 337 | } 338 | 339 | module.exports = function() { 340 | return new DBWrapper(); 341 | }; 342 | -------------------------------------------------------------------------------- /modules/DBMessenger/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const os = require('os'); 4 | const dbWrapper = require('./DBWrapper')(); 5 | const messenger = require('./Messenger')(); 6 | const intranetMessenger = require('./IntranetMessenger')(); 7 | const utils = require('./utils.js'); 8 | 9 | /** 10 | * This is a singleton class. 11 | * This class has two responsibilities: 12 | * 1. Store information in a local database 13 | * 2. Communicate information with peers 14 | */ 15 | class DBMessenger { 16 | /** This is the constructor (note the singleton implementation) */ 17 | constructor() { 18 | if (DBMessenger._instance) { 19 | return DBMessenger._instance; 20 | } 21 | DBMessenger._instance = this; 22 | 23 | DBMessenger._instance.detectNetworkChanges(); 24 | DBMessenger._instance.initialize(); 25 | } 26 | 27 | /** 28 | * Initialize DBMessenger 29 | */ 30 | initialize() { 31 | // Data table names; like enum 32 | this.tableNames = { 33 | friends: 'Friends', 34 | chat: 'Chat', 35 | loggedInUser: 'LoggedInUserInfo', 36 | preferences: 'Preferences', 37 | }; 38 | 39 | // Message types; like enum 40 | this.messageType = { 41 | chat: 'ChatMessage', 42 | friendRequest: 'FriendRequest', 43 | userInfoResponse: 'UserInformation', 44 | }; 45 | 46 | // Related to callback functions that are executed when new messages 47 | // arrive 48 | this.listOfMessageReceiveCallbackFunctions = []; 49 | messenger.setMessageReceiveCallbackFunction( 50 | this.messageReceivedCallback.bind(this)); 51 | intranetMessenger.setMessageReceiveCallbackFunction( 52 | this.messageReceivedCallback.bind(this)); 53 | } 54 | 55 | /** 56 | * Detect network changes and act accordingly 57 | */ 58 | async detectNetworkChanges() { 59 | let net = os.networkInterfaces(); 60 | /** Recursively check (infinite loop) if the interface has changed */ 61 | async function checkInterfaces() { 62 | if ( 63 | JSON.stringify(net) !== 64 | JSON.stringify(os.networkInterfaces()) 65 | ) { 66 | net = os.networkInterfaces(); 67 | console.log('Network interface change detected'); 68 | messenger.reInitialize(); 69 | intranetMessenger.reInitialize(); 70 | } 71 | setTimeout(checkInterfaces, 5000); // Check again after 5 seconds 72 | } 73 | checkInterfaces(); 74 | } 75 | 76 | /** 77 | * Delete everything. 78 | */ 79 | async deleteDatabaseContent() { 80 | for (const key in this.tableNames) { 81 | await dbWrapper.deleteTable(this.tableNames[key]); 82 | } 83 | window.reload(); 84 | } 85 | 86 | /** 87 | * Get the list of all friends as a callback 88 | * @return {Array} the list of all friends 89 | */ 90 | async getAllFriends() { 91 | return await dbWrapper.getAll(this.tableNames.friends); 92 | } 93 | 94 | /** 95 | * Get the information of the logged in user. Note, this should not be 96 | * shared with anyone else as it contains the user's private key. 97 | * @return {Object} user information 98 | */ 99 | async getLoggedInUserInfoPrivate() { 100 | return (await dbWrapper.getAll(this.tableNames.loggedInUser))[0]; 101 | } 102 | 103 | /** 104 | * Get the information of the logged in user, which can be shared with the 105 | * others. 106 | * @return {Object} user information 107 | */ 108 | async getLoggedInUserInfoPublic() { 109 | const userInfo = await this.getLoggedInUserInfoPrivate(); 110 | userInfo.key = utils.getPublicKeyFromPrivateKey(userInfo.privateKey); 111 | delete userInfo.privateKey; 112 | return userInfo; 113 | } 114 | 115 | /** 116 | * Get the information of a particular person 117 | * @param {string} otherUserPublicKey user key of the other person 118 | */ 119 | async getUserInfo(otherUserPublicKey) { 120 | const loggedInUserInfo = await this.getLoggedInUserInfoPublic(); 121 | if (loggedInUserInfo.key === otherUserPublicKey) { 122 | return loggedInUserInfo; 123 | } 124 | return await dbWrapper.get(this.tableNames.friends, otherUserPublicKey); 125 | } 126 | 127 | /** 128 | * Write information about the user to the database. 129 | * Note that we expect this database to only have one element and the key 130 | * for the same is 0. 131 | * @param {Object} info information about the user 132 | */ 133 | async writeLoggedInUserInfo(info) { 134 | // Replace password with private key 135 | const privateKey = utils.generatePrivateKey(info); 136 | delete info.password 137 | info.privateKey = privateKey; 138 | 139 | // Update it in the database 140 | info.key = 0; 141 | await dbWrapper.addOrEditEntry(this.tableNames.loggedInUser, info); 142 | 143 | // Report to every friend regarding this change. 144 | const allFriends = await this.getAllFriends(); 145 | allFriends.forEach(function(friendInfo) { 146 | this.sendRequestOrResponse( 147 | friendInfo, 148 | this.messageType.userInfoResponse, 149 | ); 150 | }.bind(this)); 151 | 152 | // If there is a change in the private key, then the public key also 153 | // changes, which means that we need to subscribe to the new channel. 154 | messenger.initialize(); 155 | intranetMessenger.initialize(); 156 | } 157 | 158 | /** 159 | * Write a chat message to the database 160 | * @param {string} otherUserPublicKey user key of the other person 161 | * @param {Object} message the message and information associated with it 162 | */ 163 | async sendChatMessage(otherUserPublicKey, message) { 164 | dbWrapper.addOrEditEntry( 165 | this.tableNames.chat, 166 | { 167 | key: otherUserPublicKey, 168 | timestamp: message.timestamp, 169 | message: message, 170 | }, 171 | ); 172 | 173 | // Send the message via channel 174 | const messageToChannel = { 175 | type: this.messageType.chat, 176 | message: message, 177 | }; 178 | const promise1 = messenger.sendMessageToChannel( 179 | otherUserPublicKey, messageToChannel); 180 | const promise2 = intranetMessenger.sendMessageToChannel( 181 | otherUserPublicKey, messageToChannel); 182 | await Promise.allSettled([promise1, promise2]); 183 | 184 | // Update last message received and read flag 185 | dbWrapper.addOrEditEntry( 186 | this.tableNames.friends, 187 | { 188 | key: otherUserPublicKey, 189 | lastMessageTimestamp: message.timestamp, 190 | isRead: false, 191 | }, 192 | ); 193 | } 194 | 195 | /** 196 | * Write the received chat message into the database 197 | * @param {Object} messageObj message object 198 | */ 199 | async receivedChatMessage(messageObj) { 200 | // Store the received message 201 | const messageToDB = messageObj.message.message; 202 | dbWrapper.addOrEditEntry( 203 | this.tableNames.chat, 204 | { 205 | key: messageObj.senderPublicKey, 206 | timestamp: messageToDB.timestamp, 207 | message: messageToDB, 208 | }, 209 | ); 210 | 211 | // Update last message received and read flag 212 | dbWrapper.addOrEditEntry( 213 | this.tableNames.friends, 214 | { 215 | key: messageObj.senderPublicKey, 216 | lastMessageTimestamp: messageToDB.timestamp, 217 | isRead: false, 218 | }, 219 | ); 220 | } 221 | 222 | /** 223 | * Update read flag; for notification to stop showing. 224 | * @param {string} otherUserPublicKey user key of the other person 225 | */ 226 | async markChatRead(otherUserPublicKey) { 227 | dbWrapper.addOrEditEntry( 228 | this.tableNames.friends, 229 | { 230 | key: otherUserPublicKey, 231 | isRead: true, 232 | }, 233 | ); 234 | } 235 | 236 | /** 237 | * Get all messages in a shared key channel 238 | * @param {string} otherUserPublicKey user key of the other person 239 | */ 240 | async getAllMessages(otherUserPublicKey) { 241 | const chat = await dbWrapper.getInKeyRange( 242 | this.tableNames.chat, 243 | [otherUserPublicKey, 0], // Lowerbound key 244 | [otherUserPublicKey, Date.now()], // Upperbound key 245 | ); 246 | // chat.value 247 | if (chat) { 248 | return chat; 249 | } 250 | return []; 251 | } 252 | 253 | /** 254 | * Download database as JSON. 255 | */ 256 | async downloadDBAsJSON() { 257 | dbWrapper.downloadDBAsJSON(); 258 | } 259 | 260 | /** 261 | * Upload database as JSON. 262 | */ 263 | async uploadDBAsJSON() { 264 | return dbWrapper.uploadDBAsJSON(); 265 | } 266 | 267 | /** 268 | * Generate a new private key. 269 | * @return {string} a 32 character string depicting a private key 270 | */ 271 | generatePrivateKey() { 272 | return utils.generatePrivateKey(); 273 | } 274 | 275 | /** 276 | * Get public key of the logged in user 277 | * @return {string} the public key corresponding to the logged in user's 278 | * private key 279 | */ 280 | async getPublicKeyOfLoggedInUser() { 281 | const userInfo = await this.getLoggedInUserInfoPrivate(); 282 | return utils.getPublicKeyFromPrivateKey(userInfo.privateKey); 283 | } 284 | 285 | /** 286 | * Send friend request, acknowledge friend request, or, send an update on 287 | * user information 288 | * @param {Object} otherUserInfo information about the other user 289 | * @param {string} requestType type of the request 290 | */ 291 | async sendRequestOrResponse(otherUserInfo, requestType) { 292 | // Send a message to the other user 293 | const messageToChannel = { 294 | type: requestType, 295 | senderInfo: await this.getLoggedInUserInfoPublic(), 296 | }; 297 | await messenger.sendMessageToChannel( 298 | otherUserInfo.key, messageToChannel); 299 | await intranetMessenger.sendMessageToChannel( 300 | otherUserInfo.key, messageToChannel); 301 | } 302 | 303 | 304 | /** 305 | * Update friend information 306 | * @param {Object} otherUserInfo information about the other user 307 | */ 308 | async updateFriendInformation(otherUserInfo) { 309 | dbWrapper.addOrEditEntry(this.tableNames.friends, otherUserInfo); 310 | } 311 | 312 | /** 313 | * Add functions from other modules to Messenger which gets triggered when 314 | * new messages arrive in subscribed channels 315 | * @param {function} func callback function that gets invoked when a new 316 | * new message is received 317 | */ 318 | addMessageReceiveCallbackFunction(func) { 319 | this.listOfMessageReceiveCallbackFunctions.push(func); 320 | } 321 | 322 | /** 323 | * The callback function that gets invoked by Messenger when a new message 324 | * is received. 325 | * @param {Object} messageObj message object 326 | */ 327 | async messageReceivedCallback(messageObj) { 328 | // Perform actions that need to be taken from the data before other 329 | // callback functions get executed here. 330 | 331 | if (messageObj.message.type === this.messageType.friendRequest) { 332 | await this.sendRequestOrResponse( 333 | messageObj.message.senderInfo, 334 | this.messageType.userInfoResponse, 335 | ); 336 | } else if (messageObj.message.type === 337 | this.messageType.userInfoResponse) { 338 | await this.updateFriendInformation(messageObj.message.senderInfo); 339 | } else if (messageObj.message.type === this.messageType.chat) { 340 | // Nothing to do... 341 | } else { 342 | console.log('Unrecognized message type'); 343 | } 344 | 345 | // Now, Invoke all other callback functions. 346 | this.listOfMessageReceiveCallbackFunctions.forEach( 347 | function(func) { 348 | func(messageObj); 349 | }, 350 | ); 351 | } 352 | 353 | /** 354 | * Set preference in the database 355 | * @param {string} name name of the field 356 | * @param {*} value value of the field 357 | */ 358 | async setPreference(name, value) { 359 | dbWrapper.addOrEditEntry( 360 | this.tableNames.preferences, 361 | {key: name, value: value}, 362 | ); 363 | } 364 | /** 365 | * Get preference from the database 366 | * @param {string} name name of the field 367 | * @return {*} value of the field 368 | */ 369 | async getPreference(name) { 370 | return await dbWrapper.get( 371 | this.tableNames.preferences, 372 | name, 373 | ); 374 | } 375 | } 376 | 377 | module.exports = function() { 378 | return new DBMessenger(); 379 | }; 380 | -------------------------------------------------------------------------------- /modules/Chat/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2022-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const requireText = require('require-text'); 4 | const Layout = require('./../Layout'); 5 | const dbMessenger = require('./../DBMessenger')(); 6 | const addFriend = require('./../AddFriend'); 7 | const EditProfile = require('./../EditProfile'); 8 | const ViewProfile = require('./../ViewProfile'); 9 | const imagePack = require('../ImagePack'); 10 | const sounds = require('../Sounds'); 11 | const i18n = require('./../I18n')(); 12 | 13 | /** 14 | * This class implements the functionality for chatting 15 | */ 16 | class Chat { 17 | /** This is the constructor (note the singleton implementation) */ 18 | constructor() { 19 | if (Chat._instance) { 20 | return Chat._instance; 21 | } 22 | Chat._instance = this; 23 | Chat._instance.initialize(); 24 | } 25 | 26 | /** 27 | * Initialize messenger 28 | */ 29 | initialize() { 30 | this.sidebarLiIdPrefix = 'sidebar_li_'; 31 | this.activeChat = ''; // to know which chat is active in the view 32 | 33 | dbMessenger.addMessageReceiveCallbackFunction( 34 | this.receiveMessageCallback.bind(this)); 35 | 36 | // Message types; like enum 37 | this.messageType = { 38 | sent: 'sent', 39 | received: 'received', 40 | }; 41 | 42 | this.messageIdentifierLog = []; 43 | this.numMessagesEncountered = 0; 44 | this.maxLogSize = 20; 45 | } 46 | 47 | /** 48 | * This function renders the template into the UI. 49 | */ 50 | async render() { 51 | await Layout.render(); 52 | EditProfile.renderToIconBar(this.render.bind(this)); 53 | 54 | // Get the stats for emoji counts 55 | const emojiCounterMap = 56 | await dbMessenger.getPreference('emojiCounterMap'); 57 | if (typeof(emojiCounterMap) === 'undefined') { 58 | this.emojiCounterMap = {}; 59 | } else { 60 | this.emojiCounterMap = emojiCounterMap.value; 61 | } 62 | 63 | // Link css 64 | const link = document.createElement('link'); 65 | link.rel = 'stylesheet'; 66 | link.href = __dirname + '/style.css'; 67 | document.body.appendChild(link); 68 | 69 | // Ensure that the CSS is loaded before the HTML is 70 | link.addEventListener('load', function() { 71 | this.loadSidebar(); 72 | this.loadMainContent(); 73 | }.bind(this)); 74 | 75 | this.activeChat = ''; 76 | } 77 | 78 | /** 79 | * To check if the chat module is active in the view. 80 | * @return {boolean} whether chat module is active in the view or not 81 | */ 82 | isActiveInView() { 83 | // TODO: Need to find a better way of doing this 84 | return document.getElementById('friendsList') !== null; 85 | } 86 | 87 | /** 88 | * Load the sidebar 89 | */ 90 | loadSidebar() { 91 | const sidebarDOMNode = document.getElementById('sidebar'); 92 | sidebarDOMNode.innerHTML += eval('`' + 93 | requireText('./template_sidebar.html', require) + '`'); 94 | 95 | sidebarDOMNode.querySelector('#addFriendButton').onclick = 96 | this.addFriendCallback.bind(this); 97 | sidebarDOMNode.querySelector('#searchInput').onkeyup = this.search; 98 | 99 | this.loadFriendsInSidebar(); 100 | } 101 | 102 | /** 103 | * Load friends in the sidebar from the database 104 | */ 105 | async loadFriendsInSidebar() { 106 | const ul = document.getElementById('friendsList'); 107 | ul.innerHTML = ''; // Clear everything 108 | 109 | // Load friends from the DB 110 | this.allFriends = await dbMessenger.getAllFriends(); 111 | 112 | // Sort 'allFriends' by 'lastMessageTimestamp' 113 | this.allFriends.sort(function(a, b) { 114 | return b.lastMessageTimestamp - a.lastMessageTimestamp; 115 | }); 116 | 117 | for (let i = 0; i < this.allFriends.length; i++) { 118 | const li = document.createElement('li'); 119 | li.id = this.sidebarLiIdPrefix + this.allFriends[i].key; 120 | li.innerHTML = ` 121 |
124 |
${this.allFriends[i].displayName}
125 | `; 126 | li.onclick = this.loadChat(this.allFriends[i]); 127 | ul.appendChild(li); 128 | 129 | if (!this.allFriends[i].isRead) { 130 | this.doNotify(this.allFriends[i].key); 131 | } 132 | } 133 | } 134 | 135 | /** 136 | * Load the main content 137 | */ 138 | loadMainContent() { 139 | const mainContentDOMNode = document.getElementById('mainContent'); 140 | mainContentDOMNode.innerHTML += eval('`' + 141 | requireText('./template_mainContent.html', require) + '`'); 142 | } 143 | 144 | /** 145 | * Load the chat in the main content 146 | * @param {Object} friend information about a friend stored in the database 147 | * @return {function} callback function that attempts to fetch data from 148 | * the database 149 | */ 150 | loadChat(friend) { 151 | return async function() { 152 | const messages = await dbMessenger.getAllMessages(friend.key); 153 | const messageSenderInfo = 154 | document.getElementById('messageSenderInfo'); 155 | messageSenderInfo.innerHTML = ` 156 |
159 |
${friend.displayName}
160 | 161 | 162 | `; 163 | messageSenderInfo.onclick = function() { 164 | ViewProfile.render(this.render.bind(this), friend); 165 | }.bind(this); 166 | 167 | const messageReader = 168 | document.getElementById('messageReader'); 169 | messageReader.innerHTML = ''; // Clear existing messages 170 | for (let i = 0; i < messages.length; i++) { 171 | this.insertChatMessageToDOM(messages[i].message); 172 | } 173 | 174 | const messageComposer = document.getElementById('messageComposer'); 175 | const messageTextArea = 176 | messageComposer.getElementsByTagName('textarea')[0]; 177 | const messageSendButton = 178 | messageComposer.getElementsByClassName('send')[0]; 179 | messageTextArea.onkeydown = function(event) { 180 | if (event.key === 'Enter' && !event.shiftKey) { 181 | event.preventDefault(); 182 | this.sendMessage(friend.key, messageTextArea); 183 | } 184 | }.bind(this); 185 | messageSendButton.onclick = function(event) { 186 | this.sendMessage(friend.key, messageTextArea); 187 | }.bind(this); 188 | 189 | // Display the message text area; also clear it if it already has 190 | // some content 191 | messageComposer.style.display = 'flex'; 192 | messageComposer.getElementsByTagName('textarea')[0].value = ''; 193 | 194 | // For emojis 195 | const messageEmojis = document.getElementById('messageEmojis'); 196 | messageEmojis.style.display = 'flex'; 197 | messageEmojis.onclick = function(e) { 198 | if (e.target.nodeName === 'SPAN') { 199 | // Insert emoji into textarea 200 | const emoji = e.target.textContent; 201 | const start = messageTextArea.selectionStart; 202 | const end = messageTextArea.selectionEnd; 203 | messageTextArea.value = 204 | messageTextArea.value.substring(0, start) + 205 | emoji + 206 | messageTextArea.value.substring(end); 207 | 208 | // Ensure that the cursor is in the right position 209 | messageTextArea.focus(); 210 | messageTextArea.selectionStart = start + emoji.length; 211 | messageTextArea.selectionEnd = start + emoji.length; 212 | 213 | // Increment the emoji counter 214 | if (emoji in this.emojiCounterMap) { 215 | this.emojiCounterMap[emoji]++; 216 | } else { 217 | this.emojiCounterMap[emoji] = 1; 218 | } 219 | dbMessenger.setPreference( 220 | 'emojiCounterMap', this.emojiCounterMap, 221 | ); 222 | } 223 | }.bind(this); 224 | 225 | // Update the active chat 226 | this.activeChat = friend.key; 227 | 228 | // Switch the notification 229 | if (!friend.isRead) { 230 | this.unDoNotify(friend.key); 231 | } 232 | }.bind(this); 233 | } 234 | 235 | /** 236 | * Insert the chat message into a DOM (to view in the frontend) 237 | * @param {Object} message the message and information associated with it 238 | */ 239 | insertChatMessageToDOM(message) { 240 | const messageReader = document.getElementById('messageReader'); 241 | const div = document.createElement('div'); 242 | div.innerText = message.message; 243 | div.className = ((message.type == this.messageType.sent) ? 244 | 'sentMessage' : 'receivedMessage'); 245 | 246 | // Show the time at which the message arrived 247 | const span = document.createElement('span'); 248 | const date = new Date(message.timestamp); 249 | span.innerHTML = date.toDateString() + '
' + 250 | i18n.getText('Chat.at') + ' ' + date.toLocaleTimeString(); 251 | div.appendChild(span); 252 | 253 | messageReader.prepend(div); 254 | } 255 | 256 | /** 257 | * Get the profile pic 258 | * @param {string} photo either an empty string or a string that contains 259 | * the entire image (base64 encoded image, data URL) 260 | * @return {string} an image that can be used as CSS background URL 261 | */ 262 | getProfilePic(photo) { 263 | if (photo) { 264 | return photo; 265 | } else { 266 | return imagePack.getPath('interface.defaultProfilePic'); 267 | } 268 | } 269 | 270 | /** 271 | * Callback function when you press "Add Friend" vutton 272 | */ 273 | addFriendCallback() { 274 | addFriend().render(this.render.bind(this)); 275 | 276 | // Update the active chat 277 | this.activeChat = ''; 278 | } 279 | 280 | /** 281 | * Callback function when you search in the sidebar 282 | */ 283 | search() { 284 | const filter = this.value.toUpperCase(); 285 | const ul = document.getElementById('friendsList'); 286 | const li = ul.getElementsByTagName('li'); 287 | for (let i = 0; i < li.length; i++) { 288 | const txtValue = li[i].innerText; 289 | if (txtValue.toUpperCase().indexOf(filter) > -1) { 290 | li[i].style.display = ''; 291 | } else { 292 | li[i].style.display = 'none'; 293 | } 294 | } 295 | } 296 | 297 | /** 298 | * Send message 299 | * @param {string} userKey 300 | * @param {object} messageTextArea DOM element for the textarea where the 301 | * message is composed 302 | */ 303 | sendMessage(userKey, messageTextArea) { 304 | const textAreaValue = messageTextArea.value.trim(); 305 | if (textAreaValue !== '') { 306 | const message = { 307 | timestamp: Date.now(), 308 | type: this.messageType.sent, 309 | message: textAreaValue, 310 | }; 311 | this.insertChatMessageToDOM(message); 312 | dbMessenger.sendChatMessage(userKey, message); 313 | messageTextArea.value = ''; 314 | } 315 | } 316 | 317 | /** 318 | * Activate notification 319 | * @param {string} userKey 320 | */ 321 | doNotify(userKey) { 322 | const id = this.sidebarLiIdPrefix + userKey; 323 | document.getElementById(id).classList.add('notification'); 324 | } 325 | /** 326 | * De-activate notification 327 | * @param {string} userKey 328 | */ 329 | unDoNotify(userKey) { 330 | const id = this.sidebarLiIdPrefix + userKey; 331 | document.getElementById(id).classList.remove('notification'); 332 | dbMessenger.markChatRead(userKey); 333 | } 334 | 335 | /** 336 | * Checks if the incoming message identifier is already present in the log. 337 | * If it is, then we return true. If it is not, we store the new message 338 | * identifier. 339 | * @param {string} messageIdentifier the identifier for the message. We use 340 | * a concatenation of sender's public key and message timestamp for this. 341 | * @return {boolean} returns true if the identifier was previously present 342 | * in the log. 343 | */ 344 | isMessageInIdentifierLog(messageIdentifier) { 345 | const ret = this.messageIdentifierLog.includes(messageIdentifier); 346 | if (!ret) { 347 | this.messageIdentifierLog[ 348 | this.numMessagesEncountered++ % this.maxLogSize 349 | ] = messageIdentifier; 350 | } 351 | return ret; 352 | } 353 | 354 | /** 355 | * Receive messages from different channels as callback 356 | * @param {Object} message message received from DBMessenger 357 | */ 358 | async receiveMessageCallback(message) { 359 | if (message.message.type === dbMessenger.messageType.chat) { 360 | // If the new message being received is present in this log, 361 | // then do not insert. 362 | if (this.isMessageInIdentifierLog( 363 | message.senderPublicKey + message.message.message.timestamp, 364 | )) { 365 | return; 366 | } 367 | 368 | // Add it to the database 369 | message.message.message.type = this.messageType.received; 370 | dbMessenger.receivedChatMessage(message); 371 | 372 | // Before rendering, confirm if the chat module is active in the 373 | // view. If so, check if the chat that is active is the same as 374 | // that of the incoming message. 375 | if (this.isActiveInView()) { 376 | if (message.senderPublicKey === this.activeChat) { 377 | this.insertChatMessageToDOM(message.message.message); 378 | // Remove the notification if it's already there 379 | this.unDoNotify(message.senderPublicKey); 380 | } else { 381 | // Notify 382 | this.doNotify(message.senderPublicKey); 383 | } 384 | } 385 | 386 | // Play sound for alert 387 | sounds().messageReceivedSound(); 388 | console.log(Notification); 389 | const friend = this.allFriends.find(function(friend) { 390 | return friend.key == message.senderPublicKey; 391 | }); 392 | if (Notification.permission !== 'denied') { 393 | const permission = await Notification.requestPermission(); 394 | if (permission === 'granted') { 395 | const title = friend.displayName + ' sent you a message.'; 396 | const body = message.message.message.message; 397 | const icon = this.getProfilePic(friend.photo); 398 | new Notification(title, { 399 | body: body, 400 | icon: icon, 401 | }); 402 | } 403 | } 404 | } else if (message.message.type === 405 | dbMessenger.messageType.userInfoResponse) { 406 | // Reload the sidebar 407 | await this.loadFriendsInSidebar(); 408 | } 409 | } 410 | 411 | /** 412 | * Help load emoticons in the template 413 | * @return {string} content for HTML template to render emojis 414 | */ 415 | loadEmoticonsHTML() { 416 | /** 417 | * Each character in the range is returned inside a span tag 418 | * @param {number} startRange start of range of characters 419 | * @param {number} endRange end of range of characters 420 | * @return {string} HTML content in string format 421 | */ 422 | function returnCharactersInRangeAsSpan(startRange, endRange) { 423 | let ret = ''; 424 | for (let i = startRange; i <= endRange; i++) { 425 | ret += '&#' + i + ';'; 426 | } 427 | return ret; 428 | } 429 | 430 | let ret = ''; 431 | 432 | // Get the first five most frequently used emojis 433 | const obj = this.emojiCounterMap; 434 | const frequentlyUsed = Object.keys(obj).sort((a, b) => obj[b] - obj[a]) 435 | .slice(0, 5); 436 | 437 | frequentlyUsed.forEach(function(emoji) { 438 | ret += '' + emoji + ''; 439 | }); 440 | 441 | // Add a separator 442 | ret += '⋮'; 443 | 444 | // Get all emojis 445 | ret += returnCharactersInRangeAsSpan(128512, 128591); 446 | ret += returnCharactersInRangeAsSpan(9984, 10175); 447 | ret += returnCharactersInRangeAsSpan(127744, 128511); 448 | ret += returnCharactersInRangeAsSpan(128640, 128767); 449 | ret += returnCharactersInRangeAsSpan(127462, 127487); 450 | 451 | return ret; 452 | } 453 | } 454 | 455 | module.exports = function() { 456 | return new Chat(); 457 | }; 458 | -------------------------------------------------------------------------------- /modules/DBMessenger/IntranetMessenger.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2023-2024 Zenin Easa Panthakkalakath */ 2 | 3 | const os = require('os'); 4 | const ip = require('ip'); 5 | const http = require('http'); 6 | const arp = require('arptable-js'); 7 | const ping = require('ping'); 8 | const range = require('ipv4-range'); 9 | 10 | const utils = require('./utils'); 11 | const dbWrapper = require('./DBWrapper'); 12 | 13 | // Largest possible port number is 65535. Choose a number lower than that, but 14 | // not potentially used by other applications. 15 | const preferedServerPort = 43946; 16 | const maxTryDiffPorts = 10; 17 | 18 | /** 19 | * This is a singleton class. 20 | * The responsibility of this class are: 21 | * 1. Find machines in the local network (intranet) that are running HexHoot. 22 | * 2. Establish communication with these devices. 23 | */ 24 | class IntranetMessenger { 25 | /** This is the constructor (note the singleton implementation) */ 26 | constructor() { 27 | if (IntranetMessenger._instance) { 28 | return IntranetMessenger._instance; 29 | } 30 | IntranetMessenger._instance = this; 31 | 32 | this.listOfChannelsSubscribedTo = []; 33 | this.messageReceiveCallbackFunction = function() {}; 34 | 35 | IntranetMessenger._instance.initialize(); 36 | } 37 | 38 | /** 39 | * Initialize IntranetMessenger 40 | */ 41 | async initialize() { 42 | console.log('Initializing intranet messenger'); 43 | this.ipAddresses = []; 44 | this.hostsWithHexHootMap = {}; 45 | 46 | this.findIPAddressesAssignedToThisDevice(); 47 | this.startServer(preferedServerPort); 48 | this.findDevicesRunningHexHoot(); 49 | 50 | this.userInfo = (await dbWrapper().getAll('LoggedInUserInfo'))[0]; 51 | if (this.userInfo) { 52 | this.userPublicKey = 53 | utils.getPublicKeyFromPrivateKey(this.userInfo.privateKey); 54 | this.subscribeToChannel( 55 | utils.stringToBuffer(this.userPublicKey), true); 56 | } 57 | } 58 | 59 | /** 60 | * Cleanup IntranetMessenger 61 | */ 62 | async cleanup() { 63 | console.log('Cleaning up intranet messenger'); 64 | await this.stopServer(); 65 | } 66 | 67 | /** 68 | * Re-initialize IntranetMessenger 69 | */ 70 | async reInitialize() { 71 | await IntranetMessenger._instance.cleanup(); 72 | await IntranetMessenger._instance.initialize(); 73 | } 74 | 75 | /** 76 | * Find IP Addresses assigned to this device and store it all in the 77 | * variable named 'this.ipAddresses'. 78 | */ 79 | findIPAddressesAssignedToThisDevice() { 80 | // Find the IP Addresses that the network interfaces have been assigned 81 | const net = os.networkInterfaces(); 82 | Object.values(net).forEach(function(netInterface) { 83 | netInterface.forEach(function(info) { 84 | if ( 85 | info.address.startsWith('192.') || 86 | info.address.startsWith('172.') || 87 | info.address.startsWith('10.') 88 | ) { 89 | this.ipAddresses.push(info.address); 90 | } 91 | }.bind(this)); 92 | }.bind(this)); 93 | } 94 | 95 | /** 96 | * Information about this HexHoot instance 97 | * @return {Object} Information about this HexHoot instance 98 | */ 99 | getInformationAboutSelf() { 100 | const info = {}; 101 | info.application = process.env.npm_package_name; 102 | info.version = process.env.npm_package_version; 103 | info.ip = ip.address(); 104 | info.ips = this.ipAddresses; 105 | info.port = this.port; 106 | info.hostsWithHexHootMap = this.hostsWithHexHootMap; 107 | info.listOfChannelsSubscribedTo = this.listOfChannelsSubscribedTo; 108 | return info; 109 | } 110 | 111 | /** 112 | * Start the HTTP server. 113 | * This gets invoked recursively if the port is not free. The port number 114 | * is increased by one in each successive recursive call. 115 | * @param {number} port the port in which we would like to start the server 116 | */ 117 | startServer(port) { 118 | this.server = http.createServer(function(req, res) { 119 | if (req.url === '/') { 120 | if (req.method === 'GET') { 121 | const response = this.getInformationAboutSelf(); 122 | res.writeHead(200, {'Content-Type': 'application/json'}); 123 | res.write(JSON.stringify(response)); 124 | res.end(); 125 | } else if (req.method === 'POST') { 126 | let body = ''; 127 | req.on('data', function(chunk) { 128 | body += chunk.toString(); 129 | }); 130 | req.on('end', function() { 131 | const data = JSON.parse(body); 132 | 133 | // Assert that the data response has the same URL 134 | // information as the URL we sent to 135 | data.ip = this.assertIPInArrayAndReturn( 136 | req.connection.remoteAddress, data.ips, 137 | ); 138 | 139 | // Extract information about this 140 | this.saveInfoAboutPeers(data); 141 | 142 | res.end('ok'); 143 | }.bind(this)); 144 | } 145 | } else if (req.url === '/subscribeChannels') { 146 | if (req.method === 'POST') { 147 | let body = ''; 148 | req.on('data', function(chunk) { 149 | body += chunk.toString(); 150 | }); 151 | req.on('end', function() { 152 | const data = JSON.parse(body); 153 | 154 | // Assert that the data response has the same URL 155 | // information as the URL we sent to 156 | data.ip = this.assertIPInArrayAndReturn( 157 | req.connection.remoteAddress, data.ips, 158 | ); 159 | 160 | // Add the channel to the peer 161 | this.addChannelToPeer(data.ip, data.channelNames); 162 | 163 | res.end('ok'); 164 | }.bind(this)); 165 | } 166 | } else if (req.url === '/message') { 167 | if (req.method === 'POST') { 168 | let body = ''; 169 | req.on('data', function(chunk) { 170 | body += chunk.toString(); 171 | }); 172 | req.on('end', function() { 173 | this.messageReceivedCallback(body); 174 | res.end('ok'); 175 | }.bind(this)); 176 | } 177 | } else { 178 | console.log('Unrecognized request: ' + req.url); 179 | } 180 | }.bind(this)); 181 | 182 | this.server.on('error', function(err) { 183 | if (err.code === 'EADDRINUSE') { 184 | console.log(`Port ${port} is already in use`); 185 | if (port < preferedServerPort + maxTryDiffPorts) { 186 | this.startServer(port + 1); 187 | } else { 188 | alert('Error: No server ports available'); 189 | } 190 | } else { 191 | console.log(err); 192 | } 193 | }.bind(this)); 194 | 195 | this.server.on('listening', function() { 196 | this.port = port; 197 | console.log(`Server: http://${ip.address()}:${port}`); 198 | }.bind(this)); 199 | 200 | this.server.listen(port); 201 | } 202 | 203 | /** 204 | * Stop the server 205 | */ 206 | async stopServer() { 207 | return new Promise(function(resolve, reject) { 208 | this.server.close(function() { 209 | resolve('Server closed'); 210 | }); 211 | }.bind(this)); 212 | } 213 | 214 | /** 215 | * Find all devices that run HexHoot. 216 | */ 217 | async findDevicesRunningHexHoot() { 218 | /** 219 | * Ensure that the server has started before attempting to find other 220 | * devices. If this server is not running, then some of the requests in 221 | * response would be missed. 222 | * @param {Object} server 223 | * @return {Promise} 224 | */ 225 | function ensureServerRunning(server) { 226 | return new Promise(function(resolve, reject) { 227 | if (server.listening) { 228 | resolve(); 229 | } else { 230 | setTimeout(() => { 231 | ensureServerRunning(server) 232 | .then(() => resolve()) 233 | .catch((err) => reject(err)); 234 | }, 50); 235 | } 236 | }); 237 | } 238 | await ensureServerRunning(this.server); 239 | 240 | /** 241 | * Fetch through different ports that HexHoot can take to see if the 242 | * given address has HexHoot runnings. 243 | * @param {string} address of the remove device 244 | */ 245 | const fetchViaDifferentPorts = function(address) { 246 | for (let i = 0; i < maxTryDiffPorts; i++) { 247 | this.fetchInfo( 248 | `http://${address}:${preferedServerPort + i}`); 249 | } 250 | }.bind(this); 251 | 252 | 253 | // Ping around the IP addresses that this device is assigned with 254 | this.ipAddresses.forEach(async function(ip) { 255 | // Ping around this ip address 256 | const addresses = range(ip, 255); 257 | addresses.push(ip); 258 | addresses.forEach(async function(address) { 259 | const res = await ping.promise.probe( 260 | address, {timeout: 100, min_reply: 1}); 261 | if (res.alive) { 262 | console.log('Alive: ' + address); 263 | fetchViaDifferentPorts(address); 264 | } 265 | }); 266 | }); 267 | 268 | // Additionally, let's see if the ARP Table can come up with other 269 | // connections. 270 | arp.get(function(table) { 271 | table.forEach(function(row) { 272 | if (row.InternetAddress !== '?') { 273 | // Remove the enclosing brackets along with the address 274 | const address = row.PhysicalAddress 275 | .substring(1, row.PhysicalAddress.length - 1); 276 | 277 | fetchViaDifferentPorts(address); 278 | } 279 | }); 280 | }); 281 | } 282 | 283 | /** 284 | * Process and save the information about an instance of HexHoot running 285 | * on another computer. 286 | * @param {Object} info information other HexHoot instance 287 | */ 288 | saveInfoAboutPeers(info) { 289 | console.log('Saving info about peer: ' + JSON.stringify(info)); 290 | // Extract information on other hosts with HexHoot 291 | const otherhostsWithHexHootMap = info.hostsWithHexHootMap; 292 | 293 | // Remove the other hosts information and store the rest 294 | delete info.hostsWithHexHootMap; 295 | this.hostsWithHexHootMap[info.ip] = info; 296 | 297 | // Collect the latest information from the other hosts; not 298 | // just copy over the information. 299 | Object.keys(otherhostsWithHexHootMap).forEach(function(ip) { 300 | if (!ip in this.hostsWithHexHootMap) { 301 | this.fetchInfo(this.getURLFromIP(ip)); 302 | } 303 | }.bind(this)); 304 | } 305 | 306 | /** 307 | * Get URL from IP address 308 | * @param {string} ip ip address 309 | * @return {string} url to the corresponding server instance 310 | */ 311 | getURLFromIP(ip) { 312 | const info = this.hostsWithHexHootMap[ip]; 313 | return `http://${info.ip}:${info.port}`; 314 | } 315 | 316 | /** 317 | * Add new channels to a peer. 318 | * @param {string} ip ip address of the peer 319 | * @param {Array} channelNames Name of channels that the peer would like to 320 | * subscribe to 321 | */ 322 | addChannelToPeer(ip, channelNames) { 323 | channelNames.forEach(function(channelNameStr) { 324 | if (!this.hostsWithHexHootMap[ip].listOfChannelsSubscribedTo 325 | .includes(channelNameStr)) { 326 | this.hostsWithHexHootMap[ip].listOfChannelsSubscribedTo.push( 327 | channelNameStr); 328 | } 329 | }.bind(this)); 330 | } 331 | 332 | /** 333 | * Check if the URL has HexHoot; if yes, get information from that. 334 | * Furthermore, it may have already found other URLs where HexHoot is 335 | * running. Get information on that as well. 336 | * @param {string} url the full url to the server instance (would contain 337 | * 'http://', hostname and port) 338 | */ 339 | fetchInfo(url) { 340 | http.get(url, function(response) { 341 | let data = ''; 342 | 343 | // A chunk of data has been received. 344 | response.on('data', function(chunk) { 345 | data += chunk; 346 | }); 347 | 348 | // The whole response has been received. 349 | response.on('end', function() { 350 | try { 351 | data = JSON.parse(data); 352 | } catch (err) { 353 | console.log('Invalid the response:'); 354 | console.log(data); 355 | return; 356 | } 357 | if (data.application === 'hexhoot') { 358 | // Assert that the data response has the same URL 359 | // information as the URL we sent to 360 | data.ip = this.assertIPInArrayAndReturn( 361 | url, data.ips, 362 | ); 363 | 364 | // Extract information about this 365 | this.saveInfoAboutPeers(data); 366 | 367 | // Send back information about this instance 368 | const info = JSON.stringify( 369 | this.getInformationAboutSelf()); 370 | this.sendMessage(url, info); 371 | } 372 | }.bind(this)); 373 | }.bind(this)).on('error', function(err) { 374 | // Let's ignore the error. The error would be most 375 | // likely due to inexistence of HexHoot in the device 376 | // being pinged. 377 | }); 378 | } 379 | 380 | /** 381 | * Ensure that the sender IP address exist in the given array of IPs 382 | * @param {string} url sender IP extracted through request metadata 383 | * @param {Array} ipAddresses array of IP addresses passed thourhg the 384 | * request message 385 | * @return {string} url, if it exists in the list 386 | */ 387 | assertIPInArrayAndReturn(url, ipAddresses) { 388 | const parsedURL = require('url').parse(url); 389 | let hostname = ''; 390 | if (parsedURL.hostname === null) { 391 | // Example: url = '::ffff:172.16.29.1' 392 | hostname = url.slice(url.lastIndexOf(':') + 1); 393 | } else { 394 | // Example: url = 'http://172.16.29.1' 395 | hostname = parsedURL.hostname; 396 | } 397 | 398 | if (!ipAddresses.includes(hostname)) { 399 | throw new Error('Sender IP doesn\'t match the data provided'); 400 | } 401 | return hostname; 402 | } 403 | 404 | /** 405 | * Send post request to a particular url 406 | * @param {string} url the full url to the server instance (would contain 407 | * 'http://', hostname and port) 408 | * @param {string} message the message that needs to be sent 409 | */ 410 | sendMessage(url, message) { 411 | const parsedURL = require('url').parse(url); 412 | const req = http.request({ 413 | hostname: parsedURL.hostname, 414 | port: parsedURL.port, 415 | path: parsedURL.pathname, 416 | method: 'POST', 417 | headers: { 418 | 'Content-Type': 'application/json', 419 | 'Content-Length': Buffer.byteLength(message), 420 | }, 421 | }, function(res) { 422 | res.resume(); 423 | res.on('end', function() { 424 | if (!res.complete) { 425 | console.error('Connection terminated before completion'); 426 | } 427 | }); 428 | }); 429 | req.write(message); 430 | req.end(); 431 | } 432 | 433 | /** 434 | * What to do when a channel receives a message 435 | * message arrives. 436 | * @param {*} message The message that was received in the channel 437 | */ 438 | messageReceivedCallback(message) { 439 | const messageObj = JSON.parse(message); 440 | 441 | const sharedKeyString = utils.getSharedKey( 442 | this.userInfo.privateKey, messageObj.senderPublicKey); 443 | 444 | messageObj.iv = Buffer.from(messageObj.iv); // Ensuring the type 445 | messageObj.message = JSON.parse(utils.decryptMessage( 446 | messageObj.message, sharedKeyString, messageObj.iv)); 447 | 448 | console.log('Message received (intranet):'); 449 | console.log(messageObj); 450 | 451 | // Send this information to DBMessenger 452 | this.messageReceiveCallbackFunction(messageObj); 453 | } 454 | 455 | /** 456 | * Send message to all known devices stating that you are subscribing to 457 | * this channel. 458 | * @param {string} channelName name of the channel 459 | */ 460 | async subscribeToChannel(channelName) { 461 | let channelNameStr = ''; 462 | if (typeof(channelName) === 'string') { 463 | channelNameStr = channelName; 464 | channelName = utils.stringToBuffer(channelName); 465 | } else { 466 | channelNameStr = utils.bufferToString(channelName); 467 | } 468 | if (this.listOfChannelsSubscribedTo.includes(channelNameStr)) { 469 | // Already subscribed to the channel 470 | console.log('Already subscribed to the channel'); 471 | return; 472 | } 473 | 474 | // Let every HexHoot instance know that this instance is subscribed to 475 | // this channel 476 | let message = {}; 477 | message.ip = ip.address(); 478 | message.ips = this.ipAddresses; 479 | message.channelNames = [channelNameStr]; 480 | message = JSON.stringify(message); 481 | Object.keys(this.hostsWithHexHootMap).forEach(function(ip) { 482 | this.sendMessage( 483 | this.getURLFromIP(ip) + '/subscribeChannels', 484 | message, 485 | ); 486 | }.bind(this)); 487 | 488 | console.log('Subscribed to channel (intranet): ' + channelNameStr); 489 | this.listOfChannelsSubscribedTo.push(channelNameStr); 490 | } 491 | 492 | /** 493 | * Send a message to a channel 494 | * @param {Buffer} channelName is also the other user's public key 495 | * @param {string} message 496 | */ 497 | sendMessageToChannel(channelName, message) { 498 | let channelNameStr = ''; 499 | if (typeof(channelName) === 'string') { 500 | channelNameStr = channelName; 501 | channelName = utils.stringToBuffer(channelName); 502 | } else { 503 | channelNameStr = utils.bufferToString(channelName); 504 | } 505 | 506 | // Encrypt the message using the shared key 507 | const sharedKeyString = 508 | utils.getSharedKey(this.userInfo.privateKey, channelName); 509 | 510 | let iv = []; 511 | [message, iv] = utils.encryptMessage( 512 | JSON.stringify(message), sharedKeyString); 513 | 514 | // Add sender and 'iv' informations to the message, which are needed to 515 | // decrypt the message 516 | message = JSON.stringify({ 517 | 'senderPublicKey': this.userPublicKey, 518 | 'iv': iv, 519 | 'message': message, 520 | }); 521 | 522 | console.log('Sending message to channel (intranet): ' + 523 | channelNameStr); 524 | 525 | for (const [ip, info] of Object.entries(this.hostsWithHexHootMap)) { 526 | if (info.listOfChannelsSubscribedTo.includes(channelNameStr)) { 527 | this.sendMessage( 528 | this.getURLFromIP(ip) + '/message', 529 | message, 530 | ); 531 | } 532 | } 533 | } 534 | 535 | /** 536 | * Set callback function which gets triggered when new messages arrive in 537 | * subscribed channels. 538 | * @param {function} func callback function that gets invoked when a new 539 | * new message is received 540 | */ 541 | setMessageReceiveCallbackFunction(func) { 542 | this.messageReceiveCallbackFunction = func; 543 | } 544 | } 545 | 546 | module.exports = function() { 547 | return new IntranetMessenger(); 548 | }; 549 | --------------------------------------------------------------------------------