diff --git a/v2/.gitignore b/v2/.gitignore
new file mode 100644
index 0000000..dafa699
--- /dev/null
+++ b/v2/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+.env
+.env.local
diff --git a/v2/README.md b/v2/README.md
new file mode 100644
index 0000000..715047c
--- /dev/null
+++ b/v2/README.md
@@ -0,0 +1,47 @@
+# SecureBin v2
+
+SecureBin is a Google Chrome extension for interfacing securely with PasteBin.
+Users can encrypt plaintext and have it stored onto PasteBin, where they can copy the link and key to send it to another user for decryption.
+
+## What's New in v2
+
+Version 2 is a complete rewrite of the extension using modern web technologies:
+- **Framework:** React 19 with TypeScript
+- **Styling:** Tailwind CSS v4 and Radix UI primitives for a modern, beautiful, and accessible UI
+- **State Management:** Zustand
+- **Routing:** React Router v7
+- **Bundler:** Vite with `@crxjs/vite-plugin` for optimized extension builds and HMR (Hot Module Replacement)
+
+## Prerequisites
+
+To use this extension, you will need a PasteBin Developer API Key to post your payloads securely.
+1. Sign up or Log in to [PasteBin](https://pastebin.com/).
+2. Navigate to the [API Documentation](https://pastebin.com/doc_api#1) to copy your developer API key.
+3. Open the extension's Settings page to input and save your API key.
+
+## Development
+
+### Setup
+
+Ensure you have Node.js (and `npm`) installed, then install the dependencies:
+
+```bash
+cd v2
+npm install
+```
+
+### Scripts
+
+- `npm run dev`: Start the Vite development server with Hot Module Replacement (HMR). You should load the generated `v2/dist` folder in Chrome.
+- `npm run build`: Build the extension for production.
+- `npm run lint`: Run ESLint to analyze the code and automatically fix format issues.
+- `npm run test`: Run unit tests using Vitest.
+- `npm run test:watch`: Run tests in watch mode.
+- `npm run preview`: Preview the production build.
+
+## Installation
+
+1. First, build the extension using `npm run build` or start the dev server via `npm run dev`.
+2. Open Chrome and navigate to your extensions page: `chrome://extensions/`.
+3. Enable **Developer mode** in the top-right corner.
+4. Click **Load unpacked** and select the `v2/dist` directory.
diff --git a/v2/icons/logo128.png b/v2/icons/logo128.png
new file mode 100755
index 0000000..94a51ab
Binary files /dev/null and b/v2/icons/logo128.png differ
diff --git a/v2/icons/logo192.png b/v2/icons/logo192.png
new file mode 100644
index 0000000..0663a23
Binary files /dev/null and b/v2/icons/logo192.png differ
diff --git a/v2/icons/logo48.png b/v2/icons/logo48.png
new file mode 100755
index 0000000..6608db1
Binary files /dev/null and b/v2/icons/logo48.png differ
diff --git a/v2/index.html b/v2/index.html
new file mode 100644
index 0000000..fa2912c
--- /dev/null
+++ b/v2/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ SecureBin
+
+
+
+
+
+
diff --git a/v2/manifest.json b/v2/manifest.json
new file mode 100644
index 0000000..3d0656c
--- /dev/null
+++ b/v2/manifest.json
@@ -0,0 +1,33 @@
+{
+ "name": "SecureBin: Powerful Pastebin Tool",
+ "description": "Securely encrypt your text on Pastebin.com",
+ "version": "2.0.0",
+ "manifest_version": 3,
+ "action": {
+ "default_popup": "index.html",
+ "default_title": "Open SecureBin"
+ },
+ "icons": {
+ "16": "icons/logo48.png",
+ "48": "icons/logo48.png",
+ "128": "icons/logo128.png"
+ },
+ "background": {
+ "service_worker": "src/background.ts",
+ "type": "module"
+ },
+ "permissions": [
+ "storage",
+ "contextMenus",
+ "clipboardWrite",
+ "clipboardRead",
+ "activeTab"
+ ],
+ "optional_permissions": [
+ ""
+ ],
+ "host_permissions": [
+ "https://pastebin.com/*",
+ "https://cors.securebin.workers.dev/*"
+ ]
+}
diff --git a/v2/package.json b/v2/package.json
new file mode 100644
index 0000000..1c641de
--- /dev/null
+++ b/v2/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "securebin",
+ "version": "2.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "lint": "eslint . --fix",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
+ "@radix-ui/react-select": "^2.1.6",
+ "@radix-ui/react-switch": "^1.1.3",
+ "@radix-ui/react-tooltip": "^1.1.8",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "lucide-react": "^0.475.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-router-dom": "^7.1.0",
+ "tailwind-merge": "^3.0.0",
+ "zustand": "^5.0.0"
+ },
+ "devDependencies": {
+ "@crxjs/vite-plugin": "^2.0.0-beta.30",
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/chrome": "^0.0.287",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "autoprefixer": "^10.4.20",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0",
+ "vitest": "^4.1.2"
+ }
+}
diff --git a/v2/public/securebinlogo.svg b/v2/public/securebinlogo.svg
new file mode 100644
index 0000000..2eb8a46
--- /dev/null
+++ b/v2/public/securebinlogo.svg
@@ -0,0 +1,20 @@
+
+
+ Group
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/v2/public/securebinlogo_dark.svg b/v2/public/securebinlogo_dark.svg
new file mode 100644
index 0000000..1ac491b
--- /dev/null
+++ b/v2/public/securebinlogo_dark.svg
@@ -0,0 +1,20 @@
+
+
+ Group
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/v2/src/App.tsx b/v2/src/App.tsx
new file mode 100644
index 0000000..45191fd
--- /dev/null
+++ b/v2/src/App.tsx
@@ -0,0 +1,81 @@
+import { useEffect } from 'react'
+import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
+import { useStore } from './lib/store'
+import { cn } from './lib/cn'
+import NavBar from './components/NavBar'
+import Editor from './routes/Editor'
+import Settings from './routes/Settings'
+import History from './routes/History'
+import Result from './routes/Result'
+import ApiKeyConfig from './routes/ApiKeyConfig'
+import EncConfig from './routes/EncConfig'
+import Support from './routes/Support'
+
+// Routes we won't try to restore (transient pages)
+const NON_RESTORABLE_ROUTES = new Set(['/', '/home', '/result'])
+
+export default function App() {
+ const { settings, initialized, initialize } = useStore()
+ const isDark = settings.theme === 'dark'
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ useEffect(() => {
+ initialize()
+ }, [initialize])
+
+ useEffect(() => {
+ document.documentElement.classList.toggle('dark', isDark)
+ }, [isDark])
+
+ // Restore last page on popup open (after settings are loaded)
+ useEffect(() => {
+ if (!initialized) return
+ const { page_timeout } = settings
+ if (page_timeout === 0) return
+
+ chrome.storage.session.get(['lastRoute', 'lastRouteTime'], (data) => {
+ const lastRoute: string = data.lastRoute ?? ''
+ const lastRouteTime: number = data.lastRouteTime ?? 0
+ if (!lastRoute || NON_RESTORABLE_ROUTES.has(lastRoute)) return
+
+ const ageMs = Date.now() - lastRouteTime
+ if (page_timeout === -1 || ageMs < page_timeout * 1000) {
+ navigate(lastRoute, { replace: true })
+ }
+ })
+ }, [initialized])
+
+ // Persist current route so we can restore it next time
+ useEffect(() => {
+ if (!initialized) return
+ chrome.storage.session.set({ lastRoute: location.pathname, lastRouteTime: Date.now() })
+ }, [location.pathname, initialized])
+
+ if (!initialized) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ )
+}
diff --git a/v2/src/background.ts b/v2/src/background.ts
new file mode 100644
index 0000000..4d738d4
--- /dev/null
+++ b/v2/src/background.ts
@@ -0,0 +1,54 @@
+import { StorageKey, EncryptionMode } from './lib/constants'
+
+// Initialize defaults on install
+chrome.runtime.onInstalled.addListener(async () => {
+ const data = await chrome.storage.sync.get(StorageKey.SETTINGS)
+ if (!data[StorageKey.SETTINGS]) {
+ await chrome.storage.sync.set({
+ [StorageKey.SETTINGS]: JSON.stringify({
+ apiKey: 'MmU1OGNlMjcyMzllMzRhNzdjNWVmNjVkYmVhOGIyNGQ=',
+ encMode: EncryptionMode.AES_GCM,
+ keyLength: 16,
+ theme: 'light',
+ encryption: false,
+ default_action: 'Post to Pastebin',
+ page_timeout: 30,
+ }),
+ })
+ }
+
+ // Rebuild context menus on install/update to avoid duplicate registration errors
+ chrome.contextMenus.removeAll(() => {
+ chrome.contextMenus.create({
+ id: 'securebinOpen',
+ title: 'Open in SecureBin',
+ contexts: ['selection'],
+ })
+ })
+})
+
+// Also register context menu on startup (service worker restart)
+chrome.runtime.onStartup.addListener(() => {
+ chrome.contextMenus.removeAll(() => {
+ chrome.contextMenus.create({
+ id: 'securebinOpen',
+ title: 'Open in SecureBin',
+ contexts: ['selection'],
+ })
+ })
+})
+
+chrome.contextMenus.onClicked.addListener(async (info) => {
+ const text = info.selectionText
+ if (!text || info.menuItemId !== 'securebinOpen') return
+
+ // Store the selected text so the popup can load it into the editor
+ await chrome.storage.session.set({ pendingText: { text } })
+
+ // Open the extension popup
+ try {
+ await chrome.action.openPopup()
+ } catch {
+ // openPopup() may fail if popup is already open — safe to ignore
+ }
+})
diff --git a/v2/src/components/NavBar.tsx b/v2/src/components/NavBar.tsx
new file mode 100644
index 0000000..09cd054
--- /dev/null
+++ b/v2/src/components/NavBar.tsx
@@ -0,0 +1,49 @@
+import { useNavigate, useLocation } from 'react-router-dom'
+import { PenLine, Clock, Settings } from 'lucide-react'
+import { cn } from '@/lib/cn'
+import { useStore } from '@/lib/store'
+
+export default function NavBar() {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const isDark = useStore((s) => s.settings.theme === 'dark')
+
+ const navItems = [
+ { icon: PenLine, path: '/home', label: 'Editor' },
+ { icon: Clock, path: '/history', label: 'History' },
+ { icon: Settings, path: '/settings', label: 'Settings' },
+ ]
+
+ return (
+
+
+
+
+
+ {navItems.map(({ icon: Icon, path, label }) => {
+ const isActive = location.pathname === path || (path === '/home' && location.pathname === '/')
+ return (
+ navigate(path)}
+ className={cn(
+ 'p-2 rounded-lg transition-all duration-150',
+ 'hover:bg-surface-hover active:scale-95',
+ isActive
+ ? 'text-primary bg-primary-light/50'
+ : 'text-text-muted hover:text-text-secondary',
+ )}
+ title={label}
+ >
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/v2/src/components/common/CopyBox.tsx b/v2/src/components/common/CopyBox.tsx
new file mode 100644
index 0000000..329b56f
--- /dev/null
+++ b/v2/src/components/common/CopyBox.tsx
@@ -0,0 +1,102 @@
+import { useState, useCallback } from 'react'
+import { Copy, Check, ExternalLink, Eye, EyeOff } from 'lucide-react'
+import { cn } from '@/lib/cn'
+
+interface CopyBoxProps {
+ label?: string
+ value: string
+ multiline?: boolean
+ rows?: number
+ masked?: boolean
+ allowCopy?: boolean
+ openInNew?: boolean
+ className?: string
+}
+
+export default function CopyBox({
+ label,
+ value,
+ multiline = false,
+ rows = 4,
+ masked = false,
+ allowCopy = true,
+ openInNew = false,
+ className,
+}: CopyBoxProps) {
+ const [copied, setCopied] = useState(false)
+ const [visible, setVisible] = useState(!masked)
+
+ const handleCopy = useCallback(async () => {
+ await navigator.clipboard.writeText(value)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }, [value])
+
+ const handleOpenNew = useCallback(() => {
+ window.open(value, '_blank', 'noopener,noreferrer')
+ }, [value])
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+ {multiline ? (
+
+ ) : (
+
+ )}
+
+ {masked && (
+ setVisible(!visible)}
+ className="p-1.5 rounded-md text-text-muted hover:text-text-secondary hover:bg-surface-hover transition-all"
+ title={visible ? 'Hide' : 'Show'}
+ >
+ {visible ? : }
+
+ )}
+ {openInNew && (
+
+
+
+ )}
+ {allowCopy && (
+
+ {copied ? : }
+
+ )}
+
+
+
+ )
+}
diff --git a/v2/src/components/common/PageHeader.tsx b/v2/src/components/common/PageHeader.tsx
new file mode 100644
index 0000000..e4ed99a
--- /dev/null
+++ b/v2/src/components/common/PageHeader.tsx
@@ -0,0 +1,31 @@
+import { useNavigate } from 'react-router-dom'
+import { ChevronLeft } from 'lucide-react'
+
+interface PageHeaderProps {
+ title: string
+ subtitle?: string
+ showBack?: boolean
+}
+
+export default function PageHeader({ title, subtitle, showBack = true }: PageHeaderProps) {
+ const navigate = useNavigate()
+
+ return (
+
+ {showBack && (
+
navigate(-1)}
+ className="p-1.5 -ml-1.5 rounded-lg hover:bg-surface-hover active:scale-95 transition-all text-text-muted hover:text-text-primary"
+ >
+
+
+ )}
+
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+
+ )
+}
diff --git a/v2/src/components/common/SettingsRow.tsx b/v2/src/components/common/SettingsRow.tsx
new file mode 100644
index 0000000..147eb2e
--- /dev/null
+++ b/v2/src/components/common/SettingsRow.tsx
@@ -0,0 +1,48 @@
+import { ReactNode } from 'react'
+import { ChevronRight } from 'lucide-react'
+import { cn } from '@/lib/cn'
+
+interface SettingsRowProps {
+ label: string
+ description?: string
+ children?: ReactNode
+ onClick?: () => void
+ chevron?: boolean
+ danger?: boolean
+ className?: string
+}
+
+export default function SettingsRow({
+ label,
+ description,
+ children,
+ onClick,
+ chevron = false,
+ danger = false,
+ className,
+}: SettingsRowProps) {
+ const Comp = onClick ? 'button' : 'div'
+
+ return (
+
+
+
{label}
+ {description && (
+
{description}
+ )}
+
+
+ {children}
+ {chevron && }
+
+
+ )
+}
diff --git a/v2/src/components/common/StatusBadge.tsx b/v2/src/components/common/StatusBadge.tsx
new file mode 100644
index 0000000..dd0dfb7
--- /dev/null
+++ b/v2/src/components/common/StatusBadge.tsx
@@ -0,0 +1,35 @@
+import { CheckCircle2, XCircle, AlertCircle, Clock, FileText, History } from 'lucide-react'
+import { cn } from '@/lib/cn'
+
+type Variant = 'success' | 'error' | 'warning' | 'empty' | 'empty-history'
+
+interface StatusBadgeProps {
+ variant: Variant
+ title: string
+ subtitle?: string
+ className?: string
+}
+
+const config: Record = {
+ success: { icon: CheckCircle2, color: 'text-success', bg: 'bg-success/10' },
+ error: { icon: XCircle, color: 'text-danger', bg: 'bg-danger/10' },
+ warning: { icon: AlertCircle, color: 'text-warning', bg: 'bg-warning/10' },
+ empty: { icon: FileText, color: 'text-text-muted', bg: 'bg-surface-secondary' },
+ 'empty-history': { icon: History, color: 'text-text-muted', bg: 'bg-surface-secondary' },
+}
+
+export default function StatusBadge({ variant, title, subtitle, className }: StatusBadgeProps) {
+ const { icon: Icon, color, bg } = config[variant]
+
+ return (
+
+
+
+
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+ )
+}
diff --git a/v2/src/components/dialog/ConfirmDialog.tsx b/v2/src/components/dialog/ConfirmDialog.tsx
new file mode 100644
index 0000000..19908f7
--- /dev/null
+++ b/v2/src/components/dialog/ConfirmDialog.tsx
@@ -0,0 +1,75 @@
+import * as Dialog from '@radix-ui/react-dialog'
+import { AlertTriangle, X } from 'lucide-react'
+import { cn } from '@/lib/cn'
+
+interface ConfirmDialogProps {
+ open: boolean
+ title: string
+ description: string
+ confirmLabel?: string
+ danger?: boolean
+ onConfirm: () => void
+ onCancel: () => void
+}
+
+export default function ConfirmDialog({
+ open,
+ title,
+ description,
+ confirmLabel = 'Confirm',
+ danger = true,
+ onConfirm,
+ onCancel,
+}: ConfirmDialogProps) {
+ return (
+ !o && onCancel()}>
+
+
+
+
+
+
+ {description}
+
+
+
+
+ Cancel
+
+
+ {confirmLabel}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/v2/src/components/dialog/DecryptDialog.tsx b/v2/src/components/dialog/DecryptDialog.tsx
new file mode 100644
index 0000000..06e3121
--- /dev/null
+++ b/v2/src/components/dialog/DecryptDialog.tsx
@@ -0,0 +1,76 @@
+import { useState } from 'react'
+import * as Dialog from '@radix-ui/react-dialog'
+import { Unlock, X } from 'lucide-react'
+
+interface DecryptDialogProps {
+ open: boolean
+ onConfirm: (passkey: string) => void
+ onCancel: () => void
+}
+
+export default function DecryptDialog({ open, onConfirm, onCancel }: DecryptDialogProps) {
+ const [passkey, setPasskey] = useState('')
+
+ const handleConfirm = () => {
+ if (passkey.trim()) {
+ onConfirm(passkey.trim())
+ setPasskey('')
+ }
+ }
+
+ return (
+ { if (!o) { onCancel(); setPasskey('') } }}>
+
+
+
+
+
+
+
+
+ Decryption Key
+
+ Enter the passkey to decrypt the content
+
+
+
+
+ setPasskey(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleConfirm()}
+ placeholder="Paste decryption key..."
+ className="w-full px-3 py-2.5 mb-4 text-[13px] font-mono rounded-xl border border-border bg-surface-secondary/50 focus:outline-none focus:border-primary transition-colors"
+ autoFocus
+ />
+
+
+
+ Cancel
+
+
+ Decrypt
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/v2/src/components/dialog/EncryptDialog.tsx b/v2/src/components/dialog/EncryptDialog.tsx
new file mode 100644
index 0000000..729bb1c
--- /dev/null
+++ b/v2/src/components/dialog/EncryptDialog.tsx
@@ -0,0 +1,87 @@
+import { useState, useEffect } from 'react'
+import * as Dialog from '@radix-ui/react-dialog'
+import { Lock, RefreshCw, X } from 'lucide-react'
+import { useStore } from '@/lib/store'
+import { generatePasskey } from '@/lib/crypto'
+import { cn } from '@/lib/cn'
+
+interface EncryptDialogProps {
+ open: boolean
+ onConfirm: (passkey: string) => void
+ onCancel: () => void
+}
+
+export default function EncryptDialog({ open, onConfirm, onCancel }: EncryptDialogProps) {
+ const [passkey, setPasskey] = useState('')
+
+ useEffect(() => {
+ if (open) {
+ setPasskey(generatePasskey())
+ }
+ }, [open])
+
+ const regenerate = () => setPasskey(generatePasskey())
+
+ return (
+ !o && onCancel()}>
+
+
+
+
+
+
+
+
+ Encryption Passkey
+
+ Enter a passkey or use the generated one
+
+
+
+
+
+ setPasskey(e.target.value)}
+ placeholder="Enter passkey..."
+ className="w-full px-3 py-2.5 pr-10 text-[13px] font-mono rounded-xl border border-border bg-surface-secondary/50 focus:outline-none focus:border-primary transition-colors"
+ autoFocus
+ />
+
+
+
+
+
+
+
+ Cancel
+
+ onConfirm(passkey || generatePasskey())}
+ className="px-4 py-2 text-sm font-semibold rounded-xl bg-primary text-white hover:bg-primary-hover active:scale-[0.98] transition-all"
+ >
+ Encrypt
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/v2/src/components/editor/ActionBar.tsx b/v2/src/components/editor/ActionBar.tsx
new file mode 100644
index 0000000..42c7cb2
--- /dev/null
+++ b/v2/src/components/editor/ActionBar.tsx
@@ -0,0 +1,147 @@
+import { useState } from 'react'
+import { ChevronDown, Lock, Unlock, Send, Save, Link, ExternalLink, KeyRound } from 'lucide-react'
+import { useStore } from '@/lib/store'
+import { cn } from '@/lib/cn'
+import {
+ EditorAction,
+ MAX_PASTEBIN_TEXT_LENGTH,
+ MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH,
+ MAX_ENC_TEXT_LENGTH,
+} from '@/lib/constants'
+import { isEncryptionAction } from '@/lib/editor-utils'
+
+interface ActionBarProps {
+ onAction: (action?: EditorAction) => void
+}
+
+const actionConfig: Record = {
+ [EditorAction.ENCRYPT]: { icon: Lock, label: 'Encrypt' },
+ [EditorAction.ENCRYPT_PASTEBIN]: { icon: KeyRound, label: 'Encrypt & Post' },
+ [EditorAction.POST_PASTEBIN]: { icon: Send, label: 'Post to Pastebin' },
+ [EditorAction.DECRYPT]: { icon: Unlock, label: 'Decrypt' },
+ [EditorAction.DECRYPT_PASTEBIN]: { icon: Unlock, label: 'Decrypt from Link' },
+ [EditorAction.OPEN_PASTEBIN]: { icon: ExternalLink, label: 'Open Paste' },
+ [EditorAction.SAVE_DRAFT]: { icon: Save, label: 'Save Draft' },
+}
+
+export default function ActionBar({ onAction }: ActionBarProps) {
+ const { draft, updateDraft, settings } = useStore()
+ const [menuOpen, setMenuOpen] = useState(false)
+
+ const maxLen =
+ draft.action === EditorAction.ENCRYPT_PASTEBIN
+ ? MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH
+ : draft.action === EditorAction.POST_PASTEBIN
+ ? MAX_PASTEBIN_TEXT_LENGTH
+ : MAX_ENC_TEXT_LENGTH
+
+ const isOverLimit = draft.plaintext.length > maxLen
+ const pct = Math.min((draft.plaintext.length / maxLen) * 100, 100)
+ const barColor =
+ pct >= 90 ? 'bg-danger' :
+ pct >= 70 ? 'bg-warning' :
+ 'bg-success/70'
+
+ const currentAction = actionConfig[draft.action]
+ const Icon = currentAction.icon
+ const hasApiKey = settings.apiKey && settings.apiKey !== atob('MmU1OGNlMjcyMzllMzRhNzdjNWVmNjVkYmVhOGIyNGQ=')
+
+ // Derive encryption preference from the default action setting
+ const encryptionDefault = isEncryptionAction(settings.default_action)
+
+ // "Decrypt from Link" / "Open Paste" are only relevant when a Pastebin URL is detected
+ const isPastebinLink = draft.action === EditorAction.DECRYPT_PASTEBIN || draft.action === EditorAction.OPEN_PASTEBIN
+
+ const menuItems: { action: EditorAction; icon: typeof Lock; label: string; disabled?: boolean; divider?: boolean }[] = encryptionDefault
+ ? [
+ { action: EditorAction.ENCRYPT_PASTEBIN, icon: KeyRound, label: 'Encrypt & Post', disabled: !hasApiKey },
+ { action: EditorAction.POST_PASTEBIN, icon: Send, label: 'Post (Unencrypted)', disabled: !hasApiKey },
+ { action: EditorAction.ENCRYPT, icon: Lock, label: 'Encrypt Only' },
+ { action: EditorAction.SAVE_DRAFT, icon: Save, label: 'Save Draft', divider: true },
+ { action: EditorAction.DECRYPT, icon: Unlock, label: 'Decrypt' },
+ ...(isPastebinLink ? [{ action: EditorAction.DECRYPT_PASTEBIN, icon: Unlock, label: 'Decrypt from Link', disabled: !hasApiKey }] : []),
+ ]
+ : [
+ { action: EditorAction.POST_PASTEBIN, icon: Send, label: 'Post to Pastebin', disabled: !hasApiKey },
+ { action: EditorAction.ENCRYPT_PASTEBIN, icon: KeyRound, label: 'Encrypt & Post', disabled: !hasApiKey },
+ { action: EditorAction.ENCRYPT, icon: Lock, label: 'Encrypt Only' },
+ { action: EditorAction.SAVE_DRAFT, icon: Save, label: 'Save Draft', divider: true },
+ ...(isPastebinLink ? [{ action: EditorAction.OPEN_PASTEBIN, icon: Link, label: 'Open Paste', disabled: !hasApiKey, divider: true }] : []),
+ ]
+
+ return (
+
+ {/* Usage bar */}
+
+
+
+ {draft.plaintext.length.toLocaleString()} / {maxLen.toLocaleString()}
+
+
+
+ {/* Action button group */}
+
+
onAction()}
+ disabled={!draft.buttonEnabled}
+ className={cn(
+ 'flex items-center gap-2 pl-5 pr-4 py-2.5 rounded-l-xl text-sm font-semibold transition-all',
+ 'bg-primary text-white',
+ draft.buttonEnabled
+ ? 'hover:bg-primary-hover active:scale-[0.98]'
+ : 'opacity-40 cursor-not-allowed',
+ )}
+ >
+
+ {currentAction.label}
+
+
setMenuOpen(!menuOpen)}
+ className={cn(
+ 'flex items-center px-3.5 py-2.5 rounded-r-xl border-l border-white/20 text-white transition-all',
+ 'bg-primary hover:bg-primary-hover active:scale-[0.98]',
+ )}
+ >
+
+
+
+ {/* Dropdown menu */}
+ {menuOpen && (
+ <>
+
setMenuOpen(false)} />
+
+ {menuItems.map((item, i) => (
+
+ {item.divider && i > 0 &&
}
+
{
+ updateDraft({ action: item.action })
+ setMenuOpen(false)
+ onAction(item.action)
+ }}
+ disabled={item.disabled}
+ className={cn(
+ 'w-full flex items-center gap-2.5 px-3 py-2 text-sm text-left transition-colors',
+ item.disabled
+ ? 'text-text-muted/40 cursor-not-allowed'
+ : 'text-text-primary hover:bg-surface-hover',
+ draft.action === item.action && 'text-primary font-medium',
+ )}
+ >
+
+ {item.label}
+
+
+ ))}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/v2/src/components/editor/TextEditor.tsx b/v2/src/components/editor/TextEditor.tsx
new file mode 100644
index 0000000..3902c2a
--- /dev/null
+++ b/v2/src/components/editor/TextEditor.tsx
@@ -0,0 +1,66 @@
+import { useRef, useEffect, useCallback } from 'react'
+import { useStore } from '@/lib/store'
+import { cn } from '@/lib/cn'
+import { EditorAction } from '@/lib/constants'
+import { detectAction, getMaxLength, isWithinLimit } from '@/lib/editor-utils'
+
+export default function TextEditor() {
+ const { draft, updateDraft, settings } = useStore()
+ const textareaRef = useRef
(null)
+ const debounceRef = useRef | undefined>(undefined)
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const text = e.target.value
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => {
+ const action = detectAction(text, settings.default_action)
+ updateDraft({
+ plaintext: text,
+ action,
+ buttonEnabled: isWithinLimit(text, action),
+ })
+ }, 200)
+ // Immediate text update for responsive typing
+ updateDraft({ plaintext: text })
+ },
+ [settings.default_action, updateDraft],
+ )
+
+ useEffect(() => {
+ return () => clearTimeout(debounceRef.current)
+ }, [])
+
+ const placeholder = (() => {
+ if (draft.action === EditorAction.DECRYPT || draft.action === EditorAction.DECRYPT_PASTEBIN) {
+ return 'Paste encrypted text or Pastebin link...'
+ }
+ return 'Type or paste your text here...'
+ })()
+
+ const isEmpty = draft.plaintext.length === 0
+
+ return (
+
+ )
+}
diff --git a/v2/src/index.css b/v2/src/index.css
new file mode 100644
index 0000000..39d75dc
--- /dev/null
+++ b/v2/src/index.css
@@ -0,0 +1,100 @@
+@import "tailwindcss";
+
+@theme {
+ --color-primary: #1D6BC6;
+ --color-primary-hover: #1558a8;
+ --color-primary-light: #dbeafe;
+ --color-danger: #ef4444;
+ --color-danger-hover: #dc2626;
+ --color-success: #22c55e;
+ --color-warning: #f59e0b;
+ --color-surface: #ffffff;
+ --color-surface-secondary: #f8fafc;
+ --color-surface-hover: #f1f5f9;
+ --color-border: #e2e8f0;
+ --color-border-hover: #cbd5e1;
+ --color-text-primary: #0f172a;
+ --color-text-secondary: #475569;
+ --color-text-muted: #94a3b8;
+ --font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
+}
+
+@variant dark (&:where(.dark, .dark *));
+
+:root {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+
+.dark {
+ --color-primary: #4795fd;
+ --color-primary-hover: #3b82f6;
+ --color-primary-light: #1e3a5f;
+ --color-danger: #f87171;
+ --color-danger-hover: #ef4444;
+ --color-success: #4ade80;
+ --color-warning: #fbbf24;
+ --color-surface: #0f172a;
+ --color-surface-secondary: #1e293b;
+ --color-surface-hover: #334155;
+ --color-border: #334155;
+ --color-border-hover: #475569;
+ --color-text-primary: #f1f5f9;
+ --color-text-secondary: #94a3b8;
+ --color-text-muted: #64748b;
+}
+
+body {
+ margin: 0;
+ width: 420px;
+ height: 600px;
+ overflow: hidden;
+ background-color: var(--color-surface);
+ color: var(--color-text-primary);
+}
+
+#root {
+ width: 100%;
+ height: 100%;
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: 3px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-border-hover);
+}
+
+/* Dialog overlay animation */
+[data-state="open"] > [data-radix-dialog-overlay] {
+ animation: fadeIn 150ms ease-out;
+}
+[data-state="closed"] > [data-radix-dialog-overlay] {
+ animation: fadeOut 100ms ease-in;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes fadeOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+
+/* Focus ring utility */
+.focus-ring {
+ outline: none;
+}
+.focus-ring:focus-visible {
+ box-shadow: 0 0 0 2px var(--color-surface), 0 0 0 4px var(--color-primary);
+ border-radius: 8px;
+}
diff --git a/v2/src/lib/cn.ts b/v2/src/lib/cn.ts
new file mode 100644
index 0000000..fed2fe9
--- /dev/null
+++ b/v2/src/lib/cn.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/v2/src/lib/constants.ts b/v2/src/lib/constants.ts
new file mode 100644
index 0000000..9044701
--- /dev/null
+++ b/v2/src/lib/constants.ts
@@ -0,0 +1,49 @@
+export enum StorageKey {
+ SETTINGS = 'settings',
+ HISTORY = 'history',
+ DRAFT = 'draft',
+}
+
+export enum EncryptionMode {
+ AES_CBC = 'AES-CBC',
+ AES_CTR = 'AES-CTR',
+ AES_GCM = 'AES-GCM',
+}
+
+export const KEY_LENGTHS = [
+ { label: '128-bit', value: 16 },
+ { label: '192-bit', value: 24 },
+ { label: '256-bit', value: 32 },
+] as const
+
+export const ENCRYPTION_MODES = [
+ { label: 'AES-CBC', value: EncryptionMode.AES_CBC },
+ { label: 'AES-CTR', value: EncryptionMode.AES_CTR },
+ { label: 'AES-GCM (Recommended)', value: EncryptionMode.AES_GCM },
+] as const
+
+// Pastebin API maximum paste size is 512 KB (524,288 bytes).
+// For encrypted pastes, the AES-GCM ciphertext in base64 is ~4/3 of the binary size plus
+// ~48 chars of prefix/IV/salt overhead, so the plaintext limit is ≈ 75% of the raw limit
+// with a small buffer to guarantee the encoded output stays under 512 KB.
+export const PASTEBIN_MAX_BYTES = 512 * 1024 // 524,288 — Pastebin hard limit
+export const MAX_PASTEBIN_TEXT_LENGTH = PASTEBIN_MAX_BYTES // plain (unencrypted) paste
+export const MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH =
+ Math.floor(PASTEBIN_MAX_BYTES * 0.75) - 256 // ~392,960 chars — base64 + overhead buffer
+export const MAX_ENC_TEXT_LENGTH = 512 * 1024 // local encrypt / decrypt only (same ceiling)
+export const PASTEBIN_API_KEY_LENGTH = 32
+export const PASTEBIN_BASE_URL = 'pastebin.com'
+export const CORS_PROXY = 'https://cors.securebin.workers.dev/?'
+export const CIPHER_PREFIX = 'C_TXT'
+
+export enum EditorAction {
+ ENCRYPT = 'Encrypt',
+ ENCRYPT_PASTEBIN = 'Encrypt to Pastebin',
+ POST_PASTEBIN = 'Post to Pastebin',
+ DECRYPT = 'Decrypt',
+ DECRYPT_PASTEBIN = 'Decrypt from Pastebin',
+ OPEN_PASTEBIN = 'Open from Pastebin',
+ SAVE_DRAFT = 'Save Draft',
+}
+
+export const DEFAULT_API_KEY_HASH = 'MmU1OGNlMjcyMzllMzRhNzdjNWVmNjVkYmVhOGIyNGQ='
diff --git a/v2/src/lib/crypto.ts b/v2/src/lib/crypto.ts
new file mode 100644
index 0000000..8719aa0
--- /dev/null
+++ b/v2/src/lib/crypto.ts
@@ -0,0 +1,194 @@
+import { EncryptionMode } from './constants'
+
+export interface CipherData {
+ C_TXT: string
+ IV: string
+ Mode: string
+ Tag: string
+ Salt?: string
+ Length?: number
+}
+
+export interface EncryptResult {
+ cipherData: string
+ key: string
+ mode: string
+ keyLength: number
+}
+
+function arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer)
+ let binary = ''
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i])
+ }
+ return btoa(binary)
+}
+
+function base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const binary = atob(base64)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+ return bytes.buffer as ArrayBuffer
+}
+
+function getWebCryptoAlgorithm(mode: EncryptionMode): string {
+ switch (mode) {
+ case EncryptionMode.AES_GCM: return 'AES-GCM'
+ case EncryptionMode.AES_CBC: return 'AES-CBC'
+ case EncryptionMode.AES_CTR: return 'AES-CTR'
+ }
+}
+
+function getKeyBits(keyLength: number): number {
+ return keyLength * 8
+}
+
+async function deriveKeyFromPassword(
+ password: string,
+ salt: Uint8Array,
+ keyLength: number,
+ algorithm: string,
+): Promise {
+ const enc = new TextEncoder()
+ const keyMaterial = await crypto.subtle.importKey(
+ 'raw',
+ enc.encode(password),
+ 'PBKDF2',
+ false,
+ ['deriveKey'],
+ )
+ return crypto.subtle.deriveKey(
+ { name: 'PBKDF2', salt: salt as BufferSource, iterations: 10000, hash: 'SHA-256' },
+ keyMaterial,
+ { name: algorithm, length: getKeyBits(keyLength) },
+ true,
+ ['encrypt', 'decrypt'],
+ )
+}
+
+export async function encrypt(
+ plaintext: string,
+ mode: EncryptionMode,
+ keyLength: number,
+ password?: string,
+): Promise {
+ const algorithm = getWebCryptoAlgorithm(mode)
+ const iv = crypto.getRandomValues(new Uint8Array(mode === EncryptionMode.AES_GCM ? 12 : 16))
+ const enc = new TextEncoder()
+ const data = enc.encode(plaintext)
+
+ let cryptoKey: CryptoKey
+ let exportedKey = ''
+ let salt: Uint8Array | undefined
+
+ if (password) {
+ salt = crypto.getRandomValues(new Uint8Array(16))
+ cryptoKey = await deriveKeyFromPassword(password, salt, keyLength, algorithm)
+ exportedKey = password
+ } else {
+ cryptoKey = await crypto.subtle.generateKey(
+ { name: algorithm, length: getKeyBits(keyLength) },
+ true,
+ ['encrypt', 'decrypt'],
+ )
+ const rawKey = await crypto.subtle.exportKey('raw', cryptoKey)
+ exportedKey = arrayBufferToBase64(rawKey)
+ }
+
+ let encryptParams: AlgorithmIdentifier
+ if (mode === EncryptionMode.AES_GCM) {
+ encryptParams = { name: 'AES-GCM', iv } as AesGcmParams
+ } else if (mode === EncryptionMode.AES_CTR) {
+ encryptParams = { name: 'AES-CTR', counter: iv, length: 64 } as AesCtrParams
+ } else {
+ encryptParams = { name: 'AES-CBC', iv } as AesCbcParams
+ }
+
+ const encrypted = await crypto.subtle.encrypt(encryptParams, cryptoKey, data)
+
+ let ciphertext: string
+ let tag = ''
+
+ if (mode === EncryptionMode.AES_GCM) {
+ // GCM appends 16-byte tag to ciphertext
+ const encBytes = new Uint8Array(encrypted)
+ const cipherBytes = encBytes.slice(0, encBytes.length - 16)
+ const tagBytes = encBytes.slice(encBytes.length - 16)
+ ciphertext = arrayBufferToBase64(cipherBytes.buffer as ArrayBuffer)
+ tag = arrayBufferToBase64(tagBytes.buffer as ArrayBuffer)
+ } else {
+ ciphertext = arrayBufferToBase64(encrypted)
+ }
+
+ const cipherData: CipherData = {
+ C_TXT: ciphertext,
+ IV: arrayBufferToBase64(iv.buffer as ArrayBuffer),
+ Mode: mode,
+ Tag: tag,
+ ...(salt ? { Salt: arrayBufferToBase64(salt.buffer as ArrayBuffer), Length: keyLength } : {}),
+ }
+
+ return {
+ cipherData: JSON.stringify(cipherData),
+ key: exportedKey,
+ mode,
+ keyLength,
+ }
+}
+
+export async function decrypt(cipherDataStr: string, key: string): Promise {
+ const data: CipherData = JSON.parse(cipherDataStr)
+ const mode = data.Mode as EncryptionMode
+ const algorithm = getWebCryptoAlgorithm(mode)
+ const iv = new Uint8Array(base64ToArrayBuffer(data.IV))
+
+ let cryptoKey: CryptoKey
+
+ if (data.Salt && data.Length) {
+ const salt = new Uint8Array(base64ToArrayBuffer(data.Salt))
+ cryptoKey = await deriveKeyFromPassword(key, salt, data.Length, algorithm)
+ } else {
+ const rawKey = base64ToArrayBuffer(key)
+ cryptoKey = await crypto.subtle.importKey(
+ 'raw',
+ rawKey,
+ { name: algorithm, length: rawKey.byteLength * 8 },
+ false,
+ ['decrypt'],
+ )
+ }
+
+ let cipherBytes: Uint8Array
+
+ if (mode === EncryptionMode.AES_GCM) {
+ // Reconstruct ciphertext + tag
+ const ct = new Uint8Array(base64ToArrayBuffer(data.C_TXT))
+ const tag = new Uint8Array(base64ToArrayBuffer(data.Tag))
+ cipherBytes = new Uint8Array(ct.length + tag.length)
+ cipherBytes.set(ct)
+ cipherBytes.set(tag, ct.length)
+ } else {
+ cipherBytes = new Uint8Array(base64ToArrayBuffer(data.C_TXT))
+ }
+
+ let decryptParams: AlgorithmIdentifier
+ if (mode === EncryptionMode.AES_GCM) {
+ decryptParams = { name: 'AES-GCM', iv } as AesGcmParams
+ } else if (mode === EncryptionMode.AES_CTR) {
+ decryptParams = { name: 'AES-CTR', counter: iv, length: 64 } as AesCtrParams
+ } else {
+ decryptParams = { name: 'AES-CBC', iv } as AesCbcParams
+ }
+
+ const decrypted = await crypto.subtle.decrypt(decryptParams, cryptoKey, cipherBytes as ArrayBufferView)
+ return new TextDecoder().decode(decrypted)
+}
+
+export function generatePasskey(length = 20): string {
+ const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*_+-='
+ const values = crypto.getRandomValues(new Uint8Array(length))
+ return Array.from(values, (v) => charset[v % charset.length]).join('')
+}
diff --git a/v2/src/lib/editor-utils.test.ts b/v2/src/lib/editor-utils.test.ts
new file mode 100644
index 0000000..c2d7283
--- /dev/null
+++ b/v2/src/lib/editor-utils.test.ts
@@ -0,0 +1,183 @@
+import { describe, it, expect } from 'vitest'
+import {
+ detectAction,
+ getMaxLength,
+ isWithinLimit,
+ isEncryptionAction,
+} from './editor-utils'
+import {
+ EditorAction,
+ PASTEBIN_MAX_BYTES,
+ MAX_PASTEBIN_TEXT_LENGTH,
+ MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH,
+ MAX_ENC_TEXT_LENGTH,
+} from './constants'
+
+// ---------------------------------------------------------------------------
+// isEncryptionAction
+// ---------------------------------------------------------------------------
+
+describe('isEncryptionAction', () => {
+ it('returns true for ENCRYPT', () => {
+ expect(isEncryptionAction(EditorAction.ENCRYPT)).toBe(true)
+ })
+ it('returns true for ENCRYPT_PASTEBIN', () => {
+ expect(isEncryptionAction(EditorAction.ENCRYPT_PASTEBIN)).toBe(true)
+ })
+ it('returns false for POST_PASTEBIN', () => {
+ expect(isEncryptionAction(EditorAction.POST_PASTEBIN)).toBe(false)
+ })
+ it('returns false for SAVE_DRAFT', () => {
+ expect(isEncryptionAction(EditorAction.SAVE_DRAFT)).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Char limit constants
+// ---------------------------------------------------------------------------
+
+describe('Pastebin char limit constants', () => {
+ it('PASTEBIN_MAX_BYTES is exactly 512 KB', () => {
+ expect(PASTEBIN_MAX_BYTES).toBe(512 * 1024)
+ })
+
+ it('MAX_PASTEBIN_TEXT_LENGTH equals the 512 KB limit', () => {
+ expect(MAX_PASTEBIN_TEXT_LENGTH).toBe(PASTEBIN_MAX_BYTES)
+ })
+
+ it('MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH is smaller than MAX_PASTEBIN_TEXT_LENGTH', () => {
+ expect(MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH).toBeLessThan(MAX_PASTEBIN_TEXT_LENGTH)
+ })
+
+ it('MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH leaves room for base64 + overhead (output < 512 KB)', () => {
+ const n = MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH
+ const estimatedCiphertext = Math.ceil((n + 16) / 3) * 4 + 48
+ expect(estimatedCiphertext).toBeLessThan(PASTEBIN_MAX_BYTES)
+ })
+
+ it('MAX_ENC_TEXT_LENGTH equals 512 KB', () => {
+ expect(MAX_ENC_TEXT_LENGTH).toBe(512 * 1024)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// getMaxLength
+// ---------------------------------------------------------------------------
+
+describe('getMaxLength', () => {
+ it('returns the plain paste limit for POST_PASTEBIN', () => {
+ expect(getMaxLength(EditorAction.POST_PASTEBIN)).toBe(MAX_PASTEBIN_TEXT_LENGTH)
+ })
+
+ it('returns the encrypted plaintext limit for ENCRYPT_PASTEBIN', () => {
+ expect(getMaxLength(EditorAction.ENCRYPT_PASTEBIN)).toBe(MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH)
+ })
+
+ it('returns the local limit for ENCRYPT', () => {
+ expect(getMaxLength(EditorAction.ENCRYPT)).toBe(MAX_ENC_TEXT_LENGTH)
+ })
+
+ it('returns the local limit for DECRYPT', () => {
+ expect(getMaxLength(EditorAction.DECRYPT)).toBe(MAX_ENC_TEXT_LENGTH)
+ })
+
+ it('returns the local limit for SAVE_DRAFT', () => {
+ expect(getMaxLength(EditorAction.SAVE_DRAFT)).toBe(MAX_ENC_TEXT_LENGTH)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// detectAction — default POST_PASTEBIN (encryption off)
+// ---------------------------------------------------------------------------
+
+describe('detectAction with POST_PASTEBIN default', () => {
+ const def = EditorAction.POST_PASTEBIN
+
+ it('returns default for plain text', () => {
+ expect(detectAction('hello world', def)).toBe(EditorAction.POST_PASTEBIN)
+ })
+
+ it('detects OPEN_PASTEBIN for a pastebin.com link', () => {
+ expect(detectAction('https://pastebin.com/abc123', def)).toBe(EditorAction.OPEN_PASTEBIN)
+ })
+
+ it('detects OPEN_PASTEBIN for a bare pastebin.com link', () => {
+ expect(detectAction('pastebin.com/xyz', def)).toBe(EditorAction.OPEN_PASTEBIN)
+ })
+
+ it('detects DECRYPT for text containing the cipher prefix', () => {
+ expect(detectAction('C_TXT:abc:def:ghi', def)).toBe(EditorAction.DECRYPT)
+ })
+
+ it('cipher prefix takes priority over pastebin URL', () => {
+ expect(detectAction('C_TXT pastebin.com/abc', def)).toBe(EditorAction.DECRYPT)
+ })
+
+ it('does NOT match not-pastebin.com', () => {
+ expect(detectAction('not-pastebin.com/fake', def)).toBe(EditorAction.POST_PASTEBIN)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// detectAction — default ENCRYPT_PASTEBIN (encryption on)
+// ---------------------------------------------------------------------------
+
+describe('detectAction with ENCRYPT_PASTEBIN default', () => {
+ const def = EditorAction.ENCRYPT_PASTEBIN
+
+ it('returns default for plain text', () => {
+ expect(detectAction('hello world', def)).toBe(EditorAction.ENCRYPT_PASTEBIN)
+ })
+
+ it('detects DECRYPT_PASTEBIN for a pastebin.com link', () => {
+ expect(detectAction('https://pastebin.com/abc123', def)).toBe(EditorAction.DECRYPT_PASTEBIN)
+ })
+
+ it('detects DECRYPT for cipher prefix regardless of default', () => {
+ expect(detectAction('C_TXT:abc:def:ghi', def)).toBe(EditorAction.DECRYPT)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// detectAction — default ENCRYPT (local only)
+// ---------------------------------------------------------------------------
+
+describe('detectAction with ENCRYPT default', () => {
+ const def = EditorAction.ENCRYPT
+
+ it('returns default for plain text', () => {
+ expect(detectAction('hello world', def)).toBe(EditorAction.ENCRYPT)
+ })
+
+ it('detects DECRYPT_PASTEBIN for a pastebin.com link (encryption action default)', () => {
+ expect(detectAction('https://pastebin.com/abc123', def)).toBe(EditorAction.DECRYPT_PASTEBIN)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// isWithinLimit
+// ---------------------------------------------------------------------------
+
+describe('isWithinLimit', () => {
+ it('returns false for empty text', () => {
+ expect(isWithinLimit('', EditorAction.POST_PASTEBIN)).toBe(false)
+ })
+
+ it('returns true when text length equals the limit', () => {
+ const limit = getMaxLength(EditorAction.POST_PASTEBIN)
+ expect(isWithinLimit('a'.repeat(limit), EditorAction.POST_PASTEBIN)).toBe(true)
+ })
+
+ it('returns false when text exceeds the limit by one character', () => {
+ const limit = getMaxLength(EditorAction.POST_PASTEBIN)
+ expect(isWithinLimit('a'.repeat(limit + 1), EditorAction.POST_PASTEBIN)).toBe(false)
+ })
+
+ it('applies the tighter ENCRYPT_PASTEBIN limit', () => {
+ const encLimit = getMaxLength(EditorAction.ENCRYPT_PASTEBIN)
+ const plainLimit = getMaxLength(EditorAction.POST_PASTEBIN)
+ const text = 'a'.repeat(encLimit + 1)
+ expect(isWithinLimit(text, EditorAction.ENCRYPT_PASTEBIN)).toBe(false)
+ expect(text.length).toBeLessThan(plainLimit)
+ })
+})
diff --git a/v2/src/lib/editor-utils.ts b/v2/src/lib/editor-utils.ts
new file mode 100644
index 0000000..b3b34d2
--- /dev/null
+++ b/v2/src/lib/editor-utils.ts
@@ -0,0 +1,54 @@
+import {
+ EditorAction,
+ CIPHER_PREFIX,
+ MAX_PASTEBIN_TEXT_LENGTH,
+ MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH,
+ MAX_ENC_TEXT_LENGTH,
+} from './constants'
+
+/** Returns true when the action implies encryption should be used. */
+export function isEncryptionAction(action: EditorAction): boolean {
+ return action === EditorAction.ENCRYPT || action === EditorAction.ENCRYPT_PASTEBIN
+}
+
+/**
+ * Infers the most appropriate action based on text content and the user's
+ * configured default action.
+ *
+ * - C_TXT prefix → always DECRYPT
+ * - pastebin.com URL → DECRYPT_PASTEBIN if default is an encryption action, else OPEN_PASTEBIN
+ * - plain text → returns defaultAction unchanged
+ */
+export function detectAction(text: string, defaultAction: EditorAction): EditorAction {
+ const trimmed = text.trim()
+
+ if (trimmed.includes(CIPHER_PREFIX)) {
+ return EditorAction.DECRYPT
+ }
+
+ // Match pastebin.com only when it appears as a domain, not as a substring of
+ // another hostname (e.g. "not-pastebin.com" should not match).
+ if (/(?:^|[\s/:("])pastebin\.com[/\s]?/.test(trimmed)) {
+ return isEncryptionAction(defaultAction) ? EditorAction.DECRYPT_PASTEBIN : EditorAction.OPEN_PASTEBIN
+ }
+
+ return defaultAction
+}
+
+/**
+ * Returns the maximum allowed plaintext character count for a given action.
+ * For actions that post to Pastebin the limit reflects the 512 KB API maximum,
+ * with encrypted pastes getting a lower limit to account for base64 expansion.
+ */
+export function getMaxLength(action: EditorAction): number {
+ if (action === EditorAction.ENCRYPT_PASTEBIN) return MAX_ENC_PASTEBIN_PLAINTEXT_LENGTH
+ if (action === EditorAction.POST_PASTEBIN) return MAX_PASTEBIN_TEXT_LENGTH
+ return MAX_ENC_TEXT_LENGTH
+}
+
+/**
+ * Returns true when the text is non-empty and within the limit for the given action.
+ */
+export function isWithinLimit(text: string, action: EditorAction): boolean {
+ return text.length > 0 && text.length <= getMaxLength(action)
+}
diff --git a/v2/src/lib/pastebin-detection.test.ts b/v2/src/lib/pastebin-detection.test.ts
new file mode 100644
index 0000000..2a22637
--- /dev/null
+++ b/v2/src/lib/pastebin-detection.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect } from 'vitest'
+import { detectAction, isEncryptionAction } from './editor-utils'
+import { EditorAction, CIPHER_PREFIX } from './constants'
+
+// ---------------------------------------------------------------------------
+// Pastebin link detection
+// ---------------------------------------------------------------------------
+
+describe('Pastebin link detection via detectAction', () => {
+ const pastebinUrls = [
+ 'https://pastebin.com/abc123',
+ 'http://pastebin.com/xyz',
+ 'pastebin.com/AbCdEf',
+ ' pastebin.com/raw/abc ',
+ 'Check this out: pastebin.com/abc123 for more',
+ ]
+
+ const nonPastebinTexts = [
+ 'hello world',
+ 'just some plain text',
+ 'not-pastebin.com/fake',
+ 'https://github.com/user/repo',
+ ]
+
+ describe('POST_PASTEBIN default → OPEN_PASTEBIN for Pastebin URLs', () => {
+ const def = EditorAction.POST_PASTEBIN
+ pastebinUrls.forEach((url) => {
+ it(`detects OPEN_PASTEBIN for: "${url.trim()}"`, () => {
+ expect(detectAction(url, def)).toBe(EditorAction.OPEN_PASTEBIN)
+ })
+ })
+
+ nonPastebinTexts.forEach((text) => {
+ it(`no Pastebin detection for: "${text}"`, () => {
+ const action = detectAction(text, def)
+ expect(action).not.toBe(EditorAction.OPEN_PASTEBIN)
+ expect(action).not.toBe(EditorAction.DECRYPT_PASTEBIN)
+ })
+ })
+ })
+
+ describe('ENCRYPT_PASTEBIN default → DECRYPT_PASTEBIN for Pastebin URLs', () => {
+ const def = EditorAction.ENCRYPT_PASTEBIN
+ pastebinUrls.forEach((url) => {
+ it(`detects DECRYPT_PASTEBIN for: "${url.trim()}"`, () => {
+ expect(detectAction(url, def)).toBe(EditorAction.DECRYPT_PASTEBIN)
+ })
+ })
+ })
+
+ it('cipher prefix takes priority over Pastebin URL (POST default)', () => {
+ expect(detectAction(`${CIPHER_PREFIX} pastebin.com/abc`, EditorAction.POST_PASTEBIN)).toBe(EditorAction.DECRYPT)
+ })
+
+ it('cipher prefix takes priority over Pastebin URL (ENCRYPT default)', () => {
+ expect(detectAction(`${CIPHER_PREFIX} pastebin.com/abc`, EditorAction.ENCRYPT_PASTEBIN)).toBe(EditorAction.DECRYPT)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// isPastebinLink — mirrors ActionBar conditional visibility logic
+// ---------------------------------------------------------------------------
+
+describe('isPastebinLink (ActionBar conditional visibility)', () => {
+ function isPastebinLink(text: string, defaultAction: EditorAction): boolean {
+ const action = detectAction(text, defaultAction)
+ return action === EditorAction.DECRYPT_PASTEBIN || action === EditorAction.OPEN_PASTEBIN
+ }
+
+ it('true for Pastebin URL with POST default', () => {
+ expect(isPastebinLink('pastebin.com/abc', EditorAction.POST_PASTEBIN)).toBe(true)
+ })
+
+ it('true for Pastebin URL with ENCRYPT default', () => {
+ expect(isPastebinLink('https://pastebin.com/abc', EditorAction.ENCRYPT_PASTEBIN)).toBe(true)
+ })
+
+ it('false for plain text', () => {
+ expect(isPastebinLink('hello world', EditorAction.POST_PASTEBIN)).toBe(false)
+ expect(isPastebinLink('hello world', EditorAction.ENCRYPT_PASTEBIN)).toBe(false)
+ })
+
+ it('false for cipher text', () => {
+ expect(isPastebinLink('C_TXT:iv:salt:cipher', EditorAction.POST_PASTEBIN)).toBe(false)
+ expect(isPastebinLink('C_TXT:iv:salt:cipher', EditorAction.ENCRYPT_PASTEBIN)).toBe(false)
+ })
+
+ it('false for not-pastebin.com', () => {
+ expect(isPastebinLink('not-pastebin.com/fake', EditorAction.POST_PASTEBIN)).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Open in SecureBin — initial action inference
+// ---------------------------------------------------------------------------
+
+describe('Open in SecureBin — initial action inference', () => {
+ function inferInitialAction(text: string): EditorAction {
+ return text.trim().includes(CIPHER_PREFIX) ? EditorAction.DECRYPT : EditorAction.POST_PASTEBIN
+ }
+
+ it('routes cipher text to DECRYPT', () => {
+ expect(inferInitialAction('C_TXT:iv:salt:ciphertext')).toBe(EditorAction.DECRYPT)
+ })
+
+ it('routes plain text to POST_PASTEBIN', () => {
+ expect(inferInitialAction('just some selected text')).toBe(EditorAction.POST_PASTEBIN)
+ })
+
+ it('handles whitespace-padded cipher text', () => {
+ expect(inferInitialAction(' C_TXT:data ')).toBe(EditorAction.DECRYPT)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// isEncryptionAction drives Pastebin link routing
+// ---------------------------------------------------------------------------
+
+describe('isEncryptionAction drives DECRYPT_PASTEBIN vs OPEN_PASTEBIN', () => {
+ const url = 'pastebin.com/abc123'
+
+ it('POST default → OPEN_PASTEBIN for Pastebin links', () => {
+ expect(isEncryptionAction(EditorAction.POST_PASTEBIN)).toBe(false)
+ expect(detectAction(url, EditorAction.POST_PASTEBIN)).toBe(EditorAction.OPEN_PASTEBIN)
+ })
+
+ it('ENCRYPT default → DECRYPT_PASTEBIN for Pastebin links', () => {
+ expect(isEncryptionAction(EditorAction.ENCRYPT)).toBe(true)
+ expect(detectAction(url, EditorAction.ENCRYPT)).toBe(EditorAction.DECRYPT_PASTEBIN)
+ })
+
+ it('ENCRYPT_PASTEBIN default → DECRYPT_PASTEBIN for Pastebin links', () => {
+ expect(isEncryptionAction(EditorAction.ENCRYPT_PASTEBIN)).toBe(true)
+ expect(detectAction(url, EditorAction.ENCRYPT_PASTEBIN)).toBe(EditorAction.DECRYPT_PASTEBIN)
+ })
+})
diff --git a/v2/src/lib/pastebin.e2e.test.ts b/v2/src/lib/pastebin.e2e.test.ts
new file mode 100644
index 0000000..2c54ee2
--- /dev/null
+++ b/v2/src/lib/pastebin.e2e.test.ts
@@ -0,0 +1,126 @@
+/**
+ * End-to-end tests for the Pastebin integration.
+ *
+ * These tests make real HTTP requests. They require:
+ * PASTEBIN_API_KEY= in .env or the environment.
+ *
+ * Run with:
+ * npx vitest run src/lib/pastebin.e2e.test.ts
+ */
+import { describe, it, expect, beforeAll } from 'vitest'
+import { postPastebin, getPastebin, isValidDevKey } from './pastebin'
+import { CORS_PROXY } from './constants'
+
+const API_KEY = process.env.PASTEBIN_API_KEY ?? ''
+const SKIP = !API_KEY
+
+// Unique marker so we can verify the paste was actually stored
+const TEST_CONTENT = `SecureBin e2e test — ${new Date().toISOString()}`
+
+// ─── 1. Sanity: environment ───────────────────────────────────────────────────
+
+describe('environment', () => {
+ it('PASTEBIN_API_KEY is set in the environment', () => {
+ expect(API_KEY, 'Set PASTEBIN_API_KEY in .env').toBeTruthy()
+ expect(API_KEY.length).toBeGreaterThan(0)
+ })
+
+ it('CORS_PROXY uses the cloudflare-cors-anywhere ? format', () => {
+ // Format: https://proxy/?https://target.com (NOT ?uri=)
+ expect(CORS_PROXY).toContain('cors.securebin.workers.dev/?')
+ expect(CORS_PROXY).not.toContain('?uri=')
+ })
+})
+
+// ─── 2. Direct Pastebin (no proxy) ───────────────────────────────────────────
+// Isolates whether the API key itself is valid before involving the proxy.
+
+describe('Pastebin API — direct (no proxy)', { skip: SKIP }, () => {
+ it('key is accepted by Pastebin userdetails endpoint', async () => {
+ const body = new URLSearchParams()
+ body.append('api_dev_key', API_KEY)
+ body.append('api_option', 'userdetails')
+
+ const res = await fetch('https://pastebin.com/api/api_post.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ })
+ const text = await res.text()
+ console.log('[direct userdetails]', text.slice(0, 120))
+
+ // A valid dev key returns "invalid api_user_key" (user key not provided, not dev key)
+ expect(text).not.toContain('invalid api_dev_key')
+ }, 15_000)
+
+ it('posts a paste directly and returns a pastebin.com URL', async () => {
+ const body = new URLSearchParams()
+ body.append('api_dev_key', API_KEY)
+ body.append('api_paste_code', TEST_CONTENT)
+ body.append('api_option', 'paste')
+
+ const res = await fetch('https://pastebin.com/api/api_post.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ })
+ const text = await res.text()
+ console.log('[direct post]', text)
+
+ expect(text).toMatch(/^https:\/\/pastebin\.com\//)
+ }, 15_000)
+})
+
+// ─── 3. CORS proxy reachability ──────────────────────────────────────────────
+
+describe('CORS proxy', () => {
+ it('proxy is reachable and does not return an error page', async () => {
+ // Hit the proxy info page (no ?uri target) — should return the usage text,
+ // NOT a Cloudflare 1101 "Worker threw exception" page.
+ const res = await fetch('https://cors.securebin.workers.dev/')
+ const text = await res.text()
+ console.log('[proxy info page]', text.slice(0, 120))
+
+ expect(text).not.toContain('Worker threw exception')
+ expect(text).not.toContain('\n