diff --git a/eslint.config.js b/eslint.config.js index 03f40734..fcff132e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,7 +20,7 @@ export default tseslint.config( rules: { "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": [ - "warn", + "error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", diff --git a/frontend/components/CopyWidget.tsx b/frontend/components/CopyWidget.tsx new file mode 100644 index 00000000..fa95d68c --- /dev/null +++ b/frontend/components/CopyWidget.tsx @@ -0,0 +1,33 @@ +import { Button, ButtonProps } from "@heroui/react" +import { useRef, useState } from "react" +import { CopyIcon, CheckIcon } from "./icons.js" + +interface CopyIconProps extends ButtonProps { + getCopyContent: () => string +} + +export function CopyWidget({ className, getCopyContent, ...rest }: CopyIconProps) { + const numOfIssuedCopies = useRef(0) + const [hasIssuedCopies, setHasIssuedCopies] = useState(false) + const onCopy = () => { + const content = getCopyContent() + navigator.clipboard + .writeText(content) + .then(() => { + numOfIssuedCopies.current = numOfIssuedCopies.current + 1 + setHasIssuedCopies(numOfIssuedCopies.current > 0) + + setTimeout(() => { + numOfIssuedCopies.current = numOfIssuedCopies.current - 1 + setHasIssuedCopies(numOfIssuedCopies.current > 0) + }, 1000) + }) + .catch(console.error) + } + + return ( + + ) +} diff --git a/frontend/components/ErrorModal.tsx b/frontend/components/ErrorModal.tsx index a52f1e4b..0cfe5169 100644 --- a/frontend/components/ErrorModal.tsx +++ b/frontend/components/ErrorModal.tsx @@ -1,5 +1,6 @@ -import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/react" -import React from "react" +import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalProps } from "@heroui/react" +import React, { useState } from "react" +import { ErrorWithTitle } from "../utils/utils.js" export type ErrorState = { title: string @@ -7,32 +8,49 @@ export type ErrorState = { isOpen: boolean } -type ErrorModalProps = { - onDismiss: () => void - state: ErrorState -} +type ErrorModalProps = Omit + +export function useErrorModal() { + const [errorState, setErrorState] = useState({ isOpen: false, content: "", title: "" }) + + function showModal(title: string, content: string) { + setErrorState({ title, content, isOpen: true }) + } + + async function handleFailedResp(defaultTitle: string, resp: Response) { + const statusText = resp.statusText === "error" ? "Unknown error" : resp.statusText + const errText = (await resp.text()) || statusText + showModal(defaultTitle, errText) + } + + function handleError(defaultTitle: string, error: Error) { + if (error instanceof ErrorWithTitle) { + showModal(error.title, error.message) + } else { + showModal(defaultTitle, error.message) + } + } + + const ErrorModal = ({ ...rest }: ErrorModalProps) => { + const onClose = () => { + setErrorState({ isOpen: false, content: "", title: "" }) + } + return ( + + + {errorState.title} + +

{errorState.content}

+
+ + + +
+
+ ) + } -export function ErrorModal({ onDismiss, state }: ErrorModalProps) { - return ( - { - if (!open) { - onDismiss() - } - }} - > - - {state.title} - -

{state.content}

-
- - - -
-
- ) + return { ErrorModal, showModal, errorState, handleError, handleFailedResp } } diff --git a/frontend/components/PasteSettingPanel.tsx b/frontend/components/PasteSettingPanel.tsx index b0944ad5..669d98da 100644 --- a/frontend/components/PasteSettingPanel.tsx +++ b/frontend/components/PasteSettingPanel.tsx @@ -5,6 +5,7 @@ import { CardProps, Divider, Input, + mergeClasses, Radio, RadioGroup, Switch, @@ -33,12 +34,13 @@ interface PasteSettingPanelProps extends CardProps { } export function PanelSettingsPanel({ setting, onSettingChange, ...rest }: PasteSettingPanelProps) { + const radioClassNames = mergeClasses(radioOverrides, { labelWrapper: "ml-2.5" }) return ( - Settings + Settings -
+
onSettingChange({ ...setting, uploadKind: v as UploadKind })} > - + Generate a short random URL Generate a long random URL - + Set by your own {setting.uploadKind === "custom" ? ( @@ -93,7 +95,7 @@ export function PanelSettingsPanel({ setting, onSettingChange, ...rest }: PasteS value={setting.name} onValueChange={(n) => onSettingChange({ ...setting, name: n })} type="text" - classNames={inputOverrides} + classNames={radioClassNames} isInvalid={!verifyName(setting.name)[0]} errorMessage={verifyName(setting.name)[1]} startContent={ @@ -103,7 +105,7 @@ export function PanelSettingsPanel({ setting, onSettingChange, ...rest }: PasteS } /> ) : null} - +
Update or delete
{setting.uploadKind === "manage" ? ( diff --git a/frontend/components/UploadedPanel.tsx b/frontend/components/UploadedPanel.tsx index ea7927cf..751d746f 100644 --- a/frontend/components/UploadedPanel.tsx +++ b/frontend/components/UploadedPanel.tsx @@ -1,12 +1,16 @@ -import { Card, CardBody, CardHeader, CardProps, Divider, mergeClasses, Skeleton, Snippet } from "@heroui/react" import React from "react" -import { PasteResponse } from "../../shared/interfaces.js" + +import { Card, CardBody, CardHeader, CardProps, CircularProgress, Divider, Input, mergeClasses } from "@heroui/react" + +import type { PasteResponse } from "../../shared/interfaces.js" import { tst } from "../utils/overrides.js" +import { CopyWidget } from "./CopyWidget.js" interface UploadedPanelProps extends CardProps { isLoading: boolean - pasteResponse: PasteResponse | null - encryptionKey: string | null + loadingProgress?: number + pasteResponse?: PasteResponse + encryptionKey?: string } const makeDecryptionUrl = (url: string, key: string) => { @@ -15,62 +19,79 @@ const makeDecryptionUrl = (url: string, key: string) => { return urlParsed.toString() + "#" + key } -export function UploadedPanel({ isLoading, pasteResponse, className, encryptionKey, ...rest }: UploadedPanelProps) { - const snippetClassNames = { - pre: `overflow-scroll leading-[2.5] font-sans ${tst}`, - base: `w-full py-1/3 ${tst}`, - copyButton: `relative ml-[-12pt] left-[5pt] ${tst}`, +export function UploadedPanel({ + isLoading, + loadingProgress, + pasteResponse, + className, + encryptionKey, + ...rest +}: UploadedPanelProps) { + const copyWidgetClassNames = `bg-transparent ${tst} translate-y-[10%]` + const inputProps = { + "aria-labelledby": "", + readOnly: true, + className: "mb-2", } - const firstColClassNames = "w-[8rem] mr-4 whitespace-nowrap" + return ( - Uploaded Paste + Uploaded Paste - - - - - - - - - - - {encryptionKey ? ( - - - - - ) : null} - - - - - -
Paste URL - - - {pasteResponse?.url} - - -
Manage URL - - - {pasteResponse?.manageUrl} - - -
Decryption URL - - - {pasteResponse && makeDecryptionUrl(pasteResponse.url, encryptionKey)} - - -
Expire At - - {pasteResponse && new Date(pasteResponse.expireAt).toLocaleString()} - -
+ {isLoading ? ( +
+ +
+ ) : ( + pasteResponse && ( + <> + {encryptionKey && ( + makeDecryptionUrl(pasteResponse.url, encryptionKey)} + /> + } + /> + )} + pasteResponse.url} />} + /> + pasteResponse.manageUrl} /> + } + /> + {pasteResponse.suggestedUrl && ( + pasteResponse.suggestedUrl!} /> + } + /> + )} + + + ) + )}
) diff --git a/frontend/pages/DecryptPaste.tsx b/frontend/pages/DecryptPaste.tsx index 8eb26440..6d1bb206 100644 --- a/frontend/pages/DecryptPaste.tsx +++ b/frontend/pages/DecryptPaste.tsx @@ -1,17 +1,20 @@ -import React, { useEffect, useRef, useState } from "react" -import { ErrorModal, ErrorState } from "../components/ErrorModal.js" -import { decodeKey, decrypt, EncryptionScheme } from "../utils/encryption.js" +import React, { useEffect, useMemo, useState } from "react" import { Button, CircularProgress, Link, Tooltip } from "@heroui/react" -import { CheckIcon, CopyIcon, DownloadIcon, HomeIcon } from "../components/icons.js" +import binaryExtensions from "binary-extensions" + +import { useErrorModal } from "../components/ErrorModal.js" +import { DarkModeToggle, useDarkModeSelection } from "../components/DarkModeToggle.js" +import { DownloadIcon, HomeIcon } from "../components/icons.js" +import { CopyWidget } from "../components/CopyWidget.js" -import "../style.css" import { parseFilenameFromContentDisposition, parsePath } from "../../shared/parsers.js" +import { decodeKey, decrypt, EncryptionScheme } from "../utils/encryption.js" import { formatSize } from "../utils/utils.js" -import { DarkModeToggle, useDarkModeSelection } from "../components/DarkModeToggle.js" -import binaryExtensions from "binary-extensions" import { tst } from "../utils/overrides.js" +import "../style.css" + function isBinaryPath(path: string) { return binaryExtensions.includes(path.replace(/.*\./, "")) } @@ -22,20 +25,16 @@ export function DecryptPaste() { const [isFileBinary, setFileBinary] = useState(false) const [forceShowBinary, setForceShowBinary] = useState(false) + const showFileContent = pasteFile !== undefined && (!isFileBinary || forceShowBinary) const [isLoading, setIsLoading] = useState(false) - const [errorState, setErrorState] = useState({ isOpen: false, content: "", title: "" }) - - function showModal(content: string, title: string) { - setErrorState({ title, content, isOpen: true }) - } + const { ErrorModal, showModal, handleFailedResp } = useErrorModal() + const [isDark, modeSelection, setModeSelection] = useDarkModeSelection() - async function reportResponseError(resp: Response, title: string) { - const statusText = resp.statusText === "error" ? "Unknown error" : resp.statusText - const errText = (await resp.text()) || statusText - showModal(errText, title) - } + const pasteStringContent = useMemo(() => { + return pasteContentBuffer && new TextDecoder().decode(pasteContentBuffer) + }, [pasteContentBuffer]) // uncomment the following lines for testing // const url = new URL("http://localhost:8787/d/dHYQ.jpg.txt#uqeULsBTb2I3iC7rD6AaYh4oJ5lMjJA2nYR+H0U8bEA=") @@ -46,7 +45,7 @@ export function DecryptPaste() { useEffect(() => { if (keyString.length === 0) { - showModal("No encryption key is given. You should append the key after a “#” character in the URL", "Error") + showModal("Error", "No encryption key is given. You should append the key after a “#” character in the URL") } const pasteUrl = `${API_URL}/${name}` @@ -55,7 +54,7 @@ export function DecryptPaste() { setIsLoading(true) const resp = await fetch(pasteUrl) if (!resp.ok) { - await reportResponseError(resp, `Error on fetching ${pasteUrl}`) + await handleFailedResp("Failed to Fetch Paste", resp) return } @@ -64,17 +63,17 @@ export function DecryptPaste() { try { key = await decodeKey(scheme, keyString) } catch { - showModal(`Failed to parse “${keyString}” as ${scheme} key`, "Error") + showModal("Error", `Failed to parse “${keyString}” as ${scheme} key`) return } if (key === undefined) { - showModal(`Failed to parse “${keyString}” as ${scheme} key`, "Error") + showModal("Error", `Failed to parse “${keyString}” as ${scheme} key`) return } const decrypted = await decrypt(scheme, key, await resp.bytes()) if (decrypted === null) { - showModal("Failed to decrypt content", "Error") + showModal("Error", "Failed to decrypt content") } else { const filenameFromDispTrimmed = resp.headers.has("Content-Disposition") ? parseFilenameFromContentDisposition(resp.headers.get("Content-Disposition")!)?.replace( @@ -86,14 +85,15 @@ export function DecryptPaste() { const inferredFilename = filename || (ext && name + ext) || filenameFromDispTrimmed || name setPasteFile(new File([decrypted], inferredFilename)) setPasteContentBuffer(decrypted) - setFileBinary(isBinaryPath(inferredFilename)) + const isBinary = isBinaryPath(inferredFilename) + setFileBinary(isBinary) } } finally { setIsLoading(false) } } fetchPaste().catch((e) => { - showModal((e as Error).toString(), `Error on fetching ${pasteUrl}`) + showModal(`Error on fetching ${pasteUrl}`, (e as Error).toString()) console.error(e) }) }, []) @@ -121,27 +121,6 @@ export function DecryptPaste() {
) - const [isDark, modeSelection, setModeSelection] = useDarkModeSelection() - - const numOfIssuedCopies = useRef(0) - const [hasIssuedCopies, setHasIssuedCopies] = useState(false) - const onCopy = () => { - navigator.clipboard - .writeText(new TextDecoder().decode(pasteContentBuffer)) - .then(() => { - numOfIssuedCopies.current = numOfIssuedCopies.current + 1 - setHasIssuedCopies(numOfIssuedCopies.current > 0) - - setTimeout(() => { - numOfIssuedCopies.current = numOfIssuedCopies.current - 1 - setHasIssuedCopies(numOfIssuedCopies.current > 0) - }, 1000) - }) - .catch(console.error) - } - - const showFileContent = pasteFile && (!isFileBinary || forceShowBinary) - const buttonClasses = `rounded-full bg-background hover:bg-default-100 ${tst}` return (
{showFileContent && ( - + pasteStringContent!} /> )} {pasteFile && ( @@ -192,7 +169,7 @@ export function DecryptPaste() { <> {fileIndicator}
- {new TextDecoder().decode(pasteContentBuffer)} + {pasteStringContent!}
) : ( @@ -204,12 +181,7 @@ export function DecryptPaste() { - { - setErrorState({ isOpen: false, content: "", title: "" }) - }} - state={errorState} - /> +
) } diff --git a/frontend/pages/PasteBin.tsx b/frontend/pages/PasteBin.tsx index 9180a0ef..3375f428 100644 --- a/frontend/pages/PasteBin.tsx +++ b/frontend/pages/PasteBin.tsx @@ -1,13 +1,15 @@ -import React, { useEffect, useState } from "react" +import React, { useEffect, useState, useTransition } from "react" import { Button, Link } from "@heroui/react" -import { PasteResponse } from "../../shared/interfaces.js" +import type { PasteResponse } from "../../shared/interfaces.js" import { parsePath, parseFilenameFromContentDisposition } from "../../shared/parsers.js" import { DarkModeToggle, useDarkModeSelection } from "../components/DarkModeToggle.js" -import { ErrorModal, ErrorState } from "../components/ErrorModal.js" +import { useErrorModal } from "../components/ErrorModal.js" import { PanelSettingsPanel, PasteSetting } from "../components/PasteSettingPanel.js" +import { UploadedPanel } from "../components/UploadedPanel.js" +import { PasteEditor, PasteEditState } from "../components/PasteEditor.js" import { verifyExpiration, @@ -16,16 +18,12 @@ import { maxExpirationReadable, BaseUrl, APIUrl, - ErrorWithTitle, - makeErrorMsg, } from "../utils/utils.js" - -import "../style.css" -import { UploadedPanel } from "../components/UploadedPanel.js" -import { PasteEditor, PasteEditState } from "../components/PasteEditor.js" import { uploadPaste } from "../utils/uploader.js" import { tst } from "../utils/overrides.js" +import "../style.css" + export function PasteBin() { const [editorState, setEditorState] = useState({ editKind: "edit", @@ -42,25 +40,16 @@ export function PasteBin() { doEncrypt: false, }) - const [pasteResponse, setPasteResponse] = useState(null) - const [uploadedEncryptionKey, setUploadedEncryptionKey] = useState(null) - - const [isLoading, setIsLoading] = useState(false) - const [isPasteLoading, setIsPasteLoading] = useState(false) + const [pasteResponse, setPasteResponse] = useState(undefined) + const [uploadedEncryptionKey, setUploadedEncryptionKey] = useState(undefined) - const [errorState, setErrorState] = useState({ isOpen: false, content: "", title: "" }) + const [isUploadPending, startUpload] = useTransition() + const [loadingProgress, setLoadingProgress] = useState(undefined) + const [isInitPasteLoading, startFetchingInitPaste] = useTransition() const [isDark, modeSelection, setModeSelection] = useDarkModeSelection() - function showModal(title: string, content: string) { - setErrorState({ title, content, isOpen: true }) - } - - async function reportResponseError(resp: Response, title: string) { - const statusText = resp.statusText === "error" ? "Unknown error" : resp.statusText - const errText = (await resp.text()) || statusText - showModal(errText, title) - } + const { ErrorModal, showModal, handleError, handleFailedResp } = useErrorModal() // handle admin URL useEffect(() => { @@ -68,43 +57,6 @@ export function PasteBin() { const pathname = location.pathname const { name, password, filename, ext } = parsePath(pathname) - const fetchPaste = async () => { - try { - setIsPasteLoading(true) - - let pasteUrl = `${APIUrl}/${name}` - if (filename) pasteUrl = `${pasteUrl}/${filename}` - if (ext) pasteUrl = `${pasteUrl}${ext}` - - const resp = await fetch(pasteUrl) - if (!resp.ok) { - await reportResponseError(resp, `Error on fetching ${pasteUrl}`) - return - } - const contentType = resp.headers.get("Content-Type") - const contentDisp = resp.headers.get("Content-Disposition") - - if (contentType && contentType.startsWith("text/")) { - setEditorState({ - editKind: "edit", - editContent: await resp.text(), - file: null, - }) - } else { - let pasteFilename = filename - if (pasteFilename === undefined && contentDisp !== null) { - pasteFilename = parseFilenameFromContentDisposition(contentDisp) - } - setEditorState({ - editKind: "file", - editContent: "", - file: new File([await resp.blob()], pasteFilename || "[unknown filename]"), - }) - } - } finally { - setIsPasteLoading(false) - } - } if (password !== undefined && pasteSetting.manageUrl === "") { setPasteSetting({ ...pasteSetting, @@ -112,37 +64,69 @@ export function PasteBin() { manageUrl: `${APIUrl}/${name}:${password}`, }) - fetchPaste().catch(console.error) + let pasteUrl = `${APIUrl}/${name}` + if (filename) pasteUrl = `${pasteUrl}/${filename}` + if (ext) pasteUrl = `${pasteUrl}${ext}` + + startFetchingInitPaste(async () => { + try { + const resp = await fetch(pasteUrl) + if (!resp.ok) { + await handleFailedResp(`Error on Fetching ${pasteUrl}`, resp) + return + } + const contentType = resp.headers.get("Content-Type") + const contentDisp = resp.headers.get("Content-Disposition") + + if (contentType && contentType.startsWith("text/")) { + setEditorState({ + editKind: "edit", + editContent: await resp.text(), + file: null, + }) + } else { + let pasteFilename = filename + if (pasteFilename === undefined && contentDisp !== null) { + pasteFilename = parseFilenameFromContentDisposition(contentDisp) + } + setEditorState({ + editKind: "file", + editContent: "", + file: new File([await resp.blob()], pasteFilename || "[unknown filename]"), + }) + } + } catch (e) { + handleError(`Error on Fetching ${pasteUrl}`, e as Error) + } + }) } }, []) - async function onUploadPaste(): Promise { - try { - const uploaded = await uploadPaste(pasteSetting, editorState, setUploadedEncryptionKey, setIsLoading) - setPasteResponse(uploaded) - } catch (error) { - console.log(error) - if (error instanceof ErrorWithTitle) { - showModal(error.title, (error as Error).message) - } else { - showModal("Error on Uploading Paste", (error as Error).message) + function onStartUpload() { + startUpload(async () => { + try { + const uploaded = await uploadPaste(pasteSetting, editorState, setUploadedEncryptionKey, setLoadingProgress) + setPasteResponse(uploaded) + } catch (e) { + handleError("Error on Uploading Paste", e as Error) } - } + }) } - async function deletePaste() { - try { - const resp = await fetch(pasteSetting.manageUrl, { method: "DELETE" }) - if (resp.ok) { - showModal("Deleted Successfully", "It may takes 60 seconds for the deletion to propagate to the world") - setPasteResponse(null) - } else { - showModal("Error From Server", await makeErrorMsg(resp)) + function onStartDelete() { + startUpload(async () => { + try { + const resp = await fetch(pasteSetting.manageUrl, { method: "DELETE" }) + if (resp.ok) { + showModal("Deleted Successfully", "It may takes 60 seconds for the deletion to propagate to the world") + setPasteResponse(undefined) + } else { + await handleFailedResp("Error on Delete Paste", resp) + } + } catch (e) { + handleError("Error on Delete Paste", e as Error) } - } catch (e) { - showModal("Error on Deleting Paste", (e as Error).message) - console.error(e) - } + }) } function canUpload(): boolean { @@ -183,11 +167,11 @@ export function PasteBin() {

An open source pastebin deployed on Cloudflare Workers.

- Usage: Paste text or file here; submit; share it with a URL. ( + Usage: Paste text or file here. Upload. Share it with a URL. Or access with our{" "} - API Documentation + APIs - ) + .

Warning: Only for temporary share (max {maxExpirationReadable}). Files could be deleted without @@ -198,13 +182,16 @@ export function PasteBin() { const submitter = (

- {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */} - {pasteSetting.uploadKind === "manage" ? ( - // eslint-disable-next-line @typescript-eslint/no-misused-promises - ) : null} @@ -235,7 +222,7 @@ export function PasteBin() {
{info} - {(pasteResponse || isLoading) && ( + {(pasteResponse || isUploadPending) && ( {footer} - { - setIsPasteLoading(false) - setIsLoading(false) - setErrorState({ isOpen: false, content: "", title: "" }) - }} - state={errorState} - /> + ) } diff --git a/frontend/test/index.spec.tsx b/frontend/test/index.spec.tsx index 0118dbf6..47c9fcb3 100644 --- a/frontend/test/index.spec.tsx +++ b/frontend/test/index.spec.tsx @@ -61,8 +61,12 @@ describe("Pastebin", () => { expect(submitter).toBeEnabled() await userEvent.click(submitter) - expect(screen.getByText(mockedPasteUpload.url)).toBeInTheDocument() - expect(screen.getByText(mockedPasteUpload.manageUrl)).toBeInTheDocument() + await new Promise((resolve) => setTimeout(resolve, 1000)) + const urlShow = screen.getByRole("textbox", { name: "Paste URL" }) + expect((urlShow as HTMLInputElement).value).toStrictEqual(mockedPasteUpload.url) + + const manageUrlShow = screen.getByRole("textbox", { name: "Manage URL" }) + expect((manageUrlShow as HTMLInputElement).value).toStrictEqual(mockedPasteUpload.manageUrl) }) it("refuse illegal settings", async () => { diff --git a/frontend/utils/uploader.ts b/frontend/utils/uploader.ts index 96680571..7c86c531 100644 --- a/frontend/utils/uploader.ts +++ b/frontend/utils/uploader.ts @@ -1,8 +1,9 @@ -import { PasteSetting } from "../components/PasteSettingPanel.js" -import { PasteEditState } from "../components/PasteEditor.js" -import { APIUrl, ErrorWithTitle, makeErrorMsg } from "./utils.js" -import { PasteResponse } from "../../shared/interfaces.js" +import type { PasteSetting } from "../components/PasteSettingPanel.js" +import type { PasteEditState } from "../components/PasteEditor.js" +import { APIUrl, ErrorWithTitle } from "./utils.js" +import type { PasteResponse } from "../../shared/interfaces.js" import { encodeKey, encrypt, EncryptionScheme, genKey } from "./encryption.js" +import { UploadError, uploadMPU, uploadNormal, UploadOptions } from "../../shared/uploadMPU.js" async function genAndEncrypt(scheme: EncryptionScheme, content: string | Uint8Array) { const key = await genKey(scheme) @@ -13,63 +14,71 @@ async function genAndEncrypt(scheme: EncryptionScheme, content: string | Uint8Ar const encryptionScheme: EncryptionScheme = "AES-GCM" +const minChunkSize = 5 * 1024 * 1024 + export async function uploadPaste( pasteSetting: PasteSetting, editorState: PasteEditState, - onEncryptionKeyChange: (k: string) => void, - onLoadingStateChange: (isLoading: boolean) => void, + onEncryptionKeyChange: (k: string | undefined) => void, // we only generate key on upload, so need a callback of key generation + onProgress?: (progress: number | undefined) => void, ): Promise { - const fd = new FormData() - if (editorState.editKind === "file") { - if (editorState.file === null) { - throw new ErrorWithTitle("Error on Preparing Upload", "No file selected") - } - if (pasteSetting.doEncrypt) { - const { key, ciphertext } = await genAndEncrypt(encryptionScheme, await editorState.file.bytes()) - const file = new File([ciphertext], editorState.file.name) - onEncryptionKeyChange(key) - fd.append("c", file) - fd.append("encryption-scheme", encryptionScheme) - } else { - fd.append("c", editorState.file) - } - } else { - if (editorState.editContent.length === 0) { - throw new ErrorWithTitle("Error on Preparing Upload", "Empty paste") - } - if (pasteSetting.doEncrypt) { - const { key, ciphertext } = await genAndEncrypt(encryptionScheme, editorState.editContent) - onEncryptionKeyChange(key) - fd.append("c", new File([ciphertext], "")) - fd.append("encryption-scheme", encryptionScheme) + async function constructContent(): Promise { + if (editorState.editKind === "file") { + if (editorState.file === null) { + throw new ErrorWithTitle("Error on Preparing Upload", "No file selected") + } + if (pasteSetting.doEncrypt) { + const { key, ciphertext } = await genAndEncrypt(encryptionScheme, await editorState.file.bytes()) + const file = new File([ciphertext], editorState.file.name) + onEncryptionKeyChange(key) + return file + } else { + onEncryptionKeyChange(undefined) + return editorState.file + } } else { - fd.append("c", editorState.editContent) + if (editorState.editContent.length === 0) { + throw new ErrorWithTitle("Error on Preparing Upload", "Empty paste") + } + if (pasteSetting.doEncrypt) { + const { key, ciphertext } = await genAndEncrypt(encryptionScheme, editorState.editContent) + onEncryptionKeyChange(key) + return new File([ciphertext], "") + } else { + return editorState.editContent + } } } - fd.append("e", pasteSetting.expiration) - if (pasteSetting.password.length > 0) fd.append("s", pasteSetting.password) + const options: UploadOptions = { + content: await constructContent(), + isUpdate: pasteSetting.uploadKind === "manage", + isPrivate: pasteSetting.uploadKind === "long", + password: pasteSetting.password.length ? pasteSetting.password : undefined, + expire: pasteSetting.expiration, + name: pasteSetting.uploadKind === "custom" ? pasteSetting.name : undefined, + encryptionScheme: pasteSetting.doEncrypt ? encryptionScheme : undefined, + manageUrl: pasteSetting.manageUrl, + } - if (pasteSetting.uploadKind === "long") fd.append("p", "true") - else if (pasteSetting.uploadKind === "custom") fd.append("n", pasteSetting.name) + const contentLength = typeof options.content === "string" ? options.content.length : options.content.size + const apiUrlOrManageUrl = options.isUpdate ? pasteSetting.manageUrl : APIUrl - onLoadingStateChange(true) - // setPasteResponse(null) - const isUpdate = pasteSetting.uploadKind !== "manage" - // TODO: add progress indicator - const resp = isUpdate - ? await fetch(APIUrl, { - method: "POST", - body: fd, - }) - : await fetch(pasteSetting.manageUrl, { - method: "PUT", - body: fd, + try { + if (contentLength < 5 * 1024 * 1024) { + return await uploadNormal(apiUrlOrManageUrl, options) + } else { + if (onProgress) onProgress(0) + return await uploadMPU(apiUrlOrManageUrl, minChunkSize, options, (doneBytes, allBytes) => { + if (onProgress) onProgress((100 * doneBytes) / allBytes) }) - if (resp.ok) { - onLoadingStateChange(false) - return JSON.parse(await resp.text()) as PasteResponse - } else { - throw new ErrorWithTitle("Error From Server", await makeErrorMsg(resp)) + } + } catch (e) { + if (e instanceof UploadError) { + throw new ErrorWithTitle("Error on Upload", e.message) + } + throw e + } finally { + if (onProgress) onProgress(undefined) } } diff --git a/frontend/utils/utils.ts b/frontend/utils/utils.ts index 5012abc8..c3cc4942 100644 --- a/frontend/utils/utils.ts +++ b/frontend/utils/utils.ts @@ -16,11 +16,6 @@ export class ErrorWithTitle extends Error { } } -export async function makeErrorMsg(resp: Response): Promise { - const statusText = resp.statusText === "error" ? "Unknown error" : resp.statusText - return (await resp.text()) || statusText -} - export function formatSize(size: number): string { if (!size) return "0" if (size < 1024) { diff --git a/shared/uploadMPU.ts b/shared/uploadMPU.ts index 2b351089..afdf2b2e 100644 --- a/shared/uploadMPU.ts +++ b/shared/uploadMPU.ts @@ -1,44 +1,102 @@ // we will move this file to a shared directory later import { MPUCreateResponse, PasteResponse } from "./interfaces.js" +import type { EncryptionScheme } from "../frontend/utils/encryption.js" +import { parsePath } from "./parsers.js" export class UploadError extends Error { public statusCode: number + constructor(statusCode: number, msg: string) { super(msg) this.statusCode = statusCode } } +export type UploadOptions = { + content: string | File + isUpdate: boolean + + // we allow it to be undefined for convenience + isPrivate?: boolean + + password?: string + name?: string + + encryptionScheme?: EncryptionScheme + expire?: string + manageUrl?: string +} + +// note that apiUrl should be manageUrl when isUpload +export async function uploadNormal( + apiUrl: string, + { content, isUpdate, isPrivate, password, name, encryptionScheme, expire, manageUrl }: UploadOptions, +): Promise { + const fd = new FormData() + + // typescript cannot handle overload on union types + if (typeof content === "string") { + fd.set("c", content) + } else { + fd.set("c", content) + } + + if (isUpdate && manageUrl === undefined) { + throw TypeError("uploadMPU: no manageUrl specified in update") + } + + if (expire !== undefined) fd.set("e", expire) + if (password !== undefined) fd.set("s", password) + if (!isUpdate && name !== undefined) fd.set("n", name) + if (encryptionScheme !== undefined) fd.set("encryption-scheme", encryptionScheme) + if (isPrivate) fd.set("p", "1") + + const resp = isUpdate + ? await fetch(manageUrl!, { + method: "PUT", + body: fd, + }) + : await fetch(apiUrl, { + method: "POST", + body: fd, + }) + + if (!resp.ok) { + throw new UploadError(resp.status, await resp.text()) + } + + return await resp.json() +} + export async function uploadMPU( - baseUrl: string, - content: ArrayBuffer, - isUpdate: boolean, + apiUrl: string, chunkSize: number, - options: { - name?: string - isPrivate?: boolean - password?: string - expire?: string - encryptionScheme?: string - progressCallback?: (doneBytes: number, allBytes: number) => void - }, + { content, isUpdate, isPrivate, password, name, encryptionScheme, expire, manageUrl }: UploadOptions, + progressCallback?: (doneBytes: number, allBytes: number) => void, ) { - await fetch("https://shz.al") - const createReqUrl = isUpdate ? new URL(`${baseUrl}/mpu/create-update`) : new URL(`${baseUrl}/mpu/create`) + if (typeof content === "string") { + throw TypeError("Must use File when uploading as MPU") + } + + const createReqUrl = isUpdate ? new URL(`${apiUrl}/mpu/create-update`) : new URL(`${apiUrl}/mpu/create`) if (!isUpdate) { - if (options.name !== undefined) { - createReqUrl.searchParams.set("n", options.name) + if (name !== undefined) { + createReqUrl.searchParams.set("n", name) } - if (options.isPrivate) { + if (isPrivate) { createReqUrl.searchParams.set("p", "1") } } else { - if (options.name === undefined || options.password === undefined) { - throw TypeError("uploadMPU: name or password not specified for update") + if (manageUrl === undefined) { + throw TypeError("uploadMPU: no manageUrl specified in update") + } + const { name: nameFromUrl, password: passwordFromUrl } = parsePath(new URL(manageUrl).pathname) + if (passwordFromUrl === undefined) { + throw TypeError("uploadMPU: password not specified in manageUrl") } - createReqUrl.searchParams.set("name", options.name) - createReqUrl.searchParams.set("password", options.password) + createReqUrl.searchParams.set("name", nameFromUrl) + createReqUrl.searchParams.set("password", passwordFromUrl) } const createReqResp = await fetch(createReqUrl, { method: "POST" }) @@ -47,13 +105,13 @@ export async function uploadMPU( } const createResp: MPUCreateResponse = await createReqResp.json() - const numParts = Math.ceil(content.byteLength / chunkSize) + const numParts = Math.ceil(content.size / chunkSize) // TODO: parallelize const uploadedParts: R2UploadedPart[] = [] let uploadedBytes = 0 for (let i = 0; i < numParts; i++) { - const resumeUrl = new URL(`${baseUrl}/mpu/resume`) + const resumeUrl = new URL(`${apiUrl}/mpu/resume`) resumeUrl.searchParams.set("key", createResp.key) resumeUrl.searchParams.set("uploadId", createResp.uploadId) resumeUrl.searchParams.set("partNumber", (i + 1).toString()) // because partNumber need to nonzero @@ -64,28 +122,31 @@ export async function uploadMPU( } const resumeResp: R2UploadedPart = await resumeReqResp.json() uploadedParts.push(resumeResp) - uploadedBytes += chunk.byteLength - if (options.progressCallback) { - options.progressCallback(uploadedBytes, content.byteLength) + uploadedBytes += chunk.size + if (progressCallback) { + progressCallback(uploadedBytes, content.size) } } const completeFormData = new FormData() - const completeUrl = new URL(`${baseUrl}/mpu/complete`) + const completeUrl = new URL(`${apiUrl}/mpu/complete`) completeUrl.searchParams.set("name", createResp.name) completeUrl.searchParams.set("key", createResp.key) completeUrl.searchParams.set("uploadId", createResp.uploadId) completeFormData.set("c", JSON.stringify(uploadedParts)) - if (options.expire !== undefined) { - completeFormData.set("e", options.expire) + if (expire !== undefined) { + completeFormData.set("e", expire) } - if (options.password !== undefined) { - completeFormData.set("s", options.password) + if (password !== undefined) { + completeFormData.set("s", password) } - if (options.encryptionScheme !== undefined) { - completeFormData.set("encryption-scheme", options.encryptionScheme) + if (encryptionScheme !== undefined) { + completeFormData.set("encryption-scheme", encryptionScheme) } - const completeReqResp = await fetch(completeUrl, { method: isUpdate ? "PUT" : "POST", body: completeFormData }) + const completeReqResp = await fetch(completeUrl, { + method: isUpdate ? "PUT" : "POST", + body: completeFormData, + }) if (!completeReqResp.ok) { throw new UploadError(completeReqResp.status, await completeReqResp.text()) } diff --git a/worker/common.ts b/worker/common.ts index a00d9076..7665b356 100644 --- a/worker/common.ts +++ b/worker/common.ts @@ -23,6 +23,12 @@ export class WorkerError extends Error { } } +export function workerAssert(condition: boolean, msg: string): asserts condition { + if (!condition) { + throw new WorkerError(500, `Assertion failed: ${msg}`) + } +} + export function dateToUnix(date: Date): number { return Math.floor(date.getTime() / 1000) } diff --git a/worker/handlers/handleMPU.ts b/worker/handlers/handleMPU.ts index c9213b85..c379623c 100644 --- a/worker/handlers/handleMPU.ts +++ b/worker/handlers/handleMPU.ts @@ -2,6 +2,7 @@ import { MPUCreateResponse } from "../../shared/interfaces.js" import { NAME_REGEX, PASTE_NAME_LEN, PRIVATE_PASTE_NAME_LEN } from "../../shared/constants.js" import { genRandStr, WorkerError } from "../common.js" import { getPasteMetadata, pasteNameAvailable } from "../storage/storage.js" +import { parseSize } from "../../shared/parsers.js" // POST /mpu/create?n=&p= // returns JSON { name: string, key: string, uploadId: string } @@ -94,10 +95,14 @@ export async function handleMPUComplete(request: Request, env: Env, completeBody } const multipartUpload = env.R2.resumeMultipartUpload(key, uploadId) + if (name !== multipartUpload.key) { + throw new WorkerError(400, `name ‘${name}’ is not consistent with the originally specified name`) + } const object = await multipartUpload.complete(completeBody) - if (name !== object.key) { - throw new WorkerError(400, `name ‘${name}’ is not consistent with the originally specified name`) + if (object.size > parseSize(env.R2_MAX_ALLOWED)!) { + await env.R2.delete(object.key) + throw new WorkerError(413, `payload too large (max ${parseSize(env.R2_MAX_ALLOWED)!} bytes allowed)`) } return object.httpEtag } diff --git a/worker/handlers/handleWrite.ts b/worker/handlers/handleWrite.ts index 0ced862f..3c2117ff 100644 --- a/worker/handlers/handleWrite.ts +++ b/worker/handlers/handleWrite.ts @@ -147,6 +147,9 @@ export async function handlePostOrPut( } // check if name is legal + if (nameFromForm !== undefined && isPut) { + throw new WorkerError(400, `Cannot set name for a PUT request`) + } if (nameFromForm !== undefined && !NAME_REGEX.test(nameFromForm)) { throw new WorkerError(400, `Name ${nameFromForm} not satisfying regexp ${NAME_REGEX}`) } @@ -204,6 +207,7 @@ export async function handlePostOrPut( contentLength, filename, encryptionScheme, + isMPUComplete, }) return makeResponse( { diff --git a/worker/storage/storage.ts b/worker/storage/storage.ts index f29b8432..6b31fcc7 100644 --- a/worker/storage/storage.ts +++ b/worker/storage/storage.ts @@ -1,4 +1,4 @@ -import { dateToUnix, WorkerError } from "../common.js" +import { dateToUnix, workerAssert, WorkerError } from "../common.js" import { parseSize } from "../../shared/parsers.js" import { PasteLocation } from "../../shared/interfaces.js" @@ -90,9 +90,8 @@ export async function getPaste(env: Env, short: string, ctx: ExecutionContext): if (item.value === null) { return null - } else if (item.metadata === null) { - throw new WorkerError(500, `paste of name '${short}' has no metadata`) } else { + workerAssert(item.metadata != null, `paste of name '${short}' has no metadata`) const metadata = migratePasteMetadata(item.metadata) const expired = metadata.willExpireAtUnix < new Date().getTime() / 1000 @@ -141,19 +140,22 @@ export async function getPasteMetadata(env: Env, short: string): Promise parseSize(env.R2_THRESHOLD)! + ? "R2" + : "KV" const metadata: PasteMetadata = { schemaVersion: 1, - location: originalMetadata.location, + location: newLocation, filename: options.filename || originalMetadata.filename, passwd: options.passwd, @@ -188,26 +198,20 @@ export async function createPaste( env: Env, pasteName: string, content: ArrayBuffer | ReadableStream, - options: { - expirationSeconds: number - now: Date - passwd: string - filename?: string - contentLength: number - encryptionScheme?: string - isMPUComplete: boolean - }, + options: WriteOptions, ) { const expirationUnix = dateToUnix(options.now) + options.expirationSeconds let expirationUnixSpecified = dateToUnix(options.now) + Math.max(options.expirationSeconds, PASTE_EXPIRE_SPECIFIED_MIN) - const location = options.contentLength > parseSize(env.R2_THRESHOLD)! ? "R2" : "KV" + const location = options.isMPUComplete || options.contentLength > parseSize(env.R2_THRESHOLD)! ? "R2" : "KV" if (location === "R2") { expirationUnixSpecified = expirationUnixSpecified + PASTE_EXPIRE_EXTENSION_FOR_R2 - await env.R2.put(pasteName, content) + if (!options.isMPUComplete) { + await env.R2.put(pasteName, content) + } } const metadata: PasteMetadata = { diff --git a/worker/test/basic.spec.ts b/worker/test/basic.spec.ts index 3a7b85e7..272d6380 100644 --- a/worker/test/basic.spec.ts +++ b/worker/test/basic.spec.ts @@ -49,7 +49,7 @@ describe("upload", () => { const resp = await upload(ctx, { c: blob1 }) const revisitSesponse = await workerFetch(ctx, resp.url) expect(revisitSesponse.status).toStrictEqual(200) - expect(await areBlobsEqual(await revisitSesponse.blob(), blob1)).toBeTruthy() + expect(await areBlobsEqual(await revisitSesponse.blob(), blob1)).toStrictEqual(true) }) it("should return 404 for non-existent", async () => { @@ -86,7 +86,7 @@ describe("update", () => { const revisitModifiedResponse = await workerFetch(ctx, resp.url) expect(revisitModifiedResponse.status).toStrictEqual(200) const revisitBlob = await revisitModifiedResponse.blob() - expect(await areBlobsEqual(revisitBlob, blob2)).toBeTruthy() + expect(await areBlobsEqual(revisitBlob, blob2)).toStrictEqual(true) }) }) @@ -142,7 +142,10 @@ test("GET special static pages", async () => { ] for (const [accessPath, expectedTitle] of testPairs) { const resp = await (await workerFetch(ctx, `${BASE_URL}/d/${accessPath}`)).text() - expect(resp.includes(`/ ${expectedTitle}`), `testing access ${accessPath}, returning ${resp}`).toBeTruthy() + expect( + resp.includes(`/ ${expectedTitle}`), + `testing access ${accessPath}, returning ${resp}`, + ).toStrictEqual(true) } // test manage page diff --git a/worker/test/basicAuth.spec.ts b/worker/test/basicAuth.spec.ts index c7cb5607..55c16266 100644 --- a/worker/test/basicAuth.spec.ts +++ b/worker/test/basicAuth.spec.ts @@ -66,7 +66,7 @@ describe("basic auth", () => { const uploadResp1 = await upload(ctx, { c: blob1 }, { headers: authHeader }) const revisitResp = await workerFetch(ctx, uploadResp1.url) expect(revisitResp.status).toStrictEqual(200) - expect(areBlobsEqual(await revisitResp.blob(), blob1)).toBeTruthy() + expect(await areBlobsEqual(await revisitResp.blob(), blob1)).toStrictEqual(true) }) it("should allow update without auth", async () => { @@ -75,7 +75,7 @@ describe("basic auth", () => { const updateResp = await upload(ctx, { c: blob2 }, { method: "PUT", url: uploadResp1.manageUrl }) const revisitUpdatedResp = await workerFetch(ctx, updateResp.url) expect(revisitUpdatedResp.status).toStrictEqual(200) - expect(areBlobsEqual(await revisitUpdatedResp.blob(), blob2)).toBeTruthy() + expect(await areBlobsEqual(await revisitUpdatedResp.blob(), blob2)).toStrictEqual(true) }) it("should delete without auth", async () => { diff --git a/worker/test/controlHeaders.spec.ts b/worker/test/controlHeaders.spec.ts index fee03c54..7552c62c 100644 --- a/worker/test/controlHeaders.spec.ts +++ b/worker/test/controlHeaders.spec.ts @@ -39,7 +39,7 @@ test("cache control", async () => { const uploadResp = await upload(ctx, { c: genRandomBlob(1024) }) const url = uploadResp["url"] const resp = await workerFetch(ctx, url) - expect(resp.headers.has("Last-Modified")).toBeTruthy() + expect(resp.headers.has("Last-Modified")).toStrictEqual(true) expect(new Date(resp.headers.get("Last-Modified")!).getTime()).toStrictEqual(t1.getTime()) if ("CACHE_PASTE_AGE" in env) { @@ -77,7 +77,7 @@ test("content disposition without specifying filename", async () => { expect( (await workerFetch(ctx, url)).headers.get("Access-Control-Expose-Headers")?.includes("Content-Disposition"), - ).toBeTruthy() + ).toStrictEqual(true) expect((await workerFetch(ctx, url)).headers.get("Content-Disposition")).toStrictEqual("inline") expect((await workerFetch(ctx, `${url}?a`)).headers.get("Content-Disposition")).toStrictEqual("attachment") @@ -127,7 +127,7 @@ test("other HTTP methods", async () => { }), ) expect(resp.status).toStrictEqual(405) - expect(resp.headers.has("Allow")).toBeTruthy() + expect(resp.headers.has("Allow")).toStrictEqual(true) }) test("option method", async () => { @@ -144,9 +144,9 @@ test("option method", async () => { }), ) expect(resp.status).toStrictEqual(200) - expect(resp.headers.has("Access-Control-Allow-Origin")).toBeTruthy() - expect(resp.headers.has("Access-Control-Allow-Methods")).toBeTruthy() - expect(resp.headers.has("Access-Control-Max-Age")).toBeTruthy() + expect(resp.headers.has("Access-Control-Allow-Origin")).toStrictEqual(true) + expect(resp.headers.has("Access-Control-Allow-Methods")).toStrictEqual(true) + expect(resp.headers.has("Access-Control-Max-Age")).toStrictEqual(true) const resp1 = await workerFetch( ctx, @@ -158,5 +158,5 @@ test("option method", async () => { }), ) expect(resp1.status).toStrictEqual(200) - expect(resp1.headers.has("Allow")).toBeTruthy() + expect(resp1.headers.has("Allow")).toStrictEqual(true) }) diff --git a/worker/test/head.spec.ts b/worker/test/head.spec.ts index 140ef52b..4dda1562 100644 --- a/worker/test/head.spec.ts +++ b/worker/test/head.spec.ts @@ -19,7 +19,7 @@ test("HEAD", async () => { expect(headResp.status).toStrictEqual(200) expect(headResp.headers.get("Content-Type")).toStrictEqual("text/plain;charset=UTF-8") expect(headResp.headers.get("Content-Length")).toStrictEqual(blob1.size.toString()) - expect(headResp.headers.has("Last-Modified")).toBeTruthy() + expect(headResp.headers.has("Last-Modified")).toStrictEqual(true) expect(headResp.headers.get("Content-Disposition")).toStrictEqual("inline") // test head with filename and big blog @@ -34,7 +34,7 @@ test("HEAD", async () => { expect(headResp1.status).toStrictEqual(200) expect(headResp1.headers.get("Content-Type")).toStrictEqual("image/jpeg") expect(headResp1.headers.get("Content-Length")).toStrictEqual(blob2.size.toString()) - expect(headResp1.headers.has("Last-Modified")).toBeTruthy() + expect(headResp1.headers.has("Last-Modified")).toStrictEqual(true) expect(headResp1.headers.get("Content-Disposition")).toStrictEqual("inline; filename*=UTF-8''a.jpg") }) diff --git a/worker/test/mpu.spec.ts b/worker/test/mpu.spec.ts index 818f8619..8a4d9859 100644 --- a/worker/test/mpu.spec.ts +++ b/worker/test/mpu.spec.ts @@ -19,36 +19,53 @@ afterAll(() => { test("uploadMPU", async () => { const content = genRandomBlob(1024 * 1024 * 20) const callBack = vi.fn() - const uploadResp = await uploadMPU(BASE_URL, await content.arrayBuffer(), false, 1024 * 1024 * 5, { - progressCallback: callBack, - }) + const uploadResp = await uploadMPU( + BASE_URL, + 1024 * 1024 * 5, + { + isUpdate: false, + content: new File([await content.arrayBuffer()], ""), + }, + callBack, + ) expect(callBack).toBeCalledTimes(4) const getResp = await workerFetch(ctx, uploadResp.url) - expect(areBlobsEqual(await getResp.blob(), content)).toBeTruthy() - - const { name, password } = parsePath(new URL(uploadResp.manageUrl).pathname) + expect(await areBlobsEqual(await getResp.blob(), content)).toStrictEqual(true) const newContent = genRandomBlob(1024 * 1024 * 20) - await uploadMPU(BASE_URL, await content.arrayBuffer(), true, 1024 * 1024 * 5, { name, password }) + await uploadMPU( + BASE_URL, + 1024 * 1024 * 5, + { + content: new File([await newContent.arrayBuffer()], ""), + isUpdate: true, + manageUrl: uploadResp.manageUrl, + }, + callBack, + ) const reGetResp = await workerFetch(ctx, uploadResp.url) - expect(areBlobsEqual(await reGetResp.blob(), newContent)).toBeTruthy() + expect(await areBlobsEqual(await reGetResp.blob(), newContent)).toStrictEqual(true) }) describe("uploadMPU with variant parameters", () => { const content = genRandomBlob(1024 * 1024 * 10) it("handles specified name", async () => { - const uploadResp = await uploadMPU(BASE_URL, await content.arrayBuffer(), false, 1024 * 1024 * 5, { + const uploadResp = await uploadMPU(BASE_URL, 1024 * 1024 * 5, { + isUpdate: false, + content: new File([await content.arrayBuffer()], ""), name: "foobarfoobar", expire: "100", }) expect(uploadResp.expirationSeconds).toStrictEqual(100) - expect(uploadResp.url.includes("/~foobarfoobar")).toBeTruthy() + expect(uploadResp.url.includes("/~foobarfoobar")).toStrictEqual(true) }) it("handles long paste name", async () => { - const uploadResp = await uploadMPU(BASE_URL, await content.arrayBuffer(), false, 1024 * 1024 * 5, { + const uploadResp = await uploadMPU(BASE_URL, 1024 * 1024 * 5, { + isUpdate: false, + content: new File([await content.arrayBuffer()], ""), isPrivate: true, }) const { name } = parsePath(new URL(uploadResp.url).pathname) diff --git a/worker/test/r2.spec.ts b/worker/test/r2.spec.ts index 68d5e918..8d7fef40 100644 --- a/worker/test/r2.spec.ts +++ b/worker/test/r2.spec.ts @@ -19,7 +19,7 @@ test("r2 basic", async () => { // test get const resp = await workerFetch(ctx, url) expect(resp.status).toStrictEqual(200) - expect(areBlobsEqual(await resp.blob(), blob1)).toBeTruthy() + expect(await areBlobsEqual(await resp.blob(), blob1)).toStrictEqual(true) // test put const blob2 = genRandomBlob(parseSize(env.R2_THRESHOLD)! * 2) @@ -29,7 +29,7 @@ test("r2 basic", async () => { // test revisit const revisitResp = await workerFetch(ctx, url) expect(revisitResp.status).toStrictEqual(200) - expect(areBlobsEqual(await revisitResp.blob(), blob2)).toBeTruthy() + expect(await areBlobsEqual(await revisitResp.blob(), blob2)).toStrictEqual(true) // test meta const metaResp = await workerFetch(ctx, addRole(url, "m")) diff --git a/worker/test/roles.spec.ts b/worker/test/roles.spec.ts index a1338a49..1da7c0e2 100644 --- a/worker/test/roles.spec.ts +++ b/worker/test/roles.spec.ts @@ -32,7 +32,7 @@ alert("Script should be removed") 2. second \`\`\`js -console.log("hello world") +(!+[]+[]+![]).length \`\`\` > Quotation @@ -135,10 +135,10 @@ test("highlight with param lang", async () => { const resp = await workerFetch(ctx, `${url}?lang=html`) expect(resp.status).toStrictEqual(200) const body = await resp.text() - expect(body.includes("language-html")).toBeTruthy() - expect(body.includes("print("<hello world>")")).toBeTruthy() + expect(body.includes("language-html")).toStrictEqual(true) + expect(body.includes("print("<hello world>")")).toStrictEqual(true) const resp1 = await workerFetch(ctx, `${url}?lang=`) const body1 = await resp1.text() - expect(body1.includes("language-<html>")).toBeTruthy() + expect(body1.includes("language-<html>")).toStrictEqual(true) }) diff --git a/worker/test/testUtils.ts b/worker/test/testUtils.ts index a6bec011..f3b2f5d6 100644 --- a/worker/test/testUtils.ts +++ b/worker/test/testUtils.ts @@ -107,7 +107,17 @@ export function genRandomBlob(len: number): Blob { } export async function areBlobsEqual(blob1: Blob, blob2: Blob) { - return Buffer.from(await blob1.arrayBuffer()).compare(Buffer.from(await blob2.arrayBuffer())) === 0 + if (blob1.size !== blob2.size) { + return false + } + const array1 = await blob1.bytes() + const array2 = await blob2.bytes() + for (let i = 0; i < blob1.size; i++) { + if (array1[i] != array2[i]) { + return false + } + } + return true } // replace https://example.com/xxx to https://example.com/${role}/xxx diff --git a/worker/test/uploadOptions.spec.ts b/worker/test/uploadOptions.spec.ts index aadc08b6..13b3c6a2 100644 --- a/worker/test/uploadOptions.spec.ts +++ b/worker/test/uploadOptions.spec.ts @@ -33,7 +33,7 @@ test("privacy url with option p", async () => { // check revisit const revisitSesponse = await workerFetch(ctx, url) expect(revisitSesponse.status).toStrictEqual(200) - expect(await areBlobsEqual(await revisitSesponse.blob(), blob1)).toBeTruthy() + expect(await areBlobsEqual(await revisitSesponse.blob(), blob1)).toStrictEqual(true) }) test("expire with option e", async () => { @@ -85,7 +85,7 @@ test("custom path with option n", async () => { // check revisit const revisitResponse = await workerFetch(ctx, uploadResponseJson["url"]) expect(revisitResponse.status).toStrictEqual(200) - expect(await areBlobsEqual(await revisitResponse.blob(), blob1)).toBeTruthy() + expect(await areBlobsEqual(await revisitResponse.blob(), blob1)).toStrictEqual(true) }) test("custom passwd with option s", async () => { @@ -131,7 +131,7 @@ test("encryption with option encryption-scheme", async () => { expect(fetchPaste.headers.get("Content-Type")).toStrictEqual("application/octet-stream") expect(fetchPaste.headers.get("Content-Disposition")).toStrictEqual("inline; filename*=UTF-8''a.pdf.encrypted") expect(fetchPaste.headers.get("X-Encryption-Scheme")).toStrictEqual("AES-GCM") - expect(fetchPaste.headers.get("Access-Control-Expose-Headers")?.includes("X-Encryption-Scheme")).toBeTruthy() + expect(fetchPaste.headers.get("Access-Control-Expose-Headers")?.includes("X-Encryption-Scheme")).toStrictEqual(true) // fetch with filename, now the content-disposition and content-type should be changed const fetchPasteWithFilename = await workerFetch(ctx, url + "/b.pdf")