Skip to content

Commit

Permalink
File upload capabilities
Browse files Browse the repository at this point in the history
- View all panel for viewing all ciphertexts
- Database migration to enable storage of boths files and text
- stuck some ciphertexts in textareas for nicer viewing/copying
- limited the size of textareas to not make the forms huge
  • Loading branch information
CluEleSsUK committed Jul 31, 2024
1 parent e22490c commit 5b1ffeb
Show file tree
Hide file tree
Showing 21 changed files with 533 additions and 311 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ WORKDIR /app
COPY . /app
RUN rm -rf node_modules package-lock.json
RUN npm install
CMD npm start
CMD npm run start:dev
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,16 @@ Similarly, the scripts are split into `:dev` and `:prod` variants. Deploying to
The client is a React app for timelock encrypting data in your browser and uploading it to an API server.
Other users of the API server can see your timelock encrypted ciphertext and will be able to see the plaintext once
decryption time has been reached.

Start the client by running `cd client && npm install && npm start`. The default port is `1234`.
It is server rendered by the server application.

## [./server](./server)

The server is a NodeJS app with an express API for submitting timelock encrypted ciphertexts, which it stores and
automatically decrypts and serves once the decryption time has been reached.
automatically decrypts and serves once the decryption time has been reached. It also server renders the React client application.

You will need to configure a postgres database before starting the dev server, and export the required env vars detailed in [the config file](./src/server/config.ts) (and below).

Start the server by running `cd server && npm install && npm start`. The default port is `4444`.
Start the local dev server by running `npm install && npm start:dev`. The default port is `4444`.

## Environment

Expand Down
5 changes: 2 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
version: "3.1"

services:
server:
platform: linux/amd64
Expand All @@ -18,8 +16,9 @@ services:
image: postgres
restart: always
ports:
- 5432:5432
- "5432:5432"
environment:
PORT: 5432
POSTGRES_USER: postgres
POSTGRES_PASSWORD: test
volumes:
Expand Down
242 changes: 112 additions & 130 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.11.1",
"tlock-js": "^0.6.1",
"tlock-js": "0.9.0",
"typescript": "^5.0.4",
"yup": "^1.1.1"
},
Expand Down
7 changes: 6 additions & 1 deletion src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {RecentPlaintexts} from "./components/RecentPlaintexts"
import {UpcomingEncryptions} from "./components/UpcomingEncryptions"
import {EncryptForm} from "./components/EncryptForm"
import {SearchForm} from "./components/SearchForm"
import {AllList} from "./components/AllList"

function App() {
const config = {
Expand All @@ -31,11 +32,15 @@ function App() {
<Route path="/" element={<EncryptForm config={config}/>}/>
<Route path="/entry/:id" element={<TlockEntry config={config}/>}/>
<Route path="/search" element={<SearchForm config={config}/>}/>
<Route path="/all" element={<AllList config={config}/>}/>
<Route path="/*" element={<ErrorPage/>}/>
</Routes>
</Box>
<Stack width={1 / 4}>
<Button onClick={() => navigate("/search")}>Search tags</Button>
<Stack direction="row" spacing={6}>
<Button onClick={() => navigate("/search")}>Search tags</Button>
<Button onClick={() => navigate("/all")}>View all</Button>
</Stack>
<RecentPlaintexts config={config}/>
<UpcomingEncryptions config={config}/>
</Stack>
Expand Down
17 changes: 11 additions & 6 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ export type APIConfig = {
apiURL: string
}

export async function encryptAndUpload(config: APIConfig, time: number, plaintext: string, tags: Array<string>): Promise<string> {
export async function encryptAndUpload(config: APIConfig, time: number, uploadType: string, plaintext: Buffer, tags: Array<string>): Promise<string> {
const roundNumber = roundAt(time, MAINNET_CHAIN_INFO)
const ciphertext = await timelockEncrypt(roundNumber, Buffer.from(plaintext), mainnetClient())
const ciphertext = await timelockEncrypt(roundNumber, plaintext, mainnetClient())

await fetch(`${config.apiURL}/ciphertexts`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
uploadType,
ciphertext: Buffer.from(ciphertext).toString("base64"),
tags,
})
Expand All @@ -23,15 +24,19 @@ export async function encryptAndUpload(config: APIConfig, time: number, plaintex
return ciphertext
}

export async function fetchCiphertexts(config: APIConfig): Promise<Array<Ciphertext>> {
const limit = 5
export async function fetchAll(config: APIConfig): Promise<Array<Plaintext>> {
const response = await fetch(`${config.apiURL}/all`)
const json = await response.json()
return json.plaintexts
}

export async function fetchCiphertexts(config: APIConfig, limit = 5): Promise<Array<Ciphertext>> {
const response = await fetch(`${config.apiURL}/ciphertexts?limit=${limit}`)
const json = await response.json()
return json.ciphertexts
}

export async function fetchPlaintexts(config: APIConfig): Promise<Array<Plaintext>> {
const limit = 5
export async function fetchPlaintexts(config: APIConfig, limit = 5): Promise<Array<Plaintext>> {
const response = await fetch(`${config.apiURL}/plaintexts?limit=${limit}`)
const json = await response.json()
return json.plaintexts
Expand Down
31 changes: 31 additions & 0 deletions src/client/components/AllList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as React from "react"
import {useEffect, useState} from "react"
import {APIConfig, fetchAll} from "../api"
import {Box, Typography} from "@mui/material"
import {SidebarEntry, SidebarEntryPanel} from "./SidebarPanel"
import {remapPlaintexts} from "./mappings"

type AllListProps = {
config: APIConfig
}
export const AllList = (props: AllListProps) => {
const [entries, setEntries] = useState<Array<SidebarEntry>>([])
const [error, setError] = useState("")

useEffect(() => {
fetchAll(props.config)
.then(ciphertexts => setEntries(remapPlaintexts(ciphertexts)))
.catch(err => setError(`there was an error fetching ciphertexts ${err}`))
}, []);

return (
<>
<Box padding={2}>
<Typography variant={"h5"}>All Ciphertexts</Typography>
{entries.map(c => <SidebarEntryPanel entry={c}/>)}
{entries.length === 0 && <Typography>There are no ciphertexts yet :(</Typography>}
{!!error && <Typography color={"red"}>{error}</Typography>}
</Box>
</>
)
}
135 changes: 107 additions & 28 deletions src/client/components/EncryptForm.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,100 @@
import * as React from "react"
import dayjs from "dayjs"
import {useCallback, useState} from "react"
import {Box, Button, CircularProgress, Stack, TextField, Typography} from "@mui/material"
import {useCallback, useEffect, useState} from "react"
import {
Box,
Button,
CircularProgress,
FormControl,
FormControlLabel, FormLabel, Radio,
RadioGroup,
Stack,
TextField,
Typography
} from "@mui/material"
import {DateTimePicker} from "@mui/x-date-pickers/DateTimePicker"
import {APIConfig, encryptAndUpload} from "../api"
import {TagsInput} from "./TagsInput"
import {FileInput} from "./FileInput"
import {Buffer} from "tlock-js"

type EncryptFormProps = {
config: APIConfig
}
export const EncryptForm = (props: EncryptFormProps) => {
const [time, setTime] = useState(dayjs(formatDate(Date.now())))
const [plaintext, setPlaintext] = useState("")
const [uploadType, setUploadType] = useState("text")
const [file, setFile] = useState<File>()
const [bytes, setBytes] = useState<Buffer>()
const [ciphertext, setCiphertext] = useState("")
const [tags, setTags] = useState<Array<string>>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")

// sets the `bytes` var and clears the `plaintext` var if a file is uploaded
useEffect(() => {
if (!file) {
return
}

file.arrayBuffer()
.then(buf => setBytes(Buffer.from(buf)))
}, [file])

// sets the `bytes` var and clears the `file` var if text is input
useEffect(() => {
if (!plaintext) {
return
}

setBytes(Buffer.from(plaintext))
}, [plaintext]);

useEffect(() => {
setCiphertext("")
setPlaintext("")
setFile(undefined)
setBytes(undefined)
}, [uploadType]);

// called when a file is uploaded
const extractFile = (files: FileList) => {
if (files.length !== 1) {
setError("you must upload a single file")
return
}
// let's automatically add a "file" tag as well
setTags([...tags.filter(t => t === "file"), "file"])
setFile(files[0])
}

const encryptAndStore = useCallback(() => {
if (plaintext.trim() === "") {
setError("You must enter a plaintext to encrypt")
if (!bytes) {
setError("you must upload text or a file")
return
}
if (!time) {
setError("You must enter a time")
return
}
if (!uploadType) {
setError("somehow you deselected upload type")
return
}

setError("")
setIsLoading(true)
encryptAndUpload(props.config, time.toDate().getTime(), plaintext, tags)
encryptAndUpload(props.config, time.toDate().getTime(), uploadType, bytes, tags)
.then(c => setCiphertext(c))
.catch(err => setError(err.message))
.then(() => setIsLoading(false))
}, [plaintext, time])
}, [time, bytes])

const clear = useCallback(() => {
setTime(dayjs(formatDate(Date.now())))
setPlaintext("")
setBytes(undefined)
setCiphertext("")
setError("")
setTags([])
Expand Down Expand Up @@ -87,30 +144,52 @@ export const EncryptForm = (props: EncryptFormProps) => {
tags={tags}
onChange={t => setTags(t)}
/>
<Stack
direction={"row"}
spacing={2}>
<FormControl fullWidth>
<FormLabel>Type</FormLabel>
<RadioGroup
row
defaultValue={"text"}
value={uploadType}
onChange={event => setUploadType(event.target.value)}
>
<FormControlLabel value="text" control={<Radio/>} label={"Text"}/>
<FormControlLabel value="file" control={<Radio/>} label={"File"}/>
</RadioGroup>
</FormControl>
</Stack>
<Stack
direction={"row"}
width={"100%"}
spacing={2}
>
<TextField
label={"Plaintext"}
value={plaintext}
variant={"filled"}
fullWidth
onChange={event => setPlaintext(event.target.value)}
multiline
minRows={20}
/>
<TextField
label={"Ciphertext"}
value={ciphertext}
helperText={ciphertextAdvisory}
variant={"filled"}
fullWidth
multiline
disabled
minRows={20}
/>
<Box flex={1}>
{uploadType === "text" && <TextField
label={"Plaintext"}
value={plaintext}
variant={"filled"}
fullWidth
onChange={event => setPlaintext(event.target.value)}
multiline
minRows={20}
/>
}
{uploadType === "file" && <FileInput onChange={extractFile}/>}
</Box>
<Box flex={1}>
<TextField
label={"Ciphertext"}
value={ciphertext}
helperText={ciphertextAdvisory}
variant={"filled"}
fullWidth
multiline
disabled
rows={20}
/>
</Box>
</Stack>

<Stack
Expand All @@ -119,21 +198,21 @@ export const EncryptForm = (props: EncryptFormProps) => {
>
<Button
onClick={() => encryptAndStore()}
disabled={isLoading || plaintext === ""}
disabled={isLoading || (plaintext === "" && !file)}
variant={"outlined"}
>
Upload
</Button>
<Button
onClick={() => clear()}
disabled={plaintext === ""}
disabled={plaintext === "" && !file}
variant={"outlined"}
>
Clear
</Button>
{isLoading && <CircularProgress/>}
</Stack>
{!!error && <Typography>{error}</Typography>}
{!!error && <Typography color={"red"}>{error}</Typography>}
</Box>
)
}
Expand Down
18 changes: 18 additions & 0 deletions src/client/components/FileInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from "react"
import {FormControlLabel, Input} from "@mui/material"

type FileInputProps = {
onChange: (files: FileList) => void
}

export const FileInput = (props: FileInputProps) =>
<FormControlLabel
label={""} /* labels look a bit shit with the default file input element */
control={
<Input
type="file"
className={"form-control"}
onChange={(event: any) => event.currentTarget.files && props.onChange(event.currentTarget.files)}
/>
}
/>
11 changes: 1 addition & 10 deletions src/client/components/RecentPlaintexts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from "react"
import {useCallback, useEffect, useState} from "react"
import {SidebarEntry, SidebarPanel} from "./SidebarPanel"
import {APIConfig, fetchPlaintexts} from "../api"
import {Plaintext} from "../model"
import {remapPlaintexts} from "./mappings"

const refreshTimeMs = 10000

Expand Down Expand Up @@ -33,13 +33,4 @@ export const RecentPlaintexts = (props: RecentPlaintextsProps) => {
title={"Recent decryptions"}
values={plaintexts}
/>
}

function remapPlaintexts(plaintexts: Array<Plaintext>): Array<SidebarEntry> {
return plaintexts.map(it => ({
id: it.id,
time: it.decryptableAt,
content: it.plaintext,
tags: it.tags
}))
}
Loading

0 comments on commit 5b1ffeb

Please sign in to comment.