Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ce40946
chore: add ThreatCrush security scan on pull requests (#136)
ralyodio Aug 3, 2026
7b29746
Highlight Selected Items (#130)
staticaron Aug 5, 2026
ace41fb
fix: keep a torrent's own trackers in the magnet built from its file …
ugurckr Aug 13, 2026
38f84f4
feat: accept a .torrent dragged onto the search field (#147)
ugurckr Aug 13, 2026
d5e75cb
fix: retry apibay search when it answers with its no-results sentinel…
zanmlakar Aug 15, 2026
205cabb
chore: bump to 1.7.0 and close the category and source sets in CONTRI…
baairon Aug 19, 2026
9d89265
fix: take node-datachannel's prebuilt binaries instead of its install…
ugurckr Aug 28, 2026
0cb8ca2
fix: keep an unreadable feed date out of the sort comparator (#174)
ugurckr Aug 28, 2026
67fd426
fix: fall back to OSC 52 when no clipboard helper can be reached (#176)
ugurckr Aug 28, 2026
476782e
fix: make EZTV answer a search instead of returning nothing (#177)
ugurckr Aug 28, 2026
f178b42
fix: stop the search cache growing without a ceiling (#179)
ugurckr Aug 28, 2026
2ede3e4
fix: merge duplicate rows instead of dropping the losers' trackers (#…
ugurckr Aug 28, 2026
833dadb
feat: add TORLINK_NO_WEBRTC to turn the WebRTC stack off per machine …
ugurckr Aug 28, 2026
8b1df42
feat: add headless search command (#180)
funsaized Aug 28, 2026
f187dd6
fix(sources): normalize base32 infohashes in 1337x scraper (#181)
kaiserc Aug 29, 2026
bafc4e9
fix: exit after headless server shutdown (#183)
funsaized Aug 29, 2026
4c52452
feat: seed a local path and share it (#184)
ralyodio Aug 29, 2026
918d3f6
fix: seed shutdown, the serve body cap, and trackers on resume
baairon Aug 29, 2026
e07572e
ci: keep a run for every commit pushed to main
baairon Aug 30, 2026
d26d55d
feat(ui): add auto-close toggle when all torrent downloads complete
kaiserc Sep 2, 2026
0d031a8
Merge upstream v1.8.0 into feature/sync-upstream-v1.8.0
kaiserc Sep 6, 2026
2f9bb4b
feat(ui): add auto-close toggle when all torrent downloads complete
kaiserc Sep 6, 2026
9f2ab93
Merge branch 'feat/auto-close-on-complete' of https://github.com/kais…
kaiserc Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
291 changes: 291 additions & 0 deletions .github/workflows/threatcrush-scan.yml
Original file line number Diff line number Diff line change
@@ -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.',
);
}
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 <query> print search results as JSON
klink search "<query>" [--category games|movies|tv|anime|ebooks|audiobooks]
print one JSON document of merged search results
klink seed <path> share files you already have
klink watch <dir> 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":"<base64>"}

## Contributing

Expand Down
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading