cf-temp-dropper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/CONTRIBUTING.md +51 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/SECURITY.md +23 -0
- package/dist/cli.js +999 -0
- package/package.json +59 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
This project follows semantic versioning where practical.
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-06-20
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Initial CLI for creating temporary Cloudflare file drops.
|
|
12
|
+
- File splitting into temporary-account-safe static asset chunks.
|
|
13
|
+
- Generated Hono Worker with Workers Static Assets.
|
|
14
|
+
- `wrangler deploy --temporary` deployment flow.
|
|
15
|
+
- Generated landing page with media preview and verified save action.
|
|
16
|
+
- `/file` endpoint with full-file streaming, `HEAD`, `Content-Length`, `Accept-Ranges`, and byte-range responses across chunk boundaries.
|
|
17
|
+
- Browser-side parallel chunk download, IndexedDB resume, and SHA-256 verification before saving.
|
|
18
|
+
- Smoke test fixture and npm scripts.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in improving `cf-temp-dropper`.
|
|
4
|
+
|
|
5
|
+
## Local setup
|
|
6
|
+
|
|
7
|
+
Use Node.js 22 or newer.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npm run build
|
|
12
|
+
npm run smoke
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Development workflow
|
|
16
|
+
|
|
17
|
+
1. Create a branch for your change.
|
|
18
|
+
2. Keep changes focused and small.
|
|
19
|
+
3. Update `README.md` when behavior or user-facing commands change.
|
|
20
|
+
4. Run the verification commands before opening a pull request.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm run build
|
|
24
|
+
npm run smoke
|
|
25
|
+
npm pack --dry-run
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For changes to deploy behavior, also test a real temporary deployment using a non-sensitive sample file.
|
|
29
|
+
|
|
30
|
+
## Design principles
|
|
31
|
+
|
|
32
|
+
- Keep the generated Worker and UI simple enough to inspect.
|
|
33
|
+
- Prefer temporary-account-safe defaults.
|
|
34
|
+
- Avoid requiring Cloudflare login for the default path.
|
|
35
|
+
- Keep user-facing copy plain; implementation details belong in docs, not the landing page.
|
|
36
|
+
- Do not add external CDN dependencies to the generated page unless there is a strong reason.
|
|
37
|
+
|
|
38
|
+
## Reporting bugs
|
|
39
|
+
|
|
40
|
+
Please include:
|
|
41
|
+
|
|
42
|
+
- OS and Node.js version
|
|
43
|
+
- `cf-temp-dropper` version
|
|
44
|
+
- command used
|
|
45
|
+
- file size and MIME type, if relevant
|
|
46
|
+
- full error output, with secrets redacted
|
|
47
|
+
- whether the failure happened during generation, dependency install, deploy, preview, or verified save
|
|
48
|
+
|
|
49
|
+
## Security issues
|
|
50
|
+
|
|
51
|
+
Please do not open public issues for sensitive vulnerabilities. See `SECURITY.md`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cf-temp-dropper contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# cf-temp-dropper
|
|
2
|
+
|
|
3
|
+
Create a temporary Cloudflare-hosted file drop from one local file.
|
|
4
|
+
|
|
5
|
+
`cf-temp-dropper` splits a file into Workers Static Assets, generates a small Hono Worker, and deploys it with `wrangler deploy --temporary`. The resulting page can preview media through `/file` range streaming and save a checksum-verified copy through parallel chunk download.
|
|
6
|
+
|
|
7
|
+
Temporary Cloudflare preview deployments are meant for short-lived sharing. Claim the deployment in Cloudflare if you need it to live longer than the temporary preview window.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **No Cloudflare login required for the default path** — uses Wrangler temporary accounts.
|
|
12
|
+
- **Temporary public URL** — Wrangler prints a `workers.dev` URL and a claim URL.
|
|
13
|
+
- **Static asset chunking** — defaults to 4.75 MiB chunks for temporary-account safety.
|
|
14
|
+
- **Media preview** — image, audio, and video files can preview on the page.
|
|
15
|
+
- **Range streaming** — `/file` supports `HEAD` and `Range` requests for media players.
|
|
16
|
+
- **Parallel verified save** — downloads chunks in parallel, caches completed chunks in IndexedDB, assembles the file, and verifies SHA-256 before offering the saved copy.
|
|
17
|
+
- **Hono-based Worker** — generated Worker code is small and inspectable.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx cf-temp-dropper ./movie.mp4
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
After deployment, Wrangler prints something like:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
https://temp-drop-movie-mp4-xxxx.example.workers.dev
|
|
29
|
+
Claim URL: https://dash.cloudflare.com/claim-preview?claimToken=...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Open the URL to preview supported media or save the verified file.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- Node.js 22 or newer
|
|
37
|
+
- npm / npx
|
|
38
|
+
- Network access to install dependencies and run Wrangler
|
|
39
|
+
|
|
40
|
+
You do **not** need to be logged in to Cloudflare for the default temporary deployment flow. The CLI intentionally removes common Cloudflare credential environment variables when running `wrangler deploy --temporary` so Wrangler uses a temporary account instead of your real account by accident.
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cf-temp-dropper <file> [options]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Deploy a temporary file drop
|
|
52
|
+
npx cf-temp-dropper ./recording.mp3
|
|
53
|
+
|
|
54
|
+
# Keep the generated Worker project after deployment
|
|
55
|
+
npx cf-temp-dropper ./recording.mp3 --keep
|
|
56
|
+
|
|
57
|
+
# Generate only, then inspect or run locally
|
|
58
|
+
npx cf-temp-dropper ./recording.mp3 --no-deploy --out ./drop-build --yes
|
|
59
|
+
cd ./drop-build
|
|
60
|
+
npm install
|
|
61
|
+
npm run dev
|
|
62
|
+
|
|
63
|
+
# Use a custom generated Worker name
|
|
64
|
+
npx cf-temp-dropper ./archive.zip --name temp-archive-share
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Options:
|
|
68
|
+
|
|
69
|
+
| Option | Default | Description |
|
|
70
|
+
|---|---:|---|
|
|
71
|
+
| `--chunk-size-mib <n>` | `4.75` | Chunk size in MiB. Must be `<= 5` for temporary accounts. |
|
|
72
|
+
| `--parallel <n>` | `6` | Browser-side chunk download concurrency. |
|
|
73
|
+
| `--out <dir>` | temp dir | Generated Worker project directory. |
|
|
74
|
+
| `--name <name>` | derived from file | Cloudflare Worker name. |
|
|
75
|
+
| `--no-deploy` | `false` | Generate the Worker project but do not deploy. |
|
|
76
|
+
| `--keep` | `false` | Keep the generated temp project after deployment. |
|
|
77
|
+
| `--yes`, `-y` | `false` | Non-interactive overwrite of `--out`. |
|
|
78
|
+
| `--help`, `-h` | — | Show CLI help. |
|
|
79
|
+
|
|
80
|
+
## How it works
|
|
81
|
+
|
|
82
|
+
1. Computes the file SHA-256 and MIME type.
|
|
83
|
+
2. Splits the file into static asset chunks under `public/chunks/`.
|
|
84
|
+
3. Writes a `manifest.json` containing file metadata and per-chunk hashes.
|
|
85
|
+
4. Generates a Hono Worker with:
|
|
86
|
+
- `/` static page
|
|
87
|
+
- `/manifest.json`
|
|
88
|
+
- `/file` full-file streaming endpoint
|
|
89
|
+
- `HEAD /file` with `Content-Length` and `Accept-Ranges`
|
|
90
|
+
- `Range: bytes=...` support across chunk boundaries
|
|
91
|
+
5. Runs `npm install` in the generated Worker project.
|
|
92
|
+
6. Runs `wrangler deploy --temporary`.
|
|
93
|
+
|
|
94
|
+
## Preview vs verified save
|
|
95
|
+
|
|
96
|
+
The page has two paths:
|
|
97
|
+
|
|
98
|
+
- **Preview** uses `/file` directly. For audio/video, the browser can request byte ranges and seek without downloading the entire file first.
|
|
99
|
+
- **Save verified copy** downloads chunk assets in parallel, stores completed chunks in IndexedDB, assembles a `Blob`, verifies the full SHA-256, then offers the file for saving.
|
|
100
|
+
|
|
101
|
+
This keeps media preview responsive while still giving users a verified full-file download path.
|
|
102
|
+
|
|
103
|
+
## Limits
|
|
104
|
+
|
|
105
|
+
Cloudflare temporary preview deployments have stricter limits than normal claimed Workers deployments. This tool is intentionally conservative:
|
|
106
|
+
|
|
107
|
+
- each generated static asset chunk must be at most **5 MiB**
|
|
108
|
+
- the generated deployment must fit within **1,000 static files**
|
|
109
|
+
- default chunk size is **4.75 MiB** for headroom
|
|
110
|
+
|
|
111
|
+
As a rough guide, 1,000 chunks at 4.75 MiB is about 4.6 GiB before accounting for generated files. Browser memory and storage can become the practical limit before Cloudflare limits do, especially for verified save of very large files.
|
|
112
|
+
|
|
113
|
+
## Privacy and security notes
|
|
114
|
+
|
|
115
|
+
- The deployed URL is public to anyone who has the link.
|
|
116
|
+
- Temporary deployments should be treated as short-lived public shares, not private storage.
|
|
117
|
+
- Do not upload secrets, credentials, personal documents, or regulated data unless you are comfortable with public-link access.
|
|
118
|
+
- Generated chunks are static assets. The Worker reconstructs the file for streaming and download; it does not encrypt file contents.
|
|
119
|
+
- Claiming a deployment moves it into a Cloudflare account and may change its lifetime and management model.
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm install
|
|
125
|
+
npm run build
|
|
126
|
+
npm run smoke
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Useful commands:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Type-check only
|
|
133
|
+
npm run check
|
|
134
|
+
|
|
135
|
+
# Build package contents without publishing
|
|
136
|
+
npm pack --dry-run
|
|
137
|
+
|
|
138
|
+
# Generate a local Worker project for inspection
|
|
139
|
+
node dist/cli.js fixtures/sample.txt --no-deploy --out .tmp/smoke --yes
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Project layout:
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
src/cli.ts CLI, generated Worker, generated UI
|
|
146
|
+
fixtures/sample.txt smoke-test input
|
|
147
|
+
README.md user/developer documentation
|
|
148
|
+
LICENSE MIT license
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Publishing checklist
|
|
152
|
+
|
|
153
|
+
Before publishing to npm or GitHub:
|
|
154
|
+
|
|
155
|
+
- [ ] Set the final repository URL in `package.json` if desired.
|
|
156
|
+
- [ ] Run `npm run build`.
|
|
157
|
+
- [ ] Run `npm run smoke`.
|
|
158
|
+
- [ ] Run `npm pack --dry-run` and inspect included files.
|
|
159
|
+
- [ ] Try a real temporary deployment with a non-sensitive sample file.
|
|
160
|
+
- [ ] Create a GitHub release or npm tag from the same version.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT — see [LICENSE](./LICENSE).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Security fixes are expected to target the latest published version.
|
|
6
|
+
|
|
7
|
+
## Reporting a vulnerability
|
|
8
|
+
|
|
9
|
+
If you find a vulnerability, please report it privately to the maintainer instead of opening a public issue. If no private contact is listed in the repository yet, open a minimal public issue asking for a security contact without disclosing exploit details.
|
|
10
|
+
|
|
11
|
+
Please include:
|
|
12
|
+
|
|
13
|
+
- affected version or commit
|
|
14
|
+
- impact
|
|
15
|
+
- reproduction steps
|
|
16
|
+
- whether the issue affects the CLI, generated Worker, generated page, or deployment process
|
|
17
|
+
- any suggested mitigation
|
|
18
|
+
|
|
19
|
+
## Important model
|
|
20
|
+
|
|
21
|
+
`cf-temp-dropper` creates public-link temporary deployments. Anyone with the URL can access the file. It is not an encrypted file-sharing system and should not be used for secrets or regulated private data unless you add your own protection layer.
|
|
22
|
+
|
|
23
|
+
The CLI attempts to avoid accidental deployment to a real Cloudflare account by clearing common Cloudflare credential environment variables when running `wrangler deploy --temporary`. Review generated projects before deploying if you customize this behavior.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
4
|
+
import { mkdir, rm, stat, writeFile, access } from 'node:fs/promises';
|
|
5
|
+
import { basename, join, resolve } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { lookup as lookupMime } from 'mime-types';
|
|
9
|
+
const MIB = 1024 * 1024;
|
|
10
|
+
const STATIC_ASSET_MAX_MIB = 5;
|
|
11
|
+
const TEMPORARY_STATIC_ASSET_FILE_LIMIT = 1000;
|
|
12
|
+
function usage(exitCode = 0) {
|
|
13
|
+
const out = exitCode === 0 ? console.log : console.error;
|
|
14
|
+
out(`cf-temp-dropper
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
cf-temp-dropper <file> [options]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--chunk-size-mib <n> Chunk size in MiB. Default: 4.75. Max: 5 for temporary accounts.
|
|
21
|
+
--parallel <n> Browser download concurrency. Default: 6.
|
|
22
|
+
--out <dir> Output/generated Worker project directory.
|
|
23
|
+
--name <name> Cloudflare Worker name.
|
|
24
|
+
--no-deploy Generate only; do not call Wrangler deploy.
|
|
25
|
+
--keep Keep generated directory after deploy.
|
|
26
|
+
--yes Non-interactive; overwrite output directory if needed.
|
|
27
|
+
-h, --help Show this help.
|
|
28
|
+
`);
|
|
29
|
+
process.exit(exitCode);
|
|
30
|
+
}
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const opts = { chunkSizeMiB: 4.75, parallel: 6, noDeploy: false, keep: false, yes: false };
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const arg = argv[i];
|
|
35
|
+
if (arg === '-h' || arg === '--help')
|
|
36
|
+
usage(0);
|
|
37
|
+
if (arg === '--no-deploy') {
|
|
38
|
+
opts.noDeploy = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (arg === '--keep') {
|
|
42
|
+
opts.keep = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (arg === '--yes' || arg === '-y') {
|
|
46
|
+
opts.yes = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (arg === '--chunk-size-mib') {
|
|
50
|
+
opts.chunkSizeMiB = Number(argv[++i]);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (arg === '--parallel') {
|
|
54
|
+
opts.parallel = Number(argv[++i]);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (arg === '--out') {
|
|
58
|
+
opts.out = argv[++i];
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (arg === '--name') {
|
|
62
|
+
opts.name = argv[++i];
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (arg.startsWith('--'))
|
|
66
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
67
|
+
if (!opts.file)
|
|
68
|
+
opts.file = arg;
|
|
69
|
+
else
|
|
70
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
71
|
+
}
|
|
72
|
+
if (!opts.file)
|
|
73
|
+
usage(1);
|
|
74
|
+
if (!Number.isFinite(opts.chunkSizeMiB) || opts.chunkSizeMiB <= 0 || opts.chunkSizeMiB > STATIC_ASSET_MAX_MIB) {
|
|
75
|
+
throw new Error(`--chunk-size-mib must be > 0 and <= ${STATIC_ASSET_MAX_MIB}`);
|
|
76
|
+
}
|
|
77
|
+
if (!Number.isInteger(opts.parallel) || opts.parallel < 1 || opts.parallel > 32) {
|
|
78
|
+
throw new Error('--parallel must be an integer from 1 to 32');
|
|
79
|
+
}
|
|
80
|
+
return opts;
|
|
81
|
+
}
|
|
82
|
+
function slugify(input) {
|
|
83
|
+
return input.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'file';
|
|
84
|
+
}
|
|
85
|
+
async function exists(path) {
|
|
86
|
+
try {
|
|
87
|
+
await access(path);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function sha256File(path) {
|
|
95
|
+
const hash = createHash('sha256');
|
|
96
|
+
await new Promise((resolvePromise, reject) => {
|
|
97
|
+
createReadStream(path)
|
|
98
|
+
.on('data', (chunk) => hash.update(chunk))
|
|
99
|
+
.on('error', reject)
|
|
100
|
+
.on('end', resolvePromise);
|
|
101
|
+
});
|
|
102
|
+
return hash.digest('hex');
|
|
103
|
+
}
|
|
104
|
+
async function splitFile(inputPath, publicDir, chunkSize) {
|
|
105
|
+
const chunksDir = join(publicDir, 'chunks');
|
|
106
|
+
await mkdir(chunksDir, { recursive: true });
|
|
107
|
+
const fileHash = createHash('sha256');
|
|
108
|
+
const chunks = [];
|
|
109
|
+
let index = 0;
|
|
110
|
+
let currentSize = 0;
|
|
111
|
+
let currentHash = createHash('sha256');
|
|
112
|
+
let currentPath = join(chunksDir, partName(index));
|
|
113
|
+
let out = createWriteStream(currentPath);
|
|
114
|
+
const closeCurrent = async () => {
|
|
115
|
+
await new Promise((resolvePromise, reject) => out.end((err) => err ? reject(err) : resolvePromise()));
|
|
116
|
+
if (currentSize > 0) {
|
|
117
|
+
chunks.push({ index, path: `/chunks/${partName(index)}`, size: currentSize, sha256: currentHash.digest('hex') });
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
await rm(currentPath, { force: true });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
for await (const data of createReadStream(inputPath)) {
|
|
124
|
+
let buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
125
|
+
fileHash.update(buffer);
|
|
126
|
+
while (buffer.length > 0) {
|
|
127
|
+
const room = chunkSize - currentSize;
|
|
128
|
+
const slice = buffer.subarray(0, room);
|
|
129
|
+
if (!out.write(slice))
|
|
130
|
+
await new Promise((resolvePromise) => out.once('drain', resolvePromise));
|
|
131
|
+
currentHash.update(slice);
|
|
132
|
+
currentSize += slice.length;
|
|
133
|
+
buffer = buffer.subarray(slice.length);
|
|
134
|
+
if (currentSize === chunkSize) {
|
|
135
|
+
await closeCurrent();
|
|
136
|
+
index += 1;
|
|
137
|
+
currentSize = 0;
|
|
138
|
+
currentHash = createHash('sha256');
|
|
139
|
+
currentPath = join(chunksDir, partName(index));
|
|
140
|
+
out = createWriteStream(currentPath);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await closeCurrent();
|
|
145
|
+
return { chunks, fileSha256: fileHash.digest('hex') };
|
|
146
|
+
}
|
|
147
|
+
function partName(index) {
|
|
148
|
+
return `part-${String(index).padStart(5, '0')}.bin`;
|
|
149
|
+
}
|
|
150
|
+
function run(command, args, cwd, env = process.env) {
|
|
151
|
+
return new Promise((resolvePromise, reject) => {
|
|
152
|
+
const child = spawn(command, args, { cwd, env, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
153
|
+
child.on('error', reject);
|
|
154
|
+
child.on('exit', (code) => code === 0 ? resolvePromise() : reject(new Error(`${command} ${args.join(' ')} exited with ${code}`)));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function temporaryWranglerEnv(workdir) {
|
|
158
|
+
const env = { ...process.env };
|
|
159
|
+
for (const key of [
|
|
160
|
+
'CLOUDFLARE_API_TOKEN',
|
|
161
|
+
'CLOUDFLARE_ACCOUNT_ID',
|
|
162
|
+
'CLOUDFLARE_EMAIL',
|
|
163
|
+
'CLOUDFLARE_API_KEY',
|
|
164
|
+
'CF_API_TOKEN',
|
|
165
|
+
'CF_ACCOUNT_ID',
|
|
166
|
+
'CF_EMAIL',
|
|
167
|
+
'CF_API_KEY',
|
|
168
|
+
])
|
|
169
|
+
delete env[key];
|
|
170
|
+
const home = join(workdir, '.wrangler-temporary-home');
|
|
171
|
+
env.HOME = home;
|
|
172
|
+
env.XDG_CONFIG_HOME = join(home, '.config');
|
|
173
|
+
return env;
|
|
174
|
+
}
|
|
175
|
+
async function main() {
|
|
176
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
177
|
+
const inputPath = resolve(opts.file);
|
|
178
|
+
const inputStat = await stat(inputPath);
|
|
179
|
+
if (!inputStat.isFile())
|
|
180
|
+
throw new Error(`Not a file: ${inputPath}`);
|
|
181
|
+
const fileName = basename(inputPath);
|
|
182
|
+
const workerName = opts.name ?? `temp-drop-${slugify(fileName)}-${Date.now().toString(36)}`;
|
|
183
|
+
const workdir = resolve(opts.out ?? join(tmpdir(), workerName));
|
|
184
|
+
if (await exists(workdir)) {
|
|
185
|
+
if (!opts.yes)
|
|
186
|
+
throw new Error(`Output directory already exists: ${workdir}. Use --yes to overwrite.`);
|
|
187
|
+
await rm(workdir, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
const publicDir = join(workdir, 'public');
|
|
190
|
+
await mkdir(publicDir, { recursive: true });
|
|
191
|
+
console.log(`Preparing ${fileName} (${inputStat.size.toLocaleString()} bytes)`);
|
|
192
|
+
console.log(`Workdir: ${workdir}`);
|
|
193
|
+
const chunkSize = Math.floor(opts.chunkSizeMiB * MIB);
|
|
194
|
+
const estimatedChunks = Math.ceil(inputStat.size / chunkSize);
|
|
195
|
+
const generatedStaticFileCount = estimatedChunks + 4; // chunks + index.html + app.js + styles.css + manifest.json
|
|
196
|
+
if (generatedStaticFileCount > TEMPORARY_STATIC_ASSET_FILE_LIMIT) {
|
|
197
|
+
throw new Error(`Temporary preview accounts allow up to ${TEMPORARY_STATIC_ASSET_FILE_LIMIT} static asset files. ` +
|
|
198
|
+
`This input would generate about ${generatedStaticFileCount} files (${estimatedChunks} chunks + 4 UI/manifest files). ` +
|
|
199
|
+
`Use a smaller file, claim/use a normal Cloudflare account, or switch storage to R2.`);
|
|
200
|
+
}
|
|
201
|
+
const { chunks, fileSha256 } = await splitFile(inputPath, publicDir, chunkSize);
|
|
202
|
+
const mime = lookupMime(fileName) || 'application/octet-stream';
|
|
203
|
+
const manifest = {
|
|
204
|
+
version: 1,
|
|
205
|
+
fileName,
|
|
206
|
+
size: inputStat.size,
|
|
207
|
+
mime: String(mime),
|
|
208
|
+
sha256: fileSha256,
|
|
209
|
+
chunkSize,
|
|
210
|
+
createdAt: new Date().toISOString(),
|
|
211
|
+
suggestedParallelism: opts.parallel,
|
|
212
|
+
chunks,
|
|
213
|
+
};
|
|
214
|
+
await writeFile(join(publicDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
215
|
+
await writeFile(join(publicDir, 'index.html'), indexHtml());
|
|
216
|
+
await writeFile(join(publicDir, 'app.js'), appJs());
|
|
217
|
+
await writeFile(join(publicDir, 'styles.css'), stylesCss());
|
|
218
|
+
await mkdir(join(workdir, 'src'), { recursive: true });
|
|
219
|
+
await writeFile(join(workdir, 'src', 'index.ts'), workerTs());
|
|
220
|
+
await writeFile(join(workdir, 'wrangler.jsonc'), JSON.stringify({
|
|
221
|
+
name: workerName,
|
|
222
|
+
main: 'src/index.ts',
|
|
223
|
+
compatibility_date: new Date().toISOString().slice(0, 10),
|
|
224
|
+
assets: { directory: './public', binding: 'ASSETS' }
|
|
225
|
+
}, null, 2));
|
|
226
|
+
await writeFile(join(workdir, 'package.json'), JSON.stringify({
|
|
227
|
+
type: 'module',
|
|
228
|
+
private: true,
|
|
229
|
+
scripts: { deploy: 'wrangler deploy --temporary', dev: 'wrangler dev' },
|
|
230
|
+
dependencies: { hono: '^4.10.7' },
|
|
231
|
+
devDependencies: { wrangler: '^4.56.1', typescript: '^5.9.3' }
|
|
232
|
+
}, null, 2));
|
|
233
|
+
console.log(`Wrote ${chunks.length} chunk(s), manifest, Hono Worker, and static UI.`);
|
|
234
|
+
console.log(`File SHA-256: ${fileSha256}`);
|
|
235
|
+
if (opts.noDeploy) {
|
|
236
|
+
console.log('\nGenerated only. To preview/deploy:');
|
|
237
|
+
console.log(` cd ${workdir}`);
|
|
238
|
+
console.log(' npm install');
|
|
239
|
+
console.log(' npm run dev');
|
|
240
|
+
console.log(' npm run deploy');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
console.log('\nInstalling generated Worker dependencies...');
|
|
244
|
+
await run('npm', ['install'], workdir);
|
|
245
|
+
console.log('\nDeploying with wrangler deploy --temporary...');
|
|
246
|
+
await mkdir(join(workdir, '.wrangler-temporary-home'), { recursive: true });
|
|
247
|
+
await run('npx', ['wrangler', 'deploy', '--temporary'], workdir, temporaryWranglerEnv(workdir));
|
|
248
|
+
if (!opts.keep && !opts.out) {
|
|
249
|
+
console.log(`\nGenerated project kept at ${workdir} for inspection during this shell session.`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function workerTs() {
|
|
253
|
+
return `import { Hono } from 'hono';
|
|
254
|
+
|
|
255
|
+
type Env = { ASSETS: Fetcher };
|
|
256
|
+
type Manifest = {
|
|
257
|
+
fileName: string;
|
|
258
|
+
size: number;
|
|
259
|
+
mime: string;
|
|
260
|
+
chunkSize: number;
|
|
261
|
+
chunks: Array<{ index: number; path: string; size: number; sha256: string }>;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
265
|
+
|
|
266
|
+
async function loadManifest(assets: Fetcher, requestUrl: string): Promise<Manifest> {
|
|
267
|
+
const url = new URL('/manifest.json', requestUrl);
|
|
268
|
+
const res = await assets.fetch(new Request(url.toString()));
|
|
269
|
+
if (!res.ok) throw new Error('manifest.json is missing');
|
|
270
|
+
return await res.json();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parseRange(header: string | null, size: number): { start: number; end: number } | null {
|
|
274
|
+
if (!header) return null;
|
|
275
|
+
const match = /^bytes=(\\d*)-(\\d*)$/.exec(header.trim());
|
|
276
|
+
if (!match) return null;
|
|
277
|
+
let start: number;
|
|
278
|
+
let end: number;
|
|
279
|
+
if (match[1] === '' && match[2] === '') return null;
|
|
280
|
+
if (match[1] === '') {
|
|
281
|
+
const suffix = Number(match[2]);
|
|
282
|
+
if (!Number.isFinite(suffix) || suffix <= 0) return null;
|
|
283
|
+
start = Math.max(size - suffix, 0);
|
|
284
|
+
end = size - 1;
|
|
285
|
+
} else {
|
|
286
|
+
start = Number(match[1]);
|
|
287
|
+
end = match[2] === '' ? size - 1 : Number(match[2]);
|
|
288
|
+
}
|
|
289
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= size) return null;
|
|
290
|
+
return { start, end: Math.min(end, size - 1) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function streamByteRange(assets: Fetcher, requestUrl: string, manifest: Manifest, start: number, end: number): ReadableStream<Uint8Array> {
|
|
294
|
+
return new ReadableStream<Uint8Array>({
|
|
295
|
+
async start(controller) {
|
|
296
|
+
try {
|
|
297
|
+
const first = Math.floor(start / manifest.chunkSize);
|
|
298
|
+
const last = Math.floor(end / manifest.chunkSize);
|
|
299
|
+
for (let i = first; i <= last; i++) {
|
|
300
|
+
const chunk = manifest.chunks[i];
|
|
301
|
+
if (!chunk) throw new Error(\`missing chunk \${i}\`);
|
|
302
|
+
const url = new URL(chunk.path, requestUrl);
|
|
303
|
+
const res = await assets.fetch(new Request(url.toString()));
|
|
304
|
+
if (!res.ok) throw new Error(\`chunk \${i} returned HTTP \${res.status}\`);
|
|
305
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
306
|
+
const localStart = i === first ? start - i * manifest.chunkSize : 0;
|
|
307
|
+
const localEnd = i === last ? end - i * manifest.chunkSize + 1 : bytes.byteLength;
|
|
308
|
+
controller.enqueue(bytes.slice(localStart, localEnd));
|
|
309
|
+
}
|
|
310
|
+
controller.close();
|
|
311
|
+
} catch (err) {
|
|
312
|
+
controller.error(err);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
app.get('/api/manifest', async (c) => {
|
|
319
|
+
const url = new URL('/manifest.json', c.req.url);
|
|
320
|
+
const res = await c.env.ASSETS.fetch(new Request(url.toString(), c.req.raw));
|
|
321
|
+
const headers = new Headers(res.headers);
|
|
322
|
+
headers.set('cache-control', 'no-store');
|
|
323
|
+
headers.set('content-type', 'application/json; charset=utf-8');
|
|
324
|
+
return new Response(res.body, { status: res.status, headers });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
app.on('HEAD', '/file', async (c) => {
|
|
328
|
+
const manifest = await loadManifest(c.env.ASSETS, c.req.url);
|
|
329
|
+
return new Response(null, {
|
|
330
|
+
status: 200,
|
|
331
|
+
headers: {
|
|
332
|
+
'accept-ranges': 'bytes',
|
|
333
|
+
'content-type': manifest.mime || 'application/octet-stream',
|
|
334
|
+
'content-length': String(manifest.size),
|
|
335
|
+
'content-disposition': \`inline; filename*=UTF-8''\${encodeURIComponent(manifest.fileName)}\`,
|
|
336
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
app.get('/file', async (c) => {
|
|
342
|
+
const manifest = await loadManifest(c.env.ASSETS, c.req.url);
|
|
343
|
+
const baseHeaders = new Headers({
|
|
344
|
+
'accept-ranges': 'bytes',
|
|
345
|
+
'content-type': manifest.mime || 'application/octet-stream',
|
|
346
|
+
'content-disposition': \`inline; filename*=UTF-8''\${encodeURIComponent(manifest.fileName)}\`,
|
|
347
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
348
|
+
});
|
|
349
|
+
const rangeHeader = c.req.header('range');
|
|
350
|
+
if (!rangeHeader) {
|
|
351
|
+
baseHeaders.set('content-length', String(manifest.size));
|
|
352
|
+
return new Response(streamByteRange(c.env.ASSETS, c.req.url, manifest, 0, manifest.size - 1), { status: 200, headers: baseHeaders });
|
|
353
|
+
}
|
|
354
|
+
const range = parseRange(rangeHeader, manifest.size);
|
|
355
|
+
if (!range) {
|
|
356
|
+
return new Response(null, {
|
|
357
|
+
status: 416,
|
|
358
|
+
headers: { 'content-range': \`bytes */\${manifest.size}\`, 'accept-ranges': 'bytes' }
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
baseHeaders.set('content-range', \`bytes \${range.start}-\${range.end}/\${manifest.size}\`);
|
|
362
|
+
baseHeaders.set('content-length', String(range.end - range.start + 1));
|
|
363
|
+
return new Response(streamByteRange(c.env.ASSETS, c.req.url, manifest, range.start, range.end), { status: 206, headers: baseHeaders });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
app.get('/healthz', (c) => c.json({ ok: true }));
|
|
367
|
+
|
|
368
|
+
app.get('*', async (c) => c.env.ASSETS.fetch(c.req.raw));
|
|
369
|
+
|
|
370
|
+
export default app;
|
|
371
|
+
`;
|
|
372
|
+
}
|
|
373
|
+
function indexHtml() {
|
|
374
|
+
return `<!doctype html>
|
|
375
|
+
<html lang="en">
|
|
376
|
+
<head>
|
|
377
|
+
<meta charset="utf-8" />
|
|
378
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
379
|
+
<meta name="color-scheme" content="dark light" />
|
|
380
|
+
<title>Temporary file drop — waybill</title>
|
|
381
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
382
|
+
</head>
|
|
383
|
+
<body>
|
|
384
|
+
<main class="page">
|
|
385
|
+
<header class="masthead">
|
|
386
|
+
<div class="mast-brand">
|
|
387
|
+
<span class="mast-glyph" aria-hidden="true">
|
|
388
|
+
<svg viewBox="0 0 24 24" fill="none"><path d="M3 7l9-4 9 4-9 4-9-4z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M3 7v10l9 4 9-4V7" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M12 11v10" stroke="currentColor" stroke-width="1.8"/></svg>
|
|
389
|
+
</span>
|
|
390
|
+
<span class="mast-name">cf-temp-dropper</span>
|
|
391
|
+
</div>
|
|
392
|
+
<div class="mast-meta">
|
|
393
|
+
<span class="mast-label">Waybill №</span>
|
|
394
|
+
<span class="mast-id mono" id="waybill-id">—</span>
|
|
395
|
+
</div>
|
|
396
|
+
</header>
|
|
397
|
+
|
|
398
|
+
<article class="waybill">
|
|
399
|
+
<div class="waybill-stamp" aria-hidden="true">
|
|
400
|
+
<span>Temporary</span>
|
|
401
|
+
<span class="stamp-sub">expires ~60 min</span>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<section class="wb-header">
|
|
405
|
+
<div class="wb-icon" id="ficon" aria-hidden="true">
|
|
406
|
+
<svg viewBox="0 0 24 24" fill="none"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M14 3v5h5" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
|
|
407
|
+
</div>
|
|
408
|
+
<div class="wb-title-block">
|
|
409
|
+
<p class="wb-eyebrow" id="eyebrow">Loading</p>
|
|
410
|
+
<h1 id="title">Temporary file</h1>
|
|
411
|
+
<p id="meta" class="wb-sub">Reading manifest…</p>
|
|
412
|
+
<p id="expires" class="expires">Temporary link · expires in about 60 minutes.</p>
|
|
413
|
+
</div>
|
|
414
|
+
</section>
|
|
415
|
+
|
|
416
|
+
<dl class="wb-stats" aria-label="File details">
|
|
417
|
+
<div><dt>Size</dt><dd id="stat-size">—</dd></div>
|
|
418
|
+
<div><dt>Type</dt><dd id="stat-type" class="mono">—</dd></div>
|
|
419
|
+
</dl>
|
|
420
|
+
|
|
421
|
+
<section class="progress" aria-label="Download progress">
|
|
422
|
+
<div class="progress-head">
|
|
423
|
+
<span class="progress-label" id="progress-label">Waiting</span>
|
|
424
|
+
<span class="progress-percent mono" id="progress-percent">0%</span>
|
|
425
|
+
</div>
|
|
426
|
+
<div class="track"><div class="fill" id="progress-fill" style="width:0%"></div></div>
|
|
427
|
+
<div class="chunkgrid" id="chunkgrid" role="img" aria-label="Per-chunk status"></div>
|
|
428
|
+
<div class="legend" id="legend" hidden>
|
|
429
|
+
<span><i class="sw sw-done"></i> downloaded <b id="count-done">0</b></span>
|
|
430
|
+
<span><i class="sw sw-cached"></i> resumed <b id="count-cached">0</b></span>
|
|
431
|
+
<span><i class="sw sw-pending"></i> pending <b id="count-pending">0</b></span>
|
|
432
|
+
</div>
|
|
433
|
+
</section>
|
|
434
|
+
|
|
435
|
+
<div class="preview" id="preview" aria-live="polite">
|
|
436
|
+
<div class="preview-empty" id="preview-empty">
|
|
437
|
+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m9 8 7 4-7 4z" fill="currentColor"/><rect x="3" y="4" width="18" height="16" rx="3" stroke="currentColor" stroke-width="1.6"/></svg>
|
|
438
|
+
<span>Media preview appears here when available.</span>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
<div class="actions">
|
|
443
|
+
<button id="download" class="btn btn-primary" disabled>
|
|
444
|
+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 4v10m0 0 3.5-3.5M12 14l-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 18h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
|
445
|
+
<span id="download-label">Save verified copy</span>
|
|
446
|
+
</button>
|
|
447
|
+
<button id="clear" class="btn btn-ghost" disabled>Clear local cache</button>
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<p id="status" class="status" role="status">Waiting for file manifest…</p>
|
|
451
|
+
|
|
452
|
+
<div class="verify" aria-label="Integrity">
|
|
453
|
+
<div class="verify-row">
|
|
454
|
+
<span class="verify-label">SHA-256</span>
|
|
455
|
+
<code class="verify-hash mono" id="sha">—</code>
|
|
456
|
+
<button class="copy" id="copy-sha" type="button" disabled aria-label="Copy SHA-256">Copy</button>
|
|
457
|
+
</div>
|
|
458
|
+
<p class="verify-note">Use this hash to confirm the saved file matches.</p>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
<details class="manifest">
|
|
462
|
+
<summary>Full manifest</summary>
|
|
463
|
+
<pre id="manifest" class="mono"></pre>
|
|
464
|
+
</details>
|
|
465
|
+
|
|
466
|
+
</article>
|
|
467
|
+
</main>
|
|
468
|
+
<script type="module" src="/app.js"></script>
|
|
469
|
+
</body>
|
|
470
|
+
</html>`;
|
|
471
|
+
}
|
|
472
|
+
function stylesCss() {
|
|
473
|
+
return `:root {
|
|
474
|
+
color-scheme: dark;
|
|
475
|
+
--deep: #0a1e26;
|
|
476
|
+
--deep-2: #0d2530;
|
|
477
|
+
--surface: #113040;
|
|
478
|
+
--surface-2: #163a4d;
|
|
479
|
+
--surface-3: #1a4459;
|
|
480
|
+
--amber: #f0a020;
|
|
481
|
+
--amber-bright: #ffb830;
|
|
482
|
+
--amber-dim: rgba(240, 160, 32, .15);
|
|
483
|
+
--signal: #2dd4bf;
|
|
484
|
+
--pass: #4ade80;
|
|
485
|
+
--fail: #f87171;
|
|
486
|
+
--text: #e8f0f2;
|
|
487
|
+
--muted: #8ba5ad;
|
|
488
|
+
--faint: #5a7278;
|
|
489
|
+
--line: rgba(240, 160, 32, .10);
|
|
490
|
+
--line-2: rgba(255, 255, 255, .06);
|
|
491
|
+
--line-strong: rgba(240, 160, 32, .22);
|
|
492
|
+
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
493
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
494
|
+
}
|
|
495
|
+
* { box-sizing: border-box; }
|
|
496
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
497
|
+
body {
|
|
498
|
+
margin: 0;
|
|
499
|
+
min-height: 100vh;
|
|
500
|
+
font-family: var(--font);
|
|
501
|
+
color: var(--text);
|
|
502
|
+
background:
|
|
503
|
+
radial-gradient(900px 400px at 50% -5%, rgba(240, 160, 32, .06), transparent 60%),
|
|
504
|
+
linear-gradient(180deg, var(--deep-2), var(--deep) 50%);
|
|
505
|
+
-webkit-font-smoothing: antialiased;
|
|
506
|
+
text-rendering: optimizeLegibility;
|
|
507
|
+
}
|
|
508
|
+
.mono { font-family: var(--mono); font-variant-ligatures: none; }
|
|
509
|
+
|
|
510
|
+
.page { width: min(780px, 100%); margin: 0 auto; padding: clamp(16px, 4vw, 48px) clamp(14px, 4vw, 24px) 40px; }
|
|
511
|
+
|
|
512
|
+
/* Masthead — shipping label header */
|
|
513
|
+
.masthead {
|
|
514
|
+
display: flex; align-items: flex-end; justify-content: space-between;
|
|
515
|
+
gap: 12px; flex-wrap: wrap;
|
|
516
|
+
padding-bottom: 14px; margin-bottom: 0;
|
|
517
|
+
border-bottom: 2px solid var(--amber);
|
|
518
|
+
position: relative;
|
|
519
|
+
}
|
|
520
|
+
.masthead::after {
|
|
521
|
+
content: ""; position: absolute; left: 0; right: 0; bottom: -5px;
|
|
522
|
+
height: 1px; background: var(--amber); opacity: .3;
|
|
523
|
+
}
|
|
524
|
+
.mast-brand { display: inline-flex; align-items: center; gap: 10px; }
|
|
525
|
+
.mast-glyph { display: grid; place-items: center; width: 28px; height: 28px; color: var(--amber); }
|
|
526
|
+
.mast-glyph svg { width: 22px; height: 22px; }
|
|
527
|
+
.mast-name { font-family: var(--mono); font-size: 13px; font-weight: 700; letter-spacing: .08em; color: var(--text); text-transform: uppercase; }
|
|
528
|
+
.mast-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
|
529
|
+
.mast-label { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .18em; color: var(--amber); text-transform: uppercase; }
|
|
530
|
+
.mast-id { font-size: 12px; color: var(--muted); }
|
|
531
|
+
|
|
532
|
+
/* Waybill document */
|
|
533
|
+
.waybill {
|
|
534
|
+
--pad-x: clamp(18px, 4vw, 30px);
|
|
535
|
+
position: relative;
|
|
536
|
+
background: linear-gradient(180deg, var(--surface), var(--deep-2));
|
|
537
|
+
border: 1px solid var(--line-strong);
|
|
538
|
+
border-top: none;
|
|
539
|
+
padding: clamp(20px, 4vw, 32px) var(--pad-x) 0;
|
|
540
|
+
box-shadow: 0 20px 50px -24px rgba(0,0,0,.6);
|
|
541
|
+
overflow: hidden;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/* TEMPORARY stamp */
|
|
545
|
+
.waybill-stamp {
|
|
546
|
+
position: absolute; top: 12px; right: -24px;
|
|
547
|
+
transform: rotate(8deg);
|
|
548
|
+
display: flex; flex-direction: column; align-items: center;
|
|
549
|
+
padding: 5px 32px;
|
|
550
|
+
border: 2px solid var(--amber);
|
|
551
|
+
color: var(--amber);
|
|
552
|
+
font-family: var(--mono); font-weight: 700;
|
|
553
|
+
font-size: 11px; letter-spacing: .15em;
|
|
554
|
+
text-transform: uppercase;
|
|
555
|
+
opacity: .5;
|
|
556
|
+
pointer-events: none;
|
|
557
|
+
}
|
|
558
|
+
.stamp-sub { font-size: 8px; letter-spacing: .1em; margin-top: 1px; opacity: .8; }
|
|
559
|
+
|
|
560
|
+
/* Waybill header */
|
|
561
|
+
.wb-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 4px; }
|
|
562
|
+
.wb-icon { flex: none; display: grid; place-items: center; width: 48px; height: 48px; border: 1px solid var(--line-strong); color: var(--amber); background: var(--amber-dim); }
|
|
563
|
+
.wb-icon svg { width: 24px; height: 24px; }
|
|
564
|
+
.wb-title-block { min-width: 0; flex: 1; }
|
|
565
|
+
.wb-eyebrow { margin: 0 0 4px; font-family: var(--mono); font-size: 10.5px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; color: var(--amber); }
|
|
566
|
+
h1 { margin: 0; font-family: var(--mono); font-size: clamp(18px, 3.5vw, 24px); line-height: 1.2; font-weight: 700; letter-spacing: -.01em; overflow-wrap: anywhere; }
|
|
567
|
+
.wb-sub { margin: 8px 0 0; color: var(--muted); font-size: 13.5px; line-height: 1.55; }
|
|
568
|
+
.expires { margin: 10px 0 0; display: inline-flex; padding: 6px 9px; border: 1px solid var(--line-strong); background: rgba(240,160,32,.08); color: var(--amber-bright); font-family: var(--mono); font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
|
|
569
|
+
|
|
570
|
+
/* Stats grid — waybill fields */
|
|
571
|
+
.wb-stats { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 1px; margin: 24px 0; background: var(--line-2); border: 1px solid var(--line-2); }
|
|
572
|
+
.wb-stats div { min-width: 0; padding: 12px 14px; background: var(--surface-2); }
|
|
573
|
+
.wb-stats dt { margin: 0; font-family: var(--mono); color: var(--faint); font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .12em; }
|
|
574
|
+
.wb-stats dd { margin: 5px 0 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
575
|
+
.wb-stats dd.mono { font-size: 12px; font-weight: 500; color: var(--muted); }
|
|
576
|
+
|
|
577
|
+
/* Progress section */
|
|
578
|
+
.progress { margin: 0 0 20px; }
|
|
579
|
+
.progress-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
|
|
580
|
+
.progress-label { font-family: var(--mono); font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text); }
|
|
581
|
+
.progress-percent { font-size: 13px; color: var(--amber); font-weight: 600; }
|
|
582
|
+
.track { height: 6px; background: var(--surface-3); border: 1px solid var(--line-2); overflow: hidden; }
|
|
583
|
+
.fill { height: 100%; width: 0; background: var(--amber); transition: width .35s cubic-bezier(.4,0,.2,1); }
|
|
584
|
+
.is-done .fill { background: var(--pass); }
|
|
585
|
+
.is-error .fill { background: var(--fail); }
|
|
586
|
+
|
|
587
|
+
/* Chunk grid — stowage plan */
|
|
588
|
+
.chunkgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(0, 1fr)); gap: 2px; margin-top: 12px; }
|
|
589
|
+
.chunkgrid.condensed { display: none; }
|
|
590
|
+
.cell { aspect-ratio: 1.8 / 1; background: var(--surface-3); border: 1px solid var(--line-2); transition: background .2s ease, border-color .2s ease; }
|
|
591
|
+
.cell.pending { background: var(--surface-3); }
|
|
592
|
+
.cell.cached { background: rgba(45, 212, 191, .25); border-color: rgba(45, 212, 191, .3); }
|
|
593
|
+
.cell.active { background: var(--amber); border-color: var(--amber-bright); animation: pulse 1s ease-in-out infinite; }
|
|
594
|
+
.cell.done { background: rgba(74, 222, 128, .3); border-color: rgba(74, 222, 128, .35); }
|
|
595
|
+
.cell.error { background: var(--fail); }
|
|
596
|
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
|
597
|
+
|
|
598
|
+
/* Legend */
|
|
599
|
+
.legend { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 10px; font-family: var(--mono); font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
|
|
600
|
+
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
|
601
|
+
.legend b { color: var(--text); font-variant-numeric: tabular-nums; font-weight: 700; }
|
|
602
|
+
.sw { width: 10px; height: 6px; }
|
|
603
|
+
.sw-done { background: rgba(74, 222, 128, .5); border: 1px solid rgba(74, 222, 128, .4); }
|
|
604
|
+
.sw-cached { background: rgba(45, 212, 191, .4); border: 1px solid rgba(45, 212, 191, .3); }
|
|
605
|
+
.sw-pending { background: var(--surface-3); border: 1px solid var(--line-2); }
|
|
606
|
+
|
|
607
|
+
/* Preview — viewing bay */
|
|
608
|
+
.preview { margin: 0 0 20px; }
|
|
609
|
+
.preview-empty { display: flex; align-items: center; gap: 12px; padding: 20px; border: 1px dashed var(--line-strong); color: var(--faint); font-size: 13px; background: rgba(240, 160, 32, .02); }
|
|
610
|
+
.preview-empty svg { flex: none; width: 28px; height: 28px; opacity: .6; color: var(--amber); }
|
|
611
|
+
.preview-media { display: block; width: 100%; max-height: 55vh; object-fit: contain; border: 1px solid var(--line-2); background: #000; }
|
|
612
|
+
.preview video.preview-media { background: #000; }
|
|
613
|
+
.preview audio { width: 100%; }
|
|
614
|
+
.preview-file { display: flex; align-items: center; gap: 12px; padding: 16px; border: 1px solid var(--line-2); background: var(--surface-2); }
|
|
615
|
+
.preview-file svg { flex: none; width: 26px; height: 26px; color: var(--amber); }
|
|
616
|
+
.preview-file b { display: block; font-weight: 600; overflow-wrap: anywhere; }
|
|
617
|
+
.preview-file span { display: block; margin-top: 2px; color: var(--muted); font-size: 12.5px; }
|
|
618
|
+
|
|
619
|
+
/* Actions */
|
|
620
|
+
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
|
621
|
+
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 18px; border: 1px solid transparent; font-family: var(--mono); font-size: 13px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; cursor: pointer; transition: background .15s ease, border-color .15s ease, transform .1s ease, opacity .15s ease; }
|
|
622
|
+
.btn svg { width: 16px; height: 16px; }
|
|
623
|
+
.btn:active:not(:disabled) { transform: translateY(1px); }
|
|
624
|
+
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
|
625
|
+
.btn:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
|
|
626
|
+
.btn-primary { flex: 1 1 auto; color: var(--deep); background: var(--amber); border-color: var(--amber-bright); }
|
|
627
|
+
.btn-primary:hover:not(:disabled) { background: var(--amber-bright); }
|
|
628
|
+
.btn-ghost { color: var(--text); background: transparent; border-color: var(--line-strong); }
|
|
629
|
+
.btn-ghost:hover:not(:disabled) { background: var(--amber-dim); border-color: var(--amber); }
|
|
630
|
+
|
|
631
|
+
/* Status line */
|
|
632
|
+
.status { min-height: 20px; margin: 0 0 20px; color: var(--muted); font-size: 13px; line-height: 1.5; font-family: var(--mono); }
|
|
633
|
+
|
|
634
|
+
/* Verify — seal box */
|
|
635
|
+
.verify { padding: 14px 16px; border: 1px solid var(--line-strong); background: var(--surface-2); position: relative; }
|
|
636
|
+
.verify::before { content: "SEAL"; position: absolute; top: -1px; left: 12px; transform: translateY(-50%); background: var(--deep-2); padding: 0 8px; font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: .2em; color: var(--amber); }
|
|
637
|
+
.verify-row { display: flex; align-items: center; gap: 10px; }
|
|
638
|
+
.verify-label { flex: none; font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--faint); }
|
|
639
|
+
.verify-hash { flex: 1 1 auto; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
640
|
+
.copy { flex: none; padding: 5px 10px; border: 1px solid var(--line-strong); background: transparent; color: var(--text); font-family: var(--mono); font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; cursor: pointer; transition: background .15s ease; }
|
|
641
|
+
.copy:hover:not(:disabled) { background: var(--amber-dim); }
|
|
642
|
+
.copy:disabled { opacity: .4; cursor: not-allowed; }
|
|
643
|
+
.verify-note { margin: 10px 0 0; color: var(--faint); font-size: 12px; line-height: 1.5; }
|
|
644
|
+
|
|
645
|
+
/* Manifest details */
|
|
646
|
+
.manifest { margin-top: 16px; }
|
|
647
|
+
summary { cursor: pointer; color: var(--muted); font-family: var(--mono); font-size: 12px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; list-style: none; padding: 4px 0; }
|
|
648
|
+
summary::-webkit-details-marker { display: none; }
|
|
649
|
+
summary::before { content: "▸"; display: inline-block; margin-right: 8px; color: var(--amber); transition: transform .15s ease; }
|
|
650
|
+
.manifest[open] summary::before { transform: rotate(90deg); }
|
|
651
|
+
pre { margin: 10px 0 0; overflow: auto; max-height: 280px; padding: 14px; background: var(--deep); border: 1px solid var(--line-2); color: var(--muted); font-size: 12px; line-height: 1.5; }
|
|
652
|
+
|
|
653
|
+
@media (max-width: 560px) {
|
|
654
|
+
.wb-stats { grid-template-columns: repeat(2, 1fr); }
|
|
655
|
+
.btn-primary { flex-basis: 100%; }
|
|
656
|
+
.btn-ghost { flex: 1 1 auto; }
|
|
657
|
+
}
|
|
658
|
+
@media (max-width: 480px) {
|
|
659
|
+
.waybill-stamp { display: none; }
|
|
660
|
+
}
|
|
661
|
+
@media (prefers-reduced-motion: reduce) {
|
|
662
|
+
.fill { transition: none; }
|
|
663
|
+
.cell.active { animation: none; }
|
|
664
|
+
}
|
|
665
|
+
`;
|
|
666
|
+
}
|
|
667
|
+
function appJs() {
|
|
668
|
+
return `const DB_NAME = 'cf-temp-dropper-v1';
|
|
669
|
+
const STORE = 'chunks';
|
|
670
|
+
const MAX_CELLS = 500;
|
|
671
|
+
const $ = (id) => document.getElementById(id);
|
|
672
|
+
let manifest;
|
|
673
|
+
let db;
|
|
674
|
+
let cells = [];
|
|
675
|
+
let counts = { done: 0, cached: 0, pending: 0 };
|
|
676
|
+
|
|
677
|
+
function formatBytes(n) {
|
|
678
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
679
|
+
let v = n;
|
|
680
|
+
let i = 0;
|
|
681
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
682
|
+
return \`\${v.toFixed(i ? 2 : 0)} \${units[i]}\`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function el(tag, className) {
|
|
686
|
+
const node = document.createElement(tag);
|
|
687
|
+
if (className) node.className = className;
|
|
688
|
+
return node;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function setStatus(text) { $('status').textContent = text; }
|
|
692
|
+
|
|
693
|
+
function isMedia(mime = manifest?.mime || '') {
|
|
694
|
+
return mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/');
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function setPhase(phase) {
|
|
698
|
+
const box = document.querySelector('.progress');
|
|
699
|
+
box.classList.toggle('is-done', phase === 'done');
|
|
700
|
+
box.classList.toggle('is-error', phase === 'error');
|
|
701
|
+
const labels = { idle: 'Ready', downloading: 'Downloading', verifying: 'Verifying', done: 'Complete', error: 'Failed' };
|
|
702
|
+
$('progress-label').textContent = labels[phase] || 'Ready';
|
|
703
|
+
$('legend').hidden = phase !== 'downloading';
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function buildChunkGrid(total) {
|
|
707
|
+
const grid = $('chunkgrid');
|
|
708
|
+
grid.textContent = '';
|
|
709
|
+
cells = [];
|
|
710
|
+
if (total > MAX_CELLS) { grid.classList.add('condensed'); return; }
|
|
711
|
+
const frag = document.createDocumentFragment();
|
|
712
|
+
for (let i = 0; i < total; i++) {
|
|
713
|
+
const cell = el('span', 'cell pending');
|
|
714
|
+
cells.push(cell);
|
|
715
|
+
frag.appendChild(cell);
|
|
716
|
+
}
|
|
717
|
+
grid.appendChild(frag);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function setCell(index, state) {
|
|
721
|
+
const cell = cells[index];
|
|
722
|
+
if (cell) cell.className = 'cell ' + state;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function renderProgress() {
|
|
726
|
+
const total = manifest.chunks.length;
|
|
727
|
+
const ready = counts.done + counts.cached;
|
|
728
|
+
const pct = total ? Math.round((ready / total) * 100) : 100;
|
|
729
|
+
$('progress-fill').style.width = pct + '%';
|
|
730
|
+
$('progress-percent').textContent = pct + '%';
|
|
731
|
+
$('count-done').textContent = counts.done;
|
|
732
|
+
$('count-cached').textContent = counts.cached;
|
|
733
|
+
$('count-pending').textContent = counts.pending;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function setIcon(mime) {
|
|
737
|
+
const m = mime || '';
|
|
738
|
+
let path;
|
|
739
|
+
if (m.startsWith('image/')) path = '<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.7"/><circle cx="9" cy="10" r="1.6" fill="currentColor"/><path d="m4 18 5-5 4 4 3-3 4 4" stroke="currentColor" stroke-width="1.7" fill="none" stroke-linejoin="round"/>';
|
|
740
|
+
else if (m.startsWith('video/')) path = '<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="m10 9 5 3-5 3z" fill="currentColor"/>';
|
|
741
|
+
else if (m.startsWith('audio/')) path = '<path d="M9 18V6l10-2v12" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" fill="none"/><circle cx="6.5" cy="18" r="2.5" stroke="currentColor" stroke-width="1.7" fill="none"/><circle cx="16.5" cy="16" r="2.5" stroke="currentColor" stroke-width="1.7" fill="none"/>';
|
|
742
|
+
else path = '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" fill="none"/><path d="M14 3v5h5" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" fill="none"/>';
|
|
743
|
+
$('ficon').innerHTML = '<svg viewBox="0 0 24 24" fill="none">' + path + '</svg>';
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function resetPreview() {
|
|
747
|
+
$('preview').innerHTML =
|
|
748
|
+
'<div class="preview-empty" id="preview-empty">' +
|
|
749
|
+
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m9 8 7 4-7 4z" fill="currentColor"/><rect x="3" y="4" width="18" height="16" rx="3" stroke="currentColor" stroke-width="1.6"/></svg>' +
|
|
750
|
+
'<span>Media preview appears here when available. Verified download stays separate.</span></div>';
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function openDb() {
|
|
754
|
+
return new Promise((resolve, reject) => {
|
|
755
|
+
const req = indexedDB.open(DB_NAME, 1);
|
|
756
|
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
757
|
+
req.onsuccess = () => resolve(req.result);
|
|
758
|
+
req.onerror = () => reject(req.error);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function tx(mode = 'readonly') {
|
|
763
|
+
return db.transaction(STORE, mode).objectStore(STORE);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function key(index) { return \`\${manifest.sha256}:\${index}\`; }
|
|
767
|
+
|
|
768
|
+
function idbGet(k) {
|
|
769
|
+
return new Promise((resolve, reject) => {
|
|
770
|
+
const req = tx().get(k);
|
|
771
|
+
req.onsuccess = () => resolve(req.result);
|
|
772
|
+
req.onerror = () => reject(req.error);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function idbPut(k, value) {
|
|
777
|
+
return new Promise((resolve, reject) => {
|
|
778
|
+
const req = tx('readwrite').put(value, k);
|
|
779
|
+
req.onsuccess = () => resolve();
|
|
780
|
+
req.onerror = () => reject(req.error);
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function idbDelete(k) {
|
|
785
|
+
return new Promise((resolve, reject) => {
|
|
786
|
+
const req = tx('readwrite').delete(k);
|
|
787
|
+
req.onsuccess = () => resolve();
|
|
788
|
+
req.onerror = () => reject(req.error);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function sha256Hex(buffer) {
|
|
793
|
+
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
|
794
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function hasValidChunk(chunk) {
|
|
798
|
+
const stored = await idbGet(key(chunk.index));
|
|
799
|
+
return stored && stored.size === chunk.size && stored.sha256 === chunk.sha256;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function fetchChunk(chunk) {
|
|
803
|
+
if (await hasValidChunk(chunk)) return 'cached';
|
|
804
|
+
const res = await fetch(chunk.path, { cache: 'force-cache' });
|
|
805
|
+
if (!res.ok) throw new Error(\`Failed chunk \${chunk.index}: HTTP \${res.status}\`);
|
|
806
|
+
const buffer = await res.arrayBuffer();
|
|
807
|
+
const hash = await sha256Hex(buffer);
|
|
808
|
+
if (hash !== chunk.sha256) throw new Error(\`Checksum mismatch in chunk \${chunk.index}\`);
|
|
809
|
+
await idbPut(key(chunk.index), { size: buffer.byteLength, sha256: hash, buffer });
|
|
810
|
+
return 'downloaded';
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function runPool(items, concurrency, worker) {
|
|
814
|
+
let next = 0;
|
|
815
|
+
async function runner() {
|
|
816
|
+
while (next < items.length) {
|
|
817
|
+
await worker(items[next++]);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runner));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function assembleBlob() {
|
|
824
|
+
const parts = [];
|
|
825
|
+
for (const chunk of manifest.chunks) {
|
|
826
|
+
const stored = await idbGet(key(chunk.index));
|
|
827
|
+
if (!stored) throw new Error(\`Missing chunk \${chunk.index}\`);
|
|
828
|
+
parts.push(stored.buffer);
|
|
829
|
+
}
|
|
830
|
+
const blob = new Blob(parts, { type: manifest.mime || 'application/octet-stream' });
|
|
831
|
+
const hash = await sha256Hex(await blob.arrayBuffer());
|
|
832
|
+
if (hash !== manifest.sha256) throw new Error('Final checksum mismatch');
|
|
833
|
+
return blob;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function renderStreamingPreview() {
|
|
837
|
+
const mime = manifest.mime || '';
|
|
838
|
+
if (!isMedia(mime)) { resetPreview(); return; }
|
|
839
|
+
const box = $('preview');
|
|
840
|
+
box.innerHTML = '';
|
|
841
|
+
let media;
|
|
842
|
+
if (mime.startsWith('image/')) {
|
|
843
|
+
media = el('img', 'preview-media');
|
|
844
|
+
media.alt = manifest.fileName;
|
|
845
|
+
media.src = '/file';
|
|
846
|
+
} else if (mime.startsWith('video/')) {
|
|
847
|
+
media = el('video', 'preview-media');
|
|
848
|
+
media.controls = true;
|
|
849
|
+
media.playsInline = true;
|
|
850
|
+
media.preload = 'metadata';
|
|
851
|
+
media.src = '/file';
|
|
852
|
+
} else if (mime.startsWith('audio/')) {
|
|
853
|
+
media = el('audio');
|
|
854
|
+
media.controls = true;
|
|
855
|
+
media.preload = 'metadata';
|
|
856
|
+
media.src = '/file';
|
|
857
|
+
}
|
|
858
|
+
box.append(media);
|
|
859
|
+
const note = el('p', 'verify-note');
|
|
860
|
+
note.textContent = 'Preview only — use “Save verified copy” for the full file.';
|
|
861
|
+
box.append(note);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function renderVerifiedDownload(blob) {
|
|
865
|
+
if (!isMedia()) {
|
|
866
|
+
const box = $('preview');
|
|
867
|
+
box.innerHTML = '';
|
|
868
|
+
const card = el('div', 'preview-file');
|
|
869
|
+
card.innerHTML = '<svg viewBox="0 0 24 24" fill="none"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M14 3v5h5" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>';
|
|
870
|
+
const info = el('div');
|
|
871
|
+
const name = el('b');
|
|
872
|
+
name.textContent = manifest.fileName;
|
|
873
|
+
const size = el('span');
|
|
874
|
+
size.textContent = formatBytes(manifest.size) + ' · ' + (manifest.mime || 'binary file');
|
|
875
|
+
info.append(name, size);
|
|
876
|
+
card.append(info);
|
|
877
|
+
box.append(card);
|
|
878
|
+
}
|
|
879
|
+
const url = URL.createObjectURL(blob);
|
|
880
|
+
const save = el('a', 'btn btn-ghost');
|
|
881
|
+
save.href = url;
|
|
882
|
+
save.download = manifest.fileName;
|
|
883
|
+
save.textContent = 'Save verified file';
|
|
884
|
+
save.style.marginTop = '12px';
|
|
885
|
+
$('preview').append(save);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function downloadResume() {
|
|
889
|
+
$('download').disabled = true;
|
|
890
|
+
$('clear').disabled = true;
|
|
891
|
+
setPhase('downloading');
|
|
892
|
+
try {
|
|
893
|
+
counts = { done: 0, cached: 0, pending: 0 };
|
|
894
|
+
const missing = [];
|
|
895
|
+
for (const chunk of manifest.chunks) {
|
|
896
|
+
if (await hasValidChunk(chunk)) { counts.cached++; setCell(chunk.index, 'cached'); }
|
|
897
|
+
else { counts.pending++; setCell(chunk.index, 'pending'); missing.push(chunk); }
|
|
898
|
+
}
|
|
899
|
+
renderProgress();
|
|
900
|
+
setStatus(missing.length
|
|
901
|
+
? \`Downloading \${missing.length} chunk\${missing.length === 1 ? '' : 's'}\${counts.cached ? \` · \${counts.cached} resumed from this browser\` : ''}…\`
|
|
902
|
+
: 'All chunks already cached — verifying…');
|
|
903
|
+
await runPool(missing, manifest.suggestedParallelism || 6, async (chunk) => {
|
|
904
|
+
setCell(chunk.index, 'active');
|
|
905
|
+
await fetchChunk(chunk);
|
|
906
|
+
counts.done++;
|
|
907
|
+
counts.pending--;
|
|
908
|
+
setCell(chunk.index, 'done');
|
|
909
|
+
renderProgress();
|
|
910
|
+
});
|
|
911
|
+
setPhase('verifying');
|
|
912
|
+
setStatus('Verifying SHA-256…');
|
|
913
|
+
const blob = await assembleBlob();
|
|
914
|
+
setPhase('done');
|
|
915
|
+
setStatus('Verified copy ready.');
|
|
916
|
+
$('download-label').textContent = 'Prepare verified copy again';
|
|
917
|
+
renderVerifiedDownload(blob);
|
|
918
|
+
} catch (err) {
|
|
919
|
+
setPhase('error');
|
|
920
|
+
setStatus(err?.message || String(err));
|
|
921
|
+
console.error(err);
|
|
922
|
+
} finally {
|
|
923
|
+
$('download').disabled = false;
|
|
924
|
+
$('clear').disabled = false;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async function clearCache() {
|
|
929
|
+
$('clear').disabled = true;
|
|
930
|
+
for (const chunk of manifest.chunks) await idbDelete(key(chunk.index));
|
|
931
|
+
counts = { done: 0, cached: 0, pending: manifest.chunks.length };
|
|
932
|
+
for (const chunk of manifest.chunks) setCell(chunk.index, 'pending');
|
|
933
|
+
renderProgress();
|
|
934
|
+
setPhase('idle');
|
|
935
|
+
renderStreamingPreview();
|
|
936
|
+
setStatus('Local cache cleared.');
|
|
937
|
+
$('download-label').textContent = 'Save verified copy';
|
|
938
|
+
$('clear').disabled = false;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function copySha() {
|
|
942
|
+
await navigator.clipboard.writeText(manifest.sha256);
|
|
943
|
+
$('copy-sha').textContent = 'Copied';
|
|
944
|
+
setTimeout(() => { $('copy-sha').textContent = 'Copy'; }, 1200);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function formatExpiry(iso) {
|
|
948
|
+
const expires = new Date(new Date(iso).getTime() + 60 * 60 * 1000);
|
|
949
|
+
return 'Temporary link · expires around ' + expires.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function init() {
|
|
953
|
+
db = await openDb();
|
|
954
|
+
const res = await fetch('/api/manifest', { cache: 'no-store' });
|
|
955
|
+
manifest = await res.json();
|
|
956
|
+
$('title').textContent = manifest.fileName;
|
|
957
|
+
$('meta').textContent = isMedia(manifest.mime)
|
|
958
|
+
? \`Preview streams from the temporary Worker. Save a verified copy when you need the actual file.\`
|
|
959
|
+
: \`Save a checksum-verified copy assembled from resumable Cloudflare Static Asset chunks.\`;
|
|
960
|
+
$('eyebrow').textContent = manifest.mime?.startsWith('video/') ? 'Video file' : manifest.mime?.startsWith('audio/') ? 'Audio file' : manifest.mime?.startsWith('image/') ? 'Image file' : 'File drop';
|
|
961
|
+
$('stat-size').textContent = formatBytes(manifest.size);
|
|
962
|
+
$('stat-type').textContent = manifest.mime || 'application/octet-stream';
|
|
963
|
+
$('expires').textContent = formatExpiry(manifest.createdAt);
|
|
964
|
+
$('sha').textContent = manifest.sha256;
|
|
965
|
+
$('waybill-id').textContent = manifest.sha256.slice(0, 12).toUpperCase();
|
|
966
|
+
$('manifest').textContent = JSON.stringify(manifest, null, 2);
|
|
967
|
+
setIcon(manifest.mime);
|
|
968
|
+
renderStreamingPreview();
|
|
969
|
+
buildChunkGrid(manifest.chunks.length);
|
|
970
|
+
counts = { done: 0, cached: 0, pending: 0 };
|
|
971
|
+
for (const chunk of manifest.chunks) {
|
|
972
|
+
if (await hasValidChunk(chunk)) { counts.cached++; setCell(chunk.index, 'cached'); }
|
|
973
|
+
else { counts.pending++; setCell(chunk.index, 'pending'); }
|
|
974
|
+
}
|
|
975
|
+
renderProgress();
|
|
976
|
+
setPhase(counts.cached === manifest.chunks.length ? 'done' : 'idle');
|
|
977
|
+
setStatus(counts.cached
|
|
978
|
+
? \`\${counts.cached}/\${manifest.chunks.length} chunks already cached in this browser.\`
|
|
979
|
+
: isMedia(manifest.mime)
|
|
980
|
+
? 'Preview can stream now. Save a verified copy if you need the file.'
|
|
981
|
+
: 'Ready. Save a verified copy; completed chunks resume after reload.');
|
|
982
|
+
$('download').disabled = false;
|
|
983
|
+
$('clear').disabled = false;
|
|
984
|
+
$('copy-sha').disabled = false;
|
|
985
|
+
$('download').addEventListener('click', downloadResume);
|
|
986
|
+
$('clear').addEventListener('click', clearCache);
|
|
987
|
+
$('copy-sha').addEventListener('click', copySha);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
init().catch((err) => {
|
|
991
|
+
$('status').textContent = err?.message || String(err);
|
|
992
|
+
console.error(err);
|
|
993
|
+
});
|
|
994
|
+
`;
|
|
995
|
+
}
|
|
996
|
+
main().catch((err) => {
|
|
997
|
+
console.error(`Error: ${err?.message ?? err}`);
|
|
998
|
+
process.exit(1);
|
|
999
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cf-temp-dropper",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create temporary Cloudflare-hosted file drops from local files.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cf-temp-dropper": "dist/cli.js",
|
|
8
|
+
"cf-temp-drop": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"CONTRIBUTING.md",
|
|
16
|
+
"SECURITY.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
21
|
+
"smoke": "npm run build && node dist/cli.js fixtures/sample.txt --no-deploy --out .tmp/smoke --yes",
|
|
22
|
+
"prepack": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"cloudflare",
|
|
26
|
+
"cloudflare-workers",
|
|
27
|
+
"workers-static-assets",
|
|
28
|
+
"wrangler",
|
|
29
|
+
"temporary",
|
|
30
|
+
"hono",
|
|
31
|
+
"file-share",
|
|
32
|
+
"file-transfer",
|
|
33
|
+
"static-assets"
|
|
34
|
+
],
|
|
35
|
+
"author": "kiyo-e <kiyo-e@users.noreply.github.com>",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/kiyo-e/cf-temp-dropper.git"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/kiyo-e/cf-temp-dropper/issues"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/kiyo-e/cf-temp-dropper#readme",
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"hono": "^4.10.7",
|
|
50
|
+
"mime-types": "^3.0.2",
|
|
51
|
+
"wrangler": "^4.56.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/mime-types": "^3.0.1",
|
|
55
|
+
"@types/node": "^25.0.3",
|
|
56
|
+
"tsx": "^4.21.0",
|
|
57
|
+
"typescript": "^5.9.3"
|
|
58
|
+
}
|
|
59
|
+
}
|