feat: add agent workflow messaging and skill

This commit is contained in:
xixu-me committed 2026-03-21 22:02:14 +08:00
1 parent b451771169
commit ee82019d0b
30 files changed
+1462 -87

No files matched your search

+86
View File
@@ -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 <xdrop-site-url> <file-or-directory> [...]
```
Prefer these flags when relevant:
- `--quiet`: suppress progress output and keep stdout clean
- `--json`: return `transferId`, `shareUrl`, and `expiresAt`
- `--expires-in <seconds>`: choose a supported expiry
- `--api-url <url>`: override the default `<server>/api/v1`
- `--name <value>`: set the transfer display name
- `--concurrency <n>`: 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 "<share-url>"
```
Prefer these flags when relevant:
- `--output <dir>`: choose the destination directory
- `--quiet`: suppress progress output and keep stdout clean
- `--json`: return `transferId`, `outputRoot`, and saved file paths
- `--api-url <url>`: override the default `<share-origin>/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-<transferId>` 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.
+389
View File
@@ -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 <path-to-download.mjs> <share-url>
Options:
--output <dir> Destination directory. Defaults to ./xdrop-<transferId>.
--api-url <url> Override the API root. Defaults to <share-origin>/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)
})
}
+738
View File
@@ -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 <path-to-upload.mjs> --server https://xdrop.example.com <file-or-directory> [...]
Options:
--server <url> Public Xdrop site URL. Can also be set with XDROP_SERVER.
--api-url <url> Override the API root. Defaults to <server>/api/v1.
--expires-in <sec> Transfer expiry in seconds. Default: ${DEFAULT_EXPIRY_SECONDS}.
--name <value> Custom transfer display name.
--concurrency <n> 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)
})
}