- Drop files or a folder. Names, paths, and file contents are encrypted in this browser
- before upload.
+ Use the web app to drop files or a folder. Names, paths, and file contents are
+ encrypted in this browser before upload.
{uploadError ?
{uploadError}
: null}
@@ -401,8 +414,9 @@ export function UploadStudio() {
- Uploads continue automatically when this browser returns after a refresh or reopen.
- Privacy mode keeps less sensitive state on this device, with fewer local controls.
+ Uploads continue automatically when you return here in the same browser on this device
+ after a refresh or reopen. Privacy mode keeps less sensitive state on this device, with
+ fewer local controls.
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index f721d62..1d97b9c 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -858,7 +858,7 @@ h3 {
0 18px 30px rgba(2, 5, 2, 0.18);
}
-.dropzone > .warning {
+.dropzone-shell > .warning {
margin-top: 0;
}
diff --git a/apps/web/src/lib/seo/site.ts b/apps/web/src/lib/seo/site.ts
index ca4aa40..b9f98a4 100644
--- a/apps/web/src/lib/seo/site.ts
+++ b/apps/web/src/lib/seo/site.ts
@@ -4,9 +4,26 @@
export const SITE_NAME = 'Xdrop'
export const PROJECT_ONE_LINER =
- 'Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server.'
-export const DEFAULT_SEO_TITLE = 'Open Source Encrypted File Transfer in the Browser | Xdrop'
+ 'Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server.'
+export const SHORT_POSITIONING_SUMMARY =
+ 'Browser-first encrypted file transfer, with agent-ready terminal workflows.'
+export const TERMINAL_SUPPORT_BLURB =
+ 'Use Xdrop in the browser for normal sharing, or through an agent when you need to move files out of a cloud server, remote container, or automated terminal workflow.'
+export const DEFAULT_SEO_TITLE =
+ 'Open Source Encrypted File Transfer for Browsers and Agents | Xdrop'
export const DEFAULT_SEO_DESCRIPTION = PROJECT_ONE_LINER
+export const HISTORY_PAGE_TITLE = 'Manage Transfers on This Device | Xdrop'
+export const HISTORY_PAGE_DESCRIPTION =
+ 'Manage encrypted transfers stored in this browser on this device. There is no account or cross-device history.'
+export const SHARE_PAGE_TITLE = 'Share the Full Link | Xdrop'
+export const SHARE_PAGE_DESCRIPTION =
+ 'Review upload status and copy the full share link for a transfer staged in this browser on this device.'
+export const RECEIVE_PAGE_TITLE = 'Download and Decrypt in the Browser | Xdrop'
+export const RECEIVE_PAGE_DESCRIPTION =
+ 'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.'
+export const NOT_FOUND_PAGE_TITLE = 'Page Not Found | Xdrop'
+export const NOT_FOUND_PAGE_DESCRIPTION =
+ 'The address does not map to a page in Xdrop. If this came from a shared transfer, ask for the full URL, including the #k=... decryption fragment.'
export const DEFAULT_OG_IMAGE_PATH = '/brand-lockup-horizontal.png'
export const DEFAULT_OG_IMAGE_ALT = 'Xdrop horizontal brand lockup'
export const DEFAULT_LOGO_PATH = '/brand-symbol-512.png'
diff --git a/apps/web/src/lib/seo/usePageMetadata.test.tsx b/apps/web/src/lib/seo/usePageMetadata.test.tsx
index 60016e3..a2ce491 100644
--- a/apps/web/src/lib/seo/usePageMetadata.test.tsx
+++ b/apps/web/src/lib/seo/usePageMetadata.test.tsx
@@ -27,7 +27,7 @@ describe('usePageMetadata', () => {
render(
{
,
)
- expect(document.title).toBe('Open Source Encrypted File Transfer in the Browser | Xdrop')
+ expect(document.title).toBe(
+ 'Open Source Encrypted File Transfer for Browsers and Agents | Xdrop',
+ )
expect(readMetaByName('description')).toBe('SEO test description')
expect(readMetaByName('robots')).toContain('index, follow')
expect(readMetaByProperty('og:title')).toBe(
- 'Open Source Encrypted File Transfer in the Browser | Xdrop',
+ 'Open Source Encrypted File Transfer for Browsers and Agents | Xdrop',
)
expect(readMetaByProperty('og:url')).toBe(new URL('/', window.location.origin).toString())
expect(readCanonical()).toBe(new URL('/', window.location.origin).toString())
@@ -89,7 +91,7 @@ describe('usePageMetadata', () => {
expect(document.head.querySelectorAll('meta[name="description"]')).toHaveLength(1)
expect(document.head.querySelectorAll('link[rel="canonical"]')).toHaveLength(1)
expect(readMetaByName('description')).toContain(
- 'Xdrop is an open source file transfer app that encrypts files in your browser',
+ 'Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows',
)
expect(readMetaByName('robots')).toContain('index, follow')
expect(readMetaByProperty('og:type')).toBe('website')
diff --git a/package.json b/package.json
index 06f06eb..65117bd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "xdrop",
- "description": "Open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server",
+ "description": "Open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server",
"private": true,
"license": "AGPL-3.0-only",
"homepage": "https://github.com/xixu-me/xdrop",
diff --git a/skills/xdrop/SKILL.md b/skills/xdrop/SKILL.md
new file mode 100644
index 0000000..5ce43ca
--- /dev/null
+++ b/skills/xdrop/SKILL.md
@@ -0,0 +1,86 @@
+---
+name: xdrop
+description: Use this skill when the user wants to send or fetch files through an Xdrop server from the terminal, asks to automate encrypted Xdrop share-link workflows, provides an Xdrop `/t/:transferId#k=...` link to download and decrypt locally, or needs Xdrop CLI flags such as `--quiet`, `--json`, `--expires-in`, `--output`, or `--api-url`, even if they do not explicitly mention the skill name.
+---
+
+# Xdrop
+
+Use the bundled scripts inside this skill directory.
+
+## Available scripts
+
+- `scripts/upload.mjs` — Upload local files or directories to an Xdrop server and print the share link
+- `scripts/download.mjs` — Download an Xdrop share link, decrypt it locally, and save the files
+
+Environment requirements:
+
+- Bun
+- Local filesystem access
+- Network access to the target Xdrop server
+
+## Upload
+
+Run from the skill root:
+
+```bash
+bun scripts/upload.mjs --server
[...]
+```
+
+Prefer these flags when relevant:
+
+- `--quiet`: suppress progress output and keep stdout clean
+- `--json`: return `transferId`, `shareUrl`, and `expiresAt`
+- `--expires-in `: choose a supported expiry
+- `--api-url `: override the default `/api/v1`
+- `--name `: set the transfer display name
+- `--concurrency `: limit parallel uploads per file
+
+Useful examples:
+
+```bash
+bun scripts/upload.mjs --server http://localhost:8080 ./dist/report.pdf
+bun scripts/upload.mjs --server http://localhost:8080 --quiet ./archive.zip
+bun scripts/upload.mjs --server http://localhost:8080 --expires-in 600 --json ./notes.txt
+```
+
+If the user wants verification, upload a small temporary file and then confirm the public transfer API or browser can open the returned link.
+
+## Download
+
+Require the full share link, including `#k=...`. Without the fragment key, the transfer cannot be decrypted.
+
+Run from the skill root:
+
+```bash
+bun scripts/download.mjs ""
+```
+
+Prefer these flags when relevant:
+
+- `--output `: choose the destination directory
+- `--quiet`: suppress progress output and keep stdout clean
+- `--json`: return `transferId`, `outputRoot`, and saved file paths
+- `--api-url `: override the default `/api/v1`
+
+Useful examples:
+
+```bash
+bun scripts/download.mjs "http://localhost:8080/t/abc123#k=..."
+bun scripts/download.mjs --output ./downloads "http://localhost:8080/t/abc123#k=..."
+bun scripts/download.mjs --quiet --json --output ./downloads "http://localhost:8080/t/abc123#k=..."
+```
+
+By default the downloader writes to `./xdrop-` and preserves the manifest's relative paths.
+
+## Gotchas
+
+- A download link without the `#k=...` fragment is not decryptable. Ask for the full original share URL.
+- Use `--quiet` whenever another command or caller needs to capture stdout. Progress logs otherwise go to stderr, but the final result still matters.
+- The bundled scripts are self-contained. Prefer them over repository-level wrappers so the skill still works when used outside this repository.
+
+## Guardrails
+
+- Run the bundled scripts by relative path from the skill root. Do not rely on repository-level wrappers such as `bun run upload:cli` or `bun run download:cli`.
+- Prefer `--quiet` when another command or script needs to capture stdout.
+- Keep the full share link fragment intact for downloads.
+- Do not bypass the scripts' built-in path sanitization or transfer cleanup behavior with manual ad hoc commands unless the user explicitly asks.
diff --git a/skills/xdrop/scripts/download.mjs b/skills/xdrop/scripts/download.mjs
new file mode 100644
index 0000000..2dfc34b
--- /dev/null
+++ b/skills/xdrop/scripts/download.mjs
@@ -0,0 +1,389 @@
+import { mkdir, open } from 'node:fs/promises'
+import { dirname, resolve } from 'node:path'
+
+import { resolveApiUrl } from './upload.mjs'
+
+const MANIFEST_VERSION = 1
+const encoder = new TextEncoder()
+const decoder = new TextDecoder()
+let quietMode = false
+
+const HELP_TEXT = `Download files from an Xdrop share link and decrypt them locally.
+
+Usage:
+ bun
+
+Options:
+ --output Destination directory. Defaults to ./xdrop-.
+ --api-url Override the API root. Defaults to /api/v1.
+ --quiet Suppress progress output and only print the final result.
+ --json Print JSON instead of a plain output path.
+ --help Show this help.
+
+Examples:
+ bun scripts/download.mjs "http://localhost:8080/t/abc#k=..."
+ bun scripts/download.mjs --output ./downloads "http://localhost:8080/t/abc#k=..."
+`
+
+export async function main(argv = process.argv.slice(2)) {
+ const options = parseArgs(argv)
+ quietMode = options.quiet
+
+ if (options.help) {
+ process.stdout.write(`${HELP_TEXT}\n`)
+ return
+ }
+
+ if (!options.shareUrl) {
+ throw new Error('Provide a full Xdrop share link.')
+ }
+
+ const share = parseShareUrl(options.shareUrl)
+ const api = new XdropDownloadApiClient(resolveApiUrl(share.serverUrl, options.apiUrl))
+
+ logStatus(`Fetching transfer ${share.transferId}`)
+ const descriptor = await api.getPublicTransfer(share.transferId)
+ if (descriptor.status !== 'ready' || !descriptor.manifestUrl || !descriptor.wrappedRootKey) {
+ throw new Error(getTransferStatusError(descriptor.status))
+ }
+
+ const manifestResponse = await fetch(descriptor.manifestUrl)
+ if (!manifestResponse.ok) {
+ throw new Error(`Couldn't load the encrypted manifest (${manifestResponse.status}).`)
+ }
+
+ const envelopeBytes = new Uint8Array(await manifestResponse.arrayBuffer())
+ const rootKey = await unwrapRootKey(descriptor.wrappedRootKey, share.linkKey)
+ const manifest = await decryptManifest(rootKey, envelopeBytes)
+ const outputRoot = resolve(process.cwd(), options.output || `xdrop-${share.transferId}`)
+ await mkdir(outputRoot, { recursive: true })
+
+ const savedFiles = []
+ for (const [index, file] of manifest.files.entries()) {
+ const sanitizedPath = sanitizePath(file.relativePath || file.name)
+ if (!sanitizedPath) {
+ throw new Error(`Refusing to write an empty file path for ${file.fileId}.`)
+ }
+
+ const destination = resolve(outputRoot, ...sanitizedPath.split('/'))
+ await mkdir(dirname(destination), { recursive: true })
+ logStatus(`Downloading ${sanitizedPath} (${index + 1}/${manifest.files.length})`)
+ await downloadFile({
+ api,
+ transferId: share.transferId,
+ file,
+ rootKey,
+ destination,
+ })
+ savedFiles.push(destination)
+ }
+
+ if (options.json) {
+ process.stdout.write(
+ `${JSON.stringify(
+ {
+ transferId: share.transferId,
+ outputRoot,
+ files: savedFiles,
+ },
+ null,
+ 2,
+ )}\n`,
+ )
+ return
+ }
+
+ process.stdout.write(`${savedFiles.length === 1 ? savedFiles[0] : outputRoot}\n`)
+}
+
+export function parseArgs(argv) {
+ const options = {
+ output: '',
+ apiUrl: process.env.XDROP_API_URL?.trim() || '',
+ quiet: false,
+ json: false,
+ help: false,
+ shareUrl: '',
+ }
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const value = argv[index]
+ if (!value) {
+ continue
+ }
+
+ if (value === '--help' || value === '-h') {
+ options.help = true
+ continue
+ }
+ if (value === '--quiet') {
+ options.quiet = true
+ continue
+ }
+ if (value === '--json') {
+ options.json = true
+ continue
+ }
+ if (value === '--output') {
+ options.output = requireValue(argv, ++index, '--output')
+ continue
+ }
+ if (value === '--api-url') {
+ options.apiUrl = requireValue(argv, ++index, '--api-url')
+ continue
+ }
+ if (value.startsWith('--')) {
+ throw new Error(`Unknown option: ${value}`)
+ }
+ if (options.shareUrl) {
+ throw new Error('Only one share link can be downloaded at a time.')
+ }
+ options.shareUrl = value
+ }
+
+ return options
+}
+
+function requireValue(argv, index, flag) {
+ const value = argv[index]
+ if (!value) {
+ throw new Error(`Missing value for ${flag}`)
+ }
+ return value
+}
+
+export function parseShareUrl(input) {
+ const url = new URL(input)
+ const match = url.pathname.match(/\/t\/([^/]+)\/?$/u)
+ if (!match?.[1]) {
+ throw new Error('Share link must point to /t/:transferId.')
+ }
+
+ const params = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash)
+ const key = params.get('k')
+ if (!key) {
+ throw new Error('Share link is missing the decryption key fragment.')
+ }
+
+ url.hash = ''
+ url.search = ''
+ url.pathname = '/'
+
+ return {
+ transferId: match[1],
+ linkKey: fromBase64Url(key),
+ serverUrl: url,
+ }
+}
+
+export function sanitizePath(input) {
+ return input
+ .split(/[\\/]+/u)
+ .filter((segment) => segment && segment !== '.' && segment !== '..')
+ .map((segment) =>
+ Array.from(segment.replace(/[<>:"|?*]/gu, '_'))
+ .map((char) => ((char.codePointAt(0) ?? 0) < 32 ? '_' : char))
+ .join(''),
+ )
+ .join('/')
+}
+
+async function downloadFile({ api, transferId, file, rootKey, destination }) {
+ const chunks = Array.from({ length: file.totalChunks }, (_, chunkIndex) => ({
+ fileId: file.fileId,
+ chunkIndex,
+ }))
+ const urls = await api.createDownloadUrls(transferId, chunks)
+ const urlMap = new Map(urls.map((item) => [item.chunkIndex, item.url]))
+ const noncePrefix = fromBase64Url(file.noncePrefix)
+ const fileHandle = await open(destination, 'w')
+
+ try {
+ for (let chunkIndex = 0; chunkIndex < file.totalChunks; chunkIndex += 1) {
+ const url = urlMap.get(chunkIndex)
+ if (!url) {
+ throw new Error(`Missing download URL for ${file.relativePath} chunk ${chunkIndex}.`)
+ }
+
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error(`Chunk download failed with ${response.status}.`)
+ }
+
+ const ciphertext = new Uint8Array(await response.arrayBuffer())
+ const remainingBytes = Math.max(file.plaintextSize - chunkIndex * file.chunkSize, 0)
+ const plaintextChunkSize = Math.min(file.chunkSize, remainingBytes)
+ const plaintext = await decryptChunk({
+ rootKey,
+ transferId,
+ fileId: file.fileId,
+ chunkIndex,
+ noncePrefix,
+ plaintextChunkSize,
+ ciphertext,
+ })
+
+ await fileHandle.write(plaintext)
+ }
+ } finally {
+ await fileHandle.close()
+ }
+}
+
+async function unwrapRootKey(serializedEnvelope, linkKey) {
+ const envelope = JSON.parse(serializedEnvelope)
+ const wrappingKey = await deriveHkdfKey(linkKey, 'wrap-root')
+ const plaintext = await crypto.subtle.decrypt(
+ {
+ name: 'AES-GCM',
+ iv: fromBase64Url(envelope.iv),
+ },
+ wrappingKey,
+ fromBase64(envelope.ciphertext),
+ )
+
+ return new Uint8Array(plaintext)
+}
+
+async function decryptManifest(rootKey, envelopeBytes) {
+ const envelope = JSON.parse(decoder.decode(envelopeBytes))
+ const manifestKey = await deriveHkdfKey(rootKey, 'manifest')
+ const plaintext = await crypto.subtle.decrypt(
+ {
+ name: 'AES-GCM',
+ iv: fromBase64Url(envelope.iv),
+ },
+ manifestKey,
+ fromBase64(envelope.ciphertext),
+ )
+
+ return JSON.parse(decoder.decode(plaintext))
+}
+
+async function decryptChunk(options) {
+ const fileKey = await deriveHkdfKey(options.rootKey, `file:${options.fileId}`)
+ const iv = buildChunkIv(options.noncePrefix, options.chunkIndex)
+ const additionalData = encoder.encode(
+ [
+ options.transferId,
+ options.fileId,
+ options.chunkIndex,
+ options.plaintextChunkSize,
+ MANIFEST_VERSION,
+ ].join('|'),
+ )
+ const plaintext = await crypto.subtle.decrypt(
+ {
+ name: 'AES-GCM',
+ iv,
+ additionalData,
+ },
+ fileKey,
+ options.ciphertext,
+ )
+
+ return new Uint8Array(plaintext)
+}
+
+async function deriveHkdfKey(source, info) {
+ const sourceKey = await crypto.subtle.importKey('raw', source, 'HKDF', false, ['deriveKey'])
+ return crypto.subtle.deriveKey(
+ {
+ name: 'HKDF',
+ hash: 'SHA-256',
+ salt: new Uint8Array(),
+ info: encoder.encode(info),
+ },
+ sourceKey,
+ {
+ name: 'AES-GCM',
+ length: 256,
+ },
+ false,
+ ['encrypt', 'decrypt'],
+ )
+}
+
+function buildChunkIv(noncePrefix, chunkIndex) {
+ const iv = new Uint8Array(12)
+ iv.set(noncePrefix.slice(0, 8), 0)
+ new DataView(iv.buffer).setUint32(8, chunkIndex, false)
+ return iv
+}
+
+function fromBase64Url(value) {
+ const normalized = value.replace(/-/gu, '+').replace(/_/gu, '/')
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
+ return new Uint8Array(Buffer.from(padded, 'base64'))
+}
+
+function fromBase64(value) {
+ return new Uint8Array(Buffer.from(value, 'base64'))
+}
+
+function getTransferStatusError(status) {
+ switch (status) {
+ case 'expired':
+ return 'This share link has expired.'
+ case 'deleted':
+ return 'This transfer was deleted.'
+ case 'incomplete':
+ return 'This transfer is still uploading.'
+ default:
+ return 'This transfer is unavailable.'
+ }
+}
+
+function logStatus(message) {
+ if (quietMode) {
+ return
+ }
+ process.stderr.write(`${message}\n`)
+}
+
+class XdropDownloadApiClient {
+ constructor(baseUrl) {
+ this.baseUrl = baseUrl
+ }
+
+ async getPublicTransfer(transferId) {
+ return this.request(`/public/transfers/${transferId}`)
+ }
+
+ async createDownloadUrls(transferId, chunks) {
+ const response = await this.request(`/public/transfers/${transferId}/download-urls`, {
+ method: 'POST',
+ body: { chunks },
+ })
+ return response.items
+ }
+
+ async request(path, options = { method: 'GET' }) {
+ const response = await fetch(`${this.baseUrl}${path}`, {
+ method: options.method ?? 'GET',
+ headers: {
+ ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
+ },
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
+ })
+
+ if (!response.ok) {
+ const payload = await response.json().catch(() => ({}))
+ const detail = payload.message ?? payload.error ?? `Request failed with ${response.status}`
+ throw new Error(detail)
+ }
+
+ return response.json()
+ }
+}
+
+if (import.meta.main) {
+ main().catch(async (error) => {
+ if (quietMode) {
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
+ process.exit(1)
+ }
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
+ process.exit(1)
+ })
+}
diff --git a/skills/xdrop/scripts/upload.mjs b/skills/xdrop/scripts/upload.mjs
new file mode 100644
index 0000000..9922642
--- /dev/null
+++ b/skills/xdrop/scripts/upload.mjs
@@ -0,0 +1,738 @@
+import { createHash } from 'node:crypto'
+import { open, readdir, stat } from 'node:fs/promises'
+import { basename, extname, resolve } from 'node:path'
+
+const MANIFEST_VERSION = 1
+const WRAP_VERSION = 1
+const DEFAULT_EXPIRY_SECONDS = 60 * 60
+const MAX_UPLOAD_CONCURRENCY = 6
+const MAX_TRANSFER_BYTES = 256 * 1024 * 1024
+
+const encoder = new TextEncoder()
+let quietMode = false
+
+const HELP_TEXT = `Upload files to an Xdrop server and print the share link.
+
+Usage:
+ bun --server https://xdrop.example.com [...]
+
+Options:
+ --server Public Xdrop site URL. Can also be set with XDROP_SERVER.
+ --api-url Override the API root. Defaults to /api/v1.
+ --expires-in Transfer expiry in seconds. Default: ${DEFAULT_EXPIRY_SECONDS}.
+ --name Custom transfer display name.
+ --concurrency Parallel uploads per file. Default: 1, max: ${MAX_UPLOAD_CONCURRENCY}.
+ --quiet Suppress progress output and only print the final result.
+ --json Print JSON instead of a bare share link.
+ --help Show this help.
+
+Examples:
+ bun scripts/upload.mjs --server http://localhost:8080 ./dist/archive.zip
+ bun scripts/upload.mjs --server https://xdrop.example.com ./photo.jpg ./notes.txt
+`
+
+export async function main(argv = process.argv.slice(2)) {
+ const options = parseArgs(argv)
+ quietMode = options.quiet
+
+ if (options.help) {
+ process.stdout.write(`${HELP_TEXT}\n`)
+ return
+ }
+
+ if (!options.server) {
+ throw new Error('Missing --server. Pass the public Xdrop site URL or set XDROP_SERVER.')
+ }
+
+ if (options.inputs.length === 0) {
+ throw new Error('Choose at least one file or directory to upload.')
+ }
+
+ const serverUrl = normalizeSiteUrl(options.server)
+ const apiUrl = resolveApiUrl(serverUrl, options.apiUrl)
+ const files = await collectTransferInputs(options.inputs)
+ if (files.length === 0) {
+ throw new Error('No files were found in the selected paths.')
+ }
+
+ const displayName = options.name ?? defaultDisplayName(files)
+ const api = new XdropApiClient(apiUrl)
+
+ logStatus(`Creating transfer on ${serverUrl.toString()}`)
+ const created = await api.createTransfer(options.expiresInSeconds)
+ const chunkSize = created.uploadConfig.chunkSize
+ const maxFileCount = created.uploadConfig.maxFileCount
+ const maxTransferBytes = created.uploadConfig.maxTransferBytes || MAX_TRANSFER_BYTES
+
+ if (files.length > maxFileCount) {
+ throw new Error(
+ `This selection has ${files.length} files. The server limit is ${maxFileCount}.`,
+ )
+ }
+
+ const rootKey = randomBytes(32)
+ const linkKey = randomBytes(32)
+ const preparedFiles = prepareFiles(files, chunkSize)
+ const totalCiphertextBytes = preparedFiles.reduce(
+ (sum, file) => sum + file.ciphertextSizes.reduce((next, size) => next + size, 0),
+ 0,
+ )
+
+ if (totalCiphertextBytes > maxTransferBytes) {
+ throw new Error(
+ `Encrypted upload size ${formatBytes(totalCiphertextBytes)} exceeds the server limit ${formatBytes(maxTransferBytes)}.`,
+ )
+ }
+
+ const shareUrl = new URL(`/t/${created.transferId}`, serverUrl)
+ shareUrl.hash = `k=${toBase64Url(linkKey)}`
+
+ let finalized = false
+ try {
+ await api.registerFiles(
+ created.transferId,
+ created.manageToken,
+ preparedFiles.map((file) => ({
+ fileId: file.fileId,
+ totalChunks: file.totalChunks,
+ ciphertextBytes: file.ciphertextSizes.reduce((sum, size) => sum + size, 0),
+ plaintextBytes: file.plaintextSize,
+ chunkSize: file.chunkSize,
+ })),
+ )
+
+ let uploadedCiphertextBytes = 0
+ for (const [index, file] of preparedFiles.entries()) {
+ logStatus(`Uploading ${file.relativePath} (${index + 1}/${preparedFiles.length})`)
+ const uploadUrls = await api.createUploadUrls(
+ created.transferId,
+ created.manageToken,
+ Array.from({ length: file.totalChunks }, (_, chunkIndex) => ({
+ fileId: file.fileId,
+ chunkIndex,
+ })),
+ )
+ const uploadUrlMap = new Map(uploadUrls.map((item) => [item.chunkIndex, item.url]))
+ const completedChunks = await uploadFileChunks({
+ transferId: created.transferId,
+ file,
+ uploadUrlMap,
+ rootKey,
+ concurrency: options.concurrency,
+ })
+ uploadedCiphertextBytes += completedChunks.reduce(
+ (sum, chunk) => sum + chunk.ciphertextSize,
+ 0,
+ )
+ await api.completeChunks(created.transferId, created.manageToken, completedChunks)
+ logStatus(
+ `Uploaded ${file.relativePath} (${formatBytes(uploadedCiphertextBytes)} / ${formatBytes(totalCiphertextBytes)})`,
+ )
+ }
+
+ const manifest = {
+ version: 1,
+ displayName,
+ createdAt: new Date().toISOString(),
+ chunkSize,
+ files: preparedFiles.map((file) => ({
+ fileId: file.fileId,
+ name: file.name,
+ relativePath: file.relativePath,
+ mimeType: file.mimeType,
+ plaintextSize: file.plaintextSize,
+ modifiedAt: file.modifiedAt,
+ chunkSize: file.chunkSize,
+ totalChunks: file.totalChunks,
+ ciphertextSizes: file.ciphertextSizes,
+ noncePrefix: toBase64Url(file.noncePrefix),
+ metadataStripped: false,
+ })),
+ }
+
+ const manifestBytes = await encryptManifest(rootKey, manifest)
+ const wrappedRootKey = await wrapRootKey(rootKey, linkKey)
+ await api.uploadManifest(created.transferId, created.manageToken, toBase64(manifestBytes))
+ await api.finalizeTransfer(
+ created.transferId,
+ created.manageToken,
+ wrappedRootKey,
+ preparedFiles.length,
+ totalCiphertextBytes,
+ )
+
+ finalized = true
+
+ if (options.json) {
+ process.stdout.write(
+ `${JSON.stringify(
+ {
+ transferId: created.transferId,
+ shareUrl: shareUrl.toString(),
+ expiresAt: created.expiresAt,
+ },
+ null,
+ 2,
+ )}\n`,
+ )
+ return
+ }
+
+ process.stdout.write(`${shareUrl.toString()}\n`)
+ } finally {
+ if (!finalized) {
+ await api.deleteTransfer(created.transferId, created.manageToken).catch(() => {})
+ }
+ }
+}
+
+export function parseArgs(argv) {
+ const options = {
+ server: process.env.XDROP_SERVER?.trim() || '',
+ apiUrl: process.env.XDROP_API_URL?.trim() || '',
+ expiresInSeconds: DEFAULT_EXPIRY_SECONDS,
+ name: '',
+ concurrency: 1,
+ quiet: false,
+ json: false,
+ help: false,
+ inputs: [],
+ }
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const value = argv[index]
+ if (!value) {
+ continue
+ }
+
+ if (value === '--help' || value === '-h') {
+ options.help = true
+ continue
+ }
+ if (value === '--json') {
+ options.json = true
+ continue
+ }
+ if (value === '--quiet') {
+ options.quiet = true
+ continue
+ }
+ if (value === '--server') {
+ options.server = requireValue(argv, ++index, '--server')
+ continue
+ }
+ if (value === '--api-url') {
+ options.apiUrl = requireValue(argv, ++index, '--api-url')
+ continue
+ }
+ if (value === '--expires-in') {
+ const parsed = Number.parseInt(requireValue(argv, ++index, '--expires-in'), 10)
+ if (!Number.isInteger(parsed) || parsed <= 0) {
+ throw new Error('--expires-in must be a positive integer number of seconds.')
+ }
+ options.expiresInSeconds = parsed
+ continue
+ }
+ if (value === '--name') {
+ options.name = requireValue(argv, ++index, '--name')
+ continue
+ }
+ if (value === '--concurrency') {
+ const parsed = Number.parseInt(requireValue(argv, ++index, '--concurrency'), 10)
+ if (!Number.isInteger(parsed) || parsed <= 0) {
+ throw new Error('--concurrency must be a positive integer.')
+ }
+ options.concurrency = Math.min(parsed, MAX_UPLOAD_CONCURRENCY)
+ continue
+ }
+ if (value.startsWith('--')) {
+ throw new Error(`Unknown option: ${value}`)
+ }
+ options.inputs.push(value)
+ }
+
+ return options
+}
+
+function requireValue(argv, index, flag) {
+ const value = argv[index]
+ if (!value) {
+ throw new Error(`Missing value for ${flag}`)
+ }
+ return value
+}
+
+function normalizeSiteUrl(value) {
+ const url = new URL(value)
+ url.hash = ''
+ url.search = ''
+ if (url.pathname.endsWith('/api/v1')) {
+ url.pathname = url.pathname.slice(0, -'/api/v1'.length) || '/'
+ }
+ if (!url.pathname.endsWith('/')) {
+ url.pathname = `${url.pathname}/`
+ }
+ return url
+}
+
+function normalizeApiUrl(value) {
+ const url = new URL(value)
+ url.hash = ''
+ url.search = ''
+ return url.toString().replace(/\/$/u, '')
+}
+
+export function resolveApiUrl(serverUrl, apiUrl) {
+ return normalizeApiUrl(apiUrl || new URL('/api/v1', serverUrl).toString())
+}
+
+export async function collectTransferInputs(inputPaths) {
+ const files = []
+ const seenPaths = new Set()
+
+ for (const inputPath of inputPaths) {
+ const absolutePath = resolve(process.cwd(), inputPath)
+ const inputStat = await stat(absolutePath)
+ if (inputStat.isDirectory()) {
+ const rootName = basename(absolutePath)
+ const nestedFiles = await collectDirectoryFiles(absolutePath, rootName)
+ files.push(...nestedFiles)
+ continue
+ }
+
+ if (!inputStat.isFile()) {
+ throw new Error(`Only files and directories are supported: ${inputPath}`)
+ }
+
+ files.push({
+ absolutePath,
+ relativePath: basename(absolutePath),
+ size: inputStat.size,
+ modifiedAt: Math.round(inputStat.mtimeMs),
+ name: basename(absolutePath),
+ mimeType: mimeTypeFromName(absolutePath),
+ })
+ }
+
+ for (const file of files) {
+ if (seenPaths.has(file.relativePath)) {
+ throw new Error(`Duplicate relative path in upload set: ${file.relativePath}`)
+ }
+ seenPaths.add(file.relativePath)
+ }
+
+ return files
+}
+
+async function collectDirectoryFiles(directoryPath, relativePrefix) {
+ const entries = (await readdir(directoryPath, { withFileTypes: true })).sort((left, right) =>
+ left.name.localeCompare(right.name),
+ )
+ const files = []
+
+ for (const entry of entries) {
+ const absolutePath = resolve(directoryPath, entry.name)
+ const relativePath = `${relativePrefix}/${entry.name}`.replace(/\\/gu, '/')
+ if (entry.isDirectory()) {
+ files.push(...(await collectDirectoryFiles(absolutePath, relativePath)))
+ continue
+ }
+ if (!entry.isFile()) {
+ continue
+ }
+
+ const entryStat = await stat(absolutePath)
+ files.push({
+ absolutePath,
+ relativePath,
+ size: entryStat.size,
+ modifiedAt: Math.round(entryStat.mtimeMs),
+ name: entry.name,
+ mimeType: mimeTypeFromName(entry.name),
+ })
+ }
+
+ return files
+}
+
+export function defaultDisplayName(files) {
+ if (files.length === 0) {
+ return 'Untitled transfer'
+ }
+ if (files.length === 1) {
+ return files[0].relativePath
+ }
+
+ const roots = new Set(files.map((file) => file.relativePath.split('/')[0]))
+ if (roots.size === 1) {
+ return files[0].relativePath.split('/')[0]
+ }
+
+ return `${files[0].name} and ${files.length - 1} more items`
+}
+
+export function prepareFiles(files, chunkSize) {
+ return files.map((file) => {
+ const fileId = toBase64Url(randomBytes(18))
+ const noncePrefix = randomBytes(8)
+ const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize))
+ const ciphertextSizes = Array.from({ length: totalChunks }, (_, chunkIndex) => {
+ const plaintextChunkSize = Math.min(
+ chunkSize,
+ Math.max(file.size - chunkIndex * chunkSize, 0),
+ )
+ return plaintextChunkSize + 16
+ })
+
+ return {
+ ...file,
+ fileId,
+ noncePrefix,
+ chunkSize,
+ totalChunks,
+ plaintextSize: file.size,
+ ciphertextSizes,
+ }
+ })
+}
+
+async function uploadFileChunks({ transferId, file, uploadUrlMap, rootKey, concurrency }) {
+ const completedChunks = new Array(file.totalChunks)
+ await parallelLimit(
+ Array.from({ length: file.totalChunks }, (_, chunkIndex) => chunkIndex),
+ concurrency,
+ async (chunkIndex) => {
+ const uploadUrl = uploadUrlMap.get(chunkIndex)
+ if (!uploadUrl) {
+ throw new Error(`Missing upload URL for ${file.relativePath} chunk ${chunkIndex}.`)
+ }
+
+ const plaintext = await readFileChunk(
+ file.absolutePath,
+ chunkIndex * file.chunkSize,
+ file.chunkSize,
+ )
+ const encrypted = await encryptChunk({
+ rootKey,
+ transferId,
+ fileId: file.fileId,
+ chunkIndex,
+ noncePrefix: file.noncePrefix,
+ plaintextChunkSize: plaintext.byteLength,
+ plaintext,
+ })
+
+ const response = await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/octet-stream' },
+ body: encrypted.ciphertext,
+ })
+ if (!response.ok) {
+ throw new Error(
+ `Chunk upload failed for ${file.relativePath} chunk ${chunkIndex} with ${response.status}.`,
+ )
+ }
+
+ completedChunks[chunkIndex] = {
+ fileId: file.fileId,
+ chunkIndex,
+ ciphertextSize: encrypted.ciphertext.byteLength,
+ checksumSha256: encrypted.checksumHex,
+ }
+ },
+ )
+
+ return completedChunks
+}
+
+async function readFileChunk(filePath, start, chunkSize) {
+ const fileHandle = await open(filePath, 'r')
+ try {
+ const buffer = Buffer.alloc(Math.max(0, chunkSize))
+ const { bytesRead } = await fileHandle.read(buffer, 0, chunkSize, start)
+ return new Uint8Array(buffer.subarray(0, bytesRead))
+ } finally {
+ await fileHandle.close()
+ }
+}
+
+async function parallelLimit(items, concurrency, worker) {
+ let cursor = 0
+
+ await Promise.all(
+ Array.from({ length: Math.min(concurrency, items.length) }, async () => {
+ while (cursor < items.length) {
+ const index = cursor
+ cursor += 1
+ const item = items[index]
+ if (item === undefined) {
+ continue
+ }
+ await worker(item, index)
+ }
+ }),
+ )
+}
+
+async function encryptChunk(options) {
+ const fileKey = await deriveHkdfKey(options.rootKey, `file:${options.fileId}`)
+ const iv = buildChunkIv(options.noncePrefix, options.chunkIndex)
+ const additionalData = encoder.encode(
+ [
+ options.transferId,
+ options.fileId,
+ options.chunkIndex,
+ options.plaintextChunkSize,
+ MANIFEST_VERSION,
+ ].join('|'),
+ )
+ const ciphertext = new Uint8Array(
+ await crypto.subtle.encrypt(
+ {
+ name: 'AES-GCM',
+ iv,
+ additionalData,
+ },
+ fileKey,
+ options.plaintext,
+ ),
+ )
+
+ return {
+ ciphertext,
+ checksumHex: createHash('sha256').update(ciphertext).digest('hex'),
+ }
+}
+
+async function encryptManifest(rootKey, manifest) {
+ const manifestKey = await deriveHkdfKey(rootKey, 'manifest')
+ const iv = randomBytes(12)
+ const ciphertext = new Uint8Array(
+ await crypto.subtle.encrypt(
+ {
+ name: 'AES-GCM',
+ iv,
+ },
+ manifestKey,
+ encoder.encode(JSON.stringify(manifest)),
+ ),
+ )
+
+ return encoder.encode(
+ JSON.stringify({
+ version: MANIFEST_VERSION,
+ iv: toBase64Url(iv),
+ ciphertext: toBase64(ciphertext),
+ }),
+ )
+}
+
+async function wrapRootKey(rootKey, linkKey) {
+ const wrappingKey = await deriveHkdfKey(linkKey, 'wrap-root')
+ const iv = randomBytes(12)
+ const ciphertext = new Uint8Array(
+ await crypto.subtle.encrypt(
+ {
+ name: 'AES-GCM',
+ iv,
+ },
+ wrappingKey,
+ rootKey,
+ ),
+ )
+
+ return JSON.stringify({
+ version: WRAP_VERSION,
+ iv: toBase64Url(iv),
+ ciphertext: toBase64(ciphertext),
+ })
+}
+
+async function deriveHkdfKey(source, info) {
+ const sourceKey = await crypto.subtle.importKey('raw', source, 'HKDF', false, ['deriveKey'])
+ return crypto.subtle.deriveKey(
+ {
+ name: 'HKDF',
+ hash: 'SHA-256',
+ salt: new Uint8Array(),
+ info: encoder.encode(info),
+ },
+ sourceKey,
+ {
+ name: 'AES-GCM',
+ length: 256,
+ },
+ false,
+ ['encrypt', 'decrypt'],
+ )
+}
+
+function buildChunkIv(noncePrefix, chunkIndex) {
+ const iv = new Uint8Array(12)
+ iv.set(noncePrefix.slice(0, 8), 0)
+ new DataView(iv.buffer).setUint32(8, chunkIndex, false)
+ return iv
+}
+
+function randomBytes(length) {
+ const value = new Uint8Array(length)
+ crypto.getRandomValues(value)
+ return value
+}
+
+function toBase64Url(input) {
+ return Buffer.from(input)
+ .toString('base64')
+ .replace(/\+/gu, '-')
+ .replace(/\//gu, '_')
+ .replace(/=+$/u, '')
+}
+
+function toBase64(input) {
+ return Buffer.from(input).toString('base64')
+}
+
+function formatBytes(value) {
+ if (value >= 1024 * 1024 * 1024) {
+ return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GiB`
+ }
+ if (value >= 1024 * 1024) {
+ return `${(value / (1024 * 1024)).toFixed(1)} MiB`
+ }
+ if (value >= 1024) {
+ return `${(value / 1024).toFixed(1)} KiB`
+ }
+ return `${value} B`
+}
+
+function mimeTypeFromName(filePath) {
+ const extension = extname(filePath).toLowerCase()
+ return MIME_TYPES[extension] ?? 'application/octet-stream'
+}
+
+function logStatus(message) {
+ if (quietMode) {
+ return
+ }
+ process.stderr.write(`${message}\n`)
+}
+
+class XdropApiClient {
+ constructor(baseUrl) {
+ this.baseUrl = baseUrl
+ }
+
+ async createTransfer(expiresInSeconds) {
+ return this.request('/transfers', {
+ method: 'POST',
+ body: { expiresInSeconds },
+ })
+ }
+
+ async registerFiles(transferId, manageToken, files) {
+ await this.request(`/transfers/${transferId}/files`, {
+ method: 'POST',
+ token: manageToken,
+ body: files,
+ })
+ }
+
+ async createUploadUrls(transferId, manageToken, chunks) {
+ const response = await this.request(`/transfers/${transferId}/upload-urls`, {
+ method: 'POST',
+ token: manageToken,
+ body: { chunks },
+ })
+ return response.items
+ }
+
+ async completeChunks(transferId, manageToken, chunks) {
+ await this.request(`/transfers/${transferId}/chunks/complete`, {
+ method: 'POST',
+ token: manageToken,
+ body: chunks,
+ })
+ }
+
+ async uploadManifest(transferId, manageToken, ciphertextBase64) {
+ await this.request(`/transfers/${transferId}/manifest`, {
+ method: 'POST',
+ token: manageToken,
+ body: { ciphertextBase64 },
+ })
+ }
+
+ async finalizeTransfer(
+ transferId,
+ manageToken,
+ wrappedRootKey,
+ totalFiles,
+ totalCiphertextBytes,
+ ) {
+ await this.request(`/transfers/${transferId}/finalize`, {
+ method: 'POST',
+ token: manageToken,
+ body: { wrappedRootKey, totalFiles, totalCiphertextBytes },
+ })
+ }
+
+ async deleteTransfer(transferId, manageToken) {
+ await this.request(`/transfers/${transferId}`, {
+ method: 'DELETE',
+ token: manageToken,
+ })
+ }
+
+ async request(path, options) {
+ const response = await fetch(`${this.baseUrl}${path}`, {
+ method: options.method,
+ headers: {
+ ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
+ ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
+ },
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
+ })
+
+ if (!response.ok) {
+ const payload = await response.json().catch(() => ({}))
+ const detail = payload.message ?? payload.error ?? `Request failed with ${response.status}`
+ throw new Error(detail)
+ }
+
+ if (response.status === 204) {
+ return undefined
+ }
+
+ return response.json()
+ }
+}
+
+const MIME_TYPES = {
+ '.7z': 'application/x-7z-compressed',
+ '.bin': 'application/octet-stream',
+ '.csv': 'text/csv',
+ '.gif': 'image/gif',
+ '.gz': 'application/gzip',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.json': 'application/json',
+ '.md': 'text/markdown',
+ '.mp3': 'audio/mpeg',
+ '.mp4': 'video/mp4',
+ '.pdf': 'application/pdf',
+ '.png': 'image/png',
+ '.svg': 'image/svg+xml',
+ '.tar': 'application/x-tar',
+ '.txt': 'text/plain',
+ '.wav': 'audio/wav',
+ '.webm': 'video/webm',
+ '.webp': 'image/webp',
+ '.zip': 'application/zip',
+}
+
+if (import.meta.main) {
+ main().catch((error) => {
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
+ process.exit(1)
+ })
+}