Skip to content

Commit 7551c0a

Browse files
committed
feat: support frontend encryption
1 parent 7c4377a commit 7551c0a

27 files changed

+718
-123
lines changed
Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import React, { JSX, useEffect } from "react"
2-
import { ComputerIcon, MoonIcon, SunIcon } from "../icons.js"
3-
import { Tooltip, TooltipProps } from "@heroui/react"
2+
import { ComputerIcon, MoonIcon, SunIcon } from "./icons.js"
3+
import { Button, ButtonProps, Tooltip } from "@heroui/react"
44

55
export type DarkMode = "dark" | "light" | "system"
66

7-
interface MyComponentProps extends TooltipProps {
7+
interface MyComponentProps extends ButtonProps {
88
mode: DarkMode
99
onModeChange: (newMode: DarkMode) => void
1010
}
1111

1212
const icons: { name: DarkMode; icon: JSX.Element }[] = [
1313
{ name: "system", icon: <ComputerIcon className="size-6 inline" /> },
14-
{ name: "dark", icon: <MoonIcon className="size-6 inline" /> },
1514
{ name: "light", icon: <SunIcon className="size-6 inline" /> },
15+
{ name: "dark", icon: <MoonIcon className="size-6 inline" /> },
1616
]
1717

1818
export function defaultDarkMode(): DarkMode {
@@ -31,25 +31,25 @@ export function shouldBeDark(mode: DarkMode): boolean {
3131
return mode === "system" ? systemDark : mode === "dark"
3232
}
3333

34-
export function DarkModeToggle({ mode, onModeChange, ...rest }: MyComponentProps) {
34+
export function DarkModeToggle({ mode, onModeChange, className, ...rest }: MyComponentProps) {
3535
useEffect(() => {
3636
localStorage.setItem("darkModeSelect", mode)
3737
}, [mode])
3838

3939
return (
40-
<Tooltip content={`Toggle dark mode (${mode} mode now)`} {...rest}>
41-
<span
42-
className="absolute right-0"
43-
data-testid="pastebin-darkmode-toggle"
44-
role="button"
45-
aria-label="Toggle Dark Mode"
46-
onClick={() => {
40+
<Tooltip content={`Toggle dark mode (currently ${mode})`}>
41+
<Button
42+
isIconOnly
43+
className={"mr-2 rounded-full bg-background" + " " + className}
44+
aria-label="Toggle dark mode"
45+
onPress={() => {
4746
const curModeIdx = icons.findIndex(({ name }) => name === mode)
4847
onModeChange(icons[(curModeIdx + 1) % icons.length].name)
4948
}}
49+
{...rest}
5050
>
5151
{icons.find(({ name }) => name === mode)!.icon}
52-
</span>
52+
</Button>
5353
</Tooltip>
5454
)
5555
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import React, { useEffect, useRef, useState } from "react"
2+
import { ErrorModal, ErrorState } from "./ErrorModal.js"
3+
import { decodeKey, decrypt } from "../utils/encryption.js"
4+
5+
import { Button, CircularProgress, Link, Tooltip } from "@heroui/react"
6+
import { CheckIcon, CopyIcon, DownloadIcon } from "./icons.js"
7+
8+
import "../style.css"
9+
import { parseFilenameFromContentDisposition, parsePath } from "../../src/shared.js"
10+
import { formatSize } from "../utils/utils.js"
11+
import { DarkMode, DarkModeToggle, defaultDarkMode, shouldBeDark } from "./DarkModeToggle.js"
12+
13+
export function DecryptPaste() {
14+
const [pasteFile, setPasteFile] = useState<File | undefined>(undefined)
15+
const [pasteContentBuffer, setPasteContentBuffer] = useState<ArrayBuffer | undefined>(undefined)
16+
const [forceShowBinary, setForceShowBinary] = useState(false)
17+
18+
const [isLoading, setIsLoading] = useState<boolean>(false)
19+
20+
const [errorState, setErrorState] = useState<ErrorState>({ isOpen: false, content: "", title: "" })
21+
22+
function showModal(content: string, title: string) {
23+
setErrorState({ title, content, isOpen: true })
24+
}
25+
26+
async function reportResponseError(resp: Response, title: string) {
27+
const statusText = resp.statusText === "error" ? "Unknown error" : resp.statusText
28+
const errText = (await resp.text()) || statusText
29+
showModal(errText, title)
30+
}
31+
32+
const { nameFromPath } = parsePath(location.pathname)
33+
const keyString = location.hash.slice(1)
34+
// const url = new URL("http://localhost:8787/e/dSGT#TkHRDZ4CD3UQPqjY71cuwd_yE3NpEEr_CtzF0wu32jA=")
35+
// const nameFromPath = url.pathname.slice(3)
36+
// const keyString = url.hash.slice(1)
37+
38+
useEffect(() => {
39+
const pasteUrl = `${API_URL}/${nameFromPath}`
40+
41+
const fetchPaste = async () => {
42+
const scheme = "AES-GCM"
43+
let key: CryptoKey | undefined
44+
try {
45+
key = await decodeKey(scheme, keyString)
46+
} catch {
47+
showModal(`Failed to parse “${keyString}” as key`, "Error")
48+
return
49+
}
50+
if (key === undefined) {
51+
showModal(`Failed to parse “${keyString}” as key`, "Error")
52+
return
53+
}
54+
55+
try {
56+
setIsLoading(true)
57+
const resp = await fetch(pasteUrl)
58+
if (!resp.ok) {
59+
await reportResponseError(resp, `Error on fetching ${pasteUrl}`)
60+
return
61+
}
62+
63+
const decrypted = await decrypt(scheme, key, await resp.bytes())
64+
if (decrypted === null) {
65+
showModal("Failed to decrypt content", "Error")
66+
} else {
67+
const filename = resp.headers.has("Content-Disposition")
68+
? parseFilenameFromContentDisposition(resp.headers.get("Content-Disposition")!) || ""
69+
: ""
70+
const type = resp.headers.has("Content-Type")
71+
? resp.headers.get("Content-Type")!.replace(/;.*/, "")
72+
: undefined
73+
setPasteFile(new File([decrypted], filename, { type }))
74+
setPasteContentBuffer(decrypted)
75+
}
76+
} finally {
77+
setIsLoading(false)
78+
}
79+
}
80+
fetchPaste().catch((e) => {
81+
showModal((e as Error).toString(), `Error on fetching ${pasteUrl}`)
82+
console.error(e)
83+
})
84+
}, [])
85+
86+
const fileIndicator = pasteFile && (
87+
<div className="text-foreground-600 mb-2 text-small">
88+
{`${pasteFile?.name} (${formatSize(pasteFile.size)})`}
89+
{forceShowBinary && (
90+
<button className="ml-2 text-primary-500" onClick={() => setForceShowBinary(false)}>
91+
(Click to hide)
92+
</button>
93+
)}
94+
</div>
95+
)
96+
97+
const binaryFileIndicator = pasteFile && (
98+
<div className="absolute top-[50%] left-[50%] translate-[-50%] flex flex-col items-center">
99+
<div className="text-foreground-600 mb-2">{`${pasteFile?.name} (${formatSize(pasteFile.size)})`}</div>
100+
<div>
101+
Binary file{" "}
102+
<button className="text-primary-500" onClick={() => setForceShowBinary(true)}>
103+
(Click to show)
104+
</button>
105+
</div>
106+
</div>
107+
)
108+
109+
const [darkModeSelect, setDarkModeSelect] = useState<DarkMode>(defaultDarkMode())
110+
111+
const numOfIssuedCopies = useRef(0)
112+
const [hasIssuedCopies, setHasIssuedCopies] = useState<boolean>(false)
113+
const onCopy = () => {
114+
navigator.clipboard
115+
.writeText(new TextDecoder().decode(pasteContentBuffer))
116+
.then(() => {
117+
numOfIssuedCopies.current = numOfIssuedCopies.current + 1
118+
setHasIssuedCopies(numOfIssuedCopies.current > 0)
119+
120+
setTimeout(() => {
121+
numOfIssuedCopies.current = numOfIssuedCopies.current - 1
122+
setHasIssuedCopies(numOfIssuedCopies.current > 0)
123+
}, 1000)
124+
})
125+
.catch(console.error)
126+
}
127+
128+
const showFileContent = pasteFile && (!pasteFile.name || forceShowBinary)
129+
130+
return (
131+
<main
132+
className={
133+
"flex flex-col items-center min-h-screen bg-background text-foreground" +
134+
(shouldBeDark(darkModeSelect) ? " dark" : " light")
135+
}
136+
>
137+
<div className="w-full max-w-[64rem]">
138+
<div className="flex flex-row my-4 items-center justify-between">
139+
<h1 className="text-2xl inline grow">
140+
<Link href="/" className="text-2xl text-foreground-500 mr-1">
141+
{INDEX_PAGE_TITLE + " / "}
142+
</Link>
143+
<code>{nameFromPath}</code> (decrypted)
144+
</h1>
145+
{showFileContent && (
146+
<Tooltip content={`Copy to clipboard`}>
147+
<Button isIconOnly aria-label="Copy" className="mr-2 rounded-full bg-background" onPress={onCopy}>
148+
{hasIssuedCopies ? <CheckIcon className="size-6 inline" /> : <CopyIcon className="size-6 inline" />}
149+
</Button>
150+
</Tooltip>
151+
)}
152+
{pasteFile && (
153+
<Tooltip content={`Download as file`}>
154+
<Button aria-label="Download" isIconOnly className="rounded-full bg-background">
155+
<a href={URL.createObjectURL(pasteFile)}>
156+
<DownloadIcon className="size-6 inline mr-2" />
157+
</a>
158+
</Button>
159+
</Tooltip>
160+
)}
161+
<DarkModeToggle mode={darkModeSelect} onModeChange={setDarkModeSelect} className="" />
162+
</div>
163+
<div className="my-4">
164+
<div className="min-h-[30rem] w-full bg-secondary-50 rounded-lg p-3 relative">
165+
{isLoading ? (
166+
<CircularProgress className="absolute top-[50%] left-[50%] translate-[-50%]" />
167+
) : (
168+
pasteFile && (
169+
<div>
170+
{showFileContent ? (
171+
<>
172+
{fileIndicator}
173+
<div className="font-mono whitespace-pre-wrap" role="article">
174+
{new TextDecoder().decode(pasteContentBuffer)}
175+
</div>
176+
</>
177+
) : (
178+
binaryFileIndicator
179+
)}
180+
</div>
181+
)
182+
)}
183+
</div>
184+
</div>
185+
</div>
186+
<ErrorModal
187+
onDismiss={() => {
188+
setErrorState({ isOpen: false, content: "", title: "" })
189+
}}
190+
state={errorState}
191+
/>
192+
</main>
193+
)
194+
}

frontend/components/ErrorModal.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
11
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/react"
22
import React from "react"
33

4-
type ErrorModalProps = {
5-
onDismiss: () => void
6-
isOpen: boolean
4+
export type ErrorState = {
75
title: string
86
content: string
7+
isOpen: boolean
8+
}
9+
10+
type ErrorModalProps = {
11+
onDismiss: () => void
12+
state: ErrorState
913
}
1014

11-
export function ErrorModal({ onDismiss, title, content, isOpen }: ErrorModalProps) {
15+
export function ErrorModal({ onDismiss, state }: ErrorModalProps) {
1216
return (
1317
<Modal
14-
isOpen={isOpen}
18+
isOpen={state.isOpen}
1519
onOpenChange={(open) => {
1620
if (!open) {
1721
onDismiss()
1822
}
1923
}}
2024
>
2125
<ModalContent>
22-
<ModalHeader className="flex flex-col gap-1">{title}</ModalHeader>
26+
<ModalHeader className="flex flex-col gap-1">{state.title}</ModalHeader>
2327
<ModalBody>
24-
<p>{content}</p>
28+
<p>{state.content}</p>
2529
</ModalBody>
2630
<ModalFooter>
2731
<Button color="danger" variant="light" onPress={() => onDismiss()}>

0 commit comments

Comments
 (0)