mirror of
https://github.com/morten-olsen/catpic.delivery.git
synced 2026-02-08 01:46:26 +01:00
init
This commit is contained in:
25
.github/workflows/publish.yml
vendored
Normal file
25
.github/workflows/publish.yml
vendored
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules
|
||||
/.cache
|
||||
/dist
|
||||
/*.log
|
||||
12
index.html
Normal file
12
index.html
Normal 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
28
package.json
Normal 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
20
src/App.tsx
Normal 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;
|
||||
54
src/containers/Connected.tsx
Normal file
54
src/containers/Connected.tsx
Normal 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;
|
||||
45
src/containers/Welcome.tsx
Normal file
45
src/containers/Welcome.tsx
Normal 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;
|
||||
126
src/contexts/ConnectionContext.tsx
Normal file
126
src/contexts/ConnectionContext.tsx
Normal 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;
|
||||
11
src/hooks/useConnection.ts
Normal file
11
src/hooks/useConnection.ts
Normal 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
22
src/hooks/useCrypto.ts
Normal 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
13
src/index.tsx
Normal 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
92
src/utils/crypto.ts
Normal 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
16
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user