diff --git a/public/locales/en/string.json b/public/locales/en/string.json index 2994890..d5c7f96 100644 --- a/public/locales/en/string.json +++ b/public/locales/en/string.json @@ -280,8 +280,13 @@ "longDescription": "This tool URL-decodes a previously URL-encoded string. URL-decoding is the inverse operation of URL-encoding. All percent-encoded characters get decoded to characters that you can understand. Some of the most well known percent-encoded values are %20 for a space, %3a for a colon, %2f for a slash, and %3f for a question mark. The two digits following the percent sign are character's char code values in hex.", "title": "String URL decoder" }, - "inputTitle": "Input String(URL-escaped)", "resultTitle": "Output string" + }, + "privatebin": { + "title": "PrivateBin", + "description": "A secure paste service for sharing text content with encryption and expiration options.", + "shortDescription": "Share text securely with encryption and expiration", + "longDescription": "PrivateBin is a secure paste service that allows you to share text content with others. Your content is encrypted and can be configured with expiration times and burn-after-reading options. Perfect for sharing sensitive information, code snippets, or any text content that needs to be shared temporarily and securely." } } diff --git a/src/pages/tools/string/index.ts b/src/pages/tools/string/index.ts index bd645f7..5ae998a 100644 --- a/src/pages/tools/string/index.ts +++ b/src/pages/tools/string/index.ts @@ -1,3 +1,4 @@ +import { tool as stringPrivatebin } from './privatebin/meta'; import { tool as stringRemoveDuplicateLines } from './remove-duplicate-lines/meta'; import { tool as stringRotate } from './rotate/meta'; import { tool as stringQuote } from './quote/meta'; diff --git a/src/pages/tools/string/privatebin/index.tsx b/src/pages/tools/string/privatebin/index.tsx new file mode 100644 index 0000000..8c3d84a --- /dev/null +++ b/src/pages/tools/string/privatebin/index.tsx @@ -0,0 +1,269 @@ +import React, { useState } from 'react'; +import { + Box, + Button, + TextField, + FormControlLabel, + Checkbox, + Select, + MenuItem, + FormControl, + InputLabel, + Typography, + Alert, + Paper, + Tabs, + Tab +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import { InitialValuesType } from './types'; +import { createPaste, retrievePaste } from './service'; +import ToolTextInput from '@components/input/ToolTextInput'; +import ToolTextResult from '@components/result/ToolTextResult'; +import { GetGroupsType } from '@components/options/ToolOptions'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + return ( + + ); +} + +const initialValues: InitialValuesType = { + expiration: '1day', + burnAfterReading: false, + password: '' +}; + +export default function PrivateBin({ + title, + longDescription +}: ToolComponentProps) { + const { t } = useTranslation('string'); + const [input, setInput] = useState(''); + const [result, setResult] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [tabValue, setTabValue] = useState(0); + const [retrieveId, setRetrieveId] = useState(''); + const [retrievePassword, setRetrievePassword] = useState(''); + + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + setResult(''); + setError(''); + }; + + const compute = async (values: InitialValuesType, input: string) => { + if (!input.trim()) return; + + setLoading(true); + setError(''); + + try { + const pasteId = await createPaste(input, values); + setResult( + `Paste created successfully!\n\nPaste ID: ${pasteId}\n\nShare this ID with others to retrieve the content.` + ); + } catch (err) { + setError(`Failed to create paste: ${err}`); + } finally { + setLoading(false); + } + }; + + const handleRetrieve = async () => { + if (!retrieveId.trim() || !retrievePassword.trim()) { + setError('Please enter both paste ID and password'); + return; + } + + setLoading(true); + setError(''); + + try { + const content = await retrievePaste(retrieveId, retrievePassword); + setResult(`Retrieved content:\n\n${content}`); + } catch (err) { + setError(`Failed to retrieve paste: ${err}`); + } finally { + setLoading(false); + } + }; + + const getGroups: GetGroupsType = ({ + values, + updateField + }) => [ + { + title: 'Paste Settings', + component: ( + + + Expiration + + + + updateField('password', e.target.value)} + type="password" + sx={{ mb: 2 }} + /> + + + updateField('burnAfterReading', e.target.checked) + } + /> + } + label="Burn after reading" + /> + + ) + } + ]; + + const renderCustomInput = (values: InitialValuesType) => ( + + + + + + + + + + + Create a new paste + + + Enter your text below and configure the settings. Your content + will be encrypted and stored securely. + + + + + + + + + + + + + + Retrieve a paste + + + Enter the paste ID and password to retrieve the content. + + + + setRetrieveId(e.target.value)} + sx={{ mb: 2 }} + /> + + setRetrievePassword(e.target.value)} + type="password" + sx={{ mb: 3 }} + /> + + + + + + {error && ( + + {error} + + )} + + ); + + return ( + } + initialValues={initialValues} + getGroups={getGroups} + compute={compute} + input={input} + setInput={setInput} + toolInfo={{ + title: `What is ${title}?`, + description: + longDescription || + 'A secure paste service for sharing text content with encryption and expiration options.' + }} + /> + ); +} diff --git a/src/pages/tools/string/privatebin/meta.ts b/src/pages/tools/string/privatebin/meta.ts new file mode 100644 index 0000000..94702ff --- /dev/null +++ b/src/pages/tools/string/privatebin/meta.ts @@ -0,0 +1,15 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('string', { + i18n: { + name: 'string:privatebin.title', + description: 'string:privatebin.description', + shortDescription: 'string:privatebin.shortDescription', + longDescription: 'string:privatebin.longDescription' + }, + path: 'privatebin', + icon: 'material-symbols:content-paste', + keywords: ['privatebin', 'share', 'text', 'secure', 'paste', 'temporary'], + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/string/privatebin/privatebin.service.test.ts b/src/pages/tools/string/privatebin/privatebin.service.test.ts new file mode 100644 index 0000000..116d3be --- /dev/null +++ b/src/pages/tools/string/privatebin/privatebin.service.test.ts @@ -0,0 +1,6 @@ +import { expect, describe, it } from 'vitest'; +// import { main } from './service'; +// +// describe('privatebin', () => { +// +// }) diff --git a/src/pages/tools/string/privatebin/service.ts b/src/pages/tools/string/privatebin/service.ts new file mode 100644 index 0000000..49bcdee --- /dev/null +++ b/src/pages/tools/string/privatebin/service.ts @@ -0,0 +1,178 @@ +import { InitialValuesType, PasteData } from './types'; + +// Simple encryption using Web Crypto API +async function encryptText(text: string, password: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(text); + + // Generate key from password + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + { name: 'PBKDF2' }, + false, + ['deriveBits', 'deriveKey'] + ); + + const key = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: encoder.encode('privatebin-salt'), + iterations: 100000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt'] + ); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + data + ); + + return ( + btoa(String.fromCharCode(...new Uint8Array(encrypted))) + + '.' + + btoa(String.fromCharCode(...iv)) + ); +} + +async function decryptText( + encryptedData: string, + password: string +): Promise { + try { + const [encrypted, iv] = encryptedData.split('.'); + const encryptedBytes = new Uint8Array( + atob(encrypted) + .split('') + .map((c) => c.charCodeAt(0)) + ); + const ivBytes = new Uint8Array( + atob(iv) + .split('') + .map((c) => c.charCodeAt(0)) + ); + + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + { name: 'PBKDF2' }, + false, + ['deriveBits', 'deriveKey'] + ); + + const key = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: encoder.encode('privatebin-salt'), + iterations: 100000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['decrypt'] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: ivBytes }, + key, + encryptedBytes + ); + + return new TextDecoder().decode(decrypted); + } catch (error) { + throw new Error('Failed to decrypt: Invalid password or corrupted data'); + } +} + +function generateId(): string { + return ( + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) + ); +} + +function getExpirationTime(expiration: string): number { + const now = Date.now(); + switch (expiration) { + case '1hour': + return now + 60 * 60 * 1000; + case '1day': + return now + 24 * 60 * 60 * 1000; + case '1week': + return now + 7 * 24 * 60 * 60 * 1000; + case '1month': + return now + 30 * 24 * 60 * 60 * 1000; + case 'never': + return 0; + default: + return now + 24 * 60 * 60 * 1000; + } +} + +export async function createPaste( + content: string, + options: InitialValuesType +): Promise { + const pasteData: PasteData = { + id: generateId(), + content: await encryptText(content, options.password || 'default'), + expiration: options.expiration, + burnAfterReading: options.burnAfterReading, + password: options.password, + createdAt: Date.now() + }; + + // Store in localStorage (in a real app, this would be server-side) + const pastes = JSON.parse(localStorage.getItem('privatebin_pastes') || '{}'); + pastes[pasteData.id] = pasteData; + localStorage.setItem('privatebin_pastes', JSON.stringify(pastes)); + + return pasteData.id; +} + +export async function retrievePaste( + id: string, + password: string +): Promise { + const pastes = JSON.parse(localStorage.getItem('privatebin_pastes') || '{}'); + const paste = pastes[id]; + + if (!paste) { + throw new Error('Paste not found'); + } + + // Check expiration + if ( + paste.expiration !== 'never' && + paste.createdAt + getExpirationTime(paste.expiration) < Date.now() + ) { + delete pastes[id]; + localStorage.setItem('privatebin_pastes', JSON.stringify(pastes)); + throw new Error('Paste has expired'); + } + + const decryptedContent = await decryptText(paste.content, password); + + // Delete if burn after reading + if (paste.burnAfterReading) { + delete pastes[id]; + localStorage.setItem('privatebin_pastes', JSON.stringify(pastes)); + } + + return decryptedContent; +} + +export function main( + input: string, + options: InitialValuesType +): Promise { + return createPaste(input, options); +} diff --git a/src/pages/tools/string/privatebin/types.ts b/src/pages/tools/string/privatebin/types.ts new file mode 100644 index 0000000..b4822e1 --- /dev/null +++ b/src/pages/tools/string/privatebin/types.ts @@ -0,0 +1,14 @@ +export type InitialValuesType = { + expiration: '1hour' | '1day' | '1week' | '1month' | 'never'; + burnAfterReading: boolean; + password: string; +}; + +export interface PasteData { + id: string; + content: string; + expiration: string; + burnAfterReading: boolean; + password?: string; + createdAt: number; +}