refactor: update skill installation instructions and remove deprecated files
This commit is contained in:
1 parent
875d9aeb7b
commit
1dae191976
5 files changed
+4
-1211
No files matched your search
@@ -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:
|
||||
|
||||
+2
-2
@@ -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 来:
|
||||
|
||||
@@ -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 <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.
|
||||
|
||||
```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.
|
||||
|
||||
## 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.
|
||||
@@ -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 <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)
|
||||
})
|
||||
}
|
||||
@@ -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 <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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user