Skip to content

Commit d6d6a10

Browse files
committed
feat: use native formdata parsing, add passwd check, allow ReadableStream content
1 parent 6bc34e5 commit d6d6a10

File tree

9 files changed

+110
-266
lines changed

9 files changed

+110
-266
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"typescript-eslint": "^8.30.1",
4141
"vite": "^6.3.3",
4242
"vitest": "3.1.1",
43+
"toml": "^3.0.0",
4344
"wrangler": "^4.13.2"
4445
},
4546
"prettier": {
@@ -64,7 +65,6 @@
6465
"remark-parse": "^11.0.0",
6566
"remark-rehype": "^11.1.2",
6667
"tailwindcss": "^4.1.4",
67-
"toml": "^3.0.0",
6868
"unified": "^11.0.5"
6969
},
7070
"resolutions": {

src/handlers/handleWrite.ts

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { verifyAuth } from "../auth.js"
2-
import { FormDataPart, getBoundary, parseFormdata } from "../parseFormdata.js"
32
import { decode, genRandStr, isLegalUrl, WorkerError } from "../common.js"
43
import { createPaste, getPasteMetadata, pasteNameAvailable, updatePaste } from "../storage/storage.js"
54
import {
@@ -12,18 +11,55 @@ import {
1211
PASSWD_SEP,
1312
parseExpiration,
1413
parsePath,
14+
MIN_PASSWD_LEN,
15+
MAX_PASSWD_LEN,
1516
} from "../shared.js"
1617

17-
function suggestUrl(content: ArrayBuffer, short: string, baseUrl: string, filename?: string) {
18+
function suggestUrl(short: string, baseUrl: string, filename?: string, contentAsString?: string) {
19+
// TODO: should we suggest for URL redirect?
1820
if (filename) {
1921
return `${baseUrl}/${short}/${filename}`
20-
} else if (isLegalUrl(decode(content))) {
22+
} else if (contentAsString && isLegalUrl(contentAsString)) {
2123
return `${baseUrl}/u/${short}`
2224
} else {
2325
return undefined
2426
}
2527
}
2628

29+
async function getStringFromPart(part: string | null | File): Promise<string | undefined> {
30+
if (part === null || typeof part == "string") {
31+
return part || undefined
32+
} else {
33+
return decode(await part.arrayBuffer())
34+
}
35+
}
36+
37+
async function getFileFromPart(part: string | null | File): Promise<{
38+
filename?: string
39+
content?: ReadableStream | ArrayBuffer
40+
contentAsString?: string
41+
contentLength: number
42+
}> {
43+
if (part === null) {
44+
return { contentLength: 0 }
45+
} else if (typeof part == "string") {
46+
const encoded = new TextEncoder().encode(part)
47+
return { filename: undefined, content: encoded.buffer, contentAsString: part, contentLength: encoded.length }
48+
} else {
49+
if (part.size < 1000) {
50+
const arrayBuffer = await part.arrayBuffer()
51+
return {
52+
filename: part.name,
53+
content: arrayBuffer,
54+
contentAsString: decode(arrayBuffer),
55+
contentLength: part.size,
56+
}
57+
} else {
58+
return { filename: part.name, content: part.stream(), contentLength: part.size }
59+
}
60+
}
61+
}
62+
2763
export async function handlePostOrPut(
2864
request: Request,
2965
env: Env,
@@ -42,32 +78,22 @@ export async function handlePostOrPut(
4278
const url = new URL(request.url)
4379

4480
// parse formdata
45-
let form: Map<string, FormDataPart> = new Map()
46-
if (contentType.includes("multipart/form-data")) {
47-
// because cloudflare runtime treat all formdata part as strings thus corrupting binary data,
48-
// we need to manually parse formdata
49-
const uint8Array = new Uint8Array(await request.arrayBuffer())
50-
try {
51-
form = parseFormdata(uint8Array, getBoundary(contentType))
52-
} catch {
53-
throw new WorkerError(400, "error occurs when parsing formdata")
54-
}
55-
} else {
81+
if (!contentType.includes("multipart/form-data")) {
5682
throw new WorkerError(400, `bad usage, please use 'multipart/form-data' instead of ${contentType}`)
5783
}
5884

59-
const content = form.get("c")?.content
60-
const filename = form.get("c") && form.get("c")!.disposition.filename
61-
const nameFromForm = form.get("n") && decode(form.get("n")!.content)
62-
const isPrivate = form.get("p")
63-
const passwdFromForm = form.get("s") && decode(form.get("s")!.content)
64-
const expire: string =
65-
form.has("e") && form.get("e")!.content.byteLength > 0 ? decode(form.get("e")!.content) : env.DEFAULT_EXPIRATION
85+
const form = await request.formData()
86+
const { filename, content, contentAsString, contentLength } = await getFileFromPart(form.get("c"))
87+
const nameFromForm = await getStringFromPart(form.get("n"))
88+
const isPrivate = form.get("p") !== null
89+
const passwdFromForm = await getStringFromPart(form.get("s"))
90+
const expireFromPart: string | undefined = await getStringFromPart(form.get("e"))
91+
const expire = expireFromPart !== undefined ? expireFromPart : env.DEFAULT_EXPIRATION
6692

6793
// check if paste content is legal
6894
if (content === undefined) {
6995
throw new WorkerError(400, "cannot find content in formdata")
70-
} else if (content.length > MAX_LEN) {
96+
} else if (contentLength > MAX_LEN) {
7197
throw new WorkerError(413, "payload too large")
7298
}
7399

@@ -81,6 +107,18 @@ export async function handlePostOrPut(
81107
expirationSeconds = maxExpiration
82108
}
83109

110+
// check if password is legal
111+
// TODO: sync checks to frontend
112+
if (passwdFromForm) {
113+
if (passwdFromForm.length > MAX_PASSWD_LEN) {
114+
throw new WorkerError(400, `password too long (${passwdFromForm.length} > ${MAX_PASSWD_LEN})`)
115+
} else if (passwdFromForm.length < MIN_PASSWD_LEN) {
116+
throw new WorkerError(400, `password too short (${passwdFromForm.length} < ${MIN_PASSWD_LEN})`)
117+
} else if (passwdFromForm.includes("\n")) {
118+
throw new WorkerError(400, `password should not contain newline`)
119+
}
120+
}
121+
84122
// check if name is legal
85123
if (nameFromForm !== undefined && !NAME_REGEX.test(nameFromForm)) {
86124
throw new WorkerError(400, `Name ${nameFromForm} not satisfying regexp ${NAME_REGEX}`)
@@ -118,11 +156,12 @@ export async function handlePostOrPut(
118156
expirationSeconds,
119157
now,
120158
passwd: newPasswd,
159+
contentLength,
121160
filename,
122161
})
123162
return makeResponse({
124163
url: accessUrl(pasteName),
125-
suggestedUrl: suggestUrl(content, pasteName, env.DEPLOY_URL, filename),
164+
suggestedUrl: suggestUrl(pasteName, env.DEPLOY_URL, filename, contentAsString),
126165
manageUrl: manageUrl(pasteName, newPasswd),
127166
expirationSeconds,
128167
expireAt: new Date(now.getTime() + 1000 * expirationSeconds).toISOString(),
@@ -148,11 +187,12 @@ export async function handlePostOrPut(
148187
now,
149188
passwd,
150189
filename,
190+
contentLength,
151191
})
152192

153193
return makeResponse({
154194
url: accessUrl(pasteName),
155-
suggestedUrl: suggestUrl(content, pasteName, env.DEPLOY_URL, filename),
195+
suggestedUrl: suggestUrl(pasteName, env.DEPLOY_URL, filename, contentAsString),
156196
manageUrl: manageUrl(pasteName, passwd),
157197
expirationSeconds,
158198
expireAt: new Date(now.getTime() + 1000 * expirationSeconds).toISOString(),

src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ async function handleNormalRequest(request: Request, env: Env, ctx: ExecutionCon
4848
} else if (request.method === "PUT") {
4949
return await handlePostOrPut(request, env, ctx, true)
5050
} else {
51-
throw new WorkerError(405, `method ${request.method} not allowed`)
51+
return new Response(`method ${request.method} not allowed`, {
52+
status: 405,
53+
headers: {
54+
Allow: "GET, PUT, POST, DELETE, OPTION",
55+
},
56+
})
5257
}
5358
}

src/parseFormdata.ts

Lines changed: 0 additions & 187 deletions
This file was deleted.

src/shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export const NAME_REGEX = /^[a-zA-Z0-9+_\-[\]*$@,;]{3,}$/
1313
export const PASTE_NAME_LEN = 4
1414
export const PRIVATE_PASTE_NAME_LEN = 24
1515
export const DEFAULT_PASSWD_LEN = 24
16+
export const MAX_PASSWD_LEN = 128
17+
export const MIN_PASSWD_LEN = 8
1618
export const PASSWD_SEP = ":"
1719
export const MAX_LEN = 25 * 1024 * 1024
1820

0 commit comments

Comments
 (0)