├── .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 |
3 | Copyright © 2021-2024 Zenin Easa Panthakkalakath
4 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
21 |
--------------------------------------------------------------------------------
/modules/ImagePack/images/icon_lightmode.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------