├── .git-blame-ignore-revs ├── .prettierignore ├── .gitattributes ├── frontend ├── public │ └── favicon.ico ├── test │ ├── tsconfig.json │ ├── testUtils.ts │ ├── display.spec.tsx │ ├── crypto.spec.ts │ └── index.spec.tsx ├── vite-env.d.ts ├── tsconfig.json ├── hero-theme.ts ├── index.html ├── pages │ ├── render │ │ ├── index.tsx │ │ └── display.tsx │ ├── PasteBin.tsx │ └── DisplayPaste.tsx ├── display.html ├── utils │ ├── HighlightLoader.ts │ ├── overrides.ts │ ├── utils.ts │ ├── encryption.ts │ └── uploader.ts ├── components │ ├── CopyWidget.tsx │ ├── ErrorModal.tsx │ ├── UploadedPanel.tsx │ ├── DarkModeToggle.tsx │ ├── PasteInputPanel.tsx │ ├── icons.tsx │ ├── PasteSettingPanel.tsx │ └── CodeEditor.tsx ├── style.css ├── vite.config.js └── styles │ ├── highlight-theme-dark.css │ └── highlight-theme-light.css ├── worker ├── tsconfig.json ├── test │ ├── env.d.ts │ ├── tsconfig.json │ ├── accessCounter.spec.ts │ ├── head.spec.ts │ ├── r2.spec.ts │ ├── mpu.spec.ts │ ├── basicAuth.spec.ts │ ├── testUtils.ts │ ├── roles.spec.ts │ ├── basic.spec.ts │ ├── controlHeaders.spec.ts │ └── uploadOptions.spec.ts ├── pages │ ├── docs.ts │ ├── auth.ts │ └── markdown.ts ├── handlers │ ├── handleDelete.ts │ ├── handleCors.ts │ ├── handleMPU.ts │ ├── handleRead.ts │ └── handleWrite.ts ├── common.ts ├── index.ts └── storage │ └── storage.ts ├── .gitignore ├── tsconfig.json ├── vitest.config.js ├── shared ├── constants.ts ├── interfaces.ts ├── test │ └── parser.spec.ts ├── parsers.ts └── uploadPaste.ts ├── .github └── workflows │ ├── deploy.yml │ └── pr.yml ├── eslint.config.js ├── vitest.workspace.ts ├── LICENSE ├── scripts ├── bcrypt.js ├── pb.fish ├── _pb └── README.md ├── wrangler.toml ├── package.json └── README.md /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 13267d9078228245dbe36e0a0fd4c497de16b074 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | worker-configuration.d.ts 2 | frontend/github.css 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | yarn.lock -diff 2 | worker-configuration.d.ts -diff 3 | 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SharzyL/pastebin-worker/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["../worker-configuration.d.ts"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | dist 3 | **/*.rs.bk 4 | Cargo.lock 5 | bin/ 6 | pkg/ 7 | wasm-pack.log 8 | node_modules/ 9 | .cargo-ok 10 | 11 | .idea 12 | .wrangler 13 | coverage 14 | .dev.vars* 15 | -------------------------------------------------------------------------------- /frontend/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vite/client", "../vite-env.d.ts", "@testing-library/jest-dom"] 5 | }, 6 | "include": ["**/*.tsx", "**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021"], 5 | "module": "node16", 6 | "moduleResolution": "node16", 7 | "isolatedModules": true, 8 | "strict": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | declare const DEPLOY_URL: string 2 | declare const API_URL: string 3 | declare const REPO: string 4 | declare const MAX_EXPIRATION: string 5 | declare const DEFAULT_EXPIRATION: string 6 | declare const INDEX_PAGE_TITLE: string 7 | -------------------------------------------------------------------------------- /worker/test/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module "cloudflare:test" { 2 | // ProvidedEnv controls the type of `import("cloudflare:test").env` 3 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 4 | interface ProvidedEnv extends Env {} 5 | } 6 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["dom", "es2021"], 5 | "types": ["vite/client", "./vite-env.d.ts"], 6 | "target": "es2021", 7 | "jsx": "react-jsx" 8 | }, 9 | "include": ["**/*.tsx", "**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /worker/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@cloudflare/vitest-pool-workers", "../../worker-configuration.d.ts"], 5 | "moduleResolution": "bundler", 6 | "strict": true 7 | }, 8 | "include": ["**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config" 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: "istanbul", // v8 is not supported due for cf workers 7 | reporter: ["text", "json-summary", "html", "json"], 8 | }, 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /frontend/hero-theme.ts: -------------------------------------------------------------------------------- 1 | import { heroui } from "@heroui/react" 2 | 3 | export default heroui({ 4 | defaultTheme: "light", 5 | themes: { 6 | light: {}, 7 | dark: { 8 | colors: { 9 | background: "#111111", 10 | primary: { 11 | DEFAULT: "#BEF264", 12 | foreground: "#111111", 13 | }, 14 | focus: "#BEF264", 15 | }, 16 | }, 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %INDEX_PAGE_TITLE% 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/pages/render/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client" 2 | import React from "react" 3 | import { HeroUIProvider } from "@heroui/react" 4 | import { PasteBin } from "../PasteBin.js" 5 | 6 | const root = ReactDOM.createRoot(document.getElementById("root")!) 7 | 8 | root.render( 9 | 10 | 11 | 12 | 13 | , 14 | ) 15 | -------------------------------------------------------------------------------- /shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const CHAR_GEN = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678" 2 | export const NAME_REGEX = /^[a-zA-Z0-9+_\-[\]*$@,;]{3,}$/ 3 | export const PASTE_NAME_LEN = 4 4 | export const PRIVATE_PASTE_NAME_LEN = 24 5 | export const DEFAULT_PASSWD_LEN = 24 6 | export const MAX_PASSWD_LEN = 128 7 | export const MIN_PASSWD_LEN = 8 8 | export const MAX_URL_REDIRECT_LEN = 2000 9 | export const PASSWD_SEP = ":" 10 | -------------------------------------------------------------------------------- /frontend/pages/render/display.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client" 2 | import React from "react" 3 | import { HeroUIProvider } from "@heroui/react" 4 | import { DisplayPaste } from "../DisplayPaste.js" 5 | 6 | const root = ReactDOM.createRoot(document.getElementById("root")!) 7 | 8 | root.render( 9 | 10 | 11 | 12 | 13 | , 14 | ) 15 | -------------------------------------------------------------------------------- /frontend/display.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %INDEX_PAGE_TITLE% / {{PASTE_NAME}} 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /shared/interfaces.ts: -------------------------------------------------------------------------------- 1 | // This file contains things shared with frontend 2 | 3 | export type PasteLocation = "KV" | "R2" 4 | 5 | export type PasteResponse = { 6 | url: string 7 | manageUrl: string 8 | expirationSeconds: number 9 | expireAt: string 10 | } 11 | 12 | export type MetaResponse = { 13 | lastModifiedAt: string 14 | createdAt: string 15 | expireAt: string 16 | sizeBytes: number 17 | location: PasteLocation 18 | filename?: string 19 | highlightLanguage?: string 20 | encryptionScheme?: string 21 | } 22 | 23 | export type MPUCreateResponse = { 24 | name: string 25 | key: string 26 | uploadId: string 27 | } 28 | -------------------------------------------------------------------------------- /worker/pages/docs.ts: -------------------------------------------------------------------------------- 1 | import { makeMarkdown } from "./markdown.js" 2 | 3 | import tosMd from "../../doc/tos.md" 4 | import apiMd from "../../doc/api.md" 5 | 6 | export function getDocPage(path: string, env: Env): string | null { 7 | if (path === "/tos" || path === "/tos.html") { 8 | const tosMdRenderred = tosMd 9 | .replaceAll("{{TOS_MAINTAINER}}", env.TOS_MAINTAINER) 10 | .replaceAll("{{TOS_MAIL}}", env.TOS_MAIL) 11 | .replaceAll("{{BASE_URL}}", env.DEPLOY_URL) 12 | 13 | return makeMarkdown(tosMdRenderred) 14 | } else if (path === "/api" || path === "/api.html") { 15 | return makeMarkdown(apiMd) 16 | } else { 17 | return null 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /worker/handlers/handleDelete.ts: -------------------------------------------------------------------------------- 1 | import { WorkerError } from "../common.js" 2 | import { deletePaste, getPasteMetadata } from "../storage/storage.js" 3 | import { parsePath } from "../../shared/parsers.js" 4 | 5 | export async function handleDelete(request: Request, env: Env, _: ExecutionContext) { 6 | const url = new URL(request.url) 7 | const { name, password } = parsePath(url.pathname) 8 | const metadata = await getPasteMetadata(env, name) 9 | if (metadata === null) { 10 | throw new WorkerError(404, `paste of name '${name}' not found`) 11 | } else { 12 | if (password !== metadata.passwd) { 13 | throw new WorkerError(403, `incorrect password for paste '${name}`) 14 | } else { 15 | await deletePaste(env, name, metadata) 16 | return new Response("the paste will be deleted in seconds") 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/utils/HighlightLoader.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import type { HLJSApi } from "highlight.js" 3 | import { escapeHtml } from "../../worker/common.js" 4 | 5 | export function useHLJS() { 6 | const [prism, setPrism] = useState(undefined) 7 | 8 | useEffect(() => { 9 | import("highlight.js") 10 | .then((p) => { 11 | setPrism(p.default) 12 | }) 13 | .catch(console.error) 14 | }, []) 15 | 16 | return prism 17 | } 18 | 19 | export function highlightHTML(hljs: HLJSApi | undefined, lang: string | undefined, content: string) { 20 | if (hljs && lang && hljs.listLanguages().includes(lang) && lang !== "plaintext") { 21 | const highlighted = hljs.highlight(content, { language: lang }) 22 | return highlighted.value 23 | } else { 24 | return escapeHtml(content) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Test and Deploy 2 | on: 3 | push: 4 | branches: 5 | - goshujin 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: "Install Node" 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: "22" 17 | cache: "yarn" 18 | 19 | - name: "Setup" 20 | run: yarn install 21 | 22 | - name: "Build Frontend" 23 | run: yarn build:frontend 24 | 25 | - name: "Test" 26 | run: | 27 | yarn fmt 28 | yarn lint 29 | yarn test 30 | 31 | - name: "Deploy" 32 | env: 33 | CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 34 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 35 | run: | 36 | yarn deploy 37 | -------------------------------------------------------------------------------- /frontend/test/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest" 2 | 3 | export function stubBrowerFunctions() { 4 | vi.spyOn(window, "matchMedia").mockImplementation((_query: string): MediaQueryList => { 5 | return { 6 | matches: false, 7 | addListener(_callback: ((this: MediaQueryList, ev: MediaQueryListEvent) => unknown) | null) {}, 8 | addEventListener(_name: string, _listener: EventListenerOrEventListenerObject) {}, 9 | removeEventListener(_name: string, _listener: EventListenerOrEventListenerObject) {}, 10 | } as MediaQueryList 11 | }) 12 | 13 | class ResizeObserver { 14 | constructor() {} 15 | observe() {} 16 | unobserve() {} 17 | disconnect() {} 18 | } 19 | vi.stubGlobal("ResizeObserver", ResizeObserver) 20 | } 21 | 22 | export function unStubBrowerFunctions() { 23 | vi.resetAllMocks() 24 | vi.unstubAllGlobals() 25 | } 26 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from "@eslint/js" 4 | import tseslint from "typescript-eslint" 5 | import { globalIgnores } from "eslint/config" 6 | 7 | export default tseslint.config( 8 | eslint.configs.recommended, 9 | tseslint.configs.recommendedTypeChecked, 10 | { 11 | languageOptions: { 12 | parserOptions: { 13 | projectService: true, 14 | tsconfigRootDir: import.meta.dirname, 15 | }, 16 | }, 17 | }, 18 | globalIgnores(["dist/**", ".wrangler/**", "coverage/**", "scripts/**", "worker-configuration.d.ts"]), 19 | { 20 | rules: { 21 | "no-unused-vars": "off", 22 | "@typescript-eslint/no-unused-vars": [ 23 | "error", 24 | { 25 | argsIgnorePattern: "^_", 26 | varsIgnorePattern: "^_", 27 | }, 28 | ], 29 | }, 30 | }, 31 | { 32 | files: ["**/*.config.js"], 33 | extends: [tseslint.configs.disableTypeChecked], 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /worker/handlers/handleCors.ts: -------------------------------------------------------------------------------- 1 | const corsHeaders = { 2 | "Access-Control-Allow-Origin": "*", 3 | "Access-Control-Allow-Methods": "GET,HEAD,PUT,POST,OPTIONS", 4 | "Access-Control-Max-Age": "86400", 5 | } 6 | 7 | export function handleOptions(request: Request) { 8 | const headers = request.headers 9 | if (headers.get("Origin") !== null && headers.get("Access-Control-Request-Method") !== null) { 10 | const respHeaders: { [name: string]: string } = corsHeaders 11 | respHeaders["Access-Control-Allow-Headers"] = "*" 12 | 13 | return new Response(null, { 14 | headers: respHeaders, 15 | }) 16 | } else { 17 | return new Response(null, { 18 | headers: { 19 | Allow: "GET, HEAD, POST, PUT, OPTIONS, DELETE", 20 | }, 21 | }) 22 | } 23 | } 24 | 25 | export function corsWrapResponse(response: Response) { 26 | if (response.headers !== undefined) response.headers.set("Access-Control-Allow-Origin", "*") 27 | return response 28 | } 29 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkspace } from "vitest/config" 2 | import { defineWorkersProject } from "@cloudflare/vitest-pool-workers/config" 3 | 4 | export default defineWorkspace([ 5 | defineWorkersProject({ 6 | test: { 7 | name: "Workers", 8 | include: ["worker/test/**/*.spec.ts"], 9 | coverage: { 10 | provider: "istanbul", // v8 is not supported due for cf workers 11 | reporter: ["text", "json-summary", "html", "json"], 12 | }, 13 | poolOptions: { 14 | workers: { 15 | wrangler: { 16 | configPath: "./wrangler.toml", 17 | }, 18 | }, 19 | }, 20 | }, 21 | }), 22 | { 23 | extends: "frontend/vite.config.js", 24 | test: { 25 | include: ["frontend/test/**/*.spec.{ts,tsx}"], 26 | name: "Frontend", 27 | environment: "jsdom", 28 | coverage: { 29 | provider: "istanbul", 30 | reporter: ["text", "json-summary", "html", "json"], 31 | }, 32 | }, 33 | }, 34 | ]) 35 | -------------------------------------------------------------------------------- /frontend/utils/overrides.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CardSlots, 3 | InputSlots, 4 | RadioSlots, 5 | SelectSlots, 6 | SlotsToClasses, 7 | ToggleSlots, 8 | AutocompleteSlots, 9 | } from "@heroui/react" 10 | 11 | export const tst = "color-tst" 12 | export const tstGrandChild = "color-tst-grandchild" 13 | 14 | export const inputOverrides: SlotsToClasses = { 15 | inputWrapper: `!${tst}`, 16 | input: tst, 17 | } 18 | 19 | export const selectOverrides: SlotsToClasses = { 20 | trigger: tst, 21 | mainWrapper: tst, 22 | } 23 | 24 | export const autoCompleteOverrides: SlotsToClasses = { 25 | // TODO: the inner text is still not handled 26 | base: `!${tstGrandChild}`, 27 | } 28 | 29 | export const radioOverrides: SlotsToClasses = { 30 | control: tst, 31 | label: tst, 32 | labelWrapper: tst, 33 | wrapper: tst, 34 | } 35 | 36 | export const switchOverrides: SlotsToClasses = { 37 | wrapper: tst, 38 | } 39 | 40 | export const cardOverrides: SlotsToClasses = { base: tst } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Sharzy L 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /worker/test/accessCounter.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, beforeEach, vi, afterEach } from "vitest" 2 | import { genRandomBlob, upload, workerFetch } from "./testUtils" 3 | import { createExecutionContext, env, waitOnExecutionContext } from "cloudflare:test" 4 | import { PasteMetadata } from "../storage/storage" 5 | 6 | beforeEach(() => { 7 | vi.spyOn(Math, "random").mockReturnValue(0) 8 | }) 9 | 10 | afterEach(() => { 11 | vi.restoreAllMocks() 12 | }) 13 | 14 | it("increase access counter", async () => { 15 | const ctx = createExecutionContext() 16 | const content = genRandomBlob(1024) 17 | const name = "abc" 18 | const url = (await upload(ctx, { c: content, n: name })).url 19 | 20 | async function getCounter() { 21 | const paste = await env.PB.getWithMetadata("~" + name) 22 | return paste?.metadata?.accessCounter 23 | } 24 | 25 | expect(await getCounter()).toStrictEqual(0) 26 | 27 | await workerFetch(ctx, url) 28 | await waitOnExecutionContext(ctx) 29 | 30 | expect(await getCounter()).toStrictEqual(1) 31 | 32 | await workerFetch(ctx, url) 33 | await waitOnExecutionContext(ctx) 34 | 35 | expect(await getCounter()).toStrictEqual(2) 36 | }) 37 | -------------------------------------------------------------------------------- /scripts/bcrypt.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { hashSync } from "bcrypt-ts" 4 | import readline from "readline" 5 | 6 | function main() { 7 | const args = process.argv.slice(2) 8 | let rounds = 8 9 | if (args.length === 2 && args[0] === "-n") { 10 | rounds = parseInt(args[1]) 11 | if (isNaN(rounds)) { 12 | console.error(`cannot parse ${args[1]} as integer`) 13 | process.exit(1) 14 | } else if (rounds < 5) { 15 | console.error(`expected rounds at least 5 for security, get ${rounds}`) 16 | process.exit(1) 17 | } else if (rounds > 15) { 18 | console.error(`expected rounds at most 15 to prevent it being too slow, get ${rounds}`) 19 | } 20 | } 21 | 22 | const rl = readline.createInterface({ 23 | input: process.stdin, 24 | output: null, 25 | terminal: true, 26 | }) 27 | 28 | process.stderr.write("Enter password (max 72 bytes): ") 29 | rl.question("", (password) => { 30 | rl.close() 31 | try { 32 | const hash = hashSync(password, rounds) 33 | process.stdout.write("\n" + hash + "\n") 34 | } catch (err) { 35 | console.error(`Error: ${err.message}`) 36 | process.exit(1) 37 | } 38 | }) 39 | } 40 | 41 | main() 42 | -------------------------------------------------------------------------------- /frontend/components/CopyWidget.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps } from "@heroui/react" 2 | import { useRef, useState } from "react" 3 | import { CopyIcon, CheckIcon } from "./icons.js" 4 | 5 | interface CopyIconProps extends ButtonProps { 6 | getCopyContent: () => string 7 | } 8 | 9 | export function CopyWidget({ className, getCopyContent, ...rest }: CopyIconProps) { 10 | const numOfIssuedCopies = useRef(0) 11 | const [hasIssuedCopies, setHasIssuedCopies] = useState(false) 12 | const onCopy = () => { 13 | const content = getCopyContent() 14 | navigator.clipboard 15 | .writeText(content) 16 | .then(() => { 17 | numOfIssuedCopies.current = numOfIssuedCopies.current + 1 18 | setHasIssuedCopies(numOfIssuedCopies.current > 0) 19 | 20 | setTimeout(() => { 21 | numOfIssuedCopies.current = numOfIssuedCopies.current - 1 22 | setHasIssuedCopies(numOfIssuedCopies.current > 0) 23 | }, 1000) 24 | }) 25 | .catch(console.error) 26 | } 27 | 28 | return ( 29 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /frontend/style.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @plugin "./hero-theme.ts"; 4 | @source '../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'; 5 | @custom-variant dark (&:where(.dark, .dark *)); 6 | 7 | @theme { 8 | --font-sans: 9 | "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", 10 | "Noto Color Emoji"; 11 | --font-mono: 12 | "Cascadia Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 13 | } 14 | 15 | .color-tst { 16 | transition-property: background-color, color, border-color; 17 | transition-duration: var(--default-transition-duration); 18 | } 19 | 20 | @layer utilities { 21 | .\!color-tst, 22 | .\!color-tst-grandchild > div > div { 23 | transition-property: background-color, color, border-color; 24 | transition-duration: var(--default-transition-duration) !important; 25 | } 26 | } 27 | 28 | .line-number-rows { 29 | counter-reset: linenumber; 30 | } 31 | 32 | .line-number-rows > span::before { 33 | content: counter(linenumber); 34 | counter-increment: linenumber; 35 | display: block; 36 | text-align: right; 37 | padding-right: 0.5em; 38 | } 39 | 40 | /* we need some hacks to handle elements outside render root, e.g. popovers */ 41 | /* see https://github.com/heroui-inc/heroui/issues/4571 */ 42 | .dark, 43 | .light { 44 | color: hsl(var(--heroui-foreground)); 45 | background-color: hsl(var(--heroui-background)); 46 | } 47 | 48 | /* https://stackoverflow.com/questions/43492826/why-does-the-browser-not-render-a-line-break-caused-by-a-trailing-newline-charac */ 49 | pre::after { 50 | content: "\A"; 51 | } 52 | -------------------------------------------------------------------------------- /worker/common.ts: -------------------------------------------------------------------------------- 1 | import { CHAR_GEN } from "../shared/constants.js" 2 | 3 | export function decode(arrayBuffer: ArrayBuffer): string { 4 | return new TextDecoder().decode(arrayBuffer) 5 | } 6 | 7 | export function btoa_utf8(value: string): string { 8 | return btoa(String.fromCharCode(...new TextEncoder().encode(value))) 9 | } 10 | 11 | export function atob_utf8(value: string): string { 12 | const value_latin1 = atob(value) 13 | return new TextDecoder("utf-8").decode( 14 | Uint8Array.from({ length: value_latin1.length }, (element, index) => value_latin1.charCodeAt(index)), 15 | ) 16 | } 17 | 18 | export class WorkerError extends Error { 19 | public statusCode: number 20 | constructor(statusCode: number, msg: string) { 21 | super(msg) 22 | this.statusCode = statusCode 23 | } 24 | } 25 | 26 | export function workerAssert(condition: boolean, msg: string): asserts condition { 27 | if (!condition) { 28 | throw new WorkerError(500, `Assertion failed: ${msg}`) 29 | } 30 | } 31 | 32 | export function dateToUnix(date: Date): number { 33 | return Math.floor(date.getTime() / 1000) 34 | } 35 | 36 | export function genRandStr(len: number) { 37 | // TODO: switch to Web Crypto random generator 38 | let str = "" 39 | const numOfRand = CHAR_GEN.length 40 | for (let i = 0; i < len; i++) { 41 | str += CHAR_GEN.charAt(Math.floor(Math.random() * numOfRand)) 42 | } 43 | return str 44 | } 45 | 46 | export function escapeHtml(str: string): string { 47 | const tagsToReplace: Map = new Map([ 48 | ["&", "&"], 49 | ["<", "<"], 50 | [">", ">"], 51 | ['"', """], 52 | ["'", "'"], 53 | ]) 54 | return str.replace(/[&<>"']/g, function (tag): string { 55 | return tagsToReplace.get(tag) || tag 56 | }) 57 | } 58 | 59 | export function isLegalUrl(url: string): boolean { 60 | return URL.canParse(url) 61 | } 62 | -------------------------------------------------------------------------------- /frontend/test/display.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterEach, afterAll, vi } from "vitest" 2 | import { cleanup, render, screen } from "@testing-library/react" 3 | import { DisplayPaste } from "../pages/DisplayPaste.js" 4 | 5 | import "@testing-library/jest-dom/vitest" 6 | import { userEvent } from "@testing-library/user-event" 7 | import { setupServer } from "msw/node" 8 | import { http, HttpResponse } from "msw" 9 | import { encodeKey, encrypt, genKey } from "../utils/encryption.js" 10 | import { stubBrowerFunctions, unStubBrowerFunctions } from "./testUtils.js" 11 | import { APIUrl } from "../utils/utils.js" 12 | 13 | describe("decrypt page", async () => { 14 | const scheme = "AES-GCM" 15 | const key = await genKey(scheme) 16 | const contentString = "abcedf" 17 | const content = new Uint8Array(new TextEncoder().encode(contentString)) 18 | const encrypted = await encrypt(scheme, key, content) 19 | const server = setupServer( 20 | http.get(`${APIUrl}/abcd`, () => { 21 | return HttpResponse.arrayBuffer(encrypted.buffer, { 22 | headers: { "X-PB-Encryption-Scheme": "AES-GCM" }, 23 | }) 24 | }), 25 | ) 26 | 27 | beforeAll(() => { 28 | stubBrowerFunctions() 29 | server.listen() 30 | }) 31 | 32 | afterEach(() => { 33 | server.resetHandlers() 34 | cleanup() 35 | }) 36 | 37 | afterAll(() => { 38 | unStubBrowerFunctions() 39 | server.close() 40 | }) 41 | 42 | it("decrypt correctly", async () => { 43 | vi.stubGlobal("location", new URL(`https://example.com/e/abcd#${await encodeKey(key)}`)) 44 | global.URL.createObjectURL = () => "" 45 | render() 46 | 47 | const main = screen.getByRole("main") 48 | await userEvent.click(main) // meaningless click, just ensure useEffect is done 49 | 50 | const document = screen.getByRole("article") 51 | expect(document.textContent).toStrictEqual(contentString) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /worker/pages/auth.ts: -------------------------------------------------------------------------------- 1 | import { atob_utf8, btoa_utf8, WorkerError } from "../common.js" 2 | import { compareSync } from "bcrypt-ts" 3 | 4 | // Encoding function 5 | export function encodeBasicAuth(username: string, password: string): string { 6 | const credentials = `${username}:${password}` 7 | return `Basic ${btoa_utf8(credentials)}` 8 | } 9 | 10 | // Decoding function 11 | export function decodeBasicAuth(encodedString: string): { 12 | username: string 13 | password: string 14 | } { 15 | const [scheme, encodedCredentials] = encodedString.split(" ") 16 | if (scheme !== "Basic") { 17 | throw new WorkerError(400, "Invalid authentication scheme") 18 | } 19 | const credentials = atob_utf8(encodedCredentials) 20 | const [username, password] = credentials.split(":", 2) 21 | return { username, password } 22 | } 23 | 24 | // return null if auth passes or is not required, 25 | // return auth page if auth is required 26 | // throw WorkerError if auth failed 27 | // TODO: only allow hashed passwd 28 | export function verifyAuth(request: Request, env: Env): Response | null { 29 | // pass auth if 'BASIC_AUTH' is not present 30 | const basic_auth = env.BASIC_AUTH as { [username: string]: string } 31 | const auth_entries = Object.entries(basic_auth) 32 | 33 | const passwdMap: Map = new Map(auth_entries) 34 | 35 | // pass auth if 'BASIC_AUTH' is empty 36 | if (passwdMap.size === 0) return null 37 | 38 | if (request.headers.has("Authorization")) { 39 | const { username, password } = decodeBasicAuth(request.headers.get("Authorization")!) 40 | if (!passwdMap.has(username) || !compareSync(password, passwdMap.get(username)!)) { 41 | throw new WorkerError(401, "incorrect passwd for basic auth") 42 | } else { 43 | return null 44 | } 45 | } else { 46 | return new Response("HTTP basic auth is required", { 47 | status: 401, 48 | headers: { 49 | // Prompts the user for credentials. 50 | "WWW-Authenticate": 'Basic charset="UTF-8"', 51 | }, 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/components/ErrorModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalProps } from "@heroui/react" 2 | import React, { useState } from "react" 3 | import { ErrorWithTitle } from "../utils/utils.js" 4 | 5 | export type ErrorState = { 6 | title: string 7 | content: string 8 | isOpen: boolean 9 | } 10 | 11 | type ErrorModalProps = Omit 12 | 13 | export function useErrorModal() { 14 | const [errorState, setErrorState] = useState({ isOpen: false, content: "", title: "" }) 15 | 16 | function showModal(title: string, content: string) { 17 | setErrorState({ title, content, isOpen: true }) 18 | } 19 | 20 | async function handleFailedResp(defaultTitle: string, resp: Response) { 21 | const statusText = resp.statusText === "error" ? "Unknown error" : resp.statusText 22 | const errText = (await resp.text()) || statusText 23 | showModal(defaultTitle, errText) 24 | } 25 | 26 | function handleError(defaultTitle: string, error: Error) { 27 | console.error(error) 28 | if (error instanceof ErrorWithTitle) { 29 | showModal(error.title, error.message) 30 | } else { 31 | showModal(defaultTitle, error.message) 32 | } 33 | } 34 | 35 | const ErrorModal = ({ ...rest }: ErrorModalProps) => { 36 | const onClose = () => { 37 | setErrorState({ isOpen: false, content: "", title: "" }) 38 | } 39 | return ( 40 | 41 | 42 | {errorState.title} 43 | 44 |

{errorState.content}

45 |
46 | 47 | 50 | 51 |
52 |
53 | ) 54 | } 55 | 56 | return { ErrorModal, showModal, errorState, handleError, handleFailedResp } 57 | } 58 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "pb" 2 | compatibility_date = "2025-04-24" 3 | 4 | workers_dev = false 5 | main = "worker/index.ts" 6 | 7 | [[rules]] 8 | type = "Text" 9 | globs = [ "**/*.html", "**/*.md" ] 10 | fallthrough = true 11 | 12 | [assets] 13 | directory = "dist/frontend" 14 | run_worker_first = true 15 | binding = "ASSETS" 16 | 17 | [triggers] 18 | # clean r2 garbage every day 19 | crons = ["0 0 * * *"] 20 | 21 | #---------------------------------------- 22 | # lines below are what you should modify 23 | #---------------------------------------- 24 | 25 | [[routes]] 26 | # Refer to https://developers.cloudflare.com/workers/wrangler/configuration/#routes 27 | pattern = "shz.al" 28 | custom_domain = true 29 | 30 | [[kv_namespaces]] 31 | binding = "PB" # do not touch this 32 | id = "435f8959b9de485ea48751ba557d90f5" # id of your KV namespace 33 | 34 | [[r2_buckets]] 35 | binding = "R2" # do not touch this 36 | bucket_name = "pb-shz-al" # bucket name of your R2 bucket 37 | 38 | [vars] 39 | # must be consistent with your routes 40 | DEPLOY_URL = "https://shz.al" 41 | 42 | # url to repo, displayed in the index page 43 | REPO = "https://github.com/SharzyL/pastebin-worker" 44 | 45 | # the page title displayed in index page 46 | INDEX_PAGE_TITLE = "Pastebin Worker" 47 | 48 | # the name displayed in TOS 49 | TOS_MAINTAINER = "Sharzy" 50 | 51 | # the email displayed in TOS 52 | TOS_MAIL = "pb@shz.al" 53 | 54 | # Cache-Control max-age for static pages 55 | CACHE_STATIC_PAGE_AGE = 7200 56 | 57 | # Cache-Control max-age for paste pages 58 | CACHE_PASTE_AGE = 600 59 | 60 | # Default expiration 61 | DEFAULT_EXPIRATION = "7d" 62 | 63 | # Max expiration 64 | MAX_EXPIRATION = "30d" 65 | 66 | # A collection of {username: password} pair 67 | # Leave empty to disable auth 68 | BASIC_AUTH = {} 69 | 70 | # Files larger than this threshold will be stored in R2 71 | R2_THRESHOLD = "100K" 72 | 73 | # File larger than this will be denied 74 | R2_MAX_ALLOWED = "100M" 75 | 76 | # The following mimetypes will be converted to text/plain 77 | DISALLOWED_MIME_FOR_PASTE = ["text/html", "audio/x-mpegurl"] 78 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | /* global __dirname */ 2 | 3 | import { defineConfig } from "vite" 4 | import { resolve } from "path" 5 | import react from "@vitejs/plugin-react" 6 | import tailwindcss from "@tailwindcss/vite" 7 | import { readFileSync } from "node:fs" 8 | import * as toml from "toml" 9 | 10 | export default defineConfig(({ mode }) => { 11 | const wranglerConfigPath = "wrangler.toml" 12 | const devAPIUrl = "http://localhost:8787" 13 | const wranglerConfigText = readFileSync(wranglerConfigPath, "utf8") 14 | const wranglerConfigParsed = toml.parse(wranglerConfigText) 15 | 16 | function getVar(name) { 17 | if (wranglerConfigParsed.vars !== undefined && wranglerConfigParsed.vars[name] !== undefined) { 18 | return wranglerConfigParsed.vars[name] 19 | } else { 20 | throw new Error(`Cannot find vars.${name} in ${wranglerConfigPath}`) 21 | } 22 | } 23 | const deployUrl = getVar("DEPLOY_URL") 24 | 25 | const indexTitle = getVar("INDEX_PAGE_TITLE") + (mode === "development" ? " (dev)" : "") 26 | const transformHtmlPlugin = () => ({ 27 | name: "transform-html", 28 | transformIndexHtml: { 29 | order: "pre", 30 | handler(html) { 31 | return html.replace(/%INDEX_PAGE_TITLE%/g, () => indexTitle) 32 | }, 33 | }, 34 | }) 35 | 36 | return { 37 | plugins: [react(), tailwindcss(), transformHtmlPlugin()], 38 | define: { 39 | DEPLOY_URL: mode === "development" ? JSON.stringify(devAPIUrl) : JSON.stringify(deployUrl), 40 | API_URL: mode === "development" ? JSON.stringify(devAPIUrl) : JSON.stringify(deployUrl), 41 | REPO: JSON.stringify(getVar("REPO")), 42 | MAX_EXPIRATION: JSON.stringify(getVar("MAX_EXPIRATION")), 43 | DEFAULT_EXPIRATION: JSON.stringify(getVar("DEFAULT_EXPIRATION")), 44 | INDEX_PAGE_TITLE: JSON.stringify(indexTitle), 45 | }, 46 | server: { 47 | port: 5173, 48 | }, 49 | build: { 50 | rollupOptions: { 51 | input: { 52 | index: resolve(__dirname, "index.html"), 53 | display: resolve(__dirname, "display.html"), 54 | }, 55 | }, 56 | }, 57 | } 58 | }) 59 | -------------------------------------------------------------------------------- /frontend/test/crypto.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest" 2 | import { encrypt, decrypt, genKey, encodeKey, decodeKey } from "../utils/encryption.js" 3 | import { genRandStr } from "../../worker/common.js" 4 | 5 | function randArray(len: number): Uint8Array { 6 | const arr = new Uint8Array(len) 7 | crypto.getRandomValues(arr) 8 | return arr 9 | } 10 | 11 | describe("encrypt with AES-GCM", () => { 12 | it("should decrypt to same message", async () => { 13 | const text = genRandStr(4096) 14 | const textBuffer = new TextEncoder().encode(text) 15 | 16 | const key = await genKey("AES-GCM") 17 | 18 | const ciphertext = await encrypt("AES-GCM", key, textBuffer) 19 | 20 | const decryptedBuffer = await decrypt("AES-GCM", key, ciphertext) 21 | expect(decryptedBuffer).not.toBeNull() 22 | 23 | const decrypted = new TextDecoder().decode(decryptedBuffer!) 24 | 25 | expect(decrypted).toStrictEqual(text) 26 | }) 27 | 28 | it("should report decryption error", async () => { 29 | const text = genRandStr(4096) 30 | const textBuffer = new TextEncoder().encode(text) 31 | 32 | const key = await genKey("AES-GCM") 33 | const ciphertext = await encrypt("AES-GCM", key, textBuffer) 34 | 35 | ciphertext[1024] = (ciphertext[1024] + 1) % 256 36 | 37 | const decryptedBuffer = await decrypt("AES-GCM", key, ciphertext) 38 | expect(decryptedBuffer).toBeNull() 39 | }) 40 | 41 | it("should encode and decode keys correctly", async () => { 42 | const key = await genKey("AES-GCM") 43 | const plaintext = randArray(2048) 44 | const ciphertext = await encrypt("AES-GCM", key, plaintext) 45 | 46 | for (let i = 0; i < 10; i++) { 47 | const encoded = await encodeKey(key) 48 | const decodedKey = await decodeKey("AES-GCM", encoded) 49 | 50 | const decryptedBuffer = await decrypt("AES-GCM", decodedKey, ciphertext) 51 | expect(decryptedBuffer).not.toBeNull() 52 | expect(decryptedBuffer!.length).toStrictEqual(plaintext.length) 53 | for (let i = 0; i < plaintext.length; i++) { 54 | expect(plaintext[i], `${i}-th bit of decrypted`).toStrictEqual(decryptedBuffer![i]) 55 | } 56 | } 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR Tests 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | coverage-goshujin: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | ref: goshujin 13 | 14 | - name: "Install Node" 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: "22" 18 | cache: "yarn" 19 | 20 | - name: "Run Test with Coverage" 21 | run: | 22 | yarn install 23 | yarn build:frontend 24 | yarn coverage 25 | 26 | - name: "Upload Coverage" 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: coverage-goshujin 30 | path: coverage 31 | 32 | test: 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | with: 38 | ref: ${{ github.ref }} 39 | 40 | - name: "Install Node" 41 | uses: actions/setup-node@v4 42 | with: 43 | node-version: "22" 44 | cache: "yarn" 45 | 46 | - name: "Install Deps" 47 | run: yarn install 48 | 49 | - name: "Build Frontend" 50 | run: yarn build:frontend 51 | 52 | - name: "Lint" 53 | run: | 54 | yarn prettier -c . 55 | yarn eslint . 56 | 57 | - name: "Run Test with Coverage" 58 | run: yarn coverage 59 | 60 | - name: "Upload Coverage" 61 | uses: actions/upload-artifact@v4 62 | with: 63 | name: coverage-head 64 | path: coverage 65 | 66 | report-coverage: 67 | needs: test 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | 72 | - name: "Download HEAD coverage artifacts" 73 | uses: actions/download-artifact@v4 74 | with: 75 | name: coverage-head 76 | path: coverage 77 | 78 | - name: "Download goshujin coverage artifacts" 79 | uses: actions/download-artifact@v4 80 | with: 81 | name: coverage-goshujin 82 | path: coverage-goshujin 83 | 84 | - name: "Report Coverage" 85 | uses: davelosert/vitest-coverage-report-action@v2 86 | with: 87 | json-summary-compare-path: coverage-goshujin/coverage-summary.json 88 | -------------------------------------------------------------------------------- /worker/test/head.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, it, describe } from "vitest" 2 | import { addRole, genRandomBlob, upload, workerFetch } from "./testUtils" 3 | import { createExecutionContext } from "cloudflare:test" 4 | 5 | test("HEAD", async () => { 6 | const blob1 = genRandomBlob(1024) 7 | const ctx = createExecutionContext() 8 | 9 | // upload 10 | const responseJson = await upload(ctx, { c: blob1 }) 11 | 12 | // test head 13 | const headResp = await workerFetch( 14 | ctx, 15 | new Request(responseJson.url, { 16 | method: "HEAD", 17 | }), 18 | ) 19 | expect(headResp.status).toStrictEqual(200) 20 | expect(headResp.headers.get("Content-Type")).toStrictEqual("text/plain;charset=UTF-8") 21 | expect(headResp.headers.get("Content-Length")).toStrictEqual(blob1.size.toString()) 22 | expect(headResp.headers.has("Last-Modified")).toStrictEqual(true) 23 | expect(headResp.headers.get("Content-Disposition")).toStrictEqual("inline") 24 | 25 | // test head with filename and big blog 26 | const blob2 = genRandomBlob(1024 * 1024) 27 | const responseJson1 = await upload(ctx, { c: { filename: "abc", content: blob2 } }) 28 | const headResp1 = await workerFetch( 29 | ctx, 30 | new Request(responseJson1.url + "/a.jpg", { 31 | method: "HEAD", 32 | }), 33 | ) 34 | expect(headResp1.status).toStrictEqual(200) 35 | expect(headResp1.headers.get("Content-Type")).toStrictEqual("image/jpeg") 36 | expect(headResp1.headers.get("Content-Length")).toStrictEqual(blob2.size.toString()) 37 | expect(headResp1.headers.has("Last-Modified")).toStrictEqual(true) 38 | expect(headResp1.headers.get("Content-Disposition")).toStrictEqual("inline; filename*=UTF-8''a.jpg") 39 | }) 40 | 41 | describe("HEAD with URL", () => { 42 | const ctx = createExecutionContext() 43 | it("should redirect for HEAD", async () => { 44 | const contentUrl = "https://example.com:1234/abc-def?g=hi&jk=l" 45 | const responseJson = await upload(ctx, { c: contentUrl }) 46 | const headResp = await workerFetch( 47 | ctx, 48 | new Request(addRole(responseJson.url, "u"), { 49 | method: "HEAD", 50 | }), 51 | ) 52 | expect(headResp.status).toStrictEqual(302) 53 | expect(headResp.headers.get("location")).toStrictEqual(contentUrl) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /frontend/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { NAME_REGEX, PASSWD_SEP } from "../../shared/constants.js" 2 | import { parseExpiration, parseExpirationReadable } from "../../shared/parsers.js" 3 | 4 | export const BaseUrl = DEPLOY_URL 5 | export const APIUrl = API_URL 6 | 7 | export const maxExpirationSeconds = parseExpiration(MAX_EXPIRATION)! 8 | export const maxExpirationReadable = parseExpirationReadable(MAX_EXPIRATION)! 9 | 10 | export class ErrorWithTitle extends Error { 11 | public title: string 12 | 13 | constructor(title: string, msg: string) { 14 | super(msg) 15 | this.title = title 16 | } 17 | } 18 | 19 | export function formatSize(size: number): string { 20 | if (!size) return "0" 21 | if (size < 1024) { 22 | return `${size} Bytes` 23 | } else if (size < 1024 * 1024) { 24 | return `${(size / 1024).toFixed(2)} KB` 25 | } else if (size < 1024 * 1024 * 1024) { 26 | return `${(size / 1024 / 1024).toFixed(2)} MB` 27 | } else { 28 | return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB` 29 | } 30 | } 31 | 32 | export function verifyExpiration(expiration: string): [boolean, string] { 33 | const parsed = parseExpiration(expiration) 34 | if (parsed === null) { 35 | return [false, "Invalid expiration"] 36 | } else { 37 | if (parsed > maxExpirationSeconds) { 38 | return [false, `Exceed max expiration (${maxExpirationReadable})`] 39 | } else { 40 | return [true, `Expires in ${parseExpirationReadable(expiration)!}`] 41 | } 42 | } 43 | } 44 | 45 | export function verifyName(name: string): [boolean, string] { 46 | if (name.length < 3) { 47 | return [false, "Should have at least 3 characters"] 48 | } else if (!NAME_REGEX.test(name)) { 49 | return [false, "Should only contain alphanumeric and +_-[]*$@,;"] 50 | } else { 51 | return [true, ""] 52 | } 53 | } 54 | 55 | export function verifyManageUrl(url: string): [boolean, string] { 56 | try { 57 | const url_parsed = new URL(url) 58 | if (url_parsed.origin !== BaseUrl) { 59 | return [false, `URL should starts with ${BaseUrl}`] 60 | } else if (url_parsed.pathname.indexOf(PASSWD_SEP) < 0) { 61 | return [false, `URL should contain a colon`] 62 | } else { 63 | return [true, ""] 64 | } 65 | } catch (e) { 66 | if (e instanceof TypeError) { 67 | return [false, "Invalid URL"] 68 | } else { 69 | throw e 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /scripts/pb.fish: -------------------------------------------------------------------------------- 1 | # fish-shell completions for pb 2 | # See: https://github.com/SharzyL/pastebin-worker/tree/goshujin/scripts 3 | 4 | set -l commands p post u update g get d delete 5 | 6 | complete -c pb -f 7 | 8 | # common_args: 9 | complete -c pb -s d -l dry -d 'dry run' 10 | complete -c pb -s v -l verbose -d 'verbose output' 11 | 12 | # root_args: 13 | complete -c pb -n "not __fish_seen_subcommand_from $commands" -s h -l help -d 'print help' 14 | 15 | # cmdlist: 16 | complete -c pb -n "not __fish_seen_subcommand_from $commands" -a post -d "Post paste" 17 | complete -c pb -n "not __fish_seen_subcommand_from $commands" -a update -d "Update paste" 18 | complete -c pb -n "not __fish_seen_subcommand_from $commands" -a get -d "Get paste" 19 | complete -c pb -n "not __fish_seen_subcommand_from $commands" -a delete -d "Delete paste" 20 | 21 | # case post: 22 | # todo: - Read the paste from stdin 23 | complete -c pb -n "__fish_seen_subcommand_from post p" -F 24 | complete -c pb -n "__fish_seen_subcommand_from post p" -s c -l content -x -d 'Content of paste' 25 | complete -c pb -n "__fish_seen_subcommand_from post p" -s e -l expire -x -d 'Expiration time' 26 | complete -c pb -n "__fish_seen_subcommand_from post p" -s n -l name -x -d Name 27 | complete -c pb -n "__fish_seen_subcommand_from post p" -s s -l password -x -d Password 28 | complete -c pb -n "__fish_seen_subcommand_from post p" -s p -l private -f -d 'Make generated paste name longer for privacy' 29 | complete -c pb -n "__fish_seen_subcommand_from post p" -s x -l clip -f -d 'Clip the url to the clipboard' 30 | 31 | # case update: 32 | complete -c pb -n "__fish_seen_subcommand_from update u" -s c -l content -x -d 'Content of paste' 33 | complete -c pb -n "__fish_seen_subcommand_from update u" -s f -l file -r -d 'Read the paste from file' 34 | complete -c pb -n "__fish_seen_subcommand_from update u" -s e -l expire -x -d 'Expiration time' 35 | complete -c pb -n "__fish_seen_subcommand_from update u" -s s -l password -x -d Password 36 | complete -c pb -n "__fish_seen_subcommand_from update u" -s x -l clip -f -d 'Clip the url to the clipboard' 37 | 38 | # case get: 39 | complete -c pb -n "__fish_seen_subcommand_from get g" -s l -l lang -x -d 'Highlight with language in a web page' 40 | complete -c pb -n "__fish_seen_subcommand_from get g" -s m -l mine -x -d 'Content of the paste' 41 | complete -c pb -n "__fish_seen_subcommand_from get g" -s o -l output -r -d 'Output the paste in file' 42 | complete -c pb -n "__fish_seen_subcommand_from get g" -s u -l url -f -d 'Make a 302 redirection' 43 | -------------------------------------------------------------------------------- /scripts/_pb: -------------------------------------------------------------------------------- 1 | #compdef pb 2 | 3 | local -a root_args=( 4 | '(-d --dry)'{-d,--dry}'[dry run]' 5 | '(-v --verbose)'{-v,--verbose}'[verbose]' 6 | '(-h --help)'{-h,--help}'[print help]' 7 | '1:command:->commands' 8 | '*::arguments:->arguments' 9 | ) 10 | 11 | _arguments -s $root_args && return 0 12 | 13 | case "$state" in 14 | (commands) 15 | cmdlist=( 16 | {p,post}":Post paste" 17 | {u,update}":Update paste" 18 | {d,delete}":Delete paste" 19 | {g,get}":Get paste" 20 | ) 21 | _describe -t pb-commands 'pb.sh command' cmdlist 22 | ;; 23 | (arguments) 24 | local -a common_args=( 25 | '(-d --dry)'{-d,--dry}'[dry run]' 26 | '(-v --verbose)'{-v,--verbose}'[verbose]' 27 | ) 28 | 29 | case ${line[1]} in 30 | (p|post) 31 | local -a args=( 32 | '(-c --content -)'{-c,--content}'[Content of paste]' 33 | '(-c --content -)-[Read the paste from stdin]' 34 | '(-c --content -)*:paste from file:_files' 35 | '(-e --expire)'{-e,--expire}'[Expiration time]:expiration' 36 | '(-n --name)'{-n,--name}'[Name]:Name of paste' 37 | '(-s --passwd)'{-s,--passwd}'[Password]:Password of paste' 38 | '(-p --private)'{-p,--private}'[Make generated paste name longer for privacy]' 39 | '(-x --clip)'{-x,--clip}'[Clip the url to the clipboard]' 40 | ) 41 | _arguments -s $args $common_args 42 | ;; 43 | 44 | (u|update) 45 | local -a args=( 46 | '(-f -c --file --content -)'{-c,--content}'[Read the paste from file]' 47 | '(-f -c --file --content -)'{-f,--file}'[Content of the paste]:paste from file:_files' 48 | '*:paste name' 49 | 50 | '(-e --expire)'{-e,--expire}'[Expiration time]:expiration' 51 | '(-s --passwd)'{-s,--passwd}'[Password]:Password of paste' 52 | '(-x --clip)'{-x,--clip}'[Clip the url to the clipboard]' 53 | ) 54 | _arguments -s $args $common_args 55 | ;; 56 | 57 | (g|get) 58 | local -a args=( 59 | '*:paste name' 60 | 61 | '(-l --lang)'{-l,--lang}'[Highlight with language in a web page]:language' 62 | '(-m --mine)'{-m,--mine}'[Content of the paste]:mimetype' 63 | '(-o --output)'{-o,--output}'[Output the paste in file]:file:_files' 64 | '(-u --url)'{-u,--url}'[Make a 302 redirection]' 65 | ) 66 | _arguments -s $args $common_args 67 | ;; 68 | 69 | (d|delete) 70 | _arguments -s $common_args 71 | ;; 72 | esac 73 | esac 74 | 75 | -------------------------------------------------------------------------------- /worker/index.ts: -------------------------------------------------------------------------------- 1 | import { WorkerError } from "./common.js" 2 | 3 | import { handleOptions, corsWrapResponse } from "./handlers/handleCors.js" 4 | import { handlePostOrPut } from "./handlers/handleWrite.js" 5 | import { handleGet } from "./handlers/handleRead.js" 6 | import { handleDelete } from "./handlers/handleDelete.js" 7 | import { cleanExpiredInR2 } from "./storage/storage.js" 8 | 9 | export default { 10 | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { 11 | return await handleRequest(request, env, ctx) 12 | }, 13 | 14 | // eslint-disable-next-line @typescript-eslint/require-await 15 | async scheduled(controller: ScheduledController, env, ctx) { 16 | ctx.waitUntil(cleanExpiredInR2(env, controller)) 17 | }, 18 | } satisfies ExportedHandler 19 | 20 | async function handleRequest(request: Request, env: Env, ctx: ExecutionContext): Promise { 21 | try { 22 | if (request.method === "OPTIONS") { 23 | return handleOptions(request) 24 | } else { 25 | const response = await handleNormalRequest(request, env, ctx) 26 | if (response.status !== 302 && response.status !== 404 && response.headers !== undefined) { 27 | // because Cloudflare do not allow modifying redirect headers 28 | response.headers.set("Access-Control-Allow-Origin", "*") 29 | } 30 | return response 31 | } 32 | } catch (e) { 33 | if (e instanceof WorkerError) { 34 | return corsWrapResponse( 35 | new Response(`Error ${e.statusCode}: ${e.message}\n`, { 36 | status: e.statusCode, 37 | }), 38 | ) 39 | } else { 40 | const err = e as Error 41 | console.error(err.stack) 42 | return corsWrapResponse(new Response(`Error 500: ${err.message}\n`, { status: 500 })) 43 | } 44 | } 45 | } 46 | 47 | async function handleNormalRequest(request: Request, env: Env, ctx: ExecutionContext): Promise { 48 | // TODO: support HEAD method 49 | if (request.method === "POST") { 50 | return await handlePostOrPut(request, env, ctx, false) 51 | } else if (request.method === "GET") { 52 | return await handleGet(request, env, ctx, false) 53 | } else if (request.method === "HEAD") { 54 | return await handleGet(request, env, ctx, true) 55 | } else if (request.method === "DELETE") { 56 | return await handleDelete(request, env, ctx) 57 | } else if (request.method === "PUT") { 58 | return await handlePostOrPut(request, env, ctx, true) 59 | } else { 60 | return new Response(`method ${request.method} not allowed`, { 61 | status: 405, 62 | headers: { 63 | Allow: "GET, HEAD, PUT, POST, DELETE, OPTION", 64 | }, 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "pb", 4 | "version": "1.0.0", 5 | "description": "Pastebin based on Cloudflare worker", 6 | "repository": "https://github.com/SharzyL/pastebin-worker", 7 | "type": "module", 8 | "scripts": { 9 | "deploy": "wrangler deploy", 10 | "build:frontend": "vite build frontend --outDir ../dist/frontend --emptyOutDir", 11 | "build:frontend:dev": "vite build frontend --mode development --outDir ../dist/frontend --emptyOutDir", 12 | "dev:frontend": "vite serve frontend --mode development", 13 | "preview:frontend": "vite preview --outDir dist/frontend", 14 | "dev": "wrangler dev --var DEPLOY_URL:http://localhost:8787 --port 8787", 15 | "gentypes": "wrangler types --strict-vars false", 16 | "build": "wrangler deploy --dry-run --outdir=dist", 17 | "delete-paste": "wrangler kv key delete --binding PB", 18 | "test": "vitest", 19 | "fmt": "prettier --write .", 20 | "lint": "eslint .", 21 | "coverage": "vitest run --coverage" 22 | }, 23 | "author": "SharzyL ", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@cloudflare/vitest-pool-workers": "^0.8.22", 27 | "@craftamap/esbuild-plugin-html": "^0.9.0", 28 | "@testing-library/dom": "^10.4.0", 29 | "@testing-library/jest-dom": "^6.6.3", 30 | "@testing-library/react": "^16.3.0", 31 | "@testing-library/user-event": "^14.6.1", 32 | "@types/node": "^22.14.1", 33 | "@vitejs/plugin-react": "^4.4.1", 34 | "@vitest/coverage-istanbul": "3.1.1", 35 | "eslint": "^9.25.0", 36 | "jsdom": "^26.1.0", 37 | "msw": "^2.7.5", 38 | "prettier": "^3.5.3", 39 | "toml": "^3.0.0", 40 | "typescript": "^5.8.3", 41 | "typescript-eslint": "^8.30.1", 42 | "vite": "^6.3.3", 43 | "vitest": "3.1.1", 44 | "wrangler": "^4.13.2" 45 | }, 46 | "prettier": { 47 | "singleQuote": false, 48 | "semi": false, 49 | "trailingComma": "all", 50 | "tabWidth": 2, 51 | "printWidth": 120 52 | }, 53 | "dependencies": { 54 | "@heroui/react": "2.8.0-beta.2", 55 | "@mjackson/multipart-parser": "^0.8.2", 56 | "@tailwindcss/vite": "^4.1.4", 57 | "@types/bcrypt": "^5.0.2", 58 | "@types/react": "^19.1.2", 59 | "@types/react-dom": "^19.1.2", 60 | "bcrypt-ts": "^7.0.0", 61 | "chardet": "^2.1.0", 62 | "framer-motion": "^12.7.4", 63 | "highlight.js": "^11.11.1", 64 | "mdast-util-to-string": "^4.0.0", 65 | "mime": "^4.0.7", 66 | "react": "^19.1.0", 67 | "react-dom": "^19.1.0", 68 | "rehype-stringify": "^10.0.1", 69 | "remark-gfm": "^4.0.1", 70 | "remark-parse": "^11.0.0", 71 | "remark-rehype": "^11.1.2", 72 | "tailwindcss": "^4.1.4", 73 | "unified": "^11.0.5" 74 | }, 75 | "resolutions": { 76 | "stringify-entities": "4.0.2" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /worker/test/r2.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, vi, beforeEach, afterEach } from "vitest" 2 | import { addRole, areBlobsEqual, genRandomBlob, upload, workerFetch } from "./testUtils" 3 | import { MetaResponse } from "../../shared/interfaces" 4 | import { parseSize } from "../../shared/parsers" 5 | import { createExecutionContext, createScheduledController, env, waitOnExecutionContext } from "cloudflare:test" 6 | import worker from "../index" 7 | 8 | beforeEach(vi.useFakeTimers) 9 | afterEach(vi.useRealTimers) 10 | 11 | test("r2 basic", async () => { 12 | const blob1 = genRandomBlob(parseSize(env.R2_THRESHOLD)! * 2) 13 | const ctx = createExecutionContext() 14 | 15 | // upload 16 | const uploadResponse = await upload(ctx, { c: blob1 }) 17 | const url = uploadResponse.url 18 | 19 | // test get 20 | const resp = await workerFetch(ctx, url) 21 | expect(resp.status).toStrictEqual(200) 22 | expect(await areBlobsEqual(await resp.blob(), blob1)).toStrictEqual(true) 23 | 24 | // test put 25 | const blob2 = genRandomBlob(parseSize(env.R2_THRESHOLD)! * 2) 26 | const putResponseJson = await upload(ctx, { c: blob2 }, { method: "PUT", url: uploadResponse.manageUrl }) 27 | expect(putResponseJson.url).toStrictEqual(url) 28 | 29 | // test revisit 30 | const revisitResp = await workerFetch(ctx, url) 31 | expect(revisitResp.status).toStrictEqual(200) 32 | expect(await areBlobsEqual(await revisitResp.blob(), blob2)).toStrictEqual(true) 33 | 34 | // test meta 35 | const metaResp = await workerFetch(ctx, addRole(url, "m")) 36 | expect(metaResp.status).toStrictEqual(200) 37 | const meta: MetaResponse = await metaResp.json() 38 | expect(meta.location).toStrictEqual("R2") 39 | }) 40 | 41 | test("r2 schedule", async () => { 42 | const ctx = createExecutionContext() 43 | 44 | // upload 45 | vi.setSystemTime(new Date(2035, 0, 0)) 46 | const blob1 = genRandomBlob(parseSize(env.R2_THRESHOLD)! * 2) 47 | const uploadResponse = await upload(ctx, { c: blob1, e: "7d" }) 48 | const url = uploadResponse.url 49 | 50 | // test get 51 | const getResp = await workerFetch(ctx, url) 52 | expect(getResp.status).toStrictEqual(200) 53 | await getResp.blob() // we must consume body to prevent breaking isolated storage 54 | 55 | // go to past, nothing will be cleaned 56 | await worker.scheduled(createScheduledController({ scheduledTime: new Date(2000, 0, 0) }), env, ctx) 57 | await waitOnExecutionContext(ctx) 58 | 59 | // test get after cleanup 60 | const getResp1 = await workerFetch(ctx, url) 61 | expect(getResp.status).toStrictEqual(200) 62 | await getResp1.blob() 63 | 64 | // jump to 1 year later, now all pastes are expired 65 | await worker.scheduled(createScheduledController({ scheduledTime: new Date(2040, 0, 0) }), env, ctx) 66 | await waitOnExecutionContext(ctx) 67 | 68 | // test get after cleanup 69 | expect((await workerFetch(ctx, url)).status).toStrictEqual(404) 70 | }) 71 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # Scripts of pastebin-worker 2 | 3 | This directory contains a set of scripts that facilitate the usage and development of pastebin-worker. 4 | 5 | ## `pb`: paste things on command line 6 | 7 | This is a wrapper script to make it easier to use our pastebin. 8 | 9 | **Requirements**: `bash`, `jq`, `getopt`, `curl` 10 | 11 | **Installation**: download `pb` to your `PATH` and give it execution permission. For example: 12 | 13 | ```shell 14 | $ wget https://github.com/SharzyL/pastebin-worker/raw/goshujin/scripts/pb 15 | $ install -Dm755 pb ~/.local/bin 16 | ``` 17 | 18 | By default the script will use the instance on `https://shz.al`, you can either modify the script itself, or specify the `PB_DOMAIN` environment variable to use other instances. 19 | 20 | **Zsh completion**: download `_pb` in a folder within your zsh `fpath` 21 | 22 | **fish completion**: download `pb.fish` in a folder within your fish `fish_complete_path` 23 | 24 | **Usage**: 25 | 26 | ```text 27 | $ pb -h 28 | Usage: 29 | pb [-h|--help] 30 | print this help message 31 | 32 | pb [p|post] [OPTIONS] [-f] FILE 33 | upload your text to pastebin, if neither 'FILE' and 'CONTENT' are given, 34 | read the paste from stdin. 35 | 36 | pb [u|update] NAME[:PASSWD] 37 | Update your text to pastebin, if neither 'FILE' and 'CONTENT' are given, 38 | read the paste from stdin. If 'PASSWD' is not given, try to read password 39 | from the history file. 40 | 41 | pb [g|get] [OPTIONS] NAME[.EXT] 42 | fetch the paste with name 'NAME' and extension 'EXT' 43 | 44 | pb [d|delete] [OPTIONS] NAME 45 | delete the paste with name 'NAME' 46 | 47 | Options: 48 | post options: 49 | -c, --content CONTENT the content of the paste 50 | -e, --expire SECONDS the expiration time of the paste (in seconds) 51 | -n, --name NAME the name of the paste 52 | -s, --passwd PASSWD the password 53 | -p, --private make the generated paste name longer for better privacy 54 | -x, --clip clip the url to the clipboard 55 | 56 | update options: 57 | -f, --file FILE read the paste from file 58 | -c, --content CONTENT the content of the paste 59 | -e, --expire SECONDS the expiration time of the paste (in seconds) 60 | -s, --passwd PASSWD the password 61 | -x, --clip clip the url to the clipboard 62 | 63 | get options: 64 | -l, --lang LANG highlight the paste with language 'LANG' in a web page 65 | -m, --mime MIME set the 'Content-Type' header according to mime-type MIME 66 | -o, --output FILE output the paste in file 'FILE' 67 | -u, --url make a 302 URL redirection 68 | 69 | delete options: 70 | none 71 | 72 | general options: 73 | -v, --verbose display the 'underlying' curl command 74 | -d, --dry do a dry run, executing no 'curl' command at all 75 | ``` 76 | -------------------------------------------------------------------------------- /frontend/utils/encryption.ts: -------------------------------------------------------------------------------- 1 | export type EncryptionScheme = "AES-GCM" 2 | 3 | function concat(buffer1: Uint8Array, buffer2: Uint8Array): Uint8Array { 4 | const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength) 5 | tmp.set(new Uint8Array(buffer1), 0) 6 | tmp.set(new Uint8Array(buffer2), buffer1.byteLength) 7 | return tmp 8 | } 9 | 10 | function base64VariantEncode(src: Uint8Array): string { 11 | // we use a variant of base64 that replaces "/" with "_" and removes trailing padding 12 | const uint8Array = new Uint8Array(src) 13 | let binaryString = "" 14 | for (let i = 0; i < uint8Array.length; i++) { 15 | binaryString += String.fromCharCode(uint8Array[i]) 16 | } 17 | return btoa(binaryString).replaceAll("/", "_").replaceAll("=", "") 18 | } 19 | 20 | function base64VariantDecode(src: string): Uint8Array { 21 | const binaryString = atob(src.replaceAll("_", "/").replaceAll(/[^a-zA-Z0-9+/]/g, "")) 22 | const uint8Array = new Uint8Array(binaryString.length) 23 | for (let i = 0; i < binaryString.length; i++) { 24 | uint8Array[i] = binaryString.charCodeAt(i) 25 | } 26 | return uint8Array 27 | } 28 | 29 | export async function genKey(scheme: EncryptionScheme): Promise { 30 | if (scheme === "AES-GCM") { 31 | return await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]) 32 | } 33 | throw new Error(`Unsupported encryption scheme: ${scheme as string}`) 34 | } 35 | 36 | export async function encrypt(scheme: EncryptionScheme, key: CryptoKey, msg: Uint8Array): Promise { 37 | if (scheme === "AES-GCM") { 38 | const iv = crypto.getRandomValues(new Uint8Array(12)) 39 | const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, msg) 40 | return concat(iv, new Uint8Array(ciphertext)) 41 | } 42 | throw new Error(`Unsupported encryption scheme: ${scheme as string}`) 43 | } 44 | 45 | export async function decrypt( 46 | scheme: EncryptionScheme, 47 | key: CryptoKey, 48 | ciphertext: Uint8Array, 49 | ): Promise { 50 | if (scheme === "AES-GCM") { 51 | const iv = ciphertext.slice(0, 12) 52 | const trueCiphertext = ciphertext.slice(12) 53 | try { 54 | return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, key, trueCiphertext)) 55 | } catch { 56 | return null 57 | } 58 | } 59 | throw new Error(`Unsupported encryption scheme: ${scheme as string}`) 60 | } 61 | 62 | export async function encodeKey(key: CryptoKey): Promise { 63 | const raw = new Uint8Array(await crypto.subtle.exportKey("raw", key)) 64 | return base64VariantEncode(raw) 65 | } 66 | 67 | export async function decodeKey(scheme: EncryptionScheme, key: string): Promise { 68 | if (scheme === "AES-GCM") { 69 | return await crypto.subtle.importKey("raw", base64VariantDecode(key), "AES-GCM", true, ["encrypt", "decrypt"]) 70 | } 71 | throw new Error(`Unsupported encryption scheme: ${scheme as string}`) 72 | } 73 | -------------------------------------------------------------------------------- /worker/test/mpu.spec.ts: -------------------------------------------------------------------------------- 1 | import { uploadMPU } from "../../shared/uploadPaste" 2 | import { vi, test, describe, it, expect, afterAll, beforeEach } from "vitest" 3 | import { createExecutionContext } from "cloudflare:test" 4 | import { addRole, areBlobsEqual, BASE_URL, genRandomBlob, workerFetch } from "./testUtils" 5 | import { PRIVATE_PASTE_NAME_LEN } from "../../shared/constants" 6 | import { parsePath } from "../../shared/parsers" 7 | import { MetaResponse } from "../../shared/interfaces" 8 | 9 | const ctx = createExecutionContext() 10 | beforeEach(() => { 11 | vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { 12 | return await workerFetch(ctx, new Request(input, init)) 13 | }) 14 | }) 15 | 16 | afterAll(() => { 17 | vi.unstubAllGlobals() 18 | }) 19 | 20 | test("uploadMPU", async () => { 21 | const content = genRandomBlob(1024 * 1024 * 20) 22 | const callBack = vi.fn() 23 | const uploadResp = await uploadMPU( 24 | BASE_URL, 25 | 1024 * 1024 * 5, 26 | { 27 | isUpdate: false, 28 | content: new File([await content.arrayBuffer()], ""), 29 | }, 30 | callBack, 31 | ) 32 | expect(callBack).toBeCalledTimes(4) 33 | 34 | const getResp = await workerFetch(ctx, uploadResp.url) 35 | expect(await areBlobsEqual(await getResp.blob(), content)).toStrictEqual(true) 36 | 37 | const newContent = genRandomBlob(1024 * 1024 * 20) 38 | await uploadMPU( 39 | BASE_URL, 40 | 1024 * 1024 * 5, 41 | { 42 | content: new File([await newContent.arrayBuffer()], ""), 43 | isUpdate: true, 44 | manageUrl: uploadResp.manageUrl, 45 | }, 46 | callBack, 47 | ) 48 | 49 | const reGetMetaResp: MetaResponse = await (await workerFetch(ctx, addRole(uploadResp.url, "m"))).json() 50 | expect(reGetMetaResp.sizeBytes).toStrictEqual(content.size) 51 | 52 | const reGetResp = await workerFetch(ctx, uploadResp.url) 53 | expect(await areBlobsEqual(await reGetResp.blob(), newContent)).toStrictEqual(true) 54 | expect(reGetResp.headers.has("etag")).toStrictEqual(true) 55 | }) 56 | 57 | describe("uploadMPU with variant parameters", () => { 58 | const content = genRandomBlob(1024 * 1024 * 10) 59 | it("handles specified name", async () => { 60 | const uploadResp = await uploadMPU(BASE_URL, 1024 * 1024 * 5, { 61 | isUpdate: false, 62 | content: new File([await content.arrayBuffer()], ""), 63 | name: "foobarfoobar", 64 | expire: "100", 65 | }) 66 | expect(uploadResp.expirationSeconds).toStrictEqual(100) 67 | expect(uploadResp.url.includes("/~foobarfoobar")).toStrictEqual(true) 68 | }) 69 | 70 | it("handles long paste name", async () => { 71 | const uploadResp = await uploadMPU(BASE_URL, 1024 * 1024 * 5, { 72 | isUpdate: false, 73 | content: new File([await content.arrayBuffer()], ""), 74 | isPrivate: true, 75 | }) 76 | const { name } = parsePath(new URL(uploadResp.url).pathname) 77 | expect(name.length).toStrictEqual(PRIVATE_PASTE_NAME_LEN) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /frontend/styles/highlight-theme-dark.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Theme: GitHub Dark 3 | Description: Dark theme as seen on github.com 4 | Author: github.com 5 | Maintainer: @Hirse 6 | Updated: 2021-05-15 7 | 8 | Outdated base version: https://github.com/primer/github-syntax-dark 9 | Current colors taken from GitHub's CSS 10 | */ 11 | 12 | .dark .hljs { 13 | color: #c9d1d9; 14 | background: #0d1117; 15 | } 16 | 17 | .dark .hljs-doctag, 18 | .dark .hljs-keyword, 19 | .dark .hljs-meta .hljs-keyword, 20 | .dark .hljs-template-tag, 21 | .dark .hljs-template-variable, 22 | .dark .hljs-type, 23 | .dark .hljs-variable.language_ { 24 | /* prettylights-syntax-keyword */ 25 | color: #ff7b72; 26 | } 27 | 28 | .dark .hljs-title, 29 | .dark .hljs-title.class_, 30 | .dark .hljs-title.class_.inherited__, 31 | .dark .hljs-title.function_ { 32 | /* prettylights-syntax-entity */ 33 | color: #d2a8ff; 34 | } 35 | 36 | .dark .hljs-attr, 37 | .dark .hljs-attribute, 38 | .dark .hljs-literal, 39 | .dark .hljs-meta, 40 | .dark .hljs-number, 41 | .dark .hljs-operator, 42 | .dark .hljs-variable, 43 | .dark .hljs-selector-attr, 44 | .dark .hljs-selector-class, 45 | .dark .hljs-selector-id { 46 | /* prettylights-syntax-constant */ 47 | color: #79c0ff; 48 | } 49 | 50 | .dark .hljs-regexp, 51 | .dark .hljs-string, 52 | .dark .hljs-meta .hljs-string { 53 | /* prettylights-syntax-string */ 54 | color: #a5d6ff; 55 | } 56 | 57 | .dark .hljs-built_in, 58 | .dark .hljs-symbol { 59 | /* prettylights-syntax-variable */ 60 | color: #ffa657; 61 | } 62 | 63 | .dark .hljs-comment, 64 | .dark .hljs-code, 65 | .dark .hljs-formula { 66 | /* prettylights-syntax-comment */ 67 | color: #8b949e; 68 | } 69 | 70 | .dark .hljs-name, 71 | .dark .hljs-quote, 72 | .dark .hljs-selector-tag, 73 | .dark .hljs-selector-pseudo { 74 | /* prettylights-syntax-entity-tag */ 75 | color: #7ee787; 76 | } 77 | 78 | .dark .hljs-subst { 79 | /* prettylights-syntax-storage-modifier-import */ 80 | color: #c9d1d9; 81 | } 82 | 83 | .dark .hljs-section { 84 | /* prettylights-syntax-markup-heading */ 85 | color: #1f6feb; 86 | font-weight: bold; 87 | } 88 | 89 | .dark .hljs-bullet { 90 | /* prettylights-syntax-markup-list */ 91 | color: #f2cc60; 92 | } 93 | 94 | .dark .hljs-emphasis { 95 | /* prettylights-syntax-markup-italic */ 96 | color: #c9d1d9; 97 | font-style: italic; 98 | } 99 | 100 | .dark .hljs-strong { 101 | /* prettylights-syntax-markup-bold */ 102 | color: #c9d1d9; 103 | font-weight: bold; 104 | } 105 | 106 | .dark .hljs-addition { 107 | /* prettylights-syntax-markup-inserted */ 108 | color: #aff5b4; 109 | background-color: #033a16; 110 | } 111 | 112 | .dark .hljs-deletion { 113 | /* prettylights-syntax-markup-deleted */ 114 | color: #ffdcd7; 115 | background-color: #67060c; 116 | } 117 | 118 | .dark .hljs-char.escape_, 119 | .dark .hljs-link, 120 | .dark .hljs-params, 121 | .dark .hljs-property, 122 | .dark .hljs-punctuation, 123 | .dark .hljs-tag { 124 | /* purposely ignored */ 125 | } 126 | -------------------------------------------------------------------------------- /frontend/styles/highlight-theme-light.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Theme: GitHub 3 | Description: Light theme as seen on github.com 4 | Author: github.com 5 | Maintainer: @Hirse 6 | Updated: 2021-05-15 7 | 8 | Outdated base version: https://github.com/primer/github-syntax-light 9 | Current colors taken from GitHub's CSS 10 | */ 11 | 12 | .light .hljs { 13 | color: #24292e; 14 | background: #ffffff; 15 | } 16 | 17 | .light .hljs-doctag, 18 | .light .hljs-keyword, 19 | .light .hljs-meta .hljs-keyword, 20 | .light .hljs-template-tag, 21 | .light .hljs-template-variable, 22 | .light .hljs-type, 23 | .light .hljs-variable.language_ { 24 | /* prettylights-syntax-keyword */ 25 | color: #d73a49; 26 | } 27 | 28 | .light .hljs-title, 29 | .light .hljs-title.class_, 30 | .light .hljs-title.class_.inherited__, 31 | .light .hljs-title.function_ { 32 | /* prettylights-syntax-entity */ 33 | color: #6f42c1; 34 | } 35 | 36 | .light .hljs-attr, 37 | .light .hljs-attribute, 38 | .light .hljs-literal, 39 | .light .hljs-meta, 40 | .light .hljs-number, 41 | .light .hljs-operator, 42 | .light .hljs-variable, 43 | .light .hljs-selector-attr, 44 | .light .hljs-selector-class, 45 | .light .hljs-selector-id { 46 | /* prettylights-syntax-constant */ 47 | color: #005cc5; 48 | } 49 | 50 | .light .hljs-regexp, 51 | .light .hljs-string, 52 | .light .hljs-meta .hljs-string { 53 | /* prettylights-syntax-string */ 54 | color: #032f62; 55 | } 56 | 57 | .light .hljs-built_in, 58 | .light .hljs-symbol { 59 | /* prettylights-syntax-variable */ 60 | color: #e36209; 61 | } 62 | 63 | .light .hljs-comment, 64 | .light .hljs-code, 65 | .light .hljs-formula { 66 | /* prettylights-syntax-comment */ 67 | color: #6a737d; 68 | } 69 | 70 | .light .hljs-name, 71 | .light .hljs-quote, 72 | .light .hljs-selector-tag, 73 | .light .hljs-selector-pseudo { 74 | /* prettylights-syntax-entity-tag */ 75 | color: #22863a; 76 | } 77 | 78 | .light .hljs-subst { 79 | /* prettylights-syntax-storage-modifier-import */ 80 | color: #24292e; 81 | } 82 | 83 | .light .hljs-section { 84 | /* prettylights-syntax-markup-heading */ 85 | color: #005cc5; 86 | font-weight: bold; 87 | } 88 | 89 | .light .hljs-bullet { 90 | /* prettylights-syntax-markup-list */ 91 | color: #735c0f; 92 | } 93 | 94 | .light .hljs-emphasis { 95 | /* prettylights-syntax-markup-italic */ 96 | color: #24292e; 97 | font-style: italic; 98 | } 99 | 100 | .light .hljs-strong { 101 | /* prettylights-syntax-markup-bold */ 102 | color: #24292e; 103 | font-weight: bold; 104 | } 105 | 106 | .light .hljs-addition { 107 | /* prettylights-syntax-markup-inserted */ 108 | color: #22863a; 109 | background-color: #f0fff4; 110 | } 111 | 112 | .light .hljs-deletion { 113 | /* prettylights-syntax-markup-deleted */ 114 | color: #b31d28; 115 | background-color: #ffeef0; 116 | } 117 | 118 | .light .hljs-char.escape_, 119 | .light .hljs-link, 120 | .light .hljs-params, 121 | .light .hljs-property, 122 | .light .hljs-punctuation, 123 | .light .hljs-tag { 124 | /* purposely ignored */ 125 | } 126 | -------------------------------------------------------------------------------- /frontend/components/UploadedPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import { Card, CardBody, CardHeader, CardProps, CircularProgress, Divider, Input, mergeClasses } from "@heroui/react" 4 | 5 | import type { PasteResponse } from "../../shared/interfaces.js" 6 | import { tst } from "../utils/overrides.js" 7 | import { CopyWidget } from "./CopyWidget.js" 8 | 9 | interface UploadedPanelProps extends CardProps { 10 | isLoading: boolean 11 | loadingProgress?: number 12 | pasteResponse?: PasteResponse 13 | encryptionKey?: string 14 | } 15 | 16 | const makeDecryptionUrl = (url: string, key?: string) => { 17 | const urlParsed = new URL(url) 18 | urlParsed.pathname = "/d" + urlParsed.pathname 19 | if (key) { 20 | return urlParsed.toString() + "#" + key 21 | } else { 22 | return urlParsed.toString() 23 | } 24 | } 25 | 26 | export function UploadedPanel({ 27 | isLoading, 28 | loadingProgress, 29 | pasteResponse, 30 | className, 31 | encryptionKey, 32 | ...rest 33 | }: UploadedPanelProps) { 34 | const copyWidgetClassNames = `bg-transparent ${tst} translate-y-[10%]` 35 | const inputProps = { 36 | "aria-labelledby": "", 37 | readOnly: true, 38 | className: "mb-2", 39 | } 40 | 41 | return ( 42 | 43 | Uploaded Paste 44 | 45 | 46 | {isLoading ? ( 47 |
48 | 53 |
54 | ) : ( 55 | pasteResponse && ( 56 | <> 57 | makeDecryptionUrl(pasteResponse.url, encryptionKey)} 66 | /> 67 | } 68 | /> 69 | pasteResponse.url} />} 74 | /> 75 | pasteResponse.manageUrl} /> 81 | } 82 | /> 83 | 84 | 85 | ) 86 | )} 87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /worker/pages/markdown.ts: -------------------------------------------------------------------------------- 1 | import { unified } from "unified" 2 | import { Root } from "mdast" 3 | import remarkParse from "remark-parse" 4 | import remarkGfm from "remark-gfm" 5 | import remarkRehype from "remark-rehype" 6 | import rehypeStringify from "rehype-stringify" 7 | import { toString } from "mdast-util-to-string" 8 | 9 | import { escapeHtml } from "../common.js" 10 | 11 | const descriptionLimit: number = 200 12 | const defaultTitle: string = "Untitled" 13 | 14 | type DocMetadata = { 15 | title: string 16 | description: string 17 | } 18 | 19 | function getMetadata(options: { result: DocMetadata }): (_: Root) => void { 20 | return (tree: Root) => { 21 | if (tree.children.length === 0) return 22 | 23 | const firstChild = tree.children[0] 24 | // if the document begins with a h1, set its content as the title 25 | if (firstChild.type === "heading" && firstChild.depth === 1) { 26 | options.result.title = escapeHtml(toString(firstChild)) 27 | 28 | if (tree.children.length > 1) { 29 | // description is set as the content of the second node 30 | const secondChild = tree.children[1] 31 | options.result.description = escapeHtml(toString(secondChild).slice(0, descriptionLimit)) 32 | } 33 | } else { 34 | // no title is set 35 | // description is set as the content of the first node 36 | options.result.description = escapeHtml(toString(firstChild).slice(0, descriptionLimit)) 37 | } 38 | } 39 | } 40 | 41 | export function makeMarkdown(content: string): string { 42 | const metadata: DocMetadata = { title: defaultTitle, description: "" } 43 | const convertedHtml = unified() 44 | .use(remarkParse) 45 | .use(remarkGfm) 46 | .use(getMetadata, { result: metadata }) // result is written to `metadata` variable 47 | .use(remarkRehype) 48 | .use(rehypeStringify) 49 | .processSync(content) 50 | .value.toString() 51 | 52 | return ` 53 | 54 | 55 | 56 | 57 | ${metadata.title} 58 | ${metadata.description.length > 0 ? `` : ""} 59 | 60 | 61 | 62 | 63 | 66 | 67 | 68 |
69 | ${convertedHtml} 70 |
71 | 72 | 73 | 74 | 75 | ` 76 | } 77 | -------------------------------------------------------------------------------- /frontend/components/DarkModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import React, { JSX, useEffect, useState, useSyncExternalStore } from "react" 2 | import { Button, ButtonProps, Tooltip } from "@heroui/react" 3 | 4 | import { ComputerIcon, MoonIcon, SunIcon } from "./icons.js" 5 | import { tst } from "../utils/overrides.js" 6 | 7 | const modeSelections = ["system", "light", "dark"] 8 | type ModeSelection = (typeof modeSelections)[number] 9 | const icons: Record = { 10 | system: , 11 | light: , 12 | dark: , 13 | } 14 | 15 | export function useDarkModeSelection(): [ 16 | boolean, 17 | ModeSelection | undefined, 18 | React.Dispatch>, 19 | ] { 20 | const [modeSelection, setModeSelection] = useState(undefined) 21 | 22 | const isSystemDark = useSyncExternalStore( 23 | (callBack) => { 24 | const mql = window.matchMedia("(prefers-color-scheme: dark)") 25 | mql.addEventListener("change", callBack) 26 | return () => { 27 | mql.removeEventListener("change", callBack) 28 | } 29 | }, 30 | () => { 31 | return window.matchMedia("(prefers-color-scheme: dark)").matches 32 | }, 33 | () => false, 34 | ) 35 | 36 | useEffect(() => { 37 | if (modeSelection) { 38 | localStorage.setItem("darkModeSelect", modeSelection) 39 | } 40 | }, [modeSelection]) 41 | 42 | useEffect(() => { 43 | const item = localStorage.getItem("darkModeSelect") 44 | let storedSelect: ModeSelection | undefined 45 | if (item !== null) { 46 | if (item && modeSelections.includes(item)) { 47 | storedSelect = item 48 | } else { 49 | storedSelect = "system" 50 | } 51 | } else { 52 | storedSelect = "system" 53 | } 54 | setModeSelection(storedSelect) 55 | }, []) 56 | 57 | const isDark = modeSelection === undefined || modeSelection === "system" ? isSystemDark : modeSelection === "dark" 58 | 59 | useEffect(() => { 60 | if (isDark) { 61 | document.body.classList.remove("light") 62 | document.body.classList.add("dark") 63 | } else { 64 | document.body.classList.remove("dark") 65 | document.body.classList.add("light") 66 | } 67 | }, [isDark]) 68 | 69 | return [isDark, modeSelection, setModeSelection] 70 | } 71 | 72 | interface MyComponentProps extends ButtonProps { 73 | modeSelection: ModeSelection | undefined 74 | setModeSelection: React.Dispatch> 75 | } 76 | 77 | export function DarkModeToggle({ modeSelection, setModeSelection, className, ...rest }: MyComponentProps) { 78 | return modeSelection ? ( 79 | 80 | 92 | 93 | ) : null 94 | } 95 | -------------------------------------------------------------------------------- /frontend/test/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, vi, expect, beforeAll, afterEach, afterAll } from "vitest" 2 | import { cleanup, render, screen } from "@testing-library/react" 3 | import { PasteBin } from "../pages/PasteBin.js" 4 | 5 | export const mockedPasteUpload: PasteResponse = { 6 | url: "https://example.com/abcd", 7 | manageUrl: "https://example.com/abcd:aaaaaaaaaaaaaaaaaa", 8 | expireAt: "2025-05-01T00:00:00.000Z", 9 | expirationSeconds: 300, 10 | } 11 | 12 | export const mockedPasteContent = "something" 13 | 14 | export const server = setupServer( 15 | http.post(`${APIUrl}/`, () => { 16 | return HttpResponse.json(mockedPasteUpload) 17 | }), 18 | http.get(`${APIUrl}/abcd`, () => { 19 | return HttpResponse.text(mockedPasteContent) 20 | }), 21 | ) 22 | 23 | beforeAll(() => { 24 | stubBrowerFunctions() 25 | server.listen() 26 | }) 27 | 28 | afterEach(() => { 29 | server.resetHandlers() 30 | cleanup() 31 | }) 32 | 33 | afterAll(() => { 34 | unStubBrowerFunctions() 35 | server.close() 36 | }) 37 | 38 | import "@testing-library/jest-dom/vitest" 39 | import { userEvent } from "@testing-library/user-event" 40 | import { PasteResponse } from "../../shared/interfaces.js" 41 | import { setupServer } from "msw/node" 42 | import { http, HttpResponse } from "msw" 43 | import { stubBrowerFunctions, unStubBrowerFunctions } from "./testUtils.js" 44 | import { APIUrl } from "../utils/utils.js" 45 | 46 | describe("Pastebin", () => { 47 | it("can upload", async () => { 48 | render() 49 | 50 | const title = screen.getByText("Pastebin Worker") 51 | expect(title).toBeInTheDocument() 52 | 53 | const editor = screen.getByRole("textbox", { name: "Paste editor" }) 54 | expect(editor).toBeInTheDocument() 55 | 56 | const submitter = screen.getByRole("button", { name: "Upload" }) 57 | expect(submitter).toBeInTheDocument() 58 | expect(submitter).not.toBeEnabled() 59 | 60 | await userEvent.type(editor, "something") 61 | 62 | expect(submitter).toBeEnabled() 63 | await userEvent.click(submitter) 64 | 65 | await new Promise((resolve) => setTimeout(resolve, 1000)) 66 | const urlShow = screen.getByRole("textbox", { name: "Raw URL" }) 67 | expect((urlShow as HTMLInputElement).value).toStrictEqual(mockedPasteUpload.url) 68 | 69 | const manageUrlShow = screen.getByRole("textbox", { name: "Manage URL" }) 70 | expect((manageUrlShow as HTMLInputElement).value).toStrictEqual(mockedPasteUpload.manageUrl) 71 | }) 72 | 73 | it("refuse illegal settings", async () => { 74 | render() 75 | // due to bugs https://github.com/adobe/react-spectrum/discussions/8037, we need to use duplicated name here 76 | const expire = screen.getByRole("textbox", { name: "Expiration" }) 77 | expect(expire).toBeValid() 78 | await userEvent.type(expire, "xxx") 79 | expect(expire).toBeInvalid() 80 | }) 81 | }) 82 | 83 | describe("Pastebin admin page", () => { 84 | it("renders admin page", async () => { 85 | vi.stubGlobal("location", new URL("https://example.com/abcd:xxxxxxxxx")) 86 | render() 87 | 88 | const editor = screen.getByRole("textbox", { name: "Paste editor" }) 89 | await userEvent.click(editor) // meaningless click, just ensure useEffect is done 90 | expect(editor).toBeInTheDocument() 91 | expect((editor as HTMLTextAreaElement).value).toStrictEqual(mockedPasteContent) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /shared/test/parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest" 2 | import { 3 | parsePath, 4 | ParsedPath, 5 | parseFilenameFromContentDisposition, 6 | parseExpiration, 7 | parseExpirationReadable, 8 | } from "../parsers.js" 9 | 10 | test("parsePath", () => { 11 | const testPairs: [string, ParsedPath][] = [ 12 | ["/abcd", { name: "abcd" }], 13 | ["/abcd:1245", { name: "abcd", password: "1245" }], 14 | ["/~abc", { name: "~abc" }], 15 | ["/a/~abc", { name: "~abc", role: "a" }], 16 | ["/abcd.jpg", { name: "abcd", ext: ".jpg" }], 17 | ["/abcd.txt.jpg", { name: "abcd", ext: ".txt.jpg" }], 18 | ["/u/abcd.jpg", { name: "abcd", ext: ".jpg", role: "u" }], 19 | ["/a/abcd/efg.jpg", { name: "abcd", filename: "efg.jpg", ext: ".jpg", role: "a" }], 20 | ["/a/abcd/efg.txt.jpg", { name: "abcd", filename: "efg.txt.jpg", ext: ".txt.jpg", role: "a" }], 21 | ["/a/abcd/.jpg", { name: "abcd", filename: ".jpg", ext: ".jpg", role: "a" }], 22 | ["/a/abcd/cef", { name: "abcd", filename: "cef", role: "a" }], 23 | ["/a/abcd:xxxxxxxx/.jpg", { name: "abcd", filename: ".jpg", ext: ".jpg", role: "a", password: "xxxxxxxx" }], 24 | ["/abcd:xxxxxxxx.jpg", { name: "abcd", ext: ".jpg", password: "xxxxxxxx" }], 25 | ["/~abcd:xxxxxxxx.jpg", { name: "~abcd", ext: ".jpg", password: "xxxxxxxx" }], 26 | ["/a/abcd:xxxxxxxx", { name: "abcd", role: "a", password: "xxxxxxxx" }], 27 | ] 28 | 29 | for (const [input, output] of testPairs) { 30 | const parsed = parsePath(input) 31 | expect(parsed.name, `checking nameFromPath of ${input}`).toStrictEqual(output.name) 32 | expect(parsed.role, `checking role of ${input}`).toStrictEqual(output.role) 33 | expect(parsed.password, `checking passwd of ${input}`).toStrictEqual(output.password) 34 | expect(parsed.ext, `checking ext of ${input}`).toStrictEqual(output.ext) 35 | expect(parsed.filename, `checking filename of ${input}`).toStrictEqual(output.filename) 36 | } 37 | }) 38 | 39 | test("parseFilenameFromContentDisposition", () => { 40 | const testPairs: [string, string][] = [ 41 | [`inline; filename="abc.jpg"`, "abc.jpg"], 42 | [`inline; filename*=UTF-8''${encodeURIComponent("abc.jpg")}`, "abc.jpg"], 43 | [`inline; filename*=UTF-8''${encodeURIComponent("りんご")}`, "りんご"], 44 | ] 45 | for (const [input, output] of testPairs) { 46 | const parsed = parseFilenameFromContentDisposition(input) 47 | expect(parsed, `checking filename of ${input}`).toStrictEqual(output) 48 | } 49 | }) 50 | 51 | test("parseExpiration", () => { 52 | const testPairs: [string, number | null, string | null][] = [ 53 | ["1", 1, "1 second"], 54 | ["1m", 60, "1 minute"], 55 | ["0.5d", 12 * 60 * 60, "0.5 day"], 56 | ["100", 100, "100 seconds"], 57 | ["10.1", 10.1, "10.1 seconds"], 58 | ["10m", 600, "10 minutes"], 59 | ["10.0m", 600, "10 minutes"], 60 | ["10h", 10 * 60 * 60, "10 hours"], 61 | ["10.0h", 10 * 60 * 60, "10 hours"], 62 | ["10d", 10 * 24 * 60 * 60, "10 days"], 63 | ["10 d", 10 * 24 * 60 * 60, "10 days"], 64 | ["10 d", 10 * 24 * 60 * 60, "10 days"], 65 | ["10 ", 10, "10 seconds"], 66 | [" 10 ", 10, "10 seconds"], 67 | 68 | [" 10 g", null, null], 69 | ["10g", null, null], 70 | ["-10", null, null], 71 | ["-10d", null, null], 72 | ["10M", null, null], 73 | ["10Y", null, null], 74 | ["d", null, null], 75 | 76 | ["1.1.1 d", null, null], 77 | ] 78 | for (const [input, parsed, readableParsed] of testPairs) { 79 | const expiration = parseExpiration(input) 80 | expect(expiration, `checking expiration of ${input}`).toStrictEqual(parsed) 81 | 82 | const readable = parseExpirationReadable(input) 83 | expect(readable, `checking readable expiration of ${input}`).toStrictEqual(readableParsed) 84 | } 85 | }) 86 | -------------------------------------------------------------------------------- /frontend/utils/uploader.ts: -------------------------------------------------------------------------------- 1 | import type { PasteSetting } from "../components/PasteSettingPanel.js" 2 | import type { PasteEditState } from "../components/PasteInputPanel.js" 3 | import { APIUrl, ErrorWithTitle } from "./utils.js" 4 | import type { PasteResponse } from "../../shared/interfaces.js" 5 | import { encodeKey, encrypt, EncryptionScheme, genKey } from "./encryption.js" 6 | import { UploadError, uploadMPU, uploadNormal, UploadOptions } from "../../shared/uploadPaste.js" 7 | 8 | async function genAndEncrypt(scheme: EncryptionScheme, content: string | Uint8Array) { 9 | const key = await genKey(scheme) 10 | const plaintext = typeof content === "string" ? new TextEncoder().encode(content) : content 11 | const ciphertext = await encrypt(scheme, key, plaintext) 12 | return { key: await encodeKey(key), ciphertext } 13 | } 14 | 15 | const encryptionScheme: EncryptionScheme = "AES-GCM" 16 | 17 | const minChunkSize = 5 * 1024 * 1024 18 | 19 | export async function uploadPaste( 20 | pasteSetting: PasteSetting, 21 | editorState: PasteEditState, 22 | onEncryptionKeyChange: (k: string | undefined) => void, // we only generate key on upload, so need a callback of key generation 23 | onProgress?: (progress: number | undefined) => void, 24 | ): Promise { 25 | async function constructContent(): Promise { 26 | if (editorState.editKind === "file") { 27 | if (editorState.file === null) { 28 | throw new ErrorWithTitle("Error on Preparing Upload", "No file selected") 29 | } 30 | if (pasteSetting.doEncrypt) { 31 | const { key, ciphertext } = await genAndEncrypt(encryptionScheme, await editorState.file.bytes()) 32 | const file = new File([ciphertext], editorState.file.name) 33 | onEncryptionKeyChange(key) 34 | return file 35 | } else { 36 | onEncryptionKeyChange(undefined) 37 | return editorState.file 38 | } 39 | } else { 40 | if (editorState.editContent.length === 0) { 41 | throw new ErrorWithTitle("Error on Preparing Upload", "Empty paste") 42 | } 43 | if (pasteSetting.doEncrypt) { 44 | const { key, ciphertext } = await genAndEncrypt(encryptionScheme, editorState.editContent) 45 | onEncryptionKeyChange(key) 46 | return new File([ciphertext], editorState.editFilename || "") 47 | } else { 48 | onEncryptionKeyChange(undefined) 49 | return new File([editorState.editContent], editorState.editFilename || "") 50 | } 51 | } 52 | } 53 | 54 | const options: UploadOptions = { 55 | content: await constructContent(), 56 | isUpdate: pasteSetting.uploadKind === "manage", 57 | isPrivate: pasteSetting.uploadKind === "long", 58 | password: pasteSetting.password.length ? pasteSetting.password : undefined, 59 | expire: pasteSetting.expiration, 60 | name: pasteSetting.uploadKind === "custom" ? pasteSetting.name : undefined, 61 | highlightLanguage: editorState.editKind === "edit" ? editorState.editHighlightLang : undefined, 62 | encryptionScheme: pasteSetting.doEncrypt ? encryptionScheme : undefined, 63 | manageUrl: pasteSetting.manageUrl, 64 | } 65 | 66 | const contentLength = options.content.size 67 | 68 | try { 69 | if (contentLength < 5 * 1024 * 1024) { 70 | return await uploadNormal(APIUrl, options) 71 | } else { 72 | if (onProgress) onProgress(0) 73 | return await uploadMPU(APIUrl, minChunkSize, options, (doneBytes, allBytes) => { 74 | if (onProgress) onProgress((100 * doneBytes) / allBytes) 75 | }) 76 | } 77 | } catch (e) { 78 | if (e instanceof UploadError) { 79 | throw new ErrorWithTitle("Error on Upload", e.message) 80 | } 81 | throw e 82 | } finally { 83 | if (onProgress) onProgress(undefined) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /worker/test/basicAuth.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, it, describe, beforeEach, afterEach } from "vitest" 2 | import { areBlobsEqual, BASE_URL, genRandomBlob, upload, uploadExpectStatus, workerFetch } from "./testUtils" 3 | import { encodeBasicAuth, decodeBasicAuth } from "../pages/auth" 4 | import { createExecutionContext, env } from "cloudflare:test" 5 | import { hashSync } from "bcrypt-ts" 6 | 7 | test("basic auth encode and decode", () => { 8 | const userPasswdPairs = [ 9 | ["user1", "passwd1"], 10 | ["あおい", "まなか"], 11 | ["1234#", "اهلا"], 12 | ] 13 | for (const [user, passwd] of userPasswdPairs) { 14 | const encoded = encodeBasicAuth(user, passwd) 15 | const decoded = decodeBasicAuth(encoded) 16 | expect(decoded.username).toStrictEqual(user) 17 | expect(decoded.password).toStrictEqual(passwd) 18 | } 19 | }) 20 | 21 | describe("basic auth", () => { 22 | const ctx = createExecutionContext() 23 | const users: Record = { 24 | user1: "passwd1", 25 | user2: "passwd2", 26 | } 27 | const authHeader = { Authorization: encodeBasicAuth("user1", users["user1"]) } 28 | const wrongAuthHeader = { Authorization: encodeBasicAuth("user1", "wrong-password") } 29 | const blob1 = genRandomBlob(1024) 30 | 31 | /* TODO: Due to the limitation of workers-sdk, setting env here may also affect other tests occasionally 32 | It means that other tests may fail with 400 error occasionally 33 | ref: https://github.com/cloudflare/workers-sdk/issues/7339 34 | */ 35 | beforeEach(() => { 36 | env.BASIC_AUTH = Object.fromEntries(Object.entries(users).map(([user, passwd]) => [user, hashSync(passwd, 8)])) 37 | }) 38 | 39 | afterEach(() => { 40 | env.BASIC_AUTH = {} 41 | }) 42 | 43 | it("should forbid accessing index without auth", async () => { 44 | for (const page of ["", "index", "index.html"]) { 45 | expect((await workerFetch(ctx, `${BASE_URL}/${page}`)).status, `visiting ${page}`).toStrictEqual(401) 46 | } 47 | }) 48 | 49 | it("should allow accessing index without auth", async () => { 50 | expect((await workerFetch(ctx, new Request(BASE_URL, { headers: authHeader }))).status).toStrictEqual(200) 51 | }) 52 | 53 | it("should forbid upload without auth", async () => { 54 | await uploadExpectStatus(ctx, { c: blob1 }, 401, { method: "POST" }) 55 | }) 56 | 57 | it("should allow upload index without auth", async () => { 58 | await upload(ctx, { c: blob1 }, { headers: authHeader }) 59 | }) 60 | 61 | // upload with wrong auth 62 | it("should forbid upload with wrong auth", async () => { 63 | await uploadExpectStatus(ctx, { c: blob1 }, 401, { headers: wrongAuthHeader }) 64 | }) 65 | 66 | it("should allow visit paste without auth", async () => { 67 | const uploadResp1 = await upload(ctx, { c: blob1 }, { headers: authHeader }) 68 | const revisitResp = await workerFetch(ctx, uploadResp1.url) 69 | expect(revisitResp.status).toStrictEqual(200) 70 | expect(await areBlobsEqual(await revisitResp.blob(), blob1)).toStrictEqual(true) 71 | }) 72 | 73 | it("should allow update without auth", async () => { 74 | const uploadResp1 = await upload(ctx, { c: blob1 }, { headers: authHeader }) 75 | const blob2 = genRandomBlob(1024) 76 | const updateResp = await upload(ctx, { c: blob2 }, { method: "PUT", url: uploadResp1.manageUrl }) 77 | const revisitUpdatedResp = await workerFetch(ctx, updateResp.url) 78 | expect(revisitUpdatedResp.status).toStrictEqual(200) 79 | expect(await areBlobsEqual(await revisitUpdatedResp.blob(), blob2)).toStrictEqual(true) 80 | }) 81 | 82 | it("should delete without auth", async () => { 83 | const uploadResp1 = await upload(ctx, { c: blob1 }, { headers: authHeader }) 84 | const deleteResp = await workerFetch( 85 | ctx, 86 | new Request(uploadResp1.manageUrl, { 87 | method: "DELETE", 88 | }), 89 | ) 90 | expect(deleteResp.status).toStrictEqual(200) 91 | expect((await workerFetch(ctx, uploadResp1.url)).status).toStrictEqual(404) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pastebin-worker 2 | 3 | This is a pastebin that can be deployed on Cloudflare workers. Try it on [shz.al](https://shz.al). 4 | 5 | **Philosophy**: effortless deployment, friendly CLI usage, rich functionality. 6 | 7 | **Features**: 8 | 9 | 1. Share your paste with as short as 4 characters, or even customized URL. 10 | 1. **Syntax highlighting** powered by highlight.js. 11 | 1. Client-side encryption 12 | 1. Render **markdown** file as HTML 13 | 1. URL shortener 14 | 1. Customize returned `Content-Type` 15 | 16 | ## Usage 17 | 18 | 1. You can post, update, delete your paste directly on the website (such as [shz.al](https://shz.al)). 19 | 20 | 2. It also provides a convenient HTTP API to use. See [API reference](doc/api.md) for details. You can easily call API via command line (using `curl` or similar tools). 21 | 22 | 3. [pb](/scripts) is a bash script to make it easier to use on command line. 23 | 24 | ## Limitations 25 | 26 | 1. If deployed on Cloudflare Worker free-tier plan, the service allows at most 100,000 reads and 1000 writes, 1000 deletes per day. 27 | 28 | ## Deploy 29 | 30 | You are free to deploy the pastebin on your own domain if you host your domain on Cloudflare. 31 | 32 | 1. Install `node` and `yarn`. 33 | 34 | 2. Create a KV namespace and R2 bucket on Cloudflare workers dashboard, remember its ID. 35 | 36 | 3. Clone the repository and enter the directory. 37 | 38 | 4. Modify entries in `wrangler.toml`. Its comments will tell you how. 39 | 40 | 5. Login to Cloudflare and deploy with the following steps: 41 | 42 | ```console 43 | $ yarn install 44 | $ yarn wrangler login 45 | $ yarn build:frontend 46 | $ yarn deploy 47 | ``` 48 | 49 | 6. Enjoy! 50 | 51 | ## Auth 52 | 53 | If you want a private deployment (only you can upload paste, but everyone can read the paste), add the following entry to your `wrangler.toml`. 54 | 55 | ```toml 56 | [vars.BASIC_AUTH] 57 | user1 = "$2b$08$i/yH1TSIGWUNQVsxPrcVUeR0hsGioFNf3.OeHdYzxwjzLH/hzoY.i" 58 | user2 = "$2b$08$KeVnmXoMuRjNHKQjDHppEeXAf5lTLv9HMJCTlKW5uvRcEG5LOdBpO" 59 | ``` 60 | 61 | Passwords here are hashed by bcrypt2 algorithm. You can generate the hashed password by running `./scripts/bcrypt.js`. 62 | 63 | Now every access to POST request, and every access to static pages, requires an HTTP basic auth with the user-password pair listed above. For example: 64 | 65 | ```console 66 | $ curl example-pb.com 67 | HTTP basic auth is required 68 | 69 | $ curl -Fc=@/path/to/file example-pb.com 70 | HTTP basic auth is required 71 | 72 | $ curl -u admin1:wrong-passwd -Fc=@/path/to/file example-pb.com 73 | Error 401: incorrect passwd for basic auth 74 | 75 | $ curl -u admin1:this-is-passwd-1 -Fc=@/path/to/file example-pb.com 76 | { 77 | "url": "https://example-pb.com/YCDX", 78 | "admin": "https://example-pb.com/YCDX:Sij23HwbMjeZwKznY3K5trG8", 79 | "isPrivate": false 80 | } 81 | ``` 82 | 83 | ## Administration 84 | 85 | Delete a paste: 86 | 87 | ```console 88 | $ yarn delete-paste 89 | ``` 90 | 91 | List pastes: 92 | 93 | ```console 94 | $ yarn -s wrangler kv key list --binding PB > kv_list.json 95 | ``` 96 | 97 | ## Development 98 | 99 | Note that the frontend and worker code are built separatedly. To start a Vite development server of the frontend, 100 | 101 | ```console 102 | $ yarn dev:frontend 103 | ``` 104 | 105 | To develop the backend worker, we must build a develop version of frontend, 106 | 107 | ```console 108 | $ yarn build:frontend:dev 109 | ``` 110 | 111 | Then starts a local worker, 112 | 113 | ```console 114 | $ yarn dev 115 | ``` 116 | 117 | The difference between `build:frontend:dev` and `build:frontend` is that the former will points the API endpoint to your deployment URL, while the later points to `http://localhost:8787`, the address of a local worker. 118 | 119 | Run tests: 120 | 121 | ```console 122 | $ yarn test 123 | ``` 124 | 125 | Run tests with coverage report: 126 | 127 | ```console 128 | $ yarn coverage 129 | ``` 130 | 131 | Remember to run eslint checks and prettier before commiting your code. 132 | 133 | ```console 134 | $ yarn fmt 135 | $ yarn lint 136 | ``` 137 | -------------------------------------------------------------------------------- /worker/test/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { env } from "cloudflare:test" 2 | 3 | import { expect } from "vitest" 4 | import crypto from "crypto" 5 | 6 | import worker from "../index" 7 | import { PasteResponse } from "../../shared/interfaces" 8 | 9 | export const BASE_URL: string = env.DEPLOY_URL 10 | export const RAND_NAME_REGEX = /^[ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678]+$/ 11 | 12 | export const staticPages = ["", "index.html", "index", "tos", "tos.html", "api", "api.html", "favicon.ico"] 13 | 14 | type FormDataBuild = { 15 | [key: string]: string | Blob | { content: Blob; filename: string } 16 | } 17 | 18 | export async function workerFetch(ctx: ExecutionContext, req: Request | string) { 19 | // we are not using SELF.fetch since it sometimes do not print worker log to console 20 | // return await SELF.fetch(req, options) 21 | return await worker.fetch(new Request(req), env, ctx) 22 | } 23 | 24 | export async function upload( 25 | ctx: ExecutionContext, 26 | kv: FormDataBuild, 27 | options: { 28 | method?: "POST" | "PUT" 29 | url?: string 30 | headers?: Record 31 | context?: string 32 | } = {}, 33 | ): Promise { 34 | const method = options.method || "POST" 35 | const url = options.url || BASE_URL 36 | const headers = options.headers || {} 37 | const uploadResponse = await workerFetch( 38 | ctx, 39 | new Request(url, { 40 | method, 41 | body: createFormData(kv), 42 | headers, 43 | }), 44 | ) 45 | if (uploadResponse.status !== 200) { 46 | let uploadMsg = await uploadResponse.text() 47 | if (options.context) uploadMsg += ` ${options.context}` 48 | throw new Error(uploadMsg) 49 | } 50 | expect(uploadResponse.headers.get("Content-Type")).toStrictEqual("application/json;charset=UTF-8") 51 | return JSON.parse(await uploadResponse.text()) as PasteResponse 52 | } 53 | 54 | export async function uploadExpectStatus( 55 | ctx: ExecutionContext, 56 | kv: FormDataBuild, 57 | expectedStatuus: number, 58 | options: { 59 | method?: "POST" | "PUT" 60 | url?: string 61 | headers?: Record 62 | context?: string 63 | } = {}, 64 | ): Promise { 65 | const method = options.method || "POST" 66 | const url = options.url || BASE_URL 67 | const headers = options.headers || {} 68 | const uploadResponse = await workerFetch( 69 | ctx, 70 | new Request(url, { 71 | method, 72 | body: createFormData(kv), 73 | headers, 74 | }), 75 | ) 76 | if (uploadResponse.status !== expectedStatuus) { 77 | let uploadMsg = await uploadResponse.text() 78 | if (options.context) uploadMsg += ` ${options.context}` 79 | throw new Error(uploadMsg) 80 | } 81 | } 82 | 83 | export function createFormData(kv: FormDataBuild): FormData { 84 | const fd = new FormData() 85 | Object.entries(kv).forEach(([k, v]) => { 86 | if (typeof v === "string") { 87 | fd.set(k, v) 88 | } else if (v instanceof Blob) { 89 | fd.set(k, v, "") // fd.set automatically set filename to k, not what we desired 90 | } else { 91 | // hack for typing 92 | const { content, filename } = v as { content: Blob; filename: string } 93 | fd.set(k, content, filename) 94 | } 95 | }) 96 | return fd 97 | } 98 | 99 | export function genRandomBlob(len: number): Blob { 100 | const buf = Buffer.alloc(len) 101 | const chunkSize = 4096 102 | for (let i = 0; i < len; i += chunkSize) { 103 | const fillLen = Math.min(len - i, chunkSize) 104 | crypto.randomFillSync(buf, i, fillLen) 105 | } 106 | return new Blob([buf]) 107 | } 108 | 109 | export async function areBlobsEqual(blob1: Blob, blob2: Blob) { 110 | if (blob1.size !== blob2.size) { 111 | return false 112 | } 113 | const array1 = await blob1.bytes() 114 | const array2 = await blob2.bytes() 115 | for (let i = 0; i < blob1.size; i++) { 116 | if (array1[i] != array2[i]) { 117 | return false 118 | } 119 | } 120 | return true 121 | } 122 | 123 | // replace https://example.com/xxx to https://example.com/${role}/xxx 124 | export function addRole(url: string, role: string): string { 125 | const splitPoint = env.DEPLOY_URL.length 126 | return url.slice(0, splitPoint) + "/" + role + url.slice(splitPoint) 127 | } 128 | -------------------------------------------------------------------------------- /shared/parsers.ts: -------------------------------------------------------------------------------- 1 | import { PASSWD_SEP } from "./constants.js" 2 | 3 | export function parseSize(sizeStr: string): number | null { 4 | sizeStr = sizeStr.trim() 5 | const EXPIRE_REGEX = /^[\d.]+\s*[KMG]?$/ 6 | if (!EXPIRE_REGEX.test(sizeStr)) { 7 | return null 8 | } 9 | 10 | let sizeBytes = parseFloat(sizeStr) 11 | const lastChar = sizeStr[sizeStr.length - 1] 12 | if (lastChar === "K") sizeBytes *= 1024 13 | else if (lastChar === "M") sizeBytes *= 1024 * 1024 14 | else if (lastChar === "G") sizeBytes *= 1024 * 1024 * 1024 15 | return sizeBytes 16 | } 17 | 18 | export function parseExpiration(expirationStr: string): number | null { 19 | expirationStr = expirationStr.trim() 20 | const EXPIRE_REGEX = /^[\d.]+\s*[smhd]?$/ 21 | if (!EXPIRE_REGEX.test(expirationStr)) { 22 | return null 23 | } 24 | 25 | let expirationSeconds = parseFloat(expirationStr) 26 | if (isNaN(expirationSeconds)) { 27 | return null 28 | } 29 | 30 | const lastChar = expirationStr[expirationStr.length - 1] 31 | if (lastChar === "m") expirationSeconds *= 60 32 | else if (lastChar === "h") expirationSeconds *= 3600 33 | else if (lastChar === "d") expirationSeconds *= 3600 * 24 34 | return expirationSeconds 35 | } 36 | 37 | export function parseExpirationReadable(expirationStr: string): string | null { 38 | expirationStr = expirationStr.trim() 39 | const EXPIRE_REGEX = /^[\d.]+\s*[smhd]?$/ 40 | if (!EXPIRE_REGEX.test(expirationStr)) { 41 | return null 42 | } 43 | 44 | const num = parseFloat(expirationStr) 45 | if (isNaN(num)) { 46 | return null 47 | } 48 | const lastChar = expirationStr[expirationStr.length - 1] 49 | if (lastChar === "m") return `${num} minute${num > 1 ? "s" : ""}` 50 | else if (lastChar === "h") return `${num} hour${num > 1 ? "s" : ""}` 51 | else if (lastChar === "d") return `${num} day${num > 1 ? "s" : ""}` 52 | return `${num} second${num > 1 ? "s" : ""}` 53 | } 54 | 55 | export type ParsedPath = { 56 | name: string 57 | role?: string 58 | password?: string 59 | ext?: string 60 | filename?: string 61 | } 62 | 63 | export function parsePath(pathname: string): ParsedPath { 64 | pathname = pathname.slice(1) // strip the leading slash 65 | 66 | let role: string | undefined, 67 | ext: string | undefined, 68 | filename: string | undefined, 69 | passwd: string | undefined, 70 | short: string | undefined 71 | 72 | // extract and remove role 73 | if (pathname[1] === "/") { 74 | role = pathname[0] 75 | pathname = pathname.slice(2) 76 | } 77 | 78 | // extract and remove filename 79 | const startOfFilename = pathname.lastIndexOf("/") 80 | if (startOfFilename >= 0) { 81 | filename = decodeURIComponent(pathname.slice(startOfFilename + 1)) 82 | pathname = pathname.slice(0, startOfFilename) 83 | } 84 | 85 | // if having filename, parse ext from filename, else from remaining pathname 86 | if (filename) { 87 | const startOfExt = filename.indexOf(".") 88 | if (startOfExt >= 0) { 89 | ext = filename.slice(startOfExt) 90 | } 91 | } else { 92 | const startOfExt = pathname.indexOf(".") 93 | if (startOfExt >= 0) { 94 | ext = pathname.slice(startOfExt) 95 | pathname = pathname.slice(0, startOfExt) 96 | } 97 | } 98 | 99 | const endOfShort = pathname.indexOf(PASSWD_SEP) 100 | if (endOfShort < 0) { 101 | short = pathname 102 | passwd = undefined 103 | } else { 104 | short = pathname.slice(0, endOfShort) 105 | passwd = pathname.slice(endOfShort + 1) 106 | } 107 | return { role, name: short, password: passwd, ext, filename } 108 | } 109 | 110 | export function parseFilenameFromContentDisposition(contentDisposition: string): string | undefined { 111 | let filename: string | undefined = undefined 112 | 113 | const filenameStarRegex = /filename\*=UTF-8''([^;]*)/i 114 | const filenameStarMatch = contentDisposition.match(filenameStarRegex) 115 | 116 | if (filenameStarMatch && filenameStarMatch[1]) { 117 | filename = decodeURIComponent(filenameStarMatch[1]) 118 | } 119 | 120 | if (!filename) { 121 | const filenameRegex = /filename="([^"]*)"/i 122 | const filenameMatch = contentDisposition.match(filenameRegex) 123 | 124 | if (filenameMatch && filenameMatch[1]) { 125 | filename = filenameMatch[1] 126 | } 127 | } 128 | 129 | return filename 130 | } 131 | -------------------------------------------------------------------------------- /worker/test/roles.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, it, beforeEach, vi, afterEach } from "vitest" 2 | import { addRole, upload, workerFetch } from "./testUtils" 3 | import { createExecutionContext } from "cloudflare:test" 4 | import { MetaResponse } from "../../shared/interfaces" 5 | import { genRandStr } from "../common" 6 | 7 | const testMd: string = ` 8 | # Header 1 9 | 10 | This is the content of \`test.md\` 11 | 12 | 15 | 16 | ## Header 2 17 | 18 | | abc | defghi | 19 | | :-: | -----: | 20 | | bar | baz | 21 | 22 | **Bold**, \`Monospace\`, _Italics_, ~~Strikethrough~~, [URL](https://github.com) 23 | 24 | - A 25 | - A1 26 | - A2 27 | - B 28 | 29 | ![Panty](https://shz.al/~panty.jpg) 30 | 31 | 1. first 32 | 2. second 33 | 34 | \`\`\`js 35 | (!+[]+[]+![]).length 36 | \`\`\` 37 | 38 | > Quotation 39 | 40 | $$ 41 | \\int_{-\\infty}^{\\infty} e^{-x^2} = \\sqrt{\\pi} 42 | $$ 43 | ` 44 | 45 | test("markdown with role a", async () => { 46 | const ctx = createExecutionContext() 47 | const url = (await upload(ctx, { c: testMd })).url 48 | 49 | const revisitResponse = await workerFetch(ctx, addRole(url, "a")) 50 | expect(revisitResponse.status).toStrictEqual(200) 51 | expect(revisitResponse.headers.get("Content-Type")).toStrictEqual("text/html;charset=UTF-8") 52 | const responseHtml = await revisitResponse.text() 53 | expect(responseHtml.indexOf("Header 1")).toBeGreaterThan(-1) 54 | expect(responseHtml.indexOf('')).toBeGreaterThan(-1) 55 | 56 | const bigMd = "1".repeat(1024 * 1024) 57 | const bigUrl = (await upload(ctx, { c: bigMd })).url 58 | const bigResp = await (await workerFetch(ctx, addRole(bigUrl, "a"))).text() 59 | expect(bigResp.indexOf("Untitled")).toBeGreaterThan(-1) 60 | }) 61 | 62 | test("meta with role m", async () => { 63 | beforeEach(vi.useFakeTimers) 64 | afterEach(vi.useRealTimers) 65 | const t1 = new Date(2035, 0, 0) 66 | vi.setSystemTime(t1) 67 | 68 | const content = `# Hello` 69 | const ctx = createExecutionContext() 70 | const uploadResp = await upload(ctx, { c: content }) 71 | expect(new Date(uploadResp.expireAt).getTime()).toStrictEqual(t1.getTime() + uploadResp.expirationSeconds * 1000) 72 | const url = uploadResp.url 73 | 74 | const metaResponse = await workerFetch(ctx, addRole(url, "m")) 75 | expect(metaResponse.status).toStrictEqual(200) 76 | expect(metaResponse.headers.get("Content-Type")).toStrictEqual("application/json;charset=UTF-8") 77 | const meta: MetaResponse = await metaResponse.json() 78 | 79 | expect(meta.location).toStrictEqual("KV") 80 | expect(meta.filename).toBeUndefined() 81 | expect(new Date(meta.lastModifiedAt).getTime()).toStrictEqual(t1.getTime()) 82 | expect(new Date(meta.createdAt).getTime()).toStrictEqual(t1.getTime()) 83 | 84 | const t2 = new Date(2035, 0, 1) 85 | vi.setSystemTime(t2) 86 | const updateResp = await upload(ctx, { c: content, e: "1d" }, { method: "PUT", url: uploadResp.manageUrl }) 87 | expect(new Date(updateResp.expireAt).getTime()).toStrictEqual(t2.getTime() + updateResp.expirationSeconds * 1000) 88 | const updatedMeta: MetaResponse = await (await workerFetch(ctx, addRole(url, "m"))).json() 89 | expect(new Date(updatedMeta.lastModifiedAt).getTime()).toStrictEqual(t2.getTime()) 90 | expect(new Date(updatedMeta.createdAt).getTime()).toStrictEqual(t1.getTime()) 91 | }) 92 | 93 | describe("url redirect with role u", () => { 94 | const ctx = createExecutionContext() 95 | it("should redirect", async () => { 96 | const contentUrl = "https://example.com:1234/abc-def?g=hi&jk=l" 97 | const uploadResp = await upload(ctx, { c: contentUrl }) 98 | const url = uploadResp.url 99 | 100 | const resp = await workerFetch(ctx, addRole(url, "u")) 101 | expect(resp.status).toStrictEqual(302) 102 | expect(resp.headers.get("location")).toStrictEqual(contentUrl) 103 | }) 104 | 105 | it("should refuse illegal url", async () => { 106 | const contentUrl = "xxxx" 107 | const uploadResp = await upload(ctx, { c: contentUrl }) 108 | const url = uploadResp.url 109 | 110 | const resp = await workerFetch(ctx, addRole(url, "u")) 111 | expect(resp.status).toStrictEqual(400) 112 | }) 113 | 114 | it("should refuse overlong url", async () => { 115 | const contentUrl = genRandStr(4096) 116 | const uploadResp = await upload(ctx, { c: contentUrl }) 117 | const url = uploadResp.url 118 | 119 | const resp = await workerFetch(ctx, addRole(url, "u")) 120 | expect(resp.status).toStrictEqual(400) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /worker/handlers/handleMPU.ts: -------------------------------------------------------------------------------- 1 | import { MPUCreateResponse } from "../../shared/interfaces.js" 2 | import { NAME_REGEX, PASTE_NAME_LEN, PRIVATE_PASTE_NAME_LEN } from "../../shared/constants.js" 3 | import { genRandStr, WorkerError } from "../common.js" 4 | import { getPasteMetadata, pasteNameAvailable } from "../storage/storage.js" 5 | import { parseSize } from "../../shared/parsers.js" 6 | 7 | // POST /mpu/create?n=&p= 8 | // returns JSON { name: string, key: string, uploadId: string } 9 | export async function handleMPUCreate(request: Request, env: Env): Promise { 10 | const url = new URL(request.url) 11 | const n = url.searchParams.get("n") 12 | const isPrivate = url.searchParams.get("p") !== null 13 | 14 | let name: string | undefined 15 | if (n) { 16 | if (!NAME_REGEX.test(n)) { 17 | throw new WorkerError(400, `illegal paste name ‘${n}’ for MPU create`) 18 | } 19 | name = "~" + n 20 | if (!(await pasteNameAvailable(env, n))) { 21 | throw new WorkerError(409, `name ‘${name}’ is already used`) 22 | } 23 | } else { 24 | name = genRandStr(isPrivate ? PRIVATE_PASTE_NAME_LEN : PASTE_NAME_LEN) 25 | } 26 | 27 | const multipartUpload = await env.R2.createMultipartUpload(name) 28 | const resp: MPUCreateResponse = { 29 | name, 30 | key: multipartUpload.key, 31 | uploadId: multipartUpload.uploadId, 32 | } 33 | return new Response(JSON.stringify(resp)) 34 | } 35 | 36 | // POST /mpu/create-update?name=&password= 37 | // returns JSON { name: string, key: string, uploadId: string } 38 | export async function handleMPUCreateUpdate(request: Request, env: Env): Promise { 39 | const url = new URL(request.url) 40 | const name = url.searchParams.get("name") 41 | const password = url.searchParams.get("password") 42 | if (name === null || password === null) { 43 | throw new WorkerError(400, `missing name or password (password) in searchParams`) 44 | } 45 | 46 | const metadata = await getPasteMetadata(env, name) 47 | if (metadata === null) { 48 | throw new WorkerError(404, `paste of name ‘${name}’ is not found`) 49 | } 50 | if (password !== metadata.passwd) { 51 | throw new WorkerError(403, `incorrect password for paste ‘${name}’`) 52 | } 53 | 54 | const multipartUpload = await env.R2.createMultipartUpload(name) 55 | const resp: MPUCreateResponse = { 56 | name, 57 | key: multipartUpload.key, 58 | uploadId: multipartUpload.uploadId, 59 | } 60 | return new Response(JSON.stringify(resp)) 61 | } 62 | 63 | // PUT /mpu/resume?key=&uploadId=&partNumber= 64 | // return JSON { partNumber: number, etag: string } 65 | export async function handleMPUResume(request: Request, env: Env): Promise { 66 | const url = new URL(request.url) 67 | 68 | const uploadId = url.searchParams.get("uploadId") 69 | const partNumberString = url.searchParams.get("partNumber") 70 | const key = url.searchParams.get("key") 71 | if (partNumberString === null || uploadId === null || key === null) { 72 | throw new WorkerError(400, "missing partNumber or uploadId or key in searchParams") 73 | } 74 | if (request.body === null) { 75 | throw new WorkerError(400, "missing request body") 76 | } 77 | 78 | const partNumber = parseInt(partNumberString) 79 | const multipartUpload = env.R2.resumeMultipartUpload(key, uploadId) 80 | const uploadedPart: R2UploadedPart = await multipartUpload.uploadPart(partNumber, request.body) 81 | return new Response(JSON.stringify(uploadedPart)) 82 | } 83 | 84 | // POST /mpu/complete?name=&key=&uploadId= 85 | // formdata same as POST/PUT a normal paste, but 86 | // - field `c` is interpreted as JSON { partNumber: number, etag: string }[] 87 | // - field `n` is ignored 88 | export async function handleMPUComplete(request: Request, env: Env, completeBody: R2UploadedPart[]): Promise { 89 | const url = new URL(request.url) 90 | const uploadId = url.searchParams.get("uploadId") 91 | const key = url.searchParams.get("key") 92 | const name = url.searchParams.get("name") 93 | if (uploadId === null || key === null || name === null) { 94 | throw new WorkerError(400, `no uploadId or key for MPU complete`) 95 | } 96 | 97 | const multipartUpload = env.R2.resumeMultipartUpload(key, uploadId) 98 | if (name !== multipartUpload.key) { 99 | throw new WorkerError(400, `name ‘${name}’ is not consistent with the originally specified name`) 100 | } 101 | 102 | const object = await multipartUpload.complete(completeBody) 103 | if (object.size > parseSize(env.R2_MAX_ALLOWED)!) { 104 | await env.R2.delete(object.key) 105 | throw new WorkerError(413, `payload too large (max ${parseSize(env.R2_MAX_ALLOWED)!} bytes allowed)`) 106 | } 107 | return object 108 | } 109 | -------------------------------------------------------------------------------- /frontend/components/PasteInputPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody, CardProps, Tab, Tabs } from "@heroui/react" 2 | import React, { useRef, useState, DragEvent } from "react" 3 | import { formatSize } from "../utils/utils.js" 4 | import { XIcon } from "./icons.js" 5 | import { cardOverrides, tst } from "../utils/overrides.js" 6 | import { CodeEditor } from "./CodeEditor.js" 7 | 8 | export type EditKind = "edit" | "file" 9 | 10 | export type PasteEditState = { 11 | editKind: EditKind 12 | editContent: string 13 | editFilename?: string 14 | editHighlightLang?: string 15 | file: File | null 16 | } 17 | 18 | interface PasteEditorProps extends CardProps { 19 | isPasteLoading: boolean 20 | state: PasteEditState 21 | onStateChange: (state: PasteEditState) => void 22 | } 23 | 24 | export function PasteInputPanel({ isPasteLoading, state, onStateChange, ...rest }: PasteEditorProps) { 25 | const fileInput = useRef(null) 26 | const [isDragged, setDragged] = useState(false) 27 | 28 | function setFile(file: File | null) { 29 | onStateChange({ ...state, editKind: "file", file }) 30 | } 31 | 32 | function onDrop(e: DragEvent) { 33 | e.preventDefault() 34 | const items = e.dataTransfer?.items 35 | if (items) { 36 | for (let i = 0; i < items.length; i++) { 37 | if (items[i].kind === "file") { 38 | const file = items[i].getAsFile()! 39 | setFile(file) 40 | break 41 | } 42 | } 43 | } 44 | setDragged(false) 45 | } 46 | 47 | return ( 48 | 49 | 50 | { 60 | onStateChange({ ...state, editKind: k as EditKind }) 61 | }} 62 | > 63 | {/*Possibly a bug of chrome, but Tab sometimes has a transient unexpected scrollbar when resizing*/} 64 | 65 | onStateChange({ ...state, editContent: k })} 68 | lang={state.editHighlightLang} 69 | setLang={(lang) => onStateChange({ ...state, editHighlightLang: lang })} 70 | filename={state.editFilename} 71 | setFilename={(name) => onStateChange({ ...state, editFilename: name })} 72 | disabled={isPasteLoading} 73 | placeholder={isPasteLoading ? "Loading..." : "Edit your paste here"} 74 | /> 75 | 76 | 77 |
setDragged(true)} 86 | onDragLeave={() => setDragged(false)} 87 | onDragOver={(e) => { 88 | e.preventDefault() 89 | setDragged(true) 90 | }} 91 | onClick={() => fileInput.current?.click()} 92 | > 93 | { 98 | const files = e.target.files 99 | if (files && files.length) { 100 | setFile(files[0]) 101 | } 102 | }} 103 | /> 104 |
Select File
105 |

106 | 107 | {state.file !== null 108 | ? `${state.file.name} (${formatSize(state.file.size)})` 109 | : "Click or drag & drop file here"} 110 | 111 |

112 | {state.file && ( 113 | { 118 | e.stopPropagation() 119 | setFile(null) 120 | }} 121 | /> 122 | )} 123 |
124 |
125 |
126 |
127 |
128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /frontend/components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes } from "react" 2 | 3 | export const MoonIcon = (props: HTMLAttributes) => ( 4 | 13 | 18 | 19 | ) 20 | 21 | export const SunIcon = (props: HTMLAttributes) => ( 22 | 30 | 35 | 36 | ) 37 | 38 | export const ComputerIcon = (props: HTMLAttributes) => ( 39 | 47 | 52 | 53 | ) 54 | 55 | export const XIcon = (props: HTMLAttributes) => ( 56 | 64 | 69 | 70 | ) 71 | 72 | export const DownloadIcon = (props: HTMLAttributes) => ( 73 | 81 | 86 | 87 | ) 88 | 89 | export const CopyIcon = (props: HTMLAttributes) => ( 90 | 98 | 103 | 104 | ) 105 | 106 | export const CheckIcon = (props: HTMLAttributes) => ( 107 | 115 | 116 | 117 | ) 118 | 119 | export const InfoIcon = (props: HTMLAttributes) => ( 120 | 128 | 133 | 134 | ) 135 | 136 | export const HomeIcon = (props: HTMLAttributes) => ( 137 | 145 | 150 | 151 | ) 152 | -------------------------------------------------------------------------------- /shared/uploadPaste.ts: -------------------------------------------------------------------------------- 1 | // we will move this file to a shared directory later 2 | 3 | import { MPUCreateResponse, PasteResponse } from "./interfaces.js" 4 | import type { EncryptionScheme } from "../frontend/utils/encryption.js" 5 | import { parsePath } from "./parsers.js" 6 | 7 | export class UploadError extends Error { 8 | public statusCode: number 9 | 10 | constructor(statusCode: number, msg: string) { 11 | super(msg) 12 | this.statusCode = statusCode 13 | } 14 | } 15 | 16 | export type UploadOptions = { 17 | content: File 18 | isUpdate: boolean 19 | 20 | // we allow it to be undefined for convenience 21 | isPrivate?: boolean 22 | 23 | password?: string 24 | name?: string 25 | 26 | highlightLanguage?: string 27 | encryptionScheme?: EncryptionScheme 28 | expire?: string 29 | manageUrl?: string 30 | } 31 | 32 | // note that apiUrl should be manageUrl when isUpload 33 | export async function uploadNormal( 34 | apiUrl: string, 35 | { 36 | content, 37 | isUpdate, 38 | isPrivate, 39 | password, 40 | name, 41 | highlightLanguage, 42 | encryptionScheme, 43 | expire, 44 | manageUrl, 45 | }: UploadOptions, 46 | ): Promise { 47 | const fd = new FormData() 48 | 49 | // typescript cannot handle overload on union types 50 | fd.set("c", content) 51 | 52 | if (isUpdate && manageUrl === undefined) { 53 | throw TypeError("uploadMPU: no manageUrl specified in update") 54 | } 55 | 56 | if (expire !== undefined) fd.set("e", expire) 57 | if (password !== undefined) fd.set("s", password) 58 | if (!isUpdate && name !== undefined) fd.set("n", name) 59 | if (encryptionScheme !== undefined) fd.set("encryption-scheme", encryptionScheme) 60 | if (highlightLanguage !== undefined) fd.set("lang", highlightLanguage) 61 | if (isPrivate) fd.set("p", "1") 62 | 63 | const resp = isUpdate 64 | ? await fetch(manageUrl!, { 65 | method: "PUT", 66 | body: fd, 67 | }) 68 | : await fetch(apiUrl, { 69 | method: "POST", 70 | body: fd, 71 | }) 72 | 73 | if (!resp.ok) { 74 | throw new UploadError(resp.status, await resp.text()) 75 | } 76 | 77 | return await resp.json() 78 | } 79 | 80 | export async function uploadMPU( 81 | apiUrl: string, 82 | chunkSize: number, 83 | { 84 | content, 85 | isUpdate, 86 | isPrivate, 87 | password, 88 | name, 89 | highlightLanguage, 90 | encryptionScheme, 91 | expire, 92 | manageUrl, 93 | }: UploadOptions, 94 | progressCallback?: (doneBytes: number, allBytes: number) => void, 95 | ) { 96 | const createReqUrl = isUpdate ? new URL(`${apiUrl}/mpu/create-update`) : new URL(`${apiUrl}/mpu/create`) 97 | if (!isUpdate) { 98 | if (name !== undefined) { 99 | createReqUrl.searchParams.set("n", name) 100 | } 101 | if (isPrivate) { 102 | createReqUrl.searchParams.set("p", "1") 103 | } 104 | } else { 105 | if (manageUrl === undefined) { 106 | throw TypeError("uploadMPU: no manageUrl specified in update") 107 | } 108 | const { name: nameFromUrl, password: passwordFromUrl } = parsePath(new URL(manageUrl).pathname) 109 | if (passwordFromUrl === undefined) { 110 | throw TypeError("uploadMPU: password not specified in manageUrl") 111 | } 112 | createReqUrl.searchParams.set("name", nameFromUrl) 113 | createReqUrl.searchParams.set("password", passwordFromUrl) 114 | } 115 | 116 | const createReqResp = await fetch(createReqUrl, { method: "POST" }) 117 | if (!createReqResp.ok) { 118 | throw new UploadError(createReqResp.status, await createReqResp.text()) 119 | } 120 | const createResp: MPUCreateResponse = await createReqResp.json() 121 | 122 | const numParts = Math.ceil(content.size / chunkSize) 123 | 124 | // TODO: parallelize 125 | const uploadedParts: R2UploadedPart[] = [] 126 | let uploadedBytes = 0 127 | for (let i = 0; i < numParts; i++) { 128 | const resumeUrl = new URL(`${apiUrl}/mpu/resume`) 129 | resumeUrl.searchParams.set("key", createResp.key) 130 | resumeUrl.searchParams.set("uploadId", createResp.uploadId) 131 | resumeUrl.searchParams.set("partNumber", (i + 1).toString()) // because partNumber need to nonzero 132 | const chunk = content.slice(i * chunkSize, (i + 1) * chunkSize) 133 | const resumeReqResp = await fetch(resumeUrl, { method: "PUT", body: chunk }) 134 | if (!resumeReqResp.ok) { 135 | throw new UploadError(resumeReqResp.status, await resumeReqResp.text()) 136 | } 137 | const resumeResp: R2UploadedPart = await resumeReqResp.json() 138 | uploadedParts.push(resumeResp) 139 | uploadedBytes += chunk.size 140 | if (progressCallback) { 141 | progressCallback(uploadedBytes, content.size) 142 | } 143 | } 144 | 145 | const completeFormData = new FormData() 146 | const completeUrl = new URL(`${apiUrl}/mpu/complete`) 147 | completeUrl.searchParams.set("name", createResp.name) 148 | completeUrl.searchParams.set("key", createResp.key) 149 | completeUrl.searchParams.set("uploadId", createResp.uploadId) 150 | completeFormData.set("c", new File([JSON.stringify(uploadedParts)], content.name)) 151 | if (expire !== undefined) { 152 | completeFormData.set("e", expire) 153 | } 154 | if (password !== undefined) { 155 | completeFormData.set("s", password) 156 | } 157 | if (highlightLanguage !== undefined) { 158 | completeFormData.set("lang", highlightLanguage) 159 | } 160 | if (encryptionScheme !== undefined) { 161 | completeFormData.set("encryption-scheme", encryptionScheme) 162 | } 163 | const completeReqResp = await fetch(completeUrl, { 164 | method: isUpdate ? "PUT" : "POST", 165 | body: completeFormData, 166 | }) 167 | if (!completeReqResp.ok) { 168 | throw new UploadError(completeReqResp.status, await completeReqResp.text()) 169 | } 170 | const completeResp: PasteResponse = await completeReqResp.json() 171 | return completeResp 172 | } 173 | -------------------------------------------------------------------------------- /frontend/components/PasteSettingPanel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardBody, 4 | CardHeader, 5 | CardProps, 6 | Divider, 7 | Input, 8 | mergeClasses, 9 | Radio, 10 | RadioGroup, 11 | Switch, 12 | Tooltip, 13 | } from "@heroui/react" 14 | import { BaseUrl, verifyExpiration, verifyManageUrl, verifyName } from "../utils/utils.js" 15 | import React from "react" 16 | import { InfoIcon } from "./icons.js" 17 | import { cardOverrides, inputOverrides, radioOverrides, switchOverrides, tst } from "../utils/overrides.js" 18 | 19 | export type UploadKind = "short" | "long" | "custom" | "manage" 20 | 21 | export type PasteSetting = { 22 | uploadKind: UploadKind 23 | expiration: string 24 | password: string 25 | name: string 26 | manageUrl: string 27 | 28 | doEncrypt: boolean 29 | } 30 | 31 | interface PasteSettingPanelProps extends CardProps { 32 | setting: PasteSetting 33 | onSettingChange: (setting: PasteSetting) => void 34 | } 35 | 36 | export function PanelSettingsPanel({ setting, onSettingChange, ...rest }: PasteSettingPanelProps) { 37 | const radioClassNames = mergeClasses(radioOverrides, { labelWrapper: "ml-2.5" }) 38 | return ( 39 | 40 | Settings 41 | 42 | 43 |
44 | onSettingChange({ ...setting, expiration: e })} 57 | isInvalid={!verifyExpiration(setting.expiration)[0]} 58 | errorMessage={verifyExpiration(setting.expiration)[1]} 59 | description={verifyExpiration(setting.expiration)[1]} 60 | /> 61 | onSettingChange({ ...setting, password: p })} 67 | classNames={inputOverrides} 68 | placeholder={"Generated randomly"} 69 | description="Used to update/delete your paste" 70 | /> 71 |
72 | onSettingChange({ ...setting, uploadKind: v as UploadKind })} 76 | > 77 | 78 | Generate a short random URL 79 | 80 | 88 | Generate a long random URL 89 | 90 | 91 | Set by your own 92 | 93 | {setting.uploadKind === "custom" ? ( 94 | onSettingChange({ ...setting, name: n })} 97 | type="text" 98 | classNames={radioClassNames} 99 | isInvalid={!verifyName(setting.name)[0]} 100 | errorMessage={verifyName(setting.name)[1]} 101 | startContent={ 102 |
103 | {`${BaseUrl}/~`} 104 |
105 | } 106 | /> 107 | ) : null} 108 | 109 |
Update or delete
110 |
111 | {setting.uploadKind === "manage" ? ( 112 | onSettingChange({ ...setting, manageUrl: m })} 115 | type="text" 116 | className="shrink" 117 | isInvalid={!verifyManageUrl(setting.manageUrl)[0]} 118 | errorMessage={verifyManageUrl(setting.manageUrl)[1]} 119 | placeholder={`Manage URL`} 120 | /> 121 | ) : null} 122 |
123 | 124 |
125 | onSettingChange({ ...setting, doEncrypt: v })} 129 | > 130 | Client-side encryption 131 | 132 | 135 |

Client-side encryption

136 |
137 | Your paste is shared via a URL containing the decryption key in the URL hash, which is never sent to 138 | the server. Decryption happens in the browser, so only those with the key (not the server) can view 139 | the decrypted content. 140 |
141 |
142 | } 143 | > 144 | 145 | 146 | 147 |
148 |
149 | ) 150 | } 151 | -------------------------------------------------------------------------------- /worker/test/basic.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, it, expect, describe } from "vitest" 2 | 3 | import { genRandStr } from "../common" 4 | 5 | import { 6 | genRandomBlob, 7 | areBlobsEqual, 8 | workerFetch, 9 | upload, 10 | BASE_URL, 11 | RAND_NAME_REGEX, 12 | uploadExpectStatus, 13 | staticPages, 14 | addRole, 15 | } from "./testUtils" 16 | import { createExecutionContext } from "cloudflare:test" 17 | import { DEFAULT_PASSWD_LEN, PASTE_NAME_LEN } from "../../shared/constants" 18 | import { parsePath } from "../../shared/parsers" 19 | 20 | describe("upload", () => { 21 | const blob1 = genRandomBlob(1024) 22 | const ctx = createExecutionContext() 23 | 24 | it("should upload", async () => { 25 | // upload 26 | const responseJson = await upload(ctx, { c: blob1 }) 27 | 28 | // check url 29 | const url: string = responseJson.url 30 | expect(url.startsWith(BASE_URL)) 31 | 32 | // check name 33 | const name: string = url.slice(BASE_URL.length + 1) 34 | expect(name.length).toStrictEqual(PASTE_NAME_LEN) 35 | expect(RAND_NAME_REGEX.test(name)) 36 | 37 | // check manageUrl 38 | const manageUrl: string = responseJson.manageUrl 39 | expect(manageUrl).toBeDefined() 40 | expect(manageUrl.startsWith(BASE_URL)) 41 | expect(manageUrl.slice(BASE_URL.length + 1, manageUrl.lastIndexOf(":"))).toStrictEqual(name) 42 | 43 | // check passwd 44 | const passwd = manageUrl.slice(manageUrl.lastIndexOf(":") + 1) 45 | expect(passwd.length).toStrictEqual(DEFAULT_PASSWD_LEN) 46 | }) 47 | 48 | it("should return original paste", async () => { 49 | const resp = await upload(ctx, { c: blob1 }) 50 | const revisitSesponse = await workerFetch(ctx, resp.url) 51 | expect(revisitSesponse.status).toStrictEqual(200) 52 | expect(await areBlobsEqual(await revisitSesponse.blob(), blob1)).toStrictEqual(true) 53 | }) 54 | 55 | it("should return 404 for non-existent", async () => { 56 | const resp = await upload(ctx, { c: blob1 }) 57 | const name: string = resp.url.slice(BASE_URL.length + 1) 58 | let newName 59 | do { 60 | newName = genRandStr(PASTE_NAME_LEN) 61 | } while (newName === name) // roll until finding a different name 62 | const missingResponse = await workerFetch(ctx, new Request(`${BASE_URL}/${newName}`)) 63 | expect(missingResponse.status).toStrictEqual(404) 64 | }) 65 | }) 66 | 67 | describe("update", () => { 68 | const blob1 = genRandomBlob(1024) 69 | const blob2 = new Blob(["hello"]) 70 | const passwd1 = "7365ca6eac619ca3f118" 71 | const ctx = createExecutionContext() 72 | 73 | it("should disallow modify with wrong manageUrl", async () => { 74 | const resp = await upload(ctx, { c: blob1 }) 75 | await uploadExpectStatus(ctx, { c: blob2 }, 403, { method: "PUT", url: `${resp.url}:${passwd1}` }) 76 | }) 77 | 78 | it("should allow modify with manageUrl", async () => { 79 | const resp = await upload(ctx, { c: blob1 }) 80 | 81 | const putResponseJson = await upload(ctx, { c: blob2 }, { method: "PUT", url: resp.manageUrl }) 82 | expect(putResponseJson.url).toStrictEqual(resp.url) 83 | expect(putResponseJson.manageUrl).toStrictEqual(resp.manageUrl) 84 | 85 | // check visit modified 86 | const revisitModifiedResponse = await workerFetch(ctx, resp.url) 87 | expect(revisitModifiedResponse.status).toStrictEqual(200) 88 | const revisitBlob = await revisitModifiedResponse.blob() 89 | expect(await areBlobsEqual(revisitBlob, blob2)).toStrictEqual(true) 90 | }) 91 | }) 92 | 93 | describe("delete", () => { 94 | const blob1 = genRandomBlob(1024) 95 | const passwd1 = "7365ca6eac619ca3f118" 96 | const ctx = createExecutionContext() 97 | 98 | it("should disallow delete with wrong manageUrl", async () => { 99 | const resp = await upload(ctx, { c: blob1 }) 100 | // check delete with wrong manageUrl 101 | expect((await workerFetch(ctx, new Request(`${resp.url}:${passwd1}`, { method: "DELETE" }))).status).toStrictEqual( 102 | 403, 103 | ) 104 | }) 105 | 106 | it("should allow delete with wrong manageUrl", async () => { 107 | const resp = await upload(ctx, { c: blob1 }) 108 | 109 | const deleteResponse = await workerFetch(ctx, new Request(resp.manageUrl, { method: "DELETE" })) 110 | expect(deleteResponse.status).toStrictEqual(200) 111 | 112 | // check visit deleted 113 | const revisitDeletedResponse = await workerFetch(ctx, resp.url) 114 | expect(revisitDeletedResponse.status).toStrictEqual(404) 115 | }) 116 | }) 117 | 118 | test("static pages", async () => { 119 | const ctx = createExecutionContext() 120 | for (const page of staticPages) { 121 | const url = `${BASE_URL}/${page}` 122 | expect((await workerFetch(ctx, url)).status, `visiting ${url}`).toStrictEqual(200) 123 | } 124 | }) 125 | 126 | test("GET special static pages", async () => { 127 | const blob1 = genRandomBlob(1024) 128 | const ctx = createExecutionContext() 129 | const resp = await upload(ctx, { c: blob1 }) 130 | 131 | // test display page 132 | const decUrl = addRole(resp.url, "d") 133 | const { name } = parsePath(new URL(resp.url).pathname) 134 | const decResp = await workerFetch(ctx, decUrl) 135 | expect(decResp.status, `visiting ${decUrl}`).toStrictEqual(200) 136 | expect(decResp.headers.get("Content-Type")).toStrictEqual("text/html;charset=UTF-8") 137 | 138 | const testPairs = [ 139 | [name, name], 140 | [name + ".jpg", name + ".jpg"], 141 | [name + ".jpg?lang=cpp", name + ".jpg"], 142 | [name + "/a.jpg", name + "/a.jpg"], 143 | ] 144 | for (const [accessPath, expectedTitle] of testPairs) { 145 | const resp = await (await workerFetch(ctx, `${BASE_URL}/d/${accessPath}`)).text() 146 | expect( 147 | resp.includes(`/ ${expectedTitle}`), 148 | `testing access ${accessPath}, returning ${resp}`, 149 | ).toStrictEqual(true) 150 | } 151 | 152 | // test manage page 153 | const manageUrl = resp.manageUrl 154 | const manageResp = await workerFetch(ctx, manageUrl) 155 | expect(manageResp.status, `visiting ${manageUrl}`).toStrictEqual(200) 156 | expect(manageResp.headers.get("Content-Type")).toStrictEqual("text/html;charset=UTF-8") 157 | }) 158 | -------------------------------------------------------------------------------- /worker/test/controlHeaders.spec.ts: -------------------------------------------------------------------------------- 1 | import { createExecutionContext, env } from "cloudflare:test" 2 | import { afterEach, beforeEach, expect, test, vi } from "vitest" 3 | 4 | import { BASE_URL, genRandomBlob, upload, workerFetch } from "./testUtils" 5 | 6 | test("mime type", async () => { 7 | const ctx = createExecutionContext() 8 | const url = (await upload(ctx, { c: genRandomBlob(1024) })).url 9 | 10 | const url_pic = (await upload(ctx, { c: { content: genRandomBlob(1024), filename: "xx.jpg" } })).url 11 | 12 | async function testMime(accessUrl: string, mime: string) { 13 | const resp = await workerFetch(ctx, accessUrl) 14 | expect(resp.headers.get("Content-Type")).toStrictEqual(mime) 15 | } 16 | 17 | await testMime(url, "text/plain;charset=UTF-8") 18 | await testMime(`${url}.jpg`, "image/jpeg") 19 | await testMime(`${url}/test.jpg`, "image/jpeg") 20 | await testMime(`${url}?mime=random-mime`, "random-mime") 21 | await testMime(`${url}.jpg?mime=random-mime`, "random-mime") 22 | await testMime(`${url}/test.jpg?mime=random-mime`, "random-mime") 23 | 24 | await testMime(url_pic, "image/jpeg") 25 | await testMime(`${url_pic}.png`, "image/png") 26 | 27 | // test disallowed mimetypes 28 | await testMime(`${url_pic}.html`, "text/plain;charset=UTF-8") 29 | await testMime(`${url_pic}?mime=text/html`, "text/plain;charset=UTF-8") 30 | }) 31 | 32 | test("cache control", async () => { 33 | beforeEach(vi.useFakeTimers) 34 | afterEach(vi.useRealTimers) 35 | const t1 = new Date(2035, 0, 0) 36 | vi.setSystemTime(t1) 37 | 38 | const ctx = createExecutionContext() 39 | const uploadResp = await upload(ctx, { c: genRandomBlob(1024) }) 40 | const url = uploadResp["url"] 41 | const resp = await workerFetch(ctx, url) 42 | expect(resp.headers.has("Last-Modified")).toStrictEqual(true) 43 | expect(new Date(resp.headers.get("Last-Modified")!).getTime()).toStrictEqual(t1.getTime()) 44 | 45 | if ("CACHE_PASTE_AGE" in env) { 46 | expect(resp.headers.get("Cache-Control")).toStrictEqual(`public, max-age=${env.CACHE_PASTE_AGE}`) 47 | } else { 48 | expect(resp.headers.get("Cache-Control")).toBeUndefined() 49 | } 50 | 51 | const indexResp = await workerFetch(ctx, BASE_URL) 52 | if ("CACHE_STATIC_PAGE_AGE" in env) { 53 | expect(indexResp.headers.get("Cache-Control")).toStrictEqual(`public, max-age=${env.CACHE_STATIC_PAGE_AGE}`) 54 | } else { 55 | expect(indexResp.headers.get("Cache-Control")).toBeUndefined() 56 | } 57 | 58 | const t2 = new Date(2035, 0, 1) 59 | const staleResp = await workerFetch( 60 | ctx, 61 | new Request(url, { 62 | headers: { 63 | "If-Modified-Since": t2.toUTCString(), 64 | }, 65 | }), 66 | ) 67 | expect(staleResp.status).toStrictEqual(304) 68 | }) 69 | 70 | test("content disposition without specifying filename", async () => { 71 | const content = "hello" // not using Blob here, since FormData.append() automatically add filename for Blob 72 | const filename = "hello.jpg" 73 | const ctx = createExecutionContext() 74 | 75 | const uploadResp = await upload(ctx, { c: content }) 76 | const url = uploadResp["url"] 77 | 78 | expect( 79 | (await workerFetch(ctx, url)).headers.get("Access-Control-Expose-Headers")?.includes("Content-Disposition"), 80 | ).toStrictEqual(true) 81 | expect((await workerFetch(ctx, url)).headers.get("Content-Disposition")).toStrictEqual("inline") 82 | expect((await workerFetch(ctx, `${url}?a`)).headers.get("Content-Disposition")).toStrictEqual("attachment") 83 | 84 | expect((await workerFetch(ctx, `${url}/${filename}`)).headers.get("Content-Disposition")).toStrictEqual( 85 | `inline; filename*=UTF-8''${filename}`, 86 | ) 87 | expect((await workerFetch(ctx, `${url}/${filename}?a`)).headers.get("Content-Disposition")).toStrictEqual( 88 | `attachment; filename*=UTF-8''${filename}`, 89 | ) 90 | }) 91 | 92 | test("content disposition with specifying filename", async () => { 93 | const content = genRandomBlob(1024) 94 | const filename = "りんご たいへん.jpg" 95 | const filenameEncoded = encodeURIComponent(filename) 96 | const altFilename = "التفاح" 97 | const altFilenameEncoded = encodeURIComponent(altFilename) 98 | 99 | const ctx = createExecutionContext() 100 | 101 | const uploadResp = await upload(ctx, { c: { content, filename } }) 102 | const url = uploadResp.url 103 | 104 | expect((await workerFetch(ctx, url)).headers.get("Content-Disposition")).toStrictEqual( 105 | `inline; filename*=UTF-8''${filenameEncoded}`, 106 | ) 107 | expect((await workerFetch(ctx, `${url}?a`)).headers.get("Content-Disposition")).toStrictEqual( 108 | `attachment; filename*=UTF-8''${filenameEncoded}`, 109 | ) 110 | 111 | expect((await workerFetch(ctx, `${url}/${altFilename}`)).headers.get("Content-Disposition")).toStrictEqual( 112 | `inline; filename*=UTF-8''${altFilenameEncoded}`, 113 | ) 114 | expect((await workerFetch(ctx, `${url}/${altFilename}?a`)).headers.get("Content-Disposition")).toStrictEqual( 115 | `attachment; filename*=UTF-8''${altFilenameEncoded}`, 116 | ) 117 | }) 118 | 119 | test("other HTTP methods", async () => { 120 | const ctx = createExecutionContext() 121 | const resp = await workerFetch( 122 | ctx, 123 | new Request(BASE_URL, { 124 | method: "PATCH", 125 | }), 126 | ) 127 | expect(resp.status).toStrictEqual(405) 128 | expect(resp.headers.has("Allow")).toStrictEqual(true) 129 | }) 130 | 131 | test("option method", async () => { 132 | const ctx = createExecutionContext() 133 | 134 | const resp = await workerFetch( 135 | ctx, 136 | new Request(BASE_URL, { 137 | method: "OPTIONS", 138 | headers: { 139 | Origin: "https://example.com", 140 | "Access-Control-Request-Method": "PUT", 141 | }, 142 | }), 143 | ) 144 | expect(resp.status).toStrictEqual(200) 145 | expect(resp.headers.has("Access-Control-Allow-Origin")).toStrictEqual(true) 146 | expect(resp.headers.has("Access-Control-Allow-Methods")).toStrictEqual(true) 147 | expect(resp.headers.has("Access-Control-Max-Age")).toStrictEqual(true) 148 | 149 | const resp1 = await workerFetch( 150 | ctx, 151 | new Request(BASE_URL, { 152 | method: "OPTIONS", 153 | headers: { 154 | Origin: "https://example.com", 155 | }, 156 | }), 157 | ) 158 | expect(resp1.status).toStrictEqual(200) 159 | expect(resp1.headers.has("Allow")).toStrictEqual(true) 160 | }) 161 | -------------------------------------------------------------------------------- /worker/test/uploadOptions.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest" 2 | import { 3 | addRole, 4 | areBlobsEqual, 5 | BASE_URL, 6 | genRandomBlob, 7 | RAND_NAME_REGEX, 8 | upload, 9 | uploadExpectStatus, 10 | workerFetch, 11 | } from "./testUtils" 12 | import { createExecutionContext, env } from "cloudflare:test" 13 | import { MetaResponse } from "../../shared/interfaces" 14 | import { MAX_PASSWD_LEN, MIN_PASSWD_LEN, PRIVATE_PASTE_NAME_LEN } from "../../shared/constants" 15 | import { parseExpiration } from "../../shared/parsers" 16 | 17 | test("privacy url with option p", async () => { 18 | const blob1 = genRandomBlob(1024) 19 | const ctx = createExecutionContext() 20 | 21 | // upload 22 | const responseJson = await upload(ctx, { c: blob1, p: "1" }) 23 | 24 | // check url 25 | const url = responseJson["url"] 26 | expect(url.startsWith(BASE_URL)) 27 | 28 | // check name 29 | const name = url.slice(BASE_URL.length + 1) 30 | expect(name.length).toStrictEqual(PRIVATE_PASTE_NAME_LEN) 31 | expect(RAND_NAME_REGEX.test(name)) 32 | 33 | // check revisit 34 | const revisitSesponse = await workerFetch(ctx, url) 35 | expect(revisitSesponse.status).toStrictEqual(200) 36 | expect(await areBlobsEqual(await revisitSesponse.blob(), blob1)).toStrictEqual(true) 37 | }) 38 | 39 | test("expire with option e", async () => { 40 | const blob1 = genRandomBlob(1024) 41 | const ctx = createExecutionContext() 42 | 43 | async function testExpireParse(expire: string, expireSecs: number | null) { 44 | const responseJson = await upload(ctx, { c: blob1, e: expire }) 45 | expect(responseJson["expirationSeconds"]).toStrictEqual(expireSecs) 46 | } 47 | 48 | const maxExpirationSeconds = parseExpiration(env.MAX_EXPIRATION)! 49 | const defaultExpirationSeconds = parseExpiration(env.DEFAULT_EXPIRATION)! 50 | await testExpireParse("1000", 1000) 51 | await testExpireParse("100m", 6000) 52 | await testExpireParse("100h", 360000) 53 | await testExpireParse("1d", 86400) 54 | await testExpireParse("100d", maxExpirationSeconds) // longer expiration will be clipped to 30d 55 | await testExpireParse("100 m", 6000) 56 | await testExpireParse("", defaultExpirationSeconds) 57 | 58 | const testFailParse = async (expire: string) => { 59 | await uploadExpectStatus(ctx, { c: blob1, e: expire }, 400) 60 | } 61 | 62 | await testFailParse("abc") 63 | await testFailParse("1c") 64 | await testFailParse("-100m") 65 | }) 66 | 67 | test("custom path with option n", async () => { 68 | const blob1 = genRandomBlob(1024) 69 | const ctx = createExecutionContext() 70 | 71 | // check bad names 72 | const badNames = ["a", "ab", "..."] 73 | for (const name of badNames) { 74 | await uploadExpectStatus(ctx, { c: blob1, n: name }, 400) 75 | } 76 | 77 | // check good name upload 78 | const goodName = "goodName123+_-[]*$@,;" 79 | const uploadResponseJson = await upload(ctx, { 80 | c: blob1, 81 | n: goodName, 82 | }) 83 | expect(uploadResponseJson["url"]).toStrictEqual(`${BASE_URL}/~${goodName}`) 84 | 85 | // check revisit 86 | const revisitResponse = await workerFetch(ctx, uploadResponseJson["url"]) 87 | expect(revisitResponse.status).toStrictEqual(200) 88 | expect(await areBlobsEqual(await revisitResponse.blob(), blob1)).toStrictEqual(true) 89 | }) 90 | 91 | test("custom passwd with option s", async () => { 92 | const blob1 = genRandomBlob(1024) 93 | const ctx = createExecutionContext() 94 | 95 | // check good name upload 96 | const passwd = "1366eaa20c071763dc94" 97 | const wrongPasswd = "7365ca6eac619ca3f118" 98 | const uploadResponseJson = await upload(ctx, { c: blob1, s: passwd }) 99 | const url = uploadResponseJson.url 100 | const manageUrl = uploadResponseJson.manageUrl 101 | const parsedPasswd = manageUrl.slice(manageUrl.lastIndexOf(":") + 1) 102 | expect(parsedPasswd).toStrictEqual(passwd) 103 | 104 | // check password format verification 105 | await uploadExpectStatus(ctx, { c: blob1, s: "1".repeat(MIN_PASSWD_LEN - 1) }, 400) 106 | await uploadExpectStatus(ctx, { c: blob1, s: "1".repeat(MIN_PASSWD_LEN) + "\n" }, 400) 107 | await uploadExpectStatus(ctx, { c: blob1, s: "1".repeat(MAX_PASSWD_LEN + 1) }, 400) 108 | 109 | // check modify with wrong manageUrl 110 | await uploadExpectStatus(ctx, { c: blob1 }, 403, { method: "PUT", url: `${url}:${wrongPasswd}` }) 111 | 112 | // check modify 113 | const putResponseJson = await upload(ctx, { c: blob1, s: wrongPasswd }, { method: "PUT", url: manageUrl }) 114 | expect(putResponseJson.url).toStrictEqual(url) // url will not change 115 | expect(putResponseJson.manageUrl).toStrictEqual(`${url}:${wrongPasswd}`) // passwd may change 116 | }) 117 | 118 | test("encryption with option encryption-scheme", async () => { 119 | const blob1 = genRandomBlob(1024) 120 | const ctx = createExecutionContext() 121 | 122 | // check good name upload 123 | const uploadResponseJson = await upload(ctx, { 124 | c: { content: blob1, filename: "a.pdf" }, 125 | "encryption-scheme": "AES-GCM", 126 | }) 127 | const url = uploadResponseJson.url 128 | 129 | const fetchPaste = await workerFetch(ctx, url) 130 | await fetchPaste.bytes() 131 | expect(fetchPaste.headers.get("Content-Type")).toStrictEqual("application/octet-stream") 132 | expect(fetchPaste.headers.get("Content-Disposition")).toStrictEqual("inline; filename*=UTF-8''a.pdf.encrypted") 133 | expect(fetchPaste.headers.get("X-PB-Encryption-Scheme")).toStrictEqual("AES-GCM") 134 | expect(fetchPaste.headers.get("Access-Control-Expose-Headers")?.includes("X-PB-Encryption-Scheme")).toStrictEqual( 135 | true, 136 | ) 137 | 138 | // fetch with filename, now the content-disposition and content-type should be changed 139 | const fetchPasteWithFilename = await workerFetch(ctx, url + "/b.pdf") 140 | await fetchPasteWithFilename.bytes() 141 | expect(fetchPasteWithFilename.headers.get("Content-Disposition")).toStrictEqual("inline; filename*=UTF-8''b.pdf") 142 | expect(fetchPasteWithFilename.headers.get("Content-Type")).toStrictEqual("application/pdf") 143 | 144 | // fetch with ext, now only the content-type is chaanged 145 | const fetchPasteWithExt = await workerFetch(ctx, url + ".pdf") 146 | await fetchPasteWithExt.bytes() 147 | expect(fetchPasteWithExt.headers.get("Content-Disposition")).toStrictEqual("inline; filename*=UTF-8''a.pdf.encrypted") 148 | expect(fetchPasteWithExt.headers.get("Content-Type")).toStrictEqual("application/pdf") 149 | 150 | const fetchMeta: MetaResponse = await (await workerFetch(ctx, addRole(url, "m"))).json() 151 | expect(fetchMeta.encryptionScheme).toStrictEqual("AES-GCM") 152 | }) 153 | 154 | test("highlight with option lang", async () => { 155 | const blob1 = genRandomBlob(1024) 156 | const ctx = createExecutionContext() 157 | const lang = "cpp" 158 | 159 | const uploadResp = await upload(ctx, { c: blob1, lang: lang }) 160 | const metaResp: MetaResponse = await (await workerFetch(ctx, addRole(uploadResp.url, "m"))).json() 161 | expect(metaResp.highlightLanguage).toStrictEqual(lang) 162 | 163 | const getResp = await workerFetch(ctx, uploadResp.url) 164 | expect(getResp.headers.get("X-PB-Highlight-Language")).toStrictEqual(lang) 165 | expect(getResp.headers.get("Access-Control-Expose-Headers")?.includes("X-PB-Highlight-Language")).toStrictEqual(true) 166 | expect(metaResp.highlightLanguage).toStrictEqual(lang) 167 | }) 168 | -------------------------------------------------------------------------------- /frontend/components/CodeEditor.tsx: -------------------------------------------------------------------------------- 1 | // inspired by https://css-tricks.com/creating-an-editable-textarea-that-supports-syntax-highlighted-code/ 2 | 3 | import React, { useEffect, useRef, useState } from "react" 4 | import { Autocomplete, AutocompleteItem, Input, Select, SelectItem } from "@heroui/react" 5 | 6 | import { autoCompleteOverrides, inputOverrides, selectOverrides, tst } from "../utils/overrides.js" 7 | import { useHLJS, highlightHTML } from "../utils/HighlightLoader.js" 8 | 9 | import "../styles/highlight-theme-light.css" 10 | import "../styles/highlight-theme-dark.css" 11 | 12 | // TODO: 13 | // - line number 14 | // - clear button 15 | interface CodeInputProps extends React.HTMLProps { 16 | content: string 17 | setContent: (code: string) => void 18 | lang?: string 19 | setLang: (lang?: string) => void 20 | filename?: string 21 | setFilename: (filename?: string) => void 22 | placeholder?: string 23 | disabled?: boolean 24 | } 25 | 26 | interface TabSetting { 27 | char: "tab" | "space" 28 | width: 2 | 4 | 8 29 | } 30 | 31 | function formatTabSetting(s: TabSetting, forHuman: boolean) { 32 | if (forHuman) { 33 | if (s.char === "tab") { 34 | return `Tab: ${s.width}` 35 | } else { 36 | return `Spaces: ${s.width}` 37 | } 38 | } else { 39 | return `${s.char} ${s.width}` 40 | } 41 | } 42 | 43 | function parseTabSetting(s: string): TabSetting | undefined { 44 | const match = s.match(/^(tab|space) ([24])$/) 45 | if (match) { 46 | return { char: match[1] as TabSetting["char"], width: parseInt(match[2]) as TabSetting["width"] } 47 | } else { 48 | return undefined 49 | } 50 | } 51 | 52 | const tabSettings: TabSetting[] = [ 53 | { char: "tab", width: 2 }, 54 | { char: "tab", width: 4 }, 55 | { char: "tab", width: 8 }, 56 | { char: "space", width: 2 }, 57 | { char: "space", width: 4 }, 58 | { char: "space", width: 8 }, 59 | ] 60 | 61 | function handleNewLines(str: string): string { 62 | if (str.at(-1) === "\n") { 63 | str += " " 64 | } 65 | return str 66 | } 67 | 68 | export function CodeEditor({ 69 | content, 70 | setContent, 71 | lang, 72 | setLang, 73 | filename, 74 | setFilename, 75 | placeholder, 76 | disabled, 77 | className, 78 | ...rest 79 | }: CodeInputProps) { 80 | const refHighlighting = useRef(null) 81 | const refTextarea = useRef(null) 82 | const refLineNumbers = useRef(null) 83 | 84 | const [heightPx, setHeightPx] = useState(0) 85 | const hljs = useHLJS() 86 | const [tabSetting, setTabSettings] = useState({ char: "space", width: 2 }) 87 | 88 | const lineCount = (content?.match(/\n/g)?.length || 0) + 1 89 | 90 | function syncScroll() { 91 | refHighlighting.current!.scrollLeft = refTextarea.current!.scrollLeft 92 | refHighlighting.current!.scrollTop = refTextarea.current!.scrollTop 93 | if (refLineNumbers.current) { 94 | refLineNumbers.current.scrollTop = refTextarea.current!.scrollTop 95 | } 96 | } 97 | 98 | function handleInput(_: React.FormEvent) { 99 | const editing = refTextarea.current! 100 | setContent(editing.value) 101 | syncScroll() 102 | } 103 | 104 | function handleKeyDown(event: React.KeyboardEvent) { 105 | const element = refTextarea.current! 106 | if (event.key === "Tab") { 107 | event.preventDefault() // stop normal 108 | const beforeTab = content.slice(0, element.selectionStart) 109 | const afterTab = content.slice(element.selectionEnd, element.value.length) 110 | const insertedString = tabSetting.char === "tab" ? "\t" : " ".repeat(tabSetting.width) 111 | const curPos = element.selectionStart + insertedString.length 112 | setContent(beforeTab + insertedString + afterTab) 113 | // move cursor 114 | element.selectionStart = curPos 115 | element.selectionEnd = curPos 116 | } else if (event.key === "Escape") { 117 | element.blur() 118 | } 119 | } 120 | 121 | useEffect(() => { 122 | setHeightPx(refTextarea.current!.clientHeight) 123 | const observer = new ResizeObserver((entries) => { 124 | for (const entry of entries) { 125 | if (entry.contentRect) { 126 | setHeightPx(entry.contentRect.height) 127 | } 128 | } 129 | }) 130 | 131 | observer.observe(refTextarea.current!) 132 | 133 | return () => { 134 | observer.disconnect() 135 | } 136 | }, []) 137 | 138 | const lineNumOffset = `${Math.floor(Math.log10(lineCount)) + 3}ch` 139 | 140 | return ( 141 |
142 |
143 | 151 | ({ key: lang })) : []} 157 | // we must not use undefined here to avoid conversion from uncontrolled component to controlled component 158 | selectedKey={hljs && lang && hljs.listLanguages().includes(lang) ? lang : ""} 159 | onSelectionChange={(key) => { 160 | setLang((key as string) || undefined) // when key is empty string, convert back to undefined 161 | }} 162 | > 163 | {(language) => {language.key}} 164 | 165 | 179 |
180 |
181 |
185 |
186 |

192 |             
200 |               {Array.from({ length: lineCount }, (_, idx) => {
201 |                 return 
202 |               })}
203 |             
204 |           
205 | 220 |
221 |
222 |
223 | ) 224 | } 225 | -------------------------------------------------------------------------------- /frontend/pages/PasteBin.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useTransition } from "react" 2 | 3 | import { Button, Link } from "@heroui/react" 4 | 5 | import { DarkModeToggle, useDarkModeSelection } from "../components/DarkModeToggle.js" 6 | import { useErrorModal } from "../components/ErrorModal.js" 7 | import { PanelSettingsPanel, PasteSetting } from "../components/PasteSettingPanel.js" 8 | import { UploadedPanel } from "../components/UploadedPanel.js" 9 | import { PasteInputPanel, PasteEditState } from "../components/PasteInputPanel.js" 10 | 11 | import type { PasteResponse } from "../../shared/interfaces.js" 12 | import { parsePath, parseFilenameFromContentDisposition } from "../../shared/parsers.js" 13 | 14 | import { 15 | verifyExpiration, 16 | verifyManageUrl, 17 | verifyName, 18 | maxExpirationReadable, 19 | BaseUrl, 20 | APIUrl, 21 | } from "../utils/utils.js" 22 | import { uploadPaste } from "../utils/uploader.js" 23 | import { tst } from "../utils/overrides.js" 24 | 25 | import "../style.css" 26 | 27 | export function PasteBin() { 28 | const [editorState, setEditorState] = useState({ 29 | editKind: "edit", 30 | editContent: "", 31 | file: null, 32 | editHighlightLang: "plaintext", 33 | }) 34 | 35 | const [pasteSetting, setPasteSetting] = useState({ 36 | expiration: DEFAULT_EXPIRATION, 37 | manageUrl: "", 38 | name: "", 39 | password: "", 40 | uploadKind: "short", 41 | doEncrypt: false, 42 | }) 43 | 44 | const [pasteResponse, setPasteResponse] = useState(undefined) 45 | const [uploadedEncryptionKey, setUploadedEncryptionKey] = useState(undefined) 46 | 47 | const [isUploadPending, startUpload] = useTransition() 48 | const [loadingProgress, setLoadingProgress] = useState(undefined) 49 | const [isInitPasteLoading, startFetchingInitPaste] = useTransition() 50 | 51 | const [_, modeSelection, setModeSelection] = useDarkModeSelection() 52 | 53 | const { ErrorModal, showModal, handleError, handleFailedResp } = useErrorModal() 54 | 55 | // handle admin URL 56 | useEffect(() => { 57 | // TODO: do not fetch paste for a large file paste 58 | const pathname = location.pathname 59 | // const pathname = new URL("http://localhost:8787/ds2W:ShNkSKdf5rZypdcJEcAdFmw3").pathname 60 | const { name, password, filename, ext } = parsePath(pathname) 61 | 62 | if (password !== undefined && pasteSetting.manageUrl === "") { 63 | setPasteSetting({ 64 | ...pasteSetting, 65 | uploadKind: "manage", 66 | manageUrl: `${APIUrl}/${name}:${password}`, 67 | }) 68 | 69 | let pasteUrl = `${APIUrl}/${name}` 70 | if (filename) pasteUrl = `${pasteUrl}/${filename}` 71 | if (ext) pasteUrl = `${pasteUrl}${ext}` 72 | 73 | startFetchingInitPaste(async () => { 74 | try { 75 | const resp = await fetch(pasteUrl) 76 | if (!resp.ok) { 77 | await handleFailedResp(`Error on Fetching ${pasteUrl}`, resp) 78 | return 79 | } 80 | const contentType = resp.headers.get("Content-Type") 81 | const contentDisp = resp.headers.get("Content-Disposition") 82 | const contentLang = resp.headers.get("X-PB-Highlight-Language") 83 | 84 | let pasteFilename = filename 85 | if (pasteFilename === undefined && contentDisp !== null) { 86 | pasteFilename = parseFilenameFromContentDisposition(contentDisp) 87 | } 88 | 89 | if (contentLang || (contentType && contentType.startsWith("text/"))) { 90 | setEditorState({ 91 | editKind: "edit", 92 | editContent: await resp.text(), 93 | file: null, 94 | editHighlightLang: contentLang || undefined, 95 | editFilename: pasteFilename, 96 | }) 97 | } else { 98 | setEditorState({ 99 | editKind: "file", 100 | editContent: "", 101 | file: new File([await resp.blob()], pasteFilename || "[unknown filename]"), 102 | }) 103 | } 104 | } catch (e) { 105 | handleError(`Error on Fetching ${pasteUrl}`, e as Error) 106 | } 107 | }) 108 | } 109 | }, []) 110 | 111 | function onStartUpload() { 112 | startUpload(async () => { 113 | try { 114 | const uploaded = await uploadPaste(pasteSetting, editorState, setUploadedEncryptionKey, setLoadingProgress) 115 | setPasteResponse(uploaded) 116 | } catch (e) { 117 | handleError("Error on Uploading Paste", e as Error) 118 | } 119 | }) 120 | } 121 | 122 | function onStartDelete() { 123 | startUpload(async () => { 124 | try { 125 | const resp = await fetch(pasteSetting.manageUrl, { method: "DELETE" }) 126 | if (resp.ok) { 127 | showModal("Deleted Successfully", "It may takes 60 seconds for the deletion to propagate to the world") 128 | setPasteResponse(undefined) 129 | } else { 130 | await handleFailedResp("Error on Delete Paste", resp) 131 | } 132 | } catch (e) { 133 | handleError("Error on Delete Paste", e as Error) 134 | } 135 | }) 136 | } 137 | 138 | function canUpload(): boolean { 139 | if (editorState.editKind === "edit" && editorState.editContent.length === 0) { 140 | return false 141 | } else if (editorState.editKind === "file" && editorState.file === null) { 142 | return false 143 | } 144 | 145 | if (verifyExpiration(pasteSetting.expiration)[0]) { 146 | if (pasteSetting.uploadKind === "short" || pasteSetting.uploadKind === "long") { 147 | return true 148 | } else if (pasteSetting.uploadKind === "custom") { 149 | return verifyName(pasteSetting.name)[0] 150 | } else if (pasteSetting.uploadKind === "manage") { 151 | return verifyManageUrl(pasteSetting.manageUrl)[0] 152 | } else { 153 | return false 154 | } 155 | } else { 156 | return false 157 | } 158 | } 159 | 160 | function canDelete(): boolean { 161 | return verifyManageUrl(pasteSetting.manageUrl)[0] 162 | } 163 | 164 | const info = ( 165 |
166 |
167 |

{INDEX_PAGE_TITLE}

168 | 173 |
174 |

An open source pastebin deployed on Cloudflare Workers.

175 |

176 | Usage: Paste text or file here. Upload. Share it with a URL. Or access with our{" "} 177 | 178 | APIs 179 | 180 | . 181 |

182 |

183 | Warning: Only for temporary share (max {maxExpirationReadable}). Files could be deleted without 184 | notice! 185 |

186 |
187 | ) 188 | 189 | const submitter = ( 190 |
191 | 199 | {pasteSetting.uploadKind === "manage" ? ( 200 | 203 | ) : null} 204 |
205 | ) 206 | 207 | const footer = ( 208 |
209 |

210 | 211 | Terms & Conditions 212 | 213 | {" / "} 214 | 215 | Repository 216 | 217 |

218 |
219 | ) 220 | 221 | return ( 222 |
223 |
224 | {info} 225 | 231 |
232 | 237 | {(pasteResponse || isUploadPending) && ( 238 | 245 | )} 246 |
247 | {submitter} 248 |
249 | {footer} 250 | 251 |
252 | ) 253 | } 254 | -------------------------------------------------------------------------------- /worker/handlers/handleRead.ts: -------------------------------------------------------------------------------- 1 | import { decode, isLegalUrl, WorkerError } from "../common.js" 2 | import { getDocPage } from "../pages/docs.js" 3 | import { verifyAuth } from "../pages/auth.js" 4 | import mime from "mime" 5 | import { makeMarkdown } from "../pages/markdown.js" 6 | import { getPaste, getPasteMetadata, PasteMetadata, PasteWithMetadata } from "../storage/storage.js" 7 | import { MetaResponse } from "../../shared/interfaces.js" 8 | import { parsePath } from "../../shared/parsers.js" 9 | import { MAX_URL_REDIRECT_LEN } from "../../shared/constants.js" 10 | 11 | type Headers = Record 12 | 13 | async function decodeMaybeStream(content: ArrayBuffer | ReadableStream): Promise { 14 | if (content instanceof ArrayBuffer) { 15 | return decode(content) 16 | } else { 17 | const reader = content.pipeThrough(new TextDecoderStream()).getReader() 18 | let result = "" 19 | while (true) { 20 | const { done, value } = await reader.read() 21 | if (done) { 22 | break 23 | } 24 | result += value 25 | } 26 | return result 27 | } 28 | } 29 | 30 | function staticPageCacheHeader(env: Env): Headers { 31 | const age = env.CACHE_STATIC_PAGE_AGE 32 | return age ? { "Cache-Control": `public, max-age=${age}` } : {} 33 | } 34 | 35 | function pasteCacheHeader(env: Env): Headers { 36 | const age = env.CACHE_PASTE_AGE 37 | return age ? { "Cache-Control": `public, max-age=${age}` } : {} 38 | } 39 | 40 | function lastModifiedHeader(metadata: PasteMetadata): Headers { 41 | const lastModified = metadata.lastModifiedAtUnix 42 | return lastModified ? { "Last-Modified": new Date(lastModified * 1000).toUTCString() } : {} 43 | } 44 | 45 | async function handleStaticPages(request: Request, env: Env, _: ExecutionContext): Promise { 46 | const url = new URL(request.url) 47 | 48 | let path = url.pathname 49 | if (path.endsWith("/")) { 50 | path += "index.html" 51 | } else if (path.endsWith("/index")) { 52 | path += ".html" 53 | } else if (path.lastIndexOf("/") === 0 && path.indexOf(":") > 0) { 54 | path = "/index.html" // handle admin URL 55 | } 56 | if (path.startsWith("/assets/") || path === "/favicon.ico" || path === "/index.html") { 57 | if (path === "/index.html") { 58 | const authResponse = verifyAuth(request, env) 59 | if (authResponse !== null) { 60 | return authResponse 61 | } 62 | } 63 | const assetsUrl = url 64 | assetsUrl.pathname = path 65 | const resp = await env.ASSETS.fetch(assetsUrl) 66 | if (resp.status === 404) { 67 | throw new WorkerError(404, `asset '${path}' not found`) 68 | } else { 69 | const pageMime = mime.getType(path) || "text/plain" 70 | return new Response(await resp.blob(), { 71 | headers: { 72 | "Content-Type": `${pageMime};charset=UTF-8`, 73 | ...staticPageCacheHeader(env), 74 | }, 75 | }) 76 | } 77 | } 78 | 79 | const staticPageContent = getDocPage(url.pathname, env) 80 | if (staticPageContent) { 81 | // access to all static pages requires auth 82 | const authResponse = verifyAuth(request, env) 83 | if (authResponse !== null) { 84 | return authResponse 85 | } 86 | return new Response(staticPageContent, { 87 | headers: { 88 | "Content-Type": "text/html;charset=UTF-8", 89 | ...staticPageCacheHeader(env), 90 | }, 91 | }) 92 | } 93 | 94 | return null 95 | } 96 | 97 | async function getPasteWithoutContent(env: Env, name: string): Promise { 98 | const metadata = await getPasteMetadata(env, name) 99 | return metadata && { paste: new ArrayBuffer(), metadata } 100 | } 101 | 102 | export async function handleGet(request: Request, env: Env, ctx: ExecutionContext, isHead: boolean): Promise { 103 | // TODO: handle etag 104 | const staticPageResp = await handleStaticPages(request, env, ctx) 105 | if (staticPageResp !== null) { 106 | return staticPageResp 107 | } 108 | 109 | const url = new URL(request.url) 110 | 111 | const { role, name, ext, filename } = parsePath(url.pathname) 112 | 113 | const disp = url.searchParams.has("a") ? "attachment" : "inline" 114 | 115 | // when not isHead, always need to get paste unless "m" 116 | // when isHead, no need to get paste unless "u" 117 | const shouldGetPasteContent = (!isHead && role !== "m" && role !== "d") || (isHead && role === "u") 118 | 119 | const item: PasteWithMetadata | null = shouldGetPasteContent 120 | ? await getPaste(env, name, ctx) 121 | : await getPasteWithoutContent(env, name) 122 | 123 | // when paste is not found 124 | if (item === null) { 125 | throw new WorkerError(404, `paste of name '${name}' not found`) 126 | } 127 | 128 | let inferred_mime = 129 | url.searchParams.get("mime") || 130 | (ext && mime.getType(ext)) || 131 | (item.metadata.encryptionScheme && "application/octet-stream") || 132 | (item.metadata.filename && mime.getType(item.metadata.filename)) || 133 | "text/plain;charset=UTF-8" 134 | 135 | if (env.DISALLOWED_MIME_FOR_PASTE.includes(inferred_mime)) { 136 | inferred_mime = "text/plain;charset=UTF-8" 137 | } 138 | 139 | // check `if-modified-since` 140 | const pasteLastModifiedUnix = item.metadata.lastModifiedAtUnix 141 | const headerModifiedSince = request.headers.get("If-Modified-Since") 142 | if (headerModifiedSince) { 143 | const headerModifiedSinceUnix = Date.parse(headerModifiedSince) / 1000 144 | if (pasteLastModifiedUnix <= headerModifiedSinceUnix) { 145 | return new Response(null, { 146 | status: 304, // Not Modified 147 | headers: lastModifiedHeader(item.metadata), 148 | }) 149 | } 150 | } 151 | 152 | // determine filename with priority: url path > meta 153 | let returnFilename = filename || item.metadata?.filename 154 | if (returnFilename && !filename && item.metadata.encryptionScheme) { 155 | returnFilename = returnFilename + ".encrypted" // to avoid clients choose open method with extension 156 | } 157 | 158 | // handle URL redirection 159 | if (role === "u") { 160 | if (item.metadata.sizeBytes > MAX_URL_REDIRECT_LEN) { 161 | throw new WorkerError(400, `URL too long to be redirected (max ${MAX_URL_REDIRECT_LEN} bytes)`) 162 | } 163 | const redirectURL = await decodeMaybeStream(item.paste) 164 | if (isLegalUrl(redirectURL)) { 165 | return Response.redirect(redirectURL) 166 | } else { 167 | throw new WorkerError(400, "cannot parse paste content as a legal URL") 168 | } 169 | } 170 | 171 | // handle article (render as markdown) 172 | if (role === "a") { 173 | return new Response(shouldGetPasteContent ? makeMarkdown(await decodeMaybeStream(item.paste)) : null, { 174 | headers: { 175 | "Content-Type": `text/html;charset=UTF-8`, 176 | ...pasteCacheHeader(env), 177 | ...lastModifiedHeader(item.metadata), 178 | }, 179 | }) 180 | } 181 | 182 | // handle metadata access 183 | if (role === "m") { 184 | const returnedMetadata: MetaResponse = { 185 | lastModifiedAt: new Date(item.metadata.lastModifiedAtUnix * 1000).toISOString(), 186 | createdAt: new Date(item.metadata.createdAtUnix * 1000).toISOString(), 187 | expireAt: new Date(item.metadata.willExpireAtUnix * 1000).toISOString(), 188 | sizeBytes: item.metadata.sizeBytes, 189 | location: item.metadata.location, 190 | filename: item.metadata.filename, 191 | highlightLanguage: item.metadata.highlightLanguage, 192 | encryptionScheme: item.metadata.encryptionScheme, 193 | } 194 | return new Response(isHead ? null : JSON.stringify(returnedMetadata, null, 2), { 195 | headers: { 196 | "Content-Type": `application/json;charset=UTF-8`, 197 | ...pasteCacheHeader(env), 198 | ...lastModifiedHeader(item.metadata), 199 | }, 200 | }) 201 | } 202 | 203 | // handle encrypted 204 | if (role === "d") { 205 | const pageUrl = url 206 | pageUrl.search = "" 207 | pageUrl.pathname = "/display.html" 208 | const page = decode(await (await env.ASSETS.fetch(pageUrl)).arrayBuffer()).replace( 209 | "{{PASTE_NAME}}", 210 | name + (filename ? "/" + filename : ext ? ext : ""), 211 | ) 212 | return new Response(isHead ? null : page, { 213 | headers: { 214 | "Content-Type": `text/html;charset=UTF-8`, 215 | ...pasteCacheHeader(env), 216 | ...lastModifiedHeader(item.metadata), 217 | }, 218 | }) 219 | } 220 | 221 | // handle default 222 | const headers: Headers = { 223 | "Content-Type": `${inferred_mime}`, 224 | ...pasteCacheHeader(env), 225 | ...lastModifiedHeader(item.metadata), 226 | } 227 | const exposeHeaders = ["Content-Disposition"] 228 | 229 | if (item.metadata.encryptionScheme) { 230 | headers["X-PB-Encryption-Scheme"] = item.metadata.encryptionScheme 231 | exposeHeaders.push("X-PB-Encryption-Scheme") 232 | } 233 | 234 | if (item.metadata.highlightLanguage) { 235 | headers["X-PB-Highlight-Language"] = item.metadata.highlightLanguage 236 | exposeHeaders.push("X-PB-Highlight-Language") 237 | } 238 | 239 | if (item.httpEtag) { 240 | headers["etag"] = item.httpEtag 241 | } 242 | 243 | if (returnFilename) { 244 | const encodedFilename = encodeURIComponent(returnFilename) 245 | headers["Content-Disposition"] = `${disp}; filename*=UTF-8''${encodedFilename}` 246 | } else { 247 | headers["Content-Disposition"] = `${disp}` 248 | } 249 | headers["Access-Control-Expose-Headers"] = exposeHeaders.join(", ") 250 | 251 | // if content is nonempty, Content-Length will be set automatically 252 | if (!shouldGetPasteContent) { 253 | headers["Content-Length"] = item.metadata.sizeBytes.toString() 254 | } 255 | return new Response(shouldGetPasteContent ? item.paste : null, { headers }) 256 | } 257 | -------------------------------------------------------------------------------- /worker/handlers/handleWrite.ts: -------------------------------------------------------------------------------- 1 | import { verifyAuth } from "../pages/auth.js" 2 | import { decode, genRandStr, WorkerError } from "../common.js" 3 | import { createPaste, getPasteMetadata, pasteNameAvailable, updatePaste } from "../storage/storage.js" 4 | import { 5 | DEFAULT_PASSWD_LEN, 6 | NAME_REGEX, 7 | PASTE_NAME_LEN, 8 | PRIVATE_PASTE_NAME_LEN, 9 | PASSWD_SEP, 10 | MIN_PASSWD_LEN, 11 | MAX_PASSWD_LEN, 12 | } from "../../shared/constants.js" 13 | import { parsePath, parseSize, parseExpiration } from "../../shared/parsers.js" 14 | import { PasteResponse } from "../../shared/interfaces.js" 15 | import { MaxFileSizeExceededError, MultipartParseError, parseMultipartRequest } from "@mjackson/multipart-parser" 16 | import { handleMPUComplete, handleMPUCreate, handleMPUCreateUpdate, handleMPUResume } from "./handleMPU.js" 17 | 18 | type ParsedMultipartPart = { 19 | filename?: string 20 | content: ReadableStream | ArrayBuffer 21 | contentAsString: () => string 22 | contentLength: number 23 | } 24 | 25 | async function multipartToMap(req: Request, sizeLimit: number): Promise> { 26 | const partsMap = new Map() 27 | try { 28 | await parseMultipartRequest(req, { maxFileSize: sizeLimit }, async (part) => { 29 | if (part.name) { 30 | if (part.isFile) { 31 | const arrayBuffer = await part.arrayBuffer() 32 | partsMap.set(part.name, { 33 | filename: part.filename, 34 | content: arrayBuffer, 35 | contentLength: arrayBuffer.byteLength, 36 | contentAsString: () => decode(arrayBuffer), 37 | }) 38 | } else { 39 | const arrayBuffer = await part.arrayBuffer() 40 | partsMap.set(part.name, { 41 | filename: part.filename, 42 | content: arrayBuffer, 43 | contentAsString: () => decode(arrayBuffer), 44 | contentLength: arrayBuffer.byteLength, 45 | }) 46 | } 47 | } 48 | }) 49 | } catch (err) { 50 | if (err instanceof MaxFileSizeExceededError) { 51 | throw new WorkerError(413, `payload too large (max ${sizeLimit} bytes allowed)`) 52 | } else if (err instanceof MultipartParseError) { 53 | console.error(err) 54 | throw new WorkerError(400, "Failed to parse multipart request") 55 | } else { 56 | throw err 57 | } 58 | } 59 | return partsMap 60 | } 61 | 62 | export async function handlePostOrPut( 63 | request: Request, 64 | env: Env, 65 | _: ExecutionContext, 66 | isPut: boolean, 67 | ): Promise { 68 | if (!isPut) { 69 | // only POST requires auth, since PUT request already contains auth 70 | const authResponse = verifyAuth(request, env) 71 | if (authResponse !== null) { 72 | return authResponse 73 | } 74 | } 75 | 76 | const url = new URL(request.url) 77 | 78 | let isMPUComplete = false 79 | if (url.pathname === "/mpu/create" && !isPut) { 80 | return handleMPUCreate(request, env) 81 | } else if (url.pathname === "/mpu/create-update" && !isPut) { 82 | return handleMPUCreateUpdate(request, env) 83 | } else if (url.pathname === "/mpu/resume" && isPut) { 84 | return handleMPUResume(request, env) 85 | } else if (url.pathname === "/mpu/complete") { 86 | isMPUComplete = true // we will handle mpu complete later since it is uploaded with formdata 87 | } else if (url.pathname.startsWith("/mpu/")) { 88 | throw new WorkerError(400, "illegal mpu operation") 89 | } 90 | 91 | const contentType = request.headers.get("Content-Type") || "" 92 | 93 | // parse formdata 94 | if (!contentType.includes("multipart/form-data")) { 95 | throw new WorkerError(400, `bad usage, please use 'multipart/form-data' instead of ${contentType}`) 96 | } 97 | 98 | const parts = await multipartToMap(request, parseSize(env.R2_MAX_ALLOWED)!) 99 | 100 | if (!parts.has("c")) { 101 | throw new WorkerError(400, "cannot find content in formdata") 102 | } 103 | const { filename, content, contentAsString, contentLength } = parts.get("c")! 104 | const nameFromForm = parts.get("n")?.contentAsString() 105 | const isPrivate = parts.has("p") 106 | const passwdFromForm = parts.get("s")?.contentAsString() 107 | const expireFromForm: string | undefined = parts.get("e")?.contentAsString() 108 | const encryptionScheme: string | undefined = parts.get("encryption-scheme")?.contentAsString() 109 | const highlightLanguage = parts.get("lang")?.contentAsString() 110 | const expire = expireFromForm ? expireFromForm : env.DEFAULT_EXPIRATION 111 | 112 | const uploadedParts = isMPUComplete ? (JSON.parse(contentAsString()) as R2UploadedPart[]) : undefined 113 | 114 | // parse expiration 115 | let expirationSeconds = parseExpiration(expire) 116 | if (expirationSeconds === null) { 117 | throw new WorkerError(400, `‘${expire}’ is not a valid expiration specification`) 118 | } 119 | const maxExpiration = parseExpiration(env.MAX_EXPIRATION)! 120 | if (expirationSeconds > maxExpiration) { 121 | expirationSeconds = maxExpiration 122 | } 123 | 124 | // check if password is legal 125 | // TODO: sync checks to frontend 126 | if (passwdFromForm) { 127 | if (passwdFromForm.length > MAX_PASSWD_LEN) { 128 | throw new WorkerError(400, `password too long (${passwdFromForm.length} > ${MAX_PASSWD_LEN})`) 129 | } else if (passwdFromForm.length < MIN_PASSWD_LEN) { 130 | throw new WorkerError(400, `password too short (${passwdFromForm.length} < ${MIN_PASSWD_LEN})`) 131 | } else if (passwdFromForm.includes("\n")) { 132 | throw new WorkerError(400, `password should not contain newline`) 133 | } 134 | } 135 | 136 | // check if name is legal 137 | if (nameFromForm !== undefined && isPut) { 138 | throw new WorkerError(400, `Cannot set name for a PUT request`) 139 | } 140 | if (nameFromForm !== undefined && !NAME_REGEX.test(nameFromForm)) { 141 | throw new WorkerError(400, `Name ${nameFromForm} not satisfying regexp ${NAME_REGEX}`) 142 | } 143 | 144 | function makeResponse(created: PasteResponse, additionalHeaders: Record = {}): Response { 145 | return new Response(JSON.stringify(created, null, 2), { 146 | headers: { "Content-Type": "application/json;charset=UTF-8", ...additionalHeaders }, 147 | }) 148 | } 149 | 150 | function accessUrl(short: string): string { 151 | return env.DEPLOY_URL + "/" + short 152 | } 153 | 154 | function manageUrl(short: string, passwd: string): string { 155 | return env.DEPLOY_URL + "/" + short + PASSWD_SEP + passwd 156 | } 157 | 158 | const now = new Date() 159 | if (isPut) { 160 | let pasteName: string | undefined 161 | let password: string | undefined 162 | // if isMPCComplete, we cannot parse path 163 | if (!isMPUComplete) { 164 | const parsed = parsePath(url.pathname) 165 | if (parsed.password === undefined) { 166 | throw new WorkerError(403, `no password for PUT request`) 167 | } 168 | pasteName = parsed.name 169 | password = parsed.password 170 | } else { 171 | pasteName = url.searchParams.get("name") || undefined 172 | if (pasteName === undefined) { 173 | throw new WorkerError(400, `no name for MPU complete`) 174 | } 175 | } 176 | 177 | const r2Object = isMPUComplete ? await handleMPUComplete(request, env, uploadedParts!) : undefined 178 | 179 | const originalMetadata = await getPasteMetadata(env, pasteName) 180 | if (originalMetadata === null) { 181 | throw new WorkerError(404, `paste of name ‘${pasteName}’ is not found`) 182 | } 183 | 184 | // no need to check password for MPCComplete, it is already checked on creation 185 | if (!isMPUComplete && password !== originalMetadata.passwd) { 186 | throw new WorkerError(403, `incorrect password for paste ‘${pasteName}’`) 187 | } 188 | 189 | const newPasswd = passwdFromForm || originalMetadata.passwd 190 | await updatePaste(env, pasteName, content, originalMetadata, { 191 | expirationSeconds, 192 | now, 193 | passwd: newPasswd, 194 | contentLength: r2Object?.size || contentLength, 195 | filename, 196 | highlightLanguage, 197 | encryptionScheme, 198 | isMPUComplete, 199 | }) 200 | return makeResponse( 201 | { 202 | url: accessUrl(pasteName), 203 | manageUrl: manageUrl(pasteName, newPasswd), 204 | expirationSeconds, 205 | expireAt: new Date(now.getTime() + 1000 * expirationSeconds).toISOString(), 206 | }, 207 | { etag: r2Object?.httpEtag }, 208 | ) 209 | } else { 210 | let pasteName: string | undefined 211 | if (isMPUComplete) { 212 | if (url.searchParams.has("name")) { 213 | pasteName = url.searchParams.get("name")! 214 | } else { 215 | throw new WorkerError(400, `no name for MPU complete`) 216 | } 217 | } else if (nameFromForm !== undefined) { 218 | pasteName = "~" + nameFromForm 219 | if (!(await pasteNameAvailable(env, pasteName))) { 220 | throw new WorkerError(409, `name '${pasteName}' is already used`) 221 | } 222 | } else { 223 | pasteName = genRandStr(isPrivate ? PRIVATE_PASTE_NAME_LEN : PASTE_NAME_LEN) 224 | } 225 | 226 | const r2Object = isMPUComplete ? await handleMPUComplete(request, env, uploadedParts!) : undefined 227 | 228 | const password = passwdFromForm || genRandStr(DEFAULT_PASSWD_LEN) 229 | await createPaste(env, pasteName, content, { 230 | expirationSeconds, 231 | now, 232 | passwd: password, 233 | filename, 234 | highlightLanguage, 235 | contentLength: r2Object?.size || contentLength, 236 | encryptionScheme, 237 | isMPUComplete, 238 | }) 239 | 240 | return makeResponse( 241 | { 242 | url: accessUrl(pasteName), 243 | manageUrl: manageUrl(pasteName, password), 244 | expirationSeconds, 245 | expireAt: new Date(now.getTime() + 1000 * expirationSeconds).toISOString(), 246 | }, 247 | { etag: r2Object?.httpEtag }, 248 | ) 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /worker/storage/storage.ts: -------------------------------------------------------------------------------- 1 | import { dateToUnix, workerAssert, WorkerError } from "../common.js" 2 | import { parseSize } from "../../shared/parsers.js" 3 | import { PasteLocation } from "../../shared/interfaces.js" 4 | 5 | // since CF does not allow expiration shorter than 60s, extend the expiration to 70s 6 | const PASTE_EXPIRE_SPECIFIED_MIN = 70 7 | 8 | /* Since we need the metadata stored in KV to perform R2 cleanup, 9 | the paste in KV should not be deleted until it is cleaned in R2. 10 | We extend the lifetime by 2 days to avoid it being cleaned in VK too early 11 | */ 12 | const PASTE_EXPIRE_EXTENSION_FOR_R2 = 2 * 24 * 60 * 60 13 | 14 | // TODO: allow admin to upload permanent paste 15 | // TODO: add filename length check 16 | export type PasteMetadata = { 17 | schemaVersion: 1 18 | location: PasteLocation // new field on V1 19 | passwd: string 20 | 21 | lastModifiedAtUnix: number 22 | createdAtUnix: number 23 | willExpireAtUnix: number 24 | 25 | accessCounter: number // a counter representing how frequent it is accessed, to administration usage 26 | sizeBytes: number 27 | filename?: string 28 | highlightLanguage?: string 29 | encryptionScheme?: string 30 | } 31 | 32 | type PasteMetadataInStorage = { 33 | schemaVersion: number 34 | location?: PasteLocation 35 | passwd: string 36 | 37 | lastModifiedAtUnix: number 38 | createdAtUnix: number 39 | willExpireAtUnix: number 40 | 41 | accessCounter?: number 42 | sizeBytes?: number 43 | filename?: string 44 | highlightLanguage?: string 45 | encryptionScheme?: string 46 | } 47 | 48 | function migratePasteMetadata(original: PasteMetadataInStorage): PasteMetadata { 49 | return { 50 | schemaVersion: 1, 51 | location: original.location || "KV", 52 | passwd: original.passwd, 53 | 54 | lastModifiedAtUnix: original.lastModifiedAtUnix, 55 | createdAtUnix: original.createdAtUnix, 56 | willExpireAtUnix: original.willExpireAtUnix, 57 | 58 | accessCounter: original.accessCounter || 0, 59 | sizeBytes: original.sizeBytes || 0, 60 | filename: original.filename, 61 | highlightLanguage: original.highlightLanguage, 62 | encryptionScheme: original.encryptionScheme, 63 | } 64 | } 65 | 66 | export type PasteWithMetadata = { 67 | paste: ArrayBuffer | ReadableStream 68 | metadata: PasteMetadata 69 | httpEtag?: string 70 | } 71 | 72 | async function updateAccessCounter(env: Env, short: string, value: ArrayBuffer, metadata: PasteMetadata) { 73 | // update counter with probability 1% 74 | if (Math.random() < 0.01) { 75 | metadata.accessCounter += 1 76 | try { 77 | await env.PB.put(short, value, { 78 | metadata: metadata, 79 | expiration: metadata.willExpireAtUnix, 80 | }) 81 | } catch (e) { 82 | // ignore rate limit message 83 | if (!(e as Error).message.includes("KV PUT failed: 429 Too Many Requests")) { 84 | throw e 85 | } 86 | } 87 | } 88 | } 89 | 90 | export async function getPaste(env: Env, short: string, ctx: ExecutionContext): Promise { 91 | const item = await env.PB.getWithMetadata(short, { 92 | type: "arrayBuffer", 93 | }) 94 | 95 | if (item.value === null) { 96 | return null 97 | } else { 98 | workerAssert(item.metadata != null, `paste of name '${short}' has no metadata`) 99 | const metadata = migratePasteMetadata(item.metadata) 100 | const expired = metadata.willExpireAtUnix < new Date().getTime() / 1000 101 | 102 | ctx.waitUntil( 103 | (async () => { 104 | if (expired) { 105 | await deletePaste(env, short, metadata) 106 | return null 107 | } 108 | await updateAccessCounter(env, short, item.value!, metadata) 109 | })(), 110 | ) 111 | 112 | if (expired) { 113 | return null 114 | } 115 | 116 | if (metadata.location === "R2") { 117 | const object = await env.R2.get(short) 118 | if (object === null) { 119 | return null 120 | } 121 | return { paste: object.body, metadata, httpEtag: object.httpEtag } 122 | } else { 123 | return { paste: item.value, metadata } 124 | } 125 | } 126 | } 127 | 128 | // we separate usage of getPasteMetadata and getPaste to make access metric more reliable 129 | export async function getPasteMetadata(env: Env, short: string): Promise { 130 | const item = await env.PB.getWithMetadata(short, { 131 | type: "stream", 132 | }) 133 | 134 | if (item.value === null) { 135 | return null 136 | } else if (item.metadata === null) { 137 | throw new WorkerError(500, `paste of name '${short}' has no metadata`) 138 | } else { 139 | if (item.metadata.willExpireAtUnix < new Date().getTime() / 1000) { 140 | return null 141 | } 142 | return migratePasteMetadata(item.metadata) 143 | } 144 | } 145 | 146 | interface WriteOptions { 147 | now: Date 148 | contentLength: number 149 | expirationSeconds: number 150 | passwd: string 151 | filename?: string 152 | highlightLanguage?: string 153 | encryptionScheme?: string 154 | isMPUComplete: boolean 155 | } 156 | 157 | export async function updatePaste( 158 | env: Env, 159 | pasteName: string, 160 | content: ArrayBuffer | ReadableStream, 161 | originalMetadata: PasteMetadata, 162 | options: WriteOptions, 163 | ) { 164 | const expirationUnix = dateToUnix(options.now) + options.expirationSeconds 165 | let expirationUnixSpecified = 166 | dateToUnix(options.now) + Math.max(options.expirationSeconds, PASTE_EXPIRE_SPECIFIED_MIN) 167 | 168 | if (originalMetadata.location === "R2") { 169 | expirationUnixSpecified = expirationUnixSpecified + PASTE_EXPIRE_EXTENSION_FOR_R2 170 | 171 | if (!options.isMPUComplete) { 172 | await env.R2.put(pasteName, content) 173 | } 174 | } 175 | 176 | // if the paste is previous on R2, we keep it on R2 to avoid losing reference to it 177 | const newLocation = 178 | originalMetadata.location === "R2" || options.isMPUComplete || options.contentLength > parseSize(env.R2_THRESHOLD)! 179 | ? "R2" 180 | : "KV" 181 | const metadata: PasteMetadata = { 182 | schemaVersion: 1, 183 | location: newLocation, 184 | filename: options.filename, 185 | highlightLanguage: options.highlightLanguage, 186 | passwd: options.passwd, 187 | 188 | lastModifiedAtUnix: dateToUnix(options.now), 189 | createdAtUnix: originalMetadata.createdAtUnix, 190 | willExpireAtUnix: expirationUnix, 191 | accessCounter: originalMetadata.accessCounter, 192 | sizeBytes: options.contentLength, 193 | encryptionScheme: options.encryptionScheme, 194 | } 195 | 196 | await env.PB.put(pasteName, originalMetadata.location === "R2" ? "" : content, { 197 | metadata: metadata, 198 | expiration: expirationUnixSpecified, 199 | }) 200 | } 201 | 202 | export async function createPaste( 203 | env: Env, 204 | pasteName: string, 205 | content: ArrayBuffer | ReadableStream, 206 | options: WriteOptions, 207 | ) { 208 | const expirationUnix = dateToUnix(options.now) + options.expirationSeconds 209 | 210 | let expirationUnixSpecified = 211 | dateToUnix(options.now) + Math.max(options.expirationSeconds, PASTE_EXPIRE_SPECIFIED_MIN) 212 | 213 | const location = options.isMPUComplete || options.contentLength > parseSize(env.R2_THRESHOLD)! ? "R2" : "KV" 214 | if (location === "R2") { 215 | expirationUnixSpecified = expirationUnixSpecified + PASTE_EXPIRE_EXTENSION_FOR_R2 216 | 217 | if (!options.isMPUComplete) { 218 | await env.R2.put(pasteName, content) 219 | } 220 | } 221 | 222 | const metadata: PasteMetadata = { 223 | schemaVersion: 1, 224 | location: location, 225 | filename: options.filename, 226 | highlightLanguage: options.highlightLanguage, 227 | passwd: options.passwd, 228 | 229 | lastModifiedAtUnix: dateToUnix(options.now), 230 | createdAtUnix: dateToUnix(options.now), 231 | willExpireAtUnix: expirationUnix, 232 | accessCounter: 0, 233 | sizeBytes: options.contentLength, 234 | encryptionScheme: options.encryptionScheme, 235 | } 236 | 237 | await env.PB.put(pasteName, location === "R2" ? "" : content, { 238 | metadata: metadata, 239 | expiration: expirationUnixSpecified, 240 | }) 241 | } 242 | 243 | export async function pasteNameAvailable(env: Env, pasteName: string): Promise { 244 | const item = await env.PB.getWithMetadata(pasteName) 245 | if (item.value == null) { 246 | return true 247 | } else if (item.metadata === null) { 248 | throw new WorkerError(500, `paste of name '${pasteName}' has no metadata`) 249 | } else { 250 | return item.metadata.willExpireAtUnix < new Date().getTime() / 1000 251 | } 252 | } 253 | 254 | export async function deletePaste(env: Env, pasteName: string, originalMetadata: PasteMetadata): Promise { 255 | await env.PB.delete(pasteName) 256 | if (originalMetadata.location === "R2") { 257 | await env.R2.delete(pasteName) 258 | } 259 | } 260 | 261 | export async function cleanExpiredInR2(env: Env, controller: ScheduledController) { 262 | // types generated by wrangler somehow not working, so cast manually 263 | type Listed = { 264 | list_complete: false 265 | keys: KVNamespaceListKey[] 266 | cursor: string 267 | cacheStatus: string | null 268 | } 269 | 270 | const nowUnix = controller.scheduledTime / 1000 271 | 272 | let numCleaned = 0 273 | const r2NamesToClean: string[] = [] 274 | 275 | async function clean() { 276 | await env.R2.delete(r2NamesToClean) 277 | numCleaned += r2NamesToClean.length 278 | r2NamesToClean.length = 0 279 | } 280 | 281 | let cursor: string | null = null 282 | while (true) { 283 | const listed = (await env.PB.list({ cursor })) as Listed 284 | 285 | cursor = listed.cursor 286 | 287 | for (const key of listed.keys) { 288 | if (key.metadata !== undefined) { 289 | const metadata = migratePasteMetadata(key.metadata) 290 | if (metadata.location === "R2" && metadata.willExpireAtUnix < nowUnix) { 291 | r2NamesToClean.push(key.name) 292 | 293 | if (r2NamesToClean.length === 1000) { 294 | await clean() 295 | } 296 | } 297 | } 298 | } 299 | 300 | if (listed.list_complete) break 301 | } 302 | await clean() 303 | 304 | console.log(`${numCleaned} buckets cleaned`) 305 | } 306 | -------------------------------------------------------------------------------- /frontend/pages/DisplayPaste.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react" 2 | 3 | import { Button, CircularProgress, Link, Tooltip } from "@heroui/react" 4 | import chardet from "chardet" 5 | 6 | import { useErrorModal } from "../components/ErrorModal.js" 7 | import { DarkModeToggle, useDarkModeSelection } from "../components/DarkModeToggle.js" 8 | import { DownloadIcon, HomeIcon } from "../components/icons.js" 9 | import { CopyWidget } from "../components/CopyWidget.js" 10 | 11 | import { parseFilenameFromContentDisposition, parsePath } from "../../shared/parsers.js" 12 | import { decodeKey, decrypt, EncryptionScheme } from "../utils/encryption.js" 13 | import { formatSize } from "../utils/utils.js" 14 | import { tst } from "../utils/overrides.js" 15 | import { highlightHTML, useHLJS } from "../utils/HighlightLoader.js" 16 | 17 | import "../style.css" 18 | import "../styles/highlight-theme-light.css" 19 | import "../styles/highlight-theme-dark.css" 20 | 21 | const utf8CompatibleEncodings = ["UTF-8", "ASCII", "ISO-8859-1"] 22 | 23 | export function DisplayPaste() { 24 | const [pasteFile, setPasteFile] = useState(undefined) 25 | const [pasteContentBuffer, setPasteContentBuffer] = useState(undefined) 26 | const [pasteLang, setPasteLang] = useState(undefined) 27 | 28 | const [isFileBinary, setFileBinary] = useState(false) 29 | const [guessedEncoding, setGuessedEncoding] = useState(null) 30 | const [isDecrypted, setDecrypted] = useState<"not encrypted" | "encrypted" | "decrypted">("not encrypted") 31 | const [forceShowBinary, setForceShowBinary] = useState(false) 32 | const showFileContent = pasteFile !== undefined && (!isFileBinary || forceShowBinary) 33 | 34 | const [isLoading, setIsLoading] = useState(false) 35 | 36 | const { ErrorModal, showModal, handleFailedResp } = useErrorModal() 37 | const [_, modeSelection, setModeSelection] = useDarkModeSelection() 38 | const hljs = useHLJS() 39 | 40 | const pasteStringContent = pasteContentBuffer && new TextDecoder().decode(pasteContentBuffer) 41 | 42 | const highlightedHTML = pasteStringContent ? highlightHTML(hljs, pasteLang, pasteStringContent) : "" 43 | const pasteLineCount = (highlightedHTML?.match(/\n/g)?.length || 0) + 1 44 | 45 | // uncomment the following lines for testing 46 | // const url = new URL("http://localhost:8787/GQbf") 47 | const url = new URL(location.toString()) 48 | 49 | const { name, ext, filename } = parsePath(url.pathname) 50 | 51 | useEffect(() => { 52 | const pasteUrl = `${API_URL}/${name}` 53 | 54 | const fetchPaste = async () => { 55 | try { 56 | setIsLoading(true) 57 | const resp = await fetch(pasteUrl) 58 | if (!resp.ok) { 59 | await handleFailedResp("Failed to Fetch Paste", resp) 60 | return 61 | } 62 | 63 | const scheme: EncryptionScheme | null = resp.headers.get("X-PB-Encryption-Scheme") as EncryptionScheme | null 64 | let filenameFromDisp = resp.headers.has("Content-Disposition") 65 | ? parseFilenameFromContentDisposition(resp.headers.get("Content-Disposition")!) || undefined 66 | : undefined 67 | if (filenameFromDisp && scheme !== null) { 68 | filenameFromDisp = filenameFromDisp.replace(/.encrypted$/, "") 69 | } 70 | 71 | const lang = url.searchParams.get("lang") || resp.headers.get("X-PB-Highlight-Language") 72 | 73 | const inferredFilename = filename || (ext && name + ext) || filenameFromDisp 74 | const respBytes = await resp.bytes() 75 | setPasteLang(lang || undefined) 76 | 77 | const keyString = url.hash.slice(1) 78 | if (scheme === null || keyString.length === 0) { 79 | setPasteFile(new File([respBytes], inferredFilename || name)) 80 | setPasteContentBuffer(respBytes) 81 | if (scheme) { 82 | setDecrypted("encrypted") 83 | setFileBinary(true) 84 | } else { 85 | const encoding = chardet.detect(respBytes) 86 | setFileBinary(encoding === null || !utf8CompatibleEncodings.includes(encoding)) 87 | setGuessedEncoding(encoding) 88 | } 89 | } else { 90 | let key: CryptoKey | undefined 91 | try { 92 | key = await decodeKey(scheme, keyString) 93 | } catch { 94 | showModal("Error", `Failed to parse “${keyString}” as ${scheme} key`) 95 | return 96 | } 97 | if (key === undefined) { 98 | showModal("Error", `Failed to parse “${keyString}” as ${scheme} key`) 99 | return 100 | } 101 | 102 | const decrypted = await decrypt(scheme, key, respBytes) 103 | if (decrypted === null) { 104 | showModal("Error", "Failed to decrypt content") 105 | return 106 | } 107 | 108 | setPasteFile(new File([decrypted], inferredFilename || name)) 109 | setPasteContentBuffer(decrypted) 110 | setPasteLang(lang || undefined) 111 | 112 | const encoding = chardet.detect(decrypted) 113 | setFileBinary(encoding === null || !utf8CompatibleEncodings.includes(encoding)) 114 | setDecrypted("decrypted") 115 | setGuessedEncoding(encoding) 116 | } 117 | } finally { 118 | setIsLoading(false) 119 | } 120 | } 121 | fetchPaste().catch((e) => { 122 | showModal(`Error on fetching ${pasteUrl}`, (e as Error).toString()) 123 | console.error(e) 124 | }) 125 | }, []) 126 | 127 | const binaryFileIndicator = pasteFile && ( 128 |
129 |
{`${pasteFile?.name} (${formatSize(pasteFile.size)})`}
130 |
131 | This file seems to be binary or not in UTF-8{guessedEncoding ? ` (${guessedEncoding} guessed). ` : ". "} 132 | 135 |
136 |
137 | ) 138 | 139 | const lineNumOffset = `${Math.floor(Math.log10(pasteLineCount)) + 3}ch` 140 | const buttonClasses = `rounded-full bg-background hover:bg-default-100 ${tst}` 141 | return ( 142 |
145 |
146 |
147 |

148 | 149 | 152 | {INDEX_PAGE_TITLE} 153 | 154 | {" / "} 155 | {name} 156 | 157 | {isDecrypted === "decrypted" ? " (Decrypted)" : isDecrypted === "encrypted" ? " (Encrypted)" : ""} 158 | 159 |

160 | {showFileContent && ( 161 | 162 | pasteStringContent!} /> 163 | 164 | )} 165 | {pasteFile && ( 166 | 167 | 172 | 173 | )} 174 | 175 |
176 |
177 |
178 | {isLoading ? ( 179 |
180 | 184 |
185 | ) : ( 186 | pasteFile && ( 187 |
188 | {showFileContent ? ( 189 | <> 190 |
191 | {pasteFile?.name} 192 | {`(${formatSize(pasteFile.size)})`} 193 | {forceShowBinary && ( 194 | 197 | )} 198 | {pasteLang && {pasteLang}} 199 |
200 |
201 |
206 |                         
212 |                           {Array.from({ length: pasteLineCount }, (_, idx) => {
213 |                             return 
214 |                           })}
215 |                         
216 |                       
217 | 218 | ) : ( 219 | binaryFileIndicator 220 | )} 221 |
222 | ) 223 | )} 224 |
225 |
226 |
227 | 228 |
229 | ) 230 | } 231 | --------------------------------------------------------------------------------