feat: add agent workflow messaging and skill
This commit is contained in:
1 parent
b451771169
commit
ee82019d0b
30 files changed
+1462
-87
No files matched your search
+59
-16
@@ -7,15 +7,27 @@ metadata, and social profiles aligned.
|
||||
|
||||
Canonical one-liner:
|
||||
|
||||
`Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server.`
|
||||
`Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server.`
|
||||
|
||||
This is the default introduction for Xdrop. If a surface only gets one sentence, use this one.
|
||||
This is the default introduction for Xdrop when a surface only gets one sentence.
|
||||
|
||||
Positioning framework:
|
||||
|
||||
- **Category:** Open source encrypted file transfer.
|
||||
- **Primary promise:** Plaintext file names, contents, and keys stay off the server.
|
||||
- **Default experience:** Browser-first for normal sharing flows.
|
||||
- **Extended workflow:** Also usable from agent-driven terminal environments such as Codex, remote
|
||||
servers, dev containers, and CI-adjacent workflows.
|
||||
|
||||
Short positioning summary:
|
||||
|
||||
`Browser-first encrypted file transfer, with agent-ready terminal workflows.`
|
||||
|
||||
## Canonical Copy By Surface
|
||||
|
||||
README first sentence:
|
||||
|
||||
`Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server.`
|
||||
`Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server.`
|
||||
|
||||
Homepage H1:
|
||||
|
||||
@@ -23,21 +35,21 @@ Homepage H1:
|
||||
|
||||
Homepage body:
|
||||
|
||||
`Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server.`
|
||||
`Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server.`
|
||||
|
||||
Homepage and SEO title:
|
||||
|
||||
`Open Source Encrypted File Transfer in the Browser | Xdrop`
|
||||
`Open Source Encrypted File Transfer for Browsers and Agents | Xdrop`
|
||||
|
||||
Meta description:
|
||||
|
||||
`Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server.`
|
||||
`Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server.`
|
||||
|
||||
OG card headline:
|
||||
|
||||
`Open source encrypted`
|
||||
|
||||
`file transfer in your browser.`
|
||||
`file transfer for browsers and agents.`
|
||||
|
||||
OG card support line:
|
||||
|
||||
@@ -45,35 +57,62 @@ OG card support line:
|
||||
|
||||
Short social bio:
|
||||
|
||||
`Open source file transfer with in-browser encryption. Plaintext file names, contents, and keys stay off the server.`
|
||||
`Open source encrypted file transfer for browsers and agents. Plaintext file names, contents, and keys stay off the server.`
|
||||
|
||||
Short technical summary:
|
||||
|
||||
`Browser-encrypted file transfer with plaintext kept off the server.`
|
||||
`Browser-first encrypted file transfer with agent-ready terminal workflows and plaintext kept off the server.`
|
||||
|
||||
Terminal and agent support blurb:
|
||||
|
||||
`Xdrop can also be used from agent-driven terminal workflows to upload files, return encrypted share links, and download full Xdrop links for local decryption.`
|
||||
|
||||
Use-case summary:
|
||||
|
||||
`Use Xdrop in the browser for normal sharing, or through an agent when you need to move files out of a cloud server, remote container, or automated terminal workflow.`
|
||||
|
||||
Chinese reference copy:
|
||||
|
||||
- Canonical one-liner:
|
||||
`Xdrop 是一个开源加密文件传输应用,以浏览器为主体验,也支持智能体驱动的终端工作流,并确保服务端拿不到明文文件名、文件内容和密钥。`
|
||||
- Short positioning summary:
|
||||
`以浏览器为主体验的加密文件传输,也支持智能体终端工作流。`
|
||||
- Agent support blurb:
|
||||
`日常分享可直接使用浏览器;如果你需要把文件从云服务器、远程容器或自动化终端流程中传出来,也可以通过智能体使用 Xdrop。`
|
||||
|
||||
## Messaging Priorities
|
||||
|
||||
When space is limited, keep these ideas in this order:
|
||||
|
||||
1. Xdrop is open source.
|
||||
2. Encryption happens in the browser.
|
||||
2. Xdrop is encrypted file transfer, not generic file sharing.
|
||||
3. Plaintext file names, contents, and keys stay off the server.
|
||||
4. `No account required` is a useful supporting point, but not the main definition.
|
||||
4. The product is browser-first, but not browser-only.
|
||||
5. `No account required` is a useful supporting point, but not the main definition.
|
||||
|
||||
## Preferred Language
|
||||
|
||||
- Prefer `encrypts files in your browser` over `browser-side encryption` in user-facing copy.
|
||||
- Prefer `encrypted file transfer` as the main category label.
|
||||
- Prefer `browser-first` when you need to signal the main UX without implying the browser is the
|
||||
only supported way to use Xdrop.
|
||||
- Prefer `agent-driven terminal workflows` or `use Xdrop via an agent` when describing the skill
|
||||
and CLI-style experience.
|
||||
- Prefer `encrypts files in your browser` when the copy is specifically about the web app flow.
|
||||
- Prefer `keeps plaintext ... off the server` over `ciphertext-only storage` unless the audience is technical.
|
||||
- Prefer `open source file transfer app` when introducing Xdrop for the first time.
|
||||
- Prefer `in-browser encryption` as the compact form when space is tight.
|
||||
- Prefer `open source encrypted file transfer app` when introducing Xdrop for the first time.
|
||||
- Prefer `in-browser encryption` as a compact technical benefit, not as the whole product category.
|
||||
- Use `AES-256-GCM` in technical docs, threat-model explanations, and implementation notes, not as the default marketing hook.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Avoid using `private file transfer` as the only product summary.
|
||||
- Avoid presenting Xdrop as browser-only now that agent workflows are a supported entry point.
|
||||
- Avoid mixing `private`, `secure`, `browser-side`, and `ciphertext-only` as interchangeable main taglines.
|
||||
- Avoid making `no account required` the primary headline. It is a benefit, not the core definition.
|
||||
- Avoid shortening the promise to just `secure uploads` because it removes the browser and server model that makes Xdrop distinct.
|
||||
- Avoid shortening the promise to just `secure uploads` because it removes the architecture and
|
||||
server-trust model that make Xdrop distinct.
|
||||
- Avoid making `agents` or `CLI` the only headline unless the surface is explicitly about the skill
|
||||
or terminal workflow.
|
||||
|
||||
## Tone
|
||||
|
||||
@@ -81,13 +120,17 @@ When space is limited, keep these ideas in this order:
|
||||
- Technical enough to be accurate, but readable for non-specialists.
|
||||
- Calm and factual instead of hype-heavy.
|
||||
- Confident about the architecture, careful about broader security claims.
|
||||
- Product-language first, with workflow details added only where they help explain real use.
|
||||
|
||||
## Copy Review Checklist
|
||||
|
||||
Before shipping new product-facing copy, check:
|
||||
|
||||
- Does it describe Xdrop as open source?
|
||||
- Does it say encryption happens in the browser?
|
||||
- Does it clearly frame Xdrop as encrypted file transfer?
|
||||
- Does it make clear that plaintext names, contents, and keys stay off the server?
|
||||
- If it mentions the main UX, does it say browser-first rather than implying browser-only?
|
||||
- If it mentions agent usage, does it describe it as an additional workflow rather than a separate
|
||||
product?
|
||||
- Is `no account required` used as support rather than the core identity?
|
||||
- Does it avoid introducing a new summary line that conflicts with the canonical one-liner?
|
||||
@@ -37,8 +37,8 @@
|
||||
English | <a href="./README.zh.md">汉语</a>
|
||||
</p>
|
||||
|
||||
Xdrop is an open source file transfer app that encrypts files in your browser and keeps
|
||||
plaintext file names, contents, and keys off the server.
|
||||
Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal
|
||||
workflows, keeping plaintext file names, contents, and keys off the server.
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -48,6 +48,35 @@ plaintext file names, contents, and keys off the server.
|
||||
- Expiring links, sender-side management, and optional privacy mode after upload.
|
||||
- S3-compatible object storage support with PostgreSQL and Redis on the backend.
|
||||
|
||||
## Use Via Agents
|
||||
|
||||
You can also use Xdrop through an agent by installing the bundled skill:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/xixu-me/xdrop/tree/main/skills/xdrop
|
||||
```
|
||||
|
||||
After that, the agent can use Xdrop from the terminal to:
|
||||
|
||||
- Upload local files or directories and return an encrypted share link.
|
||||
- Download a full Xdrop share link, including `#k=...`, and decrypt it locally.
|
||||
- Automate repeatable handoff flows without switching to the browser UI.
|
||||
|
||||
Useful cases:
|
||||
|
||||
- On a cloud server, ask the agent to upload build artifacts, logs, or backups to your Xdrop
|
||||
instance and send back a temporary link.
|
||||
- In a remote dev container or CI-like environment, ask the agent to package a directory and move
|
||||
it through Xdrop instead of setting up ad hoc SCP or public object storage access.
|
||||
- On your local machine, hand the agent a full Xdrop link and ask it to download the files into a
|
||||
specific directory.
|
||||
|
||||
Example prompts:
|
||||
|
||||
- `Upload ./dist to https://xdrop.example.com and give me a 1-hour Xdrop link.`
|
||||
- `On this VM, send /var/log/myapp through Xdrop so I can inspect it locally.`
|
||||
- `Download this Xdrop link into ~/downloads and keep the original folder structure.`
|
||||
|
||||
## How It Works
|
||||
|
||||
1. A sender creates a transfer in the browser. Xdrop generates a random transfer root key and a
|
||||
|
||||
+27
-1
@@ -37,7 +37,7 @@
|
||||
<a href="./README.md">English</a> | 汉语
|
||||
</p>
|
||||
|
||||
Xdrop 是一个开源文件传输应用,会在浏览器中先加密文件再上传,确保服务端拿不到明文文件名、文件内容和密钥。
|
||||
Xdrop 是一个开源加密文件传输应用,默认适用于浏览器,也支持智能体驱动的终端工作流,并确保服务端拿不到明文文件名、文件内容和密钥。
|
||||
|
||||
## 亮点
|
||||
|
||||
@@ -47,6 +47,32 @@ Xdrop 是一个开源文件传输应用,会在浏览器中先加密文件再
|
||||
- 支持到期失效链接、发送方管理,以及上传后的可选隐私模式。
|
||||
- 后端支持兼容 S3 的对象存储,并使用 PostgreSQL 和 Redis。
|
||||
|
||||
## 通过智能体使用
|
||||
|
||||
你也可以把 Xdrop 作为智能体技能来用,安装存储库自带的 skill:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/xixu-me/xdrop/tree/main/skills/xdrop
|
||||
```
|
||||
|
||||
安装后,智能体可以直接在终端中使用 Xdrop 来:
|
||||
|
||||
- 上传本地文件或目录,并返回加密分享链接。
|
||||
- 接收完整的 Xdrop 分享链接(包含 `#k=...`)后,在本地下载并解密文件。
|
||||
- 把文件交接流程自动化,而不用每次都切换到浏览器界面操作。
|
||||
|
||||
适合的场景包括:
|
||||
|
||||
- 在云服务器上让智能体把构建产物、日志或备份上传到你的 Xdrop 实例,并返回一个临时链接给你。
|
||||
- 在远程开发容器或类似 CI 的环境里,让智能体把某个目录打包后通过 Xdrop 传出来,而不是临时配置 SCP 或公开对象存储权限。
|
||||
- 在本地机器上直接把完整的 Xdrop 链接交给智能体,让它下载到指定目录并完成解密。
|
||||
|
||||
示例指令:
|
||||
|
||||
- `把 ./dist 上传到 https://xdrop.example.com,并给我一个 1 小时有效的 Xdrop 链接。`
|
||||
- `在这台云服务器上把 /var/log/myapp 通过 Xdrop 发出来,我要在本地排查。`
|
||||
- `把这个 Xdrop 链接下载到 ~/downloads,并保留原始目录结构。`
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. 发送方在浏览器中创建一次传输。Xdrop 会生成随机的传输根密钥和独立的链接密钥,可选地移除图片中可删除的元数据,并在上传开始前准备好可恢复的本地状态。
|
||||
|
||||
+6
-6
@@ -8,7 +8,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server."
|
||||
content="Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server."
|
||||
/>
|
||||
<meta
|
||||
name="robots"
|
||||
@@ -20,26 +20,26 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Open Source Encrypted File Transfer in the Browser | Xdrop"
|
||||
content="Open Source Encrypted File Transfer for Browsers and Agents | Xdrop"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server."
|
||||
content="Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server."
|
||||
/>
|
||||
<meta property="og:image" content="/brand-lockup-horizontal.png" />
|
||||
<meta property="og:image:alt" content="Xdrop horizontal brand lockup" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="Open Source Encrypted File Transfer in the Browser | Xdrop"
|
||||
content="Open Source Encrypted File Transfer for Browsers and Agents | Xdrop"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server."
|
||||
content="Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server."
|
||||
/>
|
||||
<meta name="twitter:image" content="/brand-lockup-horizontal.png" />
|
||||
<meta name="theme-color" content="#12140f" />
|
||||
<title>Open Source Encrypted File Transfer in the Browser | Xdrop</title>
|
||||
<title>Open Source Encrypted File Transfer for Browsers and Agents | Xdrop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Xdrop",
|
||||
"short_name": "Xdrop",
|
||||
"description": "Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server.",
|
||||
"description": "Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server.",
|
||||
"theme_color": "#12140f",
|
||||
"background_color": "#f7f2e8",
|
||||
"display": "standalone",
|
||||
|
||||
@@ -18,9 +18,21 @@ const DEFAULT_ROBOTS =
|
||||
const PRIVATE_ROBOTS = 'noindex, nofollow, noarchive, nosnippet'
|
||||
const STRUCTURED_DATA_ID = 'xdrop-structured-data-static'
|
||||
|
||||
const HOME_TITLE = 'Open Source Encrypted File Transfer in the Browser | Xdrop'
|
||||
const HOME_TITLE = 'Open Source Encrypted File Transfer for Browsers and Agents | Xdrop'
|
||||
const HOME_DESCRIPTION =
|
||||
'Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server.'
|
||||
'Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server.'
|
||||
const HISTORY_PAGE_TITLE = 'Manage Transfers on This Device | Xdrop'
|
||||
const HISTORY_PAGE_DESCRIPTION =
|
||||
'Manage encrypted transfers stored in this browser on this device. There is no account or cross-device history.'
|
||||
const SHARE_PAGE_TITLE = 'Share the Full Link | Xdrop'
|
||||
const SHARE_PAGE_DESCRIPTION =
|
||||
'Review upload status and copy the full share link for a transfer staged in this browser on this device.'
|
||||
const RECEIVE_PAGE_TITLE = 'Download and Decrypt in the Browser | Xdrop'
|
||||
const RECEIVE_PAGE_DESCRIPTION =
|
||||
'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.'
|
||||
const NOT_FOUND_PAGE_TITLE = 'Page Not Found | Xdrop'
|
||||
const NOT_FOUND_PAGE_DESCRIPTION =
|
||||
'The address does not map to a page in Xdrop. If this came from a shared transfer, ask for the full URL, including the #k=... decryption fragment.'
|
||||
|
||||
const routeShells = [
|
||||
{
|
||||
@@ -34,35 +46,31 @@ const routeShells = [
|
||||
{
|
||||
outputPath: 'transfers/index.html',
|
||||
pagePath: '/transfers',
|
||||
title: 'Manage Transfers on This Device | Xdrop',
|
||||
description:
|
||||
'Manage encrypted transfers stored in this browser. There is no account or cross-device history.',
|
||||
title: HISTORY_PAGE_TITLE,
|
||||
description: HISTORY_PAGE_DESCRIPTION,
|
||||
robots: PRIVATE_ROBOTS,
|
||||
exposeUrl: false,
|
||||
},
|
||||
{
|
||||
outputPath: 'share/index.html',
|
||||
pagePath: '/share/',
|
||||
title: 'Share the Full Link | Xdrop',
|
||||
description:
|
||||
'Review upload status and copy the full share link for a browser-encrypted transfer.',
|
||||
title: SHARE_PAGE_TITLE,
|
||||
description: SHARE_PAGE_DESCRIPTION,
|
||||
robots: PRIVATE_ROBOTS,
|
||||
exposeUrl: false,
|
||||
},
|
||||
{
|
||||
outputPath: 't/index.html',
|
||||
pagePath: '/t/',
|
||||
title: 'Download and Decrypt in the Browser | Xdrop',
|
||||
description:
|
||||
'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.',
|
||||
title: RECEIVE_PAGE_TITLE,
|
||||
description: RECEIVE_PAGE_DESCRIPTION,
|
||||
robots: PRIVATE_ROBOTS,
|
||||
exposeUrl: false,
|
||||
},
|
||||
{
|
||||
outputPath: 'not-found/index.html',
|
||||
title: 'Page Not Found | Xdrop',
|
||||
description:
|
||||
'The address does not map to a page in Xdrop. If this came from a shared transfer, ask for the full URL, including the #k=... decryption fragment.',
|
||||
title: NOT_FOUND_PAGE_TITLE,
|
||||
description: NOT_FOUND_PAGE_DESCRIPTION,
|
||||
robots: PRIVATE_ROBOTS,
|
||||
exposeUrl: false,
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Shell() {
|
||||
/>
|
||||
<span className="brand-copy">
|
||||
<span className="brand-mark">Xdrop</span>
|
||||
<span className="brand-note">Browser-encrypted transfer</span>
|
||||
<span className="brand-note">Encrypted file transfer</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
<nav className="nav">
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { HistoryBoard } from '@/features/history/HistoryBoard'
|
||||
import { PRIVATE_ROBOTS } from '@/lib/seo/site'
|
||||
import { HISTORY_PAGE_DESCRIPTION, HISTORY_PAGE_TITLE, PRIVATE_ROBOTS } from '@/lib/seo/site'
|
||||
import { usePageMetadata } from '@/lib/seo/usePageMetadata'
|
||||
|
||||
/** HistoryPage hides local-only transfer history from search engines. */
|
||||
export function HistoryPage() {
|
||||
usePageMetadata({
|
||||
title: 'Manage Transfers on This Device | Xdrop',
|
||||
description:
|
||||
'Manage encrypted transfers stored in this browser. There is no account or cross-device history.',
|
||||
title: HISTORY_PAGE_TITLE,
|
||||
description: HISTORY_PAGE_DESCRIPTION,
|
||||
robots: PRIVATE_ROBOTS,
|
||||
exposeUrl: false,
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('HomePage', () => {
|
||||
|
||||
expect(usePageMetadataMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Open Source Encrypted File Transfer in the Browser | Xdrop',
|
||||
title: 'Open Source Encrypted File Transfer for Browsers and Agents | Xdrop',
|
||||
structuredData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
'@type': 'Organization',
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('NotFoundPage', () => {
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'This page was not found.' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Try these checks' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Try the browser that created the transfer')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Try the device and browser that created the transfer'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { PRIVATE_ROBOTS } from '@/lib/seo/site'
|
||||
import { NOT_FOUND_PAGE_DESCRIPTION, NOT_FOUND_PAGE_TITLE, PRIVATE_ROBOTS } from '@/lib/seo/site'
|
||||
import { usePageMetadata } from '@/lib/seo/usePageMetadata'
|
||||
|
||||
/** NotFoundPage explains the most common ways secure share links break. */
|
||||
export function NotFoundPage() {
|
||||
usePageMetadata({
|
||||
title: 'Page Not Found | Xdrop',
|
||||
description:
|
||||
'The address does not map to a page in Xdrop. If this came from a shared transfer, ask for the full URL, including the #k=... decryption fragment.',
|
||||
title: NOT_FOUND_PAGE_TITLE,
|
||||
description: NOT_FOUND_PAGE_DESCRIPTION,
|
||||
robots: PRIVATE_ROBOTS,
|
||||
exposeUrl: false,
|
||||
})
|
||||
@@ -37,10 +36,10 @@ export function NotFoundPage() {
|
||||
<ul className="file-list">
|
||||
<li className="file-row not-found-tip">
|
||||
<div className="file-stack">
|
||||
<strong>Try the browser that created the transfer</strong>
|
||||
<strong>Try the device and browser that created the transfer</strong>
|
||||
<p className="muted">
|
||||
Sender history and local transfer controls only exist on the device that created the
|
||||
transfer.
|
||||
Sender history and local transfer controls only exist in the browser that created
|
||||
the transfer on that device.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useParams } from 'react-router-dom'
|
||||
|
||||
import { ShareCard } from '@/features/share/ShareCard'
|
||||
import { useTransfers } from '@/features/upload/TransferContext'
|
||||
import { PRIVATE_ROBOTS } from '@/lib/seo/site'
|
||||
import { PRIVATE_ROBOTS, SHARE_PAGE_DESCRIPTION, SHARE_PAGE_TITLE } from '@/lib/seo/site'
|
||||
import { usePageMetadata } from '@/lib/seo/usePageMetadata'
|
||||
|
||||
/** SharePage shows sender-side progress and sharing controls for one local transfer. */
|
||||
@@ -12,9 +12,8 @@ export function SharePage() {
|
||||
const transfer = transfers.find((item) => item.id === transferId)
|
||||
|
||||
usePageMetadata({
|
||||
title: 'Share the Full Link | Xdrop',
|
||||
description:
|
||||
'Review upload status and copy the full share link for a browser-encrypted transfer.',
|
||||
title: SHARE_PAGE_TITLE,
|
||||
description: SHARE_PAGE_DESCRIPTION,
|
||||
robots: PRIVATE_ROBOTS,
|
||||
exposeUrl: false,
|
||||
})
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('HistoryBoard', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(
|
||||
screen.getByText(/Confirm delete to remove this transfer from storage and from this device/i),
|
||||
screen.getByText(/Confirm delete to remove this transfer from this device/i),
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm delete' }))
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ export function HistoryBoard() {
|
||||
{pendingDeleteTransferId === transfer.id ? (
|
||||
<p aria-live="polite" className="warning">
|
||||
{canManageTransfer
|
||||
? 'Confirm delete to remove this transfer from storage and from this device.'
|
||||
? 'Confirm delete to remove this transfer from this device.'
|
||||
: 'Confirm forget to remove this local record from this device.'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { formatBytes } from '@/lib/files/formatBytes'
|
||||
import { safeDownloadName, sanitizePath } from '@/lib/files/paths'
|
||||
import { isAbortError, openSaveWritable, saveBlob, supportsStreamingSave } from '@/lib/files/save'
|
||||
import { formatLocalDateTime } from '@/lib/i18n/formatDateTime'
|
||||
import { PRIVATE_ROBOTS } from '@/lib/seo/site'
|
||||
import { PRIVATE_ROBOTS, RECEIVE_PAGE_DESCRIPTION, RECEIVE_PAGE_TITLE } from '@/lib/seo/site'
|
||||
import { usePageMetadata } from '@/lib/seo/usePageMetadata'
|
||||
|
||||
type Props = {
|
||||
@@ -49,9 +49,8 @@ export function ReceiveTransfer({ transferId }: Props) {
|
||||
activeDownload !== null || downloadProgress > 0 || Boolean(downloadError)
|
||||
|
||||
usePageMetadata({
|
||||
title: 'Download and Decrypt in the Browser | Xdrop',
|
||||
description:
|
||||
'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.',
|
||||
title: RECEIVE_PAGE_TITLE,
|
||||
description: RECEIVE_PAGE_DESCRIPTION,
|
||||
robots: PRIVATE_ROBOTS,
|
||||
exposeUrl: false,
|
||||
})
|
||||
|
||||
@@ -109,7 +109,9 @@ describe('ShareCard', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('Transfer not on this device')).toBeInTheDocument()
|
||||
expect(screen.getByText('Open it in the browser that created it.')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Open it in the same browser on the device that created it.'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies the full link and resets the copied state', async () => {
|
||||
@@ -310,7 +312,9 @@ describe('ShareCard', () => {
|
||||
|
||||
expect(screen.getByText('25% uploaded')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Upload will continue automatically when this browser returns.'),
|
||||
screen.getByText(
|
||||
'Upload will continue automatically when you return here in the same browser on this device.',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/Privacy mode removed local transfer controls/i)).toBeInTheDocument()
|
||||
|
||||
@@ -322,7 +326,7 @@ describe('ShareCard', () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByText('Upload stopped in this browser.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Upload stopped in this browser on this device.')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function ShareCard({ transfer }: Props) {
|
||||
<PageStateCard
|
||||
eyebrow="Share"
|
||||
title="Transfer not on this device"
|
||||
body="Open it in the browser that created it."
|
||||
body="Open it in the same browser on the device that created it."
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -260,9 +260,9 @@ function shareStatusCopy(status: LocalTransferRecord['status']) {
|
||||
case 'ready':
|
||||
return undefined
|
||||
case 'paused':
|
||||
return 'Upload will continue automatically when this browser returns.'
|
||||
return 'Upload will continue automatically when you return here in the same browser on this device.'
|
||||
case 'failed':
|
||||
return 'Upload stopped in this browser.'
|
||||
return 'Upload stopped in this browser on this device.'
|
||||
case 'deleted':
|
||||
return 'This transfer was deleted.'
|
||||
default:
|
||||
|
||||
@@ -549,7 +549,7 @@ describe('TransferProvider actions', () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
't1:paused:This page was closed or refreshed. Upload will continue automatically when this browser returns.',
|
||||
't1:paused:This page was closed or refreshed. Upload will continue automatically when you return here in the same browser on this device.',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -360,7 +360,7 @@ describe('TransferContext helpers', () => {
|
||||
makeTransferRecord('paused', {
|
||||
id: 't2',
|
||||
lastError:
|
||||
'This page was closed or refreshed. Upload will continue automatically when this browser returns.',
|
||||
'This page was closed or refreshed. Upload will continue automatically when you return here in the same browser on this device.',
|
||||
}),
|
||||
makeTransferRecord('ready', { id: 't3' }),
|
||||
])
|
||||
@@ -370,7 +370,7 @@ describe('TransferContext helpers', () => {
|
||||
expect.objectContaining({
|
||||
id: 't1',
|
||||
lastError:
|
||||
'This page was closed or refreshed. Upload will continue automatically when this browser returns.',
|
||||
'This page was closed or refreshed. Upload will continue automatically when you return here in the same browser on this device.',
|
||||
status: 'paused',
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('TransferProvider', () => {
|
||||
't1',
|
||||
createTransferRecord('paused', {
|
||||
lastError:
|
||||
'This page was closed or refreshed. Upload will continue automatically when this browser returns.',
|
||||
'This page was closed or refreshed. Upload will continue automatically when you return here in the same browser on this device.',
|
||||
}),
|
||||
)
|
||||
sourcesStore.set('t1:file-1', createSourceRecord())
|
||||
|
||||
@@ -74,7 +74,7 @@ type UploadSource = PersistedSourceRecord & {
|
||||
|
||||
/** This message marks transfers that should resume automatically after a page return. */
|
||||
const RESUME_AFTER_NAVIGATION_MESSAGE =
|
||||
'This page was closed or refreshed. Upload will continue automatically when this browser returns.'
|
||||
'This page was closed or refreshed. Upload will continue automatically when you return here in the same browser on this device.'
|
||||
|
||||
type TransferContextValue = {
|
||||
transfers: LocalTransferRecord[]
|
||||
|
||||
@@ -160,6 +160,27 @@ describe('UploadStudio', () => {
|
||||
draftState.sources = []
|
||||
})
|
||||
|
||||
it('shows browser-first positioning with agent workflow support copy', async () => {
|
||||
renderStudio()
|
||||
|
||||
expect(await screen.findByText('Encrypted file transfer.')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
/agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server\./,
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/Use Xdrop in the browser for normal sharing, or/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'through an agent' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/xixu-me/xdrop?tab=readme-ov-file#use-via-agents',
|
||||
)
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Use the web app to drop files or a folder\. Names, paths, and file contents are encrypted in this browser before upload\./,
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blocks selections that exceed the transfer size limit', async () => {
|
||||
const { container } = renderStudio()
|
||||
|
||||
|
||||
@@ -246,6 +246,19 @@ export function UploadStudio() {
|
||||
<p className="eyebrow">End-to-end encryption</p>
|
||||
<h1 className="page-hero-title">Encrypted file transfer.</h1>
|
||||
<p className="lead">{PROJECT_ONE_LINER}</p>
|
||||
<p className="muted">
|
||||
Use Xdrop in the browser for normal sharing, or{' '}
|
||||
<a
|
||||
className="inline-link"
|
||||
href="https://github.com/xixu-me/xdrop?tab=readme-ov-file#use-via-agents"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
through an agent
|
||||
</a>{' '}
|
||||
when you need to move files out of a cloud server, remote container, or automated
|
||||
terminal workflow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -265,8 +278,8 @@ export function UploadStudio() {
|
||||
>
|
||||
<div className="dropzone-shell">
|
||||
<p className="muted dropzone-copy">
|
||||
Drop files or a folder. Names, paths, and file contents are encrypted in this browser
|
||||
before upload.
|
||||
Use the web app to drop files or a folder. Names, paths, and file contents are
|
||||
encrypted in this browser before upload.
|
||||
</p>
|
||||
{uploadError ? <p className="warning dropzone-copy">{uploadError}</p> : null}
|
||||
<div className="button-row">
|
||||
@@ -401,8 +414,9 @@ export function UploadStudio() {
|
||||
</div>
|
||||
</label>
|
||||
<p className="warning">
|
||||
Uploads continue automatically when this browser returns after a refresh or reopen.
|
||||
Privacy mode keeps less sensitive state on this device, with fewer local controls.
|
||||
Uploads continue automatically when you return here in the same browser on this device
|
||||
after a refresh or reopen. Privacy mode keeps less sensitive state on this device, with
|
||||
fewer local controls.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -858,7 +858,7 @@ h3 {
|
||||
0 18px 30px rgba(2, 5, 2, 0.18);
|
||||
}
|
||||
|
||||
.dropzone > .warning {
|
||||
.dropzone-shell > .warning {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,26 @@
|
||||
|
||||
export const SITE_NAME = 'Xdrop'
|
||||
export const PROJECT_ONE_LINER =
|
||||
'Xdrop is an open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server.'
|
||||
export const DEFAULT_SEO_TITLE = 'Open Source Encrypted File Transfer in the Browser | Xdrop'
|
||||
'Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server.'
|
||||
export const SHORT_POSITIONING_SUMMARY =
|
||||
'Browser-first encrypted file transfer, with agent-ready terminal workflows.'
|
||||
export const TERMINAL_SUPPORT_BLURB =
|
||||
'Use Xdrop in the browser for normal sharing, or through an agent when you need to move files out of a cloud server, remote container, or automated terminal workflow.'
|
||||
export const DEFAULT_SEO_TITLE =
|
||||
'Open Source Encrypted File Transfer for Browsers and Agents | Xdrop'
|
||||
export const DEFAULT_SEO_DESCRIPTION = PROJECT_ONE_LINER
|
||||
export const HISTORY_PAGE_TITLE = 'Manage Transfers on This Device | Xdrop'
|
||||
export const HISTORY_PAGE_DESCRIPTION =
|
||||
'Manage encrypted transfers stored in this browser on this device. There is no account or cross-device history.'
|
||||
export const SHARE_PAGE_TITLE = 'Share the Full Link | Xdrop'
|
||||
export const SHARE_PAGE_DESCRIPTION =
|
||||
'Review upload status and copy the full share link for a transfer staged in this browser on this device.'
|
||||
export const RECEIVE_PAGE_TITLE = 'Download and Decrypt in the Browser | Xdrop'
|
||||
export const RECEIVE_PAGE_DESCRIPTION =
|
||||
'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.'
|
||||
export const NOT_FOUND_PAGE_TITLE = 'Page Not Found | Xdrop'
|
||||
export const NOT_FOUND_PAGE_DESCRIPTION =
|
||||
'The address does not map to a page in Xdrop. If this came from a shared transfer, ask for the full URL, including the #k=... decryption fragment.'
|
||||
export const DEFAULT_OG_IMAGE_PATH = '/brand-lockup-horizontal.png'
|
||||
export const DEFAULT_OG_IMAGE_ALT = 'Xdrop horizontal brand lockup'
|
||||
export const DEFAULT_LOGO_PATH = '/brand-symbol-512.png'
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('usePageMetadata', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<MetadataProbe
|
||||
title="Open Source Encrypted File Transfer in the Browser | Xdrop"
|
||||
title="Open Source Encrypted File Transfer for Browsers and Agents | Xdrop"
|
||||
description="SEO test description"
|
||||
structuredData={{
|
||||
'@context': 'https://schema.org',
|
||||
@@ -38,11 +38,13 @@ describe('usePageMetadata', () => {
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(document.title).toBe('Open Source Encrypted File Transfer in the Browser | Xdrop')
|
||||
expect(document.title).toBe(
|
||||
'Open Source Encrypted File Transfer for Browsers and Agents | Xdrop',
|
||||
)
|
||||
expect(readMetaByName('description')).toBe('SEO test description')
|
||||
expect(readMetaByName('robots')).toContain('index, follow')
|
||||
expect(readMetaByProperty('og:title')).toBe(
|
||||
'Open Source Encrypted File Transfer in the Browser | Xdrop',
|
||||
'Open Source Encrypted File Transfer for Browsers and Agents | Xdrop',
|
||||
)
|
||||
expect(readMetaByProperty('og:url')).toBe(new URL('/', window.location.origin).toString())
|
||||
expect(readCanonical()).toBe(new URL('/', window.location.origin).toString())
|
||||
@@ -89,7 +91,7 @@ describe('usePageMetadata', () => {
|
||||
expect(document.head.querySelectorAll('meta[name="description"]')).toHaveLength(1)
|
||||
expect(document.head.querySelectorAll('link[rel="canonical"]')).toHaveLength(1)
|
||||
expect(readMetaByName('description')).toContain(
|
||||
'Xdrop is an open source file transfer app that encrypts files in your browser',
|
||||
'Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal workflows',
|
||||
)
|
||||
expect(readMetaByName('robots')).toContain('index, follow')
|
||||
expect(readMetaByProperty('og:type')).toBe('website')
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xdrop",
|
||||
"description": "Open source file transfer app that encrypts files in your browser and keeps plaintext file names, contents, and keys off the server",
|
||||
"description": "Open source encrypted file transfer app for browsers and agent-driven terminal workflows, keeping plaintext file names, contents, and keys off the server",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://github.com/xixu-me/xdrop",
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user