diff --git a/README.md b/README.md index 56cbce1..3a0bb09 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,10 @@ Humans can also open the share link directly in a browser to decrypt and downloa Agents can use Xdrop to upload files, return end-to-end encrypted share links, and use Xdrop links for local decryption. -Install the bundled skill: +Install the companion skill: ```bash -bunx skills add xixu-me/xdrop +bunx skills add xixu-me/skills -s xdrop ``` After that, the agent can use Xdrop from the terminal to: diff --git a/README.zh.md b/README.zh.md index 950ce08..b46d1ab 100644 --- a/README.zh.md +++ b/README.zh.md @@ -73,10 +73,10 @@ Xdrop 是一款面向人类与智能体的开源端到端加密文件传输应 智能体可以使用 Xdrop 上传文件,返回端到端加密的分享链接,并使用 Xdrop 链接进行本地解密。 -安装存储库附带的 skill: +安装配套的 skill: ```bash -bunx skills add xixu-me/xdrop +bunx skills add xixu-me/skills -s xdrop ``` 安装后,智能体可以直接在终端中使用 Xdrop 来: diff --git a/skills/xdrop/SKILL.md b/skills/xdrop/SKILL.md deleted file mode 100644 index 67879eb..0000000 --- a/skills/xdrop/SKILL.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -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 - -```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. - -```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. - -## Guardrails - -- 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 deleted file mode 100644 index 2dfc34b..0000000 --- a/skills/xdrop/scripts/download.mjs +++ /dev/null @@ -1,389 +0,0 @@ -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 deleted file mode 100644 index 9922642..0000000 --- a/skills/xdrop/scripts/upload.mjs +++ /dev/null @@ -1,738 +0,0 @@ -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) - }) -}