feat(string): Created PrivateBin tool

This commit is contained in:
AshAnand34 2025-07-18 16:53:27 -07:00
commit 1984733c86
7 changed files with 489 additions and 1 deletions

View file

@ -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."
}
}

View file

@ -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';

View file

@ -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 (
<div
role="tabpanel"
hidden={value !== index}
id={`privatebin-tabpanel-${index}`}
aria-labelledby={`privatebin-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
}
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<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
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<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'Paste Settings',
component: (
<Box>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Expiration</InputLabel>
<Select
value={values.expiration}
onChange={(e) =>
updateField(
'expiration',
e.target.value as InitialValuesType['expiration']
)
}
label="Expiration"
>
<MenuItem value="1hour">1 Hour</MenuItem>
<MenuItem value="1day">1 Day</MenuItem>
<MenuItem value="1week">1 Week</MenuItem>
<MenuItem value="1month">1 Month</MenuItem>
<MenuItem value="never">Never</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="Password (optional)"
value={values.password}
onChange={(e) => updateField('password', e.target.value)}
type="password"
sx={{ mb: 2 }}
/>
<FormControlLabel
control={
<Checkbox
checked={values.burnAfterReading}
onChange={(e) =>
updateField('burnAfterReading', e.target.checked)
}
/>
}
label="Burn after reading"
/>
</Box>
)
}
];
const renderCustomInput = (values: InitialValuesType) => (
<Box>
<Paper sx={{ width: '100%', mb: 2 }}>
<Tabs
value={tabValue}
onChange={handleTabChange}
aria-label="privatebin tabs"
>
<Tab label="Create Paste" />
<Tab label="Retrieve Paste" />
</Tabs>
<TabPanel value={tabValue} index={0}>
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
Create a new paste
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Enter your text below and configure the settings. Your content
will be encrypted and stored securely.
</Typography>
</Box>
<ToolTextInput
value={input}
onChange={setInput}
title="Paste Content"
placeholder="Enter your text here..."
/>
<Box sx={{ mt: 3 }}>
<Button
variant="contained"
onClick={() => compute(values, input)}
disabled={loading || !input.trim()}
fullWidth
>
{loading ? 'Creating...' : 'Create Paste'}
</Button>
</Box>
</TabPanel>
<TabPanel value={tabValue} index={1}>
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
Retrieve a paste
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Enter the paste ID and password to retrieve the content.
</Typography>
</Box>
<TextField
fullWidth
label="Paste ID"
value={retrieveId}
onChange={(e) => setRetrieveId(e.target.value)}
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="Password"
value={retrievePassword}
onChange={(e) => setRetrievePassword(e.target.value)}
type="password"
sx={{ mb: 3 }}
/>
<Button
variant="contained"
onClick={handleRetrieve}
disabled={loading || !retrieveId.trim() || !retrievePassword.trim()}
fullWidth
>
{loading ? 'Retrieving...' : 'Retrieve Paste'}
</Button>
</TabPanel>
</Paper>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</Box>
);
return (
<ToolContent
title={title}
inputComponent={renderCustomInput(initialValues)}
resultComponent={<ToolTextResult value={result} />}
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.'
}}
/>
);
}

View file

@ -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'))
});

View file

@ -0,0 +1,6 @@
import { expect, describe, it } from 'vitest';
// import { main } from './service';
//
// describe('privatebin', () => {
//
// })

View file

@ -0,0 +1,178 @@
import { InitialValuesType, PasteData } from './types';
// Simple encryption using Web Crypto API
async function encryptText(text: string, password: string): Promise<string> {
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<string> {
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<string> {
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<string> {
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<string> {
return createPaste(input, options);
}

View file

@ -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;
}