grub/client/Upload.tsx
2023-08-10 23:11:55 -05:00

290 lines
7.7 KiB
TypeScript

import { useCallback, useContext, useEffect, useState } from "react";
import * as sodium from "libsodium-wrappers-sumo";
import useWebSocket from "react-use-websocket";
import CBOR from "cbor";
import { wsUrl } from "./constants";
import { RoomContext } from "./lib/roomContext";
import { useDebouncedCallback } from "use-debounce";
import {
Row,
Col,
Button,
Form,
Badge,
OverlayTrigger,
Tooltip,
} from "react-bootstrap";
export default function Upload() {
const { roomId } = useContext(RoomContext);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [encryptionKey, setEncryptionKey] = useState(null);
const [passphrase, setPassphrase] = useState("");
const [uploadProgress, setUploadProgress] = useState(null);
const setHashedPassphrase = useDebouncedCallback(
(passphrase) => {
if (passphrase) {
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
console.log("salt", salt);
setEncryptionKey(
sodium.crypto_pwhash(
sodium.crypto_box_SEEDBYTES,
passphrase,
salt,
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
sodium.crypto_pwhash_ALG_ARGON2ID13
)
);
}
},
// delay in ms
1000
);
const [decryptionKey, setDecryptionKey] = useState("");
const [selectedSaveFile, setSelectedSaveFile] = useState(null);
const [decryptState, setDecryptState] = useState(null);
const [pendingDecryption, setPendingDecryption] = useState([]);
let decryptionKeyInternal: Uint8Array;
if (decryptionKey) {
decryptionKeyInternal = hex2Bytes(decryptionKey);
}
const [writeStream, setWriteStream] = useState(null);
const readyToDownload =
!!decryptionKeyInternal && !!selectedSaveFile && !!writeStream;
const { sendMessage } = useWebSocket(wsUrl, {
share: true,
onMessage: async (event) => {
const message = CBOR.decode(await event.data.arrayBuffer());
if (message.type === "FileUploadBegin") {
const state = sodium.crypto_secretstream_xchacha20poly1305_init_pull(
message.header,
decryptionKeyInternal
);
setDecryptState(state);
}
if (message.type === "FileUploadChunk") {
if (readyToDownload && decryptState) {
for (const { data } of [...pendingDecryption, message]) {
const { message } =
sodium.crypto_secretstream_xchacha20poly1305_pull(
decryptState,
data
);
const result = await writeStream.write(message);
}
setPendingDecryption([]);
} else {
setPendingDecryption([...pendingDecryption, message]);
}
}
if (message.type === "FileUploadComplete") {
await writeStream.close();
}
},
});
function sendCborMessage(data) {
let cbor = CBOR.encode(data);
sendMessage(cbor);
}
useEffect(() => {
(async () => {
await sodium.ready;
if (passphrase) {
setHashedPassphrase(passphrase);
} else {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
setEncryptionKey(key);
}
})();
}, [passphrase]);
const uploadFile = useCallback(async () => {
if (!encryptionKey) return;
if (!selectedFile) return;
const { state, header } =
sodium.crypto_secretstream_xchacha20poly1305_init_push(encryptionKey);
sendCborMessage({
type: "FileUploadBegin",
room_id: roomId,
header,
});
const stream = selectedFile.stream();
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Encrypt this chunk
const encryptedValue =
sodium.crypto_secretstream_xchacha20poly1305_push(
state,
value,
null,
0
);
sendCborMessage({
type: "FileUploadChunk",
room_id: roomId,
data: encryptedValue,
});
}
sendCborMessage({
type: "FileUploadComplete",
room_id: roomId,
});
} finally {
reader.releaseLock();
}
}, [encryptionKey, selectedFile]);
async function selectSaveFile() {
const newHandle = await window.showSaveFilePicker();
setSelectedSaveFile(newHandle);
const writableStream = await newHandle.createWritable();
setWriteStream(writableStream);
}
return (
<>
<h3>How does this work?</h3>
<p style={{ color: "darkred" }}>
<b>
RECEIVER MUST BE USING CHROME{" "}
<a
href="https://caniuse.com/mdn-api_window_showsavefilepicker"
target="_blank"
>
[?]
</a>
</b>
</p>
<ol>
<li>
<b>Uploader:</b> Copy the key and send it to whoever you're sending
files to
</li>
<li>
<b>Receiver:</b> Paste the key into the "Paste key here" box
</li>
<li>
<b>Receiver:</b> Press the "select save file" button to choose where
to save the file to
</li>
<li>
<b>Receiver:</b> Tell the uploader to press "upload"
</li>
<li>
<b>Uploader:</b> Press "upload"
</li>
</ol>
<p>Only one person can upload at a time.</p>
<Row>
<Col md={6}>
<h3>Send file</h3>
{/* <p>
Upload passphrase:
<Form.Control
value={passphrase}
onChange={(e) => {
setEncryptionKey(null);
setPassphrase(e.target.value);
}}
/>
</p> */}
<p>
Upload Key:{" "}
<Form.Control
type="text"
disabled
value={encryptionKey ? bytes2Hex(encryptionKey) : "generating..."}
/>
</p>
<input
type="file"
onChange={(e) => setSelectedFile(e.target.files[0])}
/>
<Button onClick={uploadFile} disabled={!encryptionKey}>
upload
</Button>
</Col>
<Col md={6}>
<h3>
Receive file
{readyToDownload ? (
<Badge bg="success">ready</Badge>
) : (
<OverlayTrigger
placement="top"
overlay={
<Tooltip>
you are not ready to download, make sure you enter the key
and select a place to save
</Tooltip>
}
>
<Badge bg="warning">not ready</Badge>
</OverlayTrigger>
)}
</h3>
<ol>
<li>
<Form.Control
type="text"
value={decryptionKey}
onChange={(e) => setDecryptionKey(e.target.value)}
placeholder="Paste key here..."
/>
</li>
<li>
<Button onClick={selectSaveFile} disabled={!!selectedSaveFile}>
{selectedSaveFile
? `Saving to "${selectedSaveFile.name}"`
: "select save file"}
</Button>
</li>
</ol>
</Col>
</Row>
</>
);
}
function bytes2Hex(byteArray: Uint8Array): string {
return Array.prototype.map
.call(byteArray, function (byte: number) {
return ("0" + (byte & 0xff).toString(16)).slice(-2);
})
.join("");
}
function hex2Bytes(hexString: string): Uint8Array {
let result = [];
for (var i = 0; i < hexString.length; i += 2) {
result.push(parseInt(hexString.substr(i, 2), 16));
}
return new Uint8Array(result);
}