Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

File upload capabilities #3

Merged
merged 1 commit into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment on lines 18 to 23
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are to use an insecure password, could you pretty please not expose the port to the outside world xD
I think you want to use the expose keyword instead of the ports one.

ports is binding your host machine's port to that container's port, whereas expose exposes a port of a container to other containers.

Copy link
Contributor Author

@CluEleSsUK CluEleSsUK Jul 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this docker compose is only for local usage - there's a separate db in heroku configured using env vars

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
29 changes: 29 additions & 0 deletions src/client/components/AllList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from "react"
import {useEffect, useState} from "react"
AnomalRoil marked this conversation as resolved.
Show resolved Hide resolved
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>
)
}
137 changes: 109 additions & 28 deletions src/client/components/EncryptForm.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,102 @@
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 {Buffer} from "tlock-js"
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"

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]);

// changing the upload type resets everything
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])
Comment on lines +65 to +71
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about setting a file size limit too? Maybe a few MBs?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is set on the server - 20mb

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see src/server/index.ts

}

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 +146,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 +200,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 @@ -34,12 +34,3 @@ export const RecentPlaintexts = (props: RecentPlaintextsProps) => {
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