Skip to content

Commit a233565

Browse files
committed
feat: support /m/ role and HEAD request, refactor tests
1 parent 11df72b commit a233565

File tree

18 files changed

+629
-392
lines changed

18 files changed

+629
-392
lines changed

doc/api.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,38 @@ $ firefox https://shz.al/u/i-p-
6565
$ curl -L https://shz.al/u/i-p-
6666
```
6767

68+
## GET `/m/<name>`
69+
70+
Get the metadata of the paste of name `<name>`.
71+
72+
If error occurs, the worker returns status code different from `200`:
73+
74+
- `404`: the paste of given name is not found.
75+
- `500`: unexpected exception. You may report this to the author to give it a fix.
76+
77+
Usage example:
78+
79+
```shell
80+
$ curl -L https://shz.al/m/i-p-
81+
{
82+
"lastModifiedAt": "2025-05-05T10:33:06.114Z",
83+
"createdAt": "2025-05-01T10:33:06.114Z",
84+
"expireAt": "2025-05-08T10:33:06.114Z",
85+
"sizeBytes": 4096,
86+
"filename": "a.jpg",
87+
"location": "KV"
88+
}
89+
```
90+
91+
Explanation of the fields:
92+
93+
- `lastModified`: String. An ISO String representing the last modification time of the paste.
94+
- `expireAt`: String. An ISO String representing when the paste will expire.
95+
- `expireAt`: String. An ISO String representing when the paste was created.
96+
- `sizeBytes`: Integer. The size of the content of the paste in bytes.
97+
- `filename`: Optional string. The file name of the paste.
98+
- `location`: String, either "KV" of "R2". Representing whether the paste content is stored in Cloudflare KV storage or R2 object storage.
99+
68100
## GET `/a/<name>`
69101

70102
Return the HTML converted from the markdown file stored in the paste of name `<name>`. The markdown conversion follows GitHub Flavored Markdown (GFM) Spec, supported by [remark-gfm](https://github.com/remarkjs/remark-gfm).
@@ -118,6 +150,10 @@ $ curl [email protected] -Fn=test-md https://shz.al
118150
$ firefox https://shz.al/a/~test-md
119151
```
120152

153+
## **HEAD** `/*`
154+
155+
Request a paste without returning the body. It accepts same parameters as all `GET` requests, and returns the same `Content-Type`, `Content-Disposition`, `Content-Length` and cache control headers with the corresponding `GET` request. Note that the `Content-Length` with `/a/<name>`, `?lang=<lang>` is the length of the paste instead of the length the actuala HTML page.
156+
121157
## **POST** `/`
122158

123159
Upload your paste. It accept parameters in form-data:
@@ -146,7 +182,7 @@ Upload your paste. It accept parameters in form-data:
146182
Explanation of the fields:
147183

148184
- `url`: String. The URL to fetch the paste. When using a customized name, it looks like `https//shz.al/~myname`.
149-
- `suggestedUrl`: String or null. The URL that may carry filename or URL redirection.
185+
- `suggestedUrl`: Optional string. The URL that may carry filename or URL redirection.
150186
- `manageUrl`: String. The URL to update and delete the paste, which is `url` suffixed by `~` and the password.
151187
- `expirationSeconds`: String. The expiration seconds.
152188
- `expireAt`: String. An ISO String representing when the paste will expire.

frontend/pb.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ export function PasteBin() {
9696
if (!open) {
9797
setIsPasteLoading(false)
9898
setIsLoading(false)
99-
console.log("set false isLoading")
10099
}
101100
}}
102101
>

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@
3636
"jsdom": "^26.1.0",
3737
"msw": "^2.7.5",
3838
"prettier": "^3.5.3",
39+
"toml": "^3.0.0",
3940
"typescript": "^5.8.3",
4041
"typescript-eslint": "^8.30.1",
4142
"vite": "^6.3.3",
4243
"vitest": "3.1.1",
43-
"toml": "^3.0.0",
4444
"wrangler": "^4.13.2"
4545
},
4646
"prettier": {
@@ -52,6 +52,7 @@
5252
},
5353
"dependencies": {
5454
"@heroui/react": "2.8.0-beta.2",
55+
"@mjackson/multipart-parser": "^0.8.2",
5556
"@tailwindcss/vite": "^4.1.4",
5657
"@types/react": "^19.1.2",
5758
"@types/react-dom": "^19.1.2",

src/handlers/handleRead.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import { verifyAuth } from "../auth.js"
44
import mime from "mime/lite"
55
import { makeMarkdown } from "../pages/markdown.js"
66
import { makeHighlight } from "../pages/highlight.js"
7-
import { getPaste, PasteMetadata } from "../storage/storage.js"
8-
import { parsePath } from "../shared.js"
7+
import { getPaste, getPasteMetadata, PasteMetadata, PasteWithMetadata } from "../storage/storage.js"
8+
import { MAX_URL_REDIRECT_LEN, MetaResponse, parsePath } from "../shared.js"
99

10-
type Headers = { [name: string]: string }
10+
type Headers = Record<string, string>
1111

1212
async function decodeMaybeStream(content: ArrayBuffer | ReadableStream): Promise<string> {
1313
if (content instanceof ArrayBuffer) {
@@ -93,7 +93,13 @@ async function handleStaticPages(request: Request, env: Env, _: ExecutionContext
9393
return null
9494
}
9595

96-
export async function handleGet(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
96+
async function getPasteWithoutContent(env: Env, name: string): Promise<PasteWithMetadata | null> {
97+
const metadata = await getPasteMetadata(env, name)
98+
return metadata && { paste: new ArrayBuffer(), metadata }
99+
}
100+
101+
export async function handleGet(request: Request, env: Env, ctx: ExecutionContext, isHead: boolean): Promise<Response> {
102+
// TODO: handle etag
97103
const staticPageResp = await handleStaticPages(request, env, ctx)
98104
if (staticPageResp !== null) {
99105
return staticPageResp
@@ -105,7 +111,13 @@ export async function handleGet(request: Request, env: Env, ctx: ExecutionContex
105111

106112
const disp = url.searchParams.has("a") ? "attachment" : "inline"
107113

108-
const item = await getPaste(env, nameFromPath, ctx)
114+
// when not isHead, always need to get paste unless "m"
115+
// when isHead, no need to get paste unless "u"
116+
const shouldGetPasteContent = (!isHead && role !== "m") || (isHead && role === "u")
117+
118+
const item: PasteWithMetadata | null = shouldGetPasteContent
119+
? await getPaste(env, nameFromPath, ctx)
120+
: await getPasteWithoutContent(env, nameFromPath)
109121

110122
// when paste is not found
111123
if (item === null) {
@@ -141,6 +153,9 @@ export async function handleGet(request: Request, env: Env, ctx: ExecutionContex
141153

142154
// handle URL redirection
143155
if (role === "u") {
156+
if (item.metadata.sizeBytes > MAX_URL_REDIRECT_LEN) {
157+
throw new WorkerError(400, `URL too long to be redirected (max ${MAX_URL_REDIRECT_LEN} bytes)`)
158+
}
144159
const redirectURL = await decodeMaybeStream(item.paste)
145160
if (isLegalUrl(redirectURL)) {
146161
return Response.redirect(redirectURL)
@@ -151,8 +166,7 @@ export async function handleGet(request: Request, env: Env, ctx: ExecutionContex
151166

152167
// handle article (render as markdown)
153168
if (role === "a") {
154-
const md = makeMarkdown(await decodeMaybeStream(item.paste))
155-
return new Response(md, {
169+
return new Response(shouldGetPasteContent ? makeMarkdown(await decodeMaybeStream(item.paste)) : null, {
156170
headers: {
157171
"Content-Type": `text/html;charset=UTF-8`,
158172
...pasteCacheHeader(env),
@@ -161,10 +175,29 @@ export async function handleGet(request: Request, env: Env, ctx: ExecutionContex
161175
})
162176
}
163177

178+
// handle metadata access
179+
if (role === "m") {
180+
const returnedMetadata: MetaResponse = {
181+
lastModifiedAt: new Date(item.metadata.lastModifiedAtUnix * 1000).toISOString(),
182+
createdAt: new Date(item.metadata.createdAtUnix * 1000).toISOString(),
183+
expireAt: new Date(item.metadata.willExpireAtUnix * 1000).toISOString(),
184+
sizeBytes: item.metadata.sizeBytes,
185+
filename: item.metadata.filename,
186+
location: item.metadata.location,
187+
}
188+
return new Response(isHead ? null : JSON.stringify(returnedMetadata, null, 2), {
189+
headers: {
190+
"Content-Type": `application/json;charset=UTF-8`,
191+
...pasteCacheHeader(env),
192+
...lastModifiedHeader(item.metadata),
193+
},
194+
})
195+
}
196+
164197
// handle language highlight
165198
const lang = url.searchParams.get("lang")
166199
if (lang) {
167-
return new Response(makeHighlight(await decodeMaybeStream(item.paste), lang), {
200+
return new Response(shouldGetPasteContent ? makeHighlight(await decodeMaybeStream(item.paste), lang) : null, {
168201
headers: {
169202
"Content-Type": `text/html;charset=UTF-8`,
170203
...pasteCacheHeader(env),
@@ -186,5 +219,10 @@ export async function handleGet(request: Request, env: Env, ctx: ExecutionContex
186219
headers["Content-Disposition"] = `${disp}`
187220
}
188221
headers["Access-Control-Expose-Headers"] = "Content-Disposition"
189-
return new Response(item.paste, { headers })
222+
223+
// if content is nonempty, Content-Length will be set automatically
224+
if (!shouldGetPasteContent) {
225+
headers["Content-Length"] = item.metadata.sizeBytes.toString()
226+
}
227+
return new Response(shouldGetPasteContent ? item.paste : null, { headers })
190228
}

src/handlers/handleWrite.ts

Lines changed: 45 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
MAX_PASSWD_LEN,
1515
parseSize,
1616
} from "../shared.js"
17+
import { MaxFileSizeExceededError, MultipartParseError, parseMultipartRequest } from "@mjackson/multipart-parser"
1718

1819
function suggestUrl(short: string, baseUrl: string, filename?: string, contentAsString?: string) {
1920
if (filename) {
@@ -25,38 +26,47 @@ function suggestUrl(short: string, baseUrl: string, filename?: string, contentAs
2526
}
2627
}
2728

28-
async function getStringFromPart(part: string | null | File): Promise<string | undefined> {
29-
if (part === null || typeof part == "string") {
30-
return part || undefined
31-
} else {
32-
return decode(await part.arrayBuffer())
33-
}
34-
}
35-
36-
async function getFileFromPart(part: string | null | File): Promise<{
29+
type ParsedMultipartPart = {
3730
filename?: string
38-
content?: ReadableStream | ArrayBuffer
31+
content: ReadableStream | ArrayBuffer
3932
contentAsString?: string
4033
contentLength: number
41-
}> {
42-
if (part === null) {
43-
return { contentLength: 0 }
44-
} else if (typeof part == "string") {
45-
const encoded = new TextEncoder().encode(part)
46-
return { filename: undefined, content: encoded.buffer, contentAsString: part, contentLength: encoded.length }
47-
} else {
48-
if (part.size < 1000) {
49-
const arrayBuffer = await part.arrayBuffer()
50-
return {
51-
filename: part.name,
52-
content: arrayBuffer,
53-
contentAsString: decode(arrayBuffer),
54-
contentLength: part.size,
34+
}
35+
36+
async function multipartToMap(req: Request, sizeLimit: number): Promise<Map<string, ParsedMultipartPart>> {
37+
const partsMap = new Map<string, ParsedMultipartPart>()
38+
try {
39+
await parseMultipartRequest(req, { maxFileSize: sizeLimit }, async (part) => {
40+
if (part.name) {
41+
if (part.isFile) {
42+
const arrayBuffer = await part.arrayBuffer()
43+
partsMap.set(part.name, {
44+
filename: part.filename,
45+
content: arrayBuffer,
46+
contentLength: arrayBuffer.byteLength,
47+
})
48+
} else {
49+
const arrayBuffer = await part.arrayBuffer()
50+
partsMap.set(part.name, {
51+
filename: part.filename,
52+
content: arrayBuffer,
53+
contentAsString: decode(arrayBuffer),
54+
contentLength: arrayBuffer.byteLength,
55+
})
56+
}
5557
}
58+
})
59+
} catch (err) {
60+
if (err instanceof MaxFileSizeExceededError) {
61+
throw new WorkerError(413, `payload too large (max ${sizeLimit} bytes allowed)`)
62+
} else if (err instanceof MultipartParseError) {
63+
console.error(err)
64+
throw new WorkerError(400, "Failed to parse multipart request")
5665
} else {
57-
return { filename: part.name, content: part.stream(), contentLength: part.size }
66+
throw err
5867
}
5968
}
69+
return partsMap
6070
}
6171

6272
export async function handlePostOrPut(
@@ -76,25 +86,24 @@ export async function handlePostOrPut(
7686
const contentType = request.headers.get("Content-Type") || ""
7787
const url = new URL(request.url)
7888

89+
// TODO: support multipart upload (https://developers.cloudflare.com/r2/api/workers/workers-multipart-usage/)
90+
7991
// parse formdata
8092
if (!contentType.includes("multipart/form-data")) {
8193
throw new WorkerError(400, `bad usage, please use 'multipart/form-data' instead of ${contentType}`)
8294
}
8395

84-
const form = await request.formData()
85-
const { filename, content, contentAsString, contentLength } = await getFileFromPart(form.get("c"))
86-
const nameFromForm = await getStringFromPart(form.get("n"))
87-
const isPrivate = form.get("p") !== null
88-
const passwdFromForm = await getStringFromPart(form.get("s"))
89-
const expireFromPart: string | undefined = await getStringFromPart(form.get("e"))
90-
const expire = expireFromPart !== undefined ? expireFromPart : env.DEFAULT_EXPIRATION
96+
const parts = await multipartToMap(request, parseSize(env.R2_MAX_ALLOWED)!)
9197

92-
// check if paste content is legal
93-
if (content === undefined) {
98+
if (!parts.has("c")) {
9499
throw new WorkerError(400, "cannot find content in formdata")
95-
} else if (contentLength > parseSize(env.R2_MAX_ALLOWED)!) {
96-
throw new WorkerError(413, "payload too large")
97100
}
101+
const { filename, content, contentAsString, contentLength } = parts.get("c")!
102+
const nameFromForm = parts.get("n")?.contentAsString
103+
const isPrivate = parts.has("p")
104+
const passwdFromForm = parts.get("s")?.contentAsString
105+
const expireFromPart: string | undefined = parts.get("e")?.contentAsString
106+
const expire = expireFromPart ? expireFromPart : env.DEFAULT_EXPIRATION
98107

99108
// parse expiration
100109
let expirationSeconds = parseExpiration(expire)

src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async function handleRequest(request: Request, env: Env, ctx: ExecutionContext):
3838
)
3939
} else {
4040
const err = e as Error
41-
console.log(err.stack)
41+
console.error(err.stack)
4242
return corsWrapResponse(new Response(`Error 500: ${err.message}\n`, { status: 500 }))
4343
}
4444
}
@@ -49,7 +49,9 @@ async function handleNormalRequest(request: Request, env: Env, ctx: ExecutionCon
4949
if (request.method === "POST") {
5050
return await handlePostOrPut(request, env, ctx, false)
5151
} else if (request.method === "GET") {
52-
return await handleGet(request, env, ctx)
52+
return await handleGet(request, env, ctx, false)
53+
} else if (request.method === "HEAD") {
54+
return await handleGet(request, env, ctx, true)
5355
} else if (request.method === "DELETE") {
5456
return await handleDelete(request, env, ctx)
5557
} else if (request.method === "PUT") {
@@ -58,7 +60,7 @@ async function handleNormalRequest(request: Request, env: Env, ctx: ExecutionCon
5860
return new Response(`method ${request.method} not allowed`, {
5961
status: 405,
6062
headers: {
61-
Allow: "GET, PUT, POST, DELETE, OPTION",
63+
Allow: "GET, HEAD, PUT, POST, DELETE, OPTION",
6264
},
6365
})
6466
}

src/shared.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// This file contains things shared with frontend
22

3+
export type PasteLocation = "KV" | "R2"
4+
35
export type PasteResponse = {
46
url: string
57
suggestedUrl?: string
@@ -8,13 +10,23 @@ export type PasteResponse = {
810
expireAt: string
911
}
1012

13+
export type MetaResponse = {
14+
lastModifiedAt: string
15+
createdAt: string
16+
expireAt: string
17+
sizeBytes: number
18+
filename?: string
19+
location: PasteLocation
20+
}
21+
1122
export const CHAR_GEN = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"
1223
export const NAME_REGEX = /^[a-zA-Z0-9+_\-[\]*$@,;]{3,}$/
1324
export const PASTE_NAME_LEN = 4
1425
export const PRIVATE_PASTE_NAME_LEN = 24
1526
export const DEFAULT_PASSWD_LEN = 24
1627
export const MAX_PASSWD_LEN = 128
1728
export const MIN_PASSWD_LEN = 8
29+
export const MAX_URL_REDIRECT_LEN = 2000
1830
export const PASSWD_SEP = ":"
1931

2032
export function parseSize(sizeStr: string): number | null {
@@ -71,21 +83,13 @@ export type ParsedPath = {
7183
}
7284

7385
export function parsePath(pathname: string): ParsedPath {
74-
// Example of paths (SEP=':'). Note: query string is not processed here
75-
// > example.com/~stocking
76-
// > example.com/~stocking:uLE4Fhb/d3414adlW653Vx0VSVw=
77-
// > example.com/abcd
78-
// > example.com/abcd.jpg
79-
// > example.com/abcd/myphoto.jpg
80-
// > example.com/u/abcd
81-
// > example.com/abcd:3ffd2e7ff214989646e006bd9ad36c58d447065e
8286
pathname = pathname.slice(1) // strip the leading slash
8387

84-
let role: string | undefined = undefined,
85-
ext: string | undefined = undefined,
86-
filename: string | undefined = undefined,
87-
passwd: string | undefined = undefined,
88-
short: string | undefined = undefined
88+
let role: string | undefined,
89+
ext: string | undefined,
90+
filename: string | undefined,
91+
passwd: string | undefined,
92+
short: string | undefined
8993

9094
// extract and remove role
9195
if (pathname[1] === "/") {

0 commit comments

Comments
 (0)