Skip to content

Commit

Permalink
experimental editor
Browse files Browse the repository at this point in the history
  • Loading branch information
alextusinean committed Jul 3, 2024
1 parent 4b37b8a commit cf71fcb
Show file tree
Hide file tree
Showing 3 changed files with 198 additions and 46 deletions.
189 changes: 154 additions & 35 deletions components/cryptForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Box,
Button,
Text,
Link,
Checkbox,
useToast,
Modal,
Expand All @@ -16,6 +17,7 @@ import {
import { FaDownload, FaEdit } from 'react-icons/fa';
import { useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import NextLink from 'next/link';
import crypto from 'crypto';

const Editor = dynamic(() => import('./editor'), { ssr: false });
Expand All @@ -24,6 +26,15 @@ function isGzip(data) {
return data[0] == 0x1F && data[1] == 0x8B;
}

function isJSON(data) {
try {
JSON.parse(data.toString());
} catch (e) {
return false;
}
return true;
}

async function pipeThrough(data, stream) {
let piped = Buffer.from('');
const reader = new Blob([data]).stream()
Expand All @@ -41,10 +52,38 @@ async function pipeThrough(data, stream) {
return piped;
}

async function cryptData(data, password, isEncryption, shouldGzip) {
let wasGunzipped = false;
if (isEncryption) {
if (shouldGzip)
data = await pipeThrough(data, new CompressionStream('gzip'));

if (password) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-128-cbc', crypto.pbkdf2Sync(password, iv, 100, 16, 'sha1'), iv);
data = Buffer.concat([iv, cipher.update(data), cipher.final()]);
}
} else {
if (password) {
const iv = data.subarray(0, 16);
const decipher = crypto.createDecipheriv('aes-128-cbc', crypto.pbkdf2Sync(password, iv, 100, 16, 'sha1'), iv);
data = Buffer.concat([decipher.update(data.subarray(16)), decipher.final()]);
}

if (isGzip(data)) {
wasGunzipped = true;
data = await pipeThrough(data, new DecompressionStream('gzip'));
}
}

return { wasGunzipped, cryptedData: data };
}

export default function CryptForm({ isEncryption, isLoading, setIsLoading, password }) {
const toast = useToast();
const saveFileRef = useRef();
const [data, setData] = useState(null);
const [editorData, setEditorData] = useState(null);
const [shouldGzip, setShouldGzip] = useState(false);
const [isEncryptionWarning, setIsEncryptionWarning] = useState(false);
const { isOpen, onOpen: _onOpen, onClose: _onClose } = useDisclosure();
Expand All @@ -62,6 +101,13 @@ export default function CryptForm({ isEncryption, isLoading, setIsLoading, passw
setIsEncryptionWarning(false);
};

const setDownloadData = (data, fileName) => {
const blobUrl = window.URL.createObjectURL(new Blob([data], { type: 'binary/octet-stream' }));
const downloader = document.getElementById('downloader');
downloader.href = blobUrl;
downloader.download = fileName;
};

const download = () => {
setData(null);
saveFileRef.current.value = '';
Expand All @@ -79,8 +125,10 @@ export default function CryptForm({ isEncryption, isLoading, setIsLoading, passw
ref={saveFileRef}
disabled={isLoading}
onChange={changeEvent => {
if (!changeEvent.target.files.length)
if (!changeEvent.target.files.length) {
setData(null);
return;
}

const fileReader = new FileReader();
fileReader.onload = loadEvent => setData(Buffer.from(loadEvent.target.result));
Expand Down Expand Up @@ -117,7 +165,74 @@ export default function CryptForm({ isEncryption, isLoading, setIsLoading, passw
<div width='100%'></div>

{!isEncryption && (
<Button leftIcon={<FaEdit />} colorScheme='teal' width='100%' mt='2' display='block' onClick={onEditorOpen}>Open editor</Button>
<Button
leftIcon={<FaEdit />}
colorScheme='orange'
width='100%' mt='2'
display='block'
onClick={async () => {
if (!data || (!password && !isGzip(data) && !isJSON(data))) {
toast({
title: `Failed ${isEncryption ? 'encrypting' : 'decrypting'} the save file`,
description: !data ? 'No file chosen' : 'No password provided',
status: 'error',
duration: 2000,
isClosable: true,
position: 'bottom-left'
});

return;
}

setIsLoading(true);

let decryptedData;
try {
decryptedData = await cryptData(data, password, false);
} catch (e) {
console.error(e);
toast({
title: 'Failed decrypting the save file',
description: 'Wrong decryption password? Try leaving the password field empty.',
status: 'error',
duration: 3500,
isClosable: true,
position: 'bottom-left'
});

setIsLoading(false);
return;
}

if (!isJSON(decryptedData.cryptedData)) {
toast({
title: 'Can\'t open editor',
description: (
<>
<Text>The save file isn&apos;t JSON formatted.</Text>
<Text>Please open an issue on GitHub:</Text>
<Link as={NextLink} href='https://github.com/alextusinean/es3-editor/issues/new' color='blue.500'>
https://github.com/alextusinean/es3-editor
</Link>
</>
),
status: 'error',
duration: 5000,
isClosable: true,
position: 'bottom-left'
});

setIsLoading(false);
return;
}

setEditorData({ wasGunzipped: decryptedData.wasGunzipped, data: decryptedData.cryptedData });
onEditorOpen();
setIsLoading(false);
}}
>
EXPERIMENTAL! Open editor
</Button>
)}

<Button
Expand All @@ -144,35 +259,13 @@ export default function CryptForm({ isEncryption, isLoading, setIsLoading, passw

setIsLoading(true);

let fileName;
let cryptedData = data;
let fileName = isEncryption ? 'SaveFile.encrypted.txt' : 'SaveFile.decrypted.txt';
let wasGunzipped = false;
let cryptedData;
try {
if (isEncryption) {
fileName = 'SaveFile.encrypted.txt';

if (shouldGzip)
cryptedData = await pipeThrough(cryptedData, new CompressionStream('gzip'));

if (password) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-128-cbc', crypto.pbkdf2Sync(password, iv, 100, 16, 'sha1'), iv);
cryptedData = Buffer.concat([iv, cipher.update(cryptedData), cipher.final()]);
}
} else {
fileName = 'SaveFile.decrypted.txt';

if (password) {
const iv = cryptedData.subarray(0, 16);
const decipher = crypto.createDecipheriv('aes-128-cbc', crypto.pbkdf2Sync(password, iv, 100, 16, 'sha1'), iv);
cryptedData = Buffer.concat([decipher.update(cryptedData.subarray(16)), decipher.final()]);
}

if (isGzip(cryptedData)) {
wasGunzipped = true;
cryptedData = await pipeThrough(cryptedData, new DecompressionStream('gzip'));
}
}
let result = await cryptData(data, password, isEncryption, shouldGzip);
wasGunzipped = result.wasGunzipped;
cryptedData = result.cryptedData;
} catch (e) {
console.error(e);
toast({
Expand All @@ -188,11 +281,7 @@ export default function CryptForm({ isEncryption, isLoading, setIsLoading, passw
return;
}

const blobUrl = window.URL.createObjectURL(new Blob([cryptedData], { type: 'binary/octet-stream' }));
const downloader = document.getElementById('downloader');
downloader.href = blobUrl;
downloader.download = fileName;

setDownloadData(cryptedData, fileName);
if (wasGunzipped)
onOpen();
else
Expand Down Expand Up @@ -247,7 +336,37 @@ export default function CryptForm({ isEncryption, isLoading, setIsLoading, passw
</Modal>

{!isEncryption && (
<Editor isOpen={isEditorOpen} onClose={onEditorClose} />
<Editor
isLoading={isLoading}
setIsLoading={setIsLoading}
isOpen={isEditorOpen}
onClose={onEditorClose}
data={editorData}
setData={setEditorData}
saveData={async () => {
let cryptedData;
try {
let result = await cryptData(editorData.data, password, true, editorData.wasGunzipped);
cryptedData = result.cryptedData;
} catch (e) {
console.error(e);
toast({
title: `Failed encrypting the edited save file`,
description: 'Internal error',
status: 'error',
duration: 3500,
isClosable: true,
position: 'bottom-left'
});

return false;
}

setDownloadData(cryptedData, 'SaveFile.encrypted.txt');
download();
return true;
}}
/>
)}
</>
);
Expand Down
53 changes: 44 additions & 9 deletions components/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,37 @@ import 'jsoneditor/dist/jsoneditor.min.css';

import Footer from './footer';

export default function Editor({ isOpen, onClose }) {
export default function Editor({ isLoading, setIsLoading, isOpen, onClose, data, setData, saveData }) {
const [editorContainer, setEditorContainer] = useState(null);
const [editor, setEditor] = useState(null);

useEffect(() => {
if (!editorContainer)
if (!editorContainer || !data)
return;

const editor = new JSONEditor(editorContainer, {
mode: 'tree',
onChange: () => {

mode: isLoading ? 'view': 'tree',
onChangeText: newData => {
setData({ ...data, data: Buffer.from(newData) });
}
});

editor.set({ phasmo: 1, money: { value: 2, currency: '$' }, 1: '2', 2: '3', 4: '5', 6: '7', 8: '9', 10: '11', 12: '13', 14: '15', 16: '17', 18: '19', 20: '21', 22: '23', 24: '25', 26: '27', 28: '29', 30: '31', 32: '33', 34: '35', 36: '37', 38: '39', 40: '41', 42: '43', 44: '45', 46: '47', 48: '49', 50: '51', 52: '53', 54: '55', 56: '57', 58: '59', 60: '61', 62: '63', 64: '65', 66: '67', 68: '69', 70: '71', 72: '73', 74: '75', 76: '77', 78: '79', 80: '81', 82: '83', 84: '85', 86: '87', 88: '89', 90: '91', 92: '93', 94: '95', 96: '97', 98: '99'});
setEditor(editor);
editor.set(JSON.parse(data.data.toString()));

return () => editor.destroy();
return () => {
setEditor(null);
editor.destroy();
};
}, [editorContainer]);

useEffect(() => {
if (!editor)
return;

editor.setMode(isLoading ? 'view' : 'tree');
}, [isLoading]);

const editorContainerRef = useCallback(node => {
if (node)
setEditorContainer(node);
Expand All @@ -49,8 +61,31 @@ export default function Editor({ isOpen, onClose }) {
</ModalBody>
<ModalFooter>
<Footer left />
<Button colorScheme='teal'>Save</Button>
<Button ml='3' onClick={onClose}>Close</Button>
<Button
colorScheme='orange'
isDisabled={isLoading}
onClick={async () => {
setIsLoading(true);

const isSaveSuccess = await saveData();
setIsLoading(false);

if (isSaveSuccess)
onClose();
}}
>
Save
</Button>
<Button
ml='3'
onClick={() => {
setData(null);
onClose();
}}
isDisabled={isLoading}
>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
Expand Down
2 changes: 0 additions & 2 deletions pages/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
Heading,
Input,
Text,
Link,
Button,
Modal,
ModalOverlay,
Expand All @@ -20,7 +19,6 @@ import {
} from '@chakra-ui/react';
import { CloseIcon } from '@chakra-ui/icons';
import { useEffect, useState } from 'react';
import NextLink from 'next/link';
import Head from 'next/head';

import CryptForm from '../components/cryptForm';
Expand Down

0 comments on commit cf71fcb

Please sign in to comment.