Skip to content

Commit 402f182

Browse files
committed
feat[r2]: add basic r2 support
1 parent 8e14cd9 commit 402f182

File tree

10 files changed

+262
-181
lines changed

10 files changed

+262
-181
lines changed

src/handlers/handleDelete.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export async function handleDelete(request: Request, env: Env, _: ExecutionConte
1212
if (passwd !== metadata.passwd) {
1313
throw new WorkerError(403, `incorrect password for paste '${nameFromPath}`)
1414
} else {
15-
await deletePaste(env, nameFromPath)
15+
await deletePaste(env, nameFromPath, metadata)
1616
return new Response("the paste will be deleted in seconds")
1717
}
1818
}

src/handlers/handleRead.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ import { parsePath } from "../shared.js"
99

1010
type Headers = { [name: string]: string }
1111

12+
async function decodeMaybeStream(content: ArrayBuffer | ReadableStream): Promise<string> {
13+
if (content instanceof ArrayBuffer) {
14+
return decode(content)
15+
} else {
16+
const reader = content.pipeThrough(new TextDecoderStream()).getReader()
17+
let result = ""
18+
while (true) {
19+
const { done, value } = await reader.read()
20+
if (done) {
21+
break
22+
}
23+
result += value
24+
}
25+
return result
26+
}
27+
}
28+
1229
function staticPageCacheHeader(env: Env): Headers {
1330
const age = env.CACHE_STATIC_PAGE_AGE
1431
return age ? { "Cache-Control": `public, max-age=${age}` } : {}
@@ -76,8 +93,8 @@ async function handleStaticPages(request: Request, env: Env, _: ExecutionContext
7693
return null
7794
}
7895

79-
export async function handleGet(request: Request, env: Env, _: ExecutionContext): Promise<Response> {
80-
const staticPageResp = await handleStaticPages(request, env, _)
96+
export async function handleGet(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
97+
const staticPageResp = await handleStaticPages(request, env, ctx)
8198
if (staticPageResp !== null) {
8299
return staticPageResp
83100
}
@@ -88,7 +105,7 @@ export async function handleGet(request: Request, env: Env, _: ExecutionContext)
88105

89106
const disp = url.searchParams.has("a") ? "attachment" : "inline"
90107

91-
const item = await getPaste(env, nameFromPath)
108+
const item = await getPaste(env, nameFromPath, ctx)
92109

93110
// when paste is not found
94111
if (item === null) {
@@ -120,7 +137,7 @@ export async function handleGet(request: Request, env: Env, _: ExecutionContext)
120137

121138
// handle URL redirection
122139
if (role === "u") {
123-
const redirectURL = decode(item.paste)
140+
const redirectURL = await decodeMaybeStream(item.paste)
124141
if (isLegalUrl(redirectURL)) {
125142
return Response.redirect(redirectURL)
126143
} else {
@@ -130,7 +147,7 @@ export async function handleGet(request: Request, env: Env, _: ExecutionContext)
130147

131148
// handle article (render as markdown)
132149
if (role === "a") {
133-
const md = makeMarkdown(decode(item.paste))
150+
const md = makeMarkdown(await decodeMaybeStream(item.paste))
134151
return new Response(md, {
135152
headers: {
136153
"Content-Type": `text/html;charset=UTF-8`,
@@ -143,7 +160,7 @@ export async function handleGet(request: Request, env: Env, _: ExecutionContext)
143160
// handle language highlight
144161
const lang = url.searchParams.get("lang")
145162
if (lang) {
146-
return new Response(makeHighlight(decode(item.paste), lang), {
163+
return new Response(makeHighlight(await decodeMaybeStream(item.paste), lang), {
147164
headers: {
148165
"Content-Type": `text/html;charset=UTF-8`,
149166
...pasteCacheHeader(env),

src/handlers/handleWrite.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { decode, genRandStr, isLegalUrl, WorkerError } from "../common.js"
33
import { createPaste, getPasteMetadata, pasteNameAvailable, updatePaste } from "../storage/storage.js"
44
import {
55
DEFAULT_PASSWD_LEN,
6-
MAX_LEN,
76
NAME_REGEX,
87
PASTE_NAME_LEN,
98
PasteResponse,
@@ -13,6 +12,7 @@ import {
1312
parsePath,
1413
MIN_PASSWD_LEN,
1514
MAX_PASSWD_LEN,
15+
parseSize,
1616
} from "../shared.js"
1717

1818
function suggestUrl(short: string, baseUrl: string, filename?: string, contentAsString?: string) {
@@ -93,7 +93,7 @@ export async function handlePostOrPut(
9393
// check if paste content is legal
9494
if (content === undefined) {
9595
throw new WorkerError(400, "cannot find content in formdata")
96-
} else if (contentLength > MAX_LEN) {
96+
} else if (contentLength > parseSize(env.R2_MAX_ALLOWED)!) {
9797
throw new WorkerError(413, "payload too large")
9898
}
9999

src/shared.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,21 @@ export const DEFAULT_PASSWD_LEN = 24
1616
export const MAX_PASSWD_LEN = 128
1717
export const MIN_PASSWD_LEN = 8
1818
export const PASSWD_SEP = ":"
19-
export const MAX_LEN = 25 * 1024 * 1024
19+
20+
export function parseSize(sizeStr: string): number | null {
21+
sizeStr = sizeStr.trim()
22+
const EXPIRE_REGEX = /^[\d.]+\s*[KMG]?$/
23+
if (!EXPIRE_REGEX.test(sizeStr)) {
24+
return null
25+
}
26+
27+
let sizeBytes = parseFloat(sizeStr)
28+
const lastChar = sizeStr[sizeStr.length - 1]
29+
if (lastChar === "K") sizeBytes *= 1024
30+
else if (lastChar === "M") sizeBytes *= 1024 * 1024
31+
else if (lastChar === "G") sizeBytes *= 1024 * 1024 * 1024
32+
return sizeBytes
33+
}
2034

2135
export function parseExpiration(expirationStr: string): number | null {
2236
expirationStr = expirationStr.trim()

src/storage/storage.ts

Lines changed: 130 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,79 @@
11
import { dateToUnix, WorkerError } from "../common.js"
2+
import { parseSize } from "../shared.js"
23

4+
export type PasteLocation = "KV" | "R2"
5+
6+
// TODO: allow admin to upload permanent paste
7+
// TODO: add filename length check
38
export type PasteMetadata = {
4-
schemaVersion: number
9+
schemaVersion: 1
10+
location: PasteLocation // new field on V1
511
passwd: string
612

713
lastModifiedAtUnix: number
814
createdAtUnix: number
9-
// TODO: allow admin to upload permanent paste
1015
willExpireAtUnix: number
1116

1217
accessCounter: number // a counter representing how frequent it is accessed, to administration usage
18+
sizeBytes: number
19+
filename?: string
20+
}
21+
22+
type PasteMetadataInStorage = {
23+
schemaVersion: number
24+
location?: PasteLocation
25+
passwd: string
26+
27+
lastModifiedAtUnix: number
28+
createdAtUnix: number
29+
willExpireAtUnix: number
30+
31+
accessCounter?: number
1332
sizeBytes?: number
14-
// TODO: add filename length check
1533
filename?: string
1634
}
1735

36+
function migratePasteMetadata(original: PasteMetadataInStorage): PasteMetadata {
37+
return {
38+
schemaVersion: 1,
39+
location: original.location || "KV",
40+
passwd: original.passwd,
41+
42+
lastModifiedAtUnix: original.lastModifiedAtUnix,
43+
createdAtUnix: original.createdAtUnix,
44+
willExpireAtUnix: original.willExpireAtUnix,
45+
46+
accessCounter: original.accessCounter || 0,
47+
sizeBytes: original.sizeBytes || 0,
48+
filename: original.filename,
49+
}
50+
}
51+
1852
export type PasteWithMetadata = {
19-
paste: ArrayBuffer
53+
paste: ArrayBuffer | ReadableStream
2054
metadata: PasteMetadata
2155
}
2256

23-
export async function getPaste(env: Env, short: string): Promise<PasteWithMetadata | null> {
24-
const item = await env.PB.getWithMetadata<PasteMetadata>(short, {
57+
async function updateAccessCounter(env: Env, short: string, value: ArrayBuffer, metadata: PasteMetadata) {
58+
// update counter with probability 1%
59+
if (Math.random() < 0.01) {
60+
metadata.accessCounter += 1
61+
try {
62+
await env.PB.put(short, value, {
63+
metadata: metadata,
64+
expiration: metadata.willExpireAtUnix,
65+
})
66+
} catch (e) {
67+
// ignore rate limit message
68+
if (!(e as Error).message.includes("KV PUT failed: 429 Too Many Requests")) {
69+
throw e
70+
}
71+
}
72+
}
73+
}
74+
75+
export async function getPaste(env: Env, short: string, ctx: ExecutionContext): Promise<PasteWithMetadata | null> {
76+
const item = await env.PB.getWithMetadata<PasteMetadataInStorage>(short, {
2577
type: "arrayBuffer",
2678
})
2779

@@ -30,34 +82,38 @@ export async function getPaste(env: Env, short: string): Promise<PasteWithMetada
3082
} else if (item.metadata === null) {
3183
throw new WorkerError(500, `paste of name '${short}' has no metadata`)
3284
} else {
33-
if (item.metadata.willExpireAtUnix < new Date().getTime() / 1000) {
85+
const metadata = migratePasteMetadata(item.metadata)
86+
const expired = metadata.willExpireAtUnix < new Date().getTime() / 1000
87+
88+
ctx.waitUntil(
89+
(async () => {
90+
if (expired) {
91+
await deletePaste(env, short, metadata)
92+
return null
93+
}
94+
await updateAccessCounter(env, short, item.value!, metadata)
95+
})(),
96+
)
97+
98+
if (expired) {
3499
return null
35100
}
36101

37-
// update counter with probability 1%
38-
// TODO: use waitUntil API
39-
if (Math.random() < 0.01) {
40-
item.metadata.accessCounter += 1
41-
try {
42-
await env.PB.put(short, item.value, {
43-
metadata: item.metadata,
44-
expiration: item.metadata.willExpireAtUnix,
45-
})
46-
} catch (e) {
47-
// ignore rate limit message
48-
if (!(e as Error).message.includes("KV PUT failed: 429 Too Many Requests")) {
49-
throw e
50-
}
102+
if (metadata.location === "R2") {
103+
const object = await env.R2.get(short)
104+
if (object === null) {
105+
throw new WorkerError(404, `cannot find R2 bucket of name '${short}'`)
51106
}
107+
return { paste: object.body, metadata }
108+
} else {
109+
return { paste: item.value, metadata }
52110
}
53-
54-
return { paste: item.value, metadata: item.metadata }
55111
}
56112
}
57113

58114
// we separate usage of getPasteMetadata and getPaste to make access metric more reliable
59115
export async function getPasteMetadata(env: Env, short: string): Promise<PasteMetadata | null> {
60-
const item = await env.PB.getWithMetadata<PasteMetadata>(short, {
116+
const item = await env.PB.getWithMetadata<PasteMetadataInStorage>(short, {
61117
type: "stream",
62118
})
63119

@@ -69,7 +125,7 @@ export async function getPasteMetadata(env: Env, short: string): Promise<PasteMe
69125
if (item.metadata.willExpireAtUnix < new Date().getTime() / 1000) {
70126
return null
71127
}
72-
return item.metadata
128+
return migratePasteMetadata(item.metadata)
73129
}
74130
}
75131

@@ -87,22 +143,29 @@ export async function updatePaste(
87143
},
88144
) {
89145
const expirationUnix = dateToUnix(options.now) + options.expirationSeconds
90-
const putOptions: KVNamespacePutOptions = {
91-
metadata: {
92-
schemaVersion: 0,
93-
filename: options.filename || originalMetadata.filename,
94-
passwd: options.passwd,
95-
96-
lastModifiedAtUnix: dateToUnix(options.now),
97-
createdAtUnix: originalMetadata.createdAtUnix,
98-
willExpireAtUnix: expirationUnix,
99-
accessCounter: originalMetadata.accessCounter,
100-
sizeBytes: options.contentLength,
101-
},
102-
expiration: expirationUnix,
146+
// since CF does not allow expiration shorter than 60s, extend the expiration to 70s
147+
const expirationUnixSpecified = dateToUnix(options.now) + Math.max(options.expirationSeconds, 70)
148+
149+
if (originalMetadata.location === "R2") {
150+
await env.R2.put(pasteName, content)
151+
}
152+
const metadata: PasteMetadata = {
153+
schemaVersion: 1,
154+
location: originalMetadata.location,
155+
filename: options.filename || originalMetadata.filename,
156+
passwd: options.passwd,
157+
158+
lastModifiedAtUnix: dateToUnix(options.now),
159+
createdAtUnix: originalMetadata.createdAtUnix,
160+
willExpireAtUnix: expirationUnix,
161+
accessCounter: originalMetadata.accessCounter,
162+
sizeBytes: options.contentLength,
103163
}
104164

105-
await env.PB.put(pasteName, content, putOptions)
165+
await env.PB.put(pasteName, originalMetadata.location === "R2" ? "" : content, {
166+
metadata: metadata,
167+
expiration: expirationUnixSpecified,
168+
})
106169
}
107170

108171
export async function createPaste(
@@ -118,22 +181,32 @@ export async function createPaste(
118181
},
119182
) {
120183
const expirationUnix = dateToUnix(options.now) + options.expirationSeconds
121-
const putOptions: KVNamespacePutOptions = {
122-
metadata: {
123-
schemaVersion: 0,
124-
filename: options.filename,
125-
passwd: options.passwd,
126-
127-
lastModifiedAtUnix: dateToUnix(options.now),
128-
createdAtUnix: dateToUnix(options.now),
129-
willExpireAtUnix: expirationUnix,
130-
accessCounter: 0,
131-
sizeBytes: options.contentLength,
132-
},
133-
expiration: expirationUnix,
184+
185+
// since CF does not allow expiration shorter than 60s, extend the expiration to 70s
186+
const expirationUnixSpecified = dateToUnix(options.now) + Math.max(options.expirationSeconds, 70)
187+
188+
const location = options.contentLength > parseSize(env.R2_THRESHOLD)! ? "R2" : "KV"
189+
if (location === "R2") {
190+
await env.R2.put(pasteName, content)
134191
}
135192

136-
await env.PB.put(pasteName, content, putOptions)
193+
const metadata: PasteMetadata = {
194+
schemaVersion: 1,
195+
location: location,
196+
filename: options.filename,
197+
passwd: options.passwd,
198+
199+
lastModifiedAtUnix: dateToUnix(options.now),
200+
createdAtUnix: dateToUnix(options.now),
201+
willExpireAtUnix: expirationUnix,
202+
accessCounter: 0,
203+
sizeBytes: options.contentLength,
204+
}
205+
206+
await env.PB.put(pasteName, location === "R2" ? "" : content, {
207+
metadata: metadata,
208+
expiration: expirationUnixSpecified,
209+
})
137210
}
138211

139212
export async function pasteNameAvailable(env: Env, pasteName: string): Promise<boolean> {
@@ -147,6 +220,9 @@ export async function pasteNameAvailable(env: Env, pasteName: string): Promise<b
147220
}
148221
}
149222

150-
export async function deletePaste(env: Env, pasteName: string): Promise<void> {
223+
export async function deletePaste(env: Env, pasteName: string, originalMetadata: PasteMetadata): Promise<void> {
151224
await env.PB.delete(pasteName)
225+
if (originalMetadata.location === "R2") {
226+
await env.R2.delete(pasteName)
227+
}
152228
}

0 commit comments

Comments
 (0)