├── .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 | 
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 |
19 | )
20 |
21 | export const SunIcon = (props: HTMLAttributes) => (
22 |
36 | )
37 |
38 | export const ComputerIcon = (props: HTMLAttributes) => (
39 |
53 | )
54 |
55 | export const XIcon = (props: HTMLAttributes) => (
56 |
70 | )
71 |
72 | export const DownloadIcon = (props: HTMLAttributes) => (
73 |
87 | )
88 |
89 | export const CopyIcon = (props: HTMLAttributes) => (
90 |
104 | )
105 |
106 | export const CheckIcon = (props: HTMLAttributes) => (
107 |
117 | )
118 |
119 | export const InfoIcon = (props: HTMLAttributes) => (
120 |
134 | )
135 |
136 | export const HomeIcon = (props: HTMLAttributes) => (
137 |
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 |
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