Skip to content

Feat: Convert URLs in torrent comments into clickable links - #3138

Closed
jakobbg wants to merge 132 commits into
Novik:developfrom
jakobbg:master
Closed

Feat: Convert URLs in torrent comments into clickable links#3138
jakobbg wants to merge 132 commits into
Novik:developfrom
jakobbg:master

Conversation

@jakobbg

@jakobbg jakobbg commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Description

This PR updates the torrent description view to automatically identify plaintext URLs (http, https, and ftp) within the torrent comments and convert them into clickable HTML hyperlinks.

Changes

  • Created a safe, type-hinted helper function getClickableComment() to handle URL regex replacement.
  • Sanitizes raw user input with htmlspecialchars() before parsing links to prevent XSS vulnerabilities.
  • Applied secure link attributes (target="_blank" and rel="noopener noreferrer") so links safely open in a new tab without navigating away from the application.

Testing Instructions

  1. View a torrent containing a plain URL in its comment (e.g., Trump: http://torrent.site/torrent.php?id=12345).
  2. Verify that the URL turns into a clickable link.
  3. Confirm that clicking the link opens the target page in a new browser tab.
  4. Verify that normal text in the comment remains unaffected and safe.

dependabot Bot and others added 30 commits June 15, 2026 17:43
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.22.9 to 7.29.6.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.6/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.29.6
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
#3066 fixed the ratio plugin on rtorrent 0.16.x by always sending
(target_string, value) to group.NAME.ratio.{min,max,upload}.set,
which is what the strict 0.16 signature requires. On older rtorrent
the same set commands are the opposite — strictly a bare value, and
the (target, value) shape is rejected with "Wrong object type".

Encapsulate the call-shape adaptation in
rTorrentSettings::getRatioGroupCommand so callers (ratio plugin
flush(), but also any future consumer) do not have to know which
signature applies. The helper:

* always uses the canonical group.* prefix. group2.* was a transient
  alias name: stubbed and non-functional on 0.15.x and removed in
  0.16. The old "if iVersion >= 0x904 and cmd in ratioCmds use
  group2." branch fell into both broken cases and produces fewer
  successful calls than the unconditional group.* path on every
  rtorrent version verified.

* prepends an empty-string target to *.set commands only when
  iVersion >= 0x1000, so 0.15.x callers continue to receive the
  single-value shape they require and 0.16+ callers receive the
  (target, value) shape they require.

ratio.php is reverted to passing the bare value to the helper for
the .set commands; the helper does the version-aware wrapping.

The unused $ratioCmds whitelist is dropped — only the group2.*
branch consulted it, and that branch is gone.

Reported by @ml-1 in #3065 after upgrading to v5.3.3 with rtorrent
0.15.7.
"Remove and delete data" removed the torrent but left the files on disk on
rtorrent builds that do not register file.append.

The removewithdata handler set d.custom5 and relied on the erasedata
event.download.erased handler to record the file list via file.append. On
rtorrent 0.16.x, file.append is only registered when method.use_deprecated is
enabled at command-registration time at startup; the rc file's
method.use_deprecated.set runs too late and only the -D launch flag turns it
on in time. With a stock launch the method is absent, so the erase event
records nothing and the data is never queued for deletion. d.erase is
independent of that event, so the torrent still disappears and the data is
silently orphaned.

Record the file list in PHP instead, using only canonical commands
(d.base_path, d.is_multi_file, f.multicall/f.frozen_path), and write the
erasedata .list file directly. This works on every rtorrent version. Because
rXMLRPCRequest flattens all returned values into ->val, query one torrent per
request so the file list can be split back out per torrent. Clear d.custom5 so
the legacy event stays inactive on builds that still provide file.append (the
list written here is authoritative), avoiding a double delete. The existing
erasedata garbage collector removes the listed files on its next scheduled run.
The download-erased event recorded the delete list via file.append (and a
fin.sh helper), which is unavailable on rtorrent builds that do not enable
method.use_deprecated. removewithdata now records that list over RPC, so the
event is dead weight on every version. Keep only the scheduled garbage
collector that applies the lists; drop the event registration/teardown and
the now-unused fin.sh helper.
 Update for magnet bulk add
This file was removed in commit 54165cb, but the stale reference means
erasedata still looks for it at launch, and fails to launch if its not
detected. Since this is no longer used anywhere, its safe to remove.
…ting

Since raw XMLRPC from the client is now forwarded as untrusted (subject
to rtorrent's command whitelist), the throttle plugin's per-torrent
apply broke: it built d.stop / d.set_throttle_name / d.start commands
in the browser and sent them as raw RPC. Making that work again would
mean whitelisting stop/start/throttle-name for all untrusted callers.

Instead, route the action through the plugin's own action.php: the
client posts only the throttle value and a hash list, and the new
rThrottle::apply() validates each hash (40 hex chars) and issues the
commands over the trusted connection
Fixes a bug where the 'Save To' context menu item triggers the
'TypeError: can't access property "trim", d.savepath is undefined' when
used on an uninitiated torrent. We solve this by falling back on
base_path, which is set even for uninitialised torrents.
A user-run diagnostic that verifies the host meets ruTorrent's requirements
and flags common install/config mistakes. It is never called at runtime.

It runs from the command line only (`php env_check.php`, exits 0/1) and refuses to
run under any web SAPI: PHP_SAPI is fixed by how PHP was invoked and cannot be
influenced by an HTTP request, so it returns 403 and leaks nothing over the web
even if left in a web-served directory (the built-in `php -S` server reports
"cli-server", not "cli", and is refused too). It's self-contained -- the only
ruTorrent file it reads is conf/config.php -- so it still runs when a missing
prerequisite has otherwise broken ruTorrent.

Checks:
* PHP >= 7.4 (arrow functions are used in the codebase) and required extensions
  (json, pcre) + fsockopen for SCGI.
* Recommended extensions (simplexml, curl, mbstring, zlib) and external programs
  (php, curl, gzip).
* Configuration: $scgi_host/$scgi_port coherence, $XMLRPCMountPoint present,
  $topDirectory exists/readable, $log_file and $tempDirectory writable, share/
  writable.
* rtorrent: connects over SCGI and reports the version, classified against the
  supported set -- 0.9.8 (the tracker baseline) and the 0.16.x series are
  supported; anything else is flagged "may or may not work".

The pure decision logic (version support policy, PHP minimum, SCGI/config
validation) lives in a Requirements class that the live probing sits on top of,
and is covered by tests/php/RequirementsTest.php.

Motivated by #2996: users on unsupported PHP/rtorrent currently get a silent
break with no indication of the cause.
…epair

UTF::utf8ize previously attempted to "repair" as many mangled strings
as possible, but I've observed a number of examples over the years where
it choked and broke rutorrent.

I would argue that taking arbitrary strings of bytes, and attempting
to convert them with perfect accuracy into valid UTF8 (when those strings
come to us mangled, corrupt and always with unknown encodings) isn't possible.

Here's a handful of examples of of byte sequences that choke the UTF8 repair code:

```php
require('php/utility/json.php');
var_dump(JSON::safeEncode("\xC0\x80"));
var_dump(JSON::safeEncode("\xED\xA0\x80"));
var_dump(JSON::safeEncode("\xED\xBF\xBF"));
var_dump(JSON::safeEncode("\xF5\x80\x80\x80"));
var_dump(JSON::safeEncode("\xF7\xBF\xBF\xBF"));
```

The problem is that I think this list is nowhere near comprehensive, and
even if we fix all of these, there will be many more corrupt sequences in
the wild we can't predict.

Instead of trying to repair all this corrupt data, let's just use the xFFFD
replacement character. Users who add files with corrupt strings will see the
replacement character (adding some awareness), but that becomes a content
problem and not a "ruTorrent is broken" problem.
Update JSON::safeEncode to sanitize, rather than attempting convert/repair
rTask::run() launches a task by asking rtorrent to run it
(execute.nothrow.bg). That is fine for a task started from a web
request, while rtorrent is idle. But a task started from a script that
rtorrent invoked through an event handler's blocking `execute` -- e.g.
the unpack plugin's update.php, registered on event.download.finished --
calls back into rtorrent while its single main thread is still blocked
inside that same handler. The nested call cannot be serviced, so it
hangs until the RPC socket times out; the client is frozen for that
whole window and the half-created task is then discarded, so nothing
runs. This is why automatic unpacking silently does nothing (and the
UI stalls on completion) while manual unpacking, a plain web request,
works.

Detect that context via the CLI SAPI -- an event-handler script runs
under php-cli, a web request does not -- and in that case launch the
task directly (FLG_RUN_AS_WEB) rather than re-entering rtorrent. The
script already runs as rtorrent's user, so the forked task keeps the
same ownership.

Fixes #2976.
_task: run event-triggered tasks directly instead of via rtorrent
Add php/methods-0.16.16.php with aliases for commands removed or
deprecated in rTorrent 0.16.16:
- Proxy addresses: network.http.proxy_address → network.proxy.http,
  network.proxy_address → network.proxy.global
- Socket queries: network.open_sockets → system.sockets.size,
  network.max_open_sockets → system.sockets.max_size
- d.multicall2 → d.multicall
- HTTP cap: network.http.max_total_connections → system.sockets.http.min_alloc
- Open files: network.max_open_files.set → system.sockets.files.min_alloc.set

Update settings.php to load the new file at iVersion >= 0x1010.
Update js/content.js with matching client-side aliases.
…6.x)

setHandlers() registers event.download.inserted handlers that capture the
matched ratio group / throttle channel into a custom field and then apply it
with:

    branch=$not=$d.get_custom=x-extratioN,,<...>,<apply>=$d.get_custom=x-extratioN

$not=$d.get_custom=... is evaluated before branch runs, so branch gets the
value (e.g. "rat_3") as its condition. On 0.16.x `not` no longer treats a
non-empty non-numeric string as true, so the expression faults and branch
never runs the apply command: the custom field is set but the torrent is
never added to the ratio view / given the throttle channel. The rule
silently does nothing on newly added torrents, while manual assignment (a
different code path) still works.

Use the command itself as the branch condition instead of a pre-substituted
value: branch=d.get_custom=x-extratioN,<apply>=$d.get_custom=x-extratioN.
branch executes d.custom and treats the non-empty result as true, running the
apply command when a rule matched and skipping it otherwise. Also removes the
now-unused d.views.has scaffolding.

Verified on rtorrent 0.16.17: before, a matching torrent received the custom
field but stayed out of the ratio view; after, it is added to the view on
insert.

Fixes #3076.
extratio: fix ratio/throttle rules not applied on torrent insert (0.16.x)
The 5.x restyle increased row spacing compared with 4.x -- extra per-cell
vertical padding/margin in the torrent list, and roomier sidebar rows --
which several users found too spacious (#2995). Add an opt-in "Compact
list rows" toggle under Settings > General > User Interface.

When enabled it sets a `compact` class on <body> and:
  - zeroes the torrent list cells' vertical padding/margin, and
  - tightens the sidebar (panel-label rows and category-panel headings,
    reached through the light DOM / ::part()).
Status icons are left at their native size, so nothing is clipped.

Off by default. Mirrors the existing alternate_color option (default in
webui.js, apply-on-load, on-change handler, options-window row) and adds
the Compact_rows string translated across all bundled languages.
xombiemp and others added 26 commits July 23, 2026 16:42
The unpause action sent only d.resume, which restarts data transfer
but leaves d.state=0 from pause's d.stop. ruTorrent flags any open
torrent with d.state=0 as paused, so an unpaused torrent stays shown
as "Pausing" forever while data flows, and rtorrent treats it as
user-stopped on its next restart.

Send d.start instead, matching the direct XML-RPC mount's unpause stub
(rTorrentStub.prototype.unpause in js/rtorrent.js).

Repro on a stock install with httprpc: click the toolbar Pause button
twice on a downloading torrent - transfers resume but the status stays
"Pausing" until the torrent is started and paused again.
… testing-round fixes

Navigation (per review): every back/cancel/OK control used
history.go(-1), which walks the browser's global history and can land
outside the app or full-page-reload a stale URL. Controls now call the
app's own navigation: a goBack() that knows each page's parent, and a
navbar home button (house icon, replacing the back arrow) that always
returns to the torrent list. Hardware/browser back still works through
the existing hashchange listener. All bare buttons got type="button".

Behavior fixes found while testing the round:
- Restore the list scroll position after the hash update (assigning an
  empty fragment scrolls to top), and reset it when the sort or filter
  actually changes, since the old position is meaningless then
- Make the filter page transactional: taps still apply live, OK commits,
  and leaving any other way rolls the changes back; show the matched
  count / total size on the filter page itself
- Neutralize sticky :hover on touch devices, which left tapped buttons
  looking permanently pressed (Bootstrap's outline-button hover is a
  filled state)
- Return to the list after a successful torrent add (errors keep the
  form for correction)
- Scope toast dismiss timers to their own element - a previous toast's
  timers could hide the current one early - and position toasts below
  the top edge so iOS doesn't extend their color into the status bar
- Route noty() notifications into mobile toasts, and toast deleted
  torrents (detected from the list diff; the desktop gets this from the
  history plugin, which is disabled on mobile)
- Truncate file percentages like the desktop does (theConverter.round
  floors) instead of rounding
- Shrink the filter tab's tap target to its content, so taps on the
  empty tab-bar space no longer open the filter page
- Show the full content path (base_path) as Save As on the General tab,
  like the desktop details; the move form still prefills the directory
- Live-refresh the settings page speed-limit selects when the limits
  change elsewhere (e.g. from the desktop UI)
- Treat OK without changes on the save-path page as cancel; the datadir
  worker stops and closes the torrent even when there is nothing to move
After a successful add, the list scrolls to the new torrent's row once
it appears (magnet and URL adds can take a few polls to materialize;
the intent expires after 30s) and fades an accent-colored highlight on
it. Detection rides the existing per-cycle row diff, picking the
topmost added row in display order when several arrive at once, and is
skipped when the user has navigated away or the active filter hides
the row. The highlight is an inset box-shadow on the cell: it renders
above the table striping, and row-level classes are reset by every
row update.
…cation)

The bundled JShrink minifier's saveRegex() treated the first unescaped '/'
as the end of a regex literal, but a '/' inside a character class ([...]) is
a literal slash. So a pattern like /[^/]/ was cut mid-regex and the rest of
the file mangled, producing an "unterminated regular expression literal"
SyntaxError in the browser.

getplugins.php minifies the entire concatenated plugin bundle in a single
pass, so one such regex in any plugin breaks the whole minified bundle and
leaves the UI unable to load (empty torrent list). Several bundled plugins
use class-internal slashes, e.g. /themes\/([^/]+)\// and the [^:/?#]
announce-URL parser.

JShrink v1.8.1 tracks the character class and only ends the regex on a '/'
outside a class. It is namespaced (JShrink\Minifier), so getplugins.php now
references \JShrink\Minifier::minify(); the class autoloader already strips
namespaces, so nothing else needs to change.

Adds tests/php/MinifierTest.php locking in the character-class behaviour and
guarding plain regexes and division operators from being mis-parsed.
…er-class

Update vendored JShrink to v1.8.1 (fixes regex character-class minification)
The PHP tests under tests/ were never executed in CI -- only the Jest (JS)
suite ran. Wire the PHP suite in and make it fail the build on failures:

- tests.yml: add a `php` job (setup-php 8.1) that runs tests/php-test.sh.
- php-test.sh: exit non-zero when any test file reports a failure. Two
  signals are honoured -- a non-zero process exit (the TestLib self-running
  suites end with exit($failures)) and failure output (the TestCase runner
  only prints Passed/Failed). Previously the script never gated, so failures
  were invisible to CI.
- PermissionTest::testPermission hardcoded uid 1000, so it only passed when
  the tests happened to run as uid 1000 -- never true in CI (uid 1001) or on
  most machines. Probe with the running process's own uid/gids instead.
CI: run the PHP test suite (and make it gate)
httprpc: restore d.state on unpause so torrents leave the paused state
A search icon right of the sort icon toggles a search bar fixed below
the tab bar; typing filters the list live with the same semantics as
the desktop quick search (case-insensitive substring on the name, * as
a wildcard, 220ms debounce; see TextSearch in js/panel.js). The search
narrows whatever the status/label/tracker filters matched, so the
filter chip and the filter page totals reflect it, and the chip shows
the active term in quotes. Closing the bar (its X button or the search
icon) clears the search so the list can't stay invisibly narrowed.

iOS details: the bar is tucked 1px under the measured tab-bar height
so fractional heights can't leave a see-through gap, and the keyboard
is dismissed when a scroll gesture starts, since position:fixed
elements drift off-screen while it's open (touchmove, not scroll:
Safari's scroll-into-view on focus would close it as it opens).
The markup was fetched without a cache-buster, so after an update the
browser could keep serving a stale cached mobile.html while the new
init.js loads through the plugin loader's ?v= token - new code running
against old markup with missing element ids. Use the core's
cacheBust() for the fetch, so the markup stays cached between releases
and refreshes together with the code on a version bump.
Add the mobile plugin as a bundled plugin
Bumps js/webui.js to 5.3.10 and every ?v= asset token (539 -> 5310) in
index.html and lang/langs.js, so cached CSS/JS/lang refresh on upgrade.
Bump version to 5.3.10 and update cache-bust params
processTorrents() read the desktop categoryList "plabel" panel
unconditionally to build the label filter list. That access threw a
TypeError in two real situations, aborting the function before any row
was rendered and leaving the torrent list blank (the error was swallowed
by the update request's empty error handler):

  - the panel is not always populated when the mobile view first paints;
  - the "no label" bucket ("-_-_-nlb-_-_-") only exists when at least one
    torrent is unlabelled, so it is missing when every torrent has a
    label -- a common configuration.

Guard both reads: fall back to a zero "no label" count and skip the
label loop when the panel is absent. When the panel is present the
behaviour is unchanged.
- Fix the empty torrent list on the direct XML-RPC mount (reported on
  the bundling PR): the tracker fetch sent ?action=getalltrackers with
  no hashes. httprpc treats that as "all torrents", but the direct
  mount's stub builds one t.multicall per given hash and silently
  sends nothing for an empty list - its callback never fires, and the
  list render lives in that callback. Name every hash explicitly, like
  the desktop's getAllTrackers, and short-circuit the zero-torrent
  case (the render also removes deleted torrents' rows, so it must
  still run)
- Insert torrents that appear between full renders (e.g. added from
  another session) at their sorted position instead of appending them
  to the end of the list
- Toast torrents added from elsewhere (another session, rss,
  automation), like the deletion toasts; a local add keeps its upload
  receipt and scroll-to-torrent instead. Skip the first render cycle,
  when every torrent counts as new
- Dismiss the on-screen keyboard on scroll for all text fields, not
  just the quick search
- Commit a new-label edit only via its check button or the return key;
  tapping or scrolling away now cancels instead of saving
plugin.request drops timeouts and errors by design (the next poll
retries), and an exception thrown in a response handler died invisibly
inside the ajax machinery. Each of the recent blank-torrent-list bugs
was hidden by exactly this silence. Log all three cases to the console
with the request that owned them - diagnostic breadcrumbs only, no
user-facing noise.
Per review: the list fetch bypassed plugin.request (so a throwing
processTorrents stayed invisible there), and the addTorrents hook
isn't a request at all. Route the fetch through plugin.request and
give the hook its own try/catch, so every path into processTorrents
leaves a named console breadcrumb instead of a silent blank list.
plugin.theme picks the color scheme, with a live try-before-you-buy
selector on the settings page like the accent one; 'system' tracks the
device's preference live. Everything themes off Bootstrap's
data-bs-theme, with the hardcoded light colors in mobile.css moved to
the corresponding CSS variables.

The near-black accent would vanish on the dark theme's near-black
background, so there it resolves to its counterpart, near-white
(it's relabeled "Contrast" to match), with the progress-bar stripes,
progress labels and selected-filter text flipping dark to stay
legible on the near-white fills. Progress labels also gain a bolder
weight across all themes.
mobile: log swallowed request failures to the console
mobile: don't blank the torrent list when the label panel is incomplete
Naming every hash regressed large lists on transports that carry the
query string in the request line: at ~950 torrents the URL outgrows
nginx's default buffers and is 414-rejected before PHP could collapse
it (stock httprpc was unaffected — its stub override collapses >50
hashes to the hashless form and POSTs a body either way).

The PHP transports all treat a hashless getalltrackers as "all
torrents", which is exactly what this fetch wants, so send that form.
Enumerate the hashes only when a probe shows the active transport is
the base XML-RPC stub — the one case that silently drops the hashless
form, and the bug the enumeration was added for. There the hashes
travel as POSTed XML with the stub's existing fragmentation, so no
list size can overflow anything.
mobile: fix the empty torrent list on the direct XML-RPC mount, plus a round of fixes
mobile: add a theme setting: light, dark, or follow the system
@jakobbg
jakobbg changed the base branch from master to develop July 28, 2026 10:34
@jakobbg
jakobbg marked this pull request as draft July 28, 2026 10:38
@jakobbg

jakobbg commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Closing PR, will redo against develop. Sorry for this.

@jakobbg jakobbg closed this Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants