This commit is contained in:
Morten Olsen
2021-06-09 00:03:41 +02:00
commit b1b24f9774
14 changed files with 5990 additions and 0 deletions

25
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Build and Deploy
on:
workflow_dispatch:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2.3.1 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
with:
persist-credentials: false
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
run: |
yarn install
NODE_ENV=production yarn build
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@4.0.0
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: gh-pages # The branch the action should deploy to.
FOLDER: dist # The folder the action should deploy.

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules
/.cache
/dist
/*.log

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title></title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="root"></div>
<script src="src/index.tsx"></script>
</body>
</html>

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "share",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "parcel build index.html"
},
"devDependencies": {
"@types/react": "^17.0.9",
"@types/react-dom": "^17.0.6",
"@types/react-qr-reader": "^2.1.3",
"parcel-bundler": "^1.12.5",
"typescript": "^4.3.2"
},
"dependencies": {
"nanoid": "^3.1.23",
"peerjs": "^1.3.2",
"react": "17.0.2",
"react-dom": "^17.0.2",
"react-dropzone": "^11.3.2",
"react-qr-code": "^1.1.1",
"react-qr-reader": "^2.2.1"
},
"browserslist": [
"last 1 Chrome version"
]
}

20
src/App.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react';
import Welcome from './containers/Welcome';
import Connected from './containers/Connected';
import useConnection, { ConnectionStates } from './hooks/useConnection';
const App: React.FC<{}> = () => {
const { state } = useConnection();
if (state === ConnectionStates.WAITING) {
return <Welcome />
}
if (state === ConnectionStates.CONNECTED) {
return <Connected />
}
return (
<div>Connected</div>
);
};
export default App;

View File

@@ -0,0 +1,54 @@
import React, { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import useConnection from '../hooks/useConnection';
const readFile = (file: File) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve({
name: file.name,
type: file.type,
body: reader.result,
});
};
reader.onerror = (err) => {
reject(err);
};
reader.readAsDataURL(file);
});
const Connected: React.FC<{}> = () => {
const { send, messages } = useConnection();
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const files = await Promise.all(acceptedFiles.map(readFile));
files.forEach(send);
}, [send]);
const {
getRootProps,
getInputProps,
isDragActive,
} = useDropzone({onDrop})
return (
<>
<div {...getRootProps()}>
<input {...getInputProps()} />
{ isDragActive ? (
<p>Drop the files here ...</p>
):(
<p>Drag 'n' drop some files here, or click to select files</p>
)}
</div>
{messages.map((message) => (
<div>
{message.name}-{message.body.length}
<img style={{ width: 300, height: 300 }} src={message.body} />
</div>
))}
</>
);
}
export default Connected;

View File

@@ -0,0 +1,45 @@
import React, { useCallback, useEffect } from 'react';
import QRCode from 'react-qr-code';
import QRReader from 'react-qr-reader'
import useConnection from '../hooks/useConnection';
const Welcome: React.FC<{}> = () => {
const { connect, clientInfo } = useConnection();
const onScan = useCallback(
(result) => {
if (result) {
connect(JSON.parse(result));
}
},
[],
);
useEffect(() => {
const hash = window.location.hash;
if (hash) {
const clientInfoEncoded = hash.substring(1);
const clientInfo = JSON.parse(atob(clientInfoEncoded));
connect(clientInfo);
}
console.log(hash);
}, []);
return (
<>
<div>{location.protocol}//{location.host}{location.pathname}#{btoa(JSON.stringify(clientInfo))}</div>
<QRCode
value={JSON.stringify(clientInfo)}
size={300}
/>
<QRReader
delay={300}
onScan={onScan}
onError={(result) => { console.error(result) }}
style={{ width: '300px', height: '300px' }}
/>
</>
);
}
export default Welcome;

View File

@@ -0,0 +1,126 @@
import React, { createContext, useMemo, useState, useCallback, useEffect } from 'react';
import Peer, { DataConnection } from 'peerjs';
import { nanoid } from 'nanoid';
import useCrypto from '../hooks/useCrypto';
enum States {
WAITING,
CONNECTING,
CONNECTED,
}
interface ConnectionContextValue {
clientInfo: any;
state: States;
messages: any[];
send: (message: any) => Promise<void>;
connect: (connectionInfo: any) => Promise<void>;
}
interface Message {
id: string;
packages: number;
content: string;
}
function dataURItoBlob(dataURI: string) {
var mime = dataURI.split(',')[0].split(':')[1].split(';')[0];
var binary = atob(dataURI.split(',')[1]);
var array = [];
for (var i = 0; i < binary.length; i++) {
array.push(binary.charCodeAt(i));
}
const blob = new Blob([new Uint8Array(array)], {type: mime});
return URL.createObjectURL(blob);
}
const ConnectionContext = createContext<ConnectionContextValue>(undefined as any);
const ConnectionProvider: React.FC = ({ children }) => {
const [secret, setSecret] = useState(nanoid());
const { encrypt, decrypt } = useCrypto(secret);
const id = useMemo(() => nanoid(), []);
const peer = useMemo(() => new Peer(id), [id]);
const [connection, setConnection] = useState<DataConnection | undefined>(undefined);
const [messages, setMessages] = useState<Message[]>([]);
const [state, setState] = useState<States>(States.WAITING);
const clientInfo = useMemo(() => ({
id,
secret,
}), [id]);
const send = useCallback(async (message: any) => {
if (!connection) return;
setMessages(current => [
...current,
{
...message,
body: dataURItoBlob(message.body),
},
]);
connection.send(await encrypt(message));
}, [connection, encrypt]);
const connect = useCallback(async (clientInfo: any) => {
setState(States.CONNECTING);
const newConnection = peer.connect(clientInfo.id);
newConnection.on('open', () => {
setSecret(clientInfo.secret);
setState(States.CONNECTED);
setConnection(newConnection);
console.log('connected', newConnection);
});
}, [peer]);
useEffect(() => {
const onConnect = (newConnection: DataConnection) => {
setState(States.CONNECTED);
setConnection(newConnection);
console.log('connected', newConnection);
};
peer.on('connection', onConnect);
return () => {
peer.off('connection', onConnect);
};
}, [peer]);
useEffect(() => {
if (!connection) {
return;
}
const handleData = async (encrypted: any) => {
const message = await decrypt(encrypted);
setMessages(current => [
...current,
{
...message,
body: dataURItoBlob(message.body),
},
]);
};
connection.on('data', handleData);
return () => {
connection.off('data', handleData);
}
}, [connection, decrypt]);
return (
<ConnectionContext.Provider
value={{
clientInfo,
state,
messages,
send,
connect,
}}
>
{children}
</ConnectionContext.Provider>
);
};
export { States, ConnectionProvider };
export default ConnectionContext;

View File

@@ -0,0 +1,11 @@
import { useContext } from 'react';
import ConnectionContext, { States } from '../contexts/ConnectionContext';
const useConnection = () => {
const context = useContext(ConnectionContext);
return context;
};
export const ConnectionStates = States;
export default useConnection;

22
src/hooks/useCrypto.ts Normal file
View File

@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import { encrypt, decrypt } from '../utils/crypto';
const useCrypto = (secret: string) => {
const doEncrypt = useCallback(async (data: any) => {
const raw = JSON.stringify(data);
const result = await encrypt(raw, secret);
return result;
}, [secret]);
const doDecrypt = useCallback(async (data: string[]) => {
return decrypt(data, secret);
}, [secret]);
return {
encrypt: doEncrypt,
decrypt: doDecrypt,
};
}
export default useCrypto;

13
src/index.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import { render } from 'react-dom';
import { ConnectionProvider } from './contexts/ConnectionContext';
import App from './App';
const root = document.getElementById('root');
const app = (
<ConnectionProvider>
<App />
</ConnectionProvider>
);
render(app, root);

92
src/utils/crypto.ts Normal file
View File

@@ -0,0 +1,92 @@
function chunkSubstr(str: string, size: number) {
const numChunks = Math.ceil(str.length / size)
const chunks = new Array(numChunks)
for (let i = 0, o = 0; i < numChunks; ++i, o += size) {
chunks[i] = str.substr(o, size)
}
return chunks
}
const buff_to_base64 = (buff) => btoa(String.fromCharCode.apply(null, buff));
const base64_to_buf = (b64) =>
Uint8Array.from(atob(b64), (c) => c.charCodeAt(null));
const enc = new TextEncoder();
const dec = new TextDecoder();
const getPasswordKey = (password: string) =>
window.crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, [
"deriveKey",
]);
const deriveKey = (passwordKey: CryptoKey, salt: Uint8Array, keyUsage: KeyUsage[]) =>
window.crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 50000,
hash: "SHA-256",
},
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
keyUsage
);
export async function encrypt(secretData: string, password: string) {
const parts = chunkSubstr(secretData, 1000000);
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const passwordKey = await getPasswordKey(password);
const resultParts = await Promise.all(parts.map(async (part) => {
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const aesKey = await deriveKey(passwordKey, salt, ["encrypt"]);
const encryptedContent = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
aesKey,
enc.encode(part)
);
const encryptedContentArr = new Uint8Array(encryptedContent);
let buff = new Uint8Array(
salt.byteLength + iv.byteLength + encryptedContentArr.byteLength
);
buff.set(salt, 0);
buff.set(iv, salt.byteLength);
buff.set(encryptedContentArr, salt.byteLength + iv.byteLength);
//const base64Buff = buff_to_base64(buff);
return buff;
}));
return resultParts;
}
export async function decrypt(encryptedData: Uint8Array[], password: string) {
try {
const passwordKey = await getPasswordKey(password);
const parts = await Promise.all(encryptedData.map(async (part) => {
const encryptedDataBuff = part;
const salt = encryptedDataBuff.slice(0, 16);
const iv = encryptedDataBuff.slice(16, 16 + 12);
const data = encryptedDataBuff.slice(16 + 12);
const aesKey = await deriveKey(passwordKey, salt, ["decrypt"]);
const decryptedContent = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
aesKey,
data
);
return dec.decode(decryptedContent);
}));
return JSON.parse(parts.join(''));
} catch (e) {
console.log(`Error - ${e}`);
return "";
}
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"jsx": "react",
"strict": true,
"esModuleInterop": true,
"sourceMap": true,
"outDir": "./dist",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"./src"
]
}

5522
yarn.lock Normal file

File diff suppressed because it is too large Load Diff