Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 152 additions & 27 deletions cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ catch (error) {
}
// Import analytics
import { analytics } from './analytics.js';
// Import large file upload modules
import { shouldUseMultipart, uploadLargeFile, resumeUpload, abortUpload, } from './largeFileUpload.js';
import { LARGE_FILE_CONSTANTS } from '../src/types/index.js';
import { listResumeStates, formatResumeState, } from './resumeState.js';
// Import our core modules
import { generateKeyPair, addFriendKey, listKeys, getKey, removeKey, loadKeyDatabase, saveKeyDatabase, ensureDirectories } from './keyManager.js';
import { encryptContent, decryptContent } from './encryptionUtils.js';
Expand Down Expand Up @@ -860,6 +864,12 @@ program
.option('--enhanced', 'Use enhanced interactive mode with advanced key selection features')
.option('--debug', 'Debug mode: show encrypted content without uploading')
.option('-c, --copy', 'Copy the URL to clipboard automatically')
// Large file options
.option('--chunk-size <mb>', 'Chunk size in MB for large file uploads (default: 10, min: 5)')
.option('--resume <file>', 'Resume an interrupted upload from a saved state file')
.option('--no-progress', 'Disable progress bar for large file uploads')
.option('--list-uploads', 'List interrupted uploads that can be resumed')
.option('--abort-upload <sessionId>', 'Abort an interrupted upload by session ID')
// PGP options
.option('--pgp', 'Use PGP encryption instead of hybrid RSA/AES')
.option('--pgp-key-file <path>', 'Use a specific PGP public key file for encryption')
Expand Down Expand Up @@ -922,6 +932,74 @@ Encryption:
}
return;
}
// ============================================
// Large File Upload Handlers
// ============================================
// List interrupted uploads
if (options.listUploads) {
const states = await listResumeStates();
if (states.length === 0) {
console.log('No interrupted uploads found.');
}
else {
console.log(`Found ${states.length} interrupted upload(s):\n`);
for (const state of states) {
console.log(formatResumeState(state));
console.log('---');
}
console.log('\nTo resume: dedpaste send --resume <session-id-or-path>');
console.log('To abort: dedpaste send --abort-upload <session-id>');
}
return;
}
// Abort an interrupted upload
if (options.abortUpload) {
try {
await abortUpload(options.abortUpload);
console.log('Upload aborted successfully.');
}
catch (error) {
console.error(`Failed to abort upload: ${error.message}`);
process.exit(1);
}
return;
}
// Resume an interrupted upload
if (options.resume) {
try {
console.log('Resuming interrupted upload...');
const url = await resumeUpload(options.resume, {
showProgress: options.progress !== false,
});
// Handle clipboard and output
if (options.copy) {
try {
const cleanUrl = url.trim();
if (clipboard.default) {
clipboard.default.writeSync(cleanUrl);
}
else {
clipboard.writeSync(cleanUrl);
}
}
catch (error) {
console.error(`Unable to copy to clipboard: ${error.message}`);
}
}
if (options.output) {
console.log(url.trim());
}
else {
console.log(`\n✓ Upload resumed and completed!\n${options.copy ? '📋 URL copied to clipboard: ' : '📋 '} ${url.trim()}\n`);
}
process.exit(0);
}
catch (error) {
console.error(`Failed to resume upload: ${error.message}`);
process.exit(1);
}
return;
}
let content;
let contentType;
let recipientName = options.for;
Expand Down Expand Up @@ -1075,34 +1153,80 @@ Encryption:
try {
// Extract filename if uploading a file
const filename = options.file ? path.basename(options.file) : '';
const headers = {
'Content-Type': contentType,
'User-Agent': `dedpaste-cli/${packageJson.version}`
};
// Include filename header if we have a file
if (filename) {
headers['X-Filename'] = filename;
let url;
// Check if we should use multipart upload for large files
// Only use multipart for file uploads (not stdin) and files over threshold
const useMultipart = options.file &&
!shouldEncrypt && // Encrypted large files not yet supported in multipart
shouldUseMultipart(options.file);
if (useMultipart) {
// Large file - use multipart upload
console.log(`Large file detected (>${LARGE_FILE_CONSTANTS.MULTIPART_THRESHOLD / (1024 * 1024)}MB). Using multipart upload...`);
// Parse chunk size if provided
let chunkSize;
if (options.chunkSize) {
const chunkMb = parseInt(options.chunkSize, 10);
if (isNaN(chunkMb) || chunkMb < 5) {
console.error('Error: Chunk size must be at least 5 MB');
process.exit(1);
}
chunkSize = chunkMb * 1024 * 1024;
}
try {
url = await uploadLargeFile(options.file, {
apiUrl: API_URL,
chunkSize,
isOneTime: options.temp,
isEncrypted: false, // Encryption handled separately
showProgress: options.progress !== false,
});
}
catch (uploadError) {
console.error(`Large file upload failed: ${uploadError.message}`);
console.error('You can resume the upload later with: dedpaste send --resume <session-id>');
console.error('To see interrupted uploads: dedpaste send --list-uploads');
process.exit(1);
}
// Track paste creation for large files
analytics.trackPasteCreated({
type: options.temp ? 'one_time' : 'regular',
content_type: contentType,
size_bytes: fs.statSync(options.file).size,
encryption_type: 'none',
method: 'file' // Use 'file' as the method type
});
}
const response = await fetch(`${API_URL}${endpoint}`, {
method: 'POST',
headers,
body: content
});
if (!response.ok) {
console.error(`Error: ${response.status} ${response.statusText}`);
const errorText = await response.text();
console.error(errorText);
process.exit(1);
else {
// Standard upload (small files or stdin)
const headers = {
'Content-Type': contentType,
'User-Agent': `dedpaste-cli/${packageJson.version}`
};
// Include filename header if we have a file
if (filename) {
headers['X-Filename'] = filename;
}
const response = await fetch(`${API_URL}${endpoint}`, {
method: 'POST',
headers,
body: content
});
if (!response.ok) {
console.error(`Error: ${response.status} ${response.statusText}`);
const errorText = await response.text();
console.error(errorText);
process.exit(1);
}
url = await response.text();
// Track paste creation
analytics.trackPasteCreated({
type: options.temp ? 'one_time' : 'regular',
content_type: contentType,
size_bytes: content.length,
encryption_type: options.encrypt ? 'RSA' : 'none',
method: options.file ? 'file' : 'stdin'
});
}
const url = await response.text();
// Track paste creation
analytics.trackPasteCreated({
type: options.temp ? 'one_time' : 'regular',
content_type: contentType,
size_bytes: content.length,
encryption_type: options.encrypt ? 'RSA' : 'none',
method: options.file ? 'file' : 'stdin'
});
// Copy to clipboard if requested
if (options.copy) {
try {
Expand Down Expand Up @@ -1132,8 +1256,9 @@ Encryption:
encryptionMessage = '🔒 This paste is encrypted and can only be decrypted with your private key\n';
}
}
const isLargeFile = useMultipart;
console.log(`
✓ Paste created successfully!
✓ Paste created successfully!${isLargeFile ? ' (multipart upload)' : ''}
${options.temp ? '⚠️ This is a one-time paste that will be deleted after first view\n' : ''}
${encryptionMessage}
${options.copy ? '📋 URL copied to clipboard: ' : '📋 '} ${url.trim()}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,8 +975,7 @@ async function handleGet(
return new Response("Paste not found", { status: 404 });
}

// Regular paste - get the content and metadata
const content = await paste.arrayBuffer();
// Regular paste - get metadata (available without reading the body)
let contentType = "text/plain";
let filename = "";

Expand Down Expand Up @@ -1014,7 +1013,8 @@ async function handleGet(
filename.endsWith(".markdown");

if (isMarkdown && !isEncrypted && !wantsRaw) {
// Convert markdown to HTML for browser viewing
// Markdown rendering requires buffering the content into memory
const content = await paste.arrayBuffer();
const textContent = new TextDecoder().decode(content);
const renderedHTML = await renderMarkdownAsHTML(textContent, id, filename);

Expand All @@ -1036,11 +1036,13 @@ async function handleGet(
const isViewableInBrowser = isViewableContentType(contentType);
const disposition = isViewableInBrowser ? "inline" : "attachment";

// Return the paste content with robust caching headers
return new Response(content, {
// Stream the R2 object body directly to avoid buffering large files in memory.
// This is critical for multipart-uploaded files which can be up to 5GB.
return new Response(paste.body, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `${disposition}; filename="${effectiveFilename}"`,
"Content-Length": paste.size.toString(),
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expand Down
5 changes: 5 additions & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,8 @@ preview_id = "0d25f4b9e61a44ab92634e7941cea0a0"
binding = "UPLOAD_SESSIONS"
id = "c0251d912bb045df9c363b1b00be81a1"
preview_id = "3e505a5b19a14816ba1105c7772a2a4c"

[observability]
enabled = true
head_sampling_rate = 1