diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4392145a..0f525f8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,13 @@ on: permissions: contents: read -# A new push to the same branch supersedes the run in flight. +# On a pull request a new push supersedes the run in flight: only the branch's +# latest commit needs a verdict. Pushes to main are never cancelled, because the +# ref is the same for every commit there, so a batch of stacked merges would +# otherwise leave each commit but the last with no result of its own. concurrency: group: ci-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: test: diff --git a/.github/workflows/threatcrush-scan.yml b/.github/workflows/threatcrush-scan.yml new file mode 100644 index 00000000..61fb4a93 --- /dev/null +++ b/.github/workflows/threatcrush-scan.yml @@ -0,0 +1,291 @@ +# Adapted from the upstream `threatcrush-scan@1.1.0` pack +# (profullstack/threatcrush → .github/workflows/threatcrush-scan.yml). +# Deviations from that pack, all to fit this repo: +# - Node 22, not 20, matching `engines.node` and the CI matrix floor +# - fails on critical/high, matching vu1nz-scan.yml, which already does +# - the legacy text-output compatibility path is dropped; see "Detect the +# CLI output interface" below +# The sh1pt fleet header is deliberately not carried over: this file is no +# longer byte-identical to the pack, and claiming a hash that does not match +# would be worse than not claiming one. +name: threatcrush security scan + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + security-events: write + +# A new push to the same branch supersedes the run in flight, as in ci.yml. +concurrency: + group: threatcrush-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: Scan for credentials and vulnerable patterns + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v7 + + # 22 is this repo's floor (`engines.node`) and the low end of the CI + # matrix. Not 24: ThreatCrush pulls in better-sqlite3, which ships + # prebuilt binaries for 22 but not yet for 24, where the install falls + # through to a node-gyp source build and fails. A security gate that + # cannot install is a security gate that does not run. + - uses: actions/setup-node@v7 + with: + node-version: "22" + + # An unretried `npm i -g` is a network call to a registry that decides + # whether a security gate runs at all. Retry before giving up; a + # transient registry blip is not a security signal and should not read + # like one. + - name: Install ThreatCrush + run: | + for attempt in 1 2 3; do + if npm install -g "@profullstack/threatcrush@latest"; then + exit 0 + fi + delay=$((attempt * 10)) + echo "::warning::ThreatCrush install attempt ${attempt}/3 failed; retrying in ${delay}s" + sleep "${delay}" + done + echo "::error::ThreatCrush install failed after 3 attempts" + exit 1 + + # Recorded into every run log so a release that changes the interface + # shows up immediately, rather than silently scoring zero. + - name: Record the CLI interface + run: | + threatcrush --version || true + threatcrush scan --help || true + + # Checked up front rather than inferred from an exit code, because exit + # codes cannot tell the two failures apart. CLIs before 0.3.0 have no + # `--format`: the scan died with `error: unknown option '--format'` and + # commander exited 1 — the same code the CLI uses for "findings at or + # above --fail-on". Read as a result, that produces a green check on a + # repository nothing has scanned. + # + # Upstream converts legacy text output with a vendored Python script. + # That path is dropped here rather than carrying a converter for a CLI + # older than the current npm release. Failing closed is the same safety + # property: an unrecognised interface stops the job instead of reporting + # an unscanned diff as clean. + - name: Detect the CLI output interface + run: | + if ! threatcrush scan --help 2>&1 | grep -q -- '--format'; then + echo "::error::CLI $(threatcrush --version 2>/dev/null || echo unknown) has no --format; cannot emit SARIF. This diff was NOT scanned." + exit 1 + fi + echo "Native SARIF output available." + + # --fail-on critical,high matches vu1nz-scan.yml, which already exits 1 + # on high/critical findings. It is not as noisy as it sounds: + # `pattern`-confidence findings are capped at medium upstream, so a bare + # "this construct exists" match cannot break a build. Only `contextual` + # and `evidence` findings reach this floor. Drop the flag to make the + # scan advisory. + - name: Scan + id: scan + run: | + set -o pipefail + code=0 + threatcrush scan . --format sarif --output threatcrush.sarif --fail-on critical,high || code=$? + + # The SARIF file is the evidence that a scan happened, and it is the + # only evidence worth trusting. An exit code says what the process + # thought; the file says what it produced. Absent the file there is + # nothing to report, and reporting nothing as "no findings" is the + # failure this whole workflow is arranged to avoid. + if [ ! -s threatcrush.sarif ]; then + echo "status=error" >> "$GITHUB_OUTPUT" + echo "::error::ThreatCrush produced no SARIF (exit ${code}) — this diff was NOT scanned" + exit 1 + fi + + case "$code" in + 0) echo "status=clean" >> "$GITHUB_OUTPUT" ;; + # Exit 1 *with* a SARIF file is the documented "findings at or + # above --fail-on" result; without one it was caught above. + # Propagate it: a gate that records the finding and then lets the + # job pass is not a gate. + 1) + echo "status=findings" >> "$GITHUB_OUTPUT" + exit 1 + ;; + *) + echo "status=error" >> "$GITHUB_OUTPUT" + echo "::error::ThreatCrush scan failed with exit code ${code} — results may be incomplete" + exit "$code" + ;; + esac + + # Reached only when an earlier step already failed the job. The empty + # run exists so the upload does not error on a missing file and bury the + # real cause; it is not a result. The scan step has already set + # status=error (or never ran), so the report says NOT RUN rather than + # rendering this as a clean scan. + - name: Ensure SARIF exists + if: always() + run: | + if [ ! -f threatcrush.sarif ]; then + cat > threatcrush.sarif <<'JSON' + { + "version": "2.1.0", + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "runs": [{ "tool": { "driver": { "name": "ThreatCrush", "rules": [] } }, "results": [] }] + } + JSON + fi + + - name: Upload to the Security tab + if: always() + continue-on-error: true + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: threatcrush.sarif + category: threatcrush + + - name: Build the report + if: always() + run: | + python3 << 'PYEOF' + import json, os + + status = os.environ.get("SCAN_STATUS", "") + try: + with open("threatcrush.sarif") as handle: + results = json.load(handle)["runs"][0]["results"] + except Exception as err: + results = None + print(f"::warning::could not read SARIF: {err}") + + lines = ["## ThreatCrush Security Scan", ""] + + # Fail closed: render findings only on positive evidence that a scan + # completed. Testing for `status == "error"` would be fail-open — + # when an earlier step fails, the scan step is *skipped*, so + # `status` is the empty string rather than "error", and the comment + # cheerfully reports "0 findings" for a scan that never started. Any + # state that is not a known-good outcome is NOT RUN. + if status not in ("clean", "findings") or results is None: + # Never render "no issues found" for a scan that did not finish. + # An unexamined diff is not a clean one, and the two are + # indistinguishable to whoever reads the comment. + lines += [ + "**NOT RUN** — the scan did not complete, so this diff was not examined.", + "This is not a clean result. See the job log.", + ] + else: + counts = {"error": 0, "warning": 0, "note": 0} + for result in results: + level = result.get("level", "warning") + if level in counts: + counts[level] += 1 + + lines.append(f"**{len(results)}** finding(s)") + lines.append("") + + if results: + badges = [] + if counts["error"]: + badges.append(f"**HIGH/CRITICAL**: {counts['error']}") + if counts["warning"]: + badges.append(f"**MEDIUM**: {counts['warning']}") + if counts["note"]: + badges.append(f"**LOW**: {counts['note']}") + if badges: + lines += [" | ".join(badges), ""] + + lines += ["| Severity | Rule | Location |", "|---|---|---|"] + for result in results[:50]: + location = result["locations"][0]["physicalLocation"] + uri = location["artifactLocation"]["uri"] + line_no = location.get("region", {}).get("startLine", 1) + label = {"error": "HIGH", "warning": "MEDIUM", "note": "LOW"}.get( + result.get("level", "warning"), "INFO" + ) + lines.append(f"| {label} | `{result.get('ruleId','?')}` | `{uri}`:{line_no} |") + if len(results) > 50: + # Say so. A silent truncation reads as "that was everything". + lines += ["", f"_…and {len(results) - 50} more. Full results in the Security tab._"] + lines += ["", "Snippets are redacted; ThreatCrush never prints matched credential material."] + else: + lines.append("No findings.") + + with open(os.environ["RUNNER_TEMP"] + "/threatcrush-comment.md", "w") as handle: + handle.write("\n".join(lines) + "\n") + PYEOF + env: + SCAN_STATUS: ${{ steps.scan.outputs.status }} + + - name: Write report to job summary + if: always() + run: cat "$RUNNER_TEMP/threatcrush-comment.md" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true + + - name: Upload SARIF artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: threatcrush-sarif + path: threatcrush.sarif + retention-days: 30 + + # Best-effort, exactly as vu1nz-scan.yml treats its own comment step. + # `pull_request` gives fork PRs a read-only token, so this 403s on fork + # submissions — the report is in the job summary either way, and the + # scan's pass/fail is decided by the scan step, not by whether a comment + # posted. Deliberately NOT switching to pull_request_target to get a + # writable token: that event runs with repository secrets in scope + # against a checkout of untrusted contributor code. + - name: Comment on PR + if: always() && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' + continue-on-error: true + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let body; + try { + body = fs.readFileSync(`${process.env.RUNNER_TEMP}/threatcrush-comment.md`, 'utf8'); + } catch { + body = '## ThreatCrush Security Scan\n\nScan completed but the report could not be read.'; + } + + try { + const { data: comments } = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body.includes('ThreatCrush Security Scan'), + ); + + if (existing) { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } + } catch (err) { + core.warning( + `Could not post PR comment (status ${err.status ?? 'unknown'}): ${err.message}. ` + + 'Findings are in the job summary.', + ); + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de18337f..073b10ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,10 @@ Then check your change against the standards below. The pull request template wa ## The standards +### Six categories, one curated source list + +Games, Movies, TV, Anime, E-Books, and Audiobooks cover the majority of real torrent traffic, and the sidebar reads at a glance because that list is short. The sources feeding those tabs are settled for the same reason: every extra index is one more thing to keep alive and more noise in the results. A pull request adding an unrelated category or unvetted source is declined however clean the code, so please open an issue before you write one. Fixes to the sources already here are always welcome. + ### Match the existing grain Reuse what's there before you write something new. Cursor movement goes through `wrapStep` (`src/ui/move.ts`). Key hints live in the `Hint` / `HELP_GROUPS` / `footerHints` system (`src/ui/keymap.ts`). Shared app state is the `Store` interface (`src/ui/store.ts`). diff --git a/README.md b/README.md index 5b56c62c..50df2921 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,10 @@ Klink expands on upstream torlink with key power-user features: - ✅ **Completed Tab & Smart File Organisation**: Cleanly separate active downloads, seeding items, and finished downloads with directory routing. - ⚠️ **Action Confirmation Dialogs**: Safety confirmation prompts before destructive actions like cancelling downloads or clearing history to prevent accidental data loss. - 📥 **Drag-and-Drop & Clipboard `.torrent` Support**: Drop a `.torrent` file directly onto the terminal or paste its path/URI into the search bar to enqueue it instantly. -- 📡 **Private Tracker Announce Preservation**: Intact tracker list and passkey preservation when loading `.torrent` files. +- 📡 **Private Tracker Announce Preservation**: Intact tracker list and passkey preservation when loading `.torrent` files or resuming downloads. +- 🌱 **Local Seeding & Sharing (`klink seed`)**: Turn any local file or directory into a shared torrent, save the `.torrent` file, and seed immediately over DHT and trackers. +- ⚡ **Headless CLI Search (`klink search`)**: Non-interactive command to query indexers and output JSON results directly from the terminal or scripts. +- 📋 **OSC 52 Remote Clipboard**: Copy magnets and links over SSH sessions without requiring local X11 or Wayland clipboard forwarding. - ✨ **High-Contrast UI Row Highlights**: Full-row active bolding and dimming across all columns in Results, Downloads, and Seeding views. - 🔧 **WebTorrent stability patch**: Guards against a null-pointer crash in `_request` introduced in webtorrent 3.x, keeping the daemon stable under heavy peer churn. @@ -83,13 +86,28 @@ Games are the only category that can run code, so they come from FitGirl alone, Klink also runs without the TUI, for servers and seedboxes: - klink search print search results as JSON + klink search "" [--category games|movies|tv|anime|ebooks|audiobooks] + print one JSON document of merged search results + klink seed share files you already have klink watch download anything dropped into a folder klink serve take magnets over HTTP and host themed web player klink files stream finished downloads over HTTP klink attach keep the TUI alive across ssh sessions -Add `--daemon` to keep watch, serve, or files running after you log out; `klink --help` has the full list of modes and flags. +Add `--daemon` to keep seed, watch, serve, or files running after you log out; `klink --help` has the full list of modes and flags. + +### Sharing something of your own + +Everything else starts with a torrent someone else made. `seed` goes the other way: + + klink seed ./album + +It turns the folder into a torrent, saves `album.torrent` next to it, prints the magnet, and starts sharing right away. Send anyone the magnet and they pull the files from you. + +`serve` takes a `.torrent` as well as a magnet, so you can hand it one you already have: + + POST /add {"magnet":"magnet:?xt=..."} + POST /add {"torrent":""} ## Contributing diff --git a/package-lock.json b/package-lock.json index 784c18fa..d94806e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "klink", - "version": "1.7.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "klink", - "version": "1.7.0", + "version": "1.8.0", "hasInstallScript": true, "license": "MIT", "dependencies": { + "create-torrent": "^6.1.2", "env-paths": "^4.0.0", "ink": "^7.1.1", "parse-torrent": "^11.0.21", diff --git a/package.json b/package.json index d6ea2c2e..7396eef5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "klink", - "version": "1.7.0", + "version": "1.8.0", "description": "Klink - A sleek, zero-setup torrent finder and downloader TUI with peer inspection, media streaming, and turtle mode.", "type": "module", "bin": { @@ -21,6 +21,8 @@ "start": "node dist/index.js", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest --coverage", "verify:seeding": "tsx scripts/verify-seeding.ts", "previews": "tsx scripts/render-previews.tsx", "postinstall": "node scripts/ensure-webrtc.cjs", @@ -58,6 +60,7 @@ "access": "public" }, "dependencies": { + "create-torrent": "^6.1.2", "env-paths": "^4.0.0", "ink": "^7.1.1", "parse-torrent": "^11.0.21", diff --git a/preview/browse.svg b/preview/browse.svg index 2cdf950e..4e3274c9 100644 --- a/preview/browse.svg +++ b/preview/browse.svg @@ -50,13 +50,13 @@ ╭─ Latest (5) ──────────────────────────────────────────────╮ + E-Books newest across all sources - Downloads + Audiobooks - Seeding # Name @@ -64,7 +64,7 @@ Seed:Lch Src - Completed + Downloads 1 @@ -73,6 +73,7 @@ 1240:88 YTS + Seeding 2 Dune: Part Two (2024) [21… @@ -80,6 +81,7 @@ 910:41 YTS + Completed 3 Breaking Bad S05E14 1080p… @@ -108,15 +110,13 @@ Download i Files - y - Copy - s - Sort - / - Search - f - Filter - b - + v + Metadata + y + Copy + s + Sort + / + Search … \ No newline at end of file diff --git a/preview/downloads.svg b/preview/downloads.svg index 423abb22..3647f081 100644 --- a/preview/downloads.svg +++ b/preview/downloads.svg @@ -42,8 +42,8 @@ Dune: Part Two (2024) [2160p BluRay] - 7.82 GB - YTS + 7.82 GB + YTS Movies @@ -71,6 +71,7 @@ Recently downloaded (2) + Audiobooks Elden Ring: Shadow of t… @@ -78,9 +79,6 @@ 1hr ago FG - - Downloads - (1) Breaking Bad S05E14 108… @@ -88,12 +86,15 @@ 1d 1hr ago EZTV - Seeding + + Downloads + (1) - Completed + Seeding + Completed ╰───────────────────────────────────────────────────────────╯ p Pause @@ -103,12 +104,12 @@ Folder s Export - b - Turtle - tab - Switch - ? - Keys + Q + Auto-close + b + Turtle + tab + Swit… diff --git a/preview/splash.svg b/preview/splash.svg index a604765a..5d209468 100644 --- a/preview/splash.svg +++ b/preview/splash.svg @@ -33,7 +33,7 @@ A curated, terminal-native torrent downloader. - games · movies · tv · anime + games · movies · tv · anime · e-books · audiobooks ╭─ Search ───────────────────────────────────────────────────╮ diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx index b798f84e..a7d68d6f 100644 --- a/scripts/render-previews-impl.tsx +++ b/scripts/render-previews-impl.tsx @@ -120,6 +120,8 @@ function makeStore( setInspectFocusSelected: noop, toggleFileSelection: noop, quitAll: noop, + autoClose: false, + toggleAutoClose: noop, listRows: 14, compact: false, contentWidth: CONTENT_WIDTH, diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 2eba0bba..b4528f73 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -210,3 +210,28 @@ describe("parseCliArgs", () => { }); }); }); + +describe("seed", () => { + it("takes the path, and the flags the other headless modes take", () => { + expect(parseCliArgs(["seed", "./album"])).toEqual({ + kind: "seed", + path: "./album", + seedTimeMs: undefined, + deleteFiles: false, + daemon: false, + }); + expect(parseCliArgs(["seed", "--seed-time", "2h", "--daemon", "./album"])).toEqual({ + kind: "seed", + path: "./album", + seedTimeMs: 2 * 60 * 60 * 1000, + deleteFiles: false, + daemon: true, + }); + }); + + // Without a path there is nothing to hash, and defaulting to the cwd would + // make a bare `torlnk seed` start hashing a home directory. + it("is invalid with no path", () => { + expect(parseCliArgs(["seed"])).toEqual({ kind: "invalid", arg: "seed (missing path)" }); + }); +}); diff --git a/src/cli/args.ts b/src/cli/args.ts index f9d39567..1b1b3b3e 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -25,6 +25,13 @@ export type CliCommand = deleteFiles?: boolean; daemon?: boolean; } + | { + kind: "seed"; + path: string; + seedTimeMs?: number; + deleteFiles?: boolean; + daemon?: boolean; + } | { kind: "files"; port?: number; host?: string; token?: string; dir?: string; daemon?: boolean } | { kind: "attach" } | { kind: "update"; force?: boolean } @@ -132,6 +139,19 @@ export function parseCliArgs(argv: string[]): CliCommand { daemon: bools.has("daemon"), }; } + if (a === "seed") { + const { bools, rest: r0 } = splitBooleans(args.slice(1)); + const { flags, rest } = readFlags(r0); + const target = rest[0]; + if (!target) return { kind: "invalid", arg: "seed (missing path)" }; + return { + kind: "seed", + path: target, + seedTimeMs: seedTimeFrom(flags["seed-time"]), + deleteFiles: bools.has("delete-files"), + daemon: bools.has("daemon"), + }; + } if (a === "files") { const { bools, rest: r0 } = splitBooleans(args.slice(1)); const { flags } = readFlags(r0); @@ -158,6 +178,7 @@ usage klink path/to/file.torrent open a .torrent file on launch klink search headless: print search results as JSON [--category games|movies|tv|anime|ebooks|audiobooks] + klink seed headless: share files you already have klink watch headless: download torrents dropped into klink serve headless: HTTP add API (POST /add) on :9161 klink files headless: serve downloads over HTTP on :9160 @@ -174,7 +195,12 @@ watch mode (no TUI): drop a .torrent, or a .magnet/.txt holding a magnet or info hash, into and it downloads then seeds. Add --to to choose where files land. Handled files move to /.processed (or /.failed). -seed mode (watch/serve): --seed-time stops seeding a torrent that long +seed a path (no TUI): torlnk seed ./album turns the folder into a torrent, +saves album.torrent next to it, prints the magnet, and starts sharing. Send +anyone the magnet and they pull the files from you. Takes --seed-time, +--delete-files and --daemon. + +seed expiry (seed/watch/serve): --seed-time stops seeding a torrent that long after it finishes (e.g. 1h, 30m, 90s, 2d); files are kept by default. Add --delete-files to also remove the downloaded data when the timer expires. @@ -187,6 +213,7 @@ left off. Downloads and seeds keep running while detached. serve mode (no TUI): a small HTTP API for handing klink a magnet. POST /add {"magnet":"..."} queue a magnet or info hash + POST /add {"torrent":""} queue an uploaded .torrent (base64 or data: URI) GET /downloads list active downloads and seeds GET /health liveness (no auth) flags: --port (default 9161), --host (default 127.0.0.1), diff --git a/src/create-torrent.d.ts b/src/create-torrent.d.ts new file mode 100644 index 00000000..2733f38a --- /dev/null +++ b/src/create-torrent.d.ts @@ -0,0 +1,24 @@ +// create-torrent ships no types, in the same way parse-torrent does not. Only +// the callback form is declared, because it is the only one torlink calls: the +// package also accepts streams and File objects, and declaring shapes we never +// use is how a hand-written declaration drifts from the package it describes. +declare module "create-torrent" { + interface CreateTorrentOptions { + announce?: string[]; + name?: string; + comment?: string; + createdBy?: string; + creationDate?: number; + private?: boolean; + pieceLength?: number; + urlList?: string[]; + } + + function createTorrent( + input: string, + opts: CreateTorrentOptions, + callback: (err: Error | null, torrent: Uint8Array) => void, + ): void; + + export = createTorrent; +} diff --git a/src/daemon/seed.paths.test.ts b/src/daemon/seed.paths.test.ts new file mode 100644 index 00000000..d062892a --- /dev/null +++ b/src/daemon/seed.paths.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; + +import { seedRootFor } from "./seed"; + +// seedRootFor resolves against the running platform's path rules, so a POSIX +// literal comes back drive-qualified on Windows ("/srv/media" -> "C:\srv\media") +// and every assertion against one fails there. Build the fixtures natively. +const MEDIA = path.resolve(path.join("srv", "media")); + +describe("seedRootFor", () => { + /* + * The one calculation that decides whether seeding works or silently + * re-downloads. A torrent names its own top-level entry, so the client's + * download directory has to be the content's PARENT: pointed at the content + * itself it looks for album/album, finds nothing, and fetches a second copy + * next to the one already on disk. + */ + it("is the content's parent, never the content", () => { + expect(seedRootFor(path.join(MEDIA, "album"))).toBe(MEDIA); + expect(seedRootFor(path.join(MEDIA, "film.mkv"))).toBe(MEDIA); + }); + + it("resolves a relative path before taking the parent", () => { + expect(path.isAbsolute(seedRootFor("./album"))).toBe(true); + expect(seedRootFor("./album")).toBe(process.cwd()); + }); + + // A trailing slash is what tab-completion gives you for a directory, and it + // would otherwise make dirname return the directory itself. + it("is not fooled by a trailing slash", () => { + expect(seedRootFor(path.join(MEDIA, "album") + path.sep)).toBe(MEDIA); + }); +}); diff --git a/src/daemon/seed.ts b/src/daemon/seed.ts new file mode 100644 index 00000000..e74b58a5 --- /dev/null +++ b/src/daemon/seed.ts @@ -0,0 +1,87 @@ +// Headless seed mode: make a torrent out of files you already have, and serve +// them to the swarm. +// +// Every other way into torlink starts from a torrent somebody else made. This +// is the origin case, and it is the one thing the client could not do: search +// finds torrents, the watch folder is handed them, the API is posted them — +// none of that helps when the content is yours and no torrent for it exists. +// +// The order matters and is the whole trick. Create the .torrent first, write it +// beside the data, then add it BY PATH. Adding the magnet instead would be +// correct and useless: a magnet carries no piece hashes, so the client has to +// fetch metadata from the swarm before it can verify anything — and the swarm +// for a torrent nobody has ever seen has no one in it to fetch from. It would +// sit at zero per cent forever, seeding data that is already on the disk under +// it. Handed the .torrent, it verifies locally and is seeding in seconds. + +import path from "node:path"; + +import { loadConfig } from "../config/config"; +import { createTorrentFor } from "../download/create"; +import { saveTorrentMeta } from "../download/persist"; +import { startRuntime, addInput } from "./runtime"; +import { startSeedReaper } from "./seed-reaper"; + +export interface SeedOptions { + seedTimeMs?: number; + deleteFiles?: boolean; +} + +function log(message: string): void { + console.log(`[torlnk seed] ${new Date().toISOString()} ${message}`); +} + +// The directory a client must be pointed at for existing data to verify. +// +// A torrent names its own top-level entry, so seeding /srv/media/album means +// the client's download directory has to be /srv/media — point it at the album +// itself and it looks for /srv/media/album/album, finds nothing, and downloads +// a complete copy next to the one already there. +export function seedRootFor(target: string): string { + return path.dirname(path.resolve(target)); +} + +export async function runSeed(target: string, options: SeedOptions = {}): Promise { + const root = seedRootFor(target); + const config = await loadConfig(); + + log(`hashing ${path.resolve(target)}`); + const created = await createTorrentFor(target, config.trackers); + log(`wrote ${created.torrentPath}`); + + // Store the metadata where the queue looks for it BEFORE adding. This is the + // step that makes the difference between seeding and pretending to: with it, + // the engine is handed piece hashes and verifies the files already on disk; + // without it, it gets a bare magnet and waits for a swarm that has nobody in + // it to send back the metadata for a torrent that has never existed. + await saveTorrentMeta(created.infoHash, created.torrentFile); + + // The download dir is the content's parent, not the configured one: this + // torrent's data is already where it is, and moving it is not on offer. + const runtime = await startRuntime(root); + const outcome = await addInput(runtime, created.torrentPath, { allowTorrentPath: true }); + if (outcome === "invalid") throw new Error(`could not seed ${created.torrentPath}`); + if (outcome === "duplicate") log("already in the queue — leaving it alone"); + + if (options.seedTimeMs) { + startSeedReaper(runtime.queue, options.seedTimeMs, { deleteFiles: options.deleteFiles }); + } + + // The magnet on its own line and nothing else on it, so `torlnk seed x | tail + // -1` is a usable thing to write in a script. + log(`seeding ${created.name} (${created.infoHash}) from ${root}`); + console.log(created.magnet); + + // Hold the process open and shut down cleanly, exactly as watch and serve do. + // Without this the mode ends here and stays alive only because webtorrent's + // handles do, so a SIGTERM (systemctl stop, or ctrl-c) would kill it without + // ever flushing state through suspend(). + await new Promise((resolve) => { + const shutdown = (): void => { + runtime.queue.suspend(); + resolve(); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + }); +} diff --git a/src/daemon/serve.test.ts b/src/daemon/serve.test.ts index 092d09eb..f4e36637 100644 --- a/src/daemon/serve.test.ts +++ b/src/daemon/serve.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import os from "node:os"; import path from "node:path"; import { promises as fs } from "node:fs"; -import { handleApi, isAuthorized, extractMagnet, parseControl, applyControl } from "./serve"; +import { handleApi, isAuthorized, extractMagnet, extractTorrentBytes, parseControl, applyControl } from "./serve"; import type { Runtime } from "./runtime"; const HASH = "abcdef0123456789abcdef0123456789abcdef01"; @@ -191,3 +191,35 @@ describe("applyControl", () => { expect(await applyControl(mkRuntime({}), { id: "z", action: "nope", deleteFiles: false })).toBe("unknown-action"); }); }); + +describe("extractTorrentBytes", () => { + // Every torrent is a bencoded dictionary, so it starts with "d". + const b64 = Buffer.from("d4:name4:teste", "utf8").toString("base64"); + + it("reads a base64 .torrent out of the body", () => { + const bytes = extractTorrentBytes(JSON.stringify({ torrent: b64 })); + expect(bytes).not.toBeNull(); + expect(bytes![0]).toBe(0x64); + }); + + // What a browser's FileReader.readAsDataURL hands you, verbatim. + it("accepts a data: URI without making the caller strip it", () => { + const uri = `data:application/x-bittorrent;base64,${b64}`; + expect(extractTorrentBytes(JSON.stringify({ torrent: uri }))).not.toBeNull(); + }); + + /* + * Buffer.from(..., "base64") ignores bytes it cannot decode rather than + * throwing, so garbage yields a short buffer instead of an error. Checking + * for the leading bencode dictionary is what turns that into a rejection. + */ + it("rejects a string that is not a torrent", () => { + expect(extractTorrentBytes(JSON.stringify({ torrent: "hello world" }))).toBeNull(); + expect(extractTorrentBytes(JSON.stringify({ torrent: "" }))).toBeNull(); + }); + + it("is null for a body that carries no torrent at all", () => { + expect(extractTorrentBytes(JSON.stringify({ magnet: "magnet:?xt=urn:btih:" + "a".repeat(40) }))).toBeNull(); + expect(extractTorrentBytes("not json")).toBeNull(); + }); +}); diff --git a/src/daemon/serve.ts b/src/daemon/serve.ts index e5dcb335..d83804d1 100644 --- a/src/daemon/serve.ts +++ b/src/daemon/serve.ts @@ -9,6 +9,7 @@ import http from "node:http"; import { startRuntime, addInput, type Runtime } from "./runtime"; +import { magnetFromTorrentBytes } from "../sources/torrentFile"; import { startSeedReaper } from "./seed-reaper"; import { LOOPBACK_HOSTS, isAuthorized, hostHeaderOk } from "./auth"; import { VERSION } from "../version"; @@ -17,7 +18,11 @@ export { isAuthorized } from "./auth"; export const DEFAULT_API_PORT = 9161; -const MAX_BODY_BYTES = 64 * 1024; // a magnet is small; cap the body hard +// A magnet is tiny, but /add also takes an uploaded .torrent, and that is one +// 20-byte hash per piece: a large multi-file release runs to a few hundred KB +// before base64 adds a third on top. The old 64KB cap fit every magnet and +// every torrent small enough to test with, then answered 413 on real content. +const MAX_BODY_BYTES = 1024 * 1024; export interface ApiResponse { status: number; @@ -38,6 +43,40 @@ export interface ServeOptions { // Pull a magnet / info hash out of a request body. Accepts JSON ({ magnet } or // { infohash }) or a raw body that is itself a magnet or info hash — forgiving, // so callers don't have to guess the exact envelope. +// A base64 .torrent from the request body, or null. +// +// The bytes travel in the JSON rather than the path to them: torlink already +// refuses to let a network caller name a local file (runtime.ts's +// allowTorrentPath), and that refusal is worth keeping -- "add this torrent" +// and "read this file off your disk and tell me about it" must not be the same +// request. Uploading the content sidesteps it entirely. +export function extractTorrentBytes(bodyText: string): Uint8Array | null { + const raw = bodyText.trim(); + if (!raw.startsWith("{")) return null; + let obj: Record; + try { + obj = JSON.parse(raw) as Record; + } catch { + return null; + } + const value = obj.torrent ?? obj.torrentFile ?? obj.file; + if (typeof value !== "string" || !value.trim()) return null; + // A data: URI is what a browser's FileReader hands you, and stripping the + // prefix here is cheaper than making every caller remember to. + const b64 = value.replace(/^data:[^,]*,/, "").trim(); + try { + const bytes = Buffer.from(b64, "base64"); + // Buffer.from ignores anything it cannot decode rather than throwing, so a + // non-base64 string yields a short buffer instead of an error. Every + // torrent starts with a bencoded dictionary, which is the cheap check that + // this is one. + if (bytes.length === 0 || bytes[0] !== 0x64) return null; + return new Uint8Array(bytes); + } catch { + return null; + } +} + export function extractMagnet(bodyText: string): string | null { const raw = bodyText.trim(); if (!raw) return null; @@ -166,8 +205,19 @@ export async function handleApi( return { status: 200, body: statusPayload(runtime) }; } if (method === "POST" && urlPath === "/add") { + // A .torrent is tried first because it is strictly more information: it + // carries the piece hashes, so data already on disk verifies locally + // instead of waiting on a swarm to serve metadata back. + const bytes = extractTorrentBytes(bodyText); + if (bytes) { + const parsed = await magnetFromTorrentBytes(bytes); + if (!parsed) return { status: 400, body: { error: "invalid .torrent" } }; + const outcome = await addInput(runtime, parsed.magnet); + if (outcome === "invalid") return { status: 400, body: { error: "invalid .torrent" } }; + return { status: 200, body: { ok: true, outcome, infoHash: parsed.infoHash } }; + } const magnet = extractMagnet(bodyText); - if (!magnet) return { status: 400, body: { error: "missing magnet or info hash" } }; + if (!magnet) return { status: 400, body: { error: "missing magnet, info hash or .torrent" } }; const outcome = await addInput(runtime, magnet); if (outcome === "invalid") return { status: 400, body: { error: "invalid magnet or info hash" } }; return { status: 200, body: { ok: true, outcome } }; diff --git a/src/deps-pin.test.ts b/src/deps-pin.test.ts index 3c130566..8e7de36b 100644 --- a/src/deps-pin.test.ts +++ b/src/deps-pin.test.ts @@ -120,4 +120,3 @@ describe("node-datachannel prebuilt binaries", () => { } }); }); - diff --git a/src/download/create.test.ts b/src/download/create.test.ts new file mode 100644 index 00000000..e0132d00 --- /dev/null +++ b/src/download/create.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { createTorrentFor, torrentOptions, torrentPathFor } from "./create"; +import { magnetFromTorrentBytes } from "../sources/torrentFile"; + +async function fixture(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-create-")); + const content = path.join(dir, "album"); + await fs.mkdir(content); + await fs.writeFile(path.join(content, "one.txt"), "first\n"); + await fs.writeFile(path.join(content, "two.txt"), "second\n"); + return content; +} + +describe("torrentOptions", () => { + // create-torrent supplies its own announce list when given none, so passing + // an empty array would replace a working default with nothing. + it("omits announce entirely rather than passing an empty list", () => { + expect(torrentOptions([])).toEqual({}); + expect(torrentOptions(["udp://t.test:1337"])).toEqual({ announce: ["udp://t.test:1337"] }); + }); +}); + +describe("torrentPathFor", () => { + // Beside the data, not in a config dir: moving the files and leaving the + // torrent behind is how you end up with a magnet nobody can serve. + // Built natively rather than from POSIX literals: torrentPathFor resolves + // against the running platform, so "/srv/media" comes back as "C:\srv\media" + // on Windows and a hardcoded expectation fails there. + it("names the torrent after the content and puts it alongside", () => { + const media = path.resolve(path.join("srv", "media")); + expect(torrentPathFor(path.join(media, "album"))).toBe(path.join(media, "album.torrent")); + expect(torrentPathFor(path.join(media, "film.mkv"))).toBe( + path.join(media, "film.mkv.torrent"), + ); + }); +}); + +describe("createTorrentFor", () => { + it("writes a .torrent that parses back to the magnet it returns", async () => { + const content = await fixture(); + const created = await createTorrentFor(content, ["udp://tracker.test:1337/announce"]); + + expect(created.infoHash).toMatch(/^[0-9a-f]{40}$/); + expect(created.name).toBe("album"); + expect(created.magnet).toContain(created.infoHash); + + // The file on disk has to be the same torrent as the magnet describes. + const onDisk = await fs.readFile(created.torrentPath); + const reparsed = await magnetFromTorrentBytes(new Uint8Array(onDisk)); + expect(reparsed?.infoHash).toBe(created.infoHash); + }); + + /* + * The field that makes seeding existing data work at all. + * + * A torrent names its own top-level entry, so a client seeding + * /srv/media/album must be pointed at /srv/media. Point it at the album and + * it looks for album/album, finds nothing, and downloads a second copy + * beside the one already there. + */ + it("reports the content's parent as the directory to seed from", async () => { + const content = await fixture(); + const created = await createTorrentFor(content); + expect(created.contentDir).toBe(path.dirname(content)); + expect(created.contentDir).not.toBe(content); + }); + + // The announce list is the difference between a torrent anyone can find and + // a DHT-only one, so it has to survive into the magnet. + it("carries the trackers it was given into the magnet", async () => { + const content = await fixture(); + const created = await createTorrentFor(content, ["udp://tracker.test:1337/announce"]); + expect(decodeURIComponent(created.magnet)).toContain("udp://tracker.test:1337/announce"); + }); + + // A direct instruction from an operator, unlike a file appearing in a watch + // folder: failing quietly would leave them with no torrent and no reason. + it("throws on a path that is not there", async () => { + await expect(createTorrentFor("/nope/not/here")).rejects.toThrow(); + }); +}); diff --git a/src/download/create.ts b/src/download/create.ts new file mode 100644 index 00000000..2b2df92e --- /dev/null +++ b/src/download/create.ts @@ -0,0 +1,76 @@ +// Making a torrent out of a path you already have. +// +// Everything else in torlink starts from a magnet or a .torrent that someone +// else made: search finds one, the watch folder is handed one, the API is +// posted one. This is the other direction — you have the files, and you want a +// torrent of them — and it is the one thing the client could not do. +// +// It produces both halves on purpose. The .torrent is what lets torlink (and +// any other client) verify the data already on disk and go straight to seeding +// instead of fetching metadata from a swarm that has no seeds yet; the magnet +// is what you actually paste somewhere. + +import createTorrent from "create-torrent"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import { magnetFromTorrentBytes } from "../sources/torrentFile"; +import type { ParsedMagnet } from "../sources/magnet"; + +export interface CreatedTorrent extends ParsedMagnet { + // The bencoded .torrent, and where it was written. + torrentFile: Uint8Array; + torrentPath: string; + // The directory a client must be pointed at for the existing data to verify: + // the PARENT of the path, because a torrent names its own top-level entry. + contentDir: string; +} + +// A torrent with no announce list is a DHT-only torrent, which works but is +// slower to be found and invisible to anything watching a tracker. The caller's +// configured trackers are used when it has some; create-torrent supplies its +// own defaults otherwise. +export function torrentOptions(announce: string[]): { announce?: string[] } { + return announce.length > 0 ? { announce } : {}; +} + +// create-torrent is callback-shaped and hashes every piece, so this is the one +// genuinely slow step: a large directory is read end to end. +export function buildTorrent(target: string, announce: string[]): Promise { + return new Promise((resolve, reject) => { + createTorrent(target, torrentOptions(announce), (err: Error | null, torrent: Uint8Array) => { + if (err) reject(err); + else resolve(torrent); + }); + }); +} + +// Where the .torrent goes: beside the data, named after it. Next to the content +// rather than in a config directory because the two belong together — moving +// the files and leaving the torrent behind is how you end up with a magnet +// nobody can serve. +export function torrentPathFor(target: string): string { + const full = path.resolve(target); + return path.join(path.dirname(full), `${path.basename(full)}.torrent`); +} + +// Create a torrent for `target`, write it beside the data, and return both the +// magnet and everything a caller needs to seed it. Throws on a path that cannot +// be read: unlike the watch folder, this is a direct instruction from an +// operator, and failing quietly would leave them with no torrent and no reason. +export async function createTorrentFor( + target: string, + announce: string[] = [], +): Promise { + const full = path.resolve(target); + await fs.stat(full); + + const torrentFile = await buildTorrent(full, announce); + const parsed = await magnetFromTorrentBytes(torrentFile); + if (!parsed) throw new Error("created a torrent that could not be parsed back"); + + const torrentPath = torrentPathFor(full); + await fs.writeFile(torrentPath, torrentFile); + + return { ...parsed, torrentFile, torrentPath, contentDir: path.dirname(full) }; +} diff --git a/src/download/queue.getmetadata.test.ts b/src/download/queue.getmetadata.test.ts new file mode 100644 index 00000000..392138d8 --- /dev/null +++ b/src/download/queue.getmetadata.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DownloadQueue } from "./queue"; +import * as persist from "./persist"; +import { promises as fs } from "node:fs"; +import parseTorrent from "parse-torrent"; + +vi.mock("./engine", () => { + const mockEngine = { + getMetadata: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + }; + return { + TorrentEngine: vi.fn().mockImplementation(function() { return mockEngine; }), + message: vi.fn((e) => String(e)), + }; +}); + +vi.mock("./persist", () => ({ + torrentMetaExists: vi.fn(), + torrentMetaPath: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + promises: { + readFile: vi.fn(), + }, + existsSync: vi.fn(), + mkdirSync: vi.fn(), + renameSync: vi.fn(), +})); + +vi.mock("parse-torrent", () => ({ + default: vi.fn(), +})); + +describe("DownloadQueue getMetadata", () => { + let queue: DownloadQueue; + let engineMock: any; + + beforeEach(() => { + vi.clearAllMocks(); + queue = new DownloadQueue(); + engineMock = (queue as any).engine; + }); + + it("returns live metadata from engine if available", async () => { + const fakeMeta = { infoHash: "abc", name: "test", announce: [] }; + engineMock.getMetadata.mockReturnValue(fakeMeta); + + const result = await queue.getMetadata("abc"); + expect(engineMock.getMetadata).toHaveBeenCalledWith("abc"); + expect(result).toBe(fakeMeta); + }); + + it("parses .torrent file from disk if engine has no live meta", async () => { + engineMock.getMetadata.mockReturnValue(null); + vi.mocked(persist.torrentMetaExists).mockReturnValue(true); + vi.mocked(persist.torrentMetaPath).mockReturnValue("/tmp/test.torrent"); + + const fakeBuf = Buffer.from("test"); + vi.mocked(fs.readFile).mockResolvedValue(fakeBuf); + + vi.mocked(parseTorrent).mockReturnValue({ + infoHash: "abc", + name: "parsed name", + announce: ["http://tracker.org"], + length: 123, + } as any); + + const result = await queue.getMetadata("abc"); + expect(result).toMatchObject({ + infoHash: "abc", + name: "parsed name", + announce: ["http://tracker.org"], + length: 123, + }); + expect(fs.readFile).toHaveBeenCalledWith("/tmp/test.torrent"); + }); + + it("parses magnet URI if not in engine and no .torrent file", async () => { + engineMock.getMetadata.mockReturnValue(null); + vi.mocked(persist.torrentMetaExists).mockReturnValue(false); + + vi.mocked(parseTorrent).mockReturnValue({ + infoHash: "def", + name: "magnet name", + announce: ["udp://tracker2.org"], + } as any); + + const magnet = "magnet:?xt=urn:btih:def&dn=magnet+name"; + const result = await queue.getMetadata("def", magnet); + + expect(parseTorrent).toHaveBeenCalledWith(magnet); + expect(result).toMatchObject({ + infoHash: "def", + name: "magnet name", + announce: ["udp://tracker2.org"], + }); + }); + + it("returns null if no sources have metadata", async () => { + engineMock.getMetadata.mockReturnValue(null); + vi.mocked(persist.torrentMetaExists).mockReturnValue(false); + + const result = await queue.getMetadata("xyz"); + expect(result).toBeNull(); + }); +}); diff --git a/src/download/queue.metadata.test.ts b/src/download/queue.metadata.test.ts index 392138d8..51e21f77 100644 --- a/src/download/queue.metadata.test.ts +++ b/src/download/queue.metadata.test.ts @@ -1,109 +1,86 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { DownloadQueue } from "./queue"; -import * as persist from "./persist"; -import { promises as fs } from "node:fs"; -import parseTorrent from "parse-torrent"; +import { describe, it, expect, vi } from "vitest"; + +// What startEngine is handed is the whole point of these tests, so the engine +// is stubbed to record its `source` and `announce` arguments and the metadata +// store is stubbed to say whether a .torrent exists for an id. +const added: { id: string; source: string; announce?: string[] }[] = []; + +vi.mock("./engine", () => ({ + TorrentEngine: class { + add( + id: string, + source: string, + _dir: string, + _handlers: unknown, + announce?: string[], + ): void { + added.push({ id, source, announce }); + } + remove(): void {} + stats(): undefined { + return undefined; + } + destroy(): void {} + }, +})); -vi.mock("./engine", () => { - const mockEngine = { - getMetadata: vi.fn(), - add: vi.fn(), - remove: vi.fn(), - }; +vi.mock("./persist", async (importOriginal) => { + const actual = await importOriginal(); return { - TorrentEngine: vi.fn().mockImplementation(function() { return mockEngine; }), - message: vi.fn((e) => String(e)), + ...actual, + torrentMetaExists: (id: string) => id === "has-meta", + torrentMetaPath: (id: string) => `/meta/${id}.torrent`, + saveQueue: async () => {}, + saveSeeds: async () => {}, + saveHistory: async () => {}, }; }); -vi.mock("./persist", () => ({ - torrentMetaExists: vi.fn(), - torrentMetaPath: vi.fn(), -})); - -vi.mock("node:fs", () => ({ - promises: { - readFile: vi.fn(), - }, - existsSync: vi.fn(), - mkdirSync: vi.fn(), - renameSync: vi.fn(), -})); - -vi.mock("parse-torrent", () => ({ - default: vi.fn(), -})); - -describe("DownloadQueue getMetadata", () => { - let queue: DownloadQueue; - let engineMock: any; - - beforeEach(() => { - vi.clearAllMocks(); - queue = new DownloadQueue(); - engineMock = (queue as any).engine; +const { DownloadQueue } = await import("./queue"); + +const MAGNET = "magnet:?xt=urn:btih:0000000000000000000000000000000000000000"; + +describe("startEngine source selection", () => { + /* + * The difference between seeding and pretending to. + * + * A magnet carries no piece hashes, so a client handed one cannot verify a + * single byte until the swarm sends it metadata. For a torrent created from + * local content that swarm is empty (nobody has ever seen this info hash), so + * it waits forever on data that is already on the disk underneath it. Handed + * the stored .torrent it verifies locally and completes at once. + */ + it("prefers a stored .torrent over the magnet", () => { + added.length = 0; + const q = new DownloadQueue(); + q.add({ id: "has-meta", name: "local", magnet: MAGNET }, "/data"); + expect(added.at(-1)?.source).toBe("/meta/has-meta.torrent"); }); - it("returns live metadata from engine if available", async () => { - const fakeMeta = { infoHash: "abc", name: "test", announce: [] }; - engineMock.getMetadata.mockReturnValue(fakeMeta); - - const result = await queue.getMetadata("abc"); - expect(engineMock.getMetadata).toHaveBeenCalledWith("abc"); - expect(result).toBe(fakeMeta); - }); - - it("parses .torrent file from disk if engine has no live meta", async () => { - engineMock.getMetadata.mockReturnValue(null); - vi.mocked(persist.torrentMetaExists).mockReturnValue(true); - vi.mocked(persist.torrentMetaPath).mockReturnValue("/tmp/test.torrent"); - - const fakeBuf = Buffer.from("test"); - vi.mocked(fs.readFile).mockResolvedValue(fakeBuf); - - vi.mocked(parseTorrent).mockReturnValue({ - infoHash: "abc", - name: "parsed name", - announce: ["http://tracker.org"], - length: 123, - } as any); - - const result = await queue.getMetadata("abc"); - expect(result).toMatchObject({ - infoHash: "abc", - name: "parsed name", - announce: ["http://tracker.org"], - length: 123, - }); - expect(fs.readFile).toHaveBeenCalledWith("/tmp/test.torrent"); - }); - - it("parses magnet URI if not in engine and no .torrent file", async () => { - engineMock.getMetadata.mockReturnValue(null); - vi.mocked(persist.torrentMetaExists).mockReturnValue(false); - - vi.mocked(parseTorrent).mockReturnValue({ - infoHash: "def", - name: "magnet name", - announce: ["udp://tracker2.org"], - } as any); - - const magnet = "magnet:?xt=urn:btih:def&dn=magnet+name"; - const result = await queue.getMetadata("def", magnet); - - expect(parseTorrent).toHaveBeenCalledWith(magnet); - expect(result).toMatchObject({ - infoHash: "def", - name: "magnet name", - announce: ["udp://tracker2.org"], - }); + // The ordinary case: nothing downloaded yet, so a magnet is all there is. + it("falls back to the magnet when there is no stored .torrent", () => { + added.length = 0; + const q = new DownloadQueue(); + q.add({ id: "no-meta", name: "remote", magnet: MAGNET }, "/data"); + expect(added.at(-1)?.source).toBe(MAGNET); }); - it("returns null if no sources have metadata", async () => { - engineMock.getMetadata.mockReturnValue(null); - vi.mocked(persist.torrentMetaExists).mockReturnValue(false); - - const result = await queue.getMetadata("xyz"); - expect(result).toBeNull(); + /* + * A row merged from several sources carries every source's announce URLs on + * its magnet. Once the stored .torrent is what reaches webtorrent, that magnet + * is out of the picture, and the .torrent only knows the list it shipped with, + * so the merged trackers have to be passed as announce or the merge is undone + * on the first resume. + */ + it("carries the magnet's trackers even when the .torrent is used", () => { + added.length = 0; + const merged = `${MAGNET}&tr=${encodeURIComponent("udp://from-a.test:1337/announce")}&tr=${encodeURIComponent("udp://from-b.test:1337/announce")}`; + const q = new DownloadQueue(); + q.add({ id: "has-meta", name: "local", magnet: merged }, "/data"); + + expect(added.at(-1)?.source).toBe("/meta/has-meta.torrent"); + expect(added.at(-1)?.announce).toEqual( + expect.arrayContaining(["udp://from-a.test:1337/announce", "udp://from-b.test:1337/announce"]), + ); }); }); diff --git a/src/download/queue.ts b/src/download/queue.ts index 075d8df7..72616e86 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -15,6 +15,7 @@ import { import { saveHistory, saveHistorySync, type HistoryItem } from "./history"; import { deleteSeedData } from "./delete-data"; import { disarmBootMarker } from "./bootguard"; +import { trackersOf } from "../sources/magnet"; import type { DownloadStatus, QueueItem, @@ -196,7 +197,22 @@ export class DownloadQueue extends EventEmitter { if (item.name) migrateLegacyPathSync(item.dir, item.name, "Downloads"); const source = torrentMetaExists(item.id) ? torrentMetaPath(item.id) : item.magnet; try { - this.engine.add(item.id, source, getDownloadsDir(item.dir), this.engineHandlers(item.id), this.trackers, item.strategy); + // Prefer the stored .torrent over the magnet, exactly as startSeeding + // does. A magnet carries no piece hashes, so the client cannot verify a + // single byte until the swarm serves it metadata, which is merely slow + // for a popular torrent and terminal for one that nobody else has yet. + // With the metadata on disk it verifies the local files immediately, so + // a re-add of something already downloaded, and a torrent created from + // local content, both go straight to complete instead of waiting on + // peers that may not exist. + const source = torrentMetaExists(item.id) ? torrentMetaPath(item.id) : item.magnet; + // The magnet's own trackers ride along regardless of which source won. + // A row merged from several sources carries all of their announce URLs + // (see mergeMagnetTrackers), and a stored .torrent only knows the list it + // shipped with, so passing them explicitly is what keeps that merge from + // being undone on resume. webtorrent dedupes announce internally. + const announce = [...(trackersOf(item.magnet) ?? []), ...this.trackers]; + this.engine.add(item.id, source, getDownloadsDir(item.dir), this.engineHandlers(item.id), announce, item.strategy); } catch (e) { // engine.add routes webtorrent's own synchronous failures through // onError, so the only throw that reaches here is the client failing to diff --git a/src/index.tsx b/src/index.tsx index 35709ee8..35c6186a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -28,6 +28,7 @@ const isHeadless = cmd.kind === "update" || cmd.kind === "search" || cmd.kind === "watch" || + cmd.kind === "seed" || cmd.kind === "serve" || cmd.kind === "files"; @@ -58,6 +59,13 @@ if (cmd.kind === "update") { void import("./daemon/watch").then(({ runWatch }) => runWatch(dir, downloadDir, { seedTimeMs, deleteFiles }).catch(failHeadless), ); +} else if (cmd.kind === "seed") { + if (cmd.daemon) daemonize("seed"); + const { path: target, seedTimeMs, deleteFiles } = cmd; + void import("./daemon/seed") + .then(({ runSeed }) => runSeed(target, { seedTimeMs, deleteFiles })) + .then(() => process.exit(0)) + .catch(failHeadless); } else if (cmd.kind === "serve") { if (cmd.daemon) daemonize("serve"); const options = { @@ -68,7 +76,10 @@ if (cmd.kind === "update") { seedTimeMs: cmd.seedTimeMs, deleteFiles: cmd.deleteFiles, }; - void import("./daemon/serve").then(({ runServe }) => runServe(options).catch(failHeadless)); + void import("./daemon/serve") + .then(({ runServe }) => runServe(options)) + .then(() => process.exit(0)) + .catch(failHeadless); } else if (cmd.kind === "files") { if (cmd.daemon) daemonize("files"); const options = { diff --git a/src/sources/eztv.test.ts b/src/sources/eztv.test.ts index b0df1b21..9fc9909e 100644 --- a/src/sources/eztv.test.ts +++ b/src/sources/eztv.test.ts @@ -89,7 +89,6 @@ describe("toResult", () => { expect(toResult({ ...ROW, hash: "", magnet_url: "" })).toBeNull(); }); }); - describe("eztv search", () => { it("asks for one page and nothing else when the query is empty", async () => { respond(() => [JUDY]); diff --git a/src/sources/magnet.test.ts b/src/sources/magnet.test.ts index 734ea2fd..b41ebea8 100644 --- a/src/sources/magnet.test.ts +++ b/src/sources/magnet.test.ts @@ -157,4 +157,3 @@ describe("mergeMagnetTrackers", () => { expect(mergeMagnetTrackers(primary, ["nonsense", ""])).toBe(primary); }); }); - diff --git a/src/sources/magnet.ts b/src/sources/magnet.ts index bf227998..111850f2 100644 --- a/src/sources/magnet.ts +++ b/src/sources/magnet.ts @@ -97,7 +97,10 @@ export function parseInput(input: string): ParsedMagnet | null { return { infoHash, name: infoHash, magnet: buildMagnet(infoHash, infoHash) }; } -function trackersOf(magnet: string): string[] | null { +// Exported for the queue: when a download resumes from its stored .torrent the +// magnet is no longer what reaches webtorrent, so the trackers merged onto it +// from sibling sources have to be handed over separately. +export function trackersOf(magnet: string): string[] | null { const s = magnet.trim(); if (!/^magnet:\?/i.test(s)) return null; try { @@ -129,4 +132,3 @@ export function mergeMagnetTrackers(primary: string, others: string[]): string { if (extra.length === 0) return primary; return primary.trim() + extra.map((t) => `&tr=${encodeURIComponent(t)}`).join(""); } - diff --git a/src/sources/piratebay.test.ts b/src/sources/piratebay.test.ts index d1441f1c..81697b00 100644 --- a/src/sources/piratebay.test.ts +++ b/src/sources/piratebay.test.ts @@ -73,7 +73,6 @@ describe("apibay sentinel retry", () => { expect(askedUrl(0)).toContain("/precompiled/"); }); }); - // Field names and value shapes are verbatim from an apibay q.php response. const ROW = { id: "10944926", diff --git a/src/sources/torrentFile.ts b/src/sources/torrentFile.ts index 321f2cb8..336c5b6a 100644 --- a/src/sources/torrentFile.ts +++ b/src/sources/torrentFile.ts @@ -7,24 +7,40 @@ import { buildMagnet, type ParsedMagnet } from "./magnet"; // image dropped in the watch folder from being pulled into memory whole. const MAX_TORRENT_BYTES = 16 * 1024 * 1024; +// The same read, from bytes already in hand rather than a path. The HTTP add +// API needs this: it accepts an uploaded .torrent, and must never be able to +// point the daemon at a local file (see runtime.ts's allowTorrentPath). +export async function magnetFromTorrentBytes(bytes: Uint8Array): Promise { + try { + if (bytes.length === 0 || bytes.length > MAX_TORRENT_BYTES) return null; + return await readParsed(bytes); + } catch { + return null; + } +} + export async function magnetFromTorrentFile(path: string): Promise { try { const stat = await fs.stat(path); if (!stat.isFile() || stat.size === 0 || stat.size > MAX_TORRENT_BYTES) return null; const buf = await fs.readFile(path); - const parsed = await parseTorrent(new Uint8Array(buf)); - const infoHash = parsed?.infoHash?.toLowerCase(); - if (!infoHash) return null; - const name = parsed.name || infoHash; - // Carry the file's own announce list into the magnet. Without it a torrent - // that isn't on the public DHT — a private tracker, a small private swarm — - // sits at zero peers forever, and on a private tracker the passkey that - // makes an announce work at all lives in that URL. - const announce = Array.isArray(parsed.announce) - ? parsed.announce.filter((url): url is string => typeof url === "string") - : []; - return { infoHash, name, magnet: buildMagnet(infoHash, name, announce) }; + return await readParsed(new Uint8Array(buf)); } catch { return null; } } + +async function readParsed(bytes: Uint8Array): Promise { + const parsed = await parseTorrent(bytes); + const infoHash = parsed?.infoHash?.toLowerCase(); + if (!infoHash) return null; + const name = parsed.name || infoHash; + // Carry the file's own announce list into the magnet. Without it a torrent + // that isn't on the public DHT — a private tracker, a small private swarm — + // sits at zero peers forever, and on a private tracker the passkey that + // makes an announce work at all lives in that URL. + const announce = Array.isArray(parsed.announce) + ? parsed.announce.filter((url): url is string => typeof url === "string") + : []; + return { infoHash, name, magnet: buildMagnet(infoHash, name, announce) }; +} diff --git a/src/ui/App.shortcuts.test.tsx b/src/ui/App.shortcuts.test.tsx index d384e554..62654dde 100644 --- a/src/ui/App.shortcuts.test.tsx +++ b/src/ui/App.shortcuts.test.tsx @@ -173,4 +173,17 @@ describe("App Keyboard Shortcuts", () => { stdin.write("\x1b"); await new Promise((r) => setTimeout(r, 50)); }); + + it("handles the 'Q' shortcut to toggle auto-close mode", async () => { + const { stdin } = render(); + await new Promise((r) => setTimeout(r, 50)); + + // Press 'Q' to toggle auto-close on + stdin.write("Q"); + await new Promise((r) => setTimeout(r, 20)); + + // Press 'Q' again to toggle auto-close off + stdin.write("Q"); + await new Promise((r) => setTimeout(r, 20)); + }); }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 234c542f..0876db18 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -138,6 +138,8 @@ export function App({ }, []); const [updateVersion, setUpdateVersion] = useState(null); + const [autoClose, setAutoClose] = useState(false); + const autoCloseTimerRef = useRef(null); const [recovered, setRecovered] = useState(false); const booting = useRef(false); @@ -223,39 +225,59 @@ export function App({ }; }, []); + const quitAll = useCallback(() => { + if (autoCloseTimerRef.current) { + clearTimeout(autoCloseTimerRef.current); + autoCloseTimerRef.current = null; + } + // Flush all state synchronously up front so nothing is lost to the hard + // exit; the unmount effect still runs suspend() for the engine teardown. + autoDownloader?.stop(); + queue?.persistSync(); + if (onQuit) onQuit(); + else exit(); + }, [queue, autoDownloader, onQuit, exit]); + useEffect(() => { if (!queue) return; - const onCompleted = (name: string): void => + const onCompleted = (name: string): void => { setNotice(`${ICON.done} ${truncate(cleanText(name), 40)}`); + if (autoClose) { + const remaining = queue.getItems().some( + (item) => item.status === "downloading" || item.status === "queued" + ); + if (!remaining) { + setNotice("All downloads completed · Closing app in 2s..."); + if (autoCloseTimerRef.current) clearTimeout(autoCloseTimerRef.current); + autoCloseTimerRef.current = setTimeout(() => { + quitAll(); + }, 2000); + } + } + }; queue.on("completed", onCompleted); return () => { queue.off("completed", onCompleted); }; - }, [queue]); + }, [queue, autoClose, quitAll]); useEffect(() => { setInspectingId(null); setInspectingPeersId(null); }, [section]); - useEffect( () => () => { + if (autoCloseTimerRef.current) { + clearTimeout(autoCloseTimerRef.current); + autoCloseTimerRef.current = null; + } autoDownloader?.stop(); queue?.suspend(); }, [queue, autoDownloader], ); - const quitAll = useCallback(() => { - // Flush all state synchronously up front so nothing is lost to the hard - // exit; the unmount effect still runs suspend() for the engine teardown. - autoDownloader?.stop(); - queue?.persistSync(); - if (onQuit) onQuit(); - else exit(); - }, [queue, autoDownloader, onQuit, exit]); - const setConfig = useCallback( (c: Config) => { setConfigState(c); @@ -284,6 +306,18 @@ export function App({ setConfig({ ...config, throttleEnabled: !config.throttleEnabled }); }, [config, setConfig]); + const toggleAutoClose = useCallback(() => { + if (autoCloseTimerRef.current) { + clearTimeout(autoCloseTimerRef.current); + autoCloseTimerRef.current = null; + } + setAutoClose((prev) => { + const next = !prev; + setNotice(next ? "Auto-close enabled · will close when downloads finish" : "Auto-close disabled"); + return next; + }); + }, []); + useEffect(() => { if (queue && config) { queue.setThrottle(config.throttleEnabled, config.throttleDownloadLimit, config.throttleUploadLimit); @@ -622,6 +656,8 @@ export function App({ setInspectFocusSelected, toggleFileSelection, toggleThrottle, + autoClose, + toggleAutoClose, quitAll, listRows, compact, @@ -660,6 +696,8 @@ export function App({ inspectingMetaMagnet, toggleFileSelection, toggleThrottle, + autoClose, + toggleAutoClose, listRows, compact, contentWidth, @@ -775,6 +813,10 @@ export function App({ quitAll(); return; } + if (input === "Q") { + toggleAutoClose(); + return; + } if (input === "b") { store?.toggleThrottle(); return; @@ -812,6 +854,7 @@ export function App({ {store.config.throttleEnabled ? 🐢 Throttled : null} {store.config.webServerEnabled ? 🌐 http://localhost:{store.config.webServerPort} : null} + {store.autoClose ? ⏱ Auto-close : null} {notice ? ( @@ -921,7 +964,7 @@ export function App({ {showFooter ? ( -