Add client and server source to monorepo (#65)

This commit is contained in:
Alan Friedman 2019-05-13 09:39:17 -04:00 committed by GitHub
parent 8655983a3c
commit 4e038ec655
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
93 changed files with 21467 additions and 22 deletions

36
.circleci/config.yml Normal file
View File

@ -0,0 +1,36 @@
# Javascript Node CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-javascript/ for more details
#
version: 2
jobs:
build:
docker:
# specify the version you desire here
- image: circleci/node:8.10
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/
# - image: circleci/mongo:3.4.4
working_directory: ~/repo
steps:
- checkout
# Download and cache dependencies
- restore_cache:
keys:
- v1-dependencies-{{ checksum "yarn.lock" }}
# fallback to using the latest cache if no exact match is found
- v1-dependencies-
- run: yarn install
- save_cache:
paths:
- node_modules
key: v1-dependencies-{{ checksum "yarn.lock" }}
- run: yarn test

2
.gitignore vendored
View File

@ -1,5 +1,3 @@
.DS_Store
server
client
node_modules
*.log

View File

@ -1,5 +1,3 @@
yarn setup
echo "building client..."
cd client
yarn --production=false

4
client/.env.example Normal file
View File

@ -0,0 +1,4 @@
REACT_APP_API_HOST=localhost
REACT_APP_API_PROTOCOL=http
REACT_APP_API_PORT=3001
REACT_APP_COMMIT_SHA=some_sha

9
client/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules
.DS_Store
dist
coverage
*.log
.env*
!.env.example
build/
*sublime*

21
client/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016-present darkwire.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
client/README.md Normal file
View File

@ -0,0 +1,3 @@
# Darkwire Client
This is the client for [Darkwire](https://github.com/darkwire/darkwire.io). It requires [darkwire-server](../server) in order to run.

View File

@ -0,0 +1 @@
module.exports = {};

8
client/jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@/*":["src/*"]
}
}
}

69
client/package.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "darkwire-client",
"version": "2.0.0-beta.12",
"main": "index.js",
"contributors": [
{
"name": "Daniel Seripap"
},
{
"name": "Alan Friedman"
}
],
"license": "MIT",
"dependencies": {
"autosize": "^4.0.2",
"bootstrap": "^4.3.1",
"clipboard": "^2.0.4",
"feather-icons": "^4.21.0",
"jquery": "^3.4.1",
"lodash": "^4.17.11",
"moment": "^2.24.0",
"node-sass": "^4.12.0",
"popper.js": "^1.15.0",
"query-string": "^6.5.0",
"randomcolor": "^0.5.4",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-feather": "^1.1.6",
"react-linkify": "^0.2.2",
"react-modal": "^3.8.1",
"react-redux": "^7.0.3",
"react-router-dom": "^5.0.0",
"react-scripts": "3.0.0",
"react-simple-dropdown": "^3.2.3",
"redux": "^4.0.1",
"redux-thunk": "^2.3.0",
"sanitize-html": "^1.20.1",
"shortid": "^2.2.14",
"socket.io-client": "^2.2.0",
"tinycon": "^0.6.8",
"webcrypto-shim": "^0.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "REACT_APP_COMMIT_SHA=`git rev-parse --short HEAD` react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.12.1",
"enzyme-to-json": "^3.3.5"
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

41
client/public/index.html Normal file
View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en" class='h-100'>
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<meta name="robots" content="nofollow">
<meta name="description" content="darkwire.io is the simplest way to chat with encryption online.">
<title>darkwire.io - instant encrypted web chat</title>
</head>
<body class='h-100'>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="h-100"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "Darkwire",
"name": "Darkwire.io - encrypted web chat",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

20
client/src/actions/app.js Normal file
View File

@ -0,0 +1,20 @@
export const openModal = payload => ({ type: 'OPEN_MODAL', payload })
export const closeModal = () => ({ type: 'CLOSE_MODAL' })
export const setScrolledToBottom = payload => ({ type: 'SET_SCROLLED_TO_BOTTOM', payload })
export const showNotice = payload => async (dispatch) => {
dispatch({ type: 'SHOW_NOTICE', payload })
}
export const toggleWindowFocus = payload => async (dispatch) => {
dispatch({ type: 'TOGGLE_WINDOW_FOCUS', payload })
}
export const toggleSoundEnabled = payload => async (dispatch) => {
dispatch({ type: 'TOGGLE_SOUND_ENABLED', payload })
}
export const toggleSocketConnected = payload => async (dispatch) => {
dispatch({ type: 'TOGGLE_SOCKET_CONNECTED', payload })
}

View File

@ -0,0 +1,12 @@
const methodMap = {
GET: '',
POST: 'CREATE_',
PUT: 'UPDATE_',
DELETE: 'DELETE_',
}
export const fetchStart = (name, method, resourceId, meta) => ({ type: `FETCH_${methodMap[method]}${name.toUpperCase()}_START`, payload: { resourceId, meta } })
export const fetchSuccess = (name, method, response) => ({ type: `FETCH_${methodMap[method]}${name.toUpperCase()}_SUCCESS`, payload: response })
export const fetchFailure = (name, method, response) => ({ type: `FETCH_${methodMap[method]}${name.toUpperCase()}_FAILURE`, payload: response })

View File

@ -0,0 +1,4 @@
export * from './fetch'
export * from './room'
export * from './app'

View File

@ -0,0 +1,98 @@
import fetch from 'api'
import isEqual from 'lodash/isEqual'
import {
process as processMessage,
prepare as prepareMessage,
} from 'utils/message'
import { getIO } from 'utils/socket'
export const createRoom = id => async dispatch => fetch({
resourceName: 'handshake',
method: 'POST',
body: {
roomId: id,
},
}, dispatch, 'handshake')
export const receiveSocketMessage = payload => async (dispatch, getState) => {
const state = getState()
const message = await processMessage(payload, state)
// Pass current state to all HANDLE_SOCKET_MESSAGE reducers for convenience, since each may have different needs
dispatch({ type: `HANDLE_SOCKET_MESSAGE_${message.type}`, payload: { payload: message.payload, state } })
}
export const createUser = payload => async (dispatch) => {
dispatch({ type: 'CREATE_USER', payload })
}
export const sendUserEnter = payload => async () => {
getIO().emit('USER_ENTER', {
publicKey: payload.publicKey,
})
}
export const receiveUserExit = payload => async (dispatch, getState) => {
const state = getState()
const exitingUser = state.room.members.find(m => !payload.map(p => JSON.stringify(p.publicKey)).includes(JSON.stringify(m.publicKey)))
const exitingUserId = exitingUser.id
const exitingUsername = exitingUser.username
dispatch({
type: 'USER_EXIT',
payload: {
members: payload,
id: exitingUserId,
username: exitingUsername,
},
})
}
export const receiveUserEnter = payload => async (dispatch) => {
dispatch({ type: 'USER_ENTER', payload })
}
export const onFileTransfer = payload => async (dispatch) => {
dispatch({ type: 'PREFLIGHT_FILE_TRANSFER', payload })
}
export const sendSocketMessage = payload => async (dispatch, getState) => {
const state = getState()
const msg = await prepareMessage(payload, state)
dispatch({ type: `SEND_SOCKET_MESSAGE_${msg.original.type}`, payload: msg.original.payload })
getIO().emit('PAYLOAD', msg.toSend)
}
export const toggleLockRoom = () => async (dispatch, getState) => {
const state = getState()
getIO().emit('TOGGLE_LOCK_ROOM', null, (res) => {
dispatch({
type: 'TOGGLE_LOCK_ROOM',
payload: {
locked: res.isLocked,
username: state.user.username,
sender: state.user.id,
},
})
})
}
export const receiveToggleLockRoom = payload => async (dispatch, getState) => {
const state = getState()
const lockedByUser = state.room.members.find(m => isEqual(m.publicKey, payload.publicKey))
const lockedByUsername = lockedByUser.username
const lockedByUserId = lockedByUser.id
dispatch({
type: 'RECEIVE_TOGGLE_LOCK_ROOM',
payload: {
username: lockedByUsername,
locked: payload.locked,
id: lockedByUserId,
},
})
}
export const clearActivities = () => async (dispatch) => {
dispatch({ type: 'CLEAR_ACTIVITIES' })
}

26
client/src/api/config.js Normal file
View File

@ -0,0 +1,26 @@
let host
let protocol
let port
switch (process.env.NODE_ENV) {
case 'staging':
host = process.env.REACT_APP_API_HOST
protocol = process.env.REACT_APP_API_PROTOCOL || 'https'
port = process.env.REACT_APP_API_PORT || 443
break
case 'production':
host = process.env.REACT_APP_API_HOST
protocol = process.env.REACT_APP_API_PROTOCOL || 'https'
port = process.env.REACT_APP_API_PORT || 443
break
default:
host = process.env.REACT_APP_API_HOST || 'localhost'
protocol = process.env.REACT_APP_API_PROTOCOL || 'http'
port = process.env.REACT_APP_API_PORT || 3001
}
export default {
host,
port,
protocol,
}

View File

@ -0,0 +1,13 @@
import config from './config'
export default (resourceName = '') => {
const { port, protocol, host } = config
const resourcePath = resourceName
if (!host) {
return `/${resourcePath}`;
}
return `${protocol}://${host}:${port}/${resourcePath}`
}

59
client/src/api/index.js Normal file
View File

@ -0,0 +1,59 @@
import {
fetchStart,
fetchSuccess,
fetchFailure,
} from 'actions'
import queryString from 'querystring'
import generateUrl from './generator'
export default (opts, dispatch, name, metaOpts = {}) => {
const method = opts.method || 'GET'
const resourceId = opts.resourceId
let url = generateUrl(opts.resourceName, resourceId)
const config = {
method,
headers: {},
type: 'cors',
}
if (opts.body) {
config.body = JSON.stringify(opts.body)
config.headers['Content-Type'] = 'application/json'
}
if (opts.query) {
url = `${url}?${queryString.stringify(opts.query)}`
}
return new Promise((resolve, reject) => {
const meta = { ...metaOpts, timestamp: Date.now() }
dispatch(fetchStart(name, method, resourceId, meta))
return window.fetch(url, config)
.then(async (response) => {
let json = {}
try {
json = await response.json()
} catch (e) {
throw new Error(e)
}
const dispatchOps = {
response,
json,
resourceId,
meta,
}
if (response.ok) {
dispatch(fetchSuccess(name, method, dispatchOps))
return resolve(dispatchOps)
}
dispatch(fetchFailure(name, method, dispatchOps))
return reject(dispatchOps)
})
})
}

BIN
client/src/audio/beep.mp3 Normal file

Binary file not shown.

View File

@ -0,0 +1,198 @@
/* eslint-disable */
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import apiUrlGenerator from '../../api/generator';
class About extends Component {
constructor(props) {
super(props);
this.state = {
roomId: props.roomId,
abuseReported: false
}
}
handleUpdateRoomId(evt) {
this.setState({
roomId: evt.target.value,
})
}
handleReportAbuse(evt) {
evt.preventDefault();
fetch(`${apiUrlGenerator('abuse')}/${this.state.roomId}`, {
method: 'POST'
});
this.setState({
abuseReported: true,
})
}
render() {
return (
<div>
<h4>Version</h4>
<p><strong>Client</strong>
Commit SHA: <a target="_blank" href={`https://github.com/darkwire/darkwire-client/commit/${process.env.REACT_APP_COMMIT_SHA}`}>{process.env.REACT_APP_COMMIT_SHA}</a></p>
<p><strong>Server</strong>
Commit SHA: <a target="_blank" href={`https://github.com/darkwire/darkwire-server/commit/${this.props.serverSHA}`}>{this.props.serverSHA}</a></p>
<br />
<h4>Software</h4>
<p>This software uses the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Crypto" target="_blank" rel="noopener noreferrer">Web Cryptography API</a> to
encrypt data which is transferred using <a href="https://en.wikipedia.org/wiki/WebSocket" target="_blank" rel="noopener noreferrer">secure WebSockets</a>.
Messages are never stored on a server or sent over the wire in plain-text.</p>
<p>We believe in privacy and transparency.
&nbsp;<a href="https://github.com/darkwire/darkwire.io" target="_blank" rel="noopener noreferrer">View the source code and documentation on GitHub.</a></p>
<br />
<h4>Report Abuse</h4>
<p>To report any content that violates our Acceptable Use Policy below, email us at abuse[at]darkwire.io or submit the room ID below to report anonymously.</p>
<form onSubmit={this.handleReportAbuse.bind(this)}>
{this.state.abuseReported && <div>Thank you!</div>}
<div>
<input placeholder='Room ID' onChange={this.handleUpdateRoomId.bind(this)} value={this.state.roomId} type="text"/>
<button>Submit</button>
</div>
</form>
<br />
<h4>Acceptable Use Policy</h4>
<p>This Acceptable Use Policy (this Policy) describes prohibited uses of the web services offered by Darkwire and its affiliates (the Services) and the website located at https://darkwire.io (the “Darkwire Site”). The examples described in this Policy are not exhaustive. We may modify this Policy at any time by posting a revised version on the Darkwire Site. By using the Services or accessing the Darkwire Site, you agree to the latest version of this Policy. If you violate the Policy or authorize or help others to do so, we may suspend or terminate your use of the Services.</p>
<strong>No Illegal, Harmful, or Offensive Use or Content</strong>
<p>You may not use, or encourage, promote, facilitate or instruct others to use, the Services or Darkwire Site for any illegal, harmful, fraudulent, infringing or offensive use, or to transmit, store, display, distribute or otherwise make available content that is illegal, harmful, fraudulent, infringing or offensive. Prohibited activities or content include:</p>
<ul>
<li><strong>Illegal, Harmful or Fraudulent Activities.</strong> Any activities that are illegal, that violate the rights of others, or that may be harmful to others, our operations or reputation, including disseminating, promoting or facilitating child pornography, offering or disseminating fraudulent goods, services, schemes, or promotions, make-money-fast schemes, ponzi and pyramid schemes, phishing, or pharming.</li>
<li><strong>Infringing Content.</strong> Content that infringes or misappropriates the intellectual property or proprietary rights of others.</li>
<li><strong>Offensive Content.</strong> Content that is defamatory, obscene, abusive, invasive of privacy, or otherwise objectionable, including content that constitutes child pornography, relates to bestiality, or depicts non-consensual sex acts.</li>
<li><strong>Harmful Content.</strong> Content or other computer technology that may damage, interfere with, surreptitiously intercept, or expropriate any system, program, or data, including viruses, Trojan horses, worms, time bombs, or cancelbots.</li>
</ul>
<strong>No Security Violations</strong>
<br/>You may not use the Services to violate the security or integrity of any network, computer or communications system, software application, or network or computing device (each, a System). Prohibited activities include:
<ul>
<li><strong>Unauthorized Access.</strong> Accessing or using any System without permission, including attempting to probe, scan, or test the vulnerability of a System or to breach any security or authentication measures used by a System.</li>
<li><strong>Interception.</strong> Monitoring of data or traffic on a System without permission.</li>
<li><strong>Falsification of Origin.</strong> Forging TCP-IP packet headers, e-mail headers, or any part of a message describing its origin or route. The legitimate use of aliases and anonymous remailers is not prohibited by this provision.</li>
</ul>
<strong>No Network Abuse</strong>
<br/>You may not make network connections to any users, hosts, or networks unless you have permission to communicate with them. Prohibited activities include:
<ul>
<li><strong>Monitoring or Crawling.</strong> Monitoring or crawling of a System that impairs or disrupts the System being monitored or crawled.</li>
<li><strong>Denial of Service (DoS).</strong> Inundating a target with communications requests so the target either cannot respond to legitimate traffic or responds so slowly that it becomes ineffective.</li>
<li><strong>Intentional Interference.</strong> Interfering with the proper functioning of any System, including any deliberate attempt to overload a system by mail bombing, news bombing, broadcast attacks, or flooding techniques.</li>
<li><strong>Operation of Certain Network Services.</strong> Operating network services like open proxies, open mail relays, or open recursive domain name servers.</li>
<li><strong>Avoiding System Restrictions.</strong> Using manual or electronic means to avoid any use limitations placed on a System, such as access and storage restrictions.</li>
</ul>
<strong>No E-Mail or Other Message Abuse</strong>
<br/>You will not distribute, publish, send, or facilitate the sending of unsolicited mass e-mail or other messages, promotions, advertising, or solicitations (like spam), including commercial advertising and informational announcements. You will not alter or obscure mail headers or assume a senders identity without the senders explicit permission. You will not collect replies to messages sent from another internet service provider if those messages violate this Policy or the acceptable use policy of that provider.
<strong>Our Monitoring and Enforcement</strong>
<br/>We reserve the right, but do not assume the obligation, to investigate any violation of this Policy or misuse of the Services or Darkwire Site. We may:
<ul>
<li>investigate violations of this Policy or misuse of the Services or Darkwire Site; or</li>
<li>remove, disable access to, or modify any content or resource that violates this Policy or any other agreement we have with you for use of the Services or the Darkwire Site.</li>
<li>We may report any activity that we suspect violates any law or regulation to appropriate law enforcement officials, regulators, or other appropriate third parties. Our reporting may include disclosing appropriate customer information. We also may cooperate with appropriate law enforcement agencies, regulators, or other appropriate third parties to help with the investigation and prosecution of illegal conduct by providing network and systems information related to alleged violations of this Policy.</li>
</ul>
Reporting of Violations of this Policy
<br/>If you become aware of any violation of this Policy, you will immediately notify us and provide us with assistance, as requested, to stop or remedy the violation. To report any violation of this Policy, please follow our abuse reporting process.
<br />
<br />
<h4>Disclaimer</h4>
<p className="bold">WARNING: Darkwire does not mask IP addresses nor can verify the integrity of parties recieving messages.
&nbsp;Proceed with caution and always confirm recipients beforre starting a chat session.</p>
<p>Please also note that <strong>ALL CHATROOMS</strong> are public.
&nbsp;Anyone can guess your room URL. If you need a more-private room, use the lock feature or set the URL manually by entering a room ID after &quot;darkwire.io/&quot;.
</p>
<br />
<h4>Terms of Service ("Terms")</h4>
<p>Last updated: December 11, 2017</p>
<p>Please read these Terms of Service ("Terms", "Terms of Service") carefully before using the https://darkwire.io website (the "Service") operated by Darkwire ("us", "we", or "our").</p>
<p>Your access to and use of the Service is conditioned on your acceptance of and compliance with these Terms. These Terms apply to all visitors, users and others who access or use the Service.</p>
<p>By accessing or using the Service you agree to be bound by these Terms. If you disagree with any part of the terms then you may not access the Service.</p>
<strong>Links To Other Web Sites</strong>
<p>Our Service may contain links to third-party web sites or services that are not owned or controlled by Darkwire.</p>
<p>Darkwire has no control over, and assumes no responsibility for, the content, privacy policies, or practices of any third party web sites or services. You further acknowledge and agree that Darkwire shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with use of or reliance on any such content, goods or services available on or through any such web sites or services.</p>
<p>We strongly advise you to read the terms and conditions and privacy policies of any third-party web sites or services that you visit.</p>
<strong>Termination</strong>
<p>We may terminate or suspend access to our Service immediately, without prior notice or liability, for any reason whatsoever, including without limitation if you breach the Terms.</p>
<p>All provisions of the Terms which by their nature should survive termination shall survive termination, including, without limitation, ownership provisions,
warranty disclaimers, indemnity and limitations of liability.</p>
<strong>Governing Law</strong>
<p>These Terms shall be governed and construed in accordance with the laws of New York, United States, without regard to its conflict of law provisions.</p>
<p>Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights. If any provision of these Terms is held to be
invalid or unenforceable by a court, the remaining provisions of these Terms will remain in effect. These Terms constitute the entire agreement between us
regarding our Service, and supersede and replace any prior agreements we might have between us regarding the Service.</p>
<strong>No Warranties; Exclusion of Liability; Indemnification</strong>
<p><strong>OUR WEBSITE IS OPERATED BY Darkwire ON AN "AS IS," "AS AVAILABLE" BASIS, WITHOUT REPRESENTATIONS OR WARRANTIES OF ANY KIND. TO THE FULLEST EXTENT PERMITTED BY LAW, Darkwire SPECIFICALLY DISCLAIMS ALL WARRANTIES AND CONDITIONS OF ANY KIND, INCLUDING ALL IMPLIED WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NONINFRINGEMENT FOR OUR WEBSITE AND ANY CONTRACTS AND SERVICES YOU PURCHASE THROUGH IT. Darkwire SHALL NOT HAVE ANY LIABILITY OR RESPONSIBILITY FOR ANY ERRORS OR OMISSIONS IN THE CONTENT OF OUR WEBSITE, FOR CONTRACTS OR SERVICES SOLD THROUGH OUR WEBSITE, FOR YOUR ACTION OR INACTION IN CONNECTION WITH OUR WEBSITE OR FOR ANY DAMAGE TO YOUR COMPUTER OR DATA OR ANY OTHER DAMAGE YOU MAY INCUR IN CONNECTION WITH OUR WEBSITE. YOUR USE OF OUR WEBSITE AND ANY CONTRACTS OR SERVICES ARE AT YOUR OWN RISK. IN NO EVENT SHALL EITHER Darkwire OR THEIR AGENTS BE LIABLE FOR ANY DIRECT, INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF OR IN ANY WAY CONNECTED WITH THE USE OF OUR WEBSITE, CONTRACTS AND SERVICES PURCHASED THROUGH OUR WEBSITE, THE DELAY OR INABILITY TO USE OUR WEBSITE OR OTHERWISE ARISING IN CONNECTION WITH OUR WEBSITE, CONTRACTS OR RELATED SERVICES, WHETHER BASED ON CONTRACT,
TORT, STRICT LIABILITY OR OTHERWISE, EVEN IF ADVISED OF THE POSSIBILITY OF ANY SUCH DAMAGES. IN NO EVENT SHALL Darkwires LIABILITY FOR ANY DAMAGE CLAIM EXCEED THE AMOUNT PAID BY YOU TO Darkwire FOR THE TRANSACTION GIVING RISE TO SUCH DAMAGE CLAIM.</strong></p>
<p><strong>SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO THE ABOVE EXCLUSION MAY NOT APPLY TO YOU.</strong></p>
<p><strong>WITHOUT LIMITING THE FOREGOING, Darkwire DO NOT REPRESENT OR WARRANT THAT THE INFORMATION ON THE WEBITE IS ACCURATE, COMPLETE, RELIABLE, USEFUL, TIMELY OR CURRENT OR THAT OUR WEBSITE WILL OPERATE WITHOUT INTERRUPTION OR ERROR.</strong></p>
<p><strong>YOU AGREE THAT ALL TIMES, YOU WILL LOOK TO ATTORNEYS FROM WHOM YOU PURCHASE SERVICES FOR ANY CLAIMS OF ANY NATURE, INCLUDING LOSS, DAMAGE, OR WARRANTY. Darkwire AND THEIR RESPECTIVE AFFILIATES MAKE NO REPRESENTATION OR GUARANTEES ABOUT ANY CONTRACTS AND SERVICES OFFERED THROUGH OUR WEBSITE.</strong></p>
<p><strong>Darkwire MAKES NO REPRESENTATION THAT CONTENT PROVIDED ON OUR WEBSITE, CONTRACTS, OR RELATED SERVICES ARE APPLICABLE OR APPROPRIATE FOR USE IN ALL
JURISDICTIONS.</strong></p>
<strong>Indemnification</strong>
<p>You agree to defend, indemnify and hold Darkwire harmless from and against any and all claims, damages, costs and expenses, including attorneys' fees, arising
from or related to your use of our Website or any Contracts or Services you purchase through it.</p>
<strong>Changes</strong>
<p>We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material we will try to provide at least 30 days notice prior to any new terms taking effect. What constitutes a material change will be determined at our sole discretion.</p>
<p>By continuing to access or use our Service after those revisions become effective, you agree to be bound by the revised terms. If you do not agree to the new
terms, please stop using the Service.</p>
<strong>Contact Us</strong>
<p>If you have any questions about these Terms, please contact us at hello[at]darkwire.io.</p>
<br />
<h4>Contact</h4>
<p>Questions/comments? Email us at hello[at]darkwire.io</p>
<p>Found a bug or want a new feature? <a href="https://github.com/darkwire/darkwire.io/issues" target="_blank" rel="noopener noreferrer">Open a ticket on Github</a>.</p>
<br />
<h4>Donate</h4>
<p>Darkwire is maintained and hosted by two developers with full-time jobs. If you get some value
from this service we would appreciate any donation you can afford. We use these funds for
server and DNS costs. Thank you!
</p>
<strong>Bitcoin</strong>
<p>189sPnHGcjP5uteg2UuNgcJ5eoaRAP4Bw4</p>
<strong>Ethereum</strong>
<p>0xD6e3D881036903999E2c0480fe9d2c20600C1c28</p>
<strong>Litecoin</strong>
<p>LUViQeSggBBtYoN2qNtXSuxYoRMzRY8CSX</p>
<strong>PayPal:</strong>
<br />
<form action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_blank">
<input type="hidden" name="cmd" value="_s-xclick" />
<input type="hidden" name="hosted_button_id" value="UAH5BCLA9Y8VW" />
<input type="image" src="https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif" border="0" name="submit" alt="PayPal - The safer, easier way to pay online!" />
<img alt="" border="0" src="https://www.paypalobjects.com/en_US/i/scr/pixel.gif" width="1" height="1" />
</form>
</div>
)
}
}
About.propTypes = {
serverSHA: PropTypes.string.isRequired,
roomId: PropTypes.string.isRequired,
}
export default About

View File

@ -0,0 +1,17 @@
import React from 'react'
import { mount } from 'enzyme'
import toJson from 'enzyme-to-json'
import { Chat } from './index.js'
const sendSocketMessage = jest.fn()
test('Chat Component', () => {
const component = mount(
<Chat scrollToBottom={() => {}} focusChat={false} userId="foo" username="user" showNotice={() => {}} clearActivities={() => {}} sendSocketMessage={sendSocketMessage} />
)
const componentJSON = toJson(component)
expect(component).toMatchSnapshot()
expect(componentJSON.children.length).toBe(1)
})

View File

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Chat Component 1`] = `ReactWrapper {}`;

View File

@ -0,0 +1,294 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import sanitizeHtml from 'sanitize-html'
import FileTransfer from 'components/FileTransfer'
import { CornerDownRight } from 'react-feather'
import { connect } from 'react-redux'
import { clearActivities, showNotice } from '../../actions'
import { getSelectedText, hasTouchSupport } from '../../utils/dom'
// Disable for now
// import autosize from 'autosize'
export class Chat extends Component {
constructor(props) {
super(props)
this.state = {
message: '',
touchSupport: hasTouchSupport,
shiftKeyDown: false,
}
this.commands = [{
command: 'nick',
description: 'Changes nickname.',
paramaters: ['{username}'],
usage: '/nick {username}',
scope: 'global',
action: (params) => { // eslint-disable-line
let newUsername = params.join(' ') || '' // eslint-disable-line
// Remove things that arent digits or chars
newUsername = newUsername.replace(/[^A-Za-z0-9]/g, '-')
const errors = []
if (!newUsername.trim().length) {
errors.push('Username cannot be blank')
}
if (newUsername.toString().length > 16) {
errors.push('Username cannot be greater than 16 characters')
}
if (!newUsername.match(/^[A-Z]/i)) {
errors.push('Username must start with a letter')
}
if (errors.length) {
return this.props.showNotice({
message: `${errors.join(', ')}`,
level: 'error',
})
}
this.props.sendSocketMessage({
type: 'CHANGE_USERNAME',
payload: {
id: this.props.userId,
newUsername,
currentUsername: this.props.username,
},
})
},
}, {
command: 'help',
description: 'Shows a list of commands.',
paramaters: [],
usage: '/help',
scope: 'local',
action: (params) => { // eslint-disable-line
const validCommands = this.commands.map(command => `/${command.command}`)
this.props.showNotice({
message: `Valid commands: ${validCommands.sort().join(', ')}`,
level: 'info',
})
},
}, {
command: 'me',
description: 'Invoke virtual action',
paramaters: ['{action}'],
usage: '/me {action}',
scope: 'global',
action: (params) => { // eslint-disable-line
const actionMessage = params.join(' ')
if (!actionMessage.trim().length) {
return false
}
this.props.sendSocketMessage({
type: 'USER_ACTION',
payload: {
action: actionMessage,
},
})
},
}, {
command: 'clear',
description: 'Clears the chat screen',
paramaters: [],
usage: '/clear',
scope: 'local',
action: (params = null) => { // eslint-disable-line
this.props.clearActivities()
},
}]
}
componentDidMount() {
if (!hasTouchSupport) {
// Disable for now due to vary issues:
// Paste not working, shift+enter line breaks
// autosize(this.textInput);
this.textInput.addEventListener('autosize:resized', () => {
this.props.scrollToBottom()
})
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.focusChat) {
if (!getSelectedText()) {
// Don't focus for now, evaulate UX benfits
// this.textInput.focus()
}
}
}
componentDidUpdate(nextProps, nextState) {
if (!nextState.message.trim().length) {
// autosize.update(this.textInput)
}
}
handleKeyUp(e) {
if (e.key === 'Shift') {
this.setState({
shiftKeyDown: false,
})
}
}
handleKeyPress(e) {
if (e.key === 'Shift') {
this.setState({
shiftKeyDown: true,
})
}
// Fix when autosize is enabled - line breaks require shift+enter twice
if (e.key === 'Enter' && !hasTouchSupport && !this.state.shiftKeyDown) {
e.preventDefault()
if (this.canSend()) {
this.sendMessage()
} else {
this.setState({
message: '',
})
}
}
}
executeCommand(command) {
const commandToExecute = this.commands.find(cmnd => cmnd.command === command.command)
if (commandToExecute) {
const { params } = command
const commandResult = commandToExecute.action(params)
return commandResult
}
return null
}
handleSendClick() {
this.sendMessage.bind(this)
this.textInput.focus()
}
handleFormSubmit(evt) {
evt.preventDefault()
this.sendMessage()
}
parseCommand(message) {
const commandTrigger = {
command: null,
params: [],
}
if (message.charAt(0) === '/') {
const parsedCommand = message.replace('/', '').split(' ')
commandTrigger.command = sanitizeHtml(parsedCommand[0]) || null
// Get params
if (parsedCommand.length >= 2) {
for (let i = 1; i < parsedCommand.length; i++) {
commandTrigger.params.push(parsedCommand[i])
}
}
return commandTrigger
}
return false
}
sendMessage() {
if (!this.canSend()) {
return
}
const { message } = this.state
const isCommand = this.parseCommand(message)
if (isCommand) {
const res = this.executeCommand(isCommand)
if (res === false) {
return
}
} else {
this.props.sendSocketMessage({
type: 'SEND_MESSAGE',
payload: {
text: message,
timestamp: Date.now(),
},
})
}
this.setState({
message: '',
})
}
handleInputChange(evt) {
this.setState({
message: evt.target.value,
})
}
canSend() {
return this.state.message.trim().length
}
render() {
const touchSupport = this.state.touchSupport
return (
<form onSubmit={this.handleFormSubmit.bind(this)} className="chat-preflight-container">
<textarea
rows="1"
onKeyUp={this.handleKeyUp.bind(this)}
onKeyDown={this.handleKeyPress.bind(this)}
ref={(input) => { this.textInput = input }}
autoFocus
className="chat"
value={this.state.message}
placeholder="Type here"
onChange={this.handleInputChange.bind(this)} />
<div className="input-controls">
<FileTransfer sendSocketMessage={this.props.sendSocketMessage} />
{touchSupport &&
<button onClick={this.handleSendClick.bind(this)} className={`icon is-right send btn btn-link ${this.canSend() ? 'active' : ''}`}>
<CornerDownRight className={this.canSend() ? '' : 'disabled'} />
</button>
}
</div>
</form>
)
}
}
Chat.propTypes = {
sendSocketMessage: PropTypes.func.isRequired,
showNotice: PropTypes.func.isRequired,
userId: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
clearActivities: PropTypes.func.isRequired,
focusChat: PropTypes.bool.isRequired,
scrollToBottom: PropTypes.func.isRequired,
}
const mapStateToProps = state => ({
username: state.user.username,
userId: state.user.id,
})
const mapDispatchToProps = {
clearActivities,
showNotice,
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Chat)

View File

@ -0,0 +1,11 @@
import React, { Component } from 'react'
export default class Connecting extends Component {
render() {
return (
<div>
Please wait while we secure a connection to Darkwire...
</div>
)
}
}

View File

@ -0,0 +1,119 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import uuid from 'uuid'
import { File } from 'react-feather'
import { sanitize } from 'utils'
import {styles} from './styles.module.scss'
const VALID_FILE_TYPES = ['png', 'jpg', 'jpeg', 'gif', 'zip', 'rar', 'gzip', 'pdf', 'txt', 'json', 'doc', 'docx', 'csv', 'js', 'html', 'css']
export default class FileTransfer extends Component {
constructor() {
super()
this.state = {
supported: true,
localFileQueue: [],
}
}
componentWillMount() {
if (!window.File && !window.FileReader && !window.FileList && !window.Blob && !window.btoa && !window.atob && !window.Blob && !window.URL) {
this.setState({
supported: false,
})
}
}
componentDidMount() {
this._fileInput.addEventListener('change', this.confirmFileTransfer.bind(this))
}
async encodeFile(file) {
return new Promise((resolve, reject) => {
const reader = new window.FileReader()
if (!file) {
reject()
return
}
reader.onload = (readerEvent) => {
resolve(window.btoa(readerEvent.target.result))
this._fileInput.value = ''
}
reader.readAsBinaryString(file)
})
}
async confirmFileTransfer(event) {
const file = event.target.files && event.target.files[0]
if (file) {
const fileType = file.type || 'file'
const fileName = sanitize(file.name)
const { localFileQueue } = this.state
const fileExtension = file.name.split('.').pop().toLowerCase()
if (VALID_FILE_TYPES.indexOf(fileExtension) <= -1) {
// eslint-disable-next-line no-alert
alert('File type not supported')
return false
}
if (file.size > 4 * 1000000) {
// eslint-disable-next-line no-alert
alert('Max filesize is 4MB')
return false
}
const fileId = uuid.v4()
const fileData = {
id: fileId,
file,
fileName,
fileType,
encodedFile: await this.encodeFile(file),
}
localFileQueue.push(fileData)
this.setState({
localFileQueue,
}, async () => {
this.props.sendSocketMessage({
type: 'SEND_FILE',
payload: {
fileName: fileData.fileName,
encodedFile: fileData.encodedFile,
fileType: fileData.fileType,
},
})
})
}
return false
}
canSend() {
return false
}
render() {
if (!this.state.supported) {
return null
}
return (
<div className={`${styles} icon file-transfer btn btn-link`}>
<input type="file" name="fileUploader" id="fileInput" ref={c => this._fileInput = c} />
<label htmlFor="fileInput">
<File className={this.canSend() ? '' : 'disabled'} />
</label>
</div>
)
}
}
FileTransfer.propTypes = {
sendSocketMessage: PropTypes.func.isRequired,
}

View File

@ -0,0 +1,16 @@
.styles {
input {
-ms-filter: 'alpha(opacity=0)';
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: -1;
}
label {
cursor: pointer;
height: 100%;
padding-top: 7px;
}
}

View File

@ -0,0 +1,457 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import Crypto from 'utils/crypto'
import { connect } from 'utils/socket'
import Nav from 'components/Nav'
import shortId from 'shortid'
import ChatInput from 'containers/Chat'
import Connecting from 'components/Connecting'
import Message from 'components/Message'
import Username from 'components/Username'
import Notice from 'components/Notice'
import Modal from 'react-modal'
import About from 'components/About'
import Settings from 'components/Settings'
import Welcome from 'components/Welcome'
import RoomLocked from 'components/RoomLocked'
import { X, AlertCircle } from 'react-feather'
import { defer } from 'lodash'
import Tinycon from 'tinycon'
import beepFile from 'audio/beep.mp3'
import Zoom from 'utils/ImageZoom'
import classNames from 'classnames'
import { getObjectUrl } from 'utils/file'
import styles from './styles.module.scss'
const crypto = new Crypto()
Modal.setAppElement('#root');
export default class Home extends Component {
constructor(props) {
super(props)
this.state = {
zoomableImages: [],
focusChat: false,
}
this.hasConnected = false
}
async componentWillMount() {
const roomId = encodeURI(this.props.match.params.roomId)
const res = await this.props.createRoom(roomId)
if (res.json.isLocked) {
this.props.openModal('Room Locked')
return
}
if (res.json.size === 1) {
this.props.openModal('Welcome')
}
const user = await this.createUser()
const io = connect(roomId)
const disconnectEvents = [
'reconnect_failed',
'connect_timeout',
'connect_error',
'disconnect',
'reconnect',
'reconnect_error',
'reconnecting',
'reconnect_attempt',
]
disconnectEvents.forEach((evt) => {
io.on(evt, () => {
this.props.toggleSocketConnected(false)
})
})
const connectEvents = [
'connect',
'reconnect',
]
connectEvents.forEach((evt) => {
io.on(evt, () => {
if (evt === 'connect') {
if (!this.hasConnected) {
this.initApp(user)
this.hasConnected = true
}
}
this.props.toggleSocketConnected(true)
})
})
io.on('USER_ENTER', (payload) => {
this.props.receiveUserEnter(payload)
this.props.sendSocketMessage({
type: 'ADD_USER',
payload: {
username: this.props.username,
publicKey: this.props.publicKey,
isOwner: this.props.iAmOwner,
id: this.props.userId,
},
})
})
io.on('USER_EXIT', (payload) => {
this.props.receiveUserExit(payload)
})
io.on('PAYLOAD', (payload) => {
this.props.receiveSocketMessage(payload)
})
io.on('TOGGLE_LOCK_ROOM', (payload) => {
this.props.receiveToggleLockRoom(payload)
})
}
componentDidMount() {
this.bindEvents()
if (this.props.joining) {
this.props.openModal('Connecting')
}
this.beep = window.Audio && new window.Audio(beepFile)
}
componentWillReceiveProps(nextProps) {
if (this.props.joining && !nextProps.joining) {
this.props.closeModal()
}
Tinycon.setBubble(nextProps.faviconCount)
if (nextProps.faviconCount !== 0 && this.props.soundIsEnabled) {
this.beep.play()
}
}
componentDidUpdate(prevProps) {
if (prevProps.activities.length < this.props.activities.length) {
this.scrollToBottomIfShould()
}
}
onScroll() {
const messageStreamHeight = this.messageStream.clientHeight
const activitiesListHeight = this.activitiesList.clientHeight
const bodyRect = document.body.getBoundingClientRect()
const elemRect = this.activitiesList.getBoundingClientRect()
const offset = elemRect.top - bodyRect.top
const activitiesListYPos = offset
const scrolledToBottom = (activitiesListHeight + (activitiesListYPos - 60)) <= messageStreamHeight
if (scrolledToBottom) {
if (!this.props.scrolledToBottom) {
this.props.setScrolledToBottom(true)
}
} else if (this.props.scrolledToBottom) {
this.props.setScrolledToBottom(false)
}
}
getFileDisplay(activity) {
const type = activity.fileType
if (type.match('image.*')) {
return (
<img
ref={c => this._zoomableImage = c}
className="image-transfer zoomable"
src={`data:${activity.fileType};base64,${activity.encodedFile}`}
alt={`${activity.fileName} from ${activity.username}`}
onLoad={this.handleImageDisplay.bind(this)}
/>
)
}
return null
}
getActivityComponent(activity) {
switch (activity.type) {
case 'SEND_MESSAGE':
return (
<Message
sender={activity.username}
message={activity.text}
timestamp={activity.timestamp}
/>
)
case 'USER_ENTER':
return (
<Notice>
<div><Username username={activity.username} /> joined</div>
</Notice>
)
case 'USER_EXIT':
return (
<Notice>
<div><Username username={activity.username} /> left</div>
</Notice>
)
case 'TOGGLE_LOCK_ROOM':
const lockedWord = activity.locked ? 'locked' : 'unlocked'
return (
<Notice>
<div><Username username={activity.username} /> {lockedWord} the room</div>
</Notice>
)
case 'NOTICE':
return (
<Notice>
<div>{activity.message}</div>
</Notice>
)
case 'CHANGE_USERNAME':
return (
<Notice>
<div><Username username={activity.currentUsername} /> changed their name to <Username username={activity.newUsername} /></div>
</Notice>
)
case 'USER_ACTION':
return (
<Notice>
<div>&#42; <Username username={activity.username} /> {activity.action}</div>
</Notice>
)
case 'RECEIVE_FILE':
const downloadUrl = getObjectUrl(activity.encodedFile, activity.fileType)
return (
<div>
<Username username={activity.username} /> sent you a file. <a target="_blank" href={downloadUrl} rel="noopener noreferrer">Download {activity.fileName}</a>
{this.getFileDisplay(activity)}
</div>
)
case 'SEND_FILE':
const url = getObjectUrl(activity.encodedFile, activity.fileType)
return (
<Notice>
<div>You sent <a target="_blank" href={url} rel="noopener noreferrer">{activity.fileName}</a></div>
{this.getFileDisplay(activity)}
</Notice>
)
default:
return false
}
}
getModal() {
switch (this.props.modalComponent) {
case 'Connecting':
return {
component: <Connecting />,
title: 'Connecting...',
preventClose: true,
}
case 'About':
return {
component: <About roomId={this.props.roomId} serverSHA={this.props.serverSHA} />,
title: 'About',
}
case 'Settings':
return {
component: <Settings roomId={this.props.roomId} toggleSoundEnabled={this.props.toggleSoundEnabled} soundIsEnabled={this.props.soundIsEnabled} />,
title: 'Settings & Help',
}
case 'Welcome':
return {
component: <Welcome roomId={this.props.roomId} close={this.props.closeModal} />,
title: 'Welcome to Darkwire v2.0',
}
case 'Room Locked':
return {
component: <RoomLocked />,
title: 'This room is locked',
preventClose: true,
}
default:
return {
component: null,
title: null,
}
}
}
initApp(user) {
this.props.sendUserEnter({
publicKey: user.publicKey,
})
}
handleImageDisplay() {
Zoom(this._zoomableImage)
this.scrollToBottomIfShould()
}
scrollToBottomIfShould() {
if (this.props.scrolledToBottom) {
setTimeout(() => {
this.messageStream.scrollTop = this.messageStream.scrollHeight
}, 0)
}
}
scrollToBottom() {
this.messageStream.scrollTop = this.messageStream.scrollHeight
this.props.setScrolledToBottom(true)
}
bindEvents() {
this.messageStream.addEventListener('scroll', this.onScroll.bind(this))
window.onfocus = () => {
this.props.toggleWindowFocus(true)
}
window.onblur = () => {
this.props.toggleWindowFocus(false)
}
}
createUser() {
return new Promise(async (resolve) => {
const username = shortId.generate()
const encryptDecryptKeys = await crypto.createEncryptDecryptKeys()
const exportedEncryptDecryptPrivateKey = await crypto.exportKey(encryptDecryptKeys.privateKey)
const exportedEncryptDecryptPublicKey = await crypto.exportKey(encryptDecryptKeys.publicKey)
this.props.createUser({
username,
publicKey: exportedEncryptDecryptPublicKey,
privateKey: exportedEncryptDecryptPrivateKey,
})
resolve({
publicKey: exportedEncryptDecryptPublicKey,
})
})
}
handleChatClick() {
this.setState({ focusChat: true })
defer(() => this.setState({ focusChat: false }))
}
render() {
const modalOpts = this.getModal()
return (
<div className={classNames(styles.styles, 'h-100')}>
<div className="nav-container">
{!this.props.socketConnected &&
<div className="alert-banner">
<span className="icon"><AlertCircle size="15" /></span> Disconnected
</div>
}
<Nav
members={this.props.members}
roomId={this.props.roomId}
roomLocked={this.props.roomLocked}
toggleLockRoom={this.props.toggleLockRoom}
openModal={this.props.openModal}
iAmOwner={this.props.iAmOwner}
userId={this.props.userId}
/>
</div>
<div className="main-chat">
<div onClick={this.handleChatClick.bind(this)} className="message-stream h-100" ref={el => this.messageStream = el}>
<ul className="plain" ref={el => this.activitiesList = el}>
<li><p className={styles.tos}><button className='btn btn-link' onClick={this.props.openModal.bind(this, 'About')}> By using Darkwire, you are agreeing to our Acceptable Use Policy and Terms of Service.</button></p></li>
{this.props.activities.map((activity, index) => (
<li key={index} className={`activity-item ${activity.type}`}>
{this.getActivityComponent(activity)}
</li>
))}
</ul>
</div>
<div className="chat-container">
<ChatInput scrollToBottom={this.scrollToBottom.bind(this)} focusChat={this.state.focusChat} />
</div>
</div>
<Modal
isOpen={Boolean(this.props.modalComponent)}
contentLabel="Modal"
style={{ overlay: { zIndex: 10 } }}
className={{
base: 'react-modal-content',
afterOpen: 'react-modal-content_after-open',
beforeClose: 'react-modal-content_before-close',
}}
overlayClassName={{
base: 'react-modal-overlay',
afterOpen: 'react-modal-overlay_after-open',
beforeClose: 'react-modal-overlay_before-close',
}}
shouldCloseOnOverlayClick={!modalOpts.preventClose}
onRequestClose={this.props.closeModal}
>
<div className="react-modal-header">
{!modalOpts.preventClose &&
<button onClick={this.props.closeModal} className="btn btn-link btn-plain close-modal">
<X />
</button>
}
<h3 className="react-modal-title">
{modalOpts.title}
</h3>
</div>
<div className="react-modal-component">
{modalOpts.component}
</div>
</Modal>
</div>
)
}
}
Home.defaultProps = {
modalComponent: null,
}
Home.propTypes = {
createRoom: PropTypes.func.isRequired,
receiveSocketMessage: PropTypes.func.isRequired,
sendSocketMessage: PropTypes.func.isRequired,
createUser: PropTypes.func.isRequired,
receiveUserExit: PropTypes.func.isRequired,
receiveUserEnter: PropTypes.func.isRequired,
activities: PropTypes.array.isRequired,
username: PropTypes.string.isRequired,
publicKey: PropTypes.object.isRequired,
members: PropTypes.array.isRequired,
match: PropTypes.object.isRequired,
roomId: PropTypes.string.isRequired,
roomLocked: PropTypes.bool.isRequired,
toggleLockRoom: PropTypes.func.isRequired,
receiveToggleLockRoom: PropTypes.func.isRequired,
modalComponent: PropTypes.string,
openModal: PropTypes.func.isRequired,
closeModal: PropTypes.func.isRequired,
setScrolledToBottom: PropTypes.func.isRequired,
scrolledToBottom: PropTypes.bool.isRequired,
iAmOwner: PropTypes.bool.isRequired,
sendUserEnter: PropTypes.func.isRequired,
userId: PropTypes.string.isRequired,
joining: PropTypes.bool.isRequired,
toggleWindowFocus: PropTypes.func.isRequired,
faviconCount: PropTypes.number.isRequired,
soundIsEnabled: PropTypes.bool.isRequired,
toggleSoundEnabled: PropTypes.func.isRequired,
serverSHA: PropTypes.string.isRequired,
toggleSocketConnected: PropTypes.func.isRequired,
socketConnected: PropTypes.bool.isRequired,
}

View File

@ -0,0 +1,8 @@
.styles {
.tos {
text-align: center;
a {
color: #666;
}
}
}

View File

@ -0,0 +1,36 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import Username from 'components/Username'
import moment from 'moment'
import Linkify from 'react-linkify'
class Message extends Component {
render() {
const msg = decodeURI(this.props.message)
return (
<div>
<div className="chat-meta">
<Username username={this.props.sender} />
<span className="muted timestamp">
{moment(this.props.timestamp).format('LT')}
</span>
</div>
<div className="chat">
<Linkify properties={{
target: '_blank',
rel: 'noopener noreferrer',
}}>{msg}</Linkify>
</div>
</div>
)
}
}
Message.propTypes = {
sender: PropTypes.string.isRequired,
timestamp: PropTypes.number.isRequired,
message: PropTypes.string.isRequired,
}
export default Message

View File

@ -0,0 +1,164 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import shortId from 'shortid'
import { Info, Settings, PlusCircle, User, Users, Lock, Unlock, Star } from 'react-feather'
import logoImg from 'img/logo.png'
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'
import Username from 'components/Username'
import Clipboard from 'clipboard'
import $ from 'jquery';
class Nav extends Component {
componentDidMount() {
const clip = new Clipboard('.clipboard-trigger')
clip.on('success', () => {
$('.room-id').tooltip('show')
setTimeout(() => {
$('.room-id').tooltip('hide')
}, 3000)
})
$(() => {
$('.room-id').tooltip({
trigger: 'manual',
})
$('.lock-room').tooltip({
trigger: 'manual',
})
})
}
componentDidUpdate() {
$(() => {
$('.me-icon-wrap').tooltip()
$('.owner-icon-wrap').tooltip()
})
}
newRoom() {
$('.navbar-collapse').collapse('hide')
window.open(`/${shortId.generate()}`)
}
handleSettingsClick() {
$('.navbar-collapse').collapse('hide')
this.props.openModal('Settings')
}
handleAboutClick() {
$('.navbar-collapse').collapse('hide')
this.props.openModal('About')
}
handleToggleLock() {
if (!this.props.iAmOwner) {
$('.lock-room').tooltip('show')
setTimeout(() => $('.lock-room').tooltip('hide'), 3000)
return
}
this.props.toggleLockRoom()
}
render() {
return (
<nav className="navbar navbar-expand-md navbar-dark">
<div className="meta">
<img src={logoImg} alt="Darkwire" className="logo" />
<button
data-toggle="tooltip"
data-placement="bottom"
title="Copied"
data-clipboard-text={`https://darkwire.io/${this.props.roomId}`}
className="btn btn-plain btn-link clipboard-trigger room-id ellipsis">
{`/${this.props.roomId}`}
</button>
<span className="lock-room-container">
<button
onClick={this.handleToggleLock.bind(this)}
className="lock-room btn btn-link btn-plain"
data-toggle="tooltip"
data-placement="bottom"
title="You must be the owner to lock or unlock the room"
>
{this.props.roomLocked &&
<Lock />
}
{!this.props.roomLocked &&
<Unlock className="muted" />
}
</button>
</span>
<Dropdown className="members-dropdown">
<DropdownTrigger>
<button className="btn btn-link btn-plain members-action">
<Users className="users-icon" />
</button>
<span>{this.props.members.length}</span>
</DropdownTrigger>
<DropdownContent>
<ul className="plain">
{this.props.members.map((member, index) => (
<li key={`user-${index}`}>
<Username username={member.username} />
<span className="icon-container">
{member.id === this.props.userId &&
<span data-toggle="tooltip" data-placement="bottom" title="Me" className="me-icon-wrap">
<User className="me-icon" />
</span>
}
{member.isOwner &&
<span data-toggle="tooltip" data-placement="bottom" title="Owner" className="owner-icon-wrap">
<Star className="owner-icon" />
</span>
}
</span>
</li>
))}
</ul>
</DropdownContent>
</Dropdown>
</div>
<button
className="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation">
<span className="navbar-toggler-icon" />
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav ml-auto">
<li className="nav-item">
<button className="btn btn-plain nav-link" onClick={this.newRoom.bind(this)}target="blank"><PlusCircle /> <span>New Room</span></button>
</li>
<li className="nav-item">
<button onClick={this.handleSettingsClick.bind(this)} className="btn btn-plain nav-link"><Settings /> <span>Settings</span></button>
</li>
<li className="nav-item">
<button onClick={this.handleAboutClick.bind(this)} className="btn btn-plain nav-link"><Info /> <span>About</span></button>
</li>
</ul>
</div>
</nav>
)
}
}
Nav.propTypes = {
members: PropTypes.array.isRequired,
roomId: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
roomLocked: PropTypes.bool.isRequired,
toggleLockRoom: PropTypes.func.isRequired,
openModal: PropTypes.func.isRequired,
iAmOwner: PropTypes.bool.isRequired,
}
export default Nav

View File

@ -0,0 +1,14 @@
import React from 'react'
import renderer from 'react-test-renderer'
import Notice from './index.js'
import { mount } from 'enzyme'
test.skip('Notice Component', () => {
const component = mount(
<Notice>
<div>Hello world</div>
</Notice>
)
expect(component).toMatchSnapshot()
})

View File

@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Notice Component 1`] = `
<div
className={undefined}
>
<div
className="info"
>
<div>
Hello world
</div>
</div>
</div>
`;

View File

@ -0,0 +1,26 @@
import React from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
const Notice = props => (
<div>
<div className={classNames({
info: props.level === 'info',
warning: props.level === 'warning',
danger: props.level === 'danger',
})}>
{props.children}
</div>
</div>
)
Notice.defaultProps = {
level: 'info',
}
Notice.propTypes = {
children: PropTypes.node.isRequired,
level: PropTypes.string,
}
export default Notice

View File

@ -0,0 +1,10 @@
.info {
color: white
}
.warning {
color: yellow
}
.danger {
color: gold
}

View File

@ -0,0 +1,65 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { Copy } from 'react-feather'
import Clipboard from 'clipboard'
import $ from 'jquery';
class RoomLink extends Component {
constructor(props) {
super(props)
this.state = {
roomUrl: `https://darkwire.io/${props.roomId}`,
}
}
componentDidMount() {
const clip = new Clipboard('.copy-room')
clip.on('success', () => {
$('.copy-room').tooltip('show')
setTimeout(() => {
$('.copy-room').tooltip('hide')
}, 3000)
})
$(() => {
$('.copy-room').tooltip({
trigger: 'manual',
})
})
}
componentWillUnmount() {
$('.copy-room').tooltip('hide')
}
render() {
return (
<form>
<div className="form-group">
<div className="input-group">
<input id="room-url" className="form-control" type="text" readOnly value={this.state.roomUrl} />
<span className="input-group-btn">
<button
className="copy-room btn btn-secondary"
type="button"
data-toggle="tooltip"
data-placement="bottom"
data-clipboard-text={this.state.roomUrl}
title="Copied!"
>
<Copy className="mt-1" />
</button>
</span>
</div>
</div>
</form>
)
}
}
RoomLink.propTypes = {
roomId: PropTypes.string.isRequired,
}
export default RoomLink

View File

@ -0,0 +1,11 @@
import React, { Component } from 'react'
export default class RoomLocked extends Component {
render() {
return (
<div>
This room is locked.
</div>
)
}
}

View File

@ -0,0 +1,64 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import RoomLink from 'components/RoomLink'
class Settings extends Component {
handleSoundToggle() {
this.props.toggleSoundEnabled(!this.props.soundIsEnabled)
}
render() {
return (
<div>
<div>
<h5>Sound</h5>
<form>
<div className="form-check">
<label className="form-check-label" htmlFor="sound-control">
<input id="sound-control" onChange={this.handleSoundToggle.bind(this)} className="form-check-input" type="checkbox" checked={this.props.soundIsEnabled} />
Sound
</label>
</div>
</form>
</div>
<br />
<div>
<h5>This room</h5>
<RoomLink roomId={this.props.roomId} />
</div>
<br />
<div>
<h5>Room Ownership</h5>
<p>The person who created the room is the room owner and has special privileges, like the ability to lock and unlock the room.
If the owner leaves the room, the second person to join assumes ownership. If they leave, the third person becomes owner, and so on.
The room owner has a star icon next to their username in the participants dropdown.
</p>
</div>
<br />
<div>
<h5>Lock Room</h5>
<p>If you are the room owner, you can lock and unlock the room by clicking the lock icon in the nav bar. When a room is locked, no other participants will be able to join.</p>
</div>
<br />
<div>
<h5>Slash Commands</h5>
<p>The following slash commands are available:</p>
<ul>
<li>/nick [username] <span className="text-muted">changes username</span></li>
<li>/me [action] <span className="text-muted">performs an action</span></li>
<li>/clear <span className="text-muted">clears your message history</span></li>
<li>/help <span className="text-muted">lists all commands</span></li>
</ul>
</div>
</div>
)
}
}
Settings.propTypes = {
soundIsEnabled: PropTypes.bool.isRequired,
toggleSoundEnabled: PropTypes.func.isRequired,
roomId: PropTypes.string.isRequired,
}
export default Settings

View File

@ -0,0 +1,19 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import randomColor from 'randomcolor'
class Username extends Component {
render() {
return (
<span className="username" style={{ color: randomColor({ seed: this.props.username, luminosity: 'light' }) }}>
{this.props.username}
</span>
)
}
}
Username.propTypes = {
username: PropTypes.string.isRequired,
}
export default Username

View File

@ -0,0 +1,45 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import RoomLink from 'components/RoomLink'
class Welcome extends Component {
constructor(props) {
super(props)
this.state = {
roomUrl: `https://darkwire.io/${props.roomId}`,
}
}
render() {
return (
<div>
<div>
v2.0 is a complete rewrite and includes several new features. Here are some highlights:
<ul className="native">
<li>Support on all modern browsers (Chrome, Firefox, Safari, Safari iOS, Android)</li>
<li>Slash commands (/nick, /me, /clear)</li>
<li>Room owners can lock the room, preventing anyone else from joining</li>
<li>Front-end rewritten in React.js and Redux</li>
<li>Send files up to 4 MB</li>
</ul>
<div>
You can learn more <a href="https://github.com/darkwire/darkwire.io" target="_blank" rel="noopener noreferrer">here</a>.
</div>
</div>
<br />
<p>Others can join this room using the following URL:</p>
<RoomLink roomId={this.props.roomId} />
<div className="react-modal-footer">
<button className="btn btn-primary btn-lg" onClick={this.props.close}>Ok</button>
</div>
</div>
)
}
}
Welcome.propTypes = {
roomId: PropTypes.string.isRequired,
close: PropTypes.func.isRequired,
}
export default Welcome

1
client/src/config/env.js Normal file
View File

@ -0,0 +1 @@
export default process.env.NODE_ENV

View File

@ -0,0 +1,15 @@
import { connect } from 'react-redux'
import { sendSocketMessage } from 'actions'
import ChatInput from 'components/Chat'
const mapStateToProps = () => ({
})
const mapDispatchToProps = {
sendSocketMessage,
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(ChatInput)

View File

@ -0,0 +1,66 @@
import { connect } from 'react-redux'
import Home from 'components/Home'
import {
createRoom,
receiveSocketMessage,
sendSocketMessage,
createUser,
receiveUserExit,
receiveUserEnter,
toggleLockRoom,
receiveToggleLockRoom,
openModal,
closeModal,
setScrolledToBottom,
sendUserEnter,
toggleWindowFocus,
toggleSoundEnabled,
toggleSocketConnected,
} from 'actions'
const mapStateToProps = (state) => {
const me = state.room.members.find(m => m.id === state.user.id)
return {
activities: state.activities.items,
userId: state.user.id,
username: state.user.username,
publicKey: state.user.publicKey,
privateKey: state.user.privateKey,
members: state.room.members.filter(m => m.username && m.publicKey),
roomId: state.room.id,
roomLocked: state.room.isLocked,
modalComponent: state.app.modalComponent,
scrolledToBottom: state.app.scrolledToBottom,
iAmOwner: Boolean(me && me.isOwner),
joining: state.room.joining,
faviconCount: state.app.unreadMessageCount,
soundIsEnabled: state.app.soundIsEnabled,
serverSHA: state.app.serverSHA,
socketConnected: state.app.socketConnected,
}
}
const mapDispatchToProps = {
createRoom,
receiveSocketMessage,
sendSocketMessage,
receiveUserExit,
receiveUserEnter,
createUser,
toggleLockRoom,
receiveToggleLockRoom,
openModal,
closeModal,
setScrolledToBottom,
sendUserEnter,
toggleWindowFocus,
toggleSoundEnabled,
toggleSocketConnected,
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Home)

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
client/src/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
client/src/img/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

14
client/src/index.css Normal file
View File

@ -0,0 +1,14 @@
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}

15
client/src/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html class='h-100'>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="nofollow">
<meta name="description" content="darkwire.io is the simplest way to chat with encryption online.">
<title>darkwire.io - instant encrypted web chat</title>
</head>
<body class='h-100'>
<div id="root" class="h-100">
</div>
</body>
</html>

12
client/src/index.js Normal file
View File

@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import Root from './root';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(<Root />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

7
client/src/logo.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,242 @@
const initialState = {
items: [
// {
// type: 'message | file | isTyping | usernameChange | slashCommand',
// data,
// username,
// timestamp
// }
],
}
const activities = (state = initialState, action) => {
switch (action.type) {
case 'CLEAR_ACTIVITIES':
return {
...state,
items: [],
}
case 'SEND_SOCKET_MESSAGE_SLASH_COMMAND':
return {
...state,
items: [
...state.items,
{
...action.payload,
type: 'SLASH_COMMAND',
},
],
}
case 'SEND_SOCKET_MESSAGE_FILE_TRANSFER':
return {
...state,
items: [
...state.items,
{
...action.payload,
type: 'FILE',
},
],
}
case 'SEND_SOCKET_MESSAGE_SEND_MESSAGE':
return {
...state,
items: [
...state.items,
{
...action.payload,
type: 'SEND_MESSAGE',
},
],
}
case 'HANDLE_SOCKET_MESSAGE_SEND_MESSAGE':
return {
...state,
items: [
...state.items,
{
...action.payload.payload,
type: 'SEND_MESSAGE',
},
],
}
case 'SEND_SOCKET_MESSAGE_SEND_FILE':
return {
...state,
items: [
...state.items,
{
fileName: action.payload.fileName,
encodedFile: action.payload.encodedFile,
fileType: action.payload.fileType,
username: action.payload.username,
type: 'SEND_FILE',
},
],
}
case 'HANDLE_SOCKET_MESSAGE_SEND_FILE':
return {
...state,
items: [
...state.items,
{
encodedFile: action.payload.payload.encodedFile,
fileType: action.payload.payload.fileType,
fileName: action.payload.payload.fileName,
username: action.payload.payload.username,
type: 'RECEIVE_FILE',
},
],
}
case 'HANDLE_SOCKET_MESSAGE_ADD_USER':
const newUserId = action.payload.payload.id
const haveUser = action.payload.state.room.members.find(m => m.id === newUserId)
if (haveUser) {
return state
}
// Duplicate "user entered" can happen when >2 users join
// in quick succession
const alreadyEntered = state.items.find(act => act.type === 'USER_ENTER' && act.userId === newUserId)
if (alreadyEntered) {
return state
}
if (action.payload.state.room.joining) {
return state
}
return {
...state,
items: [
...state.items,
{
userId: newUserId,
type: 'USER_ENTER',
username: action.payload.payload.username,
},
],
}
case 'USER_EXIT':
if (!action.payload.id) {
return state
}
return {
...state,
items: [
...state.items,
{
userId: action.payload.id,
type: 'USER_EXIT',
username: action.payload.username,
},
],
}
case 'TOGGLE_LOCK_ROOM':
return {
...state,
items: [
...state.items,
{
username: action.payload.username,
userId: action.payload.id,
type: 'TOGGLE_LOCK_ROOM',
locked: action.payload.locked,
sender: action.payload.sender,
},
],
}
case 'RECEIVE_TOGGLE_LOCK_ROOM':
return {
...state,
items: [
...state.items,
{
username: action.payload.username,
userId: action.payload.id,
type: 'TOGGLE_LOCK_ROOM',
locked: action.payload.locked,
sender: action.payload.sender,
},
],
}
case 'SHOW_NOTICE':
return {
...state,
items: [
...state.items,
{
type: 'NOTICE',
message: action.payload.message,
},
],
}
case 'SEND_SOCKET_MESSAGE_CHANGE_USERNAME':
return {
...state,
items: [
...state.items,
{
type: 'CHANGE_USERNAME',
currentUsername: action.payload.currentUsername,
newUsername: action.payload.newUsername,
},
].map((item) => {
if (item.sender === action.payload.sender && item.type !== 'CHANGE_USERNAME') {
return {
...item,
username: action.payload.newUsername,
}
}
return item
}),
}
case 'HANDLE_SOCKET_MESSAGE_CHANGE_USERNAME':
return {
...state,
items: [
...state.items,
{
type: 'CHANGE_USERNAME',
currentUsername: action.payload.payload.currentUsername,
newUsername: action.payload.payload.newUsername,
},
].map((item) => {
if (['SEND_MESSAGE', 'USER_ACTION'].includes(item.type) && item.sender === action.payload.payload.sender) {
return {
...item,
username: action.payload.payload.newUsername,
}
}
return item
}),
}
case 'SEND_SOCKET_MESSAGE_USER_ACTION':
return {
...state,
items: [
...state.items,
{
type: 'USER_ACTION',
...action.payload,
},
],
}
case 'HANDLE_SOCKET_MESSAGE_USER_ACTION':
return {
...state,
items: [
...state.items,
{
type: 'USER_ACTION',
...action.payload.payload,
},
],
}
default:
return state
}
}
export default activities

View File

@ -0,0 +1,59 @@
const initialState = {
modalComponent: null,
scrolledToBottom: true,
windowIsFocused: true,
unreadMessageCount: 0,
soundIsEnabled: true,
serverSHA: '',
socketConnected: false,
}
const app = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_CREATE_HANDSHAKE_SUCCESS':
return {
...state,
serverSHA: action.payload.json.sha,
}
case 'OPEN_MODAL':
return {
...state,
modalComponent: action.payload,
}
case 'CLOSE_MODAL':
return {
...state,
modalComponent: null,
}
case 'SET_SCROLLED_TO_BOTTOM':
return {
...state,
scrolledToBottom: action.payload,
}
case 'TOGGLE_WINDOW_FOCUS':
return {
...state,
windowIsFocused: action.payload,
unreadMessageCount: 0,
}
case 'HANDLE_SOCKET_MESSAGE_SEND_MESSAGE':
return {
...state,
unreadMessageCount: state.windowIsFocused ? 0 : state.unreadMessageCount + 1,
}
case 'TOGGLE_SOUND_ENABLED':
return {
...state,
soundIsEnabled: action.payload,
}
case 'TOGGLE_SOCKET_CONNECTED':
return {
...state,
socketConnected: action.payload,
}
default:
return state
}
}
export default app

View File

@ -0,0 +1,16 @@
import { combineReducers } from 'redux'
import app from './app'
import activities from './activities'
import user from './user'
import room from './room'
const appReducer = combineReducers({
app,
user,
room,
activities,
})
const rootReducer = (state, action) => appReducer(state, action)
export default rootReducer

142
client/src/reducers/room.js Normal file
View File

@ -0,0 +1,142 @@
import _ from 'lodash'
const initialState = {
members: [
// {
// username,
// publicKey
// }
],
id: '',
isLocked: false,
joining: true,
size: 0,
}
const room = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_CREATE_HANDSHAKE_SUCCESS':
const isLocked = action.payload.json.isLocked
// Handle "room is locked" message for new members here
return {
...state,
id: action.payload.json.id,
isLocked,
size: action.payload.json.size,
joining: !(action.payload.json.size === 1),
}
case 'USER_EXIT':
const memberPubKeys = action.payload.members.map(m => JSON.stringify(m.publicKey))
return {
...state,
members: state.members
.filter(m => memberPubKeys.includes(JSON.stringify(m.publicKey)))
.map((m) => {
const payloadMember = action.payload.members.find(member => _.isEqual(m.publicKey, member.publicKey))
return {
...m,
...payloadMember,
}
}),
}
case 'HANDLE_SOCKET_MESSAGE_ADD_USER':
const membersWithId = state.members.filter(m => m.id)
const joining = state.joining ? membersWithId.length + 1 < state.size : false
return {
...state,
members: state.members.map((member) => {
if (_.isEqual(member.publicKey, action.payload.payload.publicKey)) {
return {
...member,
username: action.payload.payload.username,
isOwner: action.payload.payload.isOwner,
id: action.payload.payload.publicKey.n,
}
}
return member
}),
joining,
}
case 'CREATE_USER':
return {
...state,
members: [
...state.members,
{
username: action.payload.username,
publicKey: action.payload.publicKey,
id: action.payload.publicKey.n,
},
],
}
case 'USER_ENTER':
/*
In this payload the server sends all users' public keys. Normally the server
will have all the users the client does, but in some cases - such as when
new users join before this client has registered with the server (this can
happen when lots of users join in quick succession) - the client
will receive a USER_ENTER event that doesn't contain itself. In that case we
want to prepend "me" to the members payload
*/
const diff = _.differenceBy(state.members, action.payload, m => m.publicKey.n)
const members = diff.length ? state.members.concat(action.payload) : action.payload
return {
...state,
members: members.map((user) => {
const exists = state.members.find(m => _.isEqual(m.publicKey, user.publicKey))
if (exists) {
return {
...user,
...exists,
}
}
return {
publicKey: user.publicKey,
isOwner: user.isOwner,
id: user.id,
}
}),
}
case 'TOGGLE_LOCK_ROOM':
return {
...state,
isLocked: !state.isLocked,
}
case 'RECEIVE_TOGGLE_LOCK_ROOM':
return {
...state,
isLocked: action.payload.locked,
}
case 'SEND_SOCKET_MESSAGE_CHANGE_USERNAME':
const newUsername = action.payload.newUsername
const userId = action.payload.id
return {
...state,
members: state.members.map(member => (
member.id === userId ?
{
...member,
username: newUsername,
} : member
)),
}
case 'HANDLE_SOCKET_MESSAGE_CHANGE_USERNAME':
const newUsername2 = action.payload.payload.newUsername
const userId2 = action.payload.payload.id
return {
...state,
members: state.members.map(member => (
member.id === userId2 ?
{
...member,
username: newUsername2,
} : member
)),
}
default:
return state
}
}
export default room

View File

@ -0,0 +1,25 @@
const initialState = {
privateKey: {},
publicKey: {},
username: '',
id: '',
}
const user = (state = initialState, action) => {
switch (action.type) {
case 'CREATE_USER':
return {
...action.payload,
id: action.payload.publicKey.n,
}
case 'SEND_SOCKET_MESSAGE_CHANGE_USERNAME':
return {
...state,
username: action.payload.newUsername,
}
default:
return state
}
}
export default user

37
client/src/root.js Normal file
View File

@ -0,0 +1,37 @@
import 'bootstrap/dist/css/bootstrap.min.css'
import 'react-simple-dropdown/styles/Dropdown.css'
import 'stylesheets/app.sass'
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import React, { Component } from 'react'
import { Redirect } from 'react-router'
import { Provider } from 'react-redux'
import configureStore from 'store'
import { BrowserRouter, Route, Switch } from 'react-router-dom'
import shortId from 'shortid'
import Home from 'containers/Home'
import { hasTouchSupport } from './utils/dom'
const store = configureStore()
export default class Root extends Component {
componentWillMount() {
if (hasTouchSupport) {
document.body.classList.add('touch')
}
}
render() {
return (
<Provider store={store}>
<BrowserRouter>
<div className="h-100">
<Switch>
<Route exact path="/" render={() => <Redirect to={`/${shortId.generate()}`} />} />
<Route path="/:roomId" component={Home} />
</Switch>
</div>
</BrowserRouter>
</Provider>
)
}
}

135
client/src/serviceWorker.js Normal file
View File

@ -0,0 +1,135 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

4
client/src/setupTests.js Normal file
View File

@ -0,0 +1,4 @@
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });

27
client/src/store/index.js Normal file
View File

@ -0,0 +1,27 @@
import { createStore, applyMiddleware, compose } from 'redux'
import reducers from 'reducers'
import thunk from 'redux-thunk'
const composeEnhancers = process.env.NODE_ENV === 'production' ? compose : (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose)
const enabledMiddlewares = [thunk]
const middlewares = applyMiddleware(...enabledMiddlewares)
export default function configureStore(preloadedState) {
const store = createStore(
reducers,
preloadedState,
composeEnhancers(middlewares)
)
if (module.hot) {
module.hot.accept('../reducers', () => {
// eslint-disable-next-line global-require
const nextRootReducer = require('../reducers/index')
store.replaceReducer(nextRootReducer)
})
}
return store
}

View File

@ -0,0 +1,228 @@
@import 'colors'
@import 'typography'
@import 'nav'
@import 'footer'
body
background-color: $primary
color: $body
ul.plain
list-style: none
margin: 0px
padding: 0px
a
cursor: pointer
button
cursor: pointer
button.btn-link.btn-plain
padding: 0px
color: white
text-decoration: none
color: white
&:hover, &:focus
color: white
button.btn-link
.page-header
height: 50px
svg.disabled
color: $inactive
svg text
user-select: none
svg:hover
background: none
svg text::selection
background: none
textarea.chat
border-radius: 0px
resize: none !important
width: 100%
max-height: 200px
min-height: 50px
background-color: $secondary
border-top: 1px solid $inactive
border-left: 0px
border-right: 0px
border-bottom: 0px
color: $body
outline: 0
font-size: 16px
padding: 12px 50px 12px 12px
body.touch textarea.chat
padding-right: 100px
input[type='text'],
textarea
font-size: 16px
.right-border
border-right: 1px solid $inactive
.bottom-border
border-bottom: 1px solid $inactive
.sidebar
top: 0
bottom: 0
left: 0
z-index: 2
overflow-x: hidden
overflow-y: auto
border-right: 1px solid $inactive
background-color: $secondary
.online-users
font-size: 11px
font-weight: bold
text-transform: uppercase
color: $inactive
margin: 10px 0
.chat-container
margin-top: auto
width: 100%
.chat-preflight-container
position: relative
.input-controls-left
position: absolute
left: 0px
bottom: 0px
height: 100%
display: flex
width: 45px
.send
padding: 0px
padding-top: 12px
width: 100%
.input-controls
position: absolute
right: 0px
bottom: 0px
height: 100%
display: flex
button
&.send
padding-top: 12px
height: 50px
border-radius: 0px
&.active
background: #007bff
color: white
.message-stream
padding: 0px 15px
overflow: auto
overflow-y: scroll
-webkit-overflow-scrolling: touch
flex: 1
display: flex
flex-direction: column
ul
width: 100%
margin-top: auto
li:last-child
border-bottom: 0
.activity-item
margin-bottom: 15px
&.SEND_MESSAGE
.chat-meta
font-size: 13px
.timestamp
font-size: 11px
padding-left: 10px
.chat
word-wrap: break-word
font-size: 16px
white-space: pre-line
.image-transfer
display: block
margin-top: 10px
max-width: 100%
@media(min-width: 576px)
.activity-item
.image-transfer
max-width: 500px
.react-modal-overlay
position: fixed
top: 0px
left: 0px
right: 0px
bottom: 0px
background-color: rgba(0, 0, 0, 0.5)
z-index: 10
.react-modal-content
-webkit-overflow-scrolling: touch
border: 1px solid rgb(204, 204, 204)
background: rgb(0, 0, 0)
overflow: auto
border-radius: 4px
outline: none
padding: 20px
max-width: 600px
width: calc(100% - 30px)
margin: 20px auto 0px auto
max-height: 75%
.close-modal
float: right
padding: 0px
.react-modal-header
overflow: auto
margin-bottom: 20px
.react-modal-footer
margin-top: 25px
button
float: right
.ellipsis
text-overflow: ellipsis
overflow: hidden
white-space: nowrap
.main-chat
padding-top: 55px
display: flex
flex-direction: column
height: 100%
.zoom-overlay
z-index: 420
background: rgba(255, 255, 255, 0.9)
position: fixed
top: 0
left: 0
right: 0
bottom: 0
pointer-events: none
filter: "alpha(opacity=0)"
opacity: 0
transition: opacity 300ms
.zoom-overlay-open .zoom-overlay
filter: "alpha(opacity=100)"
opacity: 1
.zoom-img,
.zoom-img-wrap
position: relative
z-index: 666
transition: all 300ms
img.zoom-img
cursor: zoom-out
img.zoomable
cursor: zoom-in

View File

@ -0,0 +1,10 @@
$primary: #242424
$secondary: #181818
$inactive: #424242
$body: #E7E7E7
$md-gray: #999
$dk-gray: #666
$error: #d00000
.muted
color: $dk-gray

View File

View File

@ -0,0 +1,104 @@
@import 'colors'
.logo
height: 30px
margin-right: 10px
.nav-container
position: fixed
top: 0px
width: 100%
z-index: 5
background: black
.alert-banner
width: 100%
height: 30px
background: $error
color: white
padding: 5px 15px
.icon
top: 3px
position: relative
nav
border-bottom: 1px solid $inactive
.meta
padding-left: 1rem
height: 100%
align-items: center
display: flex
flex-basis: 80%
@media(min-width: 768px)
nav .meta
flex-basis: 40%
.navbar-toggler
border: 0px
.navbar
padding-left: 0
padding-right: 0
padding-top: 0px
padding-bottom: 0px
height: 55px
.room-id
margin-right: 15px
max-width: 95px
ul.navbar-nav
li
padding-bottom: 10px
svg
width: 20px
margin-right: 10px
span
width: auto
button.lock-room
margin-right: 15px
display: flex
@media(min-width: 768px)
ul.navbar-nav li
margin-left: 25px
padding-bottom: 0px
.nav-link
display: flex
align-items: center
color: $md-gray !important
&:hover
color: white !important
.navbar-collapse
padding: 0px 1rem
background: black
.members-dropdown
margin-left: 10px
.dropdown__trigger
position: relative
top: 3px
.members-action
margin-right: 5px
.dropdown__content
background: black
border-radius: 3px
border: 1px solid $inactive
padding: 15px
width: 175px
left: -110px
ul
li
overflow: auto
height: 25px
.username
overflow: hidden
text-overflow: ellipsis
display: inline-block
width: 100px
white-space: nowrap
.icon-container
float: right
.me-icon, .owner-icon
width: 15px

View File

@ -0,0 +1,5 @@
module.exports = {
plugins: [
require('autoprefixer')({}), // eslint-disable-line
],
}

View File

@ -0,0 +1,19 @@
html, body
font-size: 14px
h1, h2, h3, h4, h5, h6
margin: 0px
h4
font-weight: normal
h5
font-size: 1.15rem
line-height: 1.3
h6
text-transform: uppercase
font-size: 0.75rem
.bold
font-weight: bold

4
client/src/test/setup.js Normal file
View File

@ -0,0 +1,4 @@
import Enzyme from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
Enzyme.configure({ adapter: new Adapter() })

View File

@ -0,0 +1,250 @@
/* eslint-disable */
function Zoom(domContent) {
const OFFSET = 80
// From http://youmightnotneedjquery.com/#offset
function offset(element) {
const rect = element.getBoundingClientRect()
return {
top: rect.top + document.body.scrollTop,
left: rect.left + document.body.scrollLeft,
}
}
function zoomListener() {
let activeZoom = null
let initialScrollPosition = null
let initialTouchPosition = null
function listen() {
domContent.addEventListener('click', (event) => {
if (event.target.tagName !== 'IMG') return
zoom(event)
})
}
function zoom(event) {
event.stopPropagation()
if (document.body.classList.contains('zoom-overlay-open')) return
if (event.target.width >= (window.innerWidth - OFFSET)) return
if (event.metaKey || event.ctrlKey) return openInNewWindow()
closeActiveZoom({ forceDispose: true })
activeZoom = vanillaZoom(event.target)
activeZoom.zoomImage()
addCloseActiveZoomListeners()
}
function openInNewWindow() {
window.open(event.target.getAttribute('data-original') ||
event.target.currentSrc ||
event.target.src,
'_blank')
}
function closeActiveZoom(options) {
options = options || { forceDispose: false }
if (!activeZoom) return
activeZoom[options.forceDispose ? 'dispose' : 'close']()
removeCloseActiveZoomListeners()
activeZoom = null
}
function addCloseActiveZoomListeners() {
// todo(fat): probably worth throttling this
window.addEventListener('scroll', handleScroll)
document.addEventListener('click', handleClick)
document.addEventListener('keyup', handleEscPressed)
document.addEventListener('touchstart', handleTouchStart)
}
function removeCloseActiveZoomListeners() {
window.removeEventListener('scroll', handleScroll)
document.removeEventListener('keyup', handleEscPressed)
document.removeEventListener('click', handleClick)
document.removeEventListener('touchstart', handleTouchStart)
}
function handleScroll(event) {
if (initialScrollPosition === null) initialScrollPosition = window.scrollY
const deltaY = initialScrollPosition - window.scrollY
if (Math.abs(deltaY) >= 40) closeActiveZoom()
}
function handleEscPressed(event) {
if (event.keyCode == 27) closeActiveZoom()
}
function handleClick(event) {
event.stopPropagation()
event.preventDefault()
closeActiveZoom()
}
function handleTouchStart(event) {
initialTouchPosition = event.touches[0].pageY
event.target.addEventListener('touchmove', handleTouchMove)
}
function handleTouchMove(event) {
if (Math.abs(event.touches[0].pageY - initialTouchPosition) <= 10) return
closeActiveZoom()
event.target.removeEventListener('touchmove', handleTouchMove)
}
return { listen }
}
var vanillaZoom = (function () {
let fullHeight = null
let fullWidth = null
let overlay = null
let imgScaleFactor = null
let targetImage = null
let targetImageWrap = null
let targetImageClone = null
function zoomImage() {
const img = document.createElement('img')
img.onload = function () {
fullHeight = Number(img.height)
fullWidth = Number(img.width)
zoomOriginal()
}
img.src = targetImage.currentSrc || targetImage.src
}
function zoomOriginal() {
targetImageWrap = document.createElement('div')
targetImageWrap.className = 'zoom-img-wrap'
targetImageWrap.style.position = 'absolute'
targetImageWrap.style.top = `${offset(targetImage).top}px`
targetImageWrap.style.left = `${offset(targetImage).left}px`
targetImageClone = targetImage.cloneNode()
targetImageClone.style.visibility = 'hidden'
targetImage.style.width = `${targetImage.offsetWidth}px`
targetImage.parentNode.replaceChild(targetImageClone, targetImage)
document.body.appendChild(targetImageWrap)
targetImageWrap.appendChild(targetImage)
targetImage.classList.add('zoom-img')
targetImage.setAttribute('data-action', 'zoom-out')
overlay = document.createElement('div')
overlay.className = 'zoom-overlay'
document.body.appendChild(overlay)
calculateZoom()
triggerAnimation()
}
function calculateZoom() {
targetImage.offsetWidth // repaint before animating
const originalFullImageWidth = fullWidth
const originalFullImageHeight = fullHeight
const maxScaleFactor = originalFullImageWidth / targetImage.width
const viewportHeight = window.innerHeight - OFFSET
const viewportWidth = window.innerWidth - OFFSET
const imageAspectRatio = originalFullImageWidth / originalFullImageHeight
const viewportAspectRatio = viewportWidth / viewportHeight
if (originalFullImageWidth < viewportWidth && originalFullImageHeight < viewportHeight) {
imgScaleFactor = maxScaleFactor
} else if (imageAspectRatio < viewportAspectRatio) {
imgScaleFactor = (viewportHeight / originalFullImageHeight) * maxScaleFactor
} else {
imgScaleFactor = (viewportWidth / originalFullImageWidth) * maxScaleFactor
}
}
function triggerAnimation() {
targetImage.offsetWidth // repaint before animating
const imageOffset = offset(targetImage)
const scrollTop = window.scrollY
const viewportY = scrollTop + (window.innerHeight / 2)
const viewportX = (window.innerWidth / 2)
const imageCenterY = imageOffset.top + (targetImage.height / 2)
const imageCenterX = imageOffset.left + (targetImage.width / 2)
const translateY = viewportY - imageCenterY
const translateX = viewportX - imageCenterX
const targetImageTransform = `scale(${imgScaleFactor})`
const targetImageWrapTransform =
`translate(${translateX}px, ${translateY}px) translateZ(0)`
targetImage.style.webkitTransform = targetImageTransform
targetImage.style.msTransform = targetImageTransform
targetImage.style.transform = targetImageTransform
targetImageWrap.style.webkitTransform = targetImageWrapTransform
targetImageWrap.style.msTransform = targetImageWrapTransform
targetImageWrap.style.transform = targetImageWrapTransform
document.body.classList.add('zoom-overlay-open')
}
function close() {
document.body.classList.remove('zoom-overlay-open')
document.body.classList.add('zoom-overlay-transitioning')
targetImage.style.webkitTransform = ''
targetImage.style.msTransform = ''
targetImage.style.transform = ''
targetImageWrap.style.webkitTransform = ''
targetImageWrap.style.msTransform = ''
targetImageWrap.style.transform = ''
if (!('transition' in document.body.style)) return dispose()
targetImage.addEventListener('transitionend', dispose)
targetImage.addEventListener('webkitTransitionEnd', dispose)
}
function dispose() {
targetImage.removeEventListener('transitionend', dispose)
targetImage.removeEventListener('webkitTransitionEnd', dispose)
if (!targetImageWrap || !targetImageWrap.parentNode) return
targetImage.classList.remove('zoom-img')
targetImage.style.width = ''
targetImage.setAttribute('data-action', 'zoom')
targetImageClone.parentNode.replaceChild(targetImage, targetImageClone)
targetImageWrap.parentNode.removeChild(targetImageWrap)
overlay.parentNode.removeChild(overlay)
document.body.classList.remove('zoom-overlay-transitioning')
}
return function (target) {
targetImage = target
return { zoomImage, close, dispose }
}
}())
zoomListener().listen()
}
export default Zoom

169
client/src/utils/crypto.js Normal file
View File

@ -0,0 +1,169 @@
export default class Crypto {
constructor() {
this._crypto = window.crypto || false
if (!this._crypto || (!this._crypto.subtle && !this._crypto.webkitSubtle)) {
return false
}
}
get crypto() {
return this._crypto
}
convertStringToArrayBufferView(str) {
const bytes = new Uint8Array(str.length)
for (let i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i)
}
return bytes
}
convertArrayBufferViewToString(buffer) {
let str = ''
for (let i = 0; i < buffer.byteLength; i++) {
str += String.fromCharCode(buffer[i])
}
return str
}
createEncryptDecryptKeys() {
return this.crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048, // can be 1024, 2048, or 4096
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
hash: { name: 'SHA-1' },
},
true, // whether the key is extractable (i.e. can be used in exportKey)
['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'] // must be ['encrypt', 'decrypt'] or ['wrapKey', 'unwrapKey']
)
}
createSecretKey() {
return this.crypto.subtle.generateKey(
{
name: 'AES-CBC',
length: 256, // can be 128, 192, or 256
},
true, // whether the key is extractable (i.e. can be used in exportKey)
['encrypt', 'decrypt'] // can be 'encrypt', 'decrypt', 'wrapKey', or 'unwrapKey'
)
}
createSigningKey() {
return this.crypto.subtle.generateKey(
{
name: 'HMAC',
hash: { name: 'SHA-256' },
},
true, // whether the key is extractable (i.e. can be used in exportKey)
['sign', 'verify'] // can be 'encrypt', 'decrypt', 'wrapKey', or 'unwrapKey'
)
}
encryptMessage(data, secretKey, iv) {
return this.crypto.subtle.encrypt(
{
name: 'AES-CBC',
// Don't re-use initialization vectors!
// Always generate a new iv every time your encrypt!
iv,
},
secretKey, // from generateKey or importKey above
data // ArrayBuffer of data you want to encrypt
)
}
decryptMessage(data, secretKey, iv) {
return this.crypto.subtle.decrypt(
{
name: 'AES-CBC',
iv, // The initialization vector you used to encrypt
},
secretKey, // from generateKey or importKey above
data // ArrayBuffer of the data
)
}
importEncryptDecryptKey(jwkData, format = 'jwk', ops) {
const hashObj = {
name: 'RSA-OAEP',
hash: { name: 'SHA-1' },
}
return this.crypto.subtle.importKey(
format, // can be 'jwk' (public or private), 'spki' (public only), or 'pkcs8' (private only)
jwkData,
hashObj,
true, // whether the key is extractable (i.e. can be used in exportKey)
ops || ['encrypt', 'wrapKey'] // 'encrypt' or 'wrapKey' for public key import or
// 'decrypt' or 'unwrapKey' for private key imports
)
}
exportKey(key, format) {
return this.crypto.subtle.exportKey(
format || 'jwk', // can be 'jwk' (public or private), 'spki' (public only), or 'pkcs8' (private only)
key // can be a publicKey or privateKey, as long as extractable was true
)
}
signMessage(data, keyToSignWith) {
return this.crypto.subtle.sign(
{
name: 'HMAC',
hash: { name: 'SHA-256' },
},
keyToSignWith, // from generateKey or importKey above
data // ArrayBuffer of data you want to sign
)
}
verifyPayload(signature, data, keyToVerifyWith) {
// Will verify with sender's public key
return this.crypto.subtle.verify(
{
name: 'HMAC',
hash: { name: 'SHA-256' },
},
keyToVerifyWith, // from generateKey or importKey above
signature, // ArrayBuffer of the signature
data // ArrayBuffer of the data
)
}
wrapKey(keyToWrap, keyToWrapWith, format = 'jwk') {
return this.crypto.subtle.wrapKey(
format,
keyToWrap,
keyToWrapWith,
{
name: 'RSA-OAEP',
hash: { name: 'SHA-1' },
}
)
}
unwrapKey(
format = 'jwk',
wrappedKey,
unwrappingKey,
unwrapAlgo,
unwrappedKeyAlgo, // AES-CBC for session, HMAC for signing
extractable = true,
keyUsages// verify for signing // decrypt for session
) {
return this.crypto.subtle.unwrapKey(
format,
wrappedKey,
unwrappingKey,
unwrapAlgo,
unwrappedKeyAlgo,
extractable,
keyUsages
)
}
}

14
client/src/utils/dom.js Normal file
View File

@ -0,0 +1,14 @@
/* eslint-disable import/prefer-default-export */
export const getSelectedText = () => {
let text = ''
if (typeof window.getSelection !== 'undefined') {
text = window.getSelection().toString()
} else if (typeof document.selection !== 'undefined' && document.selection.type === 'Text') {
text = document.selection.createRange().text
}
return text
}
export const hasTouchSupport =
('ontouchstart' in window)
|| (window.DocumentTouch && document instanceof window.DocumentTouch)

29
client/src/utils/file.js Normal file
View File

@ -0,0 +1,29 @@
/* eslint-disable import/prefer-default-export */
export const getObjectUrl = (encodedFile, fileType) => {
const b64 = unescape(encodedFile)
const sliceSize = 1024
const byteCharacters = window.atob(b64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
if (byteArrays.length <= 0) {
return ''
}
const blob = new window.Blob(byteArrays, { type: fileType })
const url = window.URL.createObjectURL(blob)
return url
}

View File

@ -0,0 +1,4 @@
/* eslint-disable import/prefer-default-export */
export function sanitize(str) {
return str.replace(/[^A-Za-z0-9]/g, '-').replace(/[<>]/ig, '')
}

122
client/src/utils/message.js Normal file
View File

@ -0,0 +1,122 @@
import Crypto from './crypto'
const crypto = new Crypto()
export const process = (payload, state) => new Promise(async (resolve, reject) => {
const privateKeyJson = state.user.privateKey
const privateKey = await crypto.importEncryptDecryptKey(privateKeyJson, 'jwk', ['decrypt', 'unwrapKey'])
let sessionKey
let signingKey
const iv = await crypto.convertStringToArrayBufferView(payload.iv)
const signature = await crypto.convertStringToArrayBufferView(payload.signature)
const payloadBuffer = await crypto.convertStringToArrayBufferView(payload.payload)
await new Promise((resolvePayload) => {
payload.keys.forEach(async (key) => {
try {
sessionKey = await crypto.unwrapKey(
'jwk',
key.sessionKey,
privateKey,
{
name: 'RSA-OAEP',
hash: { name: 'SHA-1' },
},
{ name: 'AES-CBC' },
true,
['decrypt']
)
signingKey = await crypto.unwrapKey(
'jwk',
key.signingKey,
privateKey,
{
name: 'RSA-OAEP',
hash: { name: 'SHA-1' },
},
{ name: 'HMAC', hash: { name: 'SHA-256' } },
true,
['verify']
)
resolvePayload()
} catch (e) { } // eslint-disable-line
})
})
const verified = await crypto.verifyPayload(
signature,
payloadBuffer,
signingKey
)
if (!verified) {
reject()
return
}
const decryptedPayload = await crypto.decryptMessage(
payloadBuffer,
sessionKey,
iv
)
const payloadJson = JSON.parse(crypto.convertArrayBufferViewToString(new Uint8Array(decryptedPayload)))
resolve(payloadJson)
})
export const prepare = (payload, state) => new Promise(async (resolve) => {
const myUsername = state.user.username
const myId = state.user.id
const sessionKey = await crypto.createSecretKey()
const signingKey = await crypto.createSigningKey()
const iv = await crypto.crypto.getRandomValues(new Uint8Array(16))
const jsonToSend = {
...payload,
payload: {
...payload.payload,
sender: myId,
username: myUsername,
text: encodeURI(payload.payload.text),
},
}
const payloadBuffer = crypto.convertStringToArrayBufferView(JSON.stringify(jsonToSend))
const encryptedPayload = await crypto.encryptMessage(payloadBuffer, sessionKey, iv)
const payloadString = await crypto.convertArrayBufferViewToString(new Uint8Array(encryptedPayload))
const signature = await crypto.signMessage(encryptedPayload, signingKey)
const encryptedKeys = await Promise.all(state.room.members
.map(async (member) => {
const key = await crypto.importEncryptDecryptKey(member.publicKey)
const enc = await Promise.all([
crypto.wrapKey(sessionKey, key),
crypto.wrapKey(signingKey, key),
])
return {
sessionKey: enc[0],
signingKey: enc[1],
}
})
)
const ivString = await crypto.convertArrayBufferViewToString(new Uint8Array(iv))
const signatureString = await crypto.convertArrayBufferViewToString(new Uint8Array(signature))
resolve({
toSend: {
payload: payloadString,
signature: signatureString,
iv: ivString,
keys: encryptedKeys,
},
original: jsonToSend,
})
})

View File

@ -0,0 +1,17 @@
import socketIO from 'socket.io-client'
import apiConfig from '../api/config'
import generateUrl from '../api/generator';
let socket
export const connect = (roomId) => {
socket = socketIO(generateUrl(), {
query: {
roomId,
},
forceNew: true,
})
return socket
}
export const getIO = () => socket

11228
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,7 @@
"build": "./build.sh",
"start": "cd server && CLIENT_DIST_DIRECTORY='../client/build' yarn start",
"dev": "concurrently 'cd client && yarn start' 'cd server && yarn dev'",
"setup": "./setup.sh"
"test": "echo 'tests'"
},
"devDependencies": {
"concurrently": "^4.1.0"

View File

@ -6,15 +6,11 @@ Simple encrypted web chat. Powered by [socket.io](http://socket.io), the [web cr
### Darkwire Server
Darkwire server is a Node.js application that requires redis.
[darkwire-server](https://github.com/darkwire/darkwire-server)
[Darkwire server](/server) is a Node.js application that requires redis.
### Darkwire Web Client
The Darkwire.io web client is written in JavaScript with React JS and Redux.
[darkwire-client](https://github.com/darkwire/darkwire-client)
The Darkwire.io [web client](/client) is written in JavaScript with React JS and Redux.
### Development
@ -26,12 +22,6 @@ Install dependencies
$ yarn
```
Pull down the client and server repos
```
$ yarn setup
```
Start server and client
```
@ -40,10 +30,9 @@ $ yarn dev
### Production
Pull server and client repos down and create production builds of each.
Create server and client production builds
```
$ yarn setup
$ yarn build
```

7
server/.babelrc Normal file
View File

@ -0,0 +1,7 @@
{
"presets": ["@babel/preset-env"],
"plugins": [
"@babel/plugin-transform-async-to-generator",
"@babel/plugin-transform-runtime"
]
}

7
server/.env.sample Normal file
View File

@ -0,0 +1,7 @@
MAILGUN_API_KEY=api-key
MAILGUN_DOMAIN=darkwire.io
ABUSE_TO_EMAIL_ADDRESS=abuse@darkwire.io
ABUSE_FROM_EMAIL_ADDRESS=Darkwire <no-reply@darkwire.io>
REDIS_URL=redis://localhost:6379
CLIENT_DIST_DIRECTORY='client/dist/path'
ROOM_HASH_SECRET='some-uuid'

7
server/.eslintrc Normal file
View File

@ -0,0 +1,7 @@
{
"parser": "babel-eslint",
"rules": {
"semi": ["error", "always"],
"quotes": ["error", "single"]
}
}

7
server/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
build
*.log
*.rdb
.env
*sublime*

21
server/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016-present darkwire.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
server/README.md Normal file
View File

@ -0,0 +1,3 @@
# Darkwire Server
This is the backend for [Darkwire](https://github.com/darkwire/darkwire.io). It requires [darkwire-client](../client) in order to run.

9
server/build.sh Executable file
View File

@ -0,0 +1,9 @@
rm -rf build
rm -rf dist
npx babel src -d dist/src --copy-files
sha=`git rev-parse HEAD`
echo $sha
perl -pi -e "s/SHA/$sha/g" dist/src/config.json

50
server/package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "darkwire-server",
"version": "2.0.0-beta.8",
"main": "index.js",
"license": "MIT",
"contributors": [
{
"name": "Daniel Seripap"
},
{
"name": "Alan Friedman"
}
],
"dependencies": {
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/runtime": "^7.4.4",
"bluebird": "^3.5.1",
"dotenv": "^8.0.0",
"kcors": "2",
"koa": "^2.3.0",
"koa-body": "^2.3.0",
"koa-router": "^7.2.1",
"koa-send": "^5.0.0",
"koa-static": "^5.0.0",
"lodash": "^4.17.4",
"mailgun-js": "^0.22.0",
"moment": "^2.18.1",
"redis": "^2.8.0",
"socket.io": "^2.0.3",
"socket.io-redis": "^5.2.0",
"uuid": "^3.3.2"
},
"scripts": {
"build": "./build.sh",
"dev": "nodemon src/index.js --exec babel-node",
"test": "jest",
"test:watch": "jest --watch",
"start": "node dist/src/index.js"
},
"devDependencies": {
"@babel/cli": "^7.4.4",
"@babel/core": "^7.4.4",
"@babel/node": "^7.2.2",
"@babel/plugin-transform-async-to-generator": "^7.4.4",
"@babel/preset-env": "^7.4.4",
"eslint": "^4.6.1",
"jest": "^21.0.2",
"nodemon": "^1.19.0"
}
}

4
server/src/config.json Normal file
View File

@ -0,0 +1,4 @@
{
"version": "VERSION",
"sha": "SHA"
}

143
server/src/index.js Normal file
View File

@ -0,0 +1,143 @@
require('dotenv').config()
import http from 'http';
import https from 'https';
import Koa from 'koa';
import Io from 'socket.io';
import KoaBody from 'koa-body';
import cors from 'kcors';
import Router from 'koa-router';
import config from './config';
import bluebird from 'bluebird';
import Redis from 'redis';
import socketRedis from 'socket.io-redis';
import Socket from './socket';
import crypto from 'crypto'
import mailer from './utils/mailer';
import koaStatic from 'koa-static';
import koaSend from 'koa-send';
bluebird.promisifyAll(Redis.RedisClient.prototype);
bluebird.promisifyAll(Redis.Multi.prototype);
const redis = Redis.createClient(process.env.REDIS_URL)
export const getRedis = () => redis
const env = process.env.NODE_ENV || 'development';
const app = new Koa();
const PORT = process.env.PORT || 3001;
const router = new Router();
const koaBody = new KoaBody();
app.use(cors({
credentials: true,
}));
router.post('/handshake', koaBody, async (ctx) => {
const { body } = ctx.request;
const { roomId } = body;
const roomIdHash = getRoomIdHash(roomId)
let roomExists = await redis.hgetAsync('rooms', roomIdHash)
if (roomExists) {
roomExists = JSON.parse(roomExists)
}
ctx.body = {
id: roomId,
ready: true,
isLocked: Boolean(roomExists && roomExists.isLocked),
size: ((roomExists && roomExists.users.length) || 0) + 1,
version: config.version,
sha: config.sha,
};
});
router.post('/abuse/:roomId', koaBody, async (ctx) => {
let { roomId } = ctx.params;
roomId = roomId.trim();
if (process.env.ABUSE_FROM_EMAIL_ADDRESS && process.env.ABUSE_TO_EMAIL_ADDRESS) {
const abuseForRoomExists = await redis.hgetAsync('abuse', roomId);
if (!abuseForRoomExists) {
mailer.send({
from: process.env.ABUSE_FROM_EMAIL_ADDRESS,
to: process.env.ABUSE_TO_EMAIL_ADDRESS,
subject: 'Darkwire Abuse Notification',
text: `Room ID: ${roomId}`
});
}
}
await redis.hincrbyAsync('abuse', roomId, 1);
ctx.status = 200;
});
app.use(router.routes());
const clientDistDirectory = process.env.CLIENT_DIST_DIRECTORY;
if (clientDistDirectory) {
app.use(koaStatic(clientDistDirectory));
app.use(async (ctx) => {
await koaSend(ctx, 'index.html', { root: clientDistDirectory });
})
} else {
app.use(async ctx => {
ctx.body = { ready: true };
});
}
const protocol = (process.env.PROTOCOL || 'http') === 'http' ? http : https;
const server = protocol.createServer(app.callback());
const io = Io(server);
io.adapter(socketRedis(process.env.REDIS_URL));
const roomHashSecret = process.env.ROOM_HASH_SECRET;
const getRoomIdHash = (id) => {
if (env === 'development') {
return id
}
if (roomHashSecret) {
return crypto
.createHmac('sha256', roomHashSecret)
.update(id)
.digest('hex')
}
return crypto.createHash('sha256').update(id).digest('hex');
}
export const getIO = () => io
io.on('connection', async (socket) => {
const roomId = socket.handshake.query.roomId
const roomIdHash = getRoomIdHash(roomId)
let room = await redis.hgetAsync('rooms', roomIdHash)
room = JSON.parse(room || '{}')
new Socket({
roomId: roomIdHash,
socket,
room,
})
})
const init = async () => {
server.listen(PORT, () => {
console.log(`Darkwire is online at port ${PORT}`);
})
}
init()

134
server/src/socket.js Normal file
View File

@ -0,0 +1,134 @@
import _ from 'lodash';
import uuid from 'uuid/v4';
import { getIO, getRedis } from './index'
export default class Socket {
constructor(opts) {
const { roomId, socket, room } = opts
this._roomId = roomId
if (room.isLocked) {
return
}
this.init(opts)
}
async init(opts) {
const { roomId, socket, room } = opts
await this.joinRoom(roomId, socket.id)
this.handleSocket(socket)
}
async saveRoom(room) {
const json = {
...room,
updatedAt: Date.now(),
}
return getRedis().hsetAsync('rooms', this._roomId, JSON.stringify(json))
}
async destroyRoom() {
return getRedis().hdel('rooms', this._roomId)
}
fetchRoom() {
return new Promise(async (resolve, reject) => {
const res = await getRedis().hgetAsync('rooms', this._roomId)
resolve(JSON.parse(res || '{}'))
})
}
joinRoom(roomId, socketId) {
return new Promise((resolve, reject) => {
getIO().of('/').adapter.remoteJoin(socketId, roomId, (err) => {
if (err) {
reject()
}
resolve()
});
});
}
async handleSocket(socket) {
socket.on('PAYLOAD', (payload) => {
socket.to(this._roomId).emit('PAYLOAD', payload);
});
socket.on('USER_ENTER', async payload => {
let room = await this.fetchRoom()
if (_.isEmpty(room)) {
room = {
id: this._roomId,
users: [],
isLocked: false,
createdAt: Date.now(),
}
}
const newRoom = {
...room,
users: [...(room.users || []), {
socketId: socket.id,
publicKey: payload.publicKey,
isOwner: (room.users || []).length === 0,
}]
}
await this.saveRoom(newRoom)
getIO().to(this._roomId).emit('USER_ENTER', newRoom.users.map(u => ({
publicKey: u.publicKey,
isOwner: u.isOwner,
})));
})
socket.on('TOGGLE_LOCK_ROOM', async (data, callback) => {
const room = await this.fetchRoom()
const user = (room.users || []).find(u => u.socketId === socket.id && u.isOwner)
if (!user) {
callback({
isLocked: room.isLocked,
})
return
}
await this.saveRoom({
...room,
isLocked: !room.isLocked,
})
socket.to(this._roomId).emit('TOGGLE_LOCK_ROOM', {
locked: !room.isLocked,
publicKey: user && user.publicKey
});
callback({
isLocked: !room.isLocked,
})
});
socket.on('disconnect', () => this.handleDisconnect(socket));
}
async handleDisconnect(socket) {
let room = await this.fetchRoom()
const newRoom = {
...room,
users: (room.users || []).filter(u => u.socketId !== socket.id).map((u, index) => ({
...u,
isOwner: index === 0,
}))
}
await this.saveRoom(newRoom)
getIO().to(this._roomId).emit('USER_EXIT', newRoom.users);
if (newRoom.users && newRoom.users.length === 0) {
await this.destroyRoom()
}
}
}

View File

@ -0,0 +1,3 @@
export function sanitize(str) {
return str.replace(/[^A-Za-z0-9]/g, '-');
}

View File

@ -0,0 +1,24 @@
require('dotenv').config()
const {
MAILGUN_API_KEY,
MAILGUN_DOMAIN,
} = process.env;
const apiKey = MAILGUN_API_KEY;
const domain = MAILGUN_DOMAIN;
let mailgun;
if (apiKey && domain) {
mailgun = require('mailgun-js')({apiKey, domain});
}
module.exports.send = (data) => {
if (!mailgun) {
return;
}
mailgun.messages().send(data, function (error, body) {
});
}

View File

@ -0,0 +1,5 @@
import { sanitize } from './index.js';
test('sanitizes should strip bad characters', () => {
expect(sanitize('d@rkW1r# e is L3git&&!&*A*')).toBe('d-rkW1r--e-is-L3git-----A-');
});

5988
server/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +0,0 @@
rm -rf client server
git clone https://github.com/darkwire/darkwire-client client
git clone https://github.com/darkwire/darkwire-server server