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
|
Agents can use Xdrop to upload files, return end-to-end encrypted share links, and use Xdrop
|
||||||
links for local decryption.
|
links for local decryption.
|
||||||
|
|
||||||
Install the bundled skill:
|
Install the companion skill:
|
||||||
|
|
||||||
```bash
|
```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:
|
After that, the agent can use Xdrop from the terminal to:
|
||||||
|
|||||||
+2
-2
@@ -73,10 +73,10 @@ Xdrop 是一款面向人类与智能体的开源端到端加密文件传输应
|
|||||||
|
|
||||||
智能体可以使用 Xdrop 上传文件,返回端到端加密的分享链接,并使用 Xdrop 链接进行本地解密。
|
智能体可以使用 Xdrop 上传文件,返回端到端加密的分享链接,并使用 Xdrop 链接进行本地解密。
|
||||||
|
|
||||||
安装存储库附带的 skill:
|
安装配套的 skill:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bunx skills add xixu-me/xdrop
|
bunx skills add xixu-me/skills -s xdrop
|
||||||
```
|
```
|
||||||
|
|
||||||
安装后,智能体可以直接在终端中使用 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