Compare commits

..
Author SHA1 Message Date
Levi cc96cff2ea Merge PR #1583: optimize extension API loading
# Conflicts:
#	ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart
2026-10-10 17:40:56 +08:00
Levi 15738644b0 perf(extensions): load store summaries without waiting for update checks 2026-10-10 17:32:43 +08:00
Levi 44a5f9eefc Merge PR #1582: widen macOS navigation rail 2026-10-10 14:43:06 +08:00
Levi c90c690836 Merge PR #1581: synchronize extension cookie range coverage
# Conflicts:
#	ui/flutter/test/widget_test.dart
2026-10-10 14:43:04 +08:00
Levi dee1c2515f Merge PR #1535: keep extension update chip within card header 2026-10-10 14:42:53 +08:00
Levi bbf1a9fd7d fix(ui): widen navigation rail for macOS traffic lights 2026-10-10 14:34:11 +08:00
Levi b3f462bb7d test(http): synchronize ignored Range response coverage 2026-10-09 21:15:51 +08:00
Levi 0d08004c49 revert: remove macOS traffic light adjustments 2026-10-09 21:13:55 +08:00
Levi 1589d4cbe7 fix: update macOS traffic lights through window events 2026-10-09 20:58:24 +08:00
Levi b6ce7756fb fix: keep macOS traffic lights within the navigation rail 2026-10-09 20:49:14 +08:00
Levi 342c55fbd6 fix: use server-side fetch header permissions for extensions 2026-10-09 19:59:06 +08:00
Levi fe2f45e40d fix: preserve explicit Cookie headers in extension requests 2026-10-09 19:39:05 +08:00
Levi 2d4a8cddc1 fix(ui): keep extension update chip within card header 2026-09-26 11:05:43 +08:00
Levi 76c22c50d8 test: align beta2 integration checks 2026-09-25 16:36:27 +08:00
Levi f400137ead Merge PR #1542 into 2.0.0-beta.2 2026-09-25 16:23:55 +08:00
Levi a5ebbca318 Merge PR #1536 into 2.0.0-beta.2
# Conflicts:
#	ui/flutter/test/widget_test.dart
2026-09-25 16:23:55 +08:00
Levi 8f2af5660e Merge PR #1535 into 2.0.0-beta.2 2026-09-25 16:23:39 +08:00
Levi 932a49c1d1 Merge PR #1532 into 2.0.0-beta.2 2026-09-25 16:23:39 +08:00
Levi 212c735b66 Merge latest main into 2.0.0-beta.2 2026-09-25 16:23:39 +08:00
ashish6298 b5e8d61f01 fix: move checksum to HTTP opts, fix mismatch retry, scope get_download_speed to aggregate-only 2026-09-25 12:15:50 +05:30
ashish6298 929e2d2d7c test(base): add unit tests for ChecksumOption serialization 2026-09-22 16:36:41 +05:30
ashish6298 e93437c95f feat: implement Model Context Protocol (MCP) server and download manager capabilities 2026-09-22 16:16:01 +05:30
Levi 7bc7efed5b feat(task): show a dash for completed tasks without a done time
Tasks completed before this change have no doneAt value; the drawer now
always renders the Completed at row for completed tasks and falls back
to a dash instead of hiding the row.
2026-09-20 09:28:46 +08:00
Levi 1d8a502ac4 fix(task): keep DoneAt once written instead of clearing it
Done tasks never leave the done state (doStart, pause and markTaskError
all guard against Done), so the else branch clearing DoneAt was
unreachable dead code. DoneAt is now written once on completion and
kept for the task lifetime.
2026-09-20 09:21:36 +08:00
Levi 4b231f52c5 feat(task): record and display task completion time
Add DoneAt to the download task model: set when a task finishes and
cleared when it is downloaded again. The value flows through the API
model and task record, and is shown as "Completed at" in the task
drawer info tab next to the creation time.
2026-09-20 08:56:54 +08:00
Levi e956bd1ce5 style(ui): tighten card update chip spacing to 4px 2026-09-20 08:05:00 +08:00
Levi 68402c85d3 revert(ui): keep extension update check silent on failure
A failed check simply means no update chip; the failure toast and its
state field are removed.
2026-09-20 08:02:22 +08:00
Levi a480c5bd14 fix(ui): align extension update dialog title with app upgrade dialog 2026-09-19 22:16:37 +08:00
Levi e779de285d fix(ui): card chip spacing, instant flag clearing, locale-wide label
- Space the update chip off the card title to match the detail header
- Clear the extension's update flag right after a successful upgrade so
  the chip disappears without waiting for the background re-check
- Use the concise Update label across all locales
2026-09-19 21:56:46 +08:00
Levi fac17b0ccc style(ui): shorten update button label to Update
The shared newVersionUpdate label now reads Update on the app upgrade
dialog, the tracker update button and the extension update dialog, so
the extension flow matches the host dialog wording.
2026-09-19 21:47:44 +08:00
Levi 8c728ffda7 style(ui): render the update status as a bordered chip
Pill shape with a brand-green tint, outline and dot so the status
reads as a badge next to the title instead of plain text.
2026-09-19 21:41:56 +08:00
Levi 1b4956b0ec fix(ui): reserve the pointer cursor for the update chip
Extension cards no longer show a pointer cursor on hover; the tappable
update status chip shows it instead, on cards and detail headers alike.
2026-09-19 21:18:57 +08:00
Levi 2e5f8948be fix(ui): restore homepage and GitHub buttons on extension cards 2026-09-19 20:55:22 +08:00
Levi 04ebca60cf refactor(ui): make the status chip the only update entry
Remove the update button from the detail actions; the green chip next
to the extension title opens the update dialog on cards, the desktop
drawer and the mobile details page alike.
2026-09-19 20:49:20 +08:00
Levi 948f6d5487 fix(ui): let the update status chip shrink instead of overflow
Long translations (e.g. Italian, Tamil) could exceed the card title row
on minimum-width cards. The chip is now flex-sized and its label
ellipsizes, so the title and chip degrade gracefully at any width and
locale.
2026-09-19 20:02:28 +08:00
Levi 51b7abed48 refactor(ui): replace update dot with tappable status chip
Card and detail headers now show a green update chip next to the
extension title; on cards it opens the update dialog directly. The red
dot and the red card button are gone, and the status chip no longer
duplicates inside the detail content. Shorten the English label so the
chip fits narrow cards.
2026-09-19 19:47:02 +08:00
Levi d8758267c4 refactor(ui): polish extension update affordance per review
- Move the update dot from the icon corner to beside the card title
- Restyle the card update button to match the row's ghost buttons
- Drop the detail-view dot; the existing update status chip covers it
- Rename the detail update button label to just Update
2026-09-19 19:04:58 +08:00
Levi d94b59b165 refactor(ui): open extension details from every card
Installed extensions missing from the store index previously had no tap
handler on their card, so they kept on-card homepage/GitHub buttons as
the only metadata entry. Make all cards open the detail view and drop
the homepage/GitHub buttons entirely; details remain the single place
for links.
2026-09-19 18:43:57 +08:00
Levi f34cb9c971 refactor(ui): declutter extension card actions
Drop homepage/GitHub buttons from store extension cards (they remain on
the detail page); keep them only for unlisted extensions whose cards do
not open a detail view. With the labeled update button this caps card
actions at update/settings/delete.
2026-09-19 18:07:28 +08:00
Levi 172f4a3650 fix(ui): unify extension update check and improve update affordance
- Update availability is now decided solely by the backend live GitHub
  check (updateFlags); the store index may lag and previously masked
  new versions in the market view.
- Replace the ambiguous refresh icon on extension cards with a labeled
  update button, and add a red dot badge on extension icons (card and
  detail view) when an update is available.
- Surface a toast when the update check fails instead of failing silently.
2026-09-19 17:32:44 +08:00
Levi 085ebf357a fix(ui): smooth task progress bar updates 2026-09-19 00:14:15 +08:00
Levi dae13dec01 Merge latest PR #1527 into 2.0.0-beta.2 2026-09-18 09:43:51 +08:00
Levi a8f682559f Merge latest PR #1523 into 2.0.0-beta.2 2026-09-18 09:43:51 +08:00
Levi 3c0c019bc9 fix(bt): derive cleanup boundaries from torrent storage layout 2026-09-18 09:33:42 +08:00
Levi 69b3e223ad fix(bt): always preserve the torrent root directory 2026-09-18 09:23:46 +08:00
Levi 183616fcc0 fix(webview): suppress GTK fallback error document events 2026-09-18 09:13:40 +08:00
Levi 3415364ab7 test(webview): allow for throttled timers and diagnose selector timeouts 2026-09-18 09:04:39 +08:00
Levi 1b00a16277 fix(webview): update native callback lifetime and test coverage instrumentation 2026-09-18 09:00:50 +08:00
Levi b33c00d90d ci: resolve Linux native crash address in covered test binary 2026-09-18 00:36:41 +08:00
Levi 7e07ea31cc test(webview): cover event stream failures and cleanup 2026-09-18 00:31:06 +08:00
Levi c2da22f371 fix(webview): update GTK lifecycle cleanup and repeat Linux integration tests 2026-09-18 00:21:13 +08:00
Levi 2fd8a96c5d fix(webview): ignore load notifications from browser error documents 2026-09-18 00:07:55 +08:00
Levi 1776b05782 test(webview): trace navigation completion in event contract 2026-09-18 00:03:03 +08:00
Levi ffbae58e6b test(webview): trigger navigation failure consistently through proxies 2026-09-17 23:58:30 +08:00
Levi 2c7a86b21c build(deps): use webview_go with Go 1.17 minimum 2026-09-17 23:52:58 +08:00
Levi 4157e0a5bc fix(deps): update webview_go with legacy Go compatibility fix 2026-09-17 23:51:26 +08:00
Levi dfe8e9a3fd feat(extension): expose host version and WebView page events 2026-09-17 23:43:39 +08:00
Levi 7c33d978c4 fix(bt): clean partial files and empty parent directories 2026-09-17 23:35:40 +08:00
Levi 534fbbb3a2 Merge latest PR #1524 into 2.0.0-beta.2 2026-09-17 23:03:45 +08:00
Levi fdb2768008 Merge latest PR #1523 into 2.0.0-beta.2 2026-09-17 23:03:45 +08:00
Levi 27202cf99e Merge latest PR #1518 into 2.0.0-beta.2 2026-09-17 23:03:45 +08:00
Levi a05eea3268 fix(ui): restore tray menu dismissal and use upstream plugin 2026-09-17 23:02:20 +08:00
Levi 7496331ffd fix(flutter): align notification file actions with task cards 2026-09-17 23:02:14 +08:00
Levi 967ef25408 fix(bt): remove unselected files from the correct download path 2026-09-17 22:47:07 +08:00
Levi d1b2ae42af fix: finish migration to main WebView profile library 2026-09-17 21:48:41 +08:00
Levi adeb7a2895 Merge PR #1526 into 2.0.0-beta.2 2026-09-17 21:47:44 +08:00
Levi edfecefc16 Merge PR #1524 into 2.0.0-beta.2 2026-09-17 21:47:44 +08:00
Levi f7dfc4226b Merge PR #1523 into 2.0.0-beta.2 2026-09-17 21:47:44 +08:00
Levi edb8d3cbdc Merge PR #1520 into 2.0.0-beta.2 2026-09-17 21:47:43 +08:00
Levi c6dd58848d Merge latest PR #1518 into 2.0.0-beta.2 2026-09-17 21:47:43 +08:00
Levi 88dd35cc46 Merge latest main into 2.0.0-beta.2
# Conflicts:
#	internal/webview/README.md
#	ui/flutter/lib/app/rpc/webview_rpc_service.dart
#	ui/flutter/pubspec.lock
#	ui/flutter/pubspec.yaml
2026-09-17 21:47:43 +08:00
Levi 051d4267a5 fix(ui): restore path placeholders and window shortcut 2026-09-17 21:08:30 +08:00
sysy 022fbf633b fix: sanitize archive entry paths before extraction to prevent path traversal
Move path sanitization before directory handling in extractFile so
directory entries with "../" prefixes are rejected the same way file
entries already are. Add the same sanitization to extractSevenZipMultiPart
and both zip extraction loops in the Windows updater, which had no path
validation at all.
2026-09-16 23:31:35 -04:00
Levi 014221a1a7 fix(flutter): drop action buttons from error notifications
A failed download has no usable output file, so error notifications now
carry no buttons at all; only the done event offers open file / open
folder actions.
2026-09-17 09:55:07 +08:00
Levi 6f4635e2eb feat(flutter): add action buttons to desktop download notifications
Terminal task notifications now carry actionable buttons and respond to
clicks instead of being inert:

- Done notifications offer Open File and Open Folder for single-file
  tasks, and Open Folder only for folder tasks (e.g. multi-file
  torrents), following the same rule as the task details page. Error
  notifications offer Open Folder only, since the output is rarely
  usable.
- Clicking the notification body brings the app window back to the
  front (show + focus), covering the close-to-tray case.
- The click target is snapshotted into the notification (Windows: action
  arguments; macOS/Linux: notification payload) so buttons keep working
  after the task record is deleted, and a missing file is logged instead
  of surfacing an OS error dialog.
- macOS resolves buttons through three pre-registered notification
  categories (done single-file / done folder / error); Windows uses the
  actions API of the pinned notification plugin fork; Linux uses
  freedesktop notification actions.
- New openFolder label localized in all 22 ARB catalogs.

Cold-start activation is intentionally out of scope: when the process is
not running, OS activation opens the main window without dispatching the
button action.
2026-09-17 09:49:20 +08:00
Levi a83e3a2b84 Merge remote-tracking branch 'origin/main' into codex/fix-bt-file-selection
# Conflicts:
#	ui/flutter/test/features/tasks/domain/task_record_test.dart
2026-09-17 00:03:36 +08:00
Levi 5bb040eaaf fix(bt): honor selected files when creating resolved tasks 2026-09-17 00:03:03 +08:00
Levi e27c7dddb4 fix(http): close prefetch response when temporary-file setup fails 2026-09-16 23:43:43 +08:00
Levi 5342be16d9 Merge main into codex/tempdir-media-spooling 2026-09-16 23:20:29 +08:00
Levi e0ced260e2 feat: spool media inputs to disk and report producer progress 2026-09-16 23:16:19 +08:00
Levi d41e27234d Merge PR #1518 into 2.0.0-beta.2 2026-09-15 23:06:45 +08:00
Levi c703c52cbc fix(ui): refine narrow layouts and enable tab swipes 2026-09-15 23:01:48 +08:00
Levi b88144c864 Merge PR #1517 into 2.0.0-beta.2
# Conflicts:
#	go.mod
2026-09-15 22:51:56 +08:00
Levi 273850ae3f Merge PR #1516 into 2.0.0-beta.2 2026-09-15 22:51:41 +08:00
Levi 07356033f9 Merge PR #1515 into 2.0.0-beta.2 2026-09-15 22:51:41 +08:00
Levi 166aee81be Merge latest PR #1512 into 2.0.0-beta.2 2026-09-15 22:51:40 +08:00
Levi 45d7b6f5bc Merge latest main into 2.0.0-beta.2 2026-09-15 22:51:31 +08:00
Levi 5845e9ea5f Merge branch 'main' into main 2026-09-15 22:49:37 +08:00
sun f9410bbcc7 fix(hls): snapshot state in Store and publish fully initialized runs
Address the completion review findings on top of the upstream review
fixes:

- Store runs on the engine's checkpoint goroutine without the task
  lock and serializes the returned value afterwards: take the fetcher
  mutex and return a deep copy instead of the live state pointer,
  matching the HTTP protocol's Store pattern
- Load the journal and per-run base counters before publishing the run
  as active, so Stats/Progress never observe half-initialized fields
- Bind the journal fingerprint to the key IV as well: a rotated IV on
  an otherwise identical plan would silently decrypt to garbage
- Keep every EXT-X-MEDIA member per group instead of only the first
  one, so a group with a URI on a later member is still detected
- After a merge that raced with a pause, keep the staged segments and
  exit silently instead of cleaning them up; drop a dead context
  check and an unused helper, and make the MAP identity and merge
  part-file names unambiguous
2026-09-15 16:31:35 +08:00
sun f1f17948c6 fix(hls): enforce supported HLS subset with explicit errors
The merge pipeline concatenates stored bytes, so features it cannot
reproduce faithfully are now detected and rejected with a clear error
instead of silently succeeding with a broken output:

- EXT-X-GAP (a hole in the merged file) and EXT-X-DISCONTINUITY
  (a broken timeline)
- separate audio renditions referenced by the selected variant: the
  master EXT-X-MEDIA groups are parsed and an AUDIO group with its own
  URI fails the resolve; unreferenced groups are ignored
- changing init sections: a repeated EXT-X-MAP is compared by all of
  its attributes (URI and BYTERANGE), not just the URI
- encrypted EXT-X-MAP without an explicit IV: the spec does not define
  a sequence-derived IV for init sections
- non-identity AES-128 KEYFORMAT values

The supported matrix is documented in the package doc.

Add regression tests for every rejection plus real media validation:
ffmpeg-generated TS segments and fMP4 init/fragments are committed as
fixtures, downloaded through the fetcher (including an AES-128 key
rotation variant), and the merged outputs are validated structurally -
TS on the 188 byte grid with continuity counter rules, PAT/PMT and PES
presence, fMP4 by walking the ISO-BMFF box hierarchy.
2026-09-15 11:42:06 +08:00
sun 675028c64a fix(hls): isolate staging per task and finalize output atomically
- Give every task a random staging identity persisted in the saved
  state: two tasks downloading the same playlist into the same
  directory get separate segment folders and journals instead of
  deleting each other's progress
- Bind the journal to a fingerprint of the full download plan (media
  URL, ordered segments, ranges, keys); foreign or unreadable journals
  are discarded rather than applied to the wrong segments, and the
  ledger is rewritten through a temp file + rename
- Legacy states without a staging identity migrate to a fresh folder
  instead of reusing the shared URL-hash folder
- Merge into a unique temporary file next to the target, check write
  and close errors, then remove-then-rename over the final name: a
  failed merge preserves the staged segments for a retry and re-merging
  after a crash stays idempotent
- Drop the staging folder when a URL patch invalidates the plan
2026-09-15 11:27:26 +08:00
sun b6d18a267b fix(hls): rebuild request extras on restore and adopt merge semantics in Patch
- Parse the request extra in Setup so restored fetchers resend Referer,
  Cookie and other configured headers on resumed segment/key requests;
  the store/restore regression test round-trips the state and the task
  meta through JSON exactly like the engine storage does
- Rebuild the resolved plan on Start when the resource survived but the
  plan was lost to a crash between the task save and the first
  checkpoint, keeping the existing output name
- Patch now merges partial updates instead of replacing the request and
  options wholesale: URL-only patches keep the output path and renamed
  file, header-only patches keep the resolved plan and take effect on
  the next run, Labels merge per key, Proxy swaps rebuild the client,
  and nil arguments are no longer a panic. Editing a running task is
  rejected, consistent with the ed2k protocol
- Cover retry-after-failure counter resets, running-task patch
  rejection and the missing-state self-heal with regression tests
2026-09-15 11:18:40 +08:00
sun 40cfb167f6 fix(hls): isolate per-run lifecycle and use final-output byte accounting
- Give every Start its own run context, WaitGroup (including the queue
  producer), supervisor done channel and snapshots of the plan, request
  headers, HTTP client and merge target; Pause/Close wait for all run
  goroutines, including a merge in flight, before returning
- Build the HTTP client in Resolve/Start before workers launch instead of
  lazily inside do(), which raced between concurrent segment downloads
- Reject task edits while a run is active instead of mutating shared state
- Reset counters per run and seed them from the completed-segment journal;
  count only stored output bytes as progress, never discarded retry bytes
- Report Res.Size only when every segment is unencrypted with a known
  length; ciphertext sums overstate the final output, indeterminate plans
  rely on the engine's TotalDownloaded backfill at completion
2026-09-15 11:13:38 +08:00
Levi 4fd1939732 fix(ffmpeg): reserve capacity before creating media inputs 2026-09-15 10:08:29 +08:00
Levi 3ed54ba93a fix(macos): resign WebView input focus before disposal 2026-09-15 09:15:27 +08:00
Levi fe64a328ac fix(webview): synchronize Windows browser shutdown before reopening profiles 2026-09-15 09:06:56 +08:00
Levi 7e49b678f2 fix(webview): release retained macOS views before profile removal 2026-09-15 09:01:58 +08:00
Levi 311e002e55 Return persistent cookie expiration through the existing RPC contract 2026-09-15 00:56:50 +08:00
Levi 119fad9a94 Use explicit WebView2 shutdown and verify persistent cookie expiration 2026-09-15 00:54:12 +08:00
Levi e2601ea9fa Keep Linux WebViews on one persistent GTK UI thread 2026-09-15 00:52:05 +08:00
Levi a5eaa6b528 Wait for cookie propagation and identify profile assertion stages 2026-09-15 00:51:07 +08:00
Levi bed624bfce Assert HTTPS proxy routing independently of native error-page behavior 2026-09-15 00:44:20 +08:00
Levi eec53c15b2 Extend existing provider contracts and remove duplicate WebView CI 2026-09-15 00:41:47 +08:00
Levi d58135db9f Wait for Android native views to close before clearing profiles 2026-09-14 10:13:08 +08:00
Levi d642ae5018 Remove extension WebView data during uninstall 2026-09-14 10:11:53 +08:00
Levi e358123b22 fix(flutter): keep waiting task progress static 2026-09-14 10:02:19 +08:00
sun ee9b16794e Merge branch 'GopeedLab:main' into main 2026-09-14 09:55:31 +08:00
sun 4f74101b3a feat(hls): add native HLS (m3u8) download protocol
New hls protocol in internal/protocol/hls (registered ahead of http):
- master playlist variant picking (highest bandwidth), RFC3986 segment
  URL resolution, fMP4 init segments, EXT-X-BYTERANGE, EXT-X-GAP skip
- AES-128 decryption with mid-playlist key rotation and sequence-derived
  IVs; explicit errors for live streams, SAMPLE-AES and multi-period fMP4
- concurrent segment downloads with retry, journal-based resume across
  pause/app restarts, automatic in-order merge into a single .ts/.mp4
- best-effort Content-Length probing for determinate progress
- anti-hotlink Referer/Cookie/UA via task extra headers (Go transport
  does not filter them)

Core: fix SchemeFilter extension match for URLs with query strings
(also benefits .torrent links); Flutter: Protocol.hls enum, hls protocol
config model (prevents config round-trip wipe) and HLS settings block
with localized labels in all 22 locales.

Tests: parser/crypto unit tests, httptest e2e (Referer-enforced CDN,
encrypted streams, byterange, pause/resume, store/restore) and an
opt-in live-site acceptance gate (HLS_LIVE_URL).
2026-09-14 09:55:31 +08:00
Levi c47b735fc4 Preserve existing profile connections when opening more pages 2026-09-14 00:43:50 +08:00
Levi ca98806dcc Pin verified native backend and complete proxy regression checks 2026-09-14 00:34:27 +08:00
Levi a0e84a75b1 Isolate extension WebView profiles and follow host proxy settings 2026-09-14 00:30:52 +08:00
Levi 14b22cf485 test: align failed task speed assertion with PR #1510 behavior 2026-09-13 22:27:32 +08:00
Levi a3f6ec5885 Merge latest PR #1512 into 2.0.0-beta.2 2026-09-13 22:25:20 +08:00
Levi f839c3ac93 Merge latest PR #1511 into 2.0.0-beta.2 2026-09-13 22:25:20 +08:00
Levi 67d4aa9554 Merge latest PR #1510 into 2.0.0-beta.2
# Conflicts:
#	ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart
#	ui/flutter/test/widget_test.dart
2026-09-13 22:25:20 +08:00
Levi c355f9ff5c fix: retain zero download speed for known-size tasks 2026-09-13 18:56:37 +08:00
Levi 59a7d40bfe fix: select downloading tab after mobile task creation 2026-09-13 18:46:19 +08:00
Levi 5d83e91ce5 fix(stream): bound FFmpeg memory buffers and propagate Blob backpressure 2026-09-13 18:24:32 +08:00
Levi 637004c8a8 fix(ffmpeg): use app-writable temporary directory for stream buffers 2026-09-13 18:00:10 +08:00
Levi e79b37d08c fix: remove mobile reveal-to-open-file fallback 2026-09-13 17:39:54 +08:00
Levi d393d27ec7 fix: hide completed task folder action on mobile and web 2026-09-13 17:38:34 +08:00
Levi 2e8672273c feat: confirm extension updates with progress and retry 2026-09-13 17:25:31 +08:00
Levi 4802802828 fix: limit URL install loading to its submit button 2026-09-13 17:20:08 +08:00
Levi 3d372f98de fix: preserve install URL paste focus and limit local installs to desktop 2026-09-13 17:18:13 +08:00
Levi d578fe96bc test(ui): cover minimum extension card width 2026-09-13 15:39:04 +08:00
Levi 1262844363 fix(ui): keep three extension columns at default width 2026-09-13 15:36:36 +08:00
Levi 91bea83692 Merge PR #1512 into 2.0.0-beta.2 2026-09-13 15:30:10 +08:00
Levi e21bc5eeae Merge PR #1511 into 2.0.0-beta.2 2026-09-13 15:30:10 +08:00
Levi 6331060171 Merge PR #1510 into 2.0.0-beta.2 2026-09-13 15:30:10 +08:00
Levi f3ec6b8ee9 Merge latest main into 2.0.0-beta.2 2026-09-13 15:30:00 +08:00
Levi 9124976b5c feat(extension): add streaming FFmpeg WASM merge runtime 2026-09-13 15:23:29 +08:00
Levi 9820e58de9 Merge branch 'main' of github.com:GopeedLab/gopeed into codex/fix-task-detail-text-selection 2026-09-13 15:07:02 +08:00
Levi e890e593d9 feat(ui): compact number display in extension stats 2026-09-13 15:06:42 +08:00
Levi 6572e9e899 fix: automatically submit scheme extension installs 2026-09-13 15:01:50 +08:00
Levi 1c57b07575 fix: prefill scheme extension installs and forward iOS deep links 2026-09-13 14:31:36 +08:00
Levi 6f12eb1f07 Merge Android cold-start back fix and bottom exit prompt into beta 2026-09-12 20:40:25 +08:00
Levi bd03d3b31c fix(android): intercept back during cold startup and move exit toast down 2026-09-12 20:40:07 +08:00
Levi d330d8bea8 Merge mobile back navigation and exit confirmation into beta 2026-09-12 19:03:24 +08:00
Levi ee6b073bc1 fix(ui): preserve mobile home route and confirm Android exit 2026-09-12 18:56:52 +08:00
Levi a41c6c145c Merge latest PR #1505: compact creation button and inline empty-list guidance 2026-09-12 17:49:31 +08:00
Levi 86c8383584 feat(ui): simplify empty-list guidance with inline create action 2026-09-12 17:48:14 +08:00
Levi 75462fe2be fix(ui): make mobile task creation button compact 2026-09-12 17:28:42 +08:00
Levi f35111e072 fix(ui): make mobile task creation button compact 2026-09-12 17:27:44 +08:00
Levi 6aabe69f15 test(ui): align window title expectations with main 2026-09-12 16:56:06 +08:00
Levi 527539f3a1 Merge PR #1506: fix history search text alignment and clipping 2026-09-12 16:55:20 +08:00
Levi 2d239495a0 Merge PR #1505: emphasize task creation and empty-list actions 2026-09-12 16:55:20 +08:00
Levi ddce649c8b Merge latest main into 2.0.0-beta.2 2026-09-12 16:55:20 +08:00
Levi 29675858f2 fix(ui): align history search placeholder and prevent clipping 2026-09-12 10:03:08 +08:00
Levi 1fc0ff6738 feat: emphasize task creation and add empty-list actions 2026-09-12 09:57:02 +08:00
Levi 0851e27c6d Merge PR #1496: fix script settings path picker 2026-09-10 23:10:58 +08:00
Levi 3abc8d42fa Merge PR #1498: fix macOS download notifications 2026-09-10 23:10:53 +08:00
Levi 71d7d96cc0 Merge PR #1499: fix Windows notification icon
# Conflicts:
#	ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart
2026-09-10 23:10:49 +08:00
Levi f254a82d9c refactor(windows): use bundled notification icon path directly 2026-09-10 23:07:14 +08:00
Levi c4ae3330b8 fix(windows): persist PNG icon for download notifications 2026-09-10 23:00:24 +08:00
Levi a45b6455be fix(macos): initialize file picker for non-sandboxed windows 2026-09-10 14:04:43 +08:00
Levi 979988a7f4 fix(macos): request permission for download notifications 2026-09-10 13:03:51 +08:00
Levi 9efe7ff4c9 Merge PR #1488 latest head: create task panel mobile UI 2026-09-10 00:26:07 +08:00
Levi 77734c69bc Merge PR #1494 latest head: unify platform window titles 2026-09-10 00:26:03 +08:00
Levi 22db46c68a Merge PR #1495 latest head: infer extension for opaque URLs 2026-09-10 00:26:00 +08:00
Levi 99a62357ab Merge PR #1496 latest head: reuse shared file path picker 2026-09-10 00:25:56 +08:00
Levi 9558042513 Merge PR #1497 latest head: fix(android) restore notification permission prompt 2026-09-10 00:25:50 +08:00
Levi 39912718c4 fix(android): request notification permission on startup 2026-09-10 00:19:53 +08:00
Levi 3b6b134f24 ci: warn on incomplete localization catalogs 2026-09-10 00:02:31 +08:00
Levi f0aa44455c Merge branch 'main' into codex/fix-settings-script-path-picker 2026-09-09 23:41:17 +08:00
Levi 99ed770017 fix(ui): reuse shared file path picker for script settings 2026-09-09 23:39:44 +08:00
Levi f506b8f92f Merge branch 'main' into fix/create-task-panel-mobile-ui 2026-09-09 23:30:41 +08:00
Levi 9b9fd12884 Merge branch 'main' into fix/create-task-panel-mobile-ui 2026-09-09 23:27:11 +08:00
dajiaohuang cea912b414 fix(http): preserve MIME extension for opaque URLs 2026-09-09 03:45:15 +08:00
Levi 7c3d7269fe fix: unify platform window titles as Gopeed 2026-09-08 23:15:55 +08:00
Levi 1554791e17 merge: PR #1493 refresh repeated Android download intents
# Conflicts:
#	ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart
2026-09-08 22:14:48 +08:00
Levi b0aecdcfc6 merge: latest PR #1488 mobile resolve action layout 2026-09-08 22:14:14 +08:00
Levi d2eb7b2a67 fix(android): refresh download URL when create page is already open 2026-09-08 22:05:45 +08:00
Levi 7112845c82 fix: keep resolve dialog actions equal in width 2026-09-08 21:53:25 +08:00
Levi 39820707be merge: PR #1488 extension toolbar alignment fix 2026-09-08 21:28:33 +08:00
Levi 47db539d91 fix: align extension search with compact toolbar controls 2026-09-08 21:27:52 +08:00
Levi f32f7583e4 fix: align extension search with compact toolbar controls 2026-09-08 21:25:05 +08:00
Levi e3ae2865d6 fix: reconcile Azerbaijani locale with merged create action 2026-09-08 20:26:38 +08:00
Levi 1a8d73fbe6 merge: PR #1489 Android browser takeover handling 2026-09-08 20:24:00 +08:00
Levi 5728caf7db merge: PR #1488 create task mobile UI 2026-09-08 20:23:52 +08:00
Levi 77d95971ca merge: PR #1476 async native API callbacks 2026-09-08 20:23:52 +08:00
Levi 2c81520029 merge: minimal macOS window reopen fix 2026-09-08 20:23:52 +08:00
Levi 8d45eb4fef test: remove native window regression tests 2026-09-08 20:03:57 +08:00
Levi 31fa0e5bc9 fix: restore only main macOS window and select active downloads 2026-09-08 18:14:16 +08:00
Levi 0f60f40e3e Merge branch 'main' into codex/async-ffi-worker-callbacks 2026-09-07 09:23:59 +08:00
Levi b17c0fd7c8 fix(android): preserve browser takeover handling 2026-09-07 09:11:17 +08:00
Levi a834ca0d74 fix(ui): unify text field appearance 2026-09-06 22:26:35 +08:00
Levi ecfd07ccb0 fix(ui): refine create task panel behavior 2026-09-06 22:17:26 +08:00
Levi d51eeb3f2c fix: prevent native requests from blocking shutdown 2026-09-06 21:49:11 +08:00
Levi 1bb3f74c9f Merge branch 'main' into codex/async-ffi-worker-callbacks 2026-09-06 20:37:14 +08:00
Levi f7be97667f Merge remote-tracking branch 'origin/main' into codex/async-ffi-worker-callbacks 2026-09-06 13:00:59 +08:00
Levi 607d1729e6 refactor: share async invoke bridge across native platforms 2026-09-06 11:59:11 +08:00
Levi d12122ea86 feat: make desktop FFI invocation asynchronous 2026-09-06 11:23:27 +08:00
299 changed files with 3569 additions and 29209 deletions

No files matched your search

+5 -20
View File
@@ -106,32 +106,17 @@ jobs:
if ("${{ matrix.arch }}" -eq "amd64") {
# for amd64
$release = "build\windows\x64\runner\Release\"
# Use the same toolchain as cgo instead of Git's bundled DLLs.
$cc = (Get-Command -Name (go env CC) -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
foreach ($dll in @("libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll")) {
$runtimePath = [string](& $cc "-print-file-name=$dll")
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($runtimePath)) {
throw "Failed to locate $dll using $cc"
}
$runtimePath = $runtimePath.Trim()
# Some MinGW distributions keep runtime DLLs beside gcc.exe.
if ($runtimePath -eq $dll) {
$runtimePath = Join-Path (Split-Path -Parent $cc) $dll
}
if (!(Test-Path -LiteralPath $runtimePath -PathType Leaf)) {
throw "MinGW runtime $dll was not found by $cc (reported path: $runtimePath)"
}
Write-Host "Copying MinGW runtime: $runtimePath"
Copy-Item -LiteralPath $runtimePath -Destination $release
}
$mingw = "C:\Program Files\Git\mingw64\bin"
cp $mingw\libstdc++-6.dll $release
cp $mingw\libgcc_s_seh-1.dll $release
} else {
# for ARM64
$release = "build\windows\arm64\runner\Release\"
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
cp $mingw\libc++.dll $release
cp $mingw\libunwind.dll $release
cp $mingw\libwinpthread-1.dll $release
}
cp $mingw\libwinpthread-1.dll $release
cp $system\msvcp140.dll $release
cp $system\vcruntime140.dll $release
cp $system\vcruntime140_1.dll $release
@@ -798,5 +783,5 @@ jobs:
linux/arm64
linux/arm/v7
tags: |
${{ !contains(env.VERSION, 'beta') && 'liwei2633/gopeed:latest' || '' }}
liwei2633/gopeed:latest
liwei2633/gopeed:v${{ env.VERSION }}
-46
View File
@@ -69,49 +69,6 @@ jobs:
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
test-flutter-category:
# The settings editor and the backend have to read an extension list the
# same way, so both sides run the shared rule table. The Go side is covered
# by the test job above, this runs the ui side of it.
name: Test shared category rules
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Run the shared category rule table on the ui side
working-directory: ui/flutter
run: |
flutter pub get
flutter gen-l10n
flutter test test/api/downloader_config_test.dart
test-flutter-task-context-menu:
name: Test task context menus and text inputs
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CHROME_EXECUTABLE: /usr/bin/google-chrome
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Prepare Flutter tests
working-directory: ui/flutter
run: |
flutter pub get
flutter gen-l10n
- name: Test task menu actions and native input policy
working-directory: ui/flutter
run: |
flutter test --no-pub test/features/tasks/presentation/widgets/task_context_menu_test.dart test/shared/widgets/app_text_field_test.dart
flutter test --no-pub test/widget_test.dart --plain-name 'task context menu exposes'
- name: Test browser menu cancellation and input policy
working-directory: ui/flutter
run: |
"$CHROME_EXECUTABLE" --version
flutter test --no-pub --platform chrome test/features/tasks/presentation/widgets/task_context_menu_test.dart test/shared/widgets/app_text_field_test.dart
test-webview-integration:
name: Test webview integration (${{ matrix.name }})
strategy:
@@ -134,9 +91,6 @@ jobs:
if: runner.os == 'macOS'
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Test Windows FFmpeg memory and stream lifecycle
if: runner.os == 'Windows'
run: go test -v -count=1 -timeout 180s ./pkg/download/engine/ffmpeg ./pkg/download/engine/inject/stream ./pkg/download/engine
- name: Install Linux WebView dependencies
if: runner.os == 'Linux'
run: |
+1 -1
View File
@@ -20,7 +20,7 @@ Beyond core download management, Gopeed offers browser integration, JavaScript e
Visit ✈ [Official Website](https://gopeed.com)
![Application screenshot](_docs/img/ui-concept-en.webp)
![Application screenshot](_docs/img/ui-concept-en.png)
## ✨ Features
+1 -1
View File
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** の略)は、Go と Flutter で開発された高速で
✈ [公式サイト](https://gopeed.com)
![アプリのスクリーンショット](_docs/img/ui-concept-en.webp)
![アプリのスクリーンショット](_docs/img/ui-concept-en.png)
## ✨ 主な機能
+1 -1
View File
@@ -20,7 +20,7 @@ Ngoài việc quản lý tải xuống hằng ngày, Gopeed còn hỗ trợ tíc
Truy cập ✈ [Trang web chính thức](https://gopeed.com)
![Ảnh chụp màn hình ứng dụng](_docs/img/ui-concept-en.webp)
![Ảnh chụp màn hình ứng dụng](_docs/img/ui-concept-en.png)
## ✨ Tính năng chính
+1 -1
View File
@@ -20,7 +20,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
访问 ✈ [官方网站](https://gopeed.com)
![应用截图](_docs/img/ui-concept-zh-CN.webp)
![应用截图](_docs/img/ui-concept-zh-CN.png)
## ✨ 主要特性
+1 -1
View File
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
瀏覽 ✈ [官方網站](https://gopeed.com)
![應用程式截圖](_docs/img/ui-concept-en.webp)
![應用程式截圖](_docs/img/ui-concept-en.png)
## ✨ 主要功能
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 787 KiB

-52
View File
@@ -1,52 +0,0 @@
# Batch task creation
`POST /api/v1/tasks/batch` accepts ordinary and grouped batches:
```json
{
"group": true,
"groupName": "album",
"opts": {"path": "/downloads"},
"reqs": [
{"rid": "resolved-resource-id", "opts": {"selectFiles": [1]}},
{"req": {"url": "https://example.com/cover.jpg"}}
]
}
```
Each member accepts `rid` or `req`. A nonempty `rid` takes priority and creates a
task from the cached resolve result. A member's `opts`, when present, replaces
batch `opts` as a whole; omitted fields do not inherit individual batch values.
Members without `opts` use batch `opts`. BT file selections use the indexes from
the resolve result. Requests without `rid` continue to work.
For groups, batch `opts.path` selects the parent directory. Members inheriting
batch options, or with an empty path, use the reserved group directory. Explicit
relative member paths are resolved under that directory; do not prefix them with
the batch parent path. For example, parent `downloads` and member path `nested`
place the member in `downloads/<group name>/nested`. Absolute paths under the
parent directory preserve their relative structure within the group. In ordinary
batches, an empty member path uses the configured default download directory.
All members are attempted. If at least one task is persisted, the response is
successful and contains the created task IDs. A task that fails to start after
being persisted is included; use the existing task controls to retry it.
Successful tasks are kept when other members fail. If every member fails, the
API returns an error and removes any newly reserved empty group and directory.
Both ordinary and grouped batches now return an object in `data`:
```json
{"code": 0, "msg": "", "data": {"taskIds": ["task-1", "task-2"], "parentId": "group-id"}}
```
Ordinary batches omit `parentId`. **This replaces the former array response for
ordinary batches.** The outer `code`/`msg`/`data` envelope is unchanged. Clients
must read `data.taskIds`; there are no per-member results. A successful response
completes the batch, including partial success, so clients must not automatically
resubmit missing members.
With `group: true`, the server generates the group ID and reserves its directory.
Creation does not accept a client group ID or append to an existing group. Group
metadata remains available through the existing task list and detail responses;
`parentId` identifies membership for group operations.
+1 -7
View File
@@ -1,9 +1,6 @@
package main
import (
"fmt"
"os"
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
@@ -16,8 +13,5 @@ func main() {
Storage: model.StorageBolt,
WebEnable: true,
}
if err := cmd.Start(cfg); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
cmd.Start(cfg)
}
+2 -2
View File
@@ -166,7 +166,7 @@ func runResolve(args []string) error {
result, err := d.Resolve(&base.Request{
URL: url,
Labels: map[string]string(labels),
})
}, nil)
if err != nil {
return err
}
@@ -250,7 +250,7 @@ func runDownload(args []string) error {
result, err := d.Resolve(&base.Request{
URL: url,
Labels: map[string]string(labels),
})
}, nil)
if err != nil {
return err
}
+6 -14
View File
@@ -16,18 +16,17 @@ import (
//go:embed banner.txt
var banner string
func Start(cfg *model.StartConfig) error {
func Start(cfg *model.StartConfig) {
fmt.Println(banner)
srv, listener, err := rest.BuildServer(cfg)
if err != nil {
return err
panic(err)
}
fmt.Println(banner)
downloadCfg, err := rest.Downloader.GetConfig()
if err != nil {
listener.Close()
return err
panic(err)
}
if rest.Downloader.IsFirstLoad() {
if downloadCfg.FirstLoad {
// Set default download config
if cfg.DownloadConfig != nil {
cfg.DownloadConfig.Merge(downloadCfg)
@@ -52,20 +51,13 @@ func Start(cfg *model.StartConfig) error {
rest.Downloader.PutConfig(downloadCfg)
}
}
// The default download dir is resolved only after the core starts, so
// seed the built-in categories now that the dir is known. Init is
// idempotent and leaves an existing category list untouched.
downloadCfg.Init()
rest.Downloader.PutConfig(downloadCfg)
}
watchExit()
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
return err
panic(err)
}
return nil
}
func watchExit() {
-35
View File
@@ -1,35 +0,0 @@
package cmd
import (
"strings"
"testing"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
func TestStartInvalidWebAPITokenReturnsError(t *testing.T) {
tests := []struct {
name string
webAuth *model.WebAuth
}{
{name: "no credentials"},
{name: "missing username", webAuth: &model.WebAuth{Password: "secret"}},
{name: "missing password", webAuth: &model.WebAuth{Username: "gopeed"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Start(&model.StartConfig{
WebEnable: true,
ApiToken: "test-token",
WebAuth: tt.webAuth,
})
if err == nil {
t.Fatal("Start() error = nil, want web authentication validation error")
}
if !strings.Contains(err.Error(), "web authentication username and password are required") {
t.Fatalf("Start() error = %q, want missing web authentication credentials", err)
}
})
}
}
+2 -2
View File
@@ -52,8 +52,8 @@ func loadCliArgs() *args {
flag.StringVar(cfg.Username, "username", "gopeed", "Web Authentication Username")
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
flag.StringVar(cfg.Password, "password", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
cfg.ApiToken = flag.String("T", "", "API token (requires web authentication with username and password)")
flag.StringVar(cfg.ApiToken, "api-token", "", "API token (requires web authentication with username and password)")
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
flag.StringVar(cfg.ApiToken, "api-token", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
flag.BoolVar(&cfg.MCPEnable, "mcp-enable", false, "Enable MCP endpoint")
cfg.TempDir = flag.String("temp-dir", "", "Temporary directory (defaults to the OS temporary directory)")
cfg.StorageDir = flag.String("d", "", "Storage directory")
+1 -4
View File
@@ -62,10 +62,7 @@ func main() {
WebFS: sub,
WebAuth: webAuth,
}
if err := cmd.Start(cfg); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
cmd.Start(cfg)
}
func isNotBlank(str *string) bool {
+35 -34
View File
@@ -10,13 +10,11 @@ require (
github.com/bodgit/sevenzip v1.6.1
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
github.com/fclairamb/ftpserverlib v0.31.0
github.com/go-git/go-git/v5 v5.19.2
github.com/go-git/go-git/v5 v5.8.1
github.com/google/jsonschema-go v0.4.3
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/imroc/req/v3 v3.58.0
github.com/jlaffaye/ftp v0.2.4
github.com/imroc/req/v3 v3.52.2
github.com/matoous/go-nanoid/v2 v2.0.0
github.com/mattn/go-ieproxy v0.0.12
github.com/mholt/archives v0.1.5
@@ -25,17 +23,10 @@ require (
github.com/pkg/errors v0.9.1
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
github.com/rs/zerolog v1.31.0
github.com/spf13/afero v1.15.0
github.com/tetratelabs/wazero v1.12.0
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
)
require (
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/onsi/gomega v1.36.3 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
)
require (
@@ -48,40 +39,48 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.1 // indirect
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.51.0 // indirect
github.com/refraction-networking/utls v1.6.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.1 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sync v0.20.0 // indirect
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/Microsoft/go-winio v0.6.1
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
github.com/RoaringBitmap/roaring v1.9.4
github.com/acomagu/bufpipe v1.0.4 // indirect
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
github.com/anacrolix/chansync v0.7.0 // indirect
github.com/anacrolix/dht/v2 v2.23.0 // indirect
github.com/anacrolix/envpprof v1.5.0 // indirect
github.com/anacrolix/generics v0.2.0
github.com/anacrolix/generics v0.2.0 // indirect
github.com/anacrolix/go-libutp v1.3.2 // indirect
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
github.com/anacrolix/missinggo v1.3.0 // indirect
@@ -97,7 +96,7 @@ require (
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -105,13 +104,13 @@ require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.2 // indirect
github.com/go-git/go-billy/v5 v5.4.1 // indirect
github.com/go-llsqlite/adapter v0.2.0 // indirect
github.com/go-llsqlite/crawshaw v0.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -129,7 +128,7 @@ require (
github.com/multiformats/go-varint v0.1.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/dtls/v3 v3.0.10 // indirect
github.com/pion/ice/v4 v4.2.0 // indirect
github.com/pion/interceptor v0.1.42 // indirect
github.com/pion/logging v0.2.4 // indirect
@@ -140,15 +139,15 @@ require (
github.com/pion/sctp v1.9.1 // indirect
github.com/pion/sdp/v3 v3.0.17 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.5 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/skeema/knownhosts v1.2.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
@@ -156,11 +155,13 @@ require (
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.58.0
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0
golang.org/x/time v0.15.0
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.34.0
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.42.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
modernc.org/libc v1.67.4 // indirect
+110 -75
View File
@@ -25,17 +25,29 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a h1:Vzz+8aMVX9ZPPpkxgxa7mCorrAgdXCZL5EunOpOBvao=
github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
github.com/GopeedLab/webview_go v0.0.0-20260917153919-dcf00737f32a h1:nz1bC6TGwQ1kv/OVLahNZ0EEtb7v5LHfceAkW4MvS6I=
github.com/GopeedLab/webview_go v0.0.0-20260917153919-dcf00737f32a/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
github.com/GopeedLab/webview_go v0.0.0-20260917155039-1ee7ffb4169a h1:yudcfXpipKLnMaaw1losn6YeOBjvj2rAFicfjqYJexY=
github.com/GopeedLab/webview_go v0.0.0-20260917155039-1ee7ffb4169a/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
github.com/GopeedLab/webview_go v0.0.0-20260917155218-bd16da0d6f53 h1:nMSHKhbrEaWPE/x3mYl/270GPjswbUmS+E7yYjly4xk=
github.com/GopeedLab/webview_go v0.0.0-20260917155218-bd16da0d6f53/go.mod h1:oNO07SCX8xgoHLds2OBwML8TU4xils88vMQ4gadKUHQ=
github.com/GopeedLab/webview_go v0.0.0-20260917161756-2a4f989dcb1d h1:4fjlhJpBWa6IcdKVPkEMMIDd6Xo9JM09dJHdKU+1V7k=
github.com/GopeedLab/webview_go v0.0.0-20260917161756-2a4f989dcb1d/go.mod h1:oNO07SCX8xgoHLds2OBwML8TU4xils88vMQ4gadKUHQ=
github.com/GopeedLab/webview_go v0.0.0-20260917163929-ca26c3e6bcfa h1:VkdBMcNHYTqCMwOOCimA7b3xZKV/qFIGrhh3+vm9n3s=
github.com/GopeedLab/webview_go v0.0.0-20260917163929-ca26c3e6bcfa/go.mod h1:oNO07SCX8xgoHLds2OBwML8TU4xils88vMQ4gadKUHQ=
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914 h1:9VJQZIZ1R6T4m+4vlNQYNXSPNUv2gf2PiTfIXiQ3MTw=
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914/go.mod h1:oNO07SCX8xgoHLds2OBwML8TU4xils88vMQ4gadKUHQ=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs=
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
@@ -45,6 +57,8 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=
github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=
github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
@@ -141,6 +155,7 @@ github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2w
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
@@ -157,12 +172,11 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -185,14 +199,12 @@ github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fclairamb/ftpserverlib v0.31.0 h1:ws4fLJvuU/mgg1uIgRjCJfc6CrFSgIBAOxVsnawXegE=
github.com/fclairamb/ftpserverlib v0.31.0/go.mod h1:C5hUT0sd9P5XsHMxsmpcrT6wdZexu3qtUu1z0Ih6aG0=
github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
@@ -202,8 +214,8 @@ github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
@@ -212,12 +224,12 @@ github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1T
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.2 h1:OXFSRyz4g20upsGDJgQG9Bak1l/ZEv8GHVYB52O71sE=
github.com/go-git/go-billy/v5 v5.9.2/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk=
github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A=
github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@@ -238,6 +250,8 @@ github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZs
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
@@ -250,8 +264,8 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
@@ -276,13 +290,10 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
@@ -310,6 +321,11 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -327,12 +343,10 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/imroc/req/v3 v3.58.0 h1:rBqJhv2gZjMymGot4vKmNSUs/mAITMpGASqElhuMvS4=
github.com/imroc/req/v3 v3.58.0/go.mod h1:rC7NTGgfMSkVWR4GO46rMeJsUfaeOpa6pLsgpq9q3zI=
github.com/imroc/req/v3 v3.52.2 h1:xJocr1aIv0a2K9knfBQ4JnZHk+kWTITdjf0mgDg229I=
github.com/imroc/req/v3 v3.52.2/go.mod h1:dBGsDloOSZJcFs6PnTjZXYBJK70OXbZpizHBLNqcH2k=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jlaffaye/ftp v0.2.4 h1:JqI85DdkfZj8ntaHk8W9U2SC3jNfiPUU70+wtIWmlfE=
github.com/jlaffaye/ftp v0.2.4/go.mod h1:Y1ZnkzxownGIuX7xQ1mQzzkZ21+DbjVIyeKL/V+IIz4=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -345,8 +359,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -356,6 +370,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -368,6 +383,8 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-ieproxy v0.0.12 h1:OZkUFJC3ESNZPQ+6LzC3VJIFSnreeFLQyqvBWtvfL2M=
@@ -405,10 +422,13 @@ github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJh
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=
github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
@@ -420,8 +440,8 @@ github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw=
github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4=
github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ=
@@ -442,18 +462,18 @@ github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 h1:gnkvpxCSzeTKovDsxeFFl+W/i6WydzSHhu5lH/RpFfM=
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7/go.mod h1:/td+qaV3q20qTbRjYV9ABWjmCkvt+Pog/S+NqLMoUKk=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -461,7 +481,10 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
@@ -481,13 +504,13 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=
github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.51.0 h1:K8exxe9zXxeRKxaXxi/GpUqYiTrtdiWP8bo1KFya6Wc=
github.com/quic-go/quic-go v0.51.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM=
github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -504,19 +527,17 @@ github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWR
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM=
github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
@@ -543,8 +564,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
@@ -585,12 +606,12 @@ go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=
go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -600,9 +621,11 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -612,8 +635,8 @@ golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -635,8 +658,8 @@ golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -658,12 +681,14 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -680,8 +705,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -714,20 +739,26 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -735,11 +766,13 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
@@ -772,8 +805,8 @@ golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -818,6 +851,8 @@ google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-4
View File
@@ -3,7 +3,6 @@ package controller
import (
"github.com/GopeedLab/gopeed/internal/tempfiles"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/rs/zerolog"
"net/http"
"net/url"
"os"
@@ -16,9 +15,6 @@ type Controller struct {
TempDir string
GetConfig func(v any)
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
// Logger is the downloader's core logger. It may be nil, in which case
// fetchers log nothing.
Logger *zerolog.Logger
FileController
//ContextDialer() (proxy.Dialer, error)
}
+1 -5
View File
@@ -12,7 +12,7 @@ import (
// Each download task will have a corresponding Fetcher instance for the management of the download task
type Fetcher interface {
Setup(ctl *controller.Controller)
Resolve(req *base.Request) error
Resolve(req *base.Request, opts *base.Options) error
Start() error
// Patch modifies task-specific data based on the protocol.
// For HTTP: can modify Request info (URL, headers, etc.)
@@ -46,10 +46,6 @@ type Uploader interface {
WaitUpload() error
}
type DownloadCounter interface {
ReceivedBytes() int64
}
// FetcherMeta defines the meta information of a fetcher.
type FetcherMeta struct {
Req *base.Request `json:"req"`
-72
View File
@@ -1,72 +0,0 @@
package fetcher
import (
"context"
"encoding/json"
"errors"
"time"
)
type TriggerSource string
const (
TriggerScheduled TriggerSource = "scheduled"
TriggerManual TriggerSource = "manual"
)
var ErrJobSkipped = errors.New("protocol job skipped")
var ErrConfigChanged = errors.New("protocol configuration changed during execution")
// ConfigState embeds the common backend-owned state section in a protocol config.
// Public config PUTs always retain this section from the latest stored config.
type ConfigState[T any] struct {
State T `json:"state"`
}
// ProtocolHost is scoped to one manager. UpdateState runs against the latest
// config under Downloader's lock; callbacks must not perform network I/O.
type ProtocolHost struct {
Read func(any) error
UpdateState func(func(config, state json.RawMessage) (any, error)) error
Fetch func(context.Context, string, int64, func([]byte) error) ([]byte, error)
}
func ReadConfig[T any](host ProtocolHost) (*T, error) {
var cfg T
if err := host.Read(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
// UpdateState gives the callback a detached config snapshot and mutable state.
// Only the state is committed; edits to the config snapshot are discarded.
func UpdateState[C, S any](host ProtocolHost, update func(*C, *S) error) error {
return host.UpdateState(func(rawConfig, rawState json.RawMessage) (any, error) {
var cfg C
var state S
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return nil, err
}
if err := json.Unmarshal(rawState, &state); err != nil {
return nil, err
}
if err := update(&cfg, &state); err != nil {
return nil, err
}
return &state, nil
})
}
type CronJob struct {
Name string
Every time.Duration
Timeout time.Duration
Run func(context.Context, TriggerSource) error
}
type CronProvider interface{ CronJobs(ProtocolHost) []CronJob }
// ConfigNormalizer is optional. It owns protocol validation and derived fields.
type ConfigNormalizer interface {
NormalizeConfig(json.RawMessage) (any, error)
}
-73
View File
@@ -1,73 +0,0 @@
# GitHub mirror execution
This package owns GitHub URL rewriting and execution strategies. It depends only
on the standard library and does not depend on Downloader, HTTP clients, Git, or
storage. Downloader persists `Config` at its top-level `githubMirror` field;
`pkg/base` preserves the existing configuration names through aliases.
## Race: resource reads
```go
body, err := githubmirror.Race(ctx, cfg.GithubMirror, rawURL,
githubmirror.Options[[]byte]{
Kind: githubmirror.Resource,
Timeout: 30 * time.Second,
},
func(ctx context.Context, candidate string) ([]byte, error) {
return fetchAndValidate(ctx, client, candidate)
},
)
```
The callback owns HTTP requests, size limits, and content validation. The first
completed valid result wins. Other operations are canceled and drained. There is
no HEAD-probe assumption or client-specific HTTP behavior inside the executor.
## Fallback: repository operations
```go
repo, err := githubmirror.Fallback(ctx, cfg.GithubMirror, officialURL,
githubmirror.Options[*git.Repository]{
Kind: githubmirror.Repository,
},
func(ctx context.Context, candidate string) (*git.Repository, error) {
// Use a clean temporary checkout for each attempt.
return clone(ctx, candidate)
},
)
```
The official URL is attempted first; mirrors follow their configured order.
jsDelivr is excluded for repositories. Stop on the first success. Perform
persistent installation or other business state changes after success, rather
than inside a potentially repeated attempt.
## Results with resources
For temporary files, set `Options[*Download].Discard` to remove the file. Return
the partial `*Download` alongside errors, so cleanup also covers failed reads or
validation. The executor discards every failure and every race loser exactly
once. The caller owns and must track/release the winning result.
- `Timeout == 0` uses 30 seconds per resource attempt and no overall deadline
for repository attempts; a negative timeout is invalid. Extension Git HTTP
transport limits connections/TLS handshakes to 10 seconds and resets a 60-second
read deadline as data arrives. Slow continuous transfers can finish, while
stalled sources fail and allow ordered fallback. Parent cancellation still applies.
- Attempts must honor the supplied context; draining waits for them to return.
- Attempt contexts are canceled after return, so callbacks must finish reading
HTTP bodies before returning. Returning a still-streaming response is unsupported.
- Discard callbacks must accept zero values, must not panic, and run serially.
- Parent cancellation wins over a result selected while the parent is canceled;
the selected result is then discarded as well.
- Errors preserve their causes through wrapping/joining for `errors.Is`.
- `github.com` and `raw.githubusercontent.com` expand into eligible mirrors.
jsDelivr is only eligible for raw source resources. `api.github.com` is never
mirrored and is rejected by the public mirror candidates API.
- Client application update checks request the official GitHub API directly,
falling back to the independent Gopeed website API on request or validation
failure. Only package downloads obtain URLs from
`POST /api/v1/mirrors/github/candidates`. Candidate generation never performs
HTTP requests.
- Disabled mirrors and other URLs execute only the original URL.
- Config is a caller-owned immutable snapshot for the duration of a call.
-90
View File
@@ -1,90 +0,0 @@
// Package githubmirror rewrites GitHub URLs and executes mirrored operations.
package githubmirror
import (
"fmt"
"net/url"
"strings"
)
type Mirror struct {
Type string `json:"type"`
URL string `json:"url"`
IsBuiltIn bool `json:"isBuiltIn"`
IsDeleted bool `json:"isDeleted"`
}
type Config struct {
Enabled bool `json:"enabled"`
Mirrors []Mirror `json:"mirrors"`
}
func DefaultConfig() *Config {
return &Config{Enabled: true, Mirrors: []Mirror{
{Type: "jsdelivr", URL: "https://fastly.jsdelivr.net/gh", IsBuiltIn: true},
{Type: "ghProxy", URL: "https://fastgit.cc", IsBuiltIn: true},
{Type: "ghProxy", URL: "https://gh-proxy.com", IsBuiltIn: true},
{Type: "ghProxy", URL: "https://ghfast.top", IsBuiltIn: true},
}}
}
func (c *Config) Validate() error {
if len(c.Mirrors) > 32 {
return fmt.Errorf("too many GitHub mirrors")
}
for _, mirror := range c.Mirrors {
u, err := url.Parse(mirror.URL)
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("invalid GitHub mirror URL")
}
if mirror.Type != "jsdelivr" && mirror.Type != "ghProxy" {
return fmt.Errorf("invalid GitHub mirror type: %s", mirror.Type)
}
}
return nil
}
// Kind describes what a candidate endpoint must support.
type Kind uint8
const (
Resource Kind = iota
Repository
)
// Candidates preserves the official URL first. jsDelivr is only eligible for
// raw source resources; Git operations use mirrors supporting Git Smart HTTP.
// GitHub API URLs are never mirrored.
func Candidates(c *Config, raw string, kind Kind) []string {
result := []string{raw}
u, err := url.Parse(raw)
if err != nil || c == nil || !c.Enabled || u.User != nil || (u.Scheme != "https" && u.Scheme != "http") {
return result
}
if u.Host != "github.com" && u.Host != "raw.githubusercontent.com" {
return result
}
seen := map[string]bool{raw: true}
for _, mirror := range c.Mirrors {
if mirror.IsDeleted {
continue
}
var candidate string
switch mirror.Type {
case "ghProxy":
candidate = strings.TrimRight(mirror.URL, "/") + "/" + raw
case "jsdelivr":
if kind == Repository || u.Host != "raw.githubusercontent.com" {
continue
}
parts := strings.SplitN(strings.TrimPrefix(u.EscapedPath(), "/"), "/", 4)
if len(parts) != 4 {
continue
}
candidate = strings.TrimRight(mirror.URL, "/") + "/" + parts[0] + "/" + parts[1] + "@" + parts[2] + "/" + parts[3]
}
if candidate != "" && !seen[candidate] {
result = append(result, candidate)
seen[candidate] = true
}
}
return result
}
-38
View File
@@ -1,38 +0,0 @@
package githubmirror
import (
"reflect"
"testing"
)
func TestMirrorCandidates(t *testing.T) {
cfg := &Config{Enabled: true, Mirrors: []Mirror{
{Type: "jsdelivr", URL: "https://cdn.example/gh/"},
{Type: "ghProxy", URL: "https://mirror.example/"},
{Type: "ghProxy", URL: "https://deleted.example", IsDeleted: true},
}}
source := "https://raw.githubusercontent.com/owner/repo/main/a.txt"
want := []string{source, "https://cdn.example/gh/owner/repo@main/a.txt", "https://mirror.example/" + source}
if got := Candidates(cfg, source, Resource); !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
repo := "https://github.com/owner/repo.git"
if got := Candidates(cfg, repo, Repository); !reflect.DeepEqual(got, []string{repo, "https://mirror.example/" + repo}) {
t.Fatalf("git candidates %v", got)
}
for _, raw := range []string{"https://github.com.evil.example/a", "https://example.com/a", "https://user:[email protected]/a"} {
if got := Candidates(cfg, raw, Resource); !reflect.DeepEqual(got, []string{raw}) {
t.Fatalf("unexpected mirror: %v", got)
}
}
apiURL := "https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10"
for _, kind := range []Kind{Resource, Repository} {
if got := Candidates(cfg, apiURL, kind); !reflect.DeepEqual(got, []string{apiURL}) {
t.Fatalf("GitHub API must never use mirrors: %v", got)
}
}
cfg.Enabled = false
if got := Candidates(cfg, source, Resource); !reflect.DeepEqual(got, []string{source}) {
t.Fatalf("disabled mirror: %v", got)
}
}
-160
View File
@@ -1,160 +0,0 @@
package githubmirror
import (
"context"
"errors"
"fmt"
"time"
)
// Options configures execution, not URL persistence. Discard releases results
// from failed attempts and successful race losers, including partial results
// returned together with an error. The caller owns the winning result.
type Options[T any] struct {
Kind Kind
// Timeout limits each attempt. Zero uses 30 seconds for resources and no
// overall deadline for repositories, whose transport owns idle timeouts.
Timeout time.Duration
Discard func(T)
}
// Attempt must honor ctx and return a completed, validated result. Its context
// is canceled after it returns, so live response streams must not be returned.
// Return any partially acquired resources along with errors so Discard can
// release them. Failed attempts must not mutate persistent business state.
type Attempt[T any] func(context.Context, string) (T, error)
const defaultTimeout = 30 * time.Second
func prepare[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) ([]string, time.Duration, error) {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
if raw == "" {
return nil, 0, errors.New("empty GitHub source URL")
}
if options.Kind != Resource && options.Kind != Repository {
return nil, 0, errors.New("invalid GitHub resource kind")
}
if options.Timeout < 0 {
return nil, 0, errors.New("invalid GitHub attempt timeout")
}
if attempt == nil {
return nil, 0, errors.New("GitHub mirror attempt is required")
}
if config != nil {
if err := config.Validate(); err != nil {
return nil, 0, err
}
}
timeout := options.Timeout
if timeout == 0 && options.Kind == Resource {
timeout = defaultTimeout
}
return Candidates(config, raw, options.Kind), timeout, nil
}
func invoke[T any](ctx context.Context, timeout time.Duration, candidate string, attempt Attempt[T]) (value T, err error) {
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, timeout)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("GitHub mirror attempt panicked: %v", recovered)
}
}()
value, err = attempt(ctx, candidate)
if err == nil {
err = ctx.Err()
}
return
}
func discard[T any](options Options[T], value T) {
if options.Discard != nil {
options.Discard(value)
}
}
// Race executes the official URL and eligible mirrors concurrently. The first
// completed successful result wins; losing attempts are canceled and drained
// before return so no temporary files or other resources outlive the call.
// HTTP status/content validation is the attempt's responsibility.
func Race[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
var zero T
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
if err != nil {
return zero, err
}
raceCtx, cancel := context.WithCancel(ctx)
defer cancel()
type outcome struct {
index int
value T
err error
}
outcomes := make(chan outcome, len(candidates))
for index, candidate := range candidates {
go func(index int, candidate string) {
value, err := invoke(raceCtx, timeout, candidate, attempt)
outcomes <- outcome{index, value, err}
}(index, candidate)
}
var winner T
won := false
failures := make([]error, len(candidates))
for range candidates {
outcome := <-outcomes
if !won && outcome.err == nil && ctx.Err() == nil {
winner = outcome.value
won = true
cancel()
} else {
discard(options, outcome.value)
if outcome.err != nil {
failures[outcome.index] = fmt.Errorf("GitHub source %d: %w", outcome.index+1, outcome.err)
}
if ctx.Err() != nil {
cancel()
}
}
}
if err := ctx.Err(); err != nil {
if won {
discard(options, winner)
}
return zero, err
}
if won {
return winner, nil
}
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
}
// Fallback executes the official URL first, then eligible mirrors in their
// configured order. It stops immediately on success and cleans every failure.
func Fallback[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
var zero T
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
if err != nil {
return zero, err
}
failures := make([]error, 0, len(candidates))
for index, candidate := range candidates {
if err := ctx.Err(); err != nil {
return zero, err
}
value, err := invoke(ctx, timeout, candidate, attempt)
if err == nil && ctx.Err() == nil {
return value, nil
}
discard(options, value)
if parentErr := ctx.Err(); parentErr != nil {
return zero, parentErr
}
failures = append(failures, fmt.Errorf("GitHub source %d: %w", index+1, err))
}
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
}
-214
View File
@@ -1,214 +0,0 @@
package githubmirror
import (
"context"
"errors"
"reflect"
"strings"
"sync"
"testing"
"time"
)
const testSource = "https://github.com/owner/repo.git"
func testConfig() *Config {
return &Config{Enabled: true, Mirrors: []Mirror{
{Type: "ghProxy", URL: "https://first.example"},
{Type: "ghProxy", URL: "https://second.example"},
}}
}
func TestRaceWinsAndDrainsOwnedResults(t *testing.T) {
config := testConfig()
candidates := Candidates(config, testSource, Resource)
var started sync.WaitGroup
started.Add(len(candidates))
discarded := []string{}
winner, err := Race(context.Background(), config, testSource, Options[string]{
Discard: func(value string) { discarded = append(discarded, value) },
}, func(ctx context.Context, address string) (string, error) {
started.Done()
started.Wait()
switch address {
case candidates[0]:
return address, errors.New("official offline")
case candidates[1]:
return address, nil
default:
<-ctx.Done()
return address, ctx.Err()
}
})
if err != nil || winner != candidates[1] {
t.Fatalf("winner=%s err=%v", winner, err)
}
if len(discarded) != 2 || !contains(discarded, candidates[0]) || !contains(discarded, candidates[2]) {
t.Fatalf("discarded %v", discarded)
}
}
func contains(values []string, value string) bool {
for _, item := range values {
if item == value {
return true
}
}
return false
}
func TestRaceCleansSimultaneousSuccessfulLosers(t *testing.T) {
config := testConfig()
var started sync.WaitGroup
started.Add(3)
cleaned := []string{}
winner, err := Race(context.Background(), config, testSource, Options[string]{Discard: func(value string) { cleaned = append(cleaned, value) }}, func(_ context.Context, address string) (string, error) {
started.Done()
started.Wait()
return address, nil
})
if err != nil || winner == "" || len(cleaned) != 2 || contains(cleaned, winner) {
t.Fatalf("winner=%s discarded=%v err=%v", winner, cleaned, err)
}
}
func TestRaceParentCancellationCleansWinnerToo(t *testing.T) {
config := testConfig()
config.Mirrors = config.Mirrors[:1]
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleaned := []string{}
winner, err := Race(ctx, config, testSource, Options[string]{Discard: func(value string) { cleaned = append(cleaned, value) }}, func(ctx context.Context, address string) (string, error) {
if address == testSource {
return address, nil
}
<-ctx.Done()
cancel()
return address, ctx.Err()
})
if !errors.Is(err, context.Canceled) || winner != "" || len(cleaned) != 2 || !contains(cleaned, testSource) {
t.Fatalf("winner=%s cleanup=%v err=%v", winner, cleaned, err)
}
}
func TestRaceTimeoutAndErrorAggregation(t *testing.T) {
expected := errors.New("offline")
config := testConfig()
config.Mirrors = config.Mirrors[:1]
discarded := 0
_, err := Race(context.Background(), config, testSource, Options[string]{Timeout: 10 * time.Millisecond, Discard: func(string) { discarded++ }}, func(ctx context.Context, address string) (string, error) {
if address == testSource {
return address, expected
}
<-ctx.Done()
return address, ctx.Err()
})
if !errors.Is(err, expected) || !errors.Is(err, context.DeadlineExceeded) || discarded != 2 {
t.Fatalf("discarded=%d err=%v", discarded, err)
}
}
func TestFallbackPreservesOrderAndStopsOnSuccess(t *testing.T) {
config := testConfig()
candidates := Candidates(config, testSource, Repository)
tried := []string{}
cleaned := []string{}
winner, err := Fallback(context.Background(), config, testSource, Options[string]{Kind: Repository, Discard: func(value string) { cleaned = append(cleaned, value) }}, func(_ context.Context, address string) (string, error) {
tried = append(tried, address)
if address == candidates[1] {
return address, nil
}
return address, errors.New("offline")
})
if err != nil || winner != candidates[1] || !reflect.DeepEqual(tried, candidates[:2]) || !reflect.DeepEqual(cleaned, candidates[:1]) {
t.Fatalf("winner=%s tried=%v discarded=%v err=%v", winner, tried, cleaned, err)
}
tried = nil
_, err = Fallback(context.Background(), config, testSource, Options[string]{Kind: Repository}, func(_ context.Context, address string) (string, error) {
tried = append(tried, address)
return address, nil
})
if err != nil || !reflect.DeepEqual(tried, candidates[:1]) {
t.Fatalf("tried=%v err=%v", tried, err)
}
}
func TestFallbackTimeoutContinuesWithFreshContext(t *testing.T) {
discarded := []string{}
winner, err := Fallback(context.Background(), testConfig(), testSource, Options[string]{Kind: Repository, Timeout: 10 * time.Millisecond, Discard: func(value string) { discarded = append(discarded, value) }}, func(ctx context.Context, address string) (string, error) {
if address == testSource {
<-ctx.Done()
return address, ctx.Err()
}
if err := ctx.Err(); err != nil {
return address, err
}
return address, nil
})
if err != nil || !strings.HasPrefix(winner, "https://first.example/") || !reflect.DeepEqual(discarded, []string{testSource}) {
t.Fatalf("winner=%s cleanup=%v err=%v", winner, discarded, err)
}
}
func TestFallbackCancellationStopsBeforeNextMirror(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
cleaned := 0
_, err := Fallback(ctx, testConfig(), testSource, Options[string]{Kind: Repository, Discard: func(string) { cleaned++ }}, func(ctx context.Context, address string) (string, error) {
calls++
cancel()
return address, ctx.Err()
})
if !errors.Is(err, context.Canceled) || calls != 1 || cleaned != 1 {
t.Fatalf("calls=%d discarded=%d err=%v", calls, cleaned, err)
}
}
func TestExecutorsDoNotStartInvalidOrCanceledRequests(t *testing.T) {
canceled, cancel := context.WithCancel(context.Background())
cancel()
for _, test := range []struct {
ctx context.Context
config *Config
options Options[string]
attempt Attempt[string]
}{
{ctx: canceled, options: Options[string]{}, attempt: func(context.Context, string) (string, error) { t.Fatal("called after cancellation"); return "", nil }},
{ctx: context.Background(), options: Options[string]{Kind: 99}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid kind"); return "", nil }},
{ctx: context.Background(), options: Options[string]{Timeout: -time.Second}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid timeout"); return "", nil }},
{ctx: context.Background(), options: Options[string]{}, attempt: nil},
{ctx: context.Background(), config: &Config{Mirrors: []Mirror{{Type: "invalid", URL: "https://mirror.example"}}}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid config"); return "", nil }},
} {
if _, err := Race(test.ctx, test.config, testSource, test.options, test.attempt); err == nil {
t.Fatal("invalid race accepted")
}
if _, err := Fallback(test.ctx, test.config, testSource, test.options, test.attempt); err == nil {
t.Fatal("invalid fallback accepted")
}
}
}
func TestAttemptPanicBecomesError(t *testing.T) {
attempt := func(context.Context, string) (string, error) { panic("broken operation") }
if _, err := Race(context.Background(), nil, testSource, Options[string]{}, attempt); err == nil || !strings.Contains(err.Error(), "broken operation") {
t.Fatal(err)
}
if _, err := Fallback(context.Background(), nil, testSource, Options[string]{}, attempt); err == nil || !strings.Contains(err.Error(), "broken operation") {
t.Fatal(err)
}
}
func TestNonGithubResourcesAndDisabledMirrorsUseOnlyOriginal(t *testing.T) {
for _, test := range []struct {
config *Config
raw string
}{{testConfig(), "https://example.com/resource"}, {&Config{Enabled: false, Mirrors: testConfig().Mirrors}, testSource}} {
calls := 0
got, err := Race(context.Background(), test.config, test.raw, Options[string]{}, func(_ context.Context, address string) (string, error) { calls++; return address, nil })
if err != nil || got != test.raw || calls != 1 {
t.Fatalf("got=%s calls=%d err=%v", got, calls, err)
}
}
}
func TestRepositoryAttemptsHaveNoDefaultDeadline(t *testing.T) {
value, err := Fallback(context.Background(), nil, testSource, Options[string]{Kind: Repository}, func(ctx context.Context, raw string) (string, error) {
if _, ok := ctx.Deadline(); ok {
t.Fatal("repository attempt has a total deadline")
}
return raw, nil
})
if err != nil || value != testSource {
t.Fatalf("value=%s error=%v", value, err)
}
}
+2 -27
View File
@@ -1,37 +1,12 @@
package bt
import (
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
type trackerSubscriptionStatus struct {
Success bool `json:"success"`
TrackerCount int `json:"trackerCount"`
LastFetchTime time.Time `json:"lastFetchTime"`
}
type state struct {
TrackerSubscriptionStatuses map[string]trackerSubscriptionStatus `json:"trackerSubscriptionStatuses"`
TrackerSources map[string][]string `json:"trackerSources"`
LastTrackerUpdateTime time.Time `json:"lastTrackerUpdateTime"`
}
type config struct {
fetcher.ConfigState[state]
TrackerSubscribeURLs []string `json:"trackerSubscribeUrls"`
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
CustomTrackers []string `json:"customTrackers"`
ListenPort int `json:"listenPort"`
ListenPort int `json:"listenPort"`
Trackers []string `json:"trackers"`
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
SeedKeep bool `json:"seedKeep"`
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
SeedRatio float64 `json:"seedRatio"`
// SeedTime is the time in seconds to seed after downloading is complete.
SeedTime int64 `json:"seedTime"`
// DisableDHT and DisablePEX turn off DHT and PEX peer discovery. Like ListenPort,
// they are read when the shared client is built.
DisableDHT bool `json:"disableDht"`
DisablePEX bool `json:"disablePex"`
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+17 -145
View File
@@ -8,7 +8,6 @@ import (
"os"
"path/filepath"
"reflect"
"sync"
"testing"
"github.com/GopeedLab/gopeed/internal/controller"
@@ -19,11 +18,11 @@ import (
)
func TestFetcher_Resolve_Torrent(t *testing.T) {
doResolve(t, buildFetcher(t))
doResolve(t, buildFetcher())
}
func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
fetcher := buildFetcher(t)
fetcher := buildFetcher()
buf, err := os.ReadFile("./testdata/ubuntu-22.04-live-server-amd64.iso.torrent")
if err != nil {
t.Fatal(err)
@@ -32,7 +31,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
err = fetcher.Resolve(&base.Request{
URL: dataUri,
})
}, nil)
if err != nil {
panic(err)
}
@@ -54,7 +53,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
}
func TestFetcher_Config(t *testing.T) {
doResolve(t, buildConfigFetcher(t, nil))
doResolve(t, buildConfigFetcher(nil))
}
func TestFetcher_ResolveWithProxy(t *testing.T) {
@@ -62,7 +61,7 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
proxyListener := test.StartSocks5Server(usr, pwd)
defer proxyListener.Close()
doResolve(t, buildConfigFetcher(t, &base.DownloaderProxyConfig{
doResolve(t, buildConfigFetcher(&base.DownloaderProxyConfig{
Enable: true,
System: false,
Scheme: "socks5",
@@ -72,82 +71,6 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
}))
}
func TestFetcher_CloseAfterInit(t *testing.T) {
if err := closeClient(); err != nil {
t.Fatal(err)
}
for i := 0; i < 100; i++ {
f := buildFetcher(t).(*Fetcher)
f.meta.Req = &base.Request{}
lock.Lock()
err := f.initClient()
lock.Unlock()
if err != nil {
t.Fatal(err)
}
if err := closeClient(); err != nil {
t.Fatal(err)
}
}
}
func TestFetcher_CloseWithoutClient(t *testing.T) {
if err := closeClient(); err != nil {
t.Fatal(err)
}
t.Run("restored task", func(t *testing.T) {
_, build := new(FetcherManager).Restore()
f := build(&fetcher.FetcherMeta{
Req: &base.Request{URL: "./testdata/test.torrent"},
Opts: &base.Options{},
}, &fetcherData{})
f.Setup(controller.NewController())
if err := f.Close(); err != nil {
t.Errorf("Close() got = %v, want nil", err)
}
})
t.Run("failed resolve", func(t *testing.T) {
f := buildFetcher(t)
if err := f.Resolve(&base.Request{URL: "./testdata/not-exist.torrent"}); err == nil {
t.Fatal("Resolve() got = nil, want error")
}
if err := f.Close(); err != nil {
t.Errorf("Close() got = %v, want nil", err)
}
})
}
func TestFetcher_ResolveWhileClientCloses(t *testing.T) {
if err := closeClient(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { closeClient() })
var wg sync.WaitGroup
for _, u := range []string{"./testdata/test.torrent", "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent"} {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
f := buildFetcher(t)
// The other goroutine may close the client after the torrent was added.
if err := f.Resolve(&base.Request{URL: u}); err != nil && err.Error() != "torrent closed during resolution" {
t.Errorf("Resolve(%s) got = %v", u, err)
return
}
f.Close()
if err := closeClient(); err != nil {
t.Errorf("closeClient() got = %v", err)
return
}
}
}()
}
wg.Wait()
}
func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
t.Run("Resolve Single File", func(t *testing.T) {
err := fetcher.Resolve(&base.Request{
@@ -158,7 +81,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
"udp://tracker.bitsearch.to:1337/announce",
},
},
})
}, nil)
if err != nil {
panic(err)
}
@@ -188,7 +111,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
"udp://tracker.bitsearch.to:1337/announce",
},
},
})
}, nil)
if err != nil {
panic(err)
}
@@ -222,7 +145,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
err := fetcher.Resolve(&base.Request{
URL: "./testdata/test.unclean.torrent",
})
}, nil)
if err != nil {
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
}
@@ -233,7 +156,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
uri := "file:///" + file
err := fetcher.Resolve(&base.Request{
URL: uri,
})
}, nil)
if err != nil {
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
}
@@ -288,7 +211,7 @@ func TestFetcherManager_ParseName(t *testing.T) {
}
}
func buildFetcher(t testing.TB) fetcher.Fetcher {
func buildFetcher() fetcher.Fetcher {
fb := new(FetcherManager)
fetcher := fb.Build()
newController := controller.NewController()
@@ -296,15 +219,14 @@ func buildFetcher(t testing.TB) fetcher.Fetcher {
json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v)
}
fetcher.Setup(newController)
t.Cleanup(func() { fetcher.Close(); closeClient() })
return fetcher
}
func buildConfigFetcher(t testing.TB, proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
fetcher := new(FetcherManager).Build()
newController := controller.NewController()
mockCfg := config{
CustomTrackers: []string{
Trackers: []string{
"udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
}}
@@ -315,30 +237,29 @@ func buildConfigFetcher(t testing.TB, proxyConfig *base.DownloaderProxyConfig) f
return proxyConfig.ToHandler()
}
fetcher.Setup(newController)
t.Cleanup(func() { fetcher.Close(); closeClient() })
return fetcher
}
// TestFetcher_Patch tests the Patch functionality for BT fetcher.
// It tests modifying selected files after Resolve (without downloading).
func TestFetcher_Patch(t *testing.T) {
f := buildFetcher(t)
f := buildFetcher()
// Resolve a multi-file torrent
err := f.Resolve(&base.Request{
URL: "./testdata/test.torrent",
})
}, nil)
if err != nil {
t.Fatalf("Resolve failed: %v", err)
}
// Resolution describes files without choosing download options.
// Verify initial state: 3 files, all selected by default
meta := f.Meta()
if len(meta.Res.Files) != 3 {
t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files))
}
if meta.Opts != nil {
t.Fatal("Resolve should not assign download options")
if len(meta.Opts.SelectFiles) != 3 {
t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles))
}
// Total size of all files: 107484864
@@ -445,52 +366,3 @@ func TestFetcher_Patch(t *testing.T) {
}
})
}
func TestFetcher_PeerDiscoveryConfig(t *testing.T) {
tests := []struct {
name string
stored any
wantDisableDHT bool
wantDisablePEX bool
}{
{"default config", new(FetcherManager).DefaultConfig(), false, false},
{"stored config without the fields", map[string]any{"listenPort": 0, "seedRatio": 1.0}, false, false},
{"both disabled", map[string]any{"disableDht": true, "disablePex": true}, true, true},
{"only DHT disabled", map[string]any{"disableDht": true}, true, false},
{"only PEX disabled", map[string]any{"disablePex": true}, false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := buildFetcherWithConfig(tt.stored).(*Fetcher)
if f.config.DisableDHT != tt.wantDisableDHT || f.config.DisablePEX != tt.wantDisablePEX {
t.Errorf("config got DisableDHT = %v, DisablePEX = %v, want %v, %v", f.config.DisableDHT, f.config.DisablePEX, tt.wantDisableDHT, tt.wantDisablePEX)
}
})
}
}
func TestFetcher_PeerDiscoveryDisabledOnClient(t *testing.T) {
if err := closeClient(); err != nil {
t.Fatal(err)
}
f := buildFetcherWithConfig(map[string]any{"disableDht": true, "disablePex": true})
if err := f.Resolve(&base.Request{URL: "./testdata/test.torrent"}); err != nil {
t.Fatal(err)
}
if !cfg.NoDHT || !cfg.DisablePEX {
t.Errorf("client config got NoDHT = %v, DisablePEX = %v, want true", cfg.NoDHT, cfg.DisablePEX)
}
if n := len(client.DhtServers()); n != 0 {
t.Errorf("client got %d DHT servers, want 0", n)
}
}
func buildFetcherWithConfig(stored any) fetcher.Fetcher {
fetcher := new(FetcherManager).Build()
newController := controller.NewController()
newController.GetConfig = func(v any) {
json.Unmarshal([]byte(test.ToJson(stored)), v)
}
fetcher.Setup(newController)
return fetcher
}
-217
View File
@@ -1,217 +0,0 @@
package bt
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/merkle"
"github.com/anacrolix/torrent/metainfo"
"golang.org/x/time/rate"
)
// btFixture serves generated, trackerless content over a real loopback TCP peer.
// Its HTTP tracker exercises .torrent downloads; magnet links also carry the
// explicit x.pe address.
type btFixture struct {
MetaInfo *metainfo.MetaInfo
Magnet string
TorrentFile string
DataURI string
TrackerURL string
Files map[string][]byte // Paths relative to the selected download directory.
Seeder *torrent.Client
Torrent *torrent.Torrent
Limiter *rate.Limiter
}
func newBTFixture(t testing.TB, files map[string][]byte, bytesPerSecond int) *btFixture {
return makeBTFixture(t, files, bytesPerSecond, false, true)
}
func newBTHybridFixture(t testing.TB, payload []byte) *btFixture {
return makeBTFixture(t, map[string][]byte{"": payload}, 0, true, true)
}
func makeBTFixture(t testing.TB, files map[string][]byte, bytesPerSecond int, v2, private bool) *btFixture {
t.Helper()
return makeBTFixtureWithPieceSize(t, files, bytesPerSecond, v2, private, 16*1024)
}
func makeBTFixtureWithPieceSize(t testing.TB, files map[string][]byte, bytesPerSecond int, v2, private bool, pieceSize int64) *btFixture {
t.Helper()
source := t.TempDir()
name := "payload.bin"
if _, single := files[""]; !single {
name = "bundle"
}
root := filepath.Join(source, name)
expected := make(map[string][]byte, len(files))
for path, data := range files {
target := filepath.Join(root, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, data, 0600); err != nil {
t.Fatal(err)
}
expected[filepath.Join(name, filepath.FromSlash(path))] = append([]byte(nil), data...)
}
info := metainfo.Info{PieceLength: pieceSize}
if err := info.BuildFromFilePath(root); err != nil {
t.Fatal(err)
}
var layers map[string]string
if v2 {
data := files[""]
h := merkle.NewHash()
h.Write(data)
root := h.Sum(nil)
var pieceHashes []byte
for off := 0; off < len(data); off += int(info.PieceLength) {
end := min(off+int(info.PieceLength), len(data))
ph := merkle.NewHash()
ph.Write(data[off:end])
pieceHashes = append(pieceHashes, ph.SumMinLength(nil, int(info.PieceLength))...)
}
info = metainfo.Info{Name: name, PieceLength: info.PieceLength, Length: int64(len(data)), Pieces: info.Pieces, MetaVersion: 2, FileTree: metainfo.FileTree{File: metainfo.FileTreeFile{Length: int64(len(data)), PiecesRoot: string(root)}}}
layers = map[string]string{string(root): string(pieceHashes)}
}
// Private torrents avoid public DHT discovery once metadata is available.
info.Private = &private
raw, err := bencode.Marshal(&info)
if err != nil {
t.Fatal(err)
}
mi := &metainfo.MetaInfo{InfoBytes: raw, PieceLayers: layers}
cfg := torrent.TestingConfig(t)
cfg.DataDir = source
cfg.Seed = true
cfg.DisableUTP = true
cfg.DisableIPv6 = true
cfg.DisableWebtorrent = true
cfg.KeepAliveTimeout = time.Minute
cfg.MaxAllocPeerRequestDataPerConn = 1 << 20
limiter := rate.NewLimiter(rate.Inf, 16*1024)
if bytesPerSecond > 0 {
limiter.SetLimit(rate.Limit(bytesPerSecond))
}
cfg.UploadRateLimiter = limiter
seed, err := torrent.NewClient(cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { seed.Close() })
seeded, _, err := seed.AddTorrentSpec(torrent.TorrentSpecFromMetaInfo(mi))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := seeded.VerifyDataContext(ctx); err != nil {
t.Fatal(err)
}
select {
case <-seeded.Complete().On():
case <-ctx.Done():
t.Fatal("local seeder did not finish verification")
}
addr := seed.ListenAddrs()[0].String()
host, portString, err := net.SplitHostPort(addr)
if err != nil {
t.Fatal(err)
}
resolved, err := net.ResolveTCPAddr("tcp4", net.JoinHostPort(host, portString))
if err != nil {
t.Fatal(err)
}
compact := make([]byte, 6)
copy(compact, resolved.IP.To4())
binary.BigEndian.PutUint16(compact[4:], uint16(resolved.Port))
tracker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response, err := bencode.Marshal(map[string]any{"interval": 3600, "peers": string(compact)})
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Write(response)
}))
t.Cleanup(tracker.Close)
// The pinned library has a hybrid tracker-dispatch assertion bug.
// Hybrid fixtures use their explicit loopback peer rather than a tracker.
if !v2 {
mi.Announce = tracker.URL
}
var encoded bytes.Buffer
if err := mi.Write(&encoded); err != nil {
t.Fatal(err)
}
torrentFile := filepath.Join(t.TempDir(), "fixture.torrent")
if err := os.WriteFile(torrentFile, encoded.Bytes(), 0600); err != nil {
t.Fatal(err)
}
m, err := mi.MagnetV2()
if err != nil {
t.Fatal(err)
}
magnet := m.String()
u, err := url.Parse(magnet)
if err != nil {
t.Fatal(err)
}
q := u.Query()
q.Add("x.pe", addr)
u.RawQuery = q.Encode()
return &btFixture{MetaInfo: mi, Magnet: u.String(), TorrentFile: torrentFile, DataURI: "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes()), TrackerURL: tracker.URL, Files: expected, Seeder: seed, Torrent: seeded, Limiter: limiter}
}
func btPayload(size int, label string) []byte {
data := make([]byte, size)
for i := range data {
data[i] = byte((i*31 + i/251) % 251)
}
copy(data, label)
return data
}
func (f *btFixture) AssertFiles(t testing.TB, dir string, paths ...string) {
t.Helper()
if len(paths) == 0 {
for path := range f.Files {
paths = append(paths, path)
}
}
for _, path := range paths {
got, err := os.ReadFile(filepath.Join(dir, path))
if err != nil {
t.Fatal(err)
}
want, ok := f.Files[path]
if !ok {
t.Fatalf("unknown fixture file %q", path)
}
if !bytes.Equal(got, want) {
t.Fatalf("BT file %s differs: received %d bytes, want %d", path, len(got), len(want))
}
}
}
func (f *btFixture) FileURI() string {
path := filepath.ToSlash(f.TorrentFile)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return (&url.URL{Scheme: "file", Path: path}).String()
}
+9 -69
View File
@@ -17,85 +17,25 @@ import (
"github.com/anacrolix/torrent/storage"
)
func TestStartDoesNotPublishUnverifiedCompletion(t *testing.T) {
cfg := torrent.TestingConfig(t)
cfg.DisableTCP = true
cfg.DisableUTP = true
c, err := torrent.NewClient(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
payload := btPayload(32*1024, "unverified")
info := metainfo.Info{Name: "payload.bin", Length: int64(len(payload)), PieceLength: 16 * 1024}
for offset := 0; offset < len(payload); offset += int(info.PieceLength) {
hash := sha1.Sum(payload[offset : offset+int(info.PieceLength)])
info.Pieces = append(info.Pieces, hash[:]...)
}
raw, err := bencode.Marshal(info)
if err != nil {
t.Fatal(err)
}
store := &deferredFileStorage{}
spec := torrent.TorrentSpecFromMetaInfo(&metainfo.MetaInfo{InfoBytes: raw})
spec.Storage = store
tor, _, err := c.AddTorrentSpec(spec)
if err != nil {
t.Fatal(err)
}
tor.DisallowDataDownload()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f := &Fetcher{torrent: tor, storage: store, meta: &fetcher.FetcherMeta{}, data: &fetcherData{}, torrentDropCtx: ctx, torrentDropFunc: cancel, config: &config{SeedKeep: true}}
f.torrentReady.Store(true)
f.updateRes()
dir := t.TempDir()
payload[20000] ^= 0xff
if err := os.WriteFile(filepath.Join(dir, info.Name), payload, 0600); err != nil {
t.Fatal(err)
}
f.meta.Opts = &base.Options{Path: dir}
// Retain the original event channel to detect even a transient completion.
// With no network transports, the damaged piece cannot have been repaired.
completed := tor.Complete().On()
if err := f.Start(); err != nil {
t.Fatal(err)
}
select {
case <-completed:
t.Fatal("Start published completion before verifying the existing file")
default:
}
}
func TestStartUsesSelectionSubmittedAfterResolve(t *testing.T) {
cfg := torrent.TestingConfig(t)
cfg.DataDir = t.TempDir()
cfg.DisableTCP = true
cfg.DisableUTP = true
c, err := torrent.NewClient(cfg)
config := torrent.TestingConfig(t)
config.DataDir = t.TempDir()
config.DisableTCP = true
config.DisableUTP = true
c, err := torrent.NewClient(config)
if err != nil {
t.Fatal(err)
}
defer c.Close()
store := &deferredFileStorage{}
mi, err := metainfo.LoadFromFile("testdata/test.torrent")
tor, err := c.AddTorrentFromFile("testdata/test.torrent")
if err != nil {
t.Fatal(err)
}
spec := torrent.TorrentSpecFromMetaInfo(mi)
spec.Storage = store
tor, _, err := c.AddTorrentSpec(spec)
if err != nil {
t.Fatal(err)
}
tor.DisallowDataDownload()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f := &Fetcher{torrent: tor, storage: store, meta: &fetcher.FetcherMeta{}, data: &fetcherData{}, torrentDropCtx: ctx, torrentDropFunc: cancel, config: &config{SeedKeep: true}}
f := &Fetcher{torrent: tor, meta: &fetcher.FetcherMeta{Opts: &base.Options{}}, data: &fetcherData{}}
f.torrentReady.Store(true)
f.updateRes()
f.meta.Opts = &base.Options{Path: t.TempDir(), SelectFiles: []int{2}}
// Resolve initializes all files; creation replaces the selection before Start.
f.meta.Opts = &base.Options{SelectFiles: []int{2}}
if err := f.Start(); err != nil {
t.Fatal(err)
}
-116
View File
@@ -1,116 +0,0 @@
package bt
import (
"context"
"errors"
"os"
"sync"
g "github.com/anacrolix/generics"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
)
// deferredFileStorage lets metadata resolve without opening the user's files.
// It binds once. After binding, pieces are supplied directly by file storage.
type deferredFileStorage struct {
mu sync.RWMutex
ctx context.Context
info *metainfo.Info
hash metainfo.Hash
knownHashes []bool
owner storage.ClientImplCloser
torrent storage.TorrentImpl
closed bool
}
func (s *deferredFileStorage) OpenTorrent(ctx context.Context, info *metainfo.Info, hash metainfo.Hash) (storage.TorrentImpl, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed || s.info != nil {
return storage.TorrentImpl{}, errors.New("torrent storage already opened or closed")
}
s.ctx, s.info, s.hash = ctx, info, hash
s.knownHashes = make([]bool, info.NumPieces())
return storage.TorrentImpl{
PieceWithHash: func(p metainfo.Piece, hash g.Option[[]byte]) storage.PieceImpl {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return metadataPiece{err: os.ErrClosed}
}
if s.owner == nil {
s.knownHashes[p.Index()] = hash.Ok
// A missing v2 hash must remain unknown until its arrival changes
// completion and updates the library's piece request priorities.
return metadataPiece{known: hash.Ok}
}
if s.torrent.PieceWithHash != nil {
return s.torrent.PieceWithHash(p, hash)
}
return s.torrent.Piece(p)
},
Close: s.Close,
}, nil
}
func (s *deferredFileStorage) bind(opts storage.NewFileClientOpts) ([]bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil, os.ErrClosed
}
if s.info == nil {
return nil, errors.New("torrent metadata is not ready")
}
if s.owner != nil {
return nil, nil
}
if opts.FilePathMaker == nil {
opts.FilePathMaker = func(o storage.FilePathMakerOpts) string { _, name := torrentFileLayout(o.Info, *o.File); return name }
}
owner := storage.NewFileOpts(opts)
t, err := owner.OpenTorrent(s.ctx, s.info, s.hash)
if err != nil {
owner.Close()
return nil, err
}
s.owner, s.torrent = owner, t
return append([]bool(nil), s.knownHashes...), nil
}
func (s *deferredFileStorage) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.closed = true
if s.owner == nil {
return nil
}
var err error
if s.torrent.Close != nil {
err = s.torrent.Close()
}
return errors.Join(err, s.owner.Close())
}
type metadataPiece struct {
known bool
err error
}
func (p metadataPiece) Completion() storage.Completion {
return storage.Completion{Ok: p.known, Err: p.err}
}
func (p metadataPiece) ReadAt([]byte, int64) (int, error) {
return 0, errors.New("download storage is not initialized")
}
func (p metadataPiece) WriteAt([]byte, int64) (int, error) {
return 0, errors.New("download storage is not initialized")
}
func (p metadataPiece) MarkComplete() error { return errors.New("download storage is not initialized") }
func (p metadataPiece) MarkNotComplete() error {
return errors.New("download storage is not initialized")
}
-234
View File
@@ -1,234 +0,0 @@
package bt
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"sync"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
var defaultTrackerSubscribeURLs = []string{
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt",
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt",
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt",
}
func validTracker(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" || u.User != nil {
return false
}
switch u.Scheme {
case "udp", "http", "https", "ws", "wss":
return true
}
return false
}
func uniqueTrackers(groups ...[]string) []string {
result := []string{}
seen := map[string]bool{}
for _, group := range groups {
for _, raw := range group {
value := strings.TrimSpace(raw)
if value != "" && !seen[value] {
seen[value] = true
result = append(result, value)
}
}
}
return result
}
// effectiveTrackers derives the client list without persisting duplicate lists.
func (c *config) effectiveTrackers() []string {
groups := [][]string{c.CustomTrackers}
for _, source := range c.TrackerSubscribeURLs {
groups = append(groups, c.State.TrackerSources[source])
}
return uniqueTrackers(groups...)
}
func (fm *FetcherManager) NormalizeConfig(raw json.RawMessage) (any, error) {
c := fm.DefaultConfig().(*config)
if err := json.Unmarshal(raw, c); err != nil {
return nil, err
}
if c.ListenPort < 0 || c.ListenPort > 65535 || c.SeedRatio < 0 || c.SeedTime < 0 {
return nil, errors.New("invalid BT configuration")
}
if len(c.TrackerSubscribeURLs) > 64 {
return nil, errors.New("too many tracker subscriptions")
}
c.TrackerSubscribeURLs = uniqueTrackers(c.TrackerSubscribeURLs)
for _, source := range c.TrackerSubscribeURLs {
u, err := url.Parse(source)
if err != nil || u == nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil {
return nil, errors.New("invalid tracker subscription URL")
}
}
c.CustomTrackers = uniqueTrackers(c.CustomTrackers)
for _, tracker := range c.CustomTrackers {
if !validTracker(tracker) {
return nil, fmt.Errorf("invalid tracker URL: %s", tracker)
}
}
if c.State.TrackerSources == nil {
c.State.TrackerSources = map[string][]string{}
}
for source := range c.State.TrackerSources {
if !slices.Contains(c.TrackerSubscribeURLs, source) {
delete(c.State.TrackerSources, source)
}
}
if c.State.TrackerSubscriptionStatuses == nil {
c.State.TrackerSubscriptionStatuses = map[string]trackerSubscriptionStatus{}
}
for source := range c.State.TrackerSubscriptionStatuses {
if !slices.Contains(c.TrackerSubscribeURLs, source) {
delete(c.State.TrackerSubscriptionStatuses, source)
}
}
return c, nil
}
func (fm *FetcherManager) CronJobs(host fetcher.ProtocolHost) []fetcher.CronJob {
return []fetcher.CronJob{{
Name: "update-trackers",
Every: 24 * time.Hour,
Timeout: 90 * time.Second,
Run: func(ctx context.Context, source fetcher.TriggerSource) error {
return fm.updateTrackers(ctx, host, source)
},
}}
}
func parseTrackers(data []byte) ([]string, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 4096), 64*1024)
result := []string{}
for scanner.Scan() {
line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "\ufeff"))
if validTracker(line) {
result = append(result, line)
}
if len(result) > 20000 {
return nil, errors.New("too many trackers")
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if len(result) == 0 {
return nil, errors.New("subscription contains no valid trackers")
}
return uniqueTrackers(result), nil
}
func (fm *FetcherManager) updateTrackers(ctx context.Context, host fetcher.ProtocolHost, source fetcher.TriggerSource) error {
snapshot, err := fetcher.ReadConfig[config](host)
if err != nil {
return err
}
if (source == fetcher.TriggerScheduled && !snapshot.AutoUpdateTrackers) || len(snapshot.TrackerSubscribeURLs) == 0 {
return fetcher.ErrJobSkipped
}
fetched := map[string][]string{}
statuses := map[string]trackerSubscriptionStatus{}
var failures []error
var mu sync.Mutex
var wg sync.WaitGroup
slots := make(chan struct{}, 4)
for _, address := range snapshot.TrackerSubscribeURLs {
wg.Add(1)
go func(address string) {
defer wg.Done()
select {
case slots <- struct{}{}:
defer func() { <-slots }()
case <-ctx.Done():
return
}
data, err := host.Fetch(ctx, address, 2<<20, func(body []byte) error { _, err := parseTrackers(body); return err })
var trackers []string
if err == nil {
trackers, err = parseTrackers(data)
}
mu.Lock()
defer mu.Unlock()
statuses[address] = trackerSubscriptionStatus{Success: err == nil, TrackerCount: len(trackers), LastFetchTime: time.Now().UTC()}
if err != nil {
failures = append(failures, fmt.Errorf("subscription %s: %w", address, err))
} else {
fetched[address] = trackers
}
}(address)
}
wg.Wait()
if err := ctx.Err(); err != nil {
failures = append(failures, err)
}
if len(statuses) == 0 {
return errors.Join(failures...)
}
err = fetcher.UpdateState[config, state](host, func(current *config, state *state) error {
if !slices.Equal(current.TrackerSubscribeURLs, snapshot.TrackerSubscribeURLs) {
return fetcher.ErrConfigChanged
}
if state.TrackerSources == nil {
state.TrackerSources = map[string][]string{}
}
if state.TrackerSubscriptionStatuses == nil {
state.TrackerSubscriptionStatuses = map[string]trackerSubscriptionStatus{}
}
for address, status := range statuses {
state.TrackerSubscriptionStatuses[address] = status
}
for address, trackers := range fetched {
state.TrackerSources[address] = trackers
}
if len(failures) == 0 {
state.LastTrackerUpdateTime = time.Now().UTC()
}
return nil
})
if err != nil {
return err
}
current, err := fetcher.ReadConfig[config](host)
if err != nil {
return err
}
fm.applyTrackers(current.effectiveTrackers())
return errors.Join(failures...)
}
func (fm *FetcherManager) applyTrackers(trackers []string) {
lock.Lock()
defer lock.Unlock()
if client == nil {
return
}
announce := make([][]string, 0, len(trackers))
for _, tracker := range trackers {
announce = append(announce, []string{tracker})
}
for _, t := range client.Torrents() {
info := t.Info()
if info == nil || (info.Private != nil && *info.Private) {
continue
}
t.AddTrackers(announce)
}
}
-217
View File
@@ -1,217 +0,0 @@
package bt
import (
"context"
"encoding/json"
"errors"
"reflect"
"sync"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
func trackerTestHost(t *testing.T, c *config, fetch func(context.Context, string, int64, func([]byte) error) ([]byte, error)) (fetcher.ProtocolHost, *sync.Mutex) {
t.Helper()
mu := &sync.Mutex{}
return fetcher.ProtocolHost{
Read: func(dst any) error {
mu.Lock()
defer mu.Unlock()
raw, err := json.Marshal(c)
if err != nil {
return err
}
return json.Unmarshal(raw, dst)
},
UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
mu.Lock()
defer mu.Unlock()
raw, _ := json.Marshal(c)
currentState, _ := json.Marshal(c.State)
next, err := change(raw, currentState)
if err != nil {
return err
}
raw, err = json.Marshal(next)
if err != nil {
return err
}
return json.Unmarshal(raw, &c.State)
},
Fetch: fetch,
}, mu
}
func TestTrackerRefreshPartialSuccessUsesLatestConfig(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
cfg.TrackerSubscribeURLs = []string{"https://a.example/list", "https://b.example/list"}
cfg.CustomTrackers = []string{"udp://custom.example:80/announce"}
cfg.State.TrackerSources = map[string][]string{cfg.TrackerSubscribeURLs[0]: {"udp://old.example:80/announce"}, cfg.TrackerSubscribeURLs[1]: {"udp://cached.example:80/announce"}}
host, mu := trackerTestHost(t, cfg, nil)
host.Fetch = func(ctx context.Context, address string, _ int64, validate func([]byte) error) ([]byte, error) {
if address == "https://b.example/list" {
return nil, errors.New("offline")
}
// Simulate a frontend update while the network request is in flight.
mu.Lock()
cfg.CustomTrackers = []string{"udp://new-custom.example:80/announce"}
mu.Unlock()
data := []byte("udp://fresh.example:80/announce\n# ignored\nudp://fresh.example:80/announce\n")
return data, validate(data)
}
err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual)
if err == nil {
t.Fatal("partial failure must be reported")
}
want := []string{"udp://new-custom.example:80/announce", "udp://fresh.example:80/announce", "udp://cached.example:80/announce"}
if !reflect.DeepEqual(cfg.effectiveTrackers(), want) {
t.Fatalf("got %v", cfg.effectiveTrackers())
}
success := cfg.State.TrackerSubscriptionStatuses["https://a.example/list"]
failure := cfg.State.TrackerSubscriptionStatuses["https://b.example/list"]
if !success.Success || success.TrackerCount != 1 || success.LastFetchTime.IsZero() || failure.Success || failure.TrackerCount != 0 || failure.LastFetchTime.IsZero() {
t.Fatalf("unexpected subscription statuses: %+v", cfg.State.TrackerSubscriptionStatuses)
}
if !cfg.State.LastTrackerUpdateTime.IsZero() {
t.Fatal("partial refresh marked fully successful")
}
}
func TestTrackerRefreshDiscardsChangedSubscriptions(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
cfg.TrackerSubscribeURLs = []string{"https://a.example/list"}
host, mu := trackerTestHost(t, cfg, nil)
host.Fetch = func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
mu.Lock()
cfg.TrackerSubscribeURLs = []string{}
mu.Unlock()
return []byte("udp://fresh.example:80/announce"), nil
}
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); !errors.Is(err, fetcher.ErrConfigChanged) {
t.Fatal(err)
}
if len(cfg.State.TrackerSources) != 0 {
t.Fatal("outdated source was committed")
}
}
func TestTrackerRefreshManualWorksWhenAutomaticDisabled(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
cfg.AutoUpdateTrackers = false
cfg.TrackerSubscribeURLs = []string{"https://a.example/list"}
calls := 0
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
calls++
return []byte("udp://fresh.example:80/announce"), nil
})
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerScheduled); !errors.Is(err, fetcher.ErrJobSkipped) {
t.Fatal(err)
}
if calls != 0 {
t.Fatal("scheduled job fetched while disabled")
}
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); err != nil {
t.Fatal(err)
}
if calls != 1 || cfg.State.LastTrackerUpdateTime.IsZero() {
t.Fatal("manual refresh did not succeed")
}
}
func TestTrackerConfigPreservesExplicitSettings(t *testing.T) {
value, err := (&FetcherManager{}).NormalizeConfig(json.RawMessage(`{
"listenPort":6881, "trackerSubscribeUrls":[], "autoUpdateTrackers":false,
"customTrackers":["udp://custom.example:80/announce"]
}`))
if err != nil {
t.Fatal(err)
}
cfg := value.(*config)
if cfg.AutoUpdateTrackers || len(cfg.TrackerSubscribeURLs) != 0 || cfg.ListenPort != 6881 {
t.Fatalf("%+v", cfg)
}
if !reflect.DeepEqual(cfg.effectiveTrackers(), cfg.CustomTrackers) {
t.Fatalf("derived trackers: %v", cfg.effectiveTrackers())
}
}
func TestParseTrackerList(t *testing.T) {
got, err := parseTrackers([]byte("\ufeffudp://tracker.example:80/announce\r\n\n<html>error</html>\nhttps://tracker.example/announce\n"))
if err != nil || len(got) != 2 {
t.Fatalf("%v %v", got, err)
}
if _, err := parseTrackers([]byte("<html>mirror error</html>")); err == nil {
t.Fatal("invalid mirror body accepted")
}
}
func TestTrackerDefaultsRegisterDailyJob(t *testing.T) {
jobs := (&FetcherManager{}).CronJobs(fetcher.ProtocolHost{})
if len(jobs) != 1 || jobs[0].Name != "update-trackers" || jobs[0].Every != 24*time.Hour {
t.Fatal(jobs)
}
}
func TestTrackerRefreshAllFailuresPersistStatusesAndKeepCache(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
address := "https://custom.example/trackers.txt"
cfg.TrackerSubscribeURLs = []string{address}
cfg.State.TrackerSources[address] = []string{"udp://cached.example:80/announce"}
cfg.State.TrackerSubscriptionStatuses[address] = trackerSubscriptionStatus{Success: true, TrackerCount: 1, LastFetchTime: time.Now().Add(-time.Hour)}
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
return nil, errors.New("offline")
})
before := time.Now()
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); err == nil {
t.Fatal("failure was not reported")
}
status, ok := cfg.State.TrackerSubscriptionStatuses[address]
if !ok || status.Success || status.TrackerCount != 0 || status.LastFetchTime.Before(before) {
t.Fatalf("failed fetch status not stored: %+v", status)
}
if !reflect.DeepEqual(cfg.State.TrackerSources[address], []string{"udp://cached.example:80/announce"}) {
t.Fatal("last good cache lost")
}
if !reflect.DeepEqual(cfg.effectiveTrackers(), cfg.State.TrackerSources[address]) {
t.Fatal("cached trackers not retained")
}
}
func TestTrackerConfigPrunesRemovedSubscriptions(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
address := "https://custom.example/trackers.txt"
cfg.TrackerSubscribeURLs = []string{}
cfg.State.TrackerSources[address] = []string{"udp://cached.example:80/announce"}
cfg.State.TrackerSubscriptionStatuses[address] = trackerSubscriptionStatus{Success: true, TrackerCount: 1, LastFetchTime: time.Now()}
raw, _ := json.Marshal(cfg)
value, err := fm.NormalizeConfig(raw)
if err != nil {
t.Fatal(err)
}
next := value.(*config)
if len(next.State.TrackerSources) != 0 || len(next.State.TrackerSubscriptionStatuses) != 0 || len(next.effectiveTrackers()) != 0 {
t.Fatal("removed subscription still owns cached results")
}
}
func TestTrackerRefreshTimeoutPersistsCompletedAttemptStatus(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
address := "https://custom.example/trackers.txt"
cfg.TrackerSubscribeURLs = []string{address}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
cancel()
return nil, context.DeadlineExceeded
})
if err := fm.updateTrackers(ctx, host, fetcher.TriggerManual); !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
status, ok := cfg.State.TrackerSubscriptionStatuses[address]
if !ok || status.Success || status.TrackerCount != 0 || status.LastFetchTime.IsZero() {
t.Fatalf("timeout status lost: %+v", status)
}
}
+5 -2
View File
@@ -67,14 +67,17 @@ func (f *Fetcher) Setup(ctl *controller.Controller) {
f.ctl.GetConfig(&f.config)
}
func (f *Fetcher) Resolve(req *base.Request) error {
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
link, err := parseLink(req.URL)
if err != nil {
return err
}
f.meta.Req = req
f.meta.Opts = opts
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
}
f.meta.Res = buildResource(link)
return nil
}
+1 -2
View File
@@ -13,8 +13,7 @@ func TestFetcher_Resolve(t *testing.T) {
f := (&FetcherManager{}).Build()
f.Setup(controller.NewController())
f.Meta().Opts = &base.Options{Path: t.TempDir()}
err := f.Resolve(&base.Request{URL: testLink})
err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
-404
View File
@@ -1,404 +0,0 @@
package ftp
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/textproto"
"net/url"
"strings"
"sync"
"time"
"github.com/jlaffaye/ftp"
"golang.org/x/net/proxy"
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
)
const (
connectTimeout = 15 * time.Second
readTimeout = 15 * time.Second
quitTimeout = 2 * time.Second
)
// target is a parsed FTP URL. It holds the password, so it must never be
// formatted into an error or a log line.
type target struct {
host string // host name or IP, the TLS server name
addr string // host:port
user string
pass string
path string // path relative to the login directory, "" for the directory itself
tlsMode string
}
// parseTarget reads an ftp, ftps or ftpes URL. The path follows RFC 1738: it
// is relative to the login directory, and an encoded leading slash
// (ftp://host/%2Fpub) makes it absolute.
func parseTarget(rawURL, optTLS string) (*target, error) {
u, err := url.Parse(rawURL)
if err != nil {
// url.Error repeats the whole URL, password included.
return nil, errors.New("ftp: invalid URL")
}
t := &target{}
switch strings.ToLower(u.Scheme) {
case "ftps":
t.tlsMode = pftp.TLSImplicit
case "ftpes":
t.tlsMode = pftp.TLSExplicit
case "ftp":
switch strings.ToLower(optTLS) {
case "", pftp.TLSNone:
t.tlsMode = pftp.TLSNone
case pftp.TLSExplicit:
t.tlsMode = pftp.TLSExplicit
case pftp.TLSImplicit:
t.tlsMode = pftp.TLSImplicit
default:
return nil, fmt.Errorf("ftp: unknown tls mode %q", optTLS)
}
default:
return nil, fmt.Errorf("ftp: unsupported scheme %q", u.Scheme)
}
t.host = u.Hostname()
if t.host == "" {
return nil, errors.New("ftp: URL has no host")
}
port := u.Port()
if port == "" {
port = "21"
if t.tlsMode == pftp.TLSImplicit {
port = "990"
}
}
t.addr = net.JoinHostPort(t.host, port)
if u.User != nil {
// Username and Password are already percent-decoded.
t.user = u.User.Username()
t.pass, _ = u.User.Password()
}
if t.user == "" {
t.user, t.pass = "anonymous", "anonymous@"
}
t.path = strings.TrimPrefix(u.Path, "/")
return t, nil
}
type dialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
// session is one logged-in control connection. It owns the network
// connections it opens, so abort can unblock any read or write in progress.
type session struct {
*ftp.ServerConn
ctrl net.Conn // control connection as dialed, under any TLS layer
dataHost string // where data connections go
mu sync.Mutex
data net.Conn // current data connection as dialed
closed bool
stop func() bool
}
// deadline bounds the next control-connection exchange. The control
// connection is idle during a transfer, so every exchange sets a new one.
func (s *session) deadline() {
_ = s.ctrl.SetDeadline(time.Now().Add(readTimeout))
}
// clearDeadline lets the control connection stay idle through a transfer.
func (s *session) clearDeadline() {
_ = s.ctrl.SetDeadline(time.Time{})
}
// abort closes every connection at once. Pending calls fail immediately.
func (s *session) abort() {
s.mu.Lock()
defer s.mu.Unlock()
s.closed = true
_ = s.ctrl.Close()
if s.data != nil {
_ = s.data.Close()
}
}
// quit logs out and waits, briefly, for the server to hang up. Servers that
// cap logins count a session until they have seen it end, so the next login
// would otherwise be refused.
func (s *session) quit() {
if s.stop != nil {
s.stop()
}
_ = s.ctrl.SetDeadline(time.Now().Add(quitTimeout))
if s.ServerConn != nil {
_ = s.ServerConn.Quit()
}
s.abort()
}
func (s *session) setData(c net.Conn) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
_ = c.Close()
return net.ErrClosed
}
s.data = c
return nil
}
// ctrlConn is the control connection handed to the ftp library.
type ctrlConn struct {
net.Conn
}
// RemoteAddr must be a *net.TCPAddr: the library asserts it. A connection
// through a proxy may report something else, and its address is not used
// for data connections anyway (see dialData).
func (c ctrlConn) RemoteAddr() net.Addr {
if a, ok := c.Conn.RemoteAddr().(*net.TCPAddr); ok {
return a
}
return &net.TCPAddr{}
}
// Close is called by the library's Quit right after it sends QUIT. Reading
// until the server hangs up (bounded by the deadline quit set) tells us the
// server has ended the session.
func (c ctrlConn) Close() error {
_, _ = io.Copy(io.Discard, c.Conn)
return c.Conn.Close()
}
// dial opens a control connection, upgrades it to TLS when asked, and logs in.
//
// The library is given a dial function for every connection. That is how
// data connections go through the same proxy as the control connection, but
// it also means the library does no TLS on them: with a dial function it
// neither wraps an implicit-TLS control connection nor any data connection.
// We do both here. Explicit TLS (AUTH TLS) is still done by the library, and
// so are PBSZ and PROT P after login. For the same reason the library's
// DialWithTimeout and DialWithContext would have no effect: connectTimeout
// and ctx are applied to our own dials instead.
func (f *Fetcher) dial(ctx context.Context, t *target) (*session, error) {
netDial, proxied, err := f.netDialer(t)
if err != nil {
return nil, err
}
var tlsConf *tls.Config
if t.tlsMode != pftp.TLSNone {
tlsConf = f.tlsConfig(t.host)
}
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
raw, err := netDial(dialCtx, "tcp", t.addr)
if err != nil {
return nil, err
}
ctrl := raw
if t.tlsMode == pftp.TLSImplicit {
tc := tls.Client(raw, tlsConf)
if err := tc.HandshakeContext(dialCtx); err != nil {
_ = raw.Close()
return nil, err
}
ctrl = tc
}
s := &session{ctrl: raw, dataHost: t.host}
if a, ok := raw.RemoteAddr().(*net.TCPAddr); ok && !proxied {
// Same IP as the control connection, as the library itself does,
// so a name with several addresses cannot send us elsewhere.
s.dataHost = a.IP.String()
}
s.stop = context.AfterFunc(ctx, s.abort)
s.deadline()
handedOut := false
opts := []ftp.DialOption{
ftp.DialWithShutTimeout(readTimeout),
ftp.DialWithDialFunc(func(network, addr string) (net.Conn, error) {
if !handedOut {
// Dial asks for the control connection first, once.
handedOut = true
return ctrlConn{ctrl}, nil
}
return s.dialData(ctx, netDial, tlsConf, addr)
}),
}
switch t.tlsMode {
case pftp.TLSImplicit:
opts = append(opts, ftp.DialWithTLS(tlsConf))
case pftp.TLSExplicit:
opts = append(opts, ftp.DialWithExplicitTLS(tlsConf))
}
c, err := ftp.Dial(t.addr, opts...)
if err != nil {
s.stop()
s.abort()
return nil, err
}
s.ServerConn = c
s.deadline()
if err := c.Login(t.user, t.pass); err != nil {
s.stop()
s.abort()
return nil, err
}
return s, nil
}
// dialData opens a passive data connection. The library takes the host from
// the control connection's peer, which through a proxy is the proxy, so only
// the port is used here.
func (s *session) dialData(ctx context.Context, netDial dialContextFunc, tlsConf *tls.Config, addr string) (net.Conn, error) {
_, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
raw, err := netDial(dialCtx, "tcp", net.JoinHostPort(s.dataHost, port))
if err != nil {
return nil, err
}
if err := s.setData(raw); err != nil {
return nil, err
}
if tlsConf != nil {
// The handshake runs on the first read, as in the library, because
// some servers only start it once the transfer command is answered.
return tls.Client(raw, tlsConf), nil
}
return raw, nil
}
// tlsConfig returns the TLS settings for one control connection and its data
// connections. Certificates are verified unless the request sets
// skipVerifyCert, as for HTTP.
//
// Each call gets its own one-entry session cache, so a data connection
// resumes the TLS session of its own control connection. vsftpd
// (require_ssl_reuse) only checks that some session was resumed, but ProFTPD's
// mod_tls also checks that it is the control connection's; a cache shared by
// several workers would hand a data connection another worker's session.
// A cache in the manager's base config is kept as given.
func (f *Fetcher) tlsConfig(host string) *tls.Config {
cfg := &tls.Config{}
if f.manager != nil && f.manager.baseTLS != nil {
cfg = f.manager.baseTLS.Clone()
}
cfg.ServerName = host
if f.meta.Req != nil && f.meta.Req.SkipVerifyCert {
cfg.InsecureSkipVerify = true
}
if cfg.ClientSessionCache == nil {
cfg.ClientSessionCache = tls.NewLRUClientSessionCache(1)
}
return cfg
}
// netDialer picks how to reach the server, from the same proxy settings the
// HTTP protocol reads (the task's proxy, otherwise the global one).
func (f *Fetcher) netDialer(t *target) (dialContextFunc, bool, error) {
direct := (&net.Dialer{Timeout: connectTimeout}).DialContext
pu, err := f.proxyURL(t)
if err != nil {
return nil, false, err
}
if pu == nil {
return direct, false, nil
}
switch strings.ToLower(pu.Scheme) {
case "socks5", "socks5h":
d, err := proxy.FromURL(pu, &net.Dialer{Timeout: connectTimeout})
if err != nil {
return nil, false, fmt.Errorf("ftp: socks5 proxy: %w", err)
}
if cd, ok := d.(proxy.ContextDialer); ok {
return cd.DialContext, true, nil
}
return func(_ context.Context, network, addr string) (net.Conn, error) {
return d.Dial(network, addr)
}, true, nil
default:
// An HTTP proxy is not used. Every passive data connection goes to
// a port the server picks, and HTTP proxies usually allow CONNECT to
// port 443 only, so the transfer would fail after the login worked.
f.warnOnce.Do(func() {
if l := f.logger(); l != nil {
l.Warn().Msgf("ftp: the %s proxy at %s cannot carry FTP data connections (HTTP proxy), connecting directly; use a SOCKS5 proxy for FTP", strings.ToLower(pu.Scheme), pu.Host)
}
})
return direct, false, nil
}
}
func (f *Fetcher) proxyURL(t *target) (*url.URL, error) {
if f.ctl == nil || f.ctl.GetProxy == nil {
return nil, nil
}
handler := f.ctl.GetProxy(f.meta.Req.Proxy)
if handler == nil {
return nil, nil
}
// The proxy chooser reads only the URL; the credentials stay out.
req := &http.Request{URL: &url.URL{Scheme: "ftp", Host: t.addr, Path: "/" + t.path}, Header: http.Header{}}
pu, err := handler(req)
if err != nil {
return nil, errors.New("ftp: cannot read the proxy settings")
}
return pu, nil
}
// isLoginRefusal tells a server that is full apart from bad credentials.
// 421 always means "not now". 530 means a refused login; it is only a
// capacity limit when these credentials have worked before. The library
// drops the code when USER itself is refused, so a "too many" message counts
// as well.
func isLoginRefusal(err error, credentialsWorked bool) bool {
var te *textproto.Error
if errors.As(err, &te) {
switch te.Code {
case ftp.StatusNotAvailable:
return true
case ftp.StatusNotLoggedIn:
return credentialsWorked || strings.Contains(strings.ToLower(te.Msg), "too many")
}
return false
}
return strings.Contains(strings.ToLower(err.Error()), "too many")
}
// isPermanent reports errors a retry cannot fix: a 5xx reply and a
// certificate that does not verify.
func isPermanent(err error) bool {
var te *textproto.Error
if errors.As(err, &te) {
return te.Code >= 500
}
var verifyErr *tls.CertificateVerificationError
if errors.As(err, &verifyErr) {
return true
}
var recordErr tls.RecordHeaderError
if errors.As(err, &recordErr) {
return true
}
return errors.Is(err, errSizeChanged)
}
func replyCode(err error) int {
var te *textproto.Error
if errors.As(err, &te) {
return te.Code
}
return 0
}
-146
View File
@@ -1,146 +0,0 @@
package ftp_test
import (
"bytes"
"strings"
"sync"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/GopeedLab/gopeed/internal/logger"
iftp "github.com/GopeedLab/gopeed/internal/protocol/ftp"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
)
// syncBuffer is a log sink that the downloader's goroutines can share.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// taskEvents records the first done or error event of every task, so the
// test never reads a task's status while the downloader writes it.
type taskEvents struct {
mu sync.Mutex
final map[string]download.EventKey
}
func (e *taskEvents) listen(ev *download.Event) {
if ev.Key != download.EventKeyDone && ev.Key != download.EventKeyError {
return
}
e.mu.Lock()
defer e.mu.Unlock()
if _, seen := e.final[ev.Task.ID]; !seen {
e.final[ev.Task.ID] = ev.Key
}
}
func (e *taskEvents) wait(t *testing.T, id string) download.EventKey {
t.Helper()
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
e.mu.Lock()
key, ok := e.final[id]
e.mu.Unlock()
if ok {
return key
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("task %s did not finish", id)
return ""
}
// TestPasswordNeverLogged runs FTP tasks through the downloader with its core
// logger captured: a success, a missing file and a wrong password, all behind
// an HTTP proxy that FTP must skip with a warning.
func TestPasswordNeverLogged(t *testing.T) {
srv := iftp.StartFTPServer(t, iftp.ServerOptions{User: "u", Pass: "p@ss"})
want := srv.WriteFile(t, "secret.bin", iftp.RandomBytes(300000, 21))
d := download.NewDownloader(&download.DownloaderConfig{Storage: download.NewMemStorage(), StorageDir: t.TempDir()})
logs := &syncBuffer{}
d.Logger = &logger.Logger{Logger: zerolog.New(logs)}
events := &taskEvents{final: map[string]download.EventKey{}}
d.Listener(events.listen)
if err := d.Setup(); err != nil {
t.Fatal(err)
}
// Close, not Clear: Clear resets the extension list without a lock while
// the last task's done hooks may still read it.
defer d.Close()
cfg, err := d.GetConfig()
if err != nil {
t.Fatal(err)
}
cfg.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: "127.0.0.1:1", Usr: "proxyuser", Pwd: "proxysecret"}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
create := func(rawURL string) string {
id, err := d.CreateDirect(&base.Request{URL: rawURL}, &base.Options{Path: dir, Extra: &pftp.OptsExtra{Connections: 2}})
if err != nil {
if strings.Contains(err.Error(), "p@ss") || strings.Contains(err.Error(), "p%40ss") {
t.Fatalf("the password leaked into an error: %v", err)
}
t.Fatal(err)
}
return id
}
root := "ftp://u:p%40ss@" + srv.Addr + "/"
okID := create(root + "secret.bin")
if key := events.wait(t, okID); key != download.EventKeyDone {
t.Fatalf("download ended with %q, logs:\n%s", key, logs.String())
}
ok := d.GetTask(okID)
if ok.Protocol != "ftp" {
t.Fatalf("protocol %q, want ftp", ok.Protocol)
}
if got := iftp.FileSHA256(t, ok.Meta.SingleFilepath()); got != want {
t.Fatalf("sha256 %s, want %s", got, want)
}
missingID := create(root + "missing.bin")
if key := events.wait(t, missingID); key != download.EventKeyError {
t.Fatalf("a missing file must fail, got %q", key)
}
wrongID := create("ftp://u:wr0ng%40pw@" + srv.Addr + "/secret.bin")
if key := events.wait(t, wrongID); key != download.EventKeyError {
t.Fatalf("a wrong password must fail, got %q", key)
}
missing, wrong := d.GetTask(missingID), d.GetTask(wrongID)
for _, task := range []*download.Task{ok, missing, wrong} {
if name := task.Name(); strings.Contains(name, "p@ss") || strings.Contains(name, "p%40ss") || strings.Contains(name, "wr0ng") {
t.Fatalf("task name %q shows the password", name)
}
}
out := logs.String()
if !strings.Contains(out, "task id") || !strings.Contains(out, "HTTP proxy") {
t.Fatalf("the captured log misses the expected lines, so it proves nothing:\n%s", out)
}
for _, secret := range []string{"p@ss", "p%40ss", "wr0ng", "proxysecret"} {
if strings.Contains(out, secret) {
t.Fatalf("%q appears in the log:\n%s", secret, out)
}
}
}
File diff suppressed because it is too large. Load diff
-109
View File
@@ -1,109 +0,0 @@
package ftp
import (
"crypto/tls"
"net/url"
"path"
"strings"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
)
// fetcherData is what a task saves to resume later. Segments has the same
// Begin, End and Downloaded fields every version writes; the other fields are
// optional, so a save without them still restores.
type fetcherData struct {
Segments []*segment
// MaxConns is the login ceiling the server enforced, 0 when none was hit.
MaxConns int `json:",omitempty"`
// NoRest records that the server refused REST, so the task downloads each
// file in one sequential stream.
NoRest bool `json:",omitempty"`
}
// FetcherManager handles ftp://, ftps:// (implicit TLS) and ftpes:// (explicit
// TLS) URLs.
type FetcherManager struct {
// baseTLS is the base for every TLS connection. Nil means the system
// roots; the package's tests set it to trust their own certificate. The
// task's skipVerifyCert still turns verification off.
baseTLS *tls.Config
}
func (fm *FetcherManager) Name() string {
return "ftp"
}
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
return []*fetcher.SchemeFilter{
{Type: fetcher.FilterTypeUrl, Pattern: "FTP"},
{Type: fetcher.FilterTypeUrl, Pattern: "FTPS"},
{Type: fetcher.FilterTypeUrl, Pattern: "FTPES"},
}
}
func (fm *FetcherManager) Build() fetcher.Fetcher {
return &Fetcher{manager: fm}
}
// ParseName names a task before it is resolved. It never includes the
// userinfo of the URL.
func (fm *FetcherManager) ParseName(u string) string {
parsed, err := url.Parse(u)
if err != nil {
return ""
}
name := path.Base(strings.TrimSuffix(parsed.Path, "/"))
if name == "" || name == "/" || name == "." {
name = parsed.Hostname()
}
return name
}
func (fm *FetcherManager) AutoRename() bool {
return true
}
func (fm *FetcherManager) DefaultConfig() any {
return &config{Connections: defaultConnections}
}
func (fm *FetcherManager) Store(f fetcher.Fetcher) (any, error) {
ff := f.(*Fetcher)
ff.mu.Lock()
defer ff.mu.Unlock()
segments := make([]*segment, len(ff.segments))
for i, s := range ff.segments {
c := *s
segments[i] = &c
}
return &fetcherData{
Segments: segments,
MaxConns: ff.maxConns,
NoRest: ff.noRest,
}, nil
}
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
fd := v.(*fetcherData)
ff := fm.Build().(*Fetcher)
ff.meta = meta
if meta != nil && meta.Req != nil {
_ = base.ParseReqExtra[pftp.ReqExtra](meta.Req)
}
if meta != nil && meta.Opts != nil {
_ = base.ParseOptExtra[pftp.OptsExtra](meta.Opts)
}
ff.segments = fd.Segments
ff.maxConns = fd.MaxConns
ff.noRest = fd.NoRest
return ff
}
}
func (fm *FetcherManager) Close() error {
return nil
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-187
View File
@@ -1,187 +0,0 @@
package ftp
import (
"cmp"
"fmt"
"path"
"slices"
"strings"
"time"
"github.com/jlaffaye/ftp"
"github.com/GopeedLab/gopeed/pkg/base"
)
// Limits of a directory download. A tree past either one is refused with an
// error that names the limit; it is never cut short.
const maxWalkDepth = 8
// maxWalkFiles is a variable so tests can lower it.
var maxWalkFiles = 10000
// resolveTarget describes the URL's path: one file, or a directory walked
// into a multi-file resource whose paths are relative to it.
func (f *Fetcher) resolveTarget(s *session, t *target) (*base.Resource, error) {
isDir, size, mtime, err := f.stat(s, t.path)
if err != nil {
return nil, err
}
if !isDir {
return &base.Resource{
Size: size,
Range: true,
Files: []*base.FileInfo{{
Name: path.Base(t.path),
Size: size,
Ctime: mtime,
}},
}, nil
}
files, err := f.walk(s, t.path)
if err != nil {
return nil, err
}
name := path.Base(strings.TrimSuffix(t.path, "/"))
if name == "" || name == "." || name == "/" {
name = t.host
}
res := &base.Resource{Name: name, Range: true, Files: files}
res.CalcSize(nil)
return res, nil
}
// stat tells a file from a directory. MLST answers both at once. Without
// it, SIZE answers for a file, and a directory is one we can change into.
func (f *Fetcher) stat(s *session, p string) (isDir bool, size int64, mtime *time.Time, err error) {
if p == "" || strings.HasSuffix(p, "/") {
return true, 0, nil, nil
}
s.deadline()
if entry, mlstErr := s.GetEntry(p); mlstErr == nil {
switch entry.Type {
case ftp.EntryTypeFolder:
return true, 0, nil, nil
case ftp.EntryTypeFile:
if !entry.Time.IsZero() {
t := entry.Time
mtime = &t
}
return false, int64(entry.Size), mtime, nil
}
// A link: SIZE below follows it on the server.
}
// MLST is missing or refused the path: the older commands decide. A
// broken connection fails them too, with its own error.
s.deadline()
size, sizeErr := s.FileSize(p)
if sizeErr == nil {
if s.IsGetTimeSupported() {
s.deadline()
if t, err := s.GetTime(p); err == nil {
mtime = &t
}
}
return false, size, mtime, nil
}
if replyCode(sizeErr) == 0 {
return false, 0, nil, sizeErr
}
// SIZE failed: a directory, or a missing path.
s.deadline()
cwd, err := s.CurrentDir()
if err != nil {
return false, 0, nil, err
}
s.deadline()
if err := s.ChangeDir(p); err != nil {
return false, 0, nil, sizeErr
}
s.deadline()
if err := s.ChangeDir(cwd); err != nil {
return false, 0, nil, err
}
return true, 0, nil, nil
}
// safeEntryName reports whether a name from a listing can become a local
// path element. A hostile server could otherwise list "../x" and write
// outside the task directory.
func safeEntryName(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
return !strings.ContainsAny(name, "/\\\x00")
}
// walk lists a directory tree, at most maxWalkDepth levels below root and
// maxWalkFiles files. Files come back sorted by path, then name.
func (f *Fetcher) walk(s *session, root string) ([]*base.FileInfo, error) {
type dir struct {
remote string // path on the server
rel string // path below root, "" for root itself
depth int
}
var files []*base.FileInfo
stack := []dir{{remote: strings.TrimSuffix(root, "/"), rel: "", depth: 0}}
for len(stack) > 0 {
d := stack[len(stack)-1]
stack = stack[:len(stack)-1]
s.deadline()
entries, err := s.List(d.remote)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.Name == "." || e.Name == ".." {
continue
}
if !safeEntryName(e.Name) {
f.warn("ftp: skipping a listed name that is not a safe file name: %q", e.Name)
continue
}
remote := path.Join(d.remote, e.Name)
rel := path.Join(d.rel, e.Name)
if d.remote == "" {
remote = e.Name
}
entryType, size := e.Type, int64(e.Size)
if entryType == ftp.EntryTypeLink {
// A link to a file has a size; a link to a directory is
// not followed, so a loop cannot make the walk endless.
s.deadline()
linkSize, err := s.FileSize(remote)
if err != nil {
f.warn("ftp: skipping link %q: %v", rel, err)
continue
}
entryType, size = ftp.EntryTypeFile, linkSize
}
switch entryType {
case ftp.EntryTypeFolder:
if d.depth+1 > maxWalkDepth {
return nil, fmt.Errorf("ftp: the folder is deeper than %d levels, the limit for a folder download", maxWalkDepth)
}
stack = append(stack, dir{remote: remote, rel: rel, depth: d.depth + 1})
case ftp.EntryTypeFile:
if len(files) >= maxWalkFiles {
return nil, fmt.Errorf("ftp: the folder has more than %d files, the limit for a folder download", maxWalkFiles)
}
fi := &base.FileInfo{Name: e.Name, Path: d.rel, Size: size}
if !e.Time.IsZero() {
t := e.Time
fi.Ctime = &t
}
files = append(files, fi)
}
}
}
slices.SortFunc(files, func(a, b *base.FileInfo) int {
return cmp.Or(strings.Compare(a.Path, b.Path), strings.Compare(a.Name, b.Name))
})
return files, nil
}
+6 -2
View File
@@ -172,11 +172,11 @@ func (f *Fetcher) Meta() *fetcher.FetcherMeta {
return f.meta
}
func (f *Fetcher) Resolve(req *base.Request) (err error) {
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) (err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.meta.Req = req
f.meta.Opts = opts
f.extra = &ReqExtra{}
if err = base.ParseReqExtra[ReqExtra](req); err != nil {
return
@@ -194,6 +194,10 @@ func (f *Fetcher) Resolve(req *base.Request) (err error) {
}
state.StagingID = randomStagingID()
f.state = state
// Pin the staging folder now so the very first persisted state already
// carries it: a crash right after create still resumes into the same
// folder.
f.resolveStagingDirLocked()
size := planTotalSize(state.Segments)
f.meta.Res = &base.Resource{
Range: false,
+8 -16
View File
@@ -80,8 +80,7 @@ func mustResolve(t *testing.T, f *Fetcher, playlistURL, referer string) {
if referer != "" {
req.Extra = &ReqExtra{Header: map[string]string{"Referer": referer}}
}
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(req); err != nil {
if err := f.Resolve(req, &base.Options{Path: t.TempDir()}); err != nil {
t.Fatalf("resolve: %v", err)
}
}
@@ -208,8 +207,7 @@ func TestFetcherE2E_RefererEnforced(t *testing.T) {
// Without Referer the resolve itself must fail (403 on the playlist).
blocked := newTestFetcher(t, testConfig())
req := &base.Request{URL: server.URL + "/video.m3u8"}
blocked.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := blocked.Resolve(req); err == nil {
if err := blocked.Resolve(req, &base.Options{Path: t.TempDir()}); err == nil {
t.Fatal("resolve without Referer should fail")
}
@@ -219,8 +217,7 @@ func TestFetcherE2E_RefererEnforced(t *testing.T) {
URL: server.URL + "/video.m3u8",
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
}
allowed.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := allowed.Resolve(allowedReq); err != nil {
if err := allowed.Resolve(allowedReq, &base.Options{Path: t.TempDir()}); err != nil {
t.Fatal(err)
}
mustRun(t, allowed)
@@ -563,8 +560,7 @@ func TestFetcherE2E_StoreRestoreAuthenticated(t *testing.T) {
URL: server.URL + "/video.m3u8",
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
}
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(req); err != nil {
if err := f.Resolve(req, &base.Options{Path: t.TempDir()}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -634,8 +630,7 @@ func TestFetcherE2E_PatchPartialUpdate(t *testing.T) {
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
}
opts := &base.Options{Path: t.TempDir()}
f.Meta().Opts = opts
if err := f.Resolve(req); err != nil {
if err := f.Resolve(req, opts); err != nil {
t.Fatal(err)
}
@@ -718,8 +713,7 @@ func TestFetcherE2E_PatchWhileRunningRejected(t *testing.T) {
defer server.Close()
f := newTestFetcher(t, testConfig())
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -773,8 +767,7 @@ func TestFetcherE2E_RetryAfterFailure(t *testing.T) {
cfg.MaxRetries = 0 // fail the run on the first bad segment
cfg.PrefetchContentLength = false
f := newTestFetcher(t, cfg)
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -835,8 +828,7 @@ func TestFetcherE2E_StateMissingSelfHeal(t *testing.T) {
defer server.Close()
f := newTestFetcher(t, testConfig())
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
t.Fatal(err)
}
// Simulate the crash window where the engine persisted the task with its
Loaded 50 of 299 files, more files were not shown because too many files have changed in this diff. Show more