Skip to content

Commit 466889a

Browse files
committed
feat: reset decrypt role to d, improve frontend for decrypt
1 parent 7551c0a commit 466889a

File tree

13 files changed

+207
-66
lines changed

13 files changed

+207
-66
lines changed

doc/api.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
Return the index page.
66

7-
## **GET** `/<name>[.<ext>]` or `/<name>/<filename>[.<ext>]`
7+
## **GET** `/<name>[.<ext>]` or `/<name>/<filename>`
88

99
Fetch the paste with name `<name>`. By default, it will return the raw content of the paste.
1010

11-
The `Content-Type` header is set to `text/plain;charset=UTF-8`. If `<ext>` is given, the worker will infer mime-type from `<ext>` and change `Content-Type`. If the paste is uploaded with a filename, the worker will infer mime-type from the filename. This method accepts the following query string parameters:
11+
The `Content-Type` header is set to the mime type inferred from the filename of the paste, or `text/plain;charset=UTF-8` if no filename is present. If `<ext>` is given, the worker will infer mime-type from `<ext>` and change `Content-Type`. If the paste is uploaded with a filename, the worker will infer mime-type from the filename. This method accepts the following query string parameters:
1212

13-
The `Content-Disposition` header is set to `inline` by default. But can be overriden by `?a` query string. If the paste is uploaded with filename, or `<filename>` is set in given request URL, `Content-Disposition` is appended with `filename*` indicating the filename (with `<ext>` if it exists).
13+
The `Content-Disposition` header is set to `inline` by default. But can be overriden by `?a` query string. If the paste is uploaded with filename, or `<filename>` is set in given request URL, `Content-Disposition` is appended with `filename*` indicating the filename. If the paste is encrypted, the filename is appended with `.encrypted` suffix.
14+
15+
If the paste is encrypted, an `X-Encryption-Scheme` header will be set to the encryption scheme.
1416

1517
- `?a=`: optional. Set `Content-Disposition` to `attachment` if present.
1618

@@ -36,7 +38,10 @@ $ curl https://shz.al/~panty.jpg | feh -
3638
$ firefox 'https://shz.al/kf7z?lang=nix'
3739

3840
$ curl 'https://shz.al/~panty.jpg?mime=image/png' -w '%{content_type}' -o /dev/null -sS
39-
image/png;charset=UTF-8
41+
image/png
42+
43+
$ curl 'https://shz.al/kf7Z/panty.jpg?mime=image/png' -w '%{content_type}' -o /dev/null -sS
44+
image/png
4045
```
4146

4247
## GET `/<name>:<passwd>`
@@ -65,6 +70,21 @@ $ firefox https://shz.al/u/i-p-
6570
$ curl -L https://shz.al/u/i-p-
6671
```
6772

73+
## GET `/d/<name>`
74+
75+
Return the web page that will decrypt the paste of name `<name>` in browser.
76+
77+
If error occurs, the worker returns status code different from `302`:
78+
79+
- `404`: the paste of given name is not found.
80+
- `500`: unexpected exception. You may report this to the author to give it a fix.
81+
82+
Usage example:
83+
84+
```shell
85+
$ firefox https://shz.al/e/i-p-
86+
```
87+
6888
## GET `/m/<name>`
6989

7090
Get the metadata of the paste of name `<name>`.
@@ -83,8 +103,9 @@ $ curl -L https://shz.al/m/i-p-
83103
"createdAt": "2025-05-01T10:33:06.114Z",
84104
"expireAt": "2025-05-08T10:33:06.114Z",
85105
"sizeBytes": 4096,
106+
"location": "KV",
86107
"filename": "a.jpg",
87-
"location": "KV"
108+
"encryptionScheme": "AES-GCM"
88109
}
89110
```
90111

@@ -96,6 +117,7 @@ Explanation of the fields:
96117
- `sizeBytes`: Integer. The size of the content of the paste in bytes.
97118
- `filename`: Optional string. The file name of the paste.
98119
- `location`: String, either "KV" of "R2". Representing whether the paste content is stored in Cloudflare KV storage or R2 object storage.
120+
- `encryptionScheme`: Optional string. Currently only "AES-GCM" is possible. The encryption scheme used to encrypt the pastused to encrypt the pastused to encrypt the pastused to encrypt the paste.
99121

100122
## GET `/a/<name>`
101123

@@ -167,6 +189,8 @@ Upload your paste. It accept parameters in form-data:
167189
- `n`: optional. The customized **name** of your paste. If not specified, the worker will generate a random string (4 characters by default) as the name. You need to prefix the name with `~` when fetching the paste of customized name. The name is at least 3 characters long, consisting of alphabet, digits and characters in `+_-[]*$=@,;/`.
168190

169191
- `p`: optional. The flag of **private mode**. If specified to any value, the name of the paste is as long as 24 characters. No effect if `n` is used.
192+
-
193+
- `encryption-scheme`: optional. The encryption scheme used in the uploaded paste. It will be returned as `X-Encryption-Scheme` header on fetching paste. Note that this is not the encryption scheme that the backend will perform.
170194

171195
`POST` method returns a JSON string by default, if no error occurs, for example:
172196

frontend/components/DecryptPaste.tsx

Lines changed: 58 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
import React, { useEffect, useRef, useState } from "react"
22
import { ErrorModal, ErrorState } from "./ErrorModal.js"
3-
import { decodeKey, decrypt } from "../utils/encryption.js"
3+
import { decodeKey, decrypt, EncryptionScheme } from "../utils/encryption.js"
44

55
import { Button, CircularProgress, Link, Tooltip } from "@heroui/react"
6-
import { CheckIcon, CopyIcon, DownloadIcon } from "./icons.js"
6+
import { CheckIcon, CopyIcon, DownloadIcon, HomeIcon } from "./icons.js"
77

88
import "../style.css"
99
import { parseFilenameFromContentDisposition, parsePath } from "../../src/shared.js"
1010
import { formatSize } from "../utils/utils.js"
1111
import { DarkMode, DarkModeToggle, defaultDarkMode, shouldBeDark } from "./DarkModeToggle.js"
12+
import binaryExtensions from "binary-extensions"
13+
14+
function isBinaryPath(path: string) {
15+
return binaryExtensions.includes(path.replace(/.*\./, ""))
16+
}
1217

1318
export function DecryptPaste() {
1419
const [pasteFile, setPasteFile] = useState<File | undefined>(undefined)
1520
const [pasteContentBuffer, setPasteContentBuffer] = useState<ArrayBuffer | undefined>(undefined)
21+
22+
const [isFileBinary, setFileBinary] = useState(false)
1623
const [forceShowBinary, setForceShowBinary] = useState(false)
1724

1825
const [isLoading, setIsLoading] = useState<boolean>(false)
@@ -29,29 +36,20 @@ export function DecryptPaste() {
2936
showModal(errText, title)
3037
}
3138

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)
39+
// uncomment the following lines for testing
40+
// const url = new URL("http://localhost:8787/d/dHYQ.jpg.txt#uqeULsBTb2I3iC7rD6AaYh4oJ5lMjJA2nYR+H0U8bEA=")
41+
const url = location
42+
43+
const { nameFromPath, ext, filename } = parsePath(url.pathname)
44+
const keyString = url.hash.slice(1)
3745

3846
useEffect(() => {
47+
if (keyString.length === 0) {
48+
showModal("No encryption key is given. You should append the key after a “#” character in the URL", "Error")
49+
}
3950
const pasteUrl = `${API_URL}/${nameFromPath}`
4051

4152
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-
5553
try {
5654
setIsLoading(true)
5755
const resp = await fetch(pasteUrl)
@@ -60,18 +58,34 @@ export function DecryptPaste() {
6058
return
6159
}
6260

61+
const scheme: EncryptionScheme = (resp.headers.get("X-Encryption-Scheme") as EncryptionScheme) || "AES-GCM"
62+
let key: CryptoKey | undefined
63+
try {
64+
key = await decodeKey(scheme, keyString)
65+
} catch {
66+
showModal(`Failed to parse “${keyString}” as ${scheme} key`, "Error")
67+
return
68+
}
69+
if (key === undefined) {
70+
showModal(`Failed to parse “${keyString}” as ${scheme} key`, "Error")
71+
return
72+
}
73+
6374
const decrypted = await decrypt(scheme, key, await resp.bytes())
6475
if (decrypted === null) {
6576
showModal("Failed to decrypt content", "Error")
6677
} 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(/;.*/, "")
78+
const filenameFromDispTrimmed = resp.headers.has("Content-Disposition")
79+
? parseFilenameFromContentDisposition(resp.headers.get("Content-Disposition")!)?.replace(
80+
/.encrypted$/g,
81+
"",
82+
) || undefined
7283
: undefined
73-
setPasteFile(new File([decrypted], filename, { type }))
84+
85+
const inferredFilename = filename || (ext && nameFromPath + ext) || filenameFromDispTrimmed || nameFromPath
86+
setPasteFile(new File([decrypted], inferredFilename))
7487
setPasteContentBuffer(decrypted)
88+
setFileBinary(isBinaryPath(inferredFilename))
7589
}
7690
} finally {
7791
setIsLoading(false)
@@ -98,7 +112,7 @@ export function DecryptPaste() {
98112
<div className="absolute top-[50%] left-[50%] translate-[-50%] flex flex-col items-center">
99113
<div className="text-foreground-600 mb-2">{`${pasteFile?.name} (${formatSize(pasteFile.size)})`}</div>
100114
<div>
101-
Binary file{" "}
115+
Possibly Binary file{" "}
102116
<button className="text-primary-500" onClick={() => setForceShowBinary(true)}>
103117
(Click to show)
104118
</button>
@@ -125,40 +139,46 @@ export function DecryptPaste() {
125139
.catch(console.error)
126140
}
127141

128-
const showFileContent = pasteFile && (!pasteFile.name || forceShowBinary)
142+
const showFileContent = pasteFile && (!isFileBinary || forceShowBinary)
129143

144+
const buttonClasses = "rounded-full bg-background hover:bg-gray-100"
130145
return (
131146
<main
132147
className={
133-
"flex flex-col items-center min-h-screen bg-background text-foreground" +
148+
"flex flex-col items-center min-h-screen bg-background text-foreground mx-2" +
134149
(shouldBeDark(darkModeSelect) ? " dark" : " light")
135150
}
136151
>
137152
<div className="w-full max-w-[64rem]">
138153
<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 + " / "}
154+
<h1 className="text-xl md:text-2xl grow inline-flex items-center">
155+
<Link href="/" className="text-foreground-500 text-[length:inherited]">
156+
<Button isIconOnly aria-label="Home" className={buttonClasses + " md:hidden"}>
157+
<HomeIcon className="size-6" />
158+
</Button>
159+
<span className="hidden md:inline">{INDEX_PAGE_TITLE}</span>
142160
</Link>
143-
<code>{nameFromPath}</code> (decrypted)
161+
<span className="mx-2">{" / "}</span>
162+
<code>{nameFromPath}</code>
163+
<span className="ml-1">{isLoading ? " (Loading…)" : pasteFile ? " (Decrypted)" : ""}</span>
144164
</h1>
145165
{showFileContent && (
146166
<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" />}
167+
<Button isIconOnly aria-label="Copy" className={buttonClasses} onPress={onCopy}>
168+
{hasIssuedCopies ? <CheckIcon className="size-6" /> : <CopyIcon className="size-6" />}
149169
</Button>
150170
</Tooltip>
151171
)}
152172
{pasteFile && (
153173
<Tooltip content={`Download as file`}>
154-
<Button aria-label="Download" isIconOnly className="rounded-full bg-background">
174+
<Button aria-label="Download" isIconOnly className={buttonClasses}>
155175
<a href={URL.createObjectURL(pasteFile)}>
156-
<DownloadIcon className="size-6 inline mr-2" />
176+
<DownloadIcon className="size-6 inline" />
157177
</a>
158178
</Button>
159179
</Tooltip>
160180
)}
161-
<DarkModeToggle mode={darkModeSelect} onModeChange={setDarkModeSelect} className="" />
181+
<DarkModeToggle mode={darkModeSelect} onModeChange={setDarkModeSelect} className={buttonClasses} />
162182
</div>
163183
<div className="my-4">
164184
<div className="min-h-[30rem] w-full bg-secondary-50 rounded-lg p-3 relative">

frontend/components/PasteSettingPanel.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
1-
import { Card, CardBody, CardHeader, CardProps, Divider, Input, Radio, RadioGroup, Switch } from "@heroui/react"
1+
import {
2+
Card,
3+
CardBody,
4+
CardHeader,
5+
CardProps,
6+
Divider,
7+
Input,
8+
PopoverContent,
9+
PopoverTrigger,
10+
Popover,
11+
Radio,
12+
RadioGroup,
13+
Switch,
14+
} from "@heroui/react"
215
import { BaseUrl, verifyExpiration, verifyManageUrl, verifyName } from "../utils/utils.js"
316
import React from "react"
17+
import { InfoIcon } from "./icons.js"
418

519
export type UploadKind = "short" | "long" | "custom" | "manage"
620

@@ -101,10 +115,29 @@ export function PanelSettingsPanel({ setting, onSettingChange, ...rest }: PasteS
101115
) : null}
102116
</RadioGroup>
103117
<Divider />
104-
<div className="mt-2">
118+
<div className="mt-2 flex flex-row items-center">
105119
<Switch isSelected={setting.doEncrypt} onValueChange={(v) => onSettingChange({ ...setting, doEncrypt: v })}>
106120
Encrypt paste
107121
</Switch>
122+
<Popover>
123+
<PopoverTrigger>
124+
<InfoIcon className="inline size-5 ml-2" />
125+
</PopoverTrigger>
126+
<PopoverContent>
127+
{(titleProps) => (
128+
<div className="px-1 py-2 max-w-[20rem]">
129+
<h3 className="text-normal font-bold mb-2" {...titleProps}>
130+
Encrypted Paste
131+
</h3>
132+
<div className="text-small">
133+
Enable client-side encryption. Your paste is shared via a URL containing the decryption key in the
134+
hash. Decryption happens in the browser, so only those with the key (not the server) can view the
135+
original content.
136+
</div>
137+
</div>
138+
)}
139+
</PopoverContent>
140+
</Popover>
108141
</div>
109142
</CardBody>
110143
</Card>

frontend/components/UploadedPanel.tsx

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ interface UploadedPanelProps extends CardProps {
99

1010
const makeDecryptionUrl = (url: string, key: string) => {
1111
const urlParsed = new URL(url)
12-
urlParsed.pathname = "/e" + urlParsed.pathname
12+
urlParsed.pathname = "/d" + urlParsed.pathname
1313
return urlParsed.toString() + "#" + key
1414
}
1515

@@ -47,16 +47,6 @@ export function UploadedPanel({ pasteResponse, encryptionKey, ...rest }: Uploade
4747
</Skeleton>
4848
</td>
4949
</tr>
50-
{pasteResponse?.suggestedUrl ? (
51-
<tr>
52-
<td className={firstColClassNames}>Suggested URL</td>
53-
<td className="w-full">
54-
<Snippet hideSymbol variant="bordered" classNames={snippetClassNames}>
55-
{pasteResponse?.suggestedUrl}
56-
</Snippet>
57-
</td>
58-
</tr>
59-
) : null}
6050
{encryptionKey ? (
6151
<tr>
6252
<td className={firstColClassNames}>Decryption URL</td>

frontend/components/icons.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,37 @@ export const CheckIcon = (props: HTMLAttributes<SVGElement>) => (
115115
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
116116
</svg>
117117
)
118+
119+
export const InfoIcon = (props: HTMLAttributes<SVGElement>) => (
120+
<svg
121+
xmlns="http://www.w3.org/2000/svg"
122+
fill="none"
123+
viewBox="0 0 24 24"
124+
strokeWidth={1.5}
125+
stroke="currentColor"
126+
{...props}
127+
>
128+
<path
129+
strokeLinecap="round"
130+
strokeLinejoin="round"
131+
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
132+
/>
133+
</svg>
134+
)
135+
136+
export const HomeIcon = (props: HTMLAttributes<SVGElement>) => (
137+
<svg
138+
xmlns="http://www.w3.org/2000/svg"
139+
fill="none"
140+
viewBox="0 0 24 24"
141+
strokeWidth={1.5}
142+
stroke="currentColor"
143+
{...props}
144+
>
145+
<path
146+
strokeLinecap="round"
147+
strokeLinejoin="round"
148+
d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
149+
/>
150+
</svg>
151+
)

frontend/decrypt.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/png" href="/favicon.ico" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title>Encrypted</title>
7+
<title>%INDEX_PAGE_TITLE% / {{PASTE_NAME}}</title>
88
</head>
99
<body>
1010
<div id="root"></div>

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@tailwindcss/vite": "^4.1.4",
5757
"@types/react": "^19.1.2",
5858
"@types/react-dom": "^19.1.2",
59+
"binary-extensions": "^3.0.0",
5960
"framer-motion": "^12.7.4",
6061
"mdast-util-to-string": "^4.0.0",
6162
"mime": "^4.0.7",

0 commit comments

Comments
 (0)