feat: add agent workflow messaging and skill

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

No files matched your search

+59 -16
View File
@@ -7,15 +7,27 @@ metadata, and social profiles aligned.
Canonical one-liner: 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 ## Canonical Copy By Surface
README first sentence: 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: Homepage H1:
@@ -23,21 +35,21 @@ Homepage H1:
Homepage body: 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: 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: 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: OG card headline:
`Open source encrypted` `Open source encrypted`
`file transfer in your browser.` `file transfer for browsers and agents.`
OG card support line: OG card support line:
@@ -45,35 +57,62 @@ OG card support line:
Short social bio: 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: 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 ## Messaging Priorities
When space is limited, keep these ideas in this order: When space is limited, keep these ideas in this order:
1. Xdrop is open source. 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. 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 ## 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 `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 `open source encrypted file transfer app` when introducing Xdrop for the first time.
- Prefer `in-browser encryption` as the compact form when space is tight. - 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. - Use `AES-256-GCM` in technical docs, threat-model explanations, and implementation notes, not as the default marketing hook.
## Avoid ## Avoid
- Avoid using `private file transfer` as the only product summary. - 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 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 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 ## 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. - Technical enough to be accurate, but readable for non-specialists.
- Calm and factual instead of hype-heavy. - Calm and factual instead of hype-heavy.
- Confident about the architecture, careful about broader security claims. - 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 ## Copy Review Checklist
Before shipping new product-facing copy, check: Before shipping new product-facing copy, check:
- Does it describe Xdrop as open source? - 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? - 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? - 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? - Does it avoid introducing a new summary line that conflicts with the canonical one-liner?
+31 -2
View File
@@ -37,8 +37,8 @@
English | <a href="./README.zh.md">汉语</a> English | <a href="./README.zh.md">汉语</a>
</p> </p>
Xdrop is an open source file transfer app that encrypts files in your browser and keeps Xdrop is an open source encrypted file transfer app for browsers and agent-driven terminal
plaintext file names, contents, and keys off the server. workflows, keeping plaintext file names, contents, and keys off the server.
## Highlights ## 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. - Expiring links, sender-side management, and optional privacy mode after upload.
- S3-compatible object storage support with PostgreSQL and Redis on the backend. - 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 ## How It Works
1. A sender creates a transfer in the browser. Xdrop generates a random transfer root key and a 1. A sender creates a transfer in the browser. Xdrop generates a random transfer root key and a
+27 -1
View File
@@ -37,7 +37,7 @@
<a href="./README.md">English</a> | 汉语 <a href="./README.md">English</a> | 汉语
</p> </p>
Xdrop 是一个开源文件传输应用,会在浏览器中先加密文件再上传,确保服务端拿不到明文文件名、文件内容和密钥。 Xdrop 是一个开源加密文件传输应用,默认适用于浏览器,也支持智能体驱动的终端工作流,并确保服务端拿不到明文文件名、文件内容和密钥。
## 亮点 ## 亮点
@@ -47,6 +47,32 @@ Xdrop 是一个开源文件传输应用,会在浏览器中先加密文件再
- 支持到期失效链接、发送方管理,以及上传后的可选隐私模式。 - 支持到期失效链接、发送方管理,以及上传后的可选隐私模式。
- 后端支持兼容 S3 的对象存储,并使用 PostgreSQL 和 Redis。 - 后端支持兼容 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 会生成随机的传输根密钥和独立的链接密钥,可选地移除图片中可删除的元数据,并在上传开始前准备好可恢复的本地状态。 1. 发送方在浏览器中创建一次传输。Xdrop 会生成随机的传输根密钥和独立的链接密钥,可选地移除图片中可删除的元数据,并在上传开始前准备好可恢复的本地状态。
+6 -6
View File
@@ -8,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="description" 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 <meta
name="robots" name="robots"
@@ -20,26 +20,26 @@
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta <meta
property="og:title" 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 <meta
property="og:description" 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" content="/brand-lockup-horizontal.png" />
<meta property="og:image:alt" content="Xdrop horizontal brand lockup" /> <meta property="og:image:alt" content="Xdrop horizontal brand lockup" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta <meta
name="twitter:title" 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 <meta
name="twitter:description" 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="twitter:image" content="/brand-lockup-horizontal.png" />
<meta name="theme-color" content="#12140f" /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "Xdrop", "name": "Xdrop",
"short_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", "theme_color": "#12140f",
"background_color": "#f7f2e8", "background_color": "#f7f2e8",
"display": "standalone", "display": "standalone",
+22 -14
View File
@@ -18,9 +18,21 @@ const DEFAULT_ROBOTS =
const PRIVATE_ROBOTS = 'noindex, nofollow, noarchive, nosnippet' const PRIVATE_ROBOTS = 'noindex, nofollow, noarchive, nosnippet'
const STRUCTURED_DATA_ID = 'xdrop-structured-data-static' 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 = 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 = [ const routeShells = [
{ {
@@ -34,35 +46,31 @@ const routeShells = [
{ {
outputPath: 'transfers/index.html', outputPath: 'transfers/index.html',
pagePath: '/transfers', pagePath: '/transfers',
title: 'Manage Transfers on This Device | Xdrop', title: HISTORY_PAGE_TITLE,
description: description: HISTORY_PAGE_DESCRIPTION,
'Manage encrypted transfers stored in this browser. There is no account or cross-device history.',
robots: PRIVATE_ROBOTS, robots: PRIVATE_ROBOTS,
exposeUrl: false, exposeUrl: false,
}, },
{ {
outputPath: 'share/index.html', outputPath: 'share/index.html',
pagePath: '/share/', pagePath: '/share/',
title: 'Share the Full Link | Xdrop', title: SHARE_PAGE_TITLE,
description: description: SHARE_PAGE_DESCRIPTION,
'Review upload status and copy the full share link for a browser-encrypted transfer.',
robots: PRIVATE_ROBOTS, robots: PRIVATE_ROBOTS,
exposeUrl: false, exposeUrl: false,
}, },
{ {
outputPath: 't/index.html', outputPath: 't/index.html',
pagePath: '/t/', pagePath: '/t/',
title: 'Download and Decrypt in the Browser | Xdrop', title: RECEIVE_PAGE_TITLE,
description: description: RECEIVE_PAGE_DESCRIPTION,
'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.',
robots: PRIVATE_ROBOTS, robots: PRIVATE_ROBOTS,
exposeUrl: false, exposeUrl: false,
}, },
{ {
outputPath: 'not-found/index.html', outputPath: 'not-found/index.html',
title: 'Page Not Found | Xdrop', title: NOT_FOUND_PAGE_TITLE,
description: description: 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.',
robots: PRIVATE_ROBOTS, robots: PRIVATE_ROBOTS,
exposeUrl: false, exposeUrl: false,
}, },
+1 -1
View File
@@ -20,7 +20,7 @@ export function Shell() {
/> />
<span className="brand-copy"> <span className="brand-copy">
<span className="brand-mark">Xdrop</span> <span className="brand-mark">Xdrop</span>
<span className="brand-note">Browser-encrypted transfer</span> <span className="brand-note">Encrypted file transfer</span>
</span> </span>
</NavLink> </NavLink>
<nav className="nav"> <nav className="nav">
+3 -4
View File
@@ -1,13 +1,12 @@
import { HistoryBoard } from '@/features/history/HistoryBoard' 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' import { usePageMetadata } from '@/lib/seo/usePageMetadata'
/** HistoryPage hides local-only transfer history from search engines. */ /** HistoryPage hides local-only transfer history from search engines. */
export function HistoryPage() { export function HistoryPage() {
usePageMetadata({ usePageMetadata({
title: 'Manage Transfers on This Device | Xdrop', title: HISTORY_PAGE_TITLE,
description: description: HISTORY_PAGE_DESCRIPTION,
'Manage encrypted transfers stored in this browser. There is no account or cross-device history.',
robots: PRIVATE_ROBOTS, robots: PRIVATE_ROBOTS,
exposeUrl: false, exposeUrl: false,
}) })
+1 -1
View File
@@ -27,7 +27,7 @@ describe('HomePage', () => {
expect(usePageMetadataMock).toHaveBeenCalledWith( expect(usePageMetadataMock).toHaveBeenCalledWith(
expect.objectContaining({ 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([ structuredData: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
'@type': 'Organization', '@type': 'Organization',
@@ -14,6 +14,8 @@ describe('NotFoundPage', () => {
expect(screen.getByRole('heading', { name: 'This page was not found.' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'This page was not found.' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Try these checks' })).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()
}) })
}) })
+6 -7
View File
@@ -1,13 +1,12 @@
import { Card } from '@/components/ui/Card' 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' import { usePageMetadata } from '@/lib/seo/usePageMetadata'
/** NotFoundPage explains the most common ways secure share links break. */ /** NotFoundPage explains the most common ways secure share links break. */
export function NotFoundPage() { export function NotFoundPage() {
usePageMetadata({ usePageMetadata({
title: 'Page Not Found | Xdrop', title: NOT_FOUND_PAGE_TITLE,
description: description: 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.',
robots: PRIVATE_ROBOTS, robots: PRIVATE_ROBOTS,
exposeUrl: false, exposeUrl: false,
}) })
@@ -37,10 +36,10 @@ export function NotFoundPage() {
<ul className="file-list"> <ul className="file-list">
<li className="file-row not-found-tip"> <li className="file-row not-found-tip">
<div className="file-stack"> <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"> <p className="muted">
Sender history and local transfer controls only exist on the device that created the Sender history and local transfer controls only exist in the browser that created
transfer. the transfer on that device.
</p> </p>
</div> </div>
</li> </li>
+3 -4
View File
@@ -2,7 +2,7 @@ import { useParams } from 'react-router-dom'
import { ShareCard } from '@/features/share/ShareCard' import { ShareCard } from '@/features/share/ShareCard'
import { useTransfers } from '@/features/upload/TransferContext' 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' import { usePageMetadata } from '@/lib/seo/usePageMetadata'
/** SharePage shows sender-side progress and sharing controls for one local transfer. */ /** 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) const transfer = transfers.find((item) => item.id === transferId)
usePageMetadata({ usePageMetadata({
title: 'Share the Full Link | Xdrop', title: SHARE_PAGE_TITLE,
description: description: SHARE_PAGE_DESCRIPTION,
'Review upload status and copy the full share link for a browser-encrypted transfer.',
robots: PRIVATE_ROBOTS, robots: PRIVATE_ROBOTS,
exposeUrl: false, exposeUrl: false,
}) })
@@ -198,7 +198,7 @@ describe('HistoryBoard', () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete' })) fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect( 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() ).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Confirm delete' })) fireEvent.click(screen.getByRole('button', { name: 'Confirm delete' }))
@@ -235,7 +235,7 @@ export function HistoryBoard() {
{pendingDeleteTransferId === transfer.id ? ( {pendingDeleteTransferId === transfer.id ? (
<p aria-live="polite" className="warning"> <p aria-live="polite" className="warning">
{canManageTransfer {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.'} : 'Confirm forget to remove this local record from this device.'}
</p> </p>
) : null} ) : null}
@@ -17,7 +17,7 @@ import { formatBytes } from '@/lib/files/formatBytes'
import { safeDownloadName, sanitizePath } from '@/lib/files/paths' import { safeDownloadName, sanitizePath } from '@/lib/files/paths'
import { isAbortError, openSaveWritable, saveBlob, supportsStreamingSave } from '@/lib/files/save' import { isAbortError, openSaveWritable, saveBlob, supportsStreamingSave } from '@/lib/files/save'
import { formatLocalDateTime } from '@/lib/i18n/formatDateTime' 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' import { usePageMetadata } from '@/lib/seo/usePageMetadata'
type Props = { type Props = {
@@ -49,9 +49,8 @@ export function ReceiveTransfer({ transferId }: Props) {
activeDownload !== null || downloadProgress > 0 || Boolean(downloadError) activeDownload !== null || downloadProgress > 0 || Boolean(downloadError)
usePageMetadata({ usePageMetadata({
title: 'Download and Decrypt in the Browser | Xdrop', title: RECEIVE_PAGE_TITLE,
description: description: RECEIVE_PAGE_DESCRIPTION,
'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.',
robots: PRIVATE_ROBOTS, robots: PRIVATE_ROBOTS,
exposeUrl: false, exposeUrl: false,
}) })
@@ -109,7 +109,9 @@ describe('ShareCard', () => {
) )
expect(screen.getByText('Transfer not on this device')).toBeInTheDocument() 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 () => { 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('25% uploaded')).toBeInTheDocument()
expect( 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() ).toBeInTheDocument()
expect(screen.getByText(/Privacy mode removed local transfer controls/i)).toBeInTheDocument() expect(screen.getByText(/Privacy mode removed local transfer controls/i)).toBeInTheDocument()
@@ -322,7 +326,7 @@ describe('ShareCard', () => {
await act(async () => { await act(async () => {
await Promise.resolve() 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( rerender(
<MemoryRouter> <MemoryRouter>
+3 -3
View File
@@ -115,7 +115,7 @@ export function ShareCard({ transfer }: Props) {
<PageStateCard <PageStateCard
eyebrow="Share" eyebrow="Share"
title="Transfer not on this device" 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': case 'ready':
return undefined return undefined
case 'paused': 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': case 'failed':
return 'Upload stopped in this browser.' return 'Upload stopped in this browser on this device.'
case 'deleted': case 'deleted':
return 'This transfer was deleted.' return 'This transfer was deleted.'
default: default:
@@ -549,7 +549,7 @@ describe('TransferProvider actions', () => {
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText( 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() ).toBeInTheDocument()
}) })
@@ -360,7 +360,7 @@ describe('TransferContext helpers', () => {
makeTransferRecord('paused', { makeTransferRecord('paused', {
id: 't2', id: 't2',
lastError: 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' }), makeTransferRecord('ready', { id: 't3' }),
]) ])
@@ -370,7 +370,7 @@ describe('TransferContext helpers', () => {
expect.objectContaining({ expect.objectContaining({
id: 't1', id: 't1',
lastError: 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', status: 'paused',
}), }),
) )
@@ -226,7 +226,7 @@ describe('TransferProvider', () => {
't1', 't1',
createTransferRecord('paused', { createTransferRecord('paused', {
lastError: 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()) 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. */ /** This message marks transfers that should resume automatically after a page return. */
const RESUME_AFTER_NAVIGATION_MESSAGE = 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 = { type TransferContextValue = {
transfers: LocalTransferRecord[] transfers: LocalTransferRecord[]
@@ -160,6 +160,27 @@ describe('UploadStudio', () => {
draftState.sources = [] 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 () => { it('blocks selections that exceed the transfer size limit', async () => {
const { container } = renderStudio() const { container } = renderStudio()
+18 -4
View File
@@ -246,6 +246,19 @@ export function UploadStudio() {
<p className="eyebrow">End-to-end encryption</p> <p className="eyebrow">End-to-end encryption</p>
<h1 className="page-hero-title">Encrypted file transfer.</h1> <h1 className="page-hero-title">Encrypted file transfer.</h1>
<p className="lead">{PROJECT_ONE_LINER}</p> <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> </div>
<div <div
@@ -265,8 +278,8 @@ export function UploadStudio() {
> >
<div className="dropzone-shell"> <div className="dropzone-shell">
<p className="muted dropzone-copy"> <p className="muted dropzone-copy">
Drop files or a folder. Names, paths, and file contents are encrypted in this browser Use the web app to drop files or a folder. Names, paths, and file contents are
before upload. encrypted in this browser before upload.
</p> </p>
{uploadError ? <p className="warning dropzone-copy">{uploadError}</p> : null} {uploadError ? <p className="warning dropzone-copy">{uploadError}</p> : null}
<div className="button-row"> <div className="button-row">
@@ -401,8 +414,9 @@ export function UploadStudio() {
</div> </div>
</label> </label>
<p className="warning"> <p className="warning">
Uploads continue automatically when this browser returns after a refresh or reopen. Uploads continue automatically when you return here in the same browser on this device
Privacy mode keeps less sensitive state on this device, with fewer local controls. after a refresh or reopen. Privacy mode keeps less sensitive state on this device, with
fewer local controls.
</p> </p>
</Card> </Card>
+1 -1
View File
@@ -858,7 +858,7 @@ h3 {
0 18px 30px rgba(2, 5, 2, 0.18); 0 18px 30px rgba(2, 5, 2, 0.18);
} }
.dropzone > .warning { .dropzone-shell > .warning {
margin-top: 0; margin-top: 0;
} }
+19 -2
View File
@@ -4,9 +4,26 @@
export const SITE_NAME = 'Xdrop' export const SITE_NAME = 'Xdrop'
export const PROJECT_ONE_LINER = 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.' '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 DEFAULT_SEO_TITLE = 'Open Source Encrypted File Transfer in the Browser | Xdrop' 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 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_PATH = '/brand-lockup-horizontal.png'
export const DEFAULT_OG_IMAGE_ALT = 'Xdrop horizontal brand lockup' export const DEFAULT_OG_IMAGE_ALT = 'Xdrop horizontal brand lockup'
export const DEFAULT_LOGO_PATH = '/brand-symbol-512.png' export const DEFAULT_LOGO_PATH = '/brand-symbol-512.png'
@@ -27,7 +27,7 @@ describe('usePageMetadata', () => {
render( render(
<MemoryRouter initialEntries={['/']}> <MemoryRouter initialEntries={['/']}>
<MetadataProbe <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" description="SEO test description"
structuredData={{ structuredData={{
'@context': 'https://schema.org', '@context': 'https://schema.org',
@@ -38,11 +38,13 @@ describe('usePageMetadata', () => {
</MemoryRouter>, </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('description')).toBe('SEO test description')
expect(readMetaByName('robots')).toContain('index, follow') expect(readMetaByName('robots')).toContain('index, follow')
expect(readMetaByProperty('og:title')).toBe( 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(readMetaByProperty('og:url')).toBe(new URL('/', window.location.origin).toString())
expect(readCanonical()).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('meta[name="description"]')).toHaveLength(1)
expect(document.head.querySelectorAll('link[rel="canonical"]')).toHaveLength(1) expect(document.head.querySelectorAll('link[rel="canonical"]')).toHaveLength(1)
expect(readMetaByName('description')).toContain( 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(readMetaByName('robots')).toContain('index, follow')
expect(readMetaByProperty('og:type')).toBe('website') expect(readMetaByProperty('og:type')).toBe('website')
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "xdrop", "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, "private": true,
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"homepage": "https://github.com/xixu-me/xdrop", "homepage": "https://github.com/xixu-me/xdrop",
+86
View File
@@ -0,0 +1,86 @@
---
name: xdrop
description: Use this skill when the user wants to send or fetch files through an Xdrop server from the terminal, asks to automate encrypted Xdrop share-link workflows, provides an Xdrop `/t/:transferId#k=...` link to download and decrypt locally, or needs Xdrop CLI flags such as `--quiet`, `--json`, `--expires-in`, `--output`, or `--api-url`, even if they do not explicitly mention the skill name.
---
# Xdrop
Use the bundled scripts inside this skill directory.
## Available scripts
- `scripts/upload.mjs` — Upload local files or directories to an Xdrop server and print the share link
- `scripts/download.mjs` — Download an Xdrop share link, decrypt it locally, and save the files
Environment requirements:
- Bun
- Local filesystem access
- Network access to the target Xdrop server
## Upload
Run from the skill root:
```bash
bun scripts/upload.mjs --server <xdrop-site-url> <file-or-directory> [...]
```
Prefer these flags when relevant:
- `--quiet`: suppress progress output and keep stdout clean
- `--json`: return `transferId`, `shareUrl`, and `expiresAt`
- `--expires-in <seconds>`: choose a supported expiry
- `--api-url <url>`: override the default `<server>/api/v1`
- `--name <value>`: set the transfer display name
- `--concurrency <n>`: limit parallel uploads per file
Useful examples:
```bash
bun scripts/upload.mjs --server http://localhost:8080 ./dist/report.pdf
bun scripts/upload.mjs --server http://localhost:8080 --quiet ./archive.zip
bun scripts/upload.mjs --server http://localhost:8080 --expires-in 600 --json ./notes.txt
```
If the user wants verification, upload a small temporary file and then confirm the public transfer API or browser can open the returned link.
## Download
Require the full share link, including `#k=...`. Without the fragment key, the transfer cannot be decrypted.
Run from the skill root:
```bash
bun scripts/download.mjs "<share-url>"
```
Prefer these flags when relevant:
- `--output <dir>`: choose the destination directory
- `--quiet`: suppress progress output and keep stdout clean
- `--json`: return `transferId`, `outputRoot`, and saved file paths
- `--api-url <url>`: override the default `<share-origin>/api/v1`
Useful examples:
```bash
bun scripts/download.mjs "http://localhost:8080/t/abc123#k=..."
bun scripts/download.mjs --output ./downloads "http://localhost:8080/t/abc123#k=..."
bun scripts/download.mjs --quiet --json --output ./downloads "http://localhost:8080/t/abc123#k=..."
```
By default the downloader writes to `./xdrop-<transferId>` and preserves the manifest's relative paths.
## Gotchas
- A download link without the `#k=...` fragment is not decryptable. Ask for the full original share URL.
- Use `--quiet` whenever another command or caller needs to capture stdout. Progress logs otherwise go to stderr, but the final result still matters.
- The bundled scripts are self-contained. Prefer them over repository-level wrappers so the skill still works when used outside this repository.
## Guardrails
- Run the bundled scripts by relative path from the skill root. Do not rely on repository-level wrappers such as `bun run upload:cli` or `bun run download:cli`.
- Prefer `--quiet` when another command or script needs to capture stdout.
- Keep the full share link fragment intact for downloads.
- Do not bypass the scripts' built-in path sanitization or transfer cleanup behavior with manual ad hoc commands unless the user explicitly asks.
+389
View File
@@ -0,0 +1,389 @@
import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { resolveApiUrl } from './upload.mjs'
const MANIFEST_VERSION = 1
const encoder = new TextEncoder()
const decoder = new TextDecoder()
let quietMode = false
const HELP_TEXT = `Download files from an Xdrop share link and decrypt them locally.
Usage:
bun <path-to-download.mjs> <share-url>
Options:
--output <dir> Destination directory. Defaults to ./xdrop-<transferId>.
--api-url <url> Override the API root. Defaults to <share-origin>/api/v1.
--quiet Suppress progress output and only print the final result.
--json Print JSON instead of a plain output path.
--help Show this help.
Examples:
bun scripts/download.mjs "http://localhost:8080/t/abc#k=..."
bun scripts/download.mjs --output ./downloads "http://localhost:8080/t/abc#k=..."
`
export async function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv)
quietMode = options.quiet
if (options.help) {
process.stdout.write(`${HELP_TEXT}\n`)
return
}
if (!options.shareUrl) {
throw new Error('Provide a full Xdrop share link.')
}
const share = parseShareUrl(options.shareUrl)
const api = new XdropDownloadApiClient(resolveApiUrl(share.serverUrl, options.apiUrl))
logStatus(`Fetching transfer ${share.transferId}`)
const descriptor = await api.getPublicTransfer(share.transferId)
if (descriptor.status !== 'ready' || !descriptor.manifestUrl || !descriptor.wrappedRootKey) {
throw new Error(getTransferStatusError(descriptor.status))
}
const manifestResponse = await fetch(descriptor.manifestUrl)
if (!manifestResponse.ok) {
throw new Error(`Couldn't load the encrypted manifest (${manifestResponse.status}).`)
}
const envelopeBytes = new Uint8Array(await manifestResponse.arrayBuffer())
const rootKey = await unwrapRootKey(descriptor.wrappedRootKey, share.linkKey)
const manifest = await decryptManifest(rootKey, envelopeBytes)
const outputRoot = resolve(process.cwd(), options.output || `xdrop-${share.transferId}`)
await mkdir(outputRoot, { recursive: true })
const savedFiles = []
for (const [index, file] of manifest.files.entries()) {
const sanitizedPath = sanitizePath(file.relativePath || file.name)
if (!sanitizedPath) {
throw new Error(`Refusing to write an empty file path for ${file.fileId}.`)
}
const destination = resolve(outputRoot, ...sanitizedPath.split('/'))
await mkdir(dirname(destination), { recursive: true })
logStatus(`Downloading ${sanitizedPath} (${index + 1}/${manifest.files.length})`)
await downloadFile({
api,
transferId: share.transferId,
file,
rootKey,
destination,
})
savedFiles.push(destination)
}
if (options.json) {
process.stdout.write(
`${JSON.stringify(
{
transferId: share.transferId,
outputRoot,
files: savedFiles,
},
null,
2,
)}\n`,
)
return
}
process.stdout.write(`${savedFiles.length === 1 ? savedFiles[0] : outputRoot}\n`)
}
export function parseArgs(argv) {
const options = {
output: '',
apiUrl: process.env.XDROP_API_URL?.trim() || '',
quiet: false,
json: false,
help: false,
shareUrl: '',
}
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (!value) {
continue
}
if (value === '--help' || value === '-h') {
options.help = true
continue
}
if (value === '--quiet') {
options.quiet = true
continue
}
if (value === '--json') {
options.json = true
continue
}
if (value === '--output') {
options.output = requireValue(argv, ++index, '--output')
continue
}
if (value === '--api-url') {
options.apiUrl = requireValue(argv, ++index, '--api-url')
continue
}
if (value.startsWith('--')) {
throw new Error(`Unknown option: ${value}`)
}
if (options.shareUrl) {
throw new Error('Only one share link can be downloaded at a time.')
}
options.shareUrl = value
}
return options
}
function requireValue(argv, index, flag) {
const value = argv[index]
if (!value) {
throw new Error(`Missing value for ${flag}`)
}
return value
}
export function parseShareUrl(input) {
const url = new URL(input)
const match = url.pathname.match(/\/t\/([^/]+)\/?$/u)
if (!match?.[1]) {
throw new Error('Share link must point to /t/:transferId.')
}
const params = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash)
const key = params.get('k')
if (!key) {
throw new Error('Share link is missing the decryption key fragment.')
}
url.hash = ''
url.search = ''
url.pathname = '/'
return {
transferId: match[1],
linkKey: fromBase64Url(key),
serverUrl: url,
}
}
export function sanitizePath(input) {
return input
.split(/[\\/]+/u)
.filter((segment) => segment && segment !== '.' && segment !== '..')
.map((segment) =>
Array.from(segment.replace(/[<>:"|?*]/gu, '_'))
.map((char) => ((char.codePointAt(0) ?? 0) < 32 ? '_' : char))
.join(''),
)
.join('/')
}
async function downloadFile({ api, transferId, file, rootKey, destination }) {
const chunks = Array.from({ length: file.totalChunks }, (_, chunkIndex) => ({
fileId: file.fileId,
chunkIndex,
}))
const urls = await api.createDownloadUrls(transferId, chunks)
const urlMap = new Map(urls.map((item) => [item.chunkIndex, item.url]))
const noncePrefix = fromBase64Url(file.noncePrefix)
const fileHandle = await open(destination, 'w')
try {
for (let chunkIndex = 0; chunkIndex < file.totalChunks; chunkIndex += 1) {
const url = urlMap.get(chunkIndex)
if (!url) {
throw new Error(`Missing download URL for ${file.relativePath} chunk ${chunkIndex}.`)
}
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Chunk download failed with ${response.status}.`)
}
const ciphertext = new Uint8Array(await response.arrayBuffer())
const remainingBytes = Math.max(file.plaintextSize - chunkIndex * file.chunkSize, 0)
const plaintextChunkSize = Math.min(file.chunkSize, remainingBytes)
const plaintext = await decryptChunk({
rootKey,
transferId,
fileId: file.fileId,
chunkIndex,
noncePrefix,
plaintextChunkSize,
ciphertext,
})
await fileHandle.write(plaintext)
}
} finally {
await fileHandle.close()
}
}
async function unwrapRootKey(serializedEnvelope, linkKey) {
const envelope = JSON.parse(serializedEnvelope)
const wrappingKey = await deriveHkdfKey(linkKey, 'wrap-root')
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: fromBase64Url(envelope.iv),
},
wrappingKey,
fromBase64(envelope.ciphertext),
)
return new Uint8Array(plaintext)
}
async function decryptManifest(rootKey, envelopeBytes) {
const envelope = JSON.parse(decoder.decode(envelopeBytes))
const manifestKey = await deriveHkdfKey(rootKey, 'manifest')
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: fromBase64Url(envelope.iv),
},
manifestKey,
fromBase64(envelope.ciphertext),
)
return JSON.parse(decoder.decode(plaintext))
}
async function decryptChunk(options) {
const fileKey = await deriveHkdfKey(options.rootKey, `file:${options.fileId}`)
const iv = buildChunkIv(options.noncePrefix, options.chunkIndex)
const additionalData = encoder.encode(
[
options.transferId,
options.fileId,
options.chunkIndex,
options.plaintextChunkSize,
MANIFEST_VERSION,
].join('|'),
)
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv,
additionalData,
},
fileKey,
options.ciphertext,
)
return new Uint8Array(plaintext)
}
async function deriveHkdfKey(source, info) {
const sourceKey = await crypto.subtle.importKey('raw', source, 'HKDF', false, ['deriveKey'])
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(),
info: encoder.encode(info),
},
sourceKey,
{
name: 'AES-GCM',
length: 256,
},
false,
['encrypt', 'decrypt'],
)
}
function buildChunkIv(noncePrefix, chunkIndex) {
const iv = new Uint8Array(12)
iv.set(noncePrefix.slice(0, 8), 0)
new DataView(iv.buffer).setUint32(8, chunkIndex, false)
return iv
}
function fromBase64Url(value) {
const normalized = value.replace(/-/gu, '+').replace(/_/gu, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
return new Uint8Array(Buffer.from(padded, 'base64'))
}
function fromBase64(value) {
return new Uint8Array(Buffer.from(value, 'base64'))
}
function getTransferStatusError(status) {
switch (status) {
case 'expired':
return 'This share link has expired.'
case 'deleted':
return 'This transfer was deleted.'
case 'incomplete':
return 'This transfer is still uploading.'
default:
return 'This transfer is unavailable.'
}
}
function logStatus(message) {
if (quietMode) {
return
}
process.stderr.write(`${message}\n`)
}
class XdropDownloadApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl
}
async getPublicTransfer(transferId) {
return this.request(`/public/transfers/${transferId}`)
}
async createDownloadUrls(transferId, chunks) {
const response = await this.request(`/public/transfers/${transferId}/download-urls`, {
method: 'POST',
body: { chunks },
})
return response.items
}
async request(path, options = { method: 'GET' }) {
const response = await fetch(`${this.baseUrl}${path}`, {
method: options.method ?? 'GET',
headers: {
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
})
if (!response.ok) {
const payload = await response.json().catch(() => ({}))
const detail = payload.message ?? payload.error ?? `Request failed with ${response.status}`
throw new Error(detail)
}
return response.json()
}
}
if (import.meta.main) {
main().catch(async (error) => {
if (quietMode) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.exit(1)
}
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.exit(1)
})
}
+738
View File
@@ -0,0 +1,738 @@
import { createHash } from 'node:crypto'
import { open, readdir, stat } from 'node:fs/promises'
import { basename, extname, resolve } from 'node:path'
const MANIFEST_VERSION = 1
const WRAP_VERSION = 1
const DEFAULT_EXPIRY_SECONDS = 60 * 60
const MAX_UPLOAD_CONCURRENCY = 6
const MAX_TRANSFER_BYTES = 256 * 1024 * 1024
const encoder = new TextEncoder()
let quietMode = false
const HELP_TEXT = `Upload files to an Xdrop server and print the share link.
Usage:
bun <path-to-upload.mjs> --server https://xdrop.example.com <file-or-directory> [...]
Options:
--server <url> Public Xdrop site URL. Can also be set with XDROP_SERVER.
--api-url <url> Override the API root. Defaults to <server>/api/v1.
--expires-in <sec> Transfer expiry in seconds. Default: ${DEFAULT_EXPIRY_SECONDS}.
--name <value> Custom transfer display name.
--concurrency <n> Parallel uploads per file. Default: 1, max: ${MAX_UPLOAD_CONCURRENCY}.
--quiet Suppress progress output and only print the final result.
--json Print JSON instead of a bare share link.
--help Show this help.
Examples:
bun scripts/upload.mjs --server http://localhost:8080 ./dist/archive.zip
bun scripts/upload.mjs --server https://xdrop.example.com ./photo.jpg ./notes.txt
`
export async function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv)
quietMode = options.quiet
if (options.help) {
process.stdout.write(`${HELP_TEXT}\n`)
return
}
if (!options.server) {
throw new Error('Missing --server. Pass the public Xdrop site URL or set XDROP_SERVER.')
}
if (options.inputs.length === 0) {
throw new Error('Choose at least one file or directory to upload.')
}
const serverUrl = normalizeSiteUrl(options.server)
const apiUrl = resolveApiUrl(serverUrl, options.apiUrl)
const files = await collectTransferInputs(options.inputs)
if (files.length === 0) {
throw new Error('No files were found in the selected paths.')
}
const displayName = options.name ?? defaultDisplayName(files)
const api = new XdropApiClient(apiUrl)
logStatus(`Creating transfer on ${serverUrl.toString()}`)
const created = await api.createTransfer(options.expiresInSeconds)
const chunkSize = created.uploadConfig.chunkSize
const maxFileCount = created.uploadConfig.maxFileCount
const maxTransferBytes = created.uploadConfig.maxTransferBytes || MAX_TRANSFER_BYTES
if (files.length > maxFileCount) {
throw new Error(
`This selection has ${files.length} files. The server limit is ${maxFileCount}.`,
)
}
const rootKey = randomBytes(32)
const linkKey = randomBytes(32)
const preparedFiles = prepareFiles(files, chunkSize)
const totalCiphertextBytes = preparedFiles.reduce(
(sum, file) => sum + file.ciphertextSizes.reduce((next, size) => next + size, 0),
0,
)
if (totalCiphertextBytes > maxTransferBytes) {
throw new Error(
`Encrypted upload size ${formatBytes(totalCiphertextBytes)} exceeds the server limit ${formatBytes(maxTransferBytes)}.`,
)
}
const shareUrl = new URL(`/t/${created.transferId}`, serverUrl)
shareUrl.hash = `k=${toBase64Url(linkKey)}`
let finalized = false
try {
await api.registerFiles(
created.transferId,
created.manageToken,
preparedFiles.map((file) => ({
fileId: file.fileId,
totalChunks: file.totalChunks,
ciphertextBytes: file.ciphertextSizes.reduce((sum, size) => sum + size, 0),
plaintextBytes: file.plaintextSize,
chunkSize: file.chunkSize,
})),
)
let uploadedCiphertextBytes = 0
for (const [index, file] of preparedFiles.entries()) {
logStatus(`Uploading ${file.relativePath} (${index + 1}/${preparedFiles.length})`)
const uploadUrls = await api.createUploadUrls(
created.transferId,
created.manageToken,
Array.from({ length: file.totalChunks }, (_, chunkIndex) => ({
fileId: file.fileId,
chunkIndex,
})),
)
const uploadUrlMap = new Map(uploadUrls.map((item) => [item.chunkIndex, item.url]))
const completedChunks = await uploadFileChunks({
transferId: created.transferId,
file,
uploadUrlMap,
rootKey,
concurrency: options.concurrency,
})
uploadedCiphertextBytes += completedChunks.reduce(
(sum, chunk) => sum + chunk.ciphertextSize,
0,
)
await api.completeChunks(created.transferId, created.manageToken, completedChunks)
logStatus(
`Uploaded ${file.relativePath} (${formatBytes(uploadedCiphertextBytes)} / ${formatBytes(totalCiphertextBytes)})`,
)
}
const manifest = {
version: 1,
displayName,
createdAt: new Date().toISOString(),
chunkSize,
files: preparedFiles.map((file) => ({
fileId: file.fileId,
name: file.name,
relativePath: file.relativePath,
mimeType: file.mimeType,
plaintextSize: file.plaintextSize,
modifiedAt: file.modifiedAt,
chunkSize: file.chunkSize,
totalChunks: file.totalChunks,
ciphertextSizes: file.ciphertextSizes,
noncePrefix: toBase64Url(file.noncePrefix),
metadataStripped: false,
})),
}
const manifestBytes = await encryptManifest(rootKey, manifest)
const wrappedRootKey = await wrapRootKey(rootKey, linkKey)
await api.uploadManifest(created.transferId, created.manageToken, toBase64(manifestBytes))
await api.finalizeTransfer(
created.transferId,
created.manageToken,
wrappedRootKey,
preparedFiles.length,
totalCiphertextBytes,
)
finalized = true
if (options.json) {
process.stdout.write(
`${JSON.stringify(
{
transferId: created.transferId,
shareUrl: shareUrl.toString(),
expiresAt: created.expiresAt,
},
null,
2,
)}\n`,
)
return
}
process.stdout.write(`${shareUrl.toString()}\n`)
} finally {
if (!finalized) {
await api.deleteTransfer(created.transferId, created.manageToken).catch(() => {})
}
}
}
export function parseArgs(argv) {
const options = {
server: process.env.XDROP_SERVER?.trim() || '',
apiUrl: process.env.XDROP_API_URL?.trim() || '',
expiresInSeconds: DEFAULT_EXPIRY_SECONDS,
name: '',
concurrency: 1,
quiet: false,
json: false,
help: false,
inputs: [],
}
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (!value) {
continue
}
if (value === '--help' || value === '-h') {
options.help = true
continue
}
if (value === '--json') {
options.json = true
continue
}
if (value === '--quiet') {
options.quiet = true
continue
}
if (value === '--server') {
options.server = requireValue(argv, ++index, '--server')
continue
}
if (value === '--api-url') {
options.apiUrl = requireValue(argv, ++index, '--api-url')
continue
}
if (value === '--expires-in') {
const parsed = Number.parseInt(requireValue(argv, ++index, '--expires-in'), 10)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error('--expires-in must be a positive integer number of seconds.')
}
options.expiresInSeconds = parsed
continue
}
if (value === '--name') {
options.name = requireValue(argv, ++index, '--name')
continue
}
if (value === '--concurrency') {
const parsed = Number.parseInt(requireValue(argv, ++index, '--concurrency'), 10)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error('--concurrency must be a positive integer.')
}
options.concurrency = Math.min(parsed, MAX_UPLOAD_CONCURRENCY)
continue
}
if (value.startsWith('--')) {
throw new Error(`Unknown option: ${value}`)
}
options.inputs.push(value)
}
return options
}
function requireValue(argv, index, flag) {
const value = argv[index]
if (!value) {
throw new Error(`Missing value for ${flag}`)
}
return value
}
function normalizeSiteUrl(value) {
const url = new URL(value)
url.hash = ''
url.search = ''
if (url.pathname.endsWith('/api/v1')) {
url.pathname = url.pathname.slice(0, -'/api/v1'.length) || '/'
}
if (!url.pathname.endsWith('/')) {
url.pathname = `${url.pathname}/`
}
return url
}
function normalizeApiUrl(value) {
const url = new URL(value)
url.hash = ''
url.search = ''
return url.toString().replace(/\/$/u, '')
}
export function resolveApiUrl(serverUrl, apiUrl) {
return normalizeApiUrl(apiUrl || new URL('/api/v1', serverUrl).toString())
}
export async function collectTransferInputs(inputPaths) {
const files = []
const seenPaths = new Set()
for (const inputPath of inputPaths) {
const absolutePath = resolve(process.cwd(), inputPath)
const inputStat = await stat(absolutePath)
if (inputStat.isDirectory()) {
const rootName = basename(absolutePath)
const nestedFiles = await collectDirectoryFiles(absolutePath, rootName)
files.push(...nestedFiles)
continue
}
if (!inputStat.isFile()) {
throw new Error(`Only files and directories are supported: ${inputPath}`)
}
files.push({
absolutePath,
relativePath: basename(absolutePath),
size: inputStat.size,
modifiedAt: Math.round(inputStat.mtimeMs),
name: basename(absolutePath),
mimeType: mimeTypeFromName(absolutePath),
})
}
for (const file of files) {
if (seenPaths.has(file.relativePath)) {
throw new Error(`Duplicate relative path in upload set: ${file.relativePath}`)
}
seenPaths.add(file.relativePath)
}
return files
}
async function collectDirectoryFiles(directoryPath, relativePrefix) {
const entries = (await readdir(directoryPath, { withFileTypes: true })).sort((left, right) =>
left.name.localeCompare(right.name),
)
const files = []
for (const entry of entries) {
const absolutePath = resolve(directoryPath, entry.name)
const relativePath = `${relativePrefix}/${entry.name}`.replace(/\\/gu, '/')
if (entry.isDirectory()) {
files.push(...(await collectDirectoryFiles(absolutePath, relativePath)))
continue
}
if (!entry.isFile()) {
continue
}
const entryStat = await stat(absolutePath)
files.push({
absolutePath,
relativePath,
size: entryStat.size,
modifiedAt: Math.round(entryStat.mtimeMs),
name: entry.name,
mimeType: mimeTypeFromName(entry.name),
})
}
return files
}
export function defaultDisplayName(files) {
if (files.length === 0) {
return 'Untitled transfer'
}
if (files.length === 1) {
return files[0].relativePath
}
const roots = new Set(files.map((file) => file.relativePath.split('/')[0]))
if (roots.size === 1) {
return files[0].relativePath.split('/')[0]
}
return `${files[0].name} and ${files.length - 1} more items`
}
export function prepareFiles(files, chunkSize) {
return files.map((file) => {
const fileId = toBase64Url(randomBytes(18))
const noncePrefix = randomBytes(8)
const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize))
const ciphertextSizes = Array.from({ length: totalChunks }, (_, chunkIndex) => {
const plaintextChunkSize = Math.min(
chunkSize,
Math.max(file.size - chunkIndex * chunkSize, 0),
)
return plaintextChunkSize + 16
})
return {
...file,
fileId,
noncePrefix,
chunkSize,
totalChunks,
plaintextSize: file.size,
ciphertextSizes,
}
})
}
async function uploadFileChunks({ transferId, file, uploadUrlMap, rootKey, concurrency }) {
const completedChunks = new Array(file.totalChunks)
await parallelLimit(
Array.from({ length: file.totalChunks }, (_, chunkIndex) => chunkIndex),
concurrency,
async (chunkIndex) => {
const uploadUrl = uploadUrlMap.get(chunkIndex)
if (!uploadUrl) {
throw new Error(`Missing upload URL for ${file.relativePath} chunk ${chunkIndex}.`)
}
const plaintext = await readFileChunk(
file.absolutePath,
chunkIndex * file.chunkSize,
file.chunkSize,
)
const encrypted = await encryptChunk({
rootKey,
transferId,
fileId: file.fileId,
chunkIndex,
noncePrefix: file.noncePrefix,
plaintextChunkSize: plaintext.byteLength,
plaintext,
})
const response = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/octet-stream' },
body: encrypted.ciphertext,
})
if (!response.ok) {
throw new Error(
`Chunk upload failed for ${file.relativePath} chunk ${chunkIndex} with ${response.status}.`,
)
}
completedChunks[chunkIndex] = {
fileId: file.fileId,
chunkIndex,
ciphertextSize: encrypted.ciphertext.byteLength,
checksumSha256: encrypted.checksumHex,
}
},
)
return completedChunks
}
async function readFileChunk(filePath, start, chunkSize) {
const fileHandle = await open(filePath, 'r')
try {
const buffer = Buffer.alloc(Math.max(0, chunkSize))
const { bytesRead } = await fileHandle.read(buffer, 0, chunkSize, start)
return new Uint8Array(buffer.subarray(0, bytesRead))
} finally {
await fileHandle.close()
}
}
async function parallelLimit(items, concurrency, worker) {
let cursor = 0
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor
cursor += 1
const item = items[index]
if (item === undefined) {
continue
}
await worker(item, index)
}
}),
)
}
async function encryptChunk(options) {
const fileKey = await deriveHkdfKey(options.rootKey, `file:${options.fileId}`)
const iv = buildChunkIv(options.noncePrefix, options.chunkIndex)
const additionalData = encoder.encode(
[
options.transferId,
options.fileId,
options.chunkIndex,
options.plaintextChunkSize,
MANIFEST_VERSION,
].join('|'),
)
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv,
additionalData,
},
fileKey,
options.plaintext,
),
)
return {
ciphertext,
checksumHex: createHash('sha256').update(ciphertext).digest('hex'),
}
}
async function encryptManifest(rootKey, manifest) {
const manifestKey = await deriveHkdfKey(rootKey, 'manifest')
const iv = randomBytes(12)
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv,
},
manifestKey,
encoder.encode(JSON.stringify(manifest)),
),
)
return encoder.encode(
JSON.stringify({
version: MANIFEST_VERSION,
iv: toBase64Url(iv),
ciphertext: toBase64(ciphertext),
}),
)
}
async function wrapRootKey(rootKey, linkKey) {
const wrappingKey = await deriveHkdfKey(linkKey, 'wrap-root')
const iv = randomBytes(12)
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv,
},
wrappingKey,
rootKey,
),
)
return JSON.stringify({
version: WRAP_VERSION,
iv: toBase64Url(iv),
ciphertext: toBase64(ciphertext),
})
}
async function deriveHkdfKey(source, info) {
const sourceKey = await crypto.subtle.importKey('raw', source, 'HKDF', false, ['deriveKey'])
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(),
info: encoder.encode(info),
},
sourceKey,
{
name: 'AES-GCM',
length: 256,
},
false,
['encrypt', 'decrypt'],
)
}
function buildChunkIv(noncePrefix, chunkIndex) {
const iv = new Uint8Array(12)
iv.set(noncePrefix.slice(0, 8), 0)
new DataView(iv.buffer).setUint32(8, chunkIndex, false)
return iv
}
function randomBytes(length) {
const value = new Uint8Array(length)
crypto.getRandomValues(value)
return value
}
function toBase64Url(input) {
return Buffer.from(input)
.toString('base64')
.replace(/\+/gu, '-')
.replace(/\//gu, '_')
.replace(/=+$/u, '')
}
function toBase64(input) {
return Buffer.from(input).toString('base64')
}
function formatBytes(value) {
if (value >= 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GiB`
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KiB`
}
return `${value} B`
}
function mimeTypeFromName(filePath) {
const extension = extname(filePath).toLowerCase()
return MIME_TYPES[extension] ?? 'application/octet-stream'
}
function logStatus(message) {
if (quietMode) {
return
}
process.stderr.write(`${message}\n`)
}
class XdropApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl
}
async createTransfer(expiresInSeconds) {
return this.request('/transfers', {
method: 'POST',
body: { expiresInSeconds },
})
}
async registerFiles(transferId, manageToken, files) {
await this.request(`/transfers/${transferId}/files`, {
method: 'POST',
token: manageToken,
body: files,
})
}
async createUploadUrls(transferId, manageToken, chunks) {
const response = await this.request(`/transfers/${transferId}/upload-urls`, {
method: 'POST',
token: manageToken,
body: { chunks },
})
return response.items
}
async completeChunks(transferId, manageToken, chunks) {
await this.request(`/transfers/${transferId}/chunks/complete`, {
method: 'POST',
token: manageToken,
body: chunks,
})
}
async uploadManifest(transferId, manageToken, ciphertextBase64) {
await this.request(`/transfers/${transferId}/manifest`, {
method: 'POST',
token: manageToken,
body: { ciphertextBase64 },
})
}
async finalizeTransfer(
transferId,
manageToken,
wrappedRootKey,
totalFiles,
totalCiphertextBytes,
) {
await this.request(`/transfers/${transferId}/finalize`, {
method: 'POST',
token: manageToken,
body: { wrappedRootKey, totalFiles, totalCiphertextBytes },
})
}
async deleteTransfer(transferId, manageToken) {
await this.request(`/transfers/${transferId}`, {
method: 'DELETE',
token: manageToken,
})
}
async request(path, options) {
const response = await fetch(`${this.baseUrl}${path}`, {
method: options.method,
headers: {
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
})
if (!response.ok) {
const payload = await response.json().catch(() => ({}))
const detail = payload.message ?? payload.error ?? `Request failed with ${response.status}`
throw new Error(detail)
}
if (response.status === 204) {
return undefined
}
return response.json()
}
}
const MIME_TYPES = {
'.7z': 'application/x-7z-compressed',
'.bin': 'application/octet-stream',
'.csv': 'text/csv',
'.gif': 'image/gif',
'.gz': 'application/gzip',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.json': 'application/json',
'.md': 'text/markdown',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.tar': 'application/x-tar',
'.txt': 'text/plain',
'.wav': 'audio/wav',
'.webm': 'video/webm',
'.webp': 'image/webp',
'.zip': 'application/zip',
}
if (import.meta.main) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.exit(1)
})
}