diff --git a/.gitignore b/.gitignore index 36c54f1..316ae56 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,12 @@ compile_commands.json # Test scratch /tmp/ + +# Local downloads and packaging scratch +/downloads/ +/package/ +/package.zip +# Stray torrents dropped at the repo root (fixtures under tests/ stay tracked) +torrents/*.torrent +# Downloaded torrent data (capital-D dir used at runtime) +/Downloads/ diff --git a/Big Buck Bunny.torrent b/Big Buck Bunny.torrent deleted file mode 100644 index a7dbde0..0000000 Binary files a/Big Buck Bunny.torrent and /dev/null differ diff --git a/CMakeLists.txt b/CMakeLists.txt index 49b0b38..a1185e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,11 @@ set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +option(NAUT_STANDALONE + "Statically embed Jansson and Lua in the daemon and client" OFF) +option(NAUT_NATIVE + "Optimize Release builds for the build machine's CPU" OFF) + if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() @@ -12,8 +17,11 @@ endif() add_compile_definitions(_GNU_SOURCE) add_compile_options(-Wall -Wextra -Wshadow -Wvla -Wpointer-arith -fno-omit-frame-pointer) -set(CMAKE_C_FLAGS_RELEASE "-O3 -march=native -DNDEBUG") +set(CMAKE_C_FLAGS_RELEASE "-O3 -DNDEBUG") set(CMAKE_C_FLAGS_DEBUG "-O0 -g3") +if(NAUT_NATIVE) + add_compile_options($<$:-march=native>) +endif() # Sanitizer convenience build: -DNAUT_SAN=address|thread|undefined if(NAUT_SAN) @@ -29,10 +37,98 @@ find_path(URING_INC liburing.h) if(NOT URING_LIB OR NOT URING_INC) message(FATAL_ERROR "liburing not found (install liburing-dev)") endif() -find_package(OpenSSL REQUIRED COMPONENTS Crypto) -find_package(PkgConfig REQUIRED) -pkg_check_modules(JANSSON REQUIRED IMPORTED_TARGET jansson) -pkg_check_modules(LUA REQUIRED IMPORTED_TARGET lua) +find_package(OpenSSL REQUIRED COMPONENTS Crypto SSL) + +# --- external download engine + tracker/DHT protocol libraries -------------- +# torrent-peer: multi-peer download engine (engine.h) — replaces Naut's own peer +# poll loop, request pipeline, and MSE transport. +# torrent-tracker: tracker/DHT wire codec (tracker.h) — drives Naut's announce +# and get_peers glue in src/discovery. +set(PEER_NATIVE ${NAUT_NATIVE} CACHE BOOL "" FORCE) +set(TRACKER_NATIVE ${NAUT_NATIVE} CACHE BOOL "" FORCE) +set(TRACKER_TESTS OFF CACHE BOOL "" FORCE) +if(NAUT_SAN STREQUAL "address" OR NAUT_SAN STREQUAL "undefined") + set(PEER_ASAN ON CACHE BOOL "" FORCE) + set(TRACKER_ASAN ON CACHE BOOL "" FORCE) +endif() +add_subdirectory(${CMAKE_SOURCE_DIR}/../torrent-peer + ${CMAKE_BINARY_DIR}/torrent-peer) +add_subdirectory(${CMAKE_SOURCE_DIR}/../torrent-tracker + ${CMAKE_BINARY_DIR}/torrent-tracker) + +if(NAUT_STANDALONE) + include(FetchContent) + + # Jansson 2.14.1 predates CMake 4's removal of pre-3.5 policy defaults. + set(CMAKE_POLICY_VERSION_MINIMUM 3.5) + set(JANSSON_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + set(JANSSON_BUILD_DOCS OFF CACHE BOOL "" FORCE) + set(JANSSON_EXAMPLES OFF CACHE BOOL "" FORCE) + set(JANSSON_INSTALL OFF CACHE BOOL "" FORCE) + FetchContent_Declare(jansson + URL https://github.com/akheron/jansson/archive/refs/tags/v2.14.1.tar.gz + URL_HASH + SHA256=979210eaffdffbcf54cfc34d047fccde13f21b529a381df26db871d886f729a4 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE) + FetchContent_MakeAvailable(jansson) + target_include_directories(jansson INTERFACE + ${jansson_SOURCE_DIR}/src + ${jansson_BINARY_DIR}/include) + + FetchContent_Declare(lua + URL https://www.lua.org/ftp/lua-5.4.8.tar.gz + URL_HASH + SHA256=4f18ddae154e793e46eeab727c59ef1c0c0c2b744e7b94219710d76f530629ae + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + SOURCE_SUBDIR cmake-unused) + FetchContent_MakeAvailable(lua) + set(LUA_SRC_DIR ${lua_SOURCE_DIR}/src) + add_library(naut_lua STATIC + ${LUA_SRC_DIR}/lapi.c + ${LUA_SRC_DIR}/lauxlib.c + ${LUA_SRC_DIR}/lbaselib.c + ${LUA_SRC_DIR}/lcode.c + ${LUA_SRC_DIR}/lcorolib.c + ${LUA_SRC_DIR}/lctype.c + ${LUA_SRC_DIR}/ldblib.c + ${LUA_SRC_DIR}/ldebug.c + ${LUA_SRC_DIR}/ldo.c + ${LUA_SRC_DIR}/ldump.c + ${LUA_SRC_DIR}/lfunc.c + ${LUA_SRC_DIR}/lgc.c + ${LUA_SRC_DIR}/linit.c + ${LUA_SRC_DIR}/liolib.c + ${LUA_SRC_DIR}/llex.c + ${LUA_SRC_DIR}/lmathlib.c + ${LUA_SRC_DIR}/lmem.c + ${LUA_SRC_DIR}/loadlib.c + ${LUA_SRC_DIR}/lobject.c + ${LUA_SRC_DIR}/lopcodes.c + ${LUA_SRC_DIR}/loslib.c + ${LUA_SRC_DIR}/lparser.c + ${LUA_SRC_DIR}/lstate.c + ${LUA_SRC_DIR}/lstring.c + ${LUA_SRC_DIR}/lstrlib.c + ${LUA_SRC_DIR}/ltable.c + ${LUA_SRC_DIR}/ltablib.c + ${LUA_SRC_DIR}/ltm.c + ${LUA_SRC_DIR}/lundump.c + ${LUA_SRC_DIR}/lutf8lib.c + ${LUA_SRC_DIR}/lvm.c + ${LUA_SRC_DIR}/lzio.c) + target_compile_definitions(naut_lua PRIVATE LUA_USE_LINUX) + target_include_directories(naut_lua PUBLIC ${LUA_SRC_DIR}) + target_link_libraries(naut_lua PUBLIC m ${CMAKE_DL_LIBS}) + + set(NAUT_JANSSON_TARGET jansson) + set(NAUT_LUA_TARGET naut_lua) +else() + find_package(PkgConfig REQUIRED) + pkg_check_modules(JANSSON REQUIRED IMPORTED_TARGET jansson) + pkg_check_modules(LUA REQUIRED IMPORTED_TARGET lua) + set(NAUT_JANSSON_TARGET PkgConfig::JANSSON) + set(NAUT_LUA_TARGET PkgConfig::LUA) +endif() # --- core: zero-dependency foundation --------------------------------------- add_library(naut_core STATIC @@ -62,21 +158,23 @@ target_link_libraries(naut_bencode PUBLIC naut_core) add_library(naut_metainfo STATIC src/metainfo/metainfo.c src/metainfo/magnet.c) target_link_libraries(naut_metainfo PUBLIC naut_bencode naut_crypto) -# --- tracker: HTTP + UDP announce (codec + blocking fetch) ------------------ -add_library(naut_tracker STATIC - src/tracker/tracker.c src/tracker/udp.c src/tracker/fetch.c) -target_link_libraries(naut_tracker PUBLIC naut_bencode) +# --- discovery: tracker announce + DHT get_peers glue over torrent-tracker --- +add_library(naut_discovery STATIC + src/discovery/tracker_client.c src/discovery/dht_client.c) +target_link_libraries(naut_discovery PUBLIC naut_core torrenttracker) -# --- dht: BEP-5 KRPC codec + bounded iterative peer lookup ------------------ -add_library(naut_dht STATIC src/dht/dht.c src/dht/fetch.c) -target_link_libraries(naut_dht PUBLIC naut_bencode naut_tracker) +# --- net: blocking HTTP/HTTPS client (RSS feeds, Torznab search) ------------- +# PIC so it can be linked into the webui plugin module; naut_log symbols resolve +# from the host executable at load time, like the rest of the plugin. +add_library(naut_net STATIC src/net/http_client.c) +set_target_properties(naut_net PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_link_libraries(naut_net PUBLIC OpenSSL::SSL OpenSSL::Crypto) -# --- peer: wire protocol codec (sans-IO) ------------------------------------ +# --- peer: wire protocol codec (sans-IO), retained for magnet metadata ------- add_library(naut_peer STATIC - src/peer/wire.c src/peer/extension.c src/peer/metadata.c src/peer/mse.c - src/peer/pipeline.c) + src/peer/wire.c src/peer/extension.c src/peer/metadata.c) target_link_libraries(naut_peer PUBLIC - naut_core naut_crypto naut_bencode naut_tracker OpenSSL::Crypto m) + naut_core naut_crypto naut_bencode m) # --- storage: file backend -------------------------------------------------- add_library(naut_storage STATIC src/storage/storage.c) @@ -95,6 +193,10 @@ add_library(naut_platform STATIC target_include_directories(naut_platform PUBLIC ${URING_INC}) target_link_libraries(naut_platform PUBLIC naut_core ${URING_LIB}) +# CPU topology helpers do not require the io_uring platform backend. +add_library(naut_system STATIC src/platform/system.c) +target_link_libraries(naut_system PUBLIC naut_core) + # --- session + extensibility control plane --------------------------------- add_library(naut_session STATIC src/session/event.c) target_link_libraries(naut_session PUBLIC naut_core) @@ -105,36 +207,40 @@ target_link_libraries(naut_torrents PUBLIC naut_storage) add_library(naut_rpc STATIC src/rpc/rpc.c) target_link_libraries(naut_rpc PUBLIC - naut_session naut_core PkgConfig::JANSSON) + naut_session naut_core ${NAUT_JANSSON_TARGET}) add_library(naut_plugin STATIC src/plugin/plugin.c) target_link_libraries(naut_plugin PUBLIC naut_rpc naut_session dl) add_library(naut_script STATIC src/script/script.c) target_link_libraries(naut_script PUBLIC - naut_session naut_core PkgConfig::LUA) + naut_session naut_core ${NAUT_LUA_TARGET}) add_library(naut_example MODULE plugins/example/example.c) target_include_directories(naut_example PRIVATE ${CMAKE_SOURCE_DIR}/include) set_target_properties(naut_example PROPERTIES PREFIX "") -# --- echo: Phase 1 gate (io_uring echo server on the buffer pool) ----------- -add_executable(naut_echo apps/echo/main.c) -target_link_libraries(naut_echo PRIVATE naut_platform naut_core) +# SQLite backs the webui account store. +find_package(PkgConfig REQUIRED) +pkg_check_modules(SQLITE3 REQUIRED IMPORTED_TARGET sqlite3) -# --- leech: Phase 3 gate (single-peer download, byte-correct + verified) ---- -add_executable(naut_leech apps/leech/main.c) -target_link_libraries(naut_leech PRIVATE naut_piece naut_peer naut_metainfo) +add_library(naut_webui MODULE plugins/webui/webui.c plugins/webui/webui_store.c) +target_include_directories(naut_webui PRIVATE ${CMAKE_SOURCE_DIR}/include) +target_link_libraries(naut_webui PRIVATE ${NAUT_JANSSON_TARGET} naut_net + PkgConfig::SQLITE3 OpenSSL::Crypto pthread) +set_target_properties(naut_webui PROPERTIES PREFIX "") -# --- swarm: Phase 4 gate (multi-peer download, rarest-first + endgame) ------- -add_executable(naut_swarm apps/swarm/main.c) -target_link_libraries(naut_swarm PRIVATE - naut_piece naut_peer naut_metainfo naut_tracker naut_dht naut_platform) +# --- swarm: multi-peer download driver over the torrent-peer engine --------- +add_library(naut_swarm_engine STATIC apps/swarm/main.c) +target_compile_definitions(naut_swarm_engine PRIVATE NAUT_SWARM_LIBRARY) +target_link_libraries(naut_swarm_engine PUBLIC + naut_piece naut_peer naut_metainfo naut_discovery naut_system + naut_session torrentpeer) # --- daemon + CLI: Phase 7 extensibility surface --------------------------- add_executable(nautd apps/nautd/main.c) target_link_libraries(nautd PRIVATE - naut_plugin naut_script naut_rpc naut_session naut_torrents naut_metainfo) + naut_plugin naut_script naut_rpc naut_session naut_metainfo naut_swarm_engine) add_executable(nautctl apps/nautctl/main.c) target_link_libraries(nautctl PRIVATE naut_rpc) @@ -151,10 +257,6 @@ add_executable(test_worker tests/unit/test_worker.c) target_link_libraries(test_worker PRIVATE naut_core naut_crypto) add_test(NAME test_worker COMMAND test_worker) -add_executable(test_pipeline tests/unit/test_pipeline.c) -target_link_libraries(test_pipeline PRIVATE naut_peer) -add_test(NAME test_pipeline COMMAND test_pipeline) - add_executable(test_rpc tests/unit/test_rpc.c) target_link_libraries(test_rpc PRIVATE naut_rpc) add_test(NAME test_rpc COMMAND test_rpc) @@ -201,18 +303,6 @@ add_executable(test_extension tests/unit/test_extension.c) target_link_libraries(test_extension PRIVATE naut_peer) add_test(NAME test_extension COMMAND test_extension) -add_executable(test_mse tests/unit/test_mse.c) -target_link_libraries(test_mse PRIVATE naut_peer) -add_test(NAME test_mse COMMAND test_mse) - -add_executable(test_tracker tests/unit/test_tracker.c) -target_link_libraries(test_tracker PRIVATE naut_tracker) -add_test(NAME test_tracker COMMAND test_tracker) - -add_executable(test_dht tests/unit/test_dht.c) -target_link_libraries(test_dht PRIVATE naut_dht) -add_test(NAME test_dht COMMAND test_dht) - add_executable(test_storage tests/unit/test_storage.c) target_link_libraries(test_storage PRIVATE naut_storage) add_test(NAME test_storage COMMAND test_storage) @@ -233,46 +323,13 @@ add_executable(test_picker tests/unit/test_picker.c) target_link_libraries(test_picker PRIVATE naut_piece) add_test(NAME test_picker COMMAND test_picker) -# Phase 3 interop gate: download from a real libtorrent seed (SKIPs without it). -add_test(NAME interop_leech - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_interop.sh $) -set_tests_properties(interop_leech PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 180) - -add_test(NAME interop_mse - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_mse.sh $) -set_tests_properties(interop_mse PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) - -add_test(NAME interop_magnet_dht - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_magnet_dht.sh - $) -set_tests_properties(interop_magnet_dht PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) - -# Phase 4 interop gate: both libtorrent seeds must contribute to one download. -add_test(NAME interop_swarm - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_swarm.sh $) -set_tests_properties(interop_swarm PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) - -add_test(NAME interop_tracker_swarm - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_tracker_swarm.sh - $ http) -set_tests_properties(interop_tracker_swarm PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) - -add_test(NAME interop_udp_tracker_swarm - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_tracker_swarm.sh - $ udp) -set_tests_properties(interop_udp_tracker_swarm PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) - -add_test(NAME interop_echo_scale - COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_echo_scale.sh - $) -set_tests_properties(interop_echo_scale PROPERTIES TIMEOUT 30) - add_test(NAME phase7_extensibility COMMAND bash ${CMAKE_SOURCE_DIR}/tests/integration/run_phase7.sh $ $ $ ${CMAKE_SOURCE_DIR}/tests/fixtures/phase7.lua) set_tests_properties(phase7_extensibility PROPERTIES TIMEOUT 15) +set_tests_properties(phase7_extensibility PROPERTIES SKIP_RETURN_CODE 77) # Example Lua scripts: parser battery + end-to-end sort path building. Only # registered when a standalone lua interpreter is available. diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..4f4d7c0 --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,18 @@ +- ✅ Set Location doesn't work, it should also show the current location. +- ✅ I need a way to modify a category (including Uncategorozied). +- ✅ Adding a Category means I can't have none selected on adding a torrent. +- ✅ Category default download location does nothing as changing it doesn't change the download location. Download location should be greyed out by default with the default location shown. Clicking should allow you to change the location. If the location is set it shouldn't change if the category is changed. +- ✅ Automation variables at half window width makes the script unseeable. It should be displayed above the script if the window is to narrow. +- ✅ We need to implement the RSS and Search Tabs. +- ✅ I need to be able to add Tags on adding a torrent. +- ✅ Categories aren't saved across restart. +- ✅ Pausing a torrent will go back into Downloading and Seeding. +- ✅ A torrents data could overlap with another existing torrent. This should be blocked to avoid +- ✅ A paused torrent should still do a full piece check. +- ✅ Something appears to have broken the peers info tab, nothing shows up. +- ✅ RSS should have a manual download button +- ✅ RSS should have a manual repull +- ✅ I should be able to force re-run a rule for cases where it was modified. +- ✅ RSS manual download button doesn't work — there's no + next to articles (feeds whose items only carry a /Atom href had no source). +- ✅ Rules should show their current matches. +- ✅ We need a real login system backed by a database. \ No newline at end of file diff --git a/README.md b/README.md index 16a70df..0bbd687 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ src/peer/ wire protocol, MSE/RC4, BEP-10, ut_metadata, and PEX apps/echo/ Phase 1 gate: io_uring echo server on the buffer pool apps/leech/ Phase 3 gate: verified single-peer download apps/swarm/ tracker/DHT discovery, magnets, and concurrent peers +apps/nautctl/ thin CLI frontend over daemon RPC +plugins/webui/ daemon plugin that serves ../torrent-ui as the web panel tests/unit/ unit + concurrency tests ``` @@ -35,6 +37,13 @@ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release ninja -C build ctest --test-dir build --output-on-failure +# portable daemon/client build with Jansson and Lua embedded +cmake -S . -B build-standalone -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DNAUT_STANDALONE=ON +ninja -C build-standalone nautd nautctl +ldd build-standalone/nautd +ldd build-standalone/nautctl + # sanitizer build (address|thread|undefined) cmake -S . -B build-tsan -G Ninja -DCMAKE_BUILD_TYPE=Debug -DNAUT_SAN=thread ninja -C build-tsan && ./build-tsan/test_buf @@ -42,13 +51,16 @@ ninja -C build-tsan && ./build-tsan/test_buf # run the Phase 1 echo gate ./build/naut_echo 9000 -# download from explicit peers, or omit them to use the torrent's trackers -./build/naut_swarm file.torrent output/ 192.0.2.10:6881 192.0.2.11:6881 -./build/naut_swarm file.torrent output/ +# start the engine, then add and inspect downloads through its RPC frontend +./build/nautd +./build/nautctl add file.torrent output/ +./build/nautctl list +./build/nautctl show 1 +./build/nautctl events -# trackerless magnet start through DHT (override bootstraps when needed) -./build/naut_swarm 'magnet:?xt=urn:btih:...' output/ -NAUT_DHT_BOOTSTRAP=127.0.0.1:6881 ./build/naut_swarm 'magnet:?xt=urn:btih:...' output/ +# explicit peers and trackerless magnets use the same daemon workflow +./build/nautctl add file.torrent output/ 192.0.2.10:6881 +./build/nautctl add 'magnet:?xt=urn:btih:...' output/ # force an encrypted single-peer MSE/RC4 connection ./build/naut_leech --mse file.torrent output/ 192.0.2.10 6881 @@ -59,41 +71,86 @@ NAUT_DHT_BOOTSTRAP=127.0.0.1:6881 ./build/naut_swarm 'magnet:?xt=urn:btih:...' o bash tests/integration/run_echo_scale.sh ./build/naut_echo # optional data-path tuning -NAUT_DIRECT_IO=1 NAUT_WORKERS=8 ./build/naut_swarm file.torrent output/ +NAUT_DIRECT_IO=1 NAUT_WORKERS=8 ./build/nautd NAUT_CPU=2 NAUT_SQPOLL=1 NAUT_HUGEPAGES=1 NAUT_NUMA_NODE=0 ./build/naut_echo 9000 ``` Requirements: Linux ≥ 6.0, `liburing` (≥ 2.x), OpenSSL `libcrypto`, Jansson, Lua, CMake ≥ 3.20, gcc/clang, Ninja. +`NAUT_STANDALONE=ON` downloads hash-pinned Jansson 2.14.1 and Lua 5.4.8 +sources at configure time and statically embeds them in `nautd` and `nautctl`. +The resulting executables still use the host's glibc/ELF loader intentionally: +fully static glibc breaks normal DNS/NSS behavior and native `.so` plugins. +Release builds target a portable CPU baseline. Use `-DNAUT_NATIVE=ON` only for +a local build that will run on the same CPU family as the build machine. + ## Daemon, RPC, plugins, and scripts -Phase 7 adds a headless control process and thin CLI over a versioned, -length-prefixed JSON protocol on a Unix socket: +`nautd` is the application engine: it owns torrent workers, storage, scripts, +plugins, progress, and lifecycle. `nautctl` is one thin frontend over a +versioned, length-prefixed JSON protocol on a Unix socket; a desktop or web +panel can use the same RPC surface. ```sh ./build/nautd \ --socket /tmp/nautd.sock \ - --plugin ./build/naut_example.so \ - --script ./tests/fixtures/phase7.lua + --plugin ./build/naut_example.so ./build/nautctl ping ./build/nautctl plugins ./build/nautctl status +./build/nautctl script ./examples/anime_sort.lua +./build/nautctl add show.torrent /downloads/show +./build/nautctl list ./build/nautctl events ``` -`nautctl` accepts an optional JSON value after the method: +The convenience commands cover normal operation: ```sh -# register a torrent's storage so a move command can resolve + relocate its files -./build/nautctl add_torrent \ - '{"torrent_id":7,"torrent":"file.torrent","root":"output/"}' -./build/nautctl emit \ - '{"type":"torrent_finished","torrent_id":7}' +./build/nautctl add file.torrent output/ [IP:PORT ...] +./build/nautctl list +./build/nautctl show 1 +./build/nautctl remove 1 +./build/nautctl script rules.lua +./build/nautctl unscript ./build/nautctl shutdown ``` +For tooling and plugin methods, the generic form remains +`nautctl METHOD [PARAMS_JSON]`. + +### Web panel + +The web panel is a daemon plugin, not part of `nautctl`. It serves the static +frontend from `../torrent-ui/public` by default and adapts that UI's `/api/*` +contract to Naut's daemon RPC surface: + +```sh +NAUT_WEBUI_ROOT=../torrent-ui/public \ +NAUT_AUTH_PASSWORD='change-me' \ +./build/nautd --socket /tmp/nautd.sock --plugin ./build/naut_webui.so +# open http://127.0.0.1:8080 +``` + +Configuration: + +```sh +NAUT_WEBUI_HOST=127.0.0.1 # default +NAUT_WEBUI_PORT=8080 # default +NAUT_WEBUI_ROOT=../torrent-ui/public +NAUT_AUTH_USER=admin # default +NAUT_AUTH_PASSWORD=change-me # generated and logged if omitted +NAUT_WEBUI_SAVE_PATH=/downloads # default add-torrent destination +``` + +The plugin implements the stable `torrent-ui` API surface: cookie login, +`/api/snapshot`, `/api/stream` Server-Sent Events, `/api/meta`, torrent detail +tabs, add/remove, and `/api/plugins` loading ES modules from +`public/plugins/plugins.json`. Some advanced qBittorrent-style controls in the +UI are accepted as no-ops until Naut grows matching daemon RPC methods. + The native ABI is declared in `include/naut/naut_plugin.h`. Plugins export `naut_plugin_register()`, receive the versioned host API, and may register RPC methods, storage backends, and event handlers. `plugins/example/example.c` @@ -105,12 +162,11 @@ hooks are `on_torrent_added`, `on_piece_complete`, `on_file_complete`, filesystem, process, package-loading, debug, and raw chunk-loading globals (`os`, `io`, `package`/`require`, `debug`, `dofile`/`loadfile`, and `load`/`loadstring` — the bytecode loaders are denied so a crafted binary chunk -can't escape the VM). `naut.move_file()` submits a bounded command from the -script thread to the daemon owner thread; the owner resolves it through the -torrent registry (`naut_session`) and performs the relocate with -`naut_storage_relocate()`. Register a torrent's storage first with the -`add_torrent` RPC so the id resolves. `phase7_extensibility` drives this -end to end and asserts the file actually moves on disk. +can't escape the VM). `naut.move_file()` submits a bounded command to the +worker that owns the torrent. That worker performs +`naut_storage_relocate()` and keeps tracking the file at its new path. +`phase7_extensibility` drives a real daemon-owned download end to end and +asserts the moved file byte-for-byte. The full script-visible surface — every event hook, the `event` object's fields, and the `naut` API table — is documented in @@ -211,9 +267,8 @@ embedded Anitomy-style filename parser ([`examples/`](examples/)). piece verifies — before the torrent finishes — and `naut_storage_relocate()` moves that file out safely (even mid-download, while other files' pieces are still arriving). `test_filemove` proves a file is relocated mid-download with - no corruption. The scripting layer (Phase 7) forwards the event to an - `on_file_complete` hook and exposes `move_file`; the daemon resolves the - command through the `naut_session` torrent registry (`src/session/session.c`) - and calls `naut_storage_relocate()` on its owner thread. `phase7_extensibility` - exercises the whole chain — script thread → bounded queue → owner thread → - storage — and asserts the file moves on disk. + no corruption. The scripting layer forwards the event to an + `on_file_complete` hook and exposes `move_file`; the daemon queues the command + back to the worker that owns the torrent's storage. + `phase7_extensibility` exercises the whole chain — download worker → script + thread → bounded command queue → download worker — and checks the moved bytes. diff --git a/apps/echo/main.c b/apps/echo/main.c deleted file mode 100644 index a27b579..0000000 --- a/apps/echo/main.c +++ /dev/null @@ -1,214 +0,0 @@ -/* naut_echo — Phase 1 gate. - * - * A single-reactor io_uring echo server that proves the foundation works end to - * end: multishot accept, recv/send driven entirely off the page-aligned buffer - * pool with ZERO per-operation allocation in steady state. Throughput on - * loopback should be limited by memory bandwidth / the single core, not by the - * allocator or syscalls. - * - * It is intentionally one-in-flight-op-per-connection (recv -> send -> recv). - * The real peer reactor (later phase) uses multishot recv + provided buffers - * and pipelines; this is the minimal honest exercise of the primitives. - * - * usage: naut_echo [port] (default 9000) - */ -#include "naut/uring.h" -#include "naut/net.h" -#include "naut/buf.h" -#include "naut/log.h" -#include "naut/system.h" - -#include -#include -#include -#include -#include -#include -#include - -#define ECHO_BLOCK (128u * 1024u) -#define ECHO_BUFS 4096u -#define RING_ENTRIES 4096u - -/* user_data tagging: low 3 bits = op, high bits = conn* (16-byte aligned). */ -enum { TAG_ACCEPT = 1, TAG_RECV = 2, TAG_SEND = 3 }; -#define UD(p, tag) ((__u64)(uintptr_t)(p) | (unsigned)(tag)) -#define UD_TAG(ud) ((unsigned)((ud) & 0x7u)) -#define UD_PTR(ud) ((conn *)(uintptr_t)((ud) & ~(__u64)0x7u)) - -typedef struct conn { - int fd; - uint32_t sent; /* bytes of buf->len already written (partial sends) */ - naut_buf *buf; - bool awaiting_notif; - bool recv_fixed; /* the in-flight recv used the fixed buffer */ -} conn; - -static volatile sig_atomic_t g_stop = 0; -static void on_signal(int s) { (void)s; g_stop = 1; } - -static naut_bufpool *g_pool; -static _Atomic uint64_t g_bytes = 0, g_conns = 0, g_zc_copied = 0; - -static void arm_recv(naut_ring *owner, conn *c) { - struct io_uring_sqe *sqe = io_uring_get_sqe(&owner->ring); - c->recv_fixed = - naut_ring_prep_recv(owner, sqe, c->fd, c->buf->data, c->buf->cap, 0); - io_uring_sqe_set_data64(sqe, UD(c, TAG_RECV)); -} - -static void arm_send(naut_ring *owner, conn *c) { - struct io_uring_sqe *sqe = io_uring_get_sqe(&owner->ring); - c->awaiting_notif = naut_ring_prep_send( - owner, sqe, c->fd, c->buf->data + c->sent, - c->buf->len - c->sent, MSG_NOSIGNAL, true); - io_uring_sqe_set_data64(sqe, UD(c, TAG_SEND)); -} - -static void conn_close(conn *c) { - close(c->fd); - naut_buf_put(c->buf); - free(c); -} - -int main(int argc, char **argv) { - uint16_t port = (argc > 1) ? (uint16_t)atoi(argv[1]) : 9000; - int cpu = getenv("NAUT_CPU") ? atoi(getenv("NAUT_CPU")) : -1; - int numa_node = - getenv("NAUT_NUMA_NODE") ? atoi(getenv("NAUT_NUMA_NODE")) : -1; - bool sqpoll = getenv("NAUT_SQPOLL") != NULL; - bool hugepages = getenv("NAUT_HUGEPAGES") != NULL; - signal(SIGINT, on_signal); - signal(SIGTERM, on_signal); - signal(SIGPIPE, SIG_IGN); - - if (cpu >= 0 && naut_pin_current_thread(cpu) != NAUT_OK) - NAUT_WARN("failed to pin reactor to CPU %d", cpu); - naut_ring r; - if (naut_ring_init_cpu(&r, RING_ENTRIES, sqpoll, cpu) != NAUT_OK) - return 1; - if (naut_ring_probe(&r) != NAUT_OK) { naut_ring_close(&r); return 1; } - struct io_uring *ring = &r.ring; - - int lfd = naut_net_listen(port, 1024, true); - if (lfd < 0) { naut_ring_close(&r); return 1; } - - g_pool = naut_bufpool_create_on_node( - ECHO_BLOCK, ECHO_BUFS, hugepages, numa_node); - if (!g_pool) { close(lfd); naut_ring_close(&r); return 1; } - (void)naut_ring_register_bufpool(&r, g_pool); - - /* prime the multishot accept */ - struct io_uring_sqe *sqe = io_uring_get_sqe(ring); - io_uring_prep_multishot_accept(sqe, lfd, NULL, NULL, 0); - io_uring_sqe_set_data64(sqe, UD(NULL, TAG_ACCEPT)); - - NAUT_INFO("echo listening on :%u", port); - - while (!g_stop) { - int rc = io_uring_submit_and_wait(ring, 1); - if (rc < 0 && rc != -EINTR) { NAUT_ERROR("submit_and_wait: %s", strerror(-rc)); break; } - - unsigned head, count = 0; - struct io_uring_cqe *cqe; - io_uring_for_each_cqe(ring, head, cqe) { - count++; - __u64 ud = cqe->user_data; - int res = cqe->res; - - switch (UD_TAG(ud)) { - case TAG_ACCEPT: { - if (res < 0) { - if (res != -ECANCELED) NAUT_WARN("accept: %s", strerror(-res)); - } else { - int cfd = res; - naut_net_tune_peer(cfd); - naut_buf *b = naut_buf_get(g_pool); - if (!b) { NAUT_WARN("pool exhausted, dropping conn"); close(cfd); } - else { - conn *c = calloc(1, sizeof(*c)); - c->fd = cfd; c->buf = b; - atomic_fetch_add(&g_conns, 1); - arm_recv(&r, c); - } - } - /* re-arm if the kernel dropped the multishot registration */ - if (!(cqe->flags & IORING_CQE_F_MORE)) { - struct io_uring_sqe *s = io_uring_get_sqe(ring); - io_uring_prep_multishot_accept(s, lfd, NULL, NULL, 0); - io_uring_sqe_set_data64(s, UD(NULL, TAG_ACCEPT)); - } - break; - } - case TAG_RECV: { - conn *c = UD_PTR(ud); - if (res <= 0) { - /* A fixed-buffer recv rejected with -EINVAL means this - * kernel doesn't support IORING_RECVSEND_FIXED_BUF on plain - * recv. Disable it ring-wide and retry THIS connection - * unfixed. We key off the per-conn flag, not the ring flag, - * so every connection that armed a fixed recv before the - * flag flipped recovers too (otherwise all but the first - * would be torn down). */ - if (res == -EINVAL && c->recv_fixed) { - if (r.recv_fixed) { - NAUT_WARN("fixed-buffer recv unsupported at runtime; " - "falling back to normal recv"); - r.recv_fixed = false; - } - arm_recv(&r, c); - break; - } - if (res < 0) - NAUT_WARN("recv completion: %s", strerror(-res)); - conn_close(c); - break; - } - c->buf->len = (uint32_t)res; - c->sent = 0; - arm_send(&r, c); - break; - } - case TAG_SEND: { - conn *c = UD_PTR(ud); - if (cqe->flags & IORING_CQE_F_NOTIF) { - if (res & IORING_NOTIF_USAGE_ZC_COPIED) { - uint64_t copied = - atomic_fetch_add(&g_zc_copied, 1) + 1; - if (copied == 8) { - NAUT_WARN("SEND_ZC is copying on this transport; " - "disabling it for this ring"); - r.send_zc = false; - } - } - c->awaiting_notif = false; - if (c->sent < c->buf->len) arm_send(&r, c); - else { c->buf->len = 0; arm_recv(&r, c); } - break; - } - if (res <= 0) { conn_close(c); break; } - c->sent += (uint32_t)res; - atomic_fetch_add(&g_bytes, (uint64_t)res); - if (!c->awaiting_notif) { - if (c->sent < c->buf->len) arm_send(&r, c); - else { c->buf->len = 0; arm_recv(&r, c); } - } - break; - } - default: - NAUT_PANIC("bad user_data tag %u", UD_TAG(ud)); - } - } - io_uring_cq_advance(ring, count); - } - - NAUT_INFO("shutting down: %llu conns, %llu bytes echoed, %llu SEND_ZC copied notifications", - (unsigned long long)atomic_load(&g_conns), - (unsigned long long)atomic_load(&g_bytes), - (unsigned long long)atomic_load(&g_zc_copied)); - close(lfd); - naut_ring_unregister_buffers(&r); - naut_bufpool_destroy(g_pool); - naut_ring_close(&r); - return 0; -} diff --git a/apps/leech/main.c b/apps/leech/main.c deleted file mode 100644 index cb980cc..0000000 --- a/apps/leech/main.c +++ /dev/null @@ -1,212 +0,0 @@ -/* naut_leech — Phase 3 gate: download a torrent from a single peer and write a - * byte-correct, hash-verified file to disk. - * - * Blocking-socket driver around the sans-IO peer codec + download engine. The - * point of this phase is protocol correctness and interop (it downloads from a - * libtorrent seed in the integration test), not peak throughput — the io_uring - * reactor that drives thousands of these comes in Phase 6. - * - * usage: naut_leech [--mse] - */ -#include "naut/metainfo.h" -#include "naut/storage.h" -#include "naut/piece.h" -#include "naut/peer.h" -#include "naut/mse.h" -#include "naut/log.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define PIPELINE_DEPTH 512 /* outstanding requests (~8 MiB in flight) */ - -static double now(void) { - struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); - return t.tv_sec + t.tv_nsec * 1e-9; -} - -static uint8_t *slurp(const char *path, size_t *len) { - FILE *f = fopen(path, "rb"); - if (!f) { NAUT_ERROR("open %s: %s", path, strerror(errno)); return NULL; } - fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET); - uint8_t *b = malloc(n); - if (fread(b, 1, n, f) != (size_t)n) { fclose(f); free(b); return NULL; } - fclose(f); *len = (size_t)n; return b; -} - -static int connect_peer(const char *ip, uint16_t port) { - int fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) return -1; - struct sockaddr_in a; memset(&a, 0, sizeof a); - a.sin_family = AF_INET; a.sin_port = htons(port); - if (inet_pton(AF_INET, ip, &a.sin_addr) != 1) { close(fd); return -1; } - if (connect(fd, (struct sockaddr *)&a, sizeof a) != 0) { - NAUT_ERROR("connect %s:%u: %s", ip, port, strerror(errno)); - close(fd); return -1; - } - int one = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); - return fd; -} - -/* send up to PIPELINE_DEPTH outstanding requests */ -static bool refill(int fd, naut_mse_stream *mse, - naut_download *d, int *outstanding) { - uint32_t idx, begin, len; - while (*outstanding < PIPELINE_DEPTH) { - if (!naut_download_next_request(d, &idx, &begin, &len)) break; - uint8_t req[17]; - naut_peer_msg_request(req, idx, begin, len); - if (!naut_mse_send_all(fd, mse, req, sizeof req)) return false; - (*outstanding)++; - } - return true; -} - -int main(int argc, char **argv) { - bool use_mse = argc > 1 && strcmp(argv[1], "--mse") == 0; - int arg = use_mse ? 2 : 1; - if (argc - arg != 4) { - fprintf(stderr, "usage: %s [--mse] \n", - argv[0]); - return 2; - } - naut_log_set_level(NAUT_LOG_INFO); - - size_t tlen; - uint8_t *tor = slurp(argv[arg], &tlen); - if (!tor) return 1; - naut_metainfo mi; - if (naut_metainfo_parse(tor, tlen, &mi) != NAUT_OK) { NAUT_ERROR("bad torrent"); return 1; } - free(tor); - - char hex[41]; naut_infohash_v1_hex(&mi, hex); - NAUT_INFO("torrent '%s': %u pieces, %lld bytes, infohash %s", - mi.name, mi.num_pieces, (long long)mi.total_length, hex); - - naut_err err; - naut_storage_opts storage_opts = { - .direct_io = getenv("NAUT_DIRECT_IO") != NULL, - .preallocate = true, - }; - naut_storage *st = naut_storage_open_opts( - mi.files, mi.num_files, argv[arg + 1], &storage_opts, &err); - if (!st) { NAUT_ERROR("storage: %s", naut_strerror(err)); return 1; } - naut_download *d = naut_download_create(&mi, st); - if (!d) return 1; - - int fd = connect_peer(argv[arg + 2], (uint16_t)atoi(argv[arg + 3])); - if (fd < 0) return 1; - - /* handshake */ - uint8_t peerid[20]; memcpy(peerid, "-NT0001-", 8); - for (int i = 8; i < 20; i++) peerid[i] = (uint8_t)(rand() & 0xff); - uint8_t hs[NAUT_HANDSHAKE_LEN]; - naut_peer_handshake_build(hs, mi.infohash_v1, peerid, 0); - naut_mse_stream mse = {0}; - uint8_t remote_hs[NAUT_HANDSHAKE_LEN]; - bool hs_done = false; - if (use_mse) { - naut_err mse_err = naut_mse_client_handshake( - fd, mi.infohash_v1, peerid, 0, &mse, remote_hs); - if (mse_err != NAUT_OK) { - NAUT_ERROR("MSE handshake failed: %s", naut_strerror(mse_err)); - return 1; - } - hs_done = true; - NAUT_INFO("MSE/RC4 peer transport established"); - uint8_t intr[5]; - naut_peer_msg_simple(intr, NAUT_MSG_INTERESTED); - if (!naut_mse_send_all(fd, &mse, intr, sizeof intr)) { - NAUT_ERROR("interested send failed"); - return 1; - } - } else if (!naut_mse_send_all(fd, &mse, hs, sizeof hs)) { - NAUT_ERROR("handshake send failed"); - return 1; - } - - /* recv buffer */ - size_t cap = 4u << 20, len = 0; - uint8_t *buf = malloc(cap); - bool unchoked = false; - int outstanding = 0; - double t0 = now(); - - while (!naut_download_complete(d)) { - if (len == cap) { cap *= 2; buf = realloc(buf, cap); } - ssize_t r = naut_mse_recv(fd, &mse, buf + len, cap - len); - if (r < 0) { NAUT_ERROR("recv: %s", strerror(errno)); break; } - if (r == 0) { NAUT_ERROR("peer closed (%.1f%% done)", - 100.0 * naut_download_pieces_done(d) / mi.num_pieces); break; } - len += (size_t)r; - - size_t pos = 0; - if (!hs_done) { - if (len < NAUT_HANDSHAKE_LEN) continue; - uint8_t ih[20], pid[20]; - if (!naut_peer_handshake_parse(buf, ih, pid, NULL) || - memcmp(ih, mi.infohash_v1, 20) != 0) { - NAUT_ERROR("handshake mismatch"); break; - } - pos = NAUT_HANDSHAKE_LEN; - hs_done = true; - uint8_t intr[5]; naut_peer_msg_simple(intr, NAUT_MSG_INTERESTED); - if (!naut_mse_send_all(fd, &mse, intr, 5)) break; - } - - /* parse all complete messages */ - for (;;) { - naut_msg m; - int c = naut_peer_msg_parse(buf + pos, len - pos, &m); - if (c == 0) break; - if (c < 0) { NAUT_ERROR("protocol error"); goto done; } - pos += (size_t)c; - switch (m.type) { - case NAUT_MSG_UNCHOKE: unchoked = true; break; - case NAUT_MSG_CHOKE: unchoked = false; break; - case NAUT_MSG_PIECE: { - outstanding--; - bool pdone = false; - naut_err e = naut_download_on_block(d, m.index, m.begin, m.payload, - (uint32_t)m.payload_len, &pdone); - if (e != NAUT_OK) { NAUT_ERROR("block rejected: %s", naut_strerror(e)); goto done; } - break; - } - default: break; /* bitfield/have/keepalive/port: ignore for a seed */ - } - } - /* compact consumed bytes */ - memmove(buf, buf + pos, len - pos); - len -= pos; - - if (unchoked && !refill(fd, &mse, d, &outstanding)) { - NAUT_ERROR("request send failed"); break; - } - } -done:; - double dt = now() - t0; - bool ok = naut_download_complete(d); - if (ok) { - double mb = (double)mi.total_length / 1e6; - NAUT_INFO("COMPLETE: %u/%u pieces, %.1f MB in %.2fs (%.1f MB/s), all SHA-1 verified", - naut_download_pieces_done(d), mi.num_pieces, mb, dt, mb / dt); - } else { - NAUT_ERROR("INCOMPLETE: %u/%u pieces", naut_download_pieces_done(d), mi.num_pieces); - } - - naut_storage_sync(st); - close(fd); - naut_download_destroy(d); - naut_storage_close(st); - naut_metainfo_free(&mi); - free(buf); - return ok ? 0 : 1; -} diff --git a/apps/nautctl/main.c b/apps/nautctl/main.c index a009c11..088808f 100644 --- a/apps/nautctl/main.c +++ b/apps/nautctl/main.c @@ -11,8 +11,17 @@ static void usage(const char *program) { fprintf(stderr, - "usage: %s [--socket PATH] METHOD [PARAMS_JSON]\n" - " %s [--socket PATH] events\n", program, program); + "usage: %s [--socket PATH] COMMAND [ARGS]\n" + "\n" + "commands:\n" + " add SOURCE OUTPUT [IP:PORT ...]\n" + " list\n" + " show TORRENT_ID\n" + " dump TORRENT_ID\n" + " remove TORRENT_ID\n" + " script PATH | unscript\n" + " status | events | shutdown\n" + " METHOD [PARAMS_JSON] (raw RPC)\n", program); } static json_t *parse_params(const char *text) { @@ -54,6 +63,15 @@ static int stream_events(const char *socket_path) { } } +static bool parse_id(const char *text, json_int_t *id) { + if (!text || !*text || *text == '-') return false; + char *end = NULL; + unsigned long long value = strtoull(text, &end, 10); + if (!end || *end || value > (unsigned long long)INT64_MAX) return false; + *id = (json_int_t)value; + return true; +} + int main(int argc, char **argv) { const char *socket_path = getenv("NAUT_SOCKET"); if (!socket_path || !*socket_path) socket_path = DEFAULT_SOCKET; @@ -66,7 +84,7 @@ int main(int argc, char **argv) { socket_path = argv[arg + 1]; arg += 2; } - if (arg >= argc || arg + 2 < argc) { + if (arg >= argc) { usage(argv[0]); return 2; } @@ -74,7 +92,52 @@ int main(int argc, char **argv) { const char *method = argv[arg++]; if (strcmp(method, "events") == 0) return stream_events(socket_path); - json_t *params = parse_params(arg < argc ? argv[arg] : NULL); + + json_t *params = NULL; + bool raw_dump = false; + if (strcmp(method, "add") == 0) { + if (arg + 1 >= argc) { usage(argv[0]); return 2; } + method = "add_torrent"; + params = json_pack("{s:s,s:s}", "source", argv[arg], + "output", argv[arg + 1]); + arg += 2; + json_t *peers = json_array(); + if (!params || !peers) { + json_decref(params); + json_decref(peers); + return 1; + } + while (arg < argc) + json_array_append_new(peers, json_string(argv[arg++])); + json_object_set_new(params, "peers", peers); + } else if (strcmp(method, "list") == 0) { + if (arg != argc) { usage(argv[0]); return 2; } + method = "torrents"; + params = json_object(); + } else if (strcmp(method, "show") == 0 || + strcmp(method, "dump") == 0 || + strcmp(method, "remove") == 0) { + json_int_t id; + if (arg + 1 != argc || !parse_id(argv[arg], &id)) { + usage(argv[0]); + return 2; + } + if (strcmp(method, "show") == 0) method = "torrent"; + else if (strcmp(method, "dump") == 0) { method = "dump_torrent"; raw_dump = true; } + else method = "remove_torrent"; + params = json_pack("{s:I}", "torrent_id", id); + } else if (strcmp(method, "script") == 0) { + if (arg + 1 != argc) { usage(argv[0]); return 2; } + method = "load_script"; + params = json_pack("{s:s}", "path", argv[arg]); + } else if (strcmp(method, "unscript") == 0) { + if (arg != argc) { usage(argv[0]); return 2; } + method = "unload_script"; + params = json_object(); + } else { + if (arg + 1 < argc) { usage(argv[0]); return 2; } + params = parse_params(arg < argc ? argv[arg] : NULL); + } if (!params) { fprintf(stderr, "nautctl: invalid JSON parameters\n"); return 2; @@ -86,8 +149,19 @@ int main(int argc, char **argv) { fprintf(stderr, "nautctl: RPC failed: %s\n", naut_strerror(error)); return 1; } - int result = print_json(reply); bool ok = json_is_true(json_object_get(reply, "ok")); + int result; + /* `dump` returns a multi-line text blob; print it raw instead of escaped JSON. */ + const char *dump = raw_dump + ? json_string_value(json_object_get( + json_object_get(reply, "result"), "dump")) + : NULL; + if (dump) { + fputs(dump, stdout); + result = 0; + } else { + result = print_json(reply); + } json_decref(reply); return result || !ok; } diff --git a/apps/nautd/main.c b/apps/nautd/main.c index 1924e9f..f313421 100644 --- a/apps/nautd/main.c +++ b/apps/nautd/main.c @@ -4,8 +4,8 @@ #include "naut/plugin.h" #include "naut/rpc.h" #include "naut/script.h" -#include "naut/session.h" #include "naut/storage.h" +#include "naut/swarm.h" #include #include @@ -17,43 +17,703 @@ #include #include #include +#include #include #include #define DEFAULT_SOCKET "/tmp/nautd.sock" #define MOVE_QUEUE_CAPACITY 64 #define MAX_SUBSCRIBERS 64 +#define MAX_TORRENTS 128 typedef struct { - uint64_t torrent_id; uint32_t file_index; char destination[PATH_MAX]; } move_command; +/* Last known on-disk location of a file after a relocate, persisted so a moved + * file is reopened in place across restarts instead of re-downloaded. */ typedef struct { - naut_event_bus *events; - naut_rpc_registry *rpc; - naut_plugin_manager *plugins; - naut_script *script; - naut_session *session; - pthread_mutex_t move_lock; + uint32_t file_index; + char *path; +} file_location; + +typedef enum { + TORRENT_QUEUED, + TORRENT_RUNNING, + TORRENT_STALLED, + TORRENT_COMPLETE, + TORRENT_STOPPING, + TORRENT_STOPPED, + TORRENT_ERROR, + TORRENT_PAUSED, + TORRENT_CHECKING, +} torrent_state; + +typedef struct daemon_state daemon_state; + +typedef struct { + daemon_state *daemon; + uint64_t id; + char *source; + bool source_is_temp; /* ephemeral /tmp upload; unlink on any destroy */ + bool source_managed; /* durable upload under state_dir/uploads; unlink only + * when the torrent is removed (not on shutdown) */ + char *name; /* optional display name (persisted for the UI) */ + char *output_dir; + char **peers; + size_t num_peers; + pthread_t thread; + bool thread_started; + bool thread_done; + pthread_mutex_t lock; + torrent_state state; + naut_err result; + bool stop_requested; + bool remove_requested; + bool paused; /* user-paused: never auto-activated (persisted) */ + bool force_start; /* bypass the queue cap (persisted) */ + bool restart_requested; /* one-shot stop->start (recheck) */ + bool needs_check; /* run a one-shot hash check (paused add/recheck) */ + bool checking; /* a check-only worker is currently running */ + int queue_pos; /* ordering within the download queue (persisted) */ + uint64_t rate_share; /* engine download cap for this torrent, bytes/sec */ + naut_swarm_stats stats; move_command moves[MOVE_QUEUE_CAPACITY]; size_t move_head; size_t move_count; uint64_t moves_processed; + uint64_t moves_failed; + file_location *locations; /* last known location of each relocated file */ + size_t num_locations; + char *pending_save_path; /* "Set location" target; the worker moves the + * torrent's files there on its next control pass */ + bool pending_save_path_reset; /* true: reset every file to its original + * download relpath; false: keep per-file moves */ + char *category; /* single category (qBittorrent-style), may be ""*/ + char **tags; /* user tags (multiple) */ + size_t num_tags; /* category + tags are the flat labels Lua sees */ + char **files; /* torrent's file relpaths (for overlap checks) */ + size_t num_files; /* parsed at add; empty for magnets pre-metadata */ + uint64_t dump_seq; /* bumped by a dump RPC; > dump_done_seq => pending */ + uint64_t dump_done_seq; /* highest dump_seq the worker has rendered */ + char *dump_text; /* latest rendered dump (owner: task) */ +} torrent_task; + +struct daemon_state { + naut_event_bus *events; + naut_rpc_registry *rpc; + naut_plugin_manager *plugins; + naut_script *script; + char *script_path; + pthread_mutex_t script_lock; + /* Script settings: the loaded script declares a schema via + * naut.define_settings; the user edits values in the web UI. The schema is + * rebuilt on each (re)load; values persist independently. */ + pthread_mutex_t settings_lock; + json_t *script_settings_schema; /* array of {key,label,type,default} */ + json_t *script_settings; /* object: key -> value string (user-set)*/ + char settings_file[PATH_MAX]; /* /script_settings.json */ + pthread_mutex_t torrent_lock; + torrent_task *torrents[MAX_TORRENTS]; + size_t torrent_count; + uint64_t next_torrent_id; pthread_mutex_t subscriber_lock; int subscribers[MAX_SUBSCRIBERS]; size_t subscriber_count; bool stopping; -} daemon_state; + bool persist_enabled; + char state_file[PATH_MAX]; /* /torrents.json */ + char uploads_dir[PATH_MAX]; /* /uploads */ + char prefs_file[PATH_MAX]; /* /prefs.json */ + /* Daemon preferences (queue + throttle). Upload limits are stored but inert: + * the engine is leech-only (no seeding) so only download limits take effect. */ + uint32_t max_active; /* max concurrent downloading torrents */ + uint64_t dl_limit; /* global download cap, bytes/sec (0=off)*/ + uint64_t alt_dl_limit; /* alt download cap, bytes/sec */ + uint64_t up_limit; /* stored, inert */ + uint64_t alt_up_limit; /* stored, inert */ + bool alt_speed_enabled; /* use alt_* limits when true */ +}; + +#define DEFAULT_MAX_ACTIVE 5 static volatile sig_atomic_t interrupted; +static naut_err queue_move(void *opaque, uint64_t torrent_id, + uint32_t file_index, const char *destination); +static void service_lifecycle(daemon_state *state); +static void persist_torrents(daemon_state *state); +static json_t *script_settings_json(daemon_state *state); + static void on_signal(int signal_number) { (void)signal_number; interrupted = 1; } +static const char *torrent_state_name(torrent_state state) { + static const char *names[] = { + [TORRENT_QUEUED] = "queued", + [TORRENT_RUNNING] = "downloading", + [TORRENT_STALLED] = "stalled", + [TORRENT_COMPLETE] = "complete", + [TORRENT_STOPPING] = "stopping", + [TORRENT_STOPPED] = "stopped", + [TORRENT_ERROR] = "error", + [TORRENT_PAUSED] = "paused", + [TORRENT_CHECKING] = "checking", + }; + return (size_t)state < NAUT_ARRAY_LEN(names) ? names[state] : "unknown"; +} + +static torrent_task *find_torrent_locked(daemon_state *state, uint64_t id) { + for (size_t i = 0; i < state->torrent_count; i++) + if (state->torrents[i]->id == id) return state->torrents[i]; + return NULL; +} + +static void torrent_progress(void *opaque, const naut_swarm_stats *stats) { + torrent_task *task = opaque; + pthread_mutex_lock(&task->lock); + task->stats = *stats; + /* A check-only run just reports verified progress; the worker decides the + * final state (it stays paused), so don't flip it to complete/running here. */ + if (!task->checking) { + if (stats->total_pieces > 0 && + stats->pieces_done == stats->total_pieces) + task->state = TORRENT_COMPLETE; + else if (stats->stalled) + task->state = TORRENT_STALLED; + else if (task->state == TORRENT_QUEUED || + task->state == TORRENT_STALLED) + task->state = TORRENT_RUNNING; + } + pthread_mutex_unlock(&task->lock); +} + +static bool torrent_should_stop(void *opaque) { + torrent_task *task = opaque; + pthread_mutex_lock(&task->lock); + bool stop = task->stop_requested; + pthread_mutex_unlock(&task->lock); + return stop; +} + +static bool torrent_should_dump(void *opaque) { + torrent_task *task = opaque; + pthread_mutex_lock(&task->lock); + bool pending = task->dump_seq != task->dump_done_seq; + pthread_mutex_unlock(&task->lock); + return pending; +} + +static void torrent_on_dump(void *opaque, const char *text) { + torrent_task *task = opaque; + char *copy = text ? strdup(text) : NULL; + pthread_mutex_lock(&task->lock); + free(task->dump_text); + task->dump_text = copy; + task->dump_done_seq = task->dump_seq; + pthread_mutex_unlock(&task->lock); +} + +/* The download throttle the reconciler computed for this torrent (its share of + * the global limit). 0 = unlimited. */ +static uint64_t torrent_download_rate(void *opaque) { + torrent_task *task = opaque; + pthread_mutex_lock(&task->lock); + uint64_t rate = task->rate_share; + pthread_mutex_unlock(&task->lock); + return rate; +} + +/* Record (or update) the last known location of a relocated file. Caller holds + * task->lock. */ +static void task_set_location(torrent_task *task, uint32_t file_index, + const char *path) { + char *copy = strdup(path); + if (!copy) return; + for (size_t i = 0; i < task->num_locations; i++) { + if (task->locations[i].file_index == file_index) { + free(task->locations[i].path); + task->locations[i].path = copy; + return; + } + } + file_location *grown = realloc(task->locations, + (task->num_locations + 1) * sizeof *grown); + if (!grown) { free(copy); return; } + task->locations = grown; + task->locations[task->num_locations].file_index = file_index; + task->locations[task->num_locations].path = copy; + task->num_locations++; +} + +/* Replace the task's tag set from a JSON array of strings (empty/duplicate + * entries dropped). NULL leaves the tags unchanged. Caller holds task->lock. */ +static void task_set_tags(torrent_task *task, const json_t *tags_json) { + if (!json_is_array(tags_json)) return; + size_t n = json_array_size(tags_json); + char **next = n ? calloc(n, sizeof *next) : NULL; + size_t count = 0; + if (next) { + for (size_t i = 0; i < n; i++) { + const char *s = json_string_value(json_array_get(tags_json, i)); + if (!s || !*s) continue; + bool dup = false; + for (size_t j = 0; j < count; j++) + if (strcmp(next[j], s) == 0) { dup = true; break; } + if (dup) continue; + char *copy = strdup(s); + if (copy) next[count++] = copy; + } + } + for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]); + free(task->tags); + task->tags = next; + task->num_tags = count; +} + +/* Set the task's category. NULL leaves it unchanged. Caller holds task->lock. */ +static void task_set_category(torrent_task *task, const char *category) { + if (!category) return; + char *copy = strdup(category); + if (!copy) return; + free(task->category); + task->category = copy; +} + +/* JSON array of the task's tags. Caller holds task->lock. */ +static json_t *task_tags_json(const torrent_task *task) { + json_t *tags = json_array(); + if (tags) + for (size_t i = 0; i < task->num_tags; i++) + json_array_append_new(tags, json_string(task->tags[i])); + return tags; +} + +/* Copy a torrent's labels (category + tags, flattened) out for the Lua + * `naut.get_labels` accessor. Runs on the script worker thread. */ +static char **script_labels(void *opaque, uint64_t torrent_id, size_t *count) { + daemon_state *state = opaque; + *count = 0; + char **out = NULL; + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, torrent_id); + if (task) { + pthread_mutex_lock(&task->lock); + bool has_cat = task->category && *task->category; + size_t cap = task->num_tags + (has_cat ? 1 : 0); + if (cap && (out = calloc(cap, sizeof *out))) { + size_t c = 0; + if (has_cat) { + char *copy = strdup(task->category); + if (copy) out[c++] = copy; + } + for (size_t i = 0; i < task->num_tags; i++) { + char *copy = strdup(task->tags[i]); + if (copy) out[c++] = copy; + } + *count = c; + if (c == 0) { free(out); out = NULL; } + } + pthread_mutex_unlock(&task->lock); + } + pthread_mutex_unlock(&state->torrent_lock); + return out; +} + +/* Remove directories left empty after moving a file out, walking up from the + * file's old parent but never reaching or passing `base` (the download root, + * which may be shared). rmdir only deletes empty dirs, so this is safe. */ +static void prune_empty_dirs(const char *old_file_path, const char *base, + size_t base_len) { + char dir[PATH_MAX]; + if ((size_t)snprintf(dir, sizeof dir, "%s", old_file_path) >= sizeof dir) + return; + char *slash = strrchr(dir, '/'); + if (!slash) return; + *slash = '\0'; /* dir = the file's parent directory */ + while (strlen(dir) > base_len && + strncmp(dir, base, base_len) == 0 && dir[base_len] == '/') { + if (rmdir(dir) != 0) break; /* non-empty / busy: stop pruning */ + slash = strrchr(dir, '/'); + if (!slash) break; + *slash = '\0'; + } +} + +/* Apply a pending "Set location": move the torrent's files under the new base + * directory, then adopt it as the output dir. Runs on the worker thread (the + * sole owner of `storage`), so it never races engine writes. A one-time move, + * with no rule that re-locates later. + * + * Per file (relative to the old base): + * - reset: -> new_base/ + * - kept, under old base: -> new_base/ (preserve moves) + * - kept, separate dir: left exactly where it is. + * Empty residual folders under the old base are pruned. */ +static void apply_pending_save_path(torrent_task *task, naut_storage *storage) { + pthread_mutex_lock(&task->lock); + if (!task->pending_save_path || task->stats.file_count == 0) { + pthread_mutex_unlock(&task->lock); + return; /* nothing to do, or file list not known yet — retry next pass */ + } + char *target = task->pending_save_path; /* take ownership */ + task->pending_save_path = NULL; + bool reset = task->pending_save_path_reset; + char *old_base = strdup(task->output_dir ? task->output_dir : ""); + size_t nfiles = task->stats.file_count; + if (nfiles > NAUT_SWARM_MAX_FILE_STATS) nfiles = NAUT_SWARM_MAX_FILE_STATS; + char **orig_rel = calloc(nfiles, sizeof *orig_rel); + char **cur = calloc(nfiles, sizeof *cur); /* each file's current abs path */ + bool ok = old_base && orig_rel && cur; + for (size_t i = 0; ok && i < nfiles; i++) { + orig_rel[i] = strdup(task->stats.file_stats[i].path); + const char *ov = NULL; + for (size_t j = 0; j < task->num_locations; j++) + if (task->locations[j].file_index == i) { + ov = task->locations[j].path; + break; + } + char tmp[PATH_MAX]; + if (ov) cur[i] = strdup(ov); + else if (orig_rel[i] && + (size_t)snprintf(tmp, sizeof tmp, "%s/%s", old_base, + orig_rel[i]) < sizeof tmp) + cur[i] = strdup(tmp); + if (!orig_rel[i] || !cur[i]) ok = false; + } + pthread_mutex_unlock(&task->lock); + if (!ok) { + for (size_t i = 0; i < nfiles; i++) { free(orig_rel[i]); free(cur[i]); } + free(orig_rel); free(cur); free(old_base); free(target); + return; + } + + /* Normalize trailing slashes for clean prefix comparisons. */ + size_t blen = strlen(old_base); + while (blen > 1 && old_base[blen - 1] == '/') old_base[--blen] = '\0'; + size_t tlen = strlen(target); + while (tlen > 1 && target[tlen - 1] == '/') target[--tlen] = '\0'; + + file_location *newloc = NULL; + size_t nnew = 0, moved = 0; + for (size_t i = 0; i < nfiles; i++) { + char def[PATH_MAX], final[PATH_MAX]; + snprintf(def, sizeof def, "%s/%s", target, orig_rel[i]); + bool under = strlen(cur[i]) > blen && + strncmp(cur[i], old_base, blen) == 0 && cur[i][blen] == '/'; + if (reset) + snprintf(final, sizeof final, "%s", def); + else if (under) + snprintf(final, sizeof final, "%s/%s", target, cur[i] + blen + 1); + else + snprintf(final, sizeof final, "%s", cur[i]); /* separate dir: leave */ + + bool did_move = false; + if (strcmp(final, cur[i]) != 0) { + naut_err e = naut_storage_relocate(storage, (size_t)i, final); + if (e == NAUT_OK) { did_move = true; moved++; } + else { + NAUT_WARN("set-location torrent=%llu file=%zu -> %s: %s", + (unsigned long long)task->id, i, final, + naut_strerror(e)); + snprintf(final, sizeof final, "%s", cur[i]); /* stayed put */ + } + } + if (did_move && under) + prune_empty_dirs(cur[i], old_base, blen); + + if (strcmp(final, def) != 0) { /* not at the default path -> track it */ + file_location *grown = realloc(newloc, (nnew + 1) * sizeof *grown); + char *p = strdup(final); + if (grown && p) { + newloc = grown; + newloc[nnew].file_index = (uint32_t)i; + newloc[nnew].path = p; + nnew++; + } else { + free(p); + if (grown) newloc = grown; + } + } + free(orig_rel[i]); + free(cur[i]); + } + free(orig_rel); + free(cur); + + pthread_mutex_lock(&task->lock); + free(task->output_dir); + task->output_dir = target; /* take ownership */ + for (size_t i = 0; i < task->num_locations; i++) free(task->locations[i].path); + free(task->locations); + task->locations = newloc; + task->num_locations = nnew; + pthread_mutex_unlock(&task->lock); + + NAUT_INFO("set-location torrent=%llu -> %s (%zu/%zu files moved%s)", + (unsigned long long)task->id, target, moved, nfiles, + reset ? ", reset to original paths" : ""); + free(old_base); + persist_torrents(task->daemon); +} + +static void torrent_control(void *opaque, naut_storage *storage) { + torrent_task *task = opaque; + bool moved = false; + for (;;) { + move_command command; + pthread_mutex_lock(&task->lock); + if (task->move_count == 0) { + pthread_mutex_unlock(&task->lock); + break; + } + command = task->moves[task->move_head]; + task->move_head = (task->move_head + 1) % MOVE_QUEUE_CAPACITY; + task->move_count--; + pthread_mutex_unlock(&task->lock); + + naut_err error = naut_storage_relocate( + storage, command.file_index, command.destination); + pthread_mutex_lock(&task->lock); + if (error == NAUT_OK) { + task->moves_processed++; + task_set_location(task, command.file_index, command.destination); + moved = true; + } else { + task->moves_failed++; + } + pthread_mutex_unlock(&task->lock); + if (error == NAUT_OK) + NAUT_INFO("moved torrent=%llu file=%u -> %s", + (unsigned long long)task->id, command.file_index, + command.destination); + else + NAUT_WARN("move torrent=%llu file=%u -> %s failed: %s", + (unsigned long long)task->id, command.file_index, + command.destination, naut_strerror(error)); + } + /* Persist the new locations so a restart reopens the files in place. */ + if (moved) persist_torrents(task->daemon); + + apply_pending_save_path(task, storage); +} + +static void *torrent_worker(void *opaque) { + torrent_task *task = opaque; + /* Snapshot the saved moved-file locations so the swarm reopens them in + * place instead of re-downloading. Copied so a later move (processed on this + * same thread) reallocating task->locations can't invalidate them. */ + naut_swarm_file_location *locations = NULL; + size_t num_locations = 0; + pthread_mutex_lock(&task->lock); + bool check_only = task->checking; + task->state = check_only ? TORRENT_CHECKING : TORRENT_RUNNING; + if (task->num_locations && + (locations = calloc(task->num_locations, sizeof *locations))) { + for (size_t i = 0; i < task->num_locations; i++) { + char *path = strdup(task->locations[i].path); + if (!path) continue; + locations[num_locations].file_index = task->locations[i].file_index; + locations[num_locations].path = path; + num_locations++; + } + } + pthread_mutex_unlock(&task->lock); + + naut_swarm_config config = { + .source = task->source, + .output_dir = task->output_dir, + .peers = (const char *const *)task->peers, + .num_peers = task->num_peers, + .locations = locations, + .num_locations = num_locations, + .torrent_id = task->id, + .events = task->daemon->events, + .keep_alive = true, + .check_only = check_only, + .on_progress = torrent_progress, + .on_control = torrent_control, + .should_stop = torrent_should_stop, + .download_rate = torrent_download_rate, + .should_dump = torrent_should_dump, + .on_dump = torrent_on_dump, + .context = task, + }; + naut_err result = naut_swarm_run(&config); + + for (size_t i = 0; i < num_locations; i++) free((char *)locations[i].path); + free(locations); + + pthread_mutex_lock(&task->lock); + task->result = result; + if (check_only) { + /* One-shot hash check finished: progress is recorded; return to the + * paused state (or queued if the user resumed mid-check). */ + task->checking = false; + task->needs_check = false; + task->state = task->paused ? TORRENT_PAUSED : TORRENT_QUEUED; + } else if (task->stop_requested) { + /* A requested stop wins over the run result: a completed torrent returns + * NAUT_OK even when paused/stopped, and marking it COMPLETE would make + * the lifecycle reconciler immediately relaunch its keep-alive worker + * (clearing `paused`) — i.e. pause wouldn't stick for seeding torrents. */ + task->state = task->paused ? TORRENT_PAUSED : TORRENT_STOPPED; + } else if (result == NAUT_OK) { + task->state = TORRENT_COMPLETE; + } else { + task->state = TORRENT_ERROR; + } + task->thread_done = true; + pthread_mutex_unlock(&task->lock); + return NULL; +} + +static json_t *torrent_json(torrent_task *task) { + pthread_mutex_lock(&task->lock); + json_t *result = json_object(); + if (result) { + json_object_set_new(result, "torrent_id", + json_integer((json_int_t)task->id)); + json_object_set_new(result, "source", json_string(task->source)); + json_object_set_new(result, "output", + json_string(task->output_dir)); + if (task->name) + json_object_set_new(result, "name", json_string(task->name)); + json_object_set_new(result, "state", + json_string(torrent_state_name(task->state))); + json_object_set_new(result, "paused", json_boolean(task->paused)); + json_object_set_new(result, "force_start", + json_boolean(task->force_start)); + json_object_set_new(result, "queue_pos", + json_integer(task->queue_pos)); + json_object_set_new(result, "bytes_done", + json_integer((json_int_t)task->stats.bytes_done)); + json_object_set_new(result, "total_bytes", + json_integer((json_int_t)task->stats.total_bytes)); + json_object_set_new(result, "pieces_done", + json_integer(task->stats.pieces_done)); + json_object_set_new(result, "total_pieces", + json_integer(task->stats.total_pieces)); + json_t *piece_states = json_array(); + if (piece_states) { + uint32_t state_count = task->stats.piece_state_count; + if (state_count > NAUT_SWARM_MAX_PIECE_STATS) + state_count = NAUT_SWARM_MAX_PIECE_STATS; + for (uint32_t i = 0; i < state_count; i++) + json_array_append_new(piece_states, + json_integer(task->stats.piece_states[i])); + json_object_set_new(result, "piece_states", piece_states); + } + json_object_set_new(result, "peers", + json_integer(task->stats.peers_active)); + json_object_set_new(result, "peers_discovered", + json_integer(task->stats.peers_total)); + json_object_set_new(result, "peers_connecting", + json_integer(task->stats.peers_connecting)); + json_object_set_new(result, "peers_failed", + json_integer(task->stats.peers_failed)); + json_t *peer_list = json_array(); + if (peer_list) { + uint32_t peer_count = task->stats.peer_count; + if (peer_count > NAUT_SWARM_MAX_PEER_STATS) + peer_count = NAUT_SWARM_MAX_PEER_STATS; + for (uint32_t i = 0; i < peer_count; i++) { + const naut_swarm_peer_stats *peer = + &task->stats.peer_stats[i]; + json_t *item = json_pack( + "{s:s,s:i,s:s,s:s,s:s,s:f,s:f,s:I,s:I,s:I,s:I}", + "ip", peer->ip, + "port", (int)peer->port, + "client", peer->client, + "connection", peer->connection, + "flags", peer->flags, + "progress", peer->progress, + "relevance", peer->relevance, + "downloaded", (json_int_t)peer->downloaded, + "uploaded", (json_int_t)peer->uploaded, + "dlspeed", (json_int_t)peer->dlspeed, + "upspeed", (json_int_t)peer->upspeed); + if (item) json_array_append_new(peer_list, item); + } + json_object_set_new(result, "peer_list", peer_list); + } + json_t *trackers = json_array(); + if (trackers) { + uint32_t tracker_count = task->stats.tracker_count; + if (tracker_count > NAUT_SWARM_MAX_TRACKER_STATS) + tracker_count = NAUT_SWARM_MAX_TRACKER_STATS; + for (uint32_t i = 0; i < tracker_count; i++) { + const naut_swarm_tracker_stats *tracker = + &task->stats.tracker_stats[i]; + json_t *item = json_pack( + "{s:s,s:i,s:s,s:i,s:i,s:i,s:i,s:s}", + "url", tracker->url, + "tier", tracker->tier, + "status", tracker->status, + "seeds", tracker->seeds, + "peers", tracker->peers, + "leeches", tracker->leeches, + "downloaded", tracker->downloaded, + "message", tracker->message); + if (item) json_array_append_new(trackers, item); + } + json_object_set_new(result, "trackers", trackers); + } + json_t *files = json_array(); + if (files) { + uint32_t file_count = task->stats.file_count; + if (file_count > NAUT_SWARM_MAX_FILE_STATS) + file_count = NAUT_SWARM_MAX_FILE_STATS; + for (uint32_t i = 0; i < file_count; i++) { + const naut_swarm_file_stats *file = + &task->stats.file_stats[i]; + json_t *item = json_pack( + "{s:s,s:I,s:f,s:i,s:f}", + "name", file->path, + "size", (json_int_t)file->size, + "progress", file->progress, + "priority", file->priority, + "availability", file->availability); + if (item) json_array_append_new(files, item); + } + json_object_set_new(result, "files", files); + } + json_object_set_new(result, "elapsed_seconds", + json_real(task->stats.elapsed_seconds)); + json_object_set_new(result, "pending_moves", + json_integer((json_int_t)task->move_count)); + json_object_set_new(result, "moves_processed", + json_integer((json_int_t)task->moves_processed)); + json_object_set_new(result, "moves_failed", + json_integer((json_int_t)task->moves_failed)); + if (task->num_locations) { + json_t *locations = json_array(); + if (locations) { + for (size_t i = 0; i < task->num_locations; i++) + json_array_append_new(locations, json_pack( + "{s:i,s:s}", + "file", (int)task->locations[i].file_index, + "path", task->locations[i].path)); + json_object_set_new(result, "locations", locations); + } + } + json_object_set_new(result, "category", + json_string(task->category ? task->category : "")); + json_object_set_new(result, "tags", task_tags_json(task)); + if (task->pending_save_path) + json_object_set_new(result, "pending_save_path", + json_string(task->pending_save_path)); + if (task->state == TORRENT_ERROR) + json_object_set_new(result, "error", + json_string(naut_strerror(task->result))); + } + pthread_mutex_unlock(&task->lock); + return result; +} + static json_t *rpc_ping(void *opaque, const json_t *params, naut_err *error) { (void)opaque; (void)params; @@ -68,18 +728,96 @@ static json_t *rpc_ping(void *opaque, const json_t *params, naut_err *error) { return result; } +static char *read_text_file_limited(const char *path, size_t max_bytes) { + FILE *file = fopen(path, "rb"); + if (!file) return NULL; + char *buf = malloc(max_bytes + 1); + if (!buf) { + fclose(file); + return NULL; + } + size_t n = fread(buf, 1, max_bytes, file); + bool too_large = !feof(file); + bool error = ferror(file); + fclose(file); + if (error) { + free(buf); + return NULL; + } + buf[n] = 0; + if (too_large) { + const char suffix[] = "\n-- truncated --\n"; + size_t suffix_len = sizeof suffix - 1; + if (max_bytes >= suffix_len) { + memcpy(buf + max_bytes - suffix_len, suffix, suffix_len + 1); + } + } + return buf; +} + +static json_t *script_status_json(daemon_state *state) { + naut_script_stats stats = {0}; + char last_error[256] = {0}; + char *path = NULL; + bool loaded = false; + + pthread_mutex_lock(&state->script_lock); + loaded = state->script != NULL; + if (state->script) { + naut_script_get_stats(state->script, &stats); + snprintf(last_error, sizeof last_error, "%s", + naut_script_last_error(state->script)); + } + if (state->script_path) + path = strdup(state->script_path); + pthread_mutex_unlock(&state->script_lock); + + char *source = path ? read_text_file_limited(path, 256 * 1024) : NULL; + json_t *script = json_pack( + "{s:b,s:s,s:s,s:I,s:I,s:I,s:I,s:I,s:s}", + "loaded", loaded, + "path", path ? path : "", + "source", source ? source : "", + "queued", (json_int_t)stats.queued, + "handled", (json_int_t)stats.handled, + "dropped", (json_int_t)stats.dropped, + "errors", (json_int_t)stats.errors, + "move_requests", (json_int_t)stats.move_requests, + "last_error", last_error); + if (script) { + json_t *settings = script_settings_json(state); + json_object_set_new(script, "settings", + settings ? settings : json_array()); + } + free(source); + free(path); + return script; +} + static json_t *rpc_status(void *opaque, const json_t *params, naut_err *error) { (void)params; daemon_state *state = opaque; - naut_script_stats stats = {0}; - if (state->script) naut_script_get_stats(state->script, &stats); - pthread_mutex_lock(&state->move_lock); - uint64_t moves = state->moves_processed; - size_t pending = state->move_count; - pthread_mutex_unlock(&state->move_lock); + size_t torrent_count; + size_t active = 0; + uint64_t moves = 0; + size_t pending = 0; + pthread_mutex_lock(&state->torrent_lock); + torrent_count = state->torrent_count; + for (size_t i = 0; i < torrent_count; i++) { + torrent_task *task = state->torrents[i]; + pthread_mutex_lock(&task->lock); + if (task->state == TORRENT_RUNNING || + task->state == TORRENT_STALLED || + task->state == TORRENT_STOPPING) + active++; + moves += task->moves_processed; + pending += task->move_count; + pthread_mutex_unlock(&task->lock); + } + pthread_mutex_unlock(&state->torrent_lock); json_t *result = json_object(); - json_t *script = json_object(); + json_t *script = script_status_json(state); if (!result || !script) { json_decref(result); json_decref(script); @@ -93,12 +831,13 @@ static json_t *rpc_status(void *opaque, const json_t *params, json_object_set_new(result, "storage_backends", json_integer((json_int_t)naut_plugin_storage_count( state->plugins))); - json_object_set_new(script, "queued", json_integer(stats.queued)); - json_object_set_new(script, "handled", json_integer(stats.handled)); - json_object_set_new(script, "dropped", json_integer(stats.dropped)); - json_object_set_new(script, "errors", json_integer(stats.errors)); - json_object_set_new(script, "move_requests", - json_integer(stats.move_requests)); + json_object_set_new(result, "torrents", + json_integer((json_int_t)torrent_count)); + json_object_set_new(result, "active_torrents", + json_integer((json_int_t)active)); + json_object_set_new(result, "script_loaded", + json_boolean(json_boolean_value( + json_object_get(script, "loaded")))); json_object_set_new(result, "script", script); json_object_set_new(result, "move_commands", json_integer(moves)); json_object_set_new(result, "pending_move_commands", @@ -107,6 +846,15 @@ static json_t *rpc_status(void *opaque, const json_t *params, return result; } +static json_t *rpc_script_status(void *opaque, const json_t *params, + naut_err *error) { + (void)params; + daemon_state *state = opaque; + json_t *script = script_status_json(state); + *error = script ? NAUT_OK : NAUT_ERR_NOMEM; + return script; +} + static json_t *rpc_plugins(void *opaque, const json_t *params, naut_err *error) { (void)params; @@ -178,122 +926,1188 @@ static json_t *rpc_shutdown(void *opaque, const json_t *params, return json_true(); } -static uint8_t *read_file(const char *path, size_t *len) { +static int b64_val(int c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; /* padding / whitespace / invalid -> skipped */ +} + +/* Decode standard base64 (padding optional, whitespace ignored). */ +static unsigned char *b64_decode(const char *in, size_t *out_len) { + size_t cap = strlen(in) / 4 * 3 + 4; + unsigned char *out = malloc(cap); + if (!out) return NULL; + size_t o = 0; + int acc = 0, bits = 0; + for (const char *p = in; *p; p++) { + if (*p == '=') break; + int v = b64_val((unsigned char)*p); + if (v < 0) continue; + acc = (acc << 6) | v; + bits += 6; + if (bits >= 8) { bits -= 8; out[o++] = (unsigned char)((acc >> bits) & 0xff); } + } + *out_len = o; + return out; +} + +static bool write_all_fd(int fd, const void *buf, size_t len) { + const char *p = buf; + while (len) { + ssize_t n = write(fd, p, len); + if (n < 0) { if (errno == EINTR) continue; return false; } + p += n; + len -= (size_t)n; + } + return true; +} + +/* A browser uploads a .torrent's bytes as base64 in "data"; the daemon writes + * its own file and owns its lifecycle (no shared path with the client). When + * persistence is enabled the file goes under /uploads so it survives + * a restart (*managed = true, unlink only on remove); otherwise it lands in /tmp + * (*managed = false, unlink on any destroy). Returns the path in `out`. */ +static bool add_torrent_write_upload(daemon_state *state, const char *data_b64, + char *out, size_t cap, bool *managed, + naut_err *error) { + size_t raw_len = 0; + unsigned char *raw = b64_decode(data_b64, &raw_len); + if (!raw || raw_len == 0) { free(raw); *error = NAUT_ERR_INVAL; return false; } + char tmpl[PATH_MAX + 32]; + if (state->persist_enabled) + snprintf(tmpl, sizeof tmpl, "%s/upload-XXXXXX", state->uploads_dir); + else + snprintf(tmpl, sizeof tmpl, "/tmp/naut-upload-XXXXXX"); + int fd = mkstemp(tmpl); + if (fd < 0) { free(raw); *error = NAUT_ERR_IO; return false; } + bool ok = write_all_fd(fd, raw, raw_len); + close(fd); + free(raw); + if (!ok) { unlink(tmpl); *error = NAUT_ERR_IO; return false; } + size_t path_len = strlen(tmpl); + if (path_len + 1 > cap) { + unlink(tmpl); + *error = NAUT_ERR_INVAL; + return false; + } + memcpy(out, tmpl, path_len + 1); + *managed = state->persist_enabled; + return true; +} + +/* --- persistence --------------------------------------------------------- */ + +/* On-disk record for one torrent (caller holds task->lock). */ +static json_t *torrent_record(const torrent_task *task) { + json_t *rec = json_object(); + if (!rec) return NULL; + json_object_set_new(rec, "torrent_id", json_integer((json_int_t)task->id)); + json_object_set_new(rec, "source", json_string(task->source)); + json_object_set_new(rec, "output", json_string(task->output_dir)); + json_object_set_new(rec, "source_managed", + json_boolean(task->source_managed)); + if (task->name) json_object_set_new(rec, "name", json_string(task->name)); + json_object_set_new(rec, "paused", json_boolean(task->paused)); + json_object_set_new(rec, "force_start", json_boolean(task->force_start)); + json_object_set_new(rec, "queue_pos", json_integer(task->queue_pos)); + json_t *peers = json_array(); + if (peers) { + for (size_t i = 0; i < task->num_peers; i++) + json_array_append_new(peers, json_string(task->peers[i])); + json_object_set_new(rec, "peers", peers); + } + if (task->num_locations) { + json_t *locations = json_array(); + if (locations) { + for (size_t i = 0; i < task->num_locations; i++) + json_array_append_new(locations, json_pack( + "{s:i,s:s}", + "file", (int)task->locations[i].file_index, + "path", task->locations[i].path)); + json_object_set_new(rec, "locations", locations); + } + } + if (task->category && *task->category) + json_object_set_new(rec, "category", json_string(task->category)); + if (task->num_tags) + json_object_set_new(rec, "tags", task_tags_json(task)); + if (task->pending_save_path) { + json_object_set_new(rec, "pending_save_path", + json_string(task->pending_save_path)); + json_object_set_new(rec, "pending_save_path_reset", + json_boolean(task->pending_save_path_reset)); + } + return rec; +} + +/* Atomically write the current (non-removed) torrent set to state_file. */ +static void persist_torrents(daemon_state *state) { + if (!state->persist_enabled) return; + json_t *array = json_array(); + if (!array) return; + pthread_mutex_lock(&state->torrent_lock); + for (size_t i = 0; i < state->torrent_count; i++) { + torrent_task *task = state->torrents[i]; + pthread_mutex_lock(&task->lock); + json_t *rec = task->remove_requested ? NULL : torrent_record(task); + pthread_mutex_unlock(&task->lock); + if (rec) json_array_append_new(array, rec); + } + pthread_mutex_unlock(&state->torrent_lock); + + char tmp[PATH_MAX + 8]; + snprintf(tmp, sizeof tmp, "%s.tmp", state->state_file); + if (json_dump_file(array, tmp, JSON_INDENT(2)) != 0) { + NAUT_WARN("persist: write %s failed", tmp); + unlink(tmp); + } else if (rename(tmp, state->state_file) != 0) { + NAUT_WARN("persist: rename to %s failed: %s", + state->state_file, strerror(errno)); + unlink(tmp); + } + json_decref(array); +} + +/* --- daemon preferences (queue limit + throttle) ------------------------ */ + +static void persist_prefs(daemon_state *state) { + if (!state->persist_enabled) return; + json_t *p = json_pack( + "{s:i,s:I,s:I,s:I,s:I,s:b}", + "max_active", (json_int_t)state->max_active, + "dl_limit", (json_int_t)state->dl_limit, + "alt_dl_limit", (json_int_t)state->alt_dl_limit, + "up_limit", (json_int_t)state->up_limit, + "alt_up_limit", (json_int_t)state->alt_up_limit, + "alt_speed_enabled", state->alt_speed_enabled); + if (!p) return; + char tmp[PATH_MAX + 8]; + snprintf(tmp, sizeof tmp, "%s.tmp", state->prefs_file); + if (json_dump_file(p, tmp, JSON_INDENT(2)) != 0 || + rename(tmp, state->prefs_file) != 0) { + NAUT_WARN("persist: write %s failed", state->prefs_file); + unlink(tmp); + } + json_decref(p); +} + +static void load_prefs(daemon_state *state) { + if (!state->persist_enabled) return; + json_error_t jerr; + json_t *p = json_load_file(state->prefs_file, 0, &jerr); + if (!p) return; + json_t *v; + if ((v = json_object_get(p, "max_active")) && json_is_integer(v) && + json_integer_value(v) > 0) + state->max_active = (uint32_t)json_integer_value(v); + if ((v = json_object_get(p, "dl_limit")) && json_is_integer(v)) + state->dl_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(p, "alt_dl_limit")) && json_is_integer(v)) + state->alt_dl_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(p, "up_limit")) && json_is_integer(v)) + state->up_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(p, "alt_up_limit")) && json_is_integer(v)) + state->alt_up_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(p, "alt_speed_enabled"))) + state->alt_speed_enabled = json_boolean_value(v); + json_decref(p); +} + +static json_t *prefs_json(daemon_state *state) { + return json_pack( + "{s:i,s:I,s:I,s:I,s:I,s:b}", + "max_active", (json_int_t)state->max_active, + "dl_limit", (json_int_t)state->dl_limit, + "alt_dl_limit", (json_int_t)state->alt_dl_limit, + "up_limit", (json_int_t)state->up_limit, + "alt_up_limit", (json_int_t)state->alt_up_limit, + "alt_speed_enabled", state->alt_speed_enabled); +} + +/* --- script settings (schema declared by the script, values set by the UI) - */ + +static const char *jstr(const json_t *obj, const char *key, + const char *fallback) { + const char *v = json_string_value(json_object_get(obj, key)); + return v ? v : fallback; +} + +/* Coerce any JSON scalar to a freshly allocated string ("true"/"false" for + * bools, plain digits for numbers). Returns NULL for non-scalars. */ +static char *json_scalar_to_string(const json_t *v) { + if (json_is_string(v)) return strdup(json_string_value(v)); + if (json_is_true(v)) return strdup("true"); + if (json_is_false(v)) return strdup("false"); + if (json_is_integer(v)) { + char buf[32]; + snprintf(buf, sizeof buf, "%lld", (long long)json_integer_value(v)); + return strdup(buf); + } + if (json_is_real(v)) { + char buf[32]; + snprintf(buf, sizeof buf, "%g", json_real_value(v)); + return strdup(buf); + } + return NULL; +} + +/* Find a schema entry by key (caller holds settings_lock). */ +static json_t *settings_schema_entry(daemon_state *state, const char *key) { + if (!state->script_settings_schema) return NULL; + size_t i; + json_t *entry; + json_array_foreach(state->script_settings_schema, i, entry) + if (strcmp(jstr(entry, "key", ""), key) == 0) return entry; + return NULL; +} + +static void persist_script_settings(daemon_state *state) { + if (!state->persist_enabled) return; + pthread_mutex_lock(&state->settings_lock); + json_t *copy = state->script_settings + ? json_deep_copy(state->script_settings) : json_object(); + pthread_mutex_unlock(&state->settings_lock); + if (!copy) return; + char tmp[PATH_MAX + 8]; + snprintf(tmp, sizeof tmp, "%s.tmp", state->settings_file); + if (json_dump_file(copy, tmp, JSON_INDENT(2)) != 0 || + rename(tmp, state->settings_file) != 0) { + NAUT_WARN("persist: write %s failed", state->settings_file); + unlink(tmp); + } + json_decref(copy); +} + +static void load_script_settings(daemon_state *state) { + if (!state->persist_enabled) return; + json_error_t jerr; + json_t *v = json_load_file(state->settings_file, 0, &jerr); + if (!v) return; + if (json_is_object(v)) { + pthread_mutex_lock(&state->settings_lock); + json_decref(state->script_settings); + state->script_settings = v; + pthread_mutex_unlock(&state->settings_lock); + } else { + json_decref(v); + } +} + +/* Host callback: the script (re)declared its settings schema. */ +static void daemon_define_settings(void *opaque, + const naut_script_setting_def *defs, + size_t count) { + daemon_state *state = opaque; + json_t *schema = json_array(); + if (!schema) return; + for (size_t i = 0; i < count; i++) { + const char *type = defs[i].type ? defs[i].type : "string"; + json_t *entry = json_pack( + "{s:s,s:s,s:s,s:s}", + "key", defs[i].key, + "label", defs[i].label ? defs[i].label : defs[i].key, + "type", type, + "default", defs[i].default_value ? defs[i].default_value : ""); + if (entry) json_array_append_new(schema, entry); + } + pthread_mutex_lock(&state->settings_lock); + json_decref(state->script_settings_schema); + state->script_settings_schema = schema; + pthread_mutex_unlock(&state->settings_lock); +} + +/* Host callback: resolve a setting (user value, else declared default). */ +static char *daemon_get_setting(void *opaque, const char *key, + naut_setting_type *type) { + daemon_state *state = opaque; + char *out = NULL; + *type = NAUT_SETTING_STRING; + pthread_mutex_lock(&state->settings_lock); + json_t *entry = settings_schema_entry(state, key); + const char *tname = entry ? jstr(entry, "type", "string") + : "string"; + if (strcmp(tname, "bool") == 0) *type = NAUT_SETTING_BOOL; + else if (strcmp(tname, "number") == 0) *type = NAUT_SETTING_NUMBER; + json_t *value = state->script_settings + ? json_object_get(state->script_settings, key) : NULL; + if (value) + out = json_scalar_to_string(value); + else if (entry) + out = strdup(jstr(entry, "default", "")); + pthread_mutex_unlock(&state->settings_lock); + return out; +} + +/* The settings block for script_status: schema fields plus the effective value + * (user-set if present, otherwise the declared default). */ +static json_t *script_settings_json(daemon_state *state) { + json_t *out = json_array(); + if (!out) return NULL; + pthread_mutex_lock(&state->settings_lock); + if (state->script_settings_schema) { + size_t i; + json_t *entry; + json_array_foreach(state->script_settings_schema, i, entry) { + const char *key = jstr(entry, "key", ""); + const char *def = jstr(entry, "default", ""); + json_t *uv = state->script_settings + ? json_object_get(state->script_settings, key) : NULL; + char *vs = uv ? json_scalar_to_string(uv) : NULL; + json_t *item = json_pack( + "{s:s,s:s,s:s,s:s,s:s}", + "key", key, + "label", jstr(entry, "label", key), + "type", jstr(entry, "type", "string"), + "default", def, + "value", vs ? vs : def); + free(vs); + if (item) json_array_append_new(out, item); + } + } + pthread_mutex_unlock(&state->settings_lock); + return out; +} + +static naut_script_host script_host(daemon_state *state) { + naut_script_host host = { + .move_file = queue_move, + .labels = script_labels, + .define_settings = daemon_define_settings, + .get_setting = daemon_get_setting, + .context = state, + }; + return host; +} + +/* Merge user-supplied values (keys must exist in the schema) and persist. */ +static json_t *rpc_set_script_settings(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + json_t *settings = json_is_object(params) + ? json_object_get(params, "settings") : NULL; + if (!json_is_object(settings)) { *error = NAUT_ERR_INVAL; return NULL; } + + pthread_mutex_lock(&state->settings_lock); + if (!state->script_settings) state->script_settings = json_object(); + if (state->script_settings) { + const char *key; + json_t *value; + json_object_foreach(settings, key, value) { + if (!settings_schema_entry(state, key)) continue; /* unknown key */ + char *vs = json_scalar_to_string(value); + if (vs) { + json_object_set_new(state->script_settings, key, + json_string(vs)); + free(vs); + } + } + } + pthread_mutex_unlock(&state->settings_lock); + persist_script_settings(state); + *error = NAUT_OK; + return script_status_json(state); +} + +/* --- data-overlap guard (block torrents that would write the same files) --- */ + +static uint8_t *slurp_file(const char *path, size_t *len) { FILE *f = fopen(path, "rb"); if (!f) return NULL; if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return NULL; } long n = ftell(f); - if (n < 0 || fseek(f, 0, SEEK_SET) != 0) { fclose(f); return NULL; } - uint8_t *buf = malloc((size_t)n); + if (n < 0) { fclose(f); return NULL; } + rewind(f); + uint8_t *buf = malloc((size_t)n + 1); if (!buf) { fclose(f); return NULL; } - if (fread(buf, 1, (size_t)n, f) != (size_t)n) { - free(buf); fclose(f); return NULL; - } + size_t got = fread(buf, 1, (size_t)n, f); fclose(f); - *len = (size_t)n; + if (got != (size_t)n) { free(buf); return NULL; } + if (len) *len = got; return buf; } -/* add_torrent {torrent_id, torrent: <.torrent path>, root: } opens - * the torrent's storage and registers it so move_file can later relocate one of - * its files. This is the control-plane seam that binds a script's move command - * to a concrete naut_storage; it runs on the daemon owner thread. */ +/* Parse a .torrent file's file list into relative paths. Returns the count (0 + * for magnets, which have no metadata yet, or on failure). Caller frees. */ +static size_t parse_torrent_files(const char *source, char ***out) { + *out = NULL; + if (!source || strncmp(source, "magnet:", 7) == 0) return 0; + size_t len = 0; + uint8_t *bytes = slurp_file(source, &len); + if (!bytes) return 0; + naut_metainfo mi; + memset(&mi, 0, sizeof mi); + if (naut_metainfo_parse(bytes, len, &mi) != NAUT_OK) { free(bytes); return 0; } + free(bytes); + char **files = mi.num_files ? calloc(mi.num_files, sizeof *files) : NULL; + size_t c = 0; + if (files) + for (size_t i = 0; i < mi.num_files; i++) + if (mi.files[i].path) { + char *p = strdup(mi.files[i].path); + if (p) files[c++] = p; + } + naut_metainfo_free(&mi); + *out = files; + return c; +} + +/* Two absolute paths conflict if equal, or one is a directory-prefix of the + * other (a file vs a folder that would contain it). */ +static bool paths_conflict(const char *a, const char *b) { + size_t la = strlen(a), lb = strlen(b); + if (la == lb) return strcmp(a, b) == 0; + const char *shorter = la < lb ? a : b, *longer = la < lb ? b : a; + size_t sl = la < lb ? la : lb; + return strncmp(shorter, longer, sl) == 0 && longer[sl] == '/'; +} + +/* Current absolute path of file `fi` (relpath `rel`) for `task`: its override if + * individually moved, else output_dir/rel. Caller holds task->lock. */ +static void task_file_abs(const torrent_task *task, size_t fi, const char *rel, + char *out, size_t outsz) { + for (size_t i = 0; i < task->num_locations; i++) + if (task->locations[i].file_index == fi) { + snprintf(out, outsz, "%s", task->locations[i].path); + return; + } + snprintf(out, outsz, "%s/%s", task->output_dir ? task->output_dir : "", rel); +} + +/* True if any of `news` (absolute paths) would write where an already-registered + * torrent's file lives. Caller holds state->torrent_lock; locks each task. */ +static bool data_overlaps_locked(daemon_state *state, char *const *news, + size_t nnew, char *conflict, size_t csz) { + for (size_t t = 0; t < state->torrent_count; t++) { + torrent_task *o = state->torrents[t]; + pthread_mutex_lock(&o->lock); + bool removed = o->remove_requested; + for (size_t fi = 0; !removed && fi < o->num_files; fi++) { + char have[PATH_MAX]; + task_file_abs(o, fi, o->files[fi], have, sizeof have); + for (size_t k = 0; k < nnew; k++) + if (paths_conflict(have, news[k])) { + snprintf(conflict, csz, "%s", have); + pthread_mutex_unlock(&o->lock); + return true; + } + } + pthread_mutex_unlock(&o->lock); + } + return false; +} + +/* Build a torrent_task from validated inputs, register it under an id, and start + * its worker. Returns the task (added to state->torrents) or NULL + *error. All + * inputs are copied; the source file is never unlinked here (the caller owns + * that decision so a failed restore does not delete a durable upload). When + * `check_overlap` is set, an add whose files would land on an existing torrent's + * data is refused with NAUT_ERR_EXIST. */ +static torrent_task *spawn_torrent(daemon_state *state, const char *source, + bool source_managed, bool source_is_temp, + const char *output, const json_t *peers_json, + const json_t *locations_json, + const json_t *meta_json, + const json_t *id_opt, const char *name, + bool start_paused, bool force_start, + int queue_pos, bool check_overlap, + naut_err *error) { + torrent_task *task = calloc(1, sizeof(*task)); + if (!task) { *error = NAUT_ERR_NOMEM; return NULL; } + task->daemon = state; + task->state = start_paused ? TORRENT_PAUSED : TORRENT_QUEUED; + task->result = NAUT_ERR_AGAIN; + task->paused = start_paused; + /* A paused torrent never runs a download worker, so hash-check its data once + * (via a check-only worker) to report accurate progress. */ + task->needs_check = start_paused; + task->force_start = force_start; + task->source = strdup(source); + task->source_is_temp = source_is_temp; + task->source_managed = source_managed; + task->output_dir = strdup(output); + task->name = (name && *name) ? strdup(name) : NULL; + if (!task->source || !task->output_dir || (name && *name && !task->name)) { + *error = NAUT_ERR_NOMEM; + goto fail_early; + } + if (pthread_mutex_init(&task->lock, NULL) != 0) { + *error = NAUT_ERR_NOMEM; + goto fail_early; + } + + task->num_peers = peers_json ? json_array_size(peers_json) : 0; + if (task->num_peers) { + task->peers = calloc(task->num_peers, sizeof(*task->peers)); + if (!task->peers) { *error = NAUT_ERR_NOMEM; goto fail_task; } + for (size_t i = 0; i < task->num_peers; i++) { + const char *peer = + json_string_value(json_array_get(peers_json, i)); + if (!peer || !*peer) { *error = NAUT_ERR_INVAL; goto fail_task; } + task->peers[i] = strdup(peer); + if (!task->peers[i]) { *error = NAUT_ERR_NOMEM; goto fail_task; } + } + } + + size_t nloc = locations_json ? json_array_size(locations_json) : 0; + for (size_t i = 0; i < nloc; i++) { + json_t *entry = json_array_get(locations_json, i); + json_t *fidx = json_object_get(entry, "file"); + const char *path = json_string_value(json_object_get(entry, "path")); + if (!json_is_integer(fidx) || json_integer_value(fidx) < 0 || !path) + continue; /* skip malformed entries rather than fail the restore */ + task_set_location(task, (uint32_t)json_integer_value(fidx), path); + } + if (meta_json) { + task_set_category(task, + json_string_value(json_object_get(meta_json, "category"))); + json_t *tags = json_object_get(meta_json, "tags"); + /* Migrate the old flat "labels" record into tags. */ + if (!json_is_array(tags)) tags = json_object_get(meta_json, "labels"); + task_set_tags(task, tags); + const char *pending = + json_string_value(json_object_get(meta_json, "pending_save_path")); + if (pending && *pending) { + task->pending_save_path = strdup(pending); + task->pending_save_path_reset = + json_boolean_value(json_object_get(meta_json, + "pending_save_path_reset")); + } + } + + /* File list (for overlap detection); empty for magnets until metadata. */ + task->num_files = parse_torrent_files(source, &task->files); + + pthread_mutex_lock(&state->torrent_lock); + if (state->torrent_count == MAX_TORRENTS) { + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_FULL; + goto fail_task; + } + if (check_overlap && task->num_files) { + char **news = calloc(task->num_files, sizeof *news); + bool ok = news != NULL; + for (size_t i = 0; ok && i < task->num_files; i++) { + char tmp[PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s/%s", output, task->files[i]); + news[i] = strdup(tmp); + if (!news[i]) ok = false; + } + char conflict[PATH_MAX] = {0}; + bool overlap = ok && data_overlaps_locked(state, news, task->num_files, + conflict, sizeof conflict); + for (size_t i = 0; news && i < task->num_files; i++) free(news[i]); + free(news); + if (!ok || overlap) { + pthread_mutex_unlock(&state->torrent_lock); + if (overlap) + NAUT_WARN("add blocked: data overlaps existing torrent at %s", + conflict); + *error = overlap ? NAUT_ERR_EXIST : NAUT_ERR_NOMEM; + goto fail_task; + } + } + if (id_opt) { + if (!json_is_integer(id_opt) || json_integer_value(id_opt) < 0) { + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_INVAL; + goto fail_task; + } + task->id = (uint64_t)json_integer_value(id_opt); + if (task->id < (uint64_t)INT64_MAX && + task->id >= state->next_torrent_id) + state->next_torrent_id = task->id + 1; + } else { + while (find_torrent_locked(state, state->next_torrent_id)) + state->next_torrent_id++; + if (state->next_torrent_id > (uint64_t)INT64_MAX) { + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_FULL; + goto fail_task; + } + task->id = state->next_torrent_id++; + } + if (find_torrent_locked(state, task->id)) { + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_INVAL; + goto fail_task; + } + if (queue_pos >= 0) { + task->queue_pos = queue_pos; + } else { /* append to the tail of the queue */ + int max_pos = 0; + for (size_t i = 0; i < state->torrent_count; i++) + if (state->torrents[i]->queue_pos > max_pos) + max_pos = state->torrents[i]->queue_pos; + task->queue_pos = max_pos + 1; + } + state->torrents[state->torrent_count++] = task; + pthread_mutex_unlock(&state->torrent_lock); + + /* Register without a worker; the lifecycle reconciler starts it when it is + * within the active-download budget (and not paused). thread_done=true marks + * it as cleanly (re)startable. */ + task->thread_done = true; + *error = NAUT_OK; + return task; + +fail_task: + for (size_t i = 0; i < task->num_peers; i++) free(task->peers[i]); + free(task->peers); + for (size_t i = 0; i < task->num_locations; i++) free(task->locations[i].path); + free(task->locations); + for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]); + free(task->tags); + for (size_t i = 0; i < task->num_files; i++) free(task->files[i]); + free(task->files); + free(task->category); + free(task->pending_save_path); + pthread_mutex_destroy(&task->lock); +fail_early: + free(task->name); + free(task->source); + free(task->output_dir); + free(task); + return NULL; +} + static json_t *rpc_add_torrent(void *opaque, const json_t *params, naut_err *error) { daemon_state *state = opaque; if (!json_is_object(params)) { *error = NAUT_ERR_INVAL; return NULL; } - json_int_t torrent_id = - json_integer_value(json_object_get(params, "torrent_id")); - const char *torrent_path = - json_string_value(json_object_get(params, "torrent")); - const char *root = json_string_value(json_object_get(params, "root")); - if (torrent_id < 0 || !torrent_path || !root) { + + const char *source = + json_string_value(json_object_get(params, "source")); + if (!source) + source = json_string_value(json_object_get(params, "torrent")); + const char *data_b64 = + json_string_value(json_object_get(params, "data")); + const char *output = + json_string_value(json_object_get(params, "output")); + if (!output) + output = json_string_value(json_object_get(params, "root")); + json_t *peers_json = json_object_get(params, "peers"); + /* need an output and either a source (path/magnet) or uploaded bytes */ + if (!output || !*output || + ((!source || !*source) && (!data_b64 || !*data_b64)) || + (peers_json && !json_is_array(peers_json))) { *error = NAUT_ERR_INVAL; return NULL; } - if (naut_session_has(state->session, (uint64_t)torrent_id)) { - *error = NAUT_ERR_INVAL; + + const char *name = json_string_value(json_object_get(params, "name")); + bool start_paused = + json_boolean_value(json_object_get(params, "paused")); + + /* materialize an upload into a daemon-owned .torrent (durable under + * state_dir/uploads when persistence is on, else an ephemeral /tmp file). */ + char temp_source[PATH_MAX]; + bool managed = false, is_temp = false; + if ((!source || !*source) && data_b64 && *data_b64) { + if (!add_torrent_write_upload(state, data_b64, temp_source, + sizeof temp_source, &managed, error)) + return NULL; + source = temp_source; + is_temp = !managed; /* /tmp fallback when persistence is disabled */ + } + + torrent_task *task = spawn_torrent( + state, source, managed, is_temp, output, peers_json, + /*locations_json=*/NULL, /*meta_json=*/params, + json_object_get(params, "torrent_id"), name, + start_paused, /*force_start=*/false, /*queue_pos=*/-1, + /*check_overlap=*/true, error); + if (!task) { + if (managed || is_temp) unlink(source); return NULL; } - size_t len = 0; - uint8_t *raw = read_file(torrent_path, &len); - if (!raw) { *error = NAUT_ERR_IO; return NULL; } - naut_metainfo mi; - naut_err err = naut_metainfo_parse(raw, len, &mi); - free(raw); - if (err != NAUT_OK) { *error = err; return NULL; } - naut_storage *storage = - naut_storage_open(mi.files, mi.num_files, root, &err); - if (!storage) { - naut_metainfo_free(&mi); - *error = err != NAUT_OK ? err : NAUT_ERR_IO; - return NULL; - } - naut_metainfo_free(&mi); - err = naut_session_add(state->session, (uint64_t)torrent_id, storage); - if (err != NAUT_OK) { - naut_storage_close(storage); - *error = err; - return NULL; + persist_torrents(state); + service_lifecycle(state); /* start it now if within the active budget */ + return torrent_json(task); +} + +static json_t *rpc_torrents(void *opaque, const json_t *params, + naut_err *error) { + (void)params; + daemon_state *state = opaque; + json_t *result = json_array(); + if (!result) { *error = NAUT_ERR_NOMEM; return NULL; } + pthread_mutex_lock(&state->torrent_lock); + for (size_t i = 0; i < state->torrent_count; i++) { + json_t *item = torrent_json(state->torrents[i]); + if (!item || json_array_append_new(result, item) != 0) { + json_decref(item); + json_decref(result); + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_NOMEM; + return NULL; + } } + pthread_mutex_unlock(&state->torrent_lock); *error = NAUT_OK; - return json_true(); + return result; +} + +static bool parse_torrent_id(const json_t *params, uint64_t *id) { + if (!json_is_object(params)) return false; + json_t *value = json_object_get(params, "torrent_id"); + if (!json_is_integer(value) || json_integer_value(value) < 0) return false; + *id = (uint64_t)json_integer_value(value); + return true; +} + +static json_t *rpc_torrent(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + uint64_t id; + if (!parse_torrent_id(params, &id)) { + *error = NAUT_ERR_INVAL; + return NULL; + } + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, id); + json_t *result = task ? torrent_json(task) : NULL; + pthread_mutex_unlock(&state->torrent_lock); + *error = task ? (result ? NAUT_OK : NAUT_ERR_NOMEM) : NAUT_ERR_NOTFOUND; + return result; +} + +static json_t *rpc_remove_torrent(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + uint64_t id; + if (!parse_torrent_id(params, &id)) { + *error = NAUT_ERR_INVAL; + return NULL; + } + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, id); + if (task) { + pthread_mutex_lock(&task->lock); + task->stop_requested = true; + task->remove_requested = true; + if (!task->thread_done) + task->state = TORRENT_STOPPING; + pthread_mutex_unlock(&task->lock); + } + pthread_mutex_unlock(&state->torrent_lock); + if (!task) { + *error = NAUT_ERR_NOTFOUND; + return NULL; + } + persist_torrents(state); /* drop the removed torrent from disk now */ + *error = NAUT_OK; + return torrent_json(task); +} + +/* Find a torrent by id, run `apply` under its lock, persist, and return its + * json. Shared by pause/resume/recheck. */ +static json_t *torrent_flag_op(daemon_state *state, const json_t *params, + void (*apply)(torrent_task *, const json_t *), + naut_err *error) { + uint64_t id; + if (!parse_torrent_id(params, &id)) { *error = NAUT_ERR_INVAL; return NULL; } + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, id); + if (task) { + pthread_mutex_lock(&task->lock); + apply(task, params); + pthread_mutex_unlock(&task->lock); + } + pthread_mutex_unlock(&state->torrent_lock); + if (!task) { *error = NAUT_ERR_NOTFOUND; return NULL; } + persist_torrents(state); + service_lifecycle(state); /* apply the desired-state change immediately */ + *error = NAUT_OK; + return torrent_json(task); +} + +static void apply_pause(torrent_task *task, const json_t *params) { + (void)params; + task->paused = true; + task->force_start = false; + if (!task->thread_done) { + task->stop_requested = true; + task->state = TORRENT_STOPPING; + } else { + task->state = TORRENT_PAUSED; + } +} + +static void apply_resume(torrent_task *task, const json_t *params) { + task->paused = false; + task->force_start = json_boolean_value(json_object_get(params, "force")); + /* The reconciler activates it (subject to the queue, or immediately if + * forced); leaving the flags is enough. */ +} + +static void apply_recheck(torrent_task *task, const json_t *params) { + (void)params; + if (task->paused) { + /* Recheck a paused torrent: hash-verify in place and stay paused (a + * check-only worker runs via the reconciler). */ + task->needs_check = true; + return; + } + /* Active torrent: stop->start so the fresh run re-hashes via the resume scan. */ + task->restart_requested = true; + if (!task->thread_done) { + task->stop_requested = true; + task->state = TORRENT_STOPPING; + } +} + +static void apply_set_labels(torrent_task *task, const json_t *params) { + /* category and/or tags; absent fields leave that part unchanged. */ + task_set_category(task, + json_string_value(json_object_get(params, "category"))); + json_t *tags = json_object_get(params, "tags"); + if (!json_is_array(tags)) tags = json_object_get(params, "labels"); + task_set_tags(task, tags); +} + +static json_t *rpc_pause_torrent(void *opaque, const json_t *params, + naut_err *error) { + return torrent_flag_op(opaque, params, apply_pause, error); +} +static json_t *rpc_set_labels(void *opaque, const json_t *params, + naut_err *error) { + return torrent_flag_op(opaque, params, apply_set_labels, error); +} + +/* "Set location": queue a one-time move of the torrent's files to a new base. + * The worker performs it on its next control pass (see apply_pending_save_path); + * a stopped torrent applies it when it next runs. */ +static void apply_set_save_path(torrent_task *task, const json_t *params) { + const char *path = json_string_value(json_object_get(params, "savePath")); + if (!path || !*path || strlen(path) >= PATH_MAX) return; + bool reset = json_boolean_value(json_object_get(params, "reset")); + /* Same base with nothing to reset is a no-op; with reset it still pulls any + * individually-moved files back to their original relpaths. */ + if (!reset && task->output_dir && strcmp(task->output_dir, path) == 0) return; + free(task->pending_save_path); + task->pending_save_path = strdup(path); + task->pending_save_path_reset = reset; +} +static json_t *rpc_set_save_path(void *opaque, const json_t *params, + naut_err *error) { + return torrent_flag_op(opaque, params, apply_set_save_path, error); +} +static json_t *rpc_resume_torrent(void *opaque, const json_t *params, + naut_err *error) { + return torrent_flag_op(opaque, params, apply_resume, error); +} +static json_t *rpc_recheck_torrent(void *opaque, const json_t *params, + naut_err *error) { + return torrent_flag_op(opaque, params, apply_recheck, error); +} + +/* Render a diagnostic state dump for one torrent. The rich engine + piece state + * lives on the swarm worker thread, so we bump the task's dump request and wait + * for the worker to render it (via torrent_should_dump/torrent_on_dump), then + * return the text. Re-resolves the task by id on every poll so a concurrently + * reaped torrent is detected rather than dereferenced. */ +static json_t *dump_result(const char *text) { + json_t *result = json_object(); + if (result) json_object_set_new(result, "dump", json_string(text)); + return result; +} + +static json_t *rpc_dump_torrent(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + uint64_t id; + if (!parse_torrent_id(params, &id)) { *error = NAUT_ERR_INVAL; return NULL; } + + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, id); + bool active = false; + uint64_t want = 0; + if (task) { + pthread_mutex_lock(&task->lock); + active = task->thread_started && !task->thread_done; + want = ++task->dump_seq; + pthread_mutex_unlock(&task->lock); + } + pthread_mutex_unlock(&state->torrent_lock); + if (!task) { *error = NAUT_ERR_NOTFOUND; return NULL; } + if (!active) { + *error = NAUT_OK; + return dump_result("torrent is not running; no live engine state to dump\n"); + } + + /* Wait for the worker thread to service the request (~3s budget). */ + char *text = NULL; + bool vanished = false; + for (int i = 0; i < 300 && !text && !vanished; i++) { + usleep(10000); /* 10 ms */ + pthread_mutex_lock(&state->torrent_lock); + torrent_task *t = find_torrent_locked(state, id); + if (!t) { + vanished = true; + } else { + pthread_mutex_lock(&t->lock); + if (t->dump_done_seq >= want && t->dump_text) + text = strdup(t->dump_text); + pthread_mutex_unlock(&t->lock); + } + pthread_mutex_unlock(&state->torrent_lock); + } + + if (!text) { + *error = NAUT_OK; + return dump_result(vanished + ? "torrent was removed before the dump completed\n" + : "dump timed out: worker did not respond\n"); + } + json_t *result = dump_result(text); + free(text); + *error = result ? NAUT_OK : NAUT_ERR_NOMEM; + return result; +} + +/* Reorder the download queue: op = top | bottom | up | down. */ +static json_t *rpc_queue_move(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + uint64_t id; + const char *op = json_is_object(params) + ? json_string_value(json_object_get(params, "op")) : NULL; + if (!parse_torrent_id(params, &id) || !op) { + *error = NAUT_ERR_INVAL; + return NULL; + } + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, id); + if (!task) { + pthread_mutex_unlock(&state->torrent_lock); + *error = NAUT_ERR_NOTFOUND; + return NULL; + } + int self = task->queue_pos, lo = self, hi = self; + torrent_task *prev = NULL, *next = NULL; /* nearest neighbors by position */ + for (size_t i = 0; i < state->torrent_count; i++) { + torrent_task *o = state->torrents[i]; + if (o == task) continue; + if (o->queue_pos < lo) lo = o->queue_pos; + if (o->queue_pos > hi) hi = o->queue_pos; + if (o->queue_pos < self && (!prev || o->queue_pos > prev->queue_pos)) + prev = o; + if (o->queue_pos > self && (!next || o->queue_pos < next->queue_pos)) + next = o; + } + if (strcmp(op, "top") == 0) { + task->queue_pos = lo - 1; + } else if (strcmp(op, "bottom") == 0) { + task->queue_pos = hi + 1; + } else if (strcmp(op, "up") == 0 && prev) { + int tmp = task->queue_pos; task->queue_pos = prev->queue_pos; + prev->queue_pos = tmp; + } else if (strcmp(op, "down") == 0 && next) { + int tmp = task->queue_pos; task->queue_pos = next->queue_pos; + next->queue_pos = tmp; + } + pthread_mutex_unlock(&state->torrent_lock); + persist_torrents(state); + service_lifecycle(state); /* reordering may change the active set */ + *error = NAUT_OK; + return torrent_json(task); +} + +static json_t *rpc_get_preferences(void *opaque, const json_t *params, + naut_err *error) { + (void)params; + daemon_state *state = opaque; + json_t *result = prefs_json(state); + *error = result ? NAUT_OK : NAUT_ERR_NOMEM; + return result; +} + +static json_t *rpc_set_preferences(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + if (!json_is_object(params)) { *error = NAUT_ERR_INVAL; return NULL; } + json_t *v; + if ((v = json_object_get(params, "max_active")) && json_is_integer(v) && + json_integer_value(v) > 0) + state->max_active = (uint32_t)json_integer_value(v); + if ((v = json_object_get(params, "dl_limit")) && json_is_integer(v) && + json_integer_value(v) >= 0) + state->dl_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(params, "alt_dl_limit")) && json_is_integer(v) && + json_integer_value(v) >= 0) + state->alt_dl_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(params, "up_limit")) && json_is_integer(v) && + json_integer_value(v) >= 0) + state->up_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(params, "alt_up_limit")) && json_is_integer(v) && + json_integer_value(v) >= 0) + state->alt_up_limit = (uint64_t)json_integer_value(v); + if ((v = json_object_get(params, "alt_speed_enabled"))) + state->alt_speed_enabled = json_boolean_value(v); + persist_prefs(state); + service_lifecycle(state); /* apply new budget / throttle shares now */ + json_t *result = prefs_json(state); + *error = result ? NAUT_OK : NAUT_ERR_NOMEM; + return result; +} + +static json_t *rpc_toggle_altspeed(void *opaque, const json_t *params, + naut_err *error) { + (void)params; + daemon_state *state = opaque; + state->alt_speed_enabled = !state->alt_speed_enabled; + persist_prefs(state); + service_lifecycle(state); /* switch the active throttle immediately */ + *error = NAUT_OK; + return json_pack("{s:b}", "alt_speed_enabled", state->alt_speed_enabled); +} + +static json_t *rpc_load_script(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + const char *path = json_is_object(params) + ? json_string_value(json_object_get(params, "path")) : NULL; + if (!path || !*path) { *error = NAUT_ERR_INVAL; return NULL; } + char *path_copy = strdup(path); + if (!path_copy) { *error = NAUT_ERR_NOMEM; return NULL; } + naut_script_host host = script_host(state); + naut_script *script = naut_script_create( + state->events, path, 256, &host, error); + if (!script) { + free(path_copy); + return NULL; + } + pthread_mutex_lock(&state->script_lock); + naut_script *old = state->script; + char *old_path = state->script_path; + state->script = script; + state->script_path = path_copy; + pthread_mutex_unlock(&state->script_lock); + *error = NAUT_OK; + naut_script_destroy(old); + free(old_path); + return script_status_json(state); +} + +static json_t *rpc_unload_script(void *opaque, const json_t *params, + naut_err *error) { + (void)params; + daemon_state *state = opaque; + pthread_mutex_lock(&state->script_lock); + naut_script *old = state->script; + char *old_path = state->script_path; + state->script = NULL; + state->script_path = NULL; + pthread_mutex_unlock(&state->script_lock); + naut_script_destroy(old); + free(old_path); + *error = NAUT_OK; + return script_status_json(state); +} + +static json_t *rpc_update_script(void *opaque, const json_t *params, + naut_err *error) { + daemon_state *state = opaque; + json_t *source_json = json_is_object(params) + ? json_object_get(params, "source") : NULL; + if (!json_is_string(source_json)) { + *error = NAUT_ERR_INVAL; + return NULL; + } + const char *source = json_string_value(source_json); + size_t source_len = json_string_length(source_json); + + pthread_mutex_lock(&state->script_lock); + char *path = state->script_path ? strdup(state->script_path) : NULL; + pthread_mutex_unlock(&state->script_lock); + if (!path) { + *error = NAUT_ERR_NOTFOUND; + return NULL; + } + + char tmp_path[PATH_MAX + 32]; + int n = snprintf(tmp_path, sizeof tmp_path, "%s.update-XXXXXX", path); + if (n < 0 || (size_t)n >= sizeof tmp_path) { + free(path); + *error = NAUT_ERR_RANGE; + return NULL; + } + + int fd = mkstemp(tmp_path); + if (fd < 0) { + free(path); + *error = NAUT_ERR_IO; + return NULL; + } + bool ok = write_all_fd(fd, source, source_len); + if (close(fd) != 0) ok = false; + if (!ok) { + unlink(tmp_path); + free(path); + *error = NAUT_ERR_IO; + return NULL; + } + + naut_script_host host = script_host(state); + naut_script *script = naut_script_create( + state->events, tmp_path, 256, &host, error); + if (!script) { + unlink(tmp_path); + free(path); + return NULL; + } + + if (rename(tmp_path, path) != 0) { + naut_script_destroy(script); + unlink(tmp_path); + free(path); + *error = NAUT_ERR_IO; + return NULL; + } + + pthread_mutex_lock(&state->script_lock); + naut_script *old = state->script; + state->script = script; + pthread_mutex_unlock(&state->script_lock); + naut_script_destroy(old); + free(path); + *error = NAUT_OK; + return script_status_json(state); } static naut_err queue_move(void *opaque, uint64_t torrent_id, uint32_t file_index, const char *destination) { daemon_state *state = opaque; - pthread_mutex_lock(&state->move_lock); - if (state->move_count == MOVE_QUEUE_CAPACITY) { - pthread_mutex_unlock(&state->move_lock); + if (!destination || strlen(destination) >= PATH_MAX) + return NAUT_ERR_RANGE; + pthread_mutex_lock(&state->torrent_lock); + torrent_task *task = find_torrent_locked(state, torrent_id); + if (!task) { + pthread_mutex_unlock(&state->torrent_lock); + return NAUT_ERR_NOTFOUND; + } + pthread_mutex_lock(&task->lock); + pthread_mutex_unlock(&state->torrent_lock); + if (task->remove_requested) { + pthread_mutex_unlock(&task->lock); + return NAUT_ERR_NOTFOUND; + } + if (task->move_count == MOVE_QUEUE_CAPACITY) { + pthread_mutex_unlock(&task->lock); return NAUT_ERR_FULL; } - size_t tail = (state->move_head + state->move_count) % - MOVE_QUEUE_CAPACITY; - state->moves[tail] = (move_command) { - .torrent_id = torrent_id, - .file_index = file_index, - }; - snprintf(state->moves[tail].destination, - sizeof state->moves[tail].destination, "%s", destination); - state->move_count++; - pthread_mutex_unlock(&state->move_lock); + size_t tail = (task->move_head + task->move_count) % MOVE_QUEUE_CAPACITY; + task->moves[tail].file_index = file_index; + snprintf(task->moves[tail].destination, + sizeof task->moves[tail].destination, "%s", destination); + task->move_count++; + pthread_mutex_unlock(&task->lock); return NAUT_OK; } -static void drain_moves(daemon_state *state) { - /* Copy each pending command out under the lock, then perform the relocate - * with the lock released (so the script thread can keep enqueuing). All - * relocates run on this, the owner thread, as the session requires. */ - for (;;) { - move_command command; - pthread_mutex_lock(&state->move_lock); - if (state->move_count == 0) { - pthread_mutex_unlock(&state->move_lock); - return; - } - command = state->moves[state->move_head]; - state->move_head = (state->move_head + 1) % MOVE_QUEUE_CAPACITY; - state->move_count--; - state->moves_processed++; - pthread_mutex_unlock(&state->move_lock); - - naut_err err = naut_session_move_file(state->session, - command.torrent_id, - command.file_index, - command.destination); - if (err == NAUT_OK) - NAUT_INFO("moved torrent=%llu file=%u -> %s", - (unsigned long long)command.torrent_id, - command.file_index, command.destination); - else - NAUT_WARN("move torrent=%llu file=%u -> %s failed: %s", - (unsigned long long)command.torrent_id, - command.file_index, command.destination, - naut_strerror(err)); - } -} - static void broadcast_event(void *opaque, const naut_event *event) { daemon_state *state = opaque; json_t *payload = naut_rpc_event_json(event); @@ -406,18 +2220,380 @@ static bool register_commands(daemon_state *state) { naut_rpc_register(state->rpc, "plugins", rpc_plugins, state) == NAUT_OK && naut_rpc_register(state->rpc, "emit", rpc_emit, state) == NAUT_OK && naut_rpc_register(state->rpc, "add_torrent", rpc_add_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "torrents", rpc_torrents, state) == NAUT_OK && + naut_rpc_register(state->rpc, "torrent", rpc_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "remove_torrent", rpc_remove_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "pause_torrent", rpc_pause_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "resume_torrent", rpc_resume_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "recheck_torrent", rpc_recheck_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "set_labels", rpc_set_labels, state) == NAUT_OK && + naut_rpc_register(state->rpc, "set_save_path", rpc_set_save_path, state) == NAUT_OK && + naut_rpc_register(state->rpc, "dump_torrent", rpc_dump_torrent, state) == NAUT_OK && + naut_rpc_register(state->rpc, "queue_move", rpc_queue_move, state) == NAUT_OK && + naut_rpc_register(state->rpc, "get_preferences", rpc_get_preferences, state) == NAUT_OK && + naut_rpc_register(state->rpc, "set_preferences", rpc_set_preferences, state) == NAUT_OK && + naut_rpc_register(state->rpc, "toggle_altspeed", rpc_toggle_altspeed, state) == NAUT_OK && + naut_rpc_register(state->rpc, "load_script", rpc_load_script, state) == NAUT_OK && + naut_rpc_register(state->rpc, "script_status", rpc_script_status, state) == NAUT_OK && + naut_rpc_register(state->rpc, "update_script", rpc_update_script, state) == NAUT_OK && + naut_rpc_register(state->rpc, "set_script_settings", rpc_set_script_settings, state) == NAUT_OK && + naut_rpc_register(state->rpc, "unload_script", rpc_unload_script, state) == NAUT_OK && naut_rpc_register(state->rpc, "shutdown", rpc_shutdown, state) == NAUT_OK; } +/* --- queue / lifecycle reconciler (main thread only) -------------------- */ + +/* (Re)start a torrent's worker. The task must have no live worker + * (thread_done). Joins any prior thread first. Main thread only. */ +/* Start the worker. `check` => a one-shot hash-check pass that keeps the torrent + * paused (no download); otherwise a normal download run (clears paused). */ +static bool start_worker_ex(torrent_task *task, bool check) { + if (task->thread_started) { + pthread_join(task->thread, NULL); + task->thread_started = false; + } + pthread_mutex_lock(&task->lock); + task->stop_requested = false; + task->restart_requested = false; + task->thread_done = false; + task->checking = check; + if (!check) task->paused = false; + task->result = NAUT_ERR_AGAIN; + task->state = check ? TORRENT_CHECKING : TORRENT_RUNNING; + pthread_mutex_unlock(&task->lock); + if (pthread_create(&task->thread, NULL, torrent_worker, task) != 0) { + pthread_mutex_lock(&task->lock); + task->state = TORRENT_ERROR; + task->thread_done = true; + task->checking = false; + pthread_mutex_unlock(&task->lock); + return false; + } + task->thread_started = true; + return true; +} + +static bool start_worker(torrent_task *task) { + return start_worker_ex(task, false); +} + +/* Ask a running worker to stop; it exits asynchronously (revisited next tick). */ +static void request_stop(torrent_task *task) { + pthread_mutex_lock(&task->lock); + if (!task->thread_done) { + task->stop_requested = true; + task->state = TORRENT_STOPPING; + } + pthread_mutex_unlock(&task->lock); +} + +typedef enum { ACT_NONE, ACT_START, ACT_STOP, ACT_SETSTATE, ACT_CHECK } lifecycle_act; + +/* Reconcile desired vs actual run-state for every torrent: enforce the + * max-active download queue, honor pause/force-start, run recheck restarts, and + * recompute each active torrent's share of the global download limit. Drives + * worker start/stop to match. Main thread only (beside reap_torrents). */ +static void service_lifecycle(daemon_state *state) { + torrent_task *tasks[MAX_TORRENTS]; + bool want_run[MAX_TORRENTS], complete[MAX_TORRENTS], forced[MAX_TORRENTS]; + bool eligible[MAX_TORRENTS]; + int qpos[MAX_TORRENTS]; + lifecycle_act act[MAX_TORRENTS]; + int target[MAX_TORRENTS]; + + pthread_mutex_lock(&state->torrent_lock); + size_t n = state->torrent_count; + for (size_t i = 0; i < n; i++) { + torrent_task *t = state->torrents[i]; + tasks[i] = t; + pthread_mutex_lock(&t->lock); + bool removed = t->remove_requested; + complete[i] = (t->state == TORRENT_COMPLETE); + forced[i] = t->force_start; + qpos[i] = t->queue_pos; + bool paused = t->paused; + pthread_mutex_unlock(&t->lock); + eligible[i] = !removed && !paused && !complete[i]; + /* Completed torrents keep their keep-alive worker but never occupy an + * active download slot. */ + want_run[i] = (!removed && complete[i]); + act[i] = ACT_NONE; + target[i] = 0; + if (removed) eligible[i] = false; /* reap owns removed tasks */ + } + + /* Choose the active set: forced torrents always run; otherwise the + * lowest-queue_pos eligible torrents up to max_active. */ + size_t order[MAX_TORRENTS], ec = 0; + for (size_t i = 0; i < n; i++) if (eligible[i]) order[ec++] = i; + for (size_t a = 1; a < ec; a++) { /* insertion sort by queue_pos */ + size_t v = order[a]; + size_t b = a; + while (b > 0 && qpos[order[b - 1]] > qpos[v]) { + order[b] = order[b - 1]; + b--; + } + order[b] = v; + } + uint32_t budget = state->max_active ? state->max_active : DEFAULT_MAX_ACTIVE; + uint32_t chosen = 0; + for (size_t k = 0; k < ec; k++) { + size_t i = order[k]; + if (forced[i]) { want_run[i] = true; } + else if (chosen < budget) { want_run[i] = true; chosen++; } + } + + /* Split the global download limit across torrents that will actually run. */ + size_t active_dl = 0; + for (size_t i = 0; i < n; i++) if (want_run[i] && !complete[i]) active_dl++; + uint64_t eff = state->alt_speed_enabled ? state->alt_dl_limit + : state->dl_limit; + uint64_t share = eff == 0 ? 0 : eff / (active_dl ? active_dl : 1); + + for (size_t i = 0; i < n; i++) { + torrent_task *t = tasks[i]; + pthread_mutex_lock(&t->lock); + t->rate_share = (want_run[i] && !complete[i]) ? share : 0; + bool removed = t->remove_requested; + bool started = t->thread_started, done = t->thread_done; + bool stopping = t->stop_requested, restart = t->restart_requested; + bool paused = t->paused, checking = t->checking; + bool needs_check = t->needs_check; + torrent_state st = t->state; + pthread_mutex_unlock(&t->lock); + if (removed) continue; + bool running = started && !done; + if (checking && running) { + act[i] = ACT_NONE; /* let the one-shot hash check finish */ + } else if (restart) { + if (running && !stopping) act[i] = ACT_STOP; + else if (done) act[i] = ACT_START; + } else if (want_run[i]) { + if (!running && done) act[i] = ACT_START; + } else { + if (running && !stopping) { + act[i] = ACT_STOP; + } else if (done && paused && needs_check) { + act[i] = ACT_CHECK; /* verify a paused torrent's data */ + } else if (done) { + int want = paused ? TORRENT_PAUSED : TORRENT_QUEUED; + if ((int)st != want) { act[i] = ACT_SETSTATE; target[i] = want; } + } + } + } + pthread_mutex_unlock(&state->torrent_lock); + + /* Apply outside torrent_lock (start_worker joins/creates threads). */ + for (size_t i = 0; i < n; i++) { + switch (act[i]) { + case ACT_START: start_worker(tasks[i]); break; + case ACT_CHECK: start_worker_ex(tasks[i], true); break; + case ACT_STOP: request_stop(tasks[i]); break; + case ACT_SETSTATE: + pthread_mutex_lock(&tasks[i]->lock); + tasks[i]->state = (torrent_state)target[i]; + pthread_mutex_unlock(&tasks[i]->lock); + break; + case ACT_NONE: break; + } + } +} + +static void stop_torrents(daemon_state *state) { + pthread_mutex_lock(&state->torrent_lock); + for (size_t i = 0; i < state->torrent_count; i++) { + torrent_task *task = state->torrents[i]; + pthread_mutex_lock(&task->lock); + task->stop_requested = true; + if (task->state == TORRENT_RUNNING) + task->state = TORRENT_STOPPING; + pthread_mutex_unlock(&task->lock); + } + pthread_mutex_unlock(&state->torrent_lock); +} + +static void destroy_torrent(torrent_task *task) { + if (task->thread_started) pthread_join(task->thread, NULL); + /* Ephemeral /tmp uploads always go. Durable managed uploads are kept across + * a normal shutdown (for restore) and removed only when the torrent was + * explicitly removed. */ + if (task->source && + (task->source_is_temp || + (task->source_managed && task->remove_requested))) + unlink(task->source); + for (size_t p = 0; p < task->num_peers; p++) free(task->peers[p]); + free(task->peers); + for (size_t i = 0; i < task->num_locations; i++) free(task->locations[i].path); + free(task->locations); + for (size_t i = 0; i < task->num_tags; i++) free(task->tags[i]); + free(task->tags); + for (size_t i = 0; i < task->num_files; i++) free(task->files[i]); + free(task->files); + free(task->category); + free(task->pending_save_path); + free(task->name); + free(task->source); + free(task->output_dir); + free(task->dump_text); + pthread_mutex_destroy(&task->lock); + free(task); +} + +static void reap_torrents(daemon_state *state) { + bool reaped = false; + for (;;) { + torrent_task *task = NULL; + pthread_mutex_lock(&state->torrent_lock); + for (size_t i = 0; i < state->torrent_count; i++) { + torrent_task *candidate = state->torrents[i]; + pthread_mutex_lock(&candidate->lock); + bool reap = candidate->remove_requested && + candidate->thread_done; + pthread_mutex_unlock(&candidate->lock); + if (!reap) continue; + task = candidate; + state->torrents[i] = + state->torrents[--state->torrent_count]; + break; + } + pthread_mutex_unlock(&state->torrent_lock); + if (!task) break; + destroy_torrent(task); + reaped = true; + } + if (reaped) persist_torrents(state); +} + +static void destroy_torrents(daemon_state *state) { + for (size_t i = 0; i < state->torrent_count; i++) + destroy_torrent(state->torrents[i]); + state->torrent_count = 0; +} + +/* Recursively create a directory path (like `mkdir -p`). */ +static int mkdir_p(const char *path, mode_t mode) { + char tmp[PATH_MAX]; + size_t len = snprintf(tmp, sizeof tmp, "%s", path); + if (len == 0 || len >= sizeof tmp) return -1; + if (tmp[len - 1] == '/') tmp[len - 1] = 0; + for (char *p = tmp + 1; *p; p++) { + if (*p != '/') continue; + *p = 0; + if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1; + *p = '/'; + } + return (mkdir(tmp, mode) != 0 && errno != EEXIST) ? -1 : 0; +} + +/* Resolve and prepare the persistence directory; fills state->state_file and + * state->uploads_dir. Returns true if persistence can be used. */ +static bool resolve_state_dir(daemon_state *state, const char *override) { + char dir[PATH_MAX]; + if (override && *override) { + if ((size_t)snprintf(dir, sizeof dir, "%s", override) >= sizeof dir) + return false; + } else { + const char *xdg = getenv("XDG_DATA_HOME"); + const char *home = getenv("HOME"); + int n; + if (xdg && *xdg) + n = snprintf(dir, sizeof dir, "%s/naut", xdg); + else if (home && *home) + n = snprintf(dir, sizeof dir, "%s/.local/share/naut", home); + else + return false; + if (n < 0 || (size_t)n >= sizeof dir) return false; + } + if (mkdir_p(dir, 0700) != 0) { + NAUT_WARN("state dir %s: %s", dir, strerror(errno)); + return false; + } + if ((size_t)snprintf(state->uploads_dir, sizeof state->uploads_dir, + "%s/uploads", dir) >= sizeof state->uploads_dir) + return false; + if (mkdir(state->uploads_dir, 0700) != 0 && errno != EEXIST) { + NAUT_WARN("uploads dir %s: %s", state->uploads_dir, strerror(errno)); + return false; + } + if ((size_t)snprintf(state->state_file, sizeof state->state_file, + "%s/torrents.json", dir) >= sizeof state->state_file) + return false; + if ((size_t)snprintf(state->prefs_file, sizeof state->prefs_file, + "%s/prefs.json", dir) >= sizeof state->prefs_file) + return false; + if ((size_t)snprintf(state->settings_file, sizeof state->settings_file, + "%s/script_settings.json", dir) >= + sizeof state->settings_file) + return false; + return true; +} + +/* Re-create torrents recorded in state_file (called once at startup). */ +static void restore_torrents(daemon_state *state) { + if (!state->persist_enabled) return; + json_error_t jerr; + json_t *array = json_load_file(state->state_file, 0, &jerr); + if (!array) return; /* no prior state, or unreadable */ + if (!json_is_array(array)) { + NAUT_WARN("state file %s is not a torrent array; ignoring", + state->state_file); + json_decref(array); + return; + } + size_t restored = 0, dropped = 0, index; + json_t *rec; + json_array_foreach(array, index, rec) { + const char *source = json_string_value(json_object_get(rec, "source")); + const char *output = json_string_value(json_object_get(rec, "output")); + if (!source || !output) { dropped++; continue; } + bool managed = + json_boolean_value(json_object_get(rec, "source_managed")); + /* A path/upload source that no longer exists can't be restored; magnets + * carry no file to check. */ + if (strncmp(source, "magnet:", 7) != 0 && access(source, R_OK) != 0) { + NAUT_WARN("restore: source missing, dropping: %s", source); + dropped++; + continue; + } + bool paused = json_boolean_value(json_object_get(rec, "paused")); + bool force_start = + json_boolean_value(json_object_get(rec, "force_start")); + json_t *qp = json_object_get(rec, "queue_pos"); + int queue_pos = json_is_integer(qp) ? (int)json_integer_value(qp) : -1; + naut_err error = NAUT_OK; + if (spawn_torrent(state, source, managed, false, output, + json_object_get(rec, "peers"), + json_object_get(rec, "locations"), + /*meta_json=*/rec, + json_object_get(rec, "torrent_id"), + json_string_value(json_object_get(rec, "name")), + paused, force_start, queue_pos, + /*check_overlap=*/false, &error)) + restored++; + else { + NAUT_WARN("restore: %s failed: %s", source, naut_strerror(error)); + dropped++; + } + } + json_decref(array); + if (restored || dropped) + NAUT_INFO("restore: %zu torrents restored, %zu dropped", + restored, dropped); + if (dropped) persist_torrents(state); /* prune dropped records */ +} + static void usage(const char *program) { fprintf(stderr, - "usage: %s [--socket PATH] [--plugin PATH]... [--script PATH]\n", + "usage: %s [--socket PATH] [--plugin PATH]... [--script PATH] " + "[--state-dir PATH] [--max-active N]\n", program); } int main(int argc, char **argv) { const char *socket_path = DEFAULT_SOCKET; const char *script_path = NULL; + const char *state_dir = NULL; + long max_active_arg = 0; /* 0 => use default/persisted */ const char *plugin_paths[64]; size_t plugin_count = 0; for (int i = 1; i < argc; i++) { @@ -428,6 +2604,10 @@ int main(int argc, char **argv) { plugin_paths[plugin_count++] = argv[++i]; else if (strcmp(argv[i], "--script") == 0 && i + 1 < argc) script_path = argv[++i]; + else if (strcmp(argv[i], "--state-dir") == 0 && i + 1 < argc) + state_dir = argv[++i]; + else if (strcmp(argv[i], "--max-active") == 0 && i + 1 < argc) + max_active_arg = strtol(argv[++i], NULL, 10); else { usage(argv[0]); return 2; @@ -438,17 +2618,39 @@ int main(int argc, char **argv) { signal(SIGTERM, on_signal); signal(SIGPIPE, SIG_IGN); daemon_state state = {0}; - pthread_mutex_init(&state.move_lock, NULL); + state.next_torrent_id = 1; + state.max_active = DEFAULT_MAX_ACTIVE; + state.persist_enabled = resolve_state_dir(&state, state_dir); + if (!state.persist_enabled) + NAUT_WARN("persistence disabled: torrents will not survive a restart"); + load_prefs(&state); /* override defaults with any saved prefs */ + if (max_active_arg > 0) state.max_active = (uint32_t)max_active_arg; + pthread_mutex_init(&state.torrent_lock, NULL); pthread_mutex_init(&state.subscriber_lock, NULL); + pthread_mutex_init(&state.script_lock, NULL); + pthread_mutex_init(&state.settings_lock, NULL); + load_script_settings(&state); /* user-set values; schema comes from the script */ state.events = naut_event_bus_create(); state.rpc = naut_rpc_registry_create(); state.plugins = naut_plugin_manager_create(state.rpc, state.events); - state.session = naut_session_create(); - if (!state.events || !state.rpc || !state.plugins || !state.session || + if (!state.events || !state.rpc || !state.plugins || !register_commands(&state)) { fprintf(stderr, "nautd: failed to initialize control plane\n"); return 1; } + if (script_path) { + naut_err error = NAUT_OK; + json_t *params = json_pack("{s:s}", "path", script_path); + json_t *loaded = params ? rpc_load_script(&state, params, &error) + : (error = NAUT_ERR_NOMEM, NULL); + json_decref(params); + json_decref(loaded); + if (error != NAUT_OK) { + fprintf(stderr, "nautd: failed to load script %s: %s\n", + script_path, naut_strerror(error)); + return 1; + } + } for (size_t i = 0; i < plugin_count; i++) { if (naut_plugin_load(state.plugins, plugin_paths[i]) != NAUT_OK) { fprintf(stderr, "nautd: failed to load plugin %s\n", @@ -456,16 +2658,6 @@ int main(int argc, char **argv) { return 1; } } - if (script_path) { - naut_err error; - state.script = naut_script_create(state.events, script_path, 256, - queue_move, &state, &error); - if (!state.script) { - fprintf(stderr, "nautd: failed to load script %s: %s\n", - script_path, naut_strerror(error)); - return 1; - } - } uint64_t event_subscription; if (naut_event_subscribe(state.events, broadcast_event, &state, &event_subscription) != NAUT_OK) @@ -475,6 +2667,7 @@ int main(int argc, char **argv) { perror("nautd: listen"); return 1; } + restore_torrents(&state); /* re-load torrents saved by a previous run */ NAUT_INFO("nautd listening on %s", socket_path); while (!state.stopping && !interrupted) { struct pollfd pollfd = {.fd = listener, .events = POLLIN}; @@ -485,23 +2678,39 @@ int main(int argc, char **argv) { } else if (ready < 0 && errno != EINTR) { break; } - drain_moves(&state); + service_lifecycle(&state); /* enforce queue, pause/resume, throttle */ + reap_torrents(&state); } close(listener); unlink(socket_path); + /* Tear down plugins first: a plugin like webui runs its own threads that + * call back into the daemon via RPC, so it must be stopped (and its + * threads joined) before we free the torrent tasks those calls touch. */ + naut_plugin_manager_destroy(state.plugins); + state.plugins = NULL; + pthread_mutex_lock(&state.script_lock); + naut_script *script = state.script; + char *loaded_script_path = state.script_path; + state.script = NULL; + state.script_path = NULL; + pthread_mutex_unlock(&state.script_lock); + naut_script_destroy(script); + free(loaded_script_path); + stop_torrents(&state); + destroy_torrents(&state); naut_event_unsubscribe(state.events, event_subscription); pthread_mutex_lock(&state.subscriber_lock); for (size_t i = 0; i < state.subscriber_count; i++) close(state.subscribers[i]); pthread_mutex_unlock(&state.subscriber_lock); - naut_script_destroy(state.script); /* joins the script thread */ - drain_moves(&state); /* flush any moves it left queued */ - naut_plugin_manager_destroy(state.plugins); naut_rpc_registry_destroy(state.rpc); - naut_session_destroy(state.session); naut_event_bus_destroy(state.events); pthread_mutex_destroy(&state.subscriber_lock); - pthread_mutex_destroy(&state.move_lock); + pthread_mutex_destroy(&state.torrent_lock); + pthread_mutex_destroy(&state.script_lock); + json_decref(state.script_settings); + json_decref(state.script_settings_schema); + pthread_mutex_destroy(&state.settings_lock); return 0; } diff --git a/apps/swarm/main.c b/apps/swarm/main.c index 63d7f5c..da0eea6 100644 --- a/apps/swarm/main.c +++ b/apps/swarm/main.c @@ -1,9 +1,13 @@ -/* naut_swarm — Phase 4 gate: download from a SWARM of peers concurrently. +/* naut_swarm — multi-peer download driver built on the torrent-peer engine. * - * A poll()-based multi-socket driver around the same sans-IO peer codec and the - * multi-peer engine (rarest-first + bounded endgame duplication). Peers can be - * supplied explicitly for deterministic testing, or discovered from the - * torrent's HTTP/UDP trackers. + * The engine (../torrent-peer, engine.h) owns all peer sockets, the wire + * protocol, the request pipeline, transports (TCP/µTP/MSE), and piece selection. + * This driver: + * - parses the .torrent / magnet and (for magnet) fetches the info dict, + * - discovers peers via HTTP/UDP trackers + DHT (src/discovery), + * - feeds discovered endpoints + a piece-priority vector to the engine, + * - drains delivered blocks, verifies+persists them through naut_download, + * - re-arms hash-failed pieces and reports progress for nautd / the web UI. * * usage: naut_swarm [ ...] */ @@ -12,55 +16,314 @@ #include "naut/metainfo.h" #include "naut/storage.h" #include "naut/piece.h" -#include "naut/peer.h" #include "naut/tracker.h" -#include "naut/bitfield.h" #include "naut/log.h" -#include "naut/pipeline.h" #include "naut/system.h" -#include "naut/worker.h" +#include "naut/swarm.h" + +#include "engine.h" /* torrent-peer multi-peer engine */ #include #include -#include +#include +#include +#include #include #include #include #include #include -#include -#include -#include -#define REQUEST_TIMEOUT 15.0 -#define EXT_RESERVED 0x0000000000100000ULL - -typedef struct { - uint32_t piece, begin, length; - double sent_at; -} req_t; +#define DEFAULT_TARGET_PEERS 80 +#define MAX_TARGET_PEERS 512 +#define TRACKER_DEFAULT_INTERVAL 1800.0 +#define TRACKER_MIN_INTERVAL 60.0 +#define TRACKER_FAILURE_RETRY_INTERVAL 300.0 +#define DHT_REFRESH_INTERVAL 300.0 +#define READY_BATCH 64 typedef struct { naut_peer_addr addr; char name[32]; } endpoint_t; -typedef struct { - int fd; - char name[40]; - uint8_t *rbuf; size_t rcap, rlen; - bool hs_done, peer_choking; - naut_bitfield have; - req_t *inflight; size_t nflight, cflight; - uint64_t blocks_received; - naut_ext_handshake extensions; - uint64_t pex_received; - naut_pipeline pipeline; - bool dead, availability_removed; -} peer_t; - static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec*1e-9; } +static uint32_t target_peer_count(void) { + const char *env = getenv("NAUT_TARGET_PEERS"); + if (!env || !*env) return DEFAULT_TARGET_PEERS; + char *end = NULL; + unsigned long value = strtoul(env, &end, 10); + if (!end || *end || value == 0) return DEFAULT_TARGET_PEERS; + return (uint32_t)NAUT_MIN(value, MAX_TARGET_PEERS); +} + +/* Below this many connected peers the swarm is "starved": re-announce as often + * as the tracker's min interval allows instead of waiting the full interval. */ +#define LOW_PEER_THRESHOLD 10 + +static double tracker_delay_seconds(int32_t interval) { + if (interval <= 0) return TRACKER_DEFAULT_INTERVAL; + if (interval < (int32_t)TRACKER_MIN_INTERVAL) + return TRACKER_MIN_INTERVAL; + return (double)interval; +} + +/* Seconds to wait before the next tracker announce. Normally the tracker's full + * advertised interval, but when the swarm is starved (< LOW_PEER_THRESHOLD + * connected peers) we re-announce sooner — down to the tracker's min_interval, + * never below our 60s floor — so a thin swarm can actually recover. */ +static double next_announce_delay(int32_t interval, int32_t min_interval, + uint32_t peers_connected) { + double full = interval > 0 ? tracker_delay_seconds(interval) + : TRACKER_FAILURE_RETRY_INTERVAL; + if (peers_connected >= LOW_PEER_THRESHOLD) return full; + double floor_s = min_interval > 0 ? (double)min_interval : TRACKER_MIN_INTERVAL; + if (floor_s < TRACKER_MIN_INTERVAL) floor_s = TRACKER_MIN_INTERVAL; + return floor_s < full ? floor_s : full; +} + +static void random_bytes(uint8_t *output, size_t length) { + int fd = open("/dev/urandom", O_RDONLY); + size_t offset = 0; + while (fd >= 0 && offset < length) { + ssize_t count = read(fd, output + offset, length - offset); + if (count > 0) { + offset += (size_t)count; + } else if (count < 0 && errno == EINTR) { + continue; + } else { + break; + } + } + if (fd >= 0) close(fd); + uint64_t fallback = (uint64_t)(now() * 1e9) ^ + (uint64_t)(uintptr_t)output ^ + (uint64_t)getpid(); + while (offset < length) { + fallback ^= fallback << 13; + fallback ^= fallback >> 7; + fallback ^= fallback << 17; + output[offset++] = (uint8_t)fallback; + } +} + +static void emit_event(const naut_swarm_config *config, naut_event_type type, + uint32_t index, const char *message, const char *path) { + if (!config->events) return; + naut_event event = { + .type = type, + .torrent_id = config->torrent_id, + .index = index, + .message = message, + .path = path, + }; + naut_event_emit(config->events, &event); +} + +static void on_file_complete(void *opaque, uint32_t index, const char *path) { + const naut_swarm_config *config = opaque; + char full_path[PATH_MAX]; + const char *event_path = path; + if (path && path[0] != '/') { + int length = snprintf(full_path, sizeof full_path, "%s/%s", + config->output_dir, path); + if (length >= 0 && (size_t)length < sizeof full_path) + event_path = full_path; + } + emit_event(config, NAUT_EVENT_FILE_COMPLETE, index, NULL, event_path); +} + +static void on_piece_complete(void *opaque, uint32_t index) { + const naut_swarm_config *config = opaque; + emit_event(config, NAUT_EVENT_PIECE_COMPLETE, index, NULL, NULL); +} + +/* --- tracker stats ------------------------------------------------------- */ + +static uint32_t init_tracker_stats(char *const *trackers, + const uint32_t *tracker_tiers, + size_t num_trackers, + naut_swarm_tracker_stats *stats, + uint32_t capacity) { + uint32_t count = 0; + if (!stats) return 0; + for (size_t i = 0; i < num_trackers && count < capacity; i++) { + naut_swarm_tracker_stats *out = &stats[count++]; + memset(out, 0, sizeof(*out)); + snprintf(out->url, sizeof out->url, "%s", trackers[i]); + out->tier = tracker_tiers ? (int32_t)tracker_tiers[i] : (int32_t)i; + snprintf(out->status, sizeof out->status, "not contacted"); + out->seeds = -1; + out->peers = -1; + out->leeches = -1; + out->downloaded = -1; + } + return count; +} + +static naut_swarm_tracker_stats *tracker_stat_for( + naut_swarm_tracker_stats *stats, uint32_t count, const char *url) { + if (!stats || !url) return NULL; + for (uint32_t i = 0; i < count; i++) + if (strcmp(stats[i].url, url) == 0) return &stats[i]; + return NULL; +} + +static void tracker_set_status(naut_swarm_tracker_stats *tracker, + const char *status, const char *message) { + if (!tracker) return; + snprintf(tracker->status, sizeof tracker->status, "%s", + status ? status : ""); + snprintf(tracker->message, sizeof tracker->message, "%s", + message ? message : ""); +} + +static void snapshot_tracker_stats(naut_swarm_stats *stats, + const naut_swarm_tracker_stats *trackers, + uint32_t tracker_count) { + if (!stats || !trackers) return; + if (tracker_count > NAUT_SWARM_MAX_TRACKER_STATS) + tracker_count = NAUT_SWARM_MAX_TRACKER_STATS; + stats->tracker_count = tracker_count; + for (uint32_t i = 0; i < tracker_count; i++) + stats->tracker_stats[i] = trackers[i]; +} + +static void snapshot_file_stats(naut_swarm_stats *stats, + const naut_download *download, + const naut_metainfo *metainfo) { + if (!stats || !metainfo || !metainfo->files) return; + uint64_t offset = 0; + uint32_t count = 0; + for (size_t i = 0; + i < metainfo->num_files && count < NAUT_SWARM_MAX_FILE_STATS; + i++) { + const naut_file *file = &metainfo->files[i]; + uint64_t size = file->length > 0 ? (uint64_t)file->length : 0; + uint64_t done = 0; + if (download && size > 0) { + uint64_t start = offset; + uint64_t end = offset + size; + uint32_t first = (uint32_t)(start / (uint64_t)metainfo->piece_length); + uint32_t last = (uint32_t)((end - 1) / + (uint64_t)metainfo->piece_length); + for (uint32_t p = first; p <= last; p++) { + if (!naut_download_have(download, p)) continue; + uint64_t piece_start = (uint64_t)p * + (uint64_t)metainfo->piece_length; + uint64_t piece_end = piece_start + + (uint64_t)metainfo->piece_length; + if (piece_end > (uint64_t)metainfo->total_length) + piece_end = (uint64_t)metainfo->total_length; + uint64_t lo = piece_start > start ? piece_start : start; + uint64_t hi = piece_end < end ? piece_end : end; + if (hi > lo) done += hi - lo; + } + } + naut_swarm_file_stats *out = &stats->file_stats[count++]; + memset(out, 0, sizeof(*out)); + snprintf(out->path, sizeof out->path, "%s", + file->path ? file->path : ""); + out->size = size; + out->progress = size ? (double)done / (double)size : 1.0; + if (out->progress > 1.0) out->progress = 1.0; + out->priority = 1; + out->availability = 1.0; + offset += size; + } + stats->file_count = count; +} + +/* Fill and deliver a progress snapshot from engine status + naut_download. + * Per-peer detail collapses to the engine's aggregate counts for now; rich + * per-peer rows are a later web-UI feature. */ +static void report_progress(const naut_swarm_config *config, + engine *eng, uint32_t torrent_id, + const naut_download *download, + const naut_metainfo *metainfo, + const naut_swarm_tracker_stats *trackers, + uint32_t tracker_count, + double started_at) { + if (!config->on_progress) return; + torrent_status ts; + memset(&ts, 0, sizeof ts); + if (eng) engine_torrent_status(eng, torrent_id, &ts); + uint32_t connecting = ts.peers > ts.peers_connected + ts.peers_failed + ? ts.peers - ts.peers_connected - ts.peers_failed : 0; + naut_swarm_stats stats = { + .total_bytes = (uint64_t)metainfo->total_length, + .bytes_done = download ? naut_download_bytes_done(download) : 0, + .total_pieces = metainfo->num_pieces, + .pieces_done = download ? naut_download_pieces_done(download) : 0, + .peers_total = ts.peers, + .peers_connecting = connecting, + .peers_active = ts.peers_connected, + .peers_failed = ts.peers_failed, + .stalled = ts.peers_connected == 0, + .elapsed_seconds = now() - started_at, + }; + snapshot_tracker_stats(&stats, trackers, tracker_count); + snapshot_file_stats(&stats, download, metainfo); + if (download) + stats.piece_state_count = (uint32_t)naut_download_piece_states( + download, stats.piece_states, NAUT_SWARM_MAX_PIECE_STATS); + if (eng) { + engine_peer_info peers[NAUT_SWARM_MAX_PEER_STATS]; + uint32_t pc = engine_peer_list(eng, torrent_id, peers, + NAUT_SWARM_MAX_PEER_STATS); + stats.peer_count = pc; + for (uint32_t i = 0; i < pc; i++) { + naut_swarm_peer_stats *o = &stats.peer_stats[i]; + memset(o, 0, sizeof *o); + snprintf(o->ip, sizeof o->ip, "%.45s", peers[i].ip); + o->port = peers[i].port; + snprintf(o->connection, sizeof o->connection, "BT"); + snprintf(o->flags, sizeof o->flags, "%s", + peers[i].state == PEER_STATE_RUNNING + ? (peers[i].unchoked ? "D" : "d") : "K"); + o->progress = peers[i].num_pieces + ? (double)peers[i].have_pieces / (double)peers[i].num_pieces + : 0.0; + o->relevance = o->progress; + o->dlspeed = peers[i].rate_bps; + o->downloaded = peers[i].bytes_received; + } + } + config->on_progress(config->context, &stats); +} + +static bool stop_requested(const naut_swarm_config *config) { + return config->should_stop && config->should_stop(config->context); +} + +static void service_control(const naut_swarm_config *config, + naut_storage *storage) { + if (config->on_control) config->on_control(config->context, storage); +} + +/* Render a full diagnostic snapshot (block assembly + engine piece selection) + * when the caller requests one, and hand the text back through on_dump. Runs on + * the owner thread, the only place engine + download state can be read safely. */ +static void service_dump(const naut_swarm_config *config, engine *eng, + uint32_t torrent_id, const naut_download *download) { + if (!config->should_dump || !config->on_dump) return; + if (!config->should_dump(config->context)) return; + + char *buf = NULL; + size_t len = 0; + FILE *f = open_memstream(&buf, &len); + if (!f) { + config->on_dump(config->context, "dump: out of memory\n"); + return; + } + naut_download_dump(download, f); + engine_dump_torrent(eng, torrent_id, f); + fclose(f); + config->on_dump(config->context, buf ? buf : "dump: render failed\n"); + free(buf); +} + static uint8_t *slurp(const char *path, size_t *len) { FILE *f = fopen(path, "rb"); if (!f) return NULL; fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET); @@ -68,12 +331,8 @@ static uint8_t *slurp(const char *path, size_t *len) { if (fread(b, 1, n, f) != (size_t)n) { fclose(f); free(b); return NULL; } fclose(f); *len = (size_t)n; return b; } -static bool send_all(int fd, const void *p, size_t n) { - const uint8_t *b = p; - while (n) { ssize_t w = send(fd, b, n, MSG_NOSIGNAL); - if (w < 0) { if (errno == EINTR) continue; return false; } b += w; n -= (size_t)w; } - return true; -} + +/* --- endpoint collection ------------------------------------------------- */ static bool endpoint_add(endpoint_t **v, size_t *n, size_t *cap, const naut_peer_addr *addr) { @@ -138,47 +397,105 @@ static bool parse_udp_tracker(const char *url, char *host, size_t hostsz, static bool discover_trackers(const uint8_t info_hash[20], uint64_t total_length, char *const *trackers, size_t num_trackers, + const uint32_t *tracker_tiers, const uint8_t peerid[20], - endpoint_t **eps, size_t *neps, size_t *cap) { + uint64_t downloaded, uint64_t left, + naut_tracker_event event, + endpoint_t **eps, size_t *neps, size_t *cap, + int32_t *announce_interval, + int32_t *announce_min_interval, + naut_swarm_tracker_stats *tracker_stats, + uint32_t tracker_count) { naut_announce_req req; memset(&req, 0, sizeof req); memcpy(req.info_hash, info_hash, sizeof req.info_hash); memcpy(req.peer_id, peerid, sizeof req.peer_id); req.port = 6881; + req.downloaded = downloaded; req.left = total_length; - req.event = NAUT_TEV_STARTED; + if (left <= total_length) req.left = left; + req.event = event; req.numwant = 100; - req.key = (uint32_t)rand(); + memcpy(&req.key, peerid + 8, sizeof req.key); + if (announce_interval) *announce_interval = 0; + if (announce_min_interval) *announce_min_interval = 0; - for (size_t i = 0; i < num_trackers; i++) { - const char *tracker = trackers[i]; - naut_tracker_response response; - naut_err e = NAUT_ERR_INVAL; - if (strncmp(tracker, "http://", 7) == 0) { - char url[4096]; - if (naut_tracker_http_url(tracker, &req, url, sizeof url) != 0) - e = naut_tracker_announce_http(url, &response); - } else if (strncmp(tracker, "udp://", 6) == 0) { - char host[256]; - uint16_t port; - if (parse_udp_tracker(tracker, host, sizeof host, &port)) - e = naut_tracker_announce_udp(host, port, &req, &response); - } else { - NAUT_WARN("tracker scheme unsupported: %s", tracker); - continue; - } - if (e != NAUT_OK) { - NAUT_WARN("tracker announce failed: %s", tracker); - continue; - } - NAUT_INFO("tracker %s returned %zu peers", tracker, response.num_peers); - for (size_t p = 0; p < response.num_peers; p++) { - if (!endpoint_add(eps, neps, cap, &response.peers[p])) { - naut_tracker_response_free(&response); - return false; + size_t tier_start = 0; + while (tier_start < num_trackers) { + uint32_t tier = tracker_tiers + ? tracker_tiers[tier_start] : (uint32_t)tier_start; + size_t tier_end = tier_start + 1; + if (tracker_tiers) + while (tier_end < num_trackers && + tracker_tiers[tier_end] == tier) + tier_end++; + + size_t tier_count = tier_end - tier_start; + uint32_t random = 0; + random_bytes((uint8_t *)&random, sizeof random); + size_t first = tier_count ? random % tier_count : 0; + bool tier_succeeded = false; + for (size_t n = 0; n < tier_count; n++) { + size_t i = tier_start + (first + n) % tier_count; + const char *tracker = trackers[i]; + naut_tracker_response response; + memset(&response, 0, sizeof response); + response.seeders = response.leechers = -1; + naut_swarm_tracker_stats *tracker_stat = + tracker_stat_for(tracker_stats, tracker_count, tracker); + naut_err e = NAUT_ERR_INVAL; + if (strncmp(tracker, "http://", 7) == 0) { + char url[4096]; + if (naut_tracker_http_url(tracker, &req, url, + sizeof url) != 0) + e = naut_tracker_announce_http(url, &response); + } else if (strncmp(tracker, "udp://", 6) == 0) { + char host[256]; + uint16_t port; + if (parse_udp_tracker(tracker, host, sizeof host, &port)) + e = naut_tracker_announce_udp(host, port, &req, &response); + } else { + tracker_set_status(tracker_stat, "unsupported", + "unsupported tracker scheme"); + NAUT_WARN("tracker scheme unsupported: %s", tracker); + continue; } + if (e != NAUT_OK) { + tracker_set_status(tracker_stat, "error", + response.failure + ? response.failure : "announce failed"); + NAUT_WARN("tracker announce failed: %s", tracker); + naut_tracker_response_free(&response); + continue; + } + tier_succeeded = true; + if (announce_interval && response.interval > 0) + *announce_interval = response.interval; + if (announce_min_interval && response.min_interval > 0) + *announce_min_interval = response.min_interval; + if (tracker_stat) { + tracker_set_status(tracker_stat, "working", ""); + tracker_stat->seeds = response.seeders; + tracker_stat->leeches = response.leechers; + tracker_stat->peers = response.num_peers > INT32_MAX + ? INT32_MAX : (int32_t)response.num_peers; + tracker_stat->downloaded = -1; + } + NAUT_INFO("tracker %s returned %zu peers", + tracker, response.num_peers); + for (size_t p = 0; p < response.num_peers; p++) { + if (!endpoint_add(eps, neps, cap, &response.peers[p])) { + naut_tracker_response_free(&response); + return false; + } + } + naut_tracker_response_free(&response); + /* Trackers within a tier are alternatives, not a fan-out set. + * Once one accepts the announce, do not load the rest. */ + break; } - naut_tracker_response_free(&response); + if (tier_succeeded) break; + tier_start = tier_end; } return true; } @@ -230,301 +547,63 @@ static bool discover_dht(const uint8_t info_hash[20], return true; } -static bool peer_reserve_inflight(peer_t *p) { - if (p->nflight == p->cflight) { - size_t cap = p->cflight ? p->cflight * 2 : 64; - req_t *v = realloc(p->inflight, cap * sizeof(*v)); - if (!v) return false; - p->inflight = v; - p->cflight = cap; +/* Hand every not-yet-fed endpoint to the engine, which owns the connection. */ +static void feed_engine(engine *eng, uint32_t torrent_id, + const endpoint_t *eps, size_t neps, size_t *fed) { + for (size_t i = *fed; i < neps; i++) { + char ip[16]; + snprintf(ip, sizeof ip, "%u.%u.%u.%u", + eps[i].addr.ip[0], eps[i].addr.ip[1], + eps[i].addr.ip[2], eps[i].addr.ip[3]); + engine_add_peer(eng, torrent_id, ip, eps[i].addr.port); } - return true; + *fed = neps; } -static void peer_add_inflight(peer_t *p, uint32_t piece, uint32_t begin, - uint32_t length) { - p->inflight[p->nflight].piece = piece; - p->inflight[p->nflight].begin = begin; - p->inflight[p->nflight].length = length; - p->inflight[p->nflight].sent_at = now(); - p->nflight++; -} -static bool peer_del_inflight(peer_t *p, uint32_t piece, uint32_t begin, - req_t *removed) { - for (size_t i = 0; i < p->nflight; i++) - if (p->inflight[i].piece == piece && p->inflight[i].begin == begin) { - if (removed) *removed = p->inflight[i]; - p->inflight[i] = p->inflight[--p->nflight]; - return true; - } - return false; -} +naut_err naut_swarm_run(const naut_swarm_config *config) { + if (!config || !config->source || !*config->source || + !config->output_dir || !*config->output_dir) + return NAUT_ERR_INVAL; -static bool peer_has_request(void *ctx, uint32_t piece, uint32_t begin) { - peer_t *p = ctx; - for (size_t i = 0; i < p->nflight; i++) - if (p->inflight[i].piece == piece && p->inflight[i].begin == begin) - return true; - return false; -} + uint8_t peerid[20]; + memcpy(peerid, "-NT0001-", 8); + random_bytes(peerid + 8, sizeof peerid - 8); -/* return remaining inflight blocks to the picker (choke / disconnect) */ -static void peer_release(naut_download *d, peer_t *p) { - for (size_t i = 0; i < p->nflight; i++) - naut_download_unrequest(d, p->inflight[i].piece, p->inflight[i].begin); - p->nflight = 0; -} - -static void peer_drop(naut_download *d, peer_t *p) { - if (!p->availability_removed) { - naut_download_remove_bitfield(d, &p->have); - p->availability_removed = true; - } - peer_release(d, p); - if (p->fd >= 0) close(p->fd); - p->fd = -1; - p->dead = true; -} - -static bool refill_one(naut_download *d, peer_t *p) { - if (p->dead || !p->hs_done || p->peer_choking || - p->nflight >= naut_pipeline_depth(&p->pipeline)) - return false; - if (!peer_reserve_inflight(p)) { - peer_drop(d, p); - return false; - } - uint32_t i, b, l; - if (!naut_download_pick_for_peer(d, &p->have, peer_has_request, p, - &i, &b, &l)) - return false; - uint8_t req[17]; - naut_peer_msg_request(req, i, b, l); - if (!send_all(p->fd, req, sizeof req)) { - naut_download_unrequest(d, i, b); - peer_drop(d, p); - return false; - } - peer_add_inflight(p, i, b, l); - return true; -} - -static void expire_requests(naut_download *d, peer_t *p, double t) { - size_t i = 0; - while (i < p->nflight) { - if (t - p->inflight[i].sent_at < REQUEST_TIMEOUT) { - i++; - continue; - } - naut_download_unrequest(d, p->inflight[i].piece, p->inflight[i].begin); - p->inflight[i] = p->inflight[--p->nflight]; - } -} - -static int connect_to(const endpoint_t *ep) { - int fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) return -1; - struct sockaddr_in a; memset(&a, 0, sizeof a); - a.sin_family = AF_INET; - a.sin_port = htons(ep->addr.port); - memcpy(&a.sin_addr, ep->addr.ip, sizeof ep->addr.ip); - if (connect(fd, (struct sockaddr *)&a, sizeof a) != 0) { close(fd); return -1; } - int one = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); - return fd; -} - -/* process all complete messages currently buffered for peer p */ -static void cancel_block(naut_download *d, peer_t *peers, int npeers, - peer_t *source, uint32_t piece, uint32_t begin) { - for (int i = 0; i < npeers; i++) { - peer_t *p = &peers[i]; - if (p == source || p->dead) continue; - req_t old; - if (!peer_del_inflight(p, piece, begin, &old)) continue; - uint8_t msg[17]; - naut_peer_msg_cancel(msg, old.piece, old.begin, old.length); - if (!send_all(p->fd, msg, sizeof msg)) peer_drop(d, p); - } -} - -static void cancel_piece(naut_download *d, peer_t *peers, int npeers, - uint32_t piece) { - for (int i = 0; i < npeers; i++) { - peer_t *p = &peers[i]; - size_t j = 0; - while (j < p->nflight) { - req_t old = p->inflight[j]; - if (old.piece != piece) { - j++; - continue; - } - p->inflight[j] = p->inflight[--p->nflight]; - naut_download_unrequest(d, old.piece, old.begin); - if (!p->dead) { - uint8_t msg[17]; - naut_peer_msg_cancel(msg, old.piece, old.begin, old.length); - if (!send_all(p->fd, msg, sizeof msg)) { - peer_drop(d, p); - break; - } - } - } - } -} - -static void merge_bitfield(naut_download *d, const naut_metainfo *mi, - peer_t *p, const uint8_t *wire, size_t wire_len) { - naut_bitfield incoming; - if (naut_bitfield_init(&incoming, mi->num_pieces) != NAUT_OK) { - peer_drop(d, p); - return; - } - naut_bitfield_from_wire(&incoming, wire, wire_len); - for (uint32_t piece = 0; piece < mi->num_pieces; piece++) { - if (naut_bitfield_test(&incoming, piece) && - !naut_bitfield_test(&p->have, piece)) { - naut_bitfield_set(&p->have, piece); - naut_download_inc_avail(d, piece); - } - } - naut_bitfield_free(&incoming); -} - -static naut_err peer_process(naut_download *d, const naut_metainfo *mi, - peer_t *peers, int npeers, peer_t *p) { - size_t pos = 0; - if (!p->hs_done) { - if (p->rlen < NAUT_HANDSHAKE_LEN) return NAUT_OK; - uint8_t ih[20], pid[20]; - if (!naut_peer_handshake_parse(p->rbuf, ih, pid, NULL) || - memcmp(ih, mi->infohash_v1, 20) != 0) { - p->dead = true; - return NAUT_OK; - } - pos = NAUT_HANDSHAKE_LEN; - p->hs_done = true; - } - for (;;) { - naut_msg m; - int c = naut_peer_msg_parse(p->rbuf + pos, p->rlen - pos, &m); - if (c == 0) break; - if (c < 0) { p->dead = true; break; } - pos += (size_t)c; - switch (m.type) { - case NAUT_MSG_BITFIELD: - merge_bitfield(d, mi, p, m.payload, m.payload_len); - if (p->dead) goto parsed; - break; - case NAUT_MSG_HAVE: - if (m.index < mi->num_pieces && !naut_bitfield_test(&p->have, m.index)) { - naut_bitfield_set(&p->have, m.index); - naut_download_inc_avail(d, m.index); - } - break; - case NAUT_MSG_UNCHOKE: p->peer_choking = false; break; - case NAUT_MSG_CHOKE: p->peer_choking = true; peer_release(d, p); break; - case NAUT_MSG_EXTENDED: - if (m.payload_len < 1) { - peer_drop(d, p); - goto parsed; - } - if (m.payload[0] == 0) { - if (naut_ext_parse_handshake( - m.payload + 1, m.payload_len - 1, - &p->extensions) != NAUT_OK) { - peer_drop(d, p); - goto parsed; - } - } else if (m.payload[0] == NAUT_EXT_UT_PEX) { - naut_pex_msg pex; - if (naut_pex_parse(m.payload + 1, m.payload_len - 1, - &pex) != NAUT_OK) { - peer_drop(d, p); - goto parsed; - } - p->pex_received += pex.num_added; - naut_pex_free(&pex); - } - break; - case NAUT_MSG_PIECE: { - req_t request; - bool expected = peer_del_inflight(p, m.index, m.begin, &request); - if (expected) { - naut_pipeline_on_block(&p->pipeline, request.length, - request.sent_at, now()); - naut_download_unrequest(d, m.index, m.begin); - if (request.length != m.payload_len) { - peer_drop(d, p); - goto parsed; - } - } - bool pdone = false; - naut_err e = naut_download_on_block(d, m.index, m.begin, m.payload, - (uint32_t)m.payload_len, &pdone); - if (e == NAUT_OK) { - p->blocks_received++; - cancel_block(d, peers, npeers, p, m.index, m.begin); - } else if (e == NAUT_ERR_PROTO) { - if (expected) cancel_piece(d, peers, npeers, m.index); - else peer_drop(d, p); - } else { - return e; - } - break; - } - default: break; - } - } -parsed: - memmove(p->rbuf, p->rbuf + pos, p->rlen - pos); - p->rlen -= pos; - return NAUT_OK; -} - -int main(int argc, char **argv) { - if (argc < 3) { - fprintf(stderr, - "usage: %s [ip:port ...]\n", - argv[0]); - return 2; - } - naut_log_set_level(NAUT_LOG_INFO); - - uint8_t peerid[20]; memcpy(peerid, "-NT0001-", 8); - srand((unsigned)time(NULL) ^ (unsigned)getpid()); - for (int i = 8; i < 20; i++) peerid[i] = (uint8_t)(rand() & 0xff); - - bool from_magnet = strncmp(argv[1], "magnet:?", 8) == 0; + bool from_magnet = strncmp(config->source, "magnet:?", 8) == 0; naut_metainfo mi; memset(&mi, 0, sizeof mi); naut_magnet magnet; memset(&magnet, 0, sizeof magnet); if (from_magnet) { - if (naut_magnet_parse(argv[1], &magnet) != NAUT_OK || + if (naut_magnet_parse(config->source, &magnet) != NAUT_OK || !magnet.has_v1) { NAUT_ERROR("magnet must contain a v1 btih hash"); naut_magnet_free(&magnet); - return 1; + return NAUT_ERR_INVAL; } } else { size_t tlen; - uint8_t *tor = slurp(argv[1], &tlen); - if (!tor) { NAUT_ERROR("read torrent"); return 1; } + uint8_t *tor = slurp(config->source, &tlen); + if (!tor) { NAUT_ERROR("read torrent"); return NAUT_ERR_IO; } if (naut_metainfo_parse(tor, tlen, &mi) != NAUT_OK) { NAUT_ERROR("parse torrent"); free(tor); - return 1; + return NAUT_ERR_PROTO; } free(tor); } endpoint_t *endpoints = NULL; size_t neps = 0, epcap = 0; - if (argc > 3) { - for (int i = 3; i < argc; i++) { + uint32_t target_peers = target_peer_count(); + int32_t tracker_interval = 0, tracker_min_interval = 0; + naut_swarm_tracker_stats tracker_stats[NAUT_SWARM_MAX_TRACKER_STATS]; + uint32_t tracker_count = 0; + if (config->num_peers > 0) { + for (size_t i = 0; i < config->num_peers; i++) { naut_peer_addr addr; - if (!endpoint_parse(argv[i], &addr)) { - NAUT_WARN("invalid peer address: %s", argv[i]); + if (!endpoint_parse(config->peers[i], &addr)) { + NAUT_WARN("invalid peer address: %s", config->peers[i]); continue; } if (!endpoint_add(&endpoints, &neps, &epcap, &addr)) { @@ -532,7 +611,7 @@ int main(int argc, char **argv) { naut_metainfo_free(&mi); naut_magnet_free(&magnet); free(endpoints); - return 1; + return NAUT_ERR_NOMEM; } } } else { @@ -540,21 +619,32 @@ int main(int argc, char **argv) { from_magnet ? magnet.infohash_v1 : mi.infohash_v1; char *const *trackers = from_magnet ? magnet.trackers : mi.trackers; + const uint32_t *tracker_tiers = + from_magnet ? NULL : mi.tracker_tiers; size_t num_trackers = from_magnet ? magnet.num_trackers : mi.num_trackers; - uint64_t total = from_magnet ? 0 : (uint64_t)mi.total_length; - if (!discover_trackers(hash, total, trackers, num_trackers, peerid, - &endpoints, &neps, &epcap) || - (neps == 0 && - !discover_dht(hash, &endpoints, &neps, &epcap))) { - NAUT_ERROR("out of memory collecting discovered peers"); - naut_metainfo_free(&mi); - naut_magnet_free(&magnet); - free(endpoints); - return 1; + tracker_count = init_tracker_stats(trackers, tracker_tiers, + num_trackers, tracker_stats, + NAUT_SWARM_MAX_TRACKER_STATS); + if (from_magnet) { + if (!discover_trackers(hash, 0, trackers, num_trackers, + tracker_tiers, peerid, 0, 0, + NAUT_TEV_STARTED, &endpoints, &neps, &epcap, + &tracker_interval, &tracker_min_interval, + tracker_stats, tracker_count) || + (neps < target_peers && + !discover_dht(hash, &endpoints, &neps, &epcap))) { + NAUT_ERROR("out of memory collecting discovered peers"); + naut_metainfo_free(&mi); + naut_magnet_free(&magnet); + free(endpoints); + return NAUT_ERR_NOMEM; + } } } + /* Magnet: resolve the info dict from a peer before we can size the torrent. + * Neither the engine nor torrent-tracker does BEP-9; use Naut's own fetch. */ if (from_magnet && neps) { uint8_t *info = NULL; size_t info_len = 0; @@ -574,7 +664,8 @@ int main(int argc, char **argv) { free(info); naut_magnet_free(&magnet); free(endpoints); - return 1; + return metadata_error != NAUT_OK ? metadata_error : + NAUT_ERR_PROTO; } free(info); NAUT_INFO("magnet metadata verified: %u pieces, %lld bytes", @@ -583,219 +674,268 @@ int main(int argc, char **argv) { naut_magnet_free(&magnet); if (neps == 0) { - NAUT_ERROR(argc > 3 ? "no valid peer addresses" : - "tracker and DHT discovery returned no peers"); - naut_metainfo_free(&mi); - free(endpoints); - return 1; + if (from_magnet) { + NAUT_ERROR("magnet metadata unavailable: no peers discovered"); + naut_metainfo_free(&mi); + free(endpoints); + return NAUT_ERR_NOTFOUND; + } + if (config->num_peers > 0) + NAUT_WARN("no valid peer addresses; torrent stalled"); } naut_err err; + /* Reopen any files relocated on a prior run in place (no re-download). */ + const char **overrides = NULL; + if (config->num_locations && mi.num_files) { + overrides = calloc(mi.num_files, sizeof *overrides); + if (overrides) + for (size_t i = 0; i < config->num_locations; i++) { + const naut_swarm_file_location *loc = &config->locations[i]; + if (loc->path && loc->file_index < mi.num_files) + overrides[loc->file_index] = loc->path; + } + } naut_storage_opts storage_opts = { .direct_io = getenv("NAUT_DIRECT_IO") != NULL, .preallocate = true, + .overrides = overrides, }; naut_storage *st = naut_storage_open_opts( - mi.files, mi.num_files, argv[2], &storage_opts, &err); + mi.files, mi.num_files, config->output_dir, &storage_opts, &err); + free(overrides); if (!st) { NAUT_ERROR("storage: %s", naut_strerror(err)); naut_metainfo_free(&mi); free(endpoints); - return 1; + return err != NAUT_OK ? err : NAUT_ERR_IO; } naut_download *d = naut_download_create(&mi, st); if (!d) { naut_storage_close(st); naut_metainfo_free(&mi); free(endpoints); - return 1; + return NAUT_ERR_NOMEM; } - int online_cpus = naut_online_cpus(); - uint32_t worker_count = - (uint32_t)NAUT_MAX(1, NAUT_MIN(8, online_cpus / 2)); - if (getenv("NAUT_WORKERS")) { - unsigned long configured = strtoul(getenv("NAUT_WORKERS"), NULL, 10); - if (configured > 0 && configured <= 256) - worker_count = (uint32_t)configured; - } - int worker_cpu_base = getenv("NAUT_WORKER_CPU_BASE") - ? atoi(getenv("NAUT_WORKER_CPU_BASE")) : -1; - naut_worker_pool *workers = - naut_worker_pool_create(worker_count, 1024, worker_cpu_base); - if (!workers) { - NAUT_ERROR("unable to create hash worker pool"); + naut_download_set_file_cb(d, on_file_complete, (void *)config); + naut_download_set_piece_cb(d, on_piece_complete, (void *)config); + err = naut_download_resume(d); + if (err != NAUT_OK) { + NAUT_ERROR("resume scan: %s", naut_strerror(err)); naut_download_destroy(d); naut_storage_close(st); naut_metainfo_free(&mi); free(endpoints); - return 1; + return err; } - naut_download_set_worker_pool(d, workers); + uint64_t resumed_bytes = naut_download_bytes_done(d); + uint64_t resumed_left = (uint64_t)mi.total_length > resumed_bytes + ? (uint64_t)mi.total_length - resumed_bytes : 0; + NAUT_INFO("resume scan complete: %u/%u pieces correct, %llu bytes left", + naut_download_pieces_done(d), mi.num_pieces, + (unsigned long long)resumed_left); - int npeers = (int)neps; - peer_t *peers = calloc(neps, sizeof(*peers)); - struct pollfd *pfd = calloc(neps + 1, sizeof(*pfd)); - int *idx_map = calloc(neps + 1, sizeof(*idx_map)); - if (!peers || !pfd || !idx_map) { - NAUT_ERROR("out of memory creating swarm"); - free(peers); free(pfd); free(idx_map); - naut_worker_pool_destroy(workers); + /* Check-only: the resume scan above already hash-verified every piece on + * disk. Report the result and stop — no peers, no engine, no download. */ + if (config->check_only) { + report_progress(config, NULL, 0, d, &mi, tracker_stats, tracker_count, + now()); naut_download_destroy(d); naut_storage_close(st); naut_metainfo_free(&mi); free(endpoints); - return 1; + return NAUT_OK; } - for (int i = 0; i < npeers; i++) peers[i].fd = -1; - int active = 0; - for (int i = 0; i < npeers; i++) { - int fd = connect_to(&endpoints[i]); - if (fd < 0) { - NAUT_WARN("connect %s failed", endpoints[i].name); - peers[i].dead = true; - continue; + if (!from_magnet && config->num_peers == 0 && !naut_download_complete(d)) { + if (!discover_trackers(mi.infohash_v1, (uint64_t)mi.total_length, + mi.trackers, mi.num_trackers, + mi.tracker_tiers, peerid, + resumed_bytes, resumed_left, + NAUT_TEV_STARTED, &endpoints, &neps, &epcap, + &tracker_interval, &tracker_min_interval, + tracker_stats, tracker_count) || + (neps < target_peers && + !discover_dht(mi.infohash_v1, &endpoints, &neps, &epcap))) { + NAUT_ERROR("out of memory collecting discovered peers"); + naut_download_destroy(d); + naut_storage_close(st); + naut_metainfo_free(&mi); + free(endpoints); + return NAUT_ERR_NOMEM; } - peer_t *p = &peers[i]; - p->fd = fd; - snprintf(p->name, sizeof p->name, "%s", endpoints[i].name); - p->peer_choking = true; - naut_pipeline_init(&p->pipeline, NAUT_BLOCK, 4, 1024, 32); - p->rcap = 1 << 18; - p->rbuf = malloc(p->rcap); - if (!p->rbuf || naut_bitfield_init(&p->have, mi.num_pieces) != NAUT_OK) { - free(p->rbuf); - p->rbuf = NULL; - close(fd); - p->fd = -1; - p->dead = true; - continue; - } - uint8_t hs[NAUT_HANDSHAKE_LEN]; - naut_peer_handshake_build(hs, mi.infohash_v1, peerid, EXT_RESERVED); - uint8_t intr[5]; naut_peer_msg_simple(intr, NAUT_MSG_INTERESTED); - uint8_t *ext = NULL; - size_t ext_len = 0; - naut_err ext_error = naut_ext_build_handshake( - NAUT_EXT_UT_METADATA, NAUT_EXT_UT_PEX, 0, 0, &ext, &ext_len); - bool sent = ext_error == NAUT_OK && - send_all(fd, hs, sizeof hs) && - send_all(fd, ext, ext_len) && - send_all(fd, intr, 5); - free(ext); - if (!sent) { - peer_drop(d, p); - continue; - } - active++; + if (neps == 0) + NAUT_WARN("tracker and DHT discovery returned no peers; torrent stalled"); } - free(endpoints); - if (!active) { - NAUT_ERROR("no peers reachable"); - goto done; + + /* Spin up the engine and register the torrent. The engine owns sockets, + * the wire protocol, the pipeline, transports, and piece selection. */ + engine_config ecfg; + memset(&ecfg, 0, sizeof ecfg); + ecfg.encryption = 1; /* offer MSE (RC4) + plaintext: most compatible */ + ecfg.fallback = 1; /* retry transport/encryption combos per endpoint */ + engine *eng = engine_create(&ecfg); + int32_t tid = eng ? engine_add_torrent(eng, mi.infohash_v1, peerid, + (uint64_t)mi.piece_length, + (uint64_t)mi.total_length, + mi.num_pieces) + : -1; + if (!eng || tid < 0) { + NAUT_ERROR("unable to create download engine"); + if (eng) engine_destroy(eng); + naut_download_destroy(d); + naut_storage_close(st); + naut_metainfo_free(&mi); + free(endpoints); + return NAUT_ERR_NOMEM; } - NAUT_INFO("swarm: %d peers, %u pieces, %lld bytes", active, mi.num_pieces, (long long)mi.total_length); + uint32_t torrent_id = (uint32_t)tid; + + /* Priority vector: skip what resume already verified, request the rest. */ + uint8_t *prio = malloc(mi.num_pieces ? mi.num_pieces : 1); + if (!prio) { + engine_destroy(eng); + naut_download_destroy(d); + naut_storage_close(st); + naut_metainfo_free(&mi); + free(endpoints); + return NAUT_ERR_NOMEM; + } + for (uint32_t p = 0; p < mi.num_pieces; p++) + prio[p] = naut_download_have(d, p) ? 0 : 1; + engine_set_priorities(eng, torrent_id, prio, mi.num_pieces); + + size_t fed = 0; + feed_engine(eng, torrent_id, endpoints, neps, &fed); double t0 = now(); + /* Use the discovered endpoint count as the initial peer proxy: if we start + * thin, schedule a quick re-announce instead of waiting the full interval. */ + double next_tracker_announce = + t0 + next_announce_delay(tracker_interval, tracker_min_interval, + (uint32_t)neps); + double next_dht_lookup = t0 + DHT_REFRESH_INTERVAL; naut_err run_error = NAUT_OK; - while (!naut_download_complete(d) && run_error == NAUT_OK) { - int nf = 0; - for (int i = 0; i < npeers; i++) { - if (peers[i].dead) continue; - pfd[nf].fd = peers[i].fd; pfd[nf].events = POLLIN; pfd[nf].revents = 0; - idx_map[nf] = i; nf++; - } - int live_peers = nf; - pfd[nf].fd = naut_worker_eventfd(workers); - pfd[nf].events = POLLIN; - pfd[nf].revents = 0; - idx_map[nf] = -1; - nf++; - if (live_peers == 0) { - uint32_t completed = 0; - run_error = naut_download_poll(d, &completed); - if (naut_download_complete(d)) break; - NAUT_ERROR("all peers gone (%.0f%% done)", - 100.0 * naut_download_pieces_done(d) / mi.num_pieces); - break; - } - int r = poll(pfd, nf, 2000); - if (r < 0) { if (errno == EINTR) continue; break; } + bool cancelled = false; - for (int k = 0; k < nf; k++) { - if (idx_map[k] < 0) { - if (pfd[k].revents & POLLIN) { - uint64_t count; - (void)read(pfd[k].fd, &count, sizeof count); - uint32_t completed = 0; - run_error = naut_download_poll(d, &completed); + NAUT_INFO("swarm: engine started, %zu peers queued, %u pieces, %lld bytes", + neps, mi.num_pieces, (long long)mi.total_length); + emit_event(config, NAUT_EVENT_TORRENT_ADDED, 0, NULL, NULL); + report_progress(config, eng, torrent_id, d, &mi, + tracker_stats, tracker_count, t0); + + engine_block blocks[READY_BATCH]; + uint64_t applied_rate = UINT64_MAX; /* force first apply */ + while (!naut_download_complete(d) && run_error == NAUT_OK) { + service_control(config, st); + service_dump(config, eng, torrent_id, d); + if (stop_requested(config)) { cancelled = true; break; } + + /* Apply the live download throttle when it changes. */ + if (config->download_rate) { + uint64_t rate = config->download_rate(config->context); + if (rate != applied_rate) { + engine_set_download_rate(eng, rate); + applied_rate = rate; + } + } + + /* Top up the swarm from trackers / DHT when it runs thin. */ + if (config->num_peers == 0) { + torrent_status ts; + engine_torrent_status(eng, torrent_id, &ts); + if (ts.peers_connected < target_peers) { + double t = now(); + if (mi.num_trackers > 0 && t >= next_tracker_announce) { + uint64_t downloaded = naut_download_bytes_done(d); + uint64_t left = (uint64_t)mi.total_length > downloaded + ? (uint64_t)mi.total_length - downloaded : 0; + int32_t interval = 0, min_interval = 0; + if (!discover_trackers(mi.infohash_v1, + (uint64_t)mi.total_length, + mi.trackers, mi.num_trackers, + mi.tracker_tiers, peerid, + downloaded, left, NAUT_TEV_NONE, + &endpoints, &neps, &epcap, + &interval, &min_interval, + tracker_stats, tracker_count)) { + run_error = NAUT_ERR_NOMEM; + break; + } + next_tracker_announce = t + next_announce_delay( + interval, min_interval, ts.peers_connected); + feed_engine(eng, torrent_id, endpoints, neps, &fed); + } + if (t >= next_dht_lookup) { + if (!discover_dht(mi.infohash_v1, &endpoints, &neps, + &epcap)) { + run_error = NAUT_ERR_NOMEM; + break; + } + next_dht_lookup = t + DHT_REFRESH_INTERVAL; + feed_engine(eng, torrent_id, endpoints, neps, &fed); } - continue; } - peer_t *p = &peers[idx_map[k]]; - if (!(pfd[k].revents & (POLLIN | POLLHUP | POLLERR))) continue; - if (p->rlen == p->rcap) { - size_t cap = p->rcap * 2; - uint8_t *buf = realloc(p->rbuf, cap); - if (!buf) { peer_drop(d, p); continue; } - p->rbuf = buf; - p->rcap = cap; + } + + engine_wait(eng, 200); + uint32_t n; + while ((n = engine_poll_ready(eng, blocks, READY_BATCH)) > 0) { + for (uint32_t i = 0; i < n; i++) { + engine_block *b = &blocks[i]; + uint8_t *data = (uint8_t *)engine_arena_base(eng, b->loop) + + (uint64_t)b->slot * PEER_BLOCK_SIZE; + bool done = false; + naut_err be = naut_download_on_block(d, b->piece, b->begin, + data, b->len, &done); + engine_release_slot(eng, b->loop, b->slot); + if (be == NAUT_ERR_PROTO) { + /* Bad/failed piece: re-arm it for another fetch. */ + engine_request_piece(eng, torrent_id, b->piece); + } else if (be != NAUT_OK && be != NAUT_ERR_RANGE) { + run_error = be; + break; + } else if (done) { + engine_set_priority(eng, torrent_id, b->piece, 0); + } } - ssize_t got = recv(p->fd, p->rbuf + p->rlen, p->rcap - p->rlen, 0); - if (got <= 0) { peer_drop(d, p); continue; } - p->rlen += (size_t)got; - run_error = peer_process(d, &mi, peers, npeers, p); if (run_error != NAUT_OK) break; - if (p->dead) peer_drop(d, p); - } - if (run_error != NAUT_OK) break; - double t = now(); - for (int i = 0; i < npeers; i++) { - peer_t *p = &peers[i]; - if (!p->dead) expire_requests(d, p, t); - } - for (;;) { - bool sent = false; - for (int i = 0; i < npeers; i++) - if (refill_one(d, &peers[i])) sent = true; - if (!sent) break; } + report_progress(config, eng, torrent_id, d, &mi, + tracker_stats, tracker_count, t0); } double dt = now() - t0; bool ok = naut_download_complete(d); if (ok) { double mb = (double)mi.total_length / 1e6; - NAUT_INFO("COMPLETE: %u/%u pieces from swarm in %.2fs (%.1f MB/s), all SHA-1 verified%s", - naut_download_pieces_done(d), mi.num_pieces, dt, mb/dt, - naut_download_in_endgame(d) ? " (passed through endgame)" : ""); + NAUT_INFO("COMPLETE: %u/%u pieces in %.2fs (%.1f MB/s), all SHA-1 verified", + naut_download_pieces_done(d), mi.num_pieces, dt, + dt > 0 ? mb / dt : 0.0); + report_progress(config, eng, torrent_id, d, &mi, + tracker_stats, tracker_count, t0); + emit_event(config, NAUT_EVENT_TORRENT_FINISHED, 0, NULL, NULL); + while (config->keep_alive && !stop_requested(config)) { + service_control(config, st); + service_dump(config, eng, torrent_id, d); + usleep(100000); + } } else { if (run_error != NAUT_OK) NAUT_ERROR("swarm stopped: %s", naut_strerror(run_error)); - NAUT_ERROR("INCOMPLETE: %u/%u pieces", naut_download_pieces_done(d), mi.num_pieces); + NAUT_ERROR("INCOMPLETE: %u/%u pieces", + naut_download_pieces_done(d), mi.num_pieces); } -done: - ok = naut_download_complete(d); + service_control(config, st); naut_storage_sync(st); - for (int i = 0; i < npeers; i++) { - if (peers[i].blocks_received) - NAUT_INFO("peer %s delivered %llu blocks (pipeline %u, RTT %.1f ms, %.1f MB/s)", - peers[i].name, - (unsigned long long)peers[i].blocks_received, - naut_pipeline_depth(&peers[i].pipeline), - peers[i].pipeline.rtt_seconds * 1000.0, - peers[i].pipeline.bytes_per_second / 1e6); - if (peers[i].pex_received) - NAUT_INFO("peer %s advertised %llu peers through PEX", - peers[i].name, - (unsigned long long)peers[i].pex_received); - if (!peers[i].dead) peer_drop(d, &peers[i]); - free(peers[i].rbuf); free(peers[i].inflight); - if (peers[i].have.words) naut_bitfield_free(&peers[i].have); - } - free(peers); free(pfd); free(idx_map); - naut_worker_pool_destroy(workers); - naut_download_destroy(d); naut_storage_close(st); naut_metainfo_free(&mi); - return ok ? 0 : 1; + engine_destroy(eng); + free(prio); + free(endpoints); + naut_download_destroy(d); + naut_storage_close(st); + naut_metainfo_free(&mi); + if (ok) return NAUT_OK; + if (cancelled) return NAUT_ERR_AGAIN; + return run_error != NAUT_OK ? run_error : NAUT_ERR_IO; } diff --git a/docs/scripting.md b/docs/scripting.md index b472583..2743913 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -16,18 +16,19 @@ them, and the `naut` API table. ## 1. Loading a script -Pass a script to the daemon with `--script`: +Load a script through the control API: ```sh -nautd --socket /tmp/nautd.sock --script ./my-rules.lua +nautctl --socket /tmp/nautd.sock script ./my-rules.lua ``` -- **One script per daemon.** If `--script` is given more than once, the last - path wins. -- The file is **loaded and executed once at startup**, on the main thread, - before the event worker starts. Use this top-level run to define your hook - functions (and any state they need). If the file fails to load or its - top-level code raises, the daemon refuses to start and prints the Lua error. +- **One active script per daemon.** Loading another script replaces the current + one. `nautctl unscript` unloads it. `nautd --script PATH` is also available + for startup configuration. +- The file is **loaded and executed once**, synchronously on the control thread, + before its event worker starts. Use this top-level run to define your hook + functions (and any state they need). A load or top-level error rejects the + request (or prevents startup when `--script` is used). - After startup the script is **event-driven**: the functions you defined are called as matching events occur. @@ -161,9 +162,9 @@ Move one **completed** file of a torrent to `destination` (the headline **Return value:** none on success. **Asynchronous semantics — important.** `move_file` does **not** perform the -move inline on the script thread. It enqueues a bounded command that the daemon -**owner thread** executes shortly after (this preserves the engine's -shared-nothing threading: the script thread never touches storage directly). +move inline on the script thread. It enqueues a bounded command that the +torrent's **owner thread** executes shortly after (the script thread never +touches storage directly). So a successful call means *"the move was accepted"*, not *"the file has moved."* The actual relocate (a `rename`, or copy+unlink across filesystems) runs later; its success or failure is reported in the **daemon log** and reflected in the @@ -189,19 +190,87 @@ function on_file_complete(event) end ``` -**Prerequisite — register the torrent's storage first.** `move_file` resolves -`torrent_id` through the daemon's torrent registry (`naut_session`). Register a -torrent's storage with the `add_torrent` RPC before any move command can take -effect: +**Prerequisite — the torrent must be loaded.** Add the torrent to the daemon; +the same worker that downloads it owns and executes its move commands: ```sh -nautctl --socket /tmp/nautd.sock add_torrent \ - '{"torrent_id":42,"torrent":"file.torrent","root":"/downloads/42"}' +nautctl --socket /tmp/nautd.sock add file.torrent /downloads/42 ``` -If `torrent_id` is unknown when the move drains, the relocate fails with -"not found" in the daemon log (the Lua call itself still succeeded, because it -only *queued* the command). +If `torrent_id` is unknown or is being removed, `naut.move_file` raises a +`move_file failed` error in the hook. + +### `naut.get_labels(torrent_id)` + +Return the torrent's labels as a **plain array (table) of strings**. Labels are +the user-assigned tags/category set in the web UI (or via the `set_labels` RPC); +they are stored on the daemon, persisted across restarts, and surfaced here so a +script can branch on them (e.g. route a finished file by its label). + +**Arguments** + +| # | Name | Lua type | Notes | +|---|---|---|---| +| 1 | `torrent_id` | `integer` | must be ≥ 0 | + +**Return value:** a sequence table of strings, e.g. `{"anime", "airing"}`. The +table is **empty** (`#labels == 0`) when the torrent has no labels or is unknown +— it is never `nil`, so it is always safe to iterate. + +```lua +function on_file_complete(event) + local labels = naut.get_labels(event.torrent_id) + for _, label in ipairs(labels) do + if label == "anime" then + naut.move_file(event.torrent_id, event.index, + "/archive/anime/" .. event.path) + return + end + end +end +``` + +Labels reflect the daemon's current state at call time (re-read on every call), +so a script always sees the latest assignment. + +### `naut.define_settings({ ... })` + +Declare the user-configurable variables the script reads, so they can be edited +in the web UI (**Automation ▸ Settings**) instead of by hand in the source. Pass +an array of entries: + +| Field | Lua type | Meaning | +|---|---|---| +| `key` | `string` | identifier passed to `naut.get_setting` | +| `label` | `string` | human label shown in the form (defaults to `key`) | +| `type` | `string` | `"string"`, `"bool"`, or `"number"` (drives the widget + the type returned by `get_setting`) | +| `default` | string/bool/number | value used until the user sets one | + +Call it once at load time (re-declaring replaces the schema). The values the user +saves persist across restarts, independently of the script source. + +### `naut.get_setting(key)` + +Return the current value of a declared setting: the user-saved value if present, +otherwise the declared default. The result is **typed** per the schema — a Lua +`boolean` for `bool`, a `number` for `number`, a `string` otherwise — or `nil` +if the key was never declared. Re-read it on each use so live edits take effect +without reloading the script. + +```lua +naut.define_settings({ + { key = "library_root", label = "Library root", type = "string", + default = "/media/anime" }, + { key = "only_video", label = "Only video files", type = "bool", + default = true }, +}) + +function on_file_complete(event) + if naut.get_setting("only_video") and not is_video(event.path) then return end + local root = naut.get_setting("library_root") + naut.move_file(event.torrent_id, event.index, root .. "/" .. basename(event.path)) +end +``` --- @@ -283,7 +352,7 @@ end Run it: ```sh -nautd --socket /tmp/nautd.sock --script ./archive-on-finish.lua & -nautctl --socket /tmp/nautd.sock add_torrent \ - '{"torrent_id":1,"torrent":"big.torrent","root":"/downloads/1"}' +nautd --socket /tmp/nautd.sock & +nautctl --socket /tmp/nautd.sock script ./archive-on-finish.lua +nautctl --socket /tmp/nautd.sock add big.torrent /downloads/1 ``` diff --git a/examples/README.md b/examples/README.md index cf22d24..a4728ef 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,13 +18,10 @@ Movies (no detectable episode) go to `//<Title> (year).<ext> ### Use it ```sh -# 1. edit SORTED_ROOT (and options) at the top of the script -# 2. start the daemon with the script -nautd --socket /tmp/nautd.sock --script examples/anime_sort.lua - -# 3. register each torrent's storage so the move can resolve + relocate its files -nautctl --socket /tmp/nautd.sock add_torrent \ - '{"torrent_id":1,"torrent":"show.torrent","root":"/downloads/1"}' +# Start the engine, load the script, and add a download. +nautd --socket /tmp/nautd.sock +nautctl --socket /tmp/nautd.sock script examples/anime_sort.lua +nautctl --socket /tmp/nautd.sock add show.torrent /downloads/1 ``` As each file completes you'll see, e.g.: diff --git a/examples/anime_sort.lua b/examples/anime_sort.lua index 89fb553..03a7017 100644 --- a/examples/anime_sort.lua +++ b/examples/anime_sort.lua @@ -9,23 +9,55 @@ -- file's last piece verifies, episodes are sorted the moment they're done — -- without waiting for the rest of the torrent. -- +-- By default it only sorts torrents you have labelled "anime" (REQUIRE_LABEL +-- below), read via naut.get_labels(), so non-anime downloads are left untouched. +-- -- Install: --- nautd --socket /tmp/nautd.sock --script examples/anime_sort.lua --- nautctl --socket /tmp/nautd.sock add_torrent \ --- '{"torrent_id":1,"torrent":"show.torrent","root":"/downloads/1"}' +-- nautd --socket /tmp/nautd.sock +-- nautctl --socket /tmp/nautd.sock script examples/anime_sort.lua +-- nautctl --socket /tmp/nautd.sock add show.torrent /downloads/1 -- -- The parser below is a VERBATIM COPY of examples/anitomy.lua (the sandbox has -- no `require`, so it must be inlined). Keep the two in sync; examples/ -- test_anime_sort.lua asserts they agree. ---------------------------------------------------------------------- --- CONFIG — edit these +-- CONFIG — these are exposed in the web UI (Automation ▸ Settings) through +-- naut.define_settings, so you can change them there WITHOUT editing this file. +-- The values below are only the defaults used until you set them in the UI. ---------------------------------------------------------------------- -local SORTED_ROOT = "/sorted" -- destination library root -local ONLY_VIDEO = true -- skip non-video files (subs, nfo, samples) -local KEEP_ORIGINAL_NAME = false -- true: keep the original filename; - -- false: rename to "Title - SNNENN.ext" +local DEFAULTS = { + sorted_root = "/workspaces/source/ai-garbo/Naut-Torrent/Downloads/Sorted/", + only_video = true, -- skip non-video files (subs, nfo, samples) + keep_original_name = false, -- false: rename to "Title - SNNENN.ext" + require_label = "anime", -- only sort torrents with this label + -- (case-insensitive); blank = any torrent +} + +-- Read a setting from the host live (so UI edits apply without a reload), +-- falling back to the default when unset or running on an older daemon. +local function setting(key) + if type(naut) == "table" and type(naut.get_setting) == "function" then + local v = naut.get_setting(key) + if v ~= nil then return v end + end + return DEFAULTS[key] +end + +-- Declare the configurable variables so the web UI can render a form for them. +if type(naut) == "table" and type(naut.define_settings) == "function" then + naut.define_settings({ + { key = "sorted_root", label = "Library root", type = "string", + default = DEFAULTS.sorted_root }, + { key = "only_video", label = "Only video files", type = "bool", + default = DEFAULTS.only_video }, + { key = "keep_original_name", label = "Keep original filename", + type = "bool", default = DEFAULTS.keep_original_name }, + { key = "require_label", label = "Required label (blank = any)", + type = "string", default = DEFAULTS.require_label }, + }) +end ---------------------------------------------------------------------- -- embedded anitomy parser (== examples/anitomy.lua) @@ -240,6 +272,8 @@ local function sanitize(s) end local function destination(parsed) + local root = setting("sorted_root") + local keep_original = setting("keep_original_name") local title = sanitize(parsed.title) local ext = parsed.extension and ("." .. parsed.extension) or "" local original = sanitize(basename(parsed.file_name)) @@ -247,18 +281,18 @@ local function destination(parsed) if parsed.episode == nil then -- movie / special: <root>/<title>/<file> local fname - if KEEP_ORIGINAL_NAME then + if keep_original then fname = original else fname = title .. (parsed.year and (" (" .. parsed.year .. ")") or "") .. ext end - return SORTED_ROOT .. "/" .. title .. "/" .. fname + return root .. "/" .. title .. "/" .. fname end local season = parsed.season or 1 local sdir = string.format("Season %02d", season) local fname - if KEEP_ORIGINAL_NAME then + if keep_original then fname = original else fname = string.format("%s - S%02dE%02d", title, season, parsed.episode) @@ -267,11 +301,25 @@ local function destination(parsed) end fname = fname .. ext end - return SORTED_ROOT .. "/" .. title .. "/" .. sdir .. "/" .. fname + return root .. "/" .. title .. "/" .. sdir .. "/" .. fname +end + +-- True if the torrent carries `want` among its labels (case-insensitive). When +-- `want` is nil the gate is disabled. If the daemon predates naut.get_labels we +-- can't check, so we sort anyway rather than silently dropping every file. +local function has_label(torrent_id, want) + if not want or want == "" then return true end + if type(naut.get_labels) ~= "function" then return true end + want = want:lower() + for _, label in ipairs(naut.get_labels(torrent_id)) do + if label:lower() == want then return true end + end + return false end -- exposed for tests; harmless in the daemon -_G.anime_sort = { anitomy = anitomy, destination = destination } +_G.anime_sort = { anitomy = anitomy, destination = destination, + has_label = has_label } ---------------------------------------------------------------------- -- event hook @@ -279,10 +327,13 @@ _G.anime_sort = { anitomy = anitomy, destination = destination } function on_file_complete(event) if not event.path then return end + if not has_label(event.torrent_id, setting("require_label")) then + return -- not labelled "anime": leave this torrent's files alone + end local name = basename(event.path) local parsed = anitomy.parse(name) - if ONLY_VIDEO and not anitomy.is_video(parsed.extension) then + if setting("only_video") and not anitomy.is_video(parsed.extension) then return -- leave subtitles, .nfo, samples, etc. where they are end diff --git a/examples/test_anime_sort.lua b/examples/test_anime_sort.lua index a7b24ca..a845510 100644 --- a/examples/test_anime_sort.lua +++ b/examples/test_anime_sort.lua @@ -7,7 +7,16 @@ local ref = require("anitomy") -- stub the host API the script calls, and silence its prints local captured -_G.naut = { move_file = function(tid, idx, dest) captured = dest end } +-- Per-torrent label stub: torrent 1 is labelled "anime"; others are unlabelled. +local LABELS = { [1] = { "anime" } } +-- Optional per-key setting overrides; nil falls back to the script's defaults. +local SETTINGS = {} +_G.naut = { + move_file = function(tid, idx, dest) captured = dest end, + get_labels = function(tid) return LABELS[tid] or {} end, + define_settings = function(_) end, -- schema declaration: no-op here + get_setting = function(key) return SETTINGS[key] end, +} local realprint = print _G.print = function() end @@ -15,10 +24,17 @@ dofile(dir .. "anime_sort.lua") -- defines on_file_complete + _G.anime_sort _G.print = realprint +local configured_root = _G.anime_sort.destination({ + title = "__ROOT_PROBE__", + file_name = "__ROOT_PROBE__.mkv", + extension = "mkv", +}):match("^(.*)/__ROOT_PROBE__/__ROOT_PROBE__%.mkv$") +assert(configured_root, "could not derive SORTED_ROOT from anime_sort.lua") + local fails = 0 -local function fire(path) +local function fire(path, torrent_id) captured = nil - on_file_complete({ torrent_id = 1, index = 0, path = path }) + on_file_complete({ torrent_id = torrent_id or 1, index = 0, path = path }) return captured end local function expect(path, want_dest) @@ -35,21 +51,21 @@ end -- episodes expect("/downloads/1/[HorribleSubs] Boku no Hero Academia - 12 [1080p].mkv", - "/sorted/Boku no Hero Academia/Season 01/Boku no Hero Academia - S01E12.mkv") + configured_root .. "/Boku no Hero Academia/Season 01/Boku no Hero Academia - S01E12.mkv") expect("/downloads/2/[Group] Attack on Titan S04E28 [BD 1080p HEVC].mkv", - "/sorted/Attack on Titan/Season 04/Attack on Titan - S04E28.mkv") + configured_root .. "/Attack on Titan/Season 04/Attack on Titan - S04E28.mkv") expect("/dl/[Commie] Steins;Gate 0 - 12 [BD 1080p].mkv", - "/sorted/Steins;Gate 0/Season 01/Steins;Gate 0 - S01E12.mkv") + configured_root .. "/Steins;Gate 0/Season 01/Steins;Gate 0 - S01E12.mkv") expect("Demon.Slayer.Kimetsu.no.Yaiba.S03E11.1080p.mkv", - "/sorted/Demon Slayer Kimetsu no Yaiba/Season 03/Demon Slayer Kimetsu no Yaiba - S03E11.mkv") + configured_root .. "/Demon Slayer Kimetsu no Yaiba/Season 03/Demon Slayer Kimetsu no Yaiba - S03E11.mkv") expect("/x/[Erai-raws] Jujutsu Kaisen 2nd Season - 17 [1080p].mkv", - "/sorted/Jujutsu Kaisen/Season 02/Jujutsu Kaisen - S02E17.mkv") + configured_root .. "/Jujutsu Kaisen/Season 02/Jujutsu Kaisen - S02E17.mkv") -- movies (no episode) expect("/m/[Group] A Silent Voice [BD 1080p FLAC].mkv", - "/sorted/A Silent Voice/A Silent Voice.mkv") + configured_root .. "/A Silent Voice/A Silent Voice.mkv") expect("/m/Spirited.Away.2001.1080p.BluRay.x264.mkv", - "/sorted/Spirited Away/Spirited Away (2001).mkv") + configured_root .. "/Spirited Away/Spirited Away (2001).mkv") -- non-video file must be skipped (no move queued) do @@ -62,6 +78,42 @@ do end end +-- label gate: a torrent without the "anime" label is left alone (no move), +-- while an "anime"-labelled torrent is still sorted. +do + local episode = "/downloads/[HorribleSubs] Boku no Hero Academia - 12 [1080p].mkv" + local unlabelled = fire(episode, 2) -- torrent 2 has no labels + if unlabelled ~= nil then + fails = fails + 1 + realprint("FAIL unlabelled torrent should be skipped, got: " + .. tostring(unlabelled)) + else + realprint("ok unlabelled torrent skipped") + end + local labelled = fire(episode, 1) -- torrent 1 is labelled "anime" + if labelled == nil then + fails = fails + 1 + realprint("FAIL anime-labelled torrent should be sorted") + else + realprint("ok anime-labelled torrent sorted") + end +end + +-- settings: a value configured in the UI (delivered via naut.get_setting) takes +-- effect live, without editing the script. +do + SETTINGS.sorted_root = "/custom/lib" + local got = fire("/downloads/[HorribleSubs] Boku no Hero Academia - 12 [1080p].mkv", 1) + SETTINGS.sorted_root = nil + local want = "/custom/lib/Boku no Hero Academia/Season 01/Boku no Hero Academia - S01E12.mkv" + if got ~= want then + fails = fails + 1 + realprint("FAIL sorted_root override not applied, got: " .. tostring(got)) + else + realprint("ok sorted_root setting override applied") + end +end + -- drift guard: embedded parser must agree with anitomy.lua local embedded = _G.anime_sort.anitomy local drift = 0 diff --git a/include/naut/common.h b/include/naut/common.h index 2884041..f4842c7 100644 --- a/include/naut/common.h +++ b/include/naut/common.h @@ -52,6 +52,7 @@ enum { NAUT_ERR_FULL = -8, NAUT_ERR_EMPTY = -9, NAUT_ERR_NOTFOUND = -10, + NAUT_ERR_EXIST = -11, /* already exists / data would overlap */ }; const char *naut_strerror(naut_err e); diff --git a/include/naut/dht.h b/include/naut/dht.h index d5b7a38..e2d7545 100644 --- a/include/naut/dht.h +++ b/include/naut/dht.h @@ -1,4 +1,5 @@ -/* dht.h - BEP-5 KRPC codec and bounded IPv4 get_peers traversal. */ +/* dht.h - bounded IPv4 BEP-5 get_peers traversal (KRPC codec from + * torrent-tracker; iterative walk + UDP socket in src/discovery). */ #ifndef NAUT_DHT_H #define NAUT_DHT_H @@ -9,54 +10,6 @@ #define NAUT_DHT_MAX_NODES 256 #define NAUT_DHT_MAX_PEERS 256 -typedef struct { - uint8_t id[NAUT_DHT_ID_LEN]; - uint8_t ip[4]; - uint16_t port; -} naut_dht_node; - -typedef enum { - NAUT_DHT_RESPONSE, - NAUT_DHT_ERROR -} naut_dht_message_type; - -typedef struct { - naut_dht_message_type type; - uint8_t transaction[8]; - size_t transaction_len; - uint8_t id[NAUT_DHT_ID_LEN]; - bool has_id; - uint8_t token[64]; - size_t token_len; - naut_dht_node *nodes; - size_t num_nodes; - naut_peer_addr *peers; - size_t num_peers; - int error_code; -} naut_dht_response; - -naut_err naut_dht_build_ping(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - uint8_t **out, size_t *out_len); -naut_err naut_dht_build_find_node(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t target[20], - uint8_t **out, size_t *out_len); -naut_err naut_dht_build_get_peers(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t info_hash[20], - uint8_t **out, size_t *out_len); -naut_err naut_dht_build_announce_peer(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t info_hash[20], - uint16_t port, bool implied_port, - const void *token, size_t token_len, - uint8_t **out, size_t *out_len); - -naut_err naut_dht_parse_response(const uint8_t *data, size_t len, - naut_dht_response *out); -void naut_dht_response_free(naut_dht_response *response); - /* Query bootstrap endpoints ("host:port") and iteratively follow returned * compact nodes until peers are found or the bounded traversal is exhausted. */ naut_err naut_dht_get_peers(const char *const *bootstrap, size_t num_bootstrap, diff --git a/include/naut/http_client.h b/include/naut/http_client.h new file mode 100644 index 0000000..f2ecd21 --- /dev/null +++ b/include/naut/http_client.h @@ -0,0 +1,26 @@ +/* http_client.h — minimal blocking HTTP/HTTPS GET client. + * + * Used by the web UI for RSS feed polling and Torznab indexer search. Supports + * plain HTTP and TLS (via OpenSSL), follows redirects, and returns the decoded + * response body. Intended for occasional fetches off the hot path. */ +#ifndef NAUT_HTTP_CLIENT_H +#define NAUT_HTTP_CLIENT_H + +#include <stddef.h> +#include "naut/common.h" + +typedef struct { + long status; /* HTTP status code (e.g. 200) */ + char *body; /* malloc'd, NUL-terminated response body */ + size_t body_len; /* length of body, excluding the NUL */ +} naut_http_response; + +/* GET `url` (http:// or https://), following up to a handful of redirects. + * On NAUT_OK, `out` owns `out->body` (free with naut_http_response_free). + * Returns NAUT_ERR_* on transport/protocol failure. A non-2xx HTTP status is + * still returned as NAUT_OK with out->status set, so callers can inspect it. */ +naut_err naut_http_get(const char *url, naut_http_response *out); + +void naut_http_response_free(naut_http_response *r); + +#endif /* NAUT_HTTP_CLIENT_H */ diff --git a/include/naut/metainfo.h b/include/naut/metainfo.h index 4358e0d..30b02a0 100644 --- a/include/naut/metainfo.h +++ b/include/naut/metainfo.h @@ -35,7 +35,9 @@ typedef struct naut_metainfo { const uint8_t *piece_hashes; naut_file *files; size_t num_files; - char **trackers; size_t num_trackers; /* announce + announce-list, flattened */ + char **trackers; size_t num_trackers; + /* Parallel to trackers. Equal values belong to one BEP-12 tier. */ + uint32_t *tracker_tiers; /* internals kept alive so piece_hashes/name stay valid */ void *_owned; diff --git a/include/naut/mse.h b/include/naut/mse.h deleted file mode 100644 index 7d76dc3..0000000 --- a/include/naut/mse.h +++ /dev/null @@ -1,88 +0,0 @@ -/* mse.h - BitTorrent Message Stream Encryption (MSE/PE) transport. */ -#ifndef NAUT_MSE_H -#define NAUT_MSE_H - -#include "naut/common.h" -#include "naut/peer.h" -#include "naut/rc4.h" - -#include <sys/types.h> - -#define NAUT_MSE_DH_LEN 96 - -typedef struct { - naut_rc4 send; - naut_rc4 recv; - bool active; -} naut_mse_stream; - -/* ---- sans-IO handshake state machine ------------------------------------- * - * The outgoing MSE/PE handshake as a pure state machine over byte buffers — no - * sockets — so the same logic drives the blocking apps and the io_uring reactor - * (where blocking in a handshake would stall a whole core's worth of peers). - * - * Drive it like a codec: pump NEED_WRITE bytes out, feed NEED_READ bytes in, - * repeat until DONE or ERROR, then call _finish(). - * - * h = naut_mse_handshake_begin(info_hash, peer_id, reserved); - * for (;;) switch (naut_mse_handshake_status(h)) { - * case NAUT_MSE_HS_NEED_WRITE: pull bytes, write them to the peer; break; - * case NAUT_MSE_HS_NEED_READ: read bytes from the peer, feed them; break; - * case NAUT_MSE_HS_DONE: naut_mse_handshake_finish(h, ...); goto ok; - * case NAUT_MSE_HS_ERROR: ... ; goto err; - * } - */ -typedef enum { - NAUT_MSE_HS_NEED_READ, - NAUT_MSE_HS_NEED_WRITE, - NAUT_MSE_HS_DONE, - NAUT_MSE_HS_ERROR, -} naut_mse_hs_status; - -typedef struct naut_mse_handshake naut_mse_handshake; - -naut_mse_handshake *naut_mse_handshake_begin( - const uint8_t info_hash[20], - const uint8_t peer_id[NAUT_PEERID_LEN], - uint64_t reserved); -void naut_mse_handshake_free(naut_mse_handshake *h); - -naut_mse_hs_status naut_mse_handshake_status(const naut_mse_handshake *h); - -/* Copy pending outgoing bytes into buf (up to cap); returns the count, 0 when - * nothing is queued. Call repeatedly until it returns 0. */ -size_t naut_mse_handshake_pull(naut_mse_handshake *h, uint8_t *buf, size_t cap); - -/* Feed received bytes; *consumed reports how many were absorbed (the rest, if - * any, must be re-fed — after DONE that remainder is the start of the encrypted - * payload stream). Returns the new status. */ -naut_mse_hs_status naut_mse_handshake_feed(naut_mse_handshake *h, - const uint8_t *data, size_t len, - size_t *consumed); - -/* Valid once status is DONE: hand out the negotiated stream and the peer's - * decrypted BitTorrent handshake. */ -naut_err naut_mse_handshake_finish(naut_mse_handshake *h, - naut_mse_stream *stream, - uint8_t remote_handshake[NAUT_HANDSHAKE_LEN]); - -/* Blocking convenience wrapper over the state machine: perform the whole - * outgoing handshake on a blocking socket, offering RC4 only. The BitTorrent - * handshake is carried as IA; the peer's decrypted handshake is returned in - * remote_handshake. */ -naut_err naut_mse_client_handshake( - int fd, - const uint8_t info_hash[20], - const uint8_t peer_id[NAUT_PEERID_LEN], - uint64_t reserved, - naut_mse_stream *stream, - uint8_t remote_handshake[NAUT_HANDSHAKE_LEN]); - -/* Stream I/O after a successful handshake. Encryption/decryption is in-place - * with connection-owned RC4 state. send_all preserves the caller's buffer. */ -bool naut_mse_send_all(int fd, naut_mse_stream *stream, - const void *data, size_t len); -ssize_t naut_mse_recv(int fd, naut_mse_stream *stream, - void *data, size_t len); - -#endif /* NAUT_MSE_H */ diff --git a/include/naut/naut_plugin.h b/include/naut/naut_plugin.h index 5613591..3f6433a 100644 --- a/include/naut/naut_plugin.h +++ b/include/naut/naut_plugin.h @@ -25,6 +25,7 @@ typedef naut_err (*naut_plugin_rpc_fn)(void *context, char **response_json); typedef void (*naut_plugin_event_fn)(void *context, const naut_event *event); +typedef naut_err (*naut_plugin_shutdown_fn)(void); typedef struct naut_host_api { uint32_t abi_version; @@ -41,6 +42,8 @@ typedef struct naut_host_api { void *context); void (*emit_event)(void *host_context, const naut_event *event); void (*log)(void *host_context, int level, const char *message); + naut_err (*call_rpc)(void *host_context, const char *method, + const char *request_json, char **response_json); } naut_host_api; /* Every plugin exports this exact symbol. */ diff --git a/include/naut/piece.h b/include/naut/piece.h index 59ffded..8080219 100644 --- a/include/naut/piece.h +++ b/include/naut/piece.h @@ -13,11 +13,17 @@ #include "naut/bitfield.h" #include "naut/worker.h" +#include <stdio.h> + typedef struct naut_download naut_download; naut_download *naut_download_create(const naut_metainfo *mi, naut_storage *st); void naut_download_destroy(naut_download *d); +/* Scan existing storage and mark SHA-1 verified pieces complete before + * requesting from peers. Invalid or missing pieces are left for download. */ +naut_err naut_download_resume(naut_download *d); + /* Optional hash offload. Completed-piece SHA-1 jobs run on the worker pool; * naut_download_poll() finalizes verified pieces on the owning engine thread. * The pool must outlive the download. */ @@ -67,6 +73,11 @@ typedef void (*naut_file_complete_cb)(void *ctx, uint32_t file_index, const char void naut_download_set_file_cb(naut_download *d, naut_file_complete_cb cb, void *ctx); bool naut_download_file_complete(const naut_download *d, uint32_t file_index); +/* Optional owner-thread notification after a piece verifies and is persisted. */ +typedef void (*naut_piece_complete_cb)(void *ctx, uint32_t piece_index); +void naut_download_set_piece_cb(naut_download *d, naut_piece_complete_cb cb, + void *ctx); + /* Hand out the next block to request. false => nothing left to hand out right * now (all blocks have been requested). */ bool naut_download_next_request(naut_download *d, @@ -82,5 +93,14 @@ bool naut_download_complete(const naut_download *d); uint32_t naut_download_num_pieces(const naut_download *d); uint32_t naut_download_pieces_done(const naut_download *d); uint64_t naut_download_bytes_done(const naut_download *d); +size_t naut_download_piece_states(const naut_download *d, uint8_t *out, + size_t capacity); + +/* Diagnostic: write a human-readable dump of block-assembly state to `out` — + * overall progress plus, for every piece not yet verified, how many of its + * blocks have arrived and how many requests are outstanding. Pairs with + * engine_dump_torrent() (which covers piece selection across peers) to + * investigate pieces that never finish downloading. */ +void naut_download_dump(const naut_download *d, FILE *out); #endif /* NAUT_PIECE_H */ diff --git a/include/naut/pipeline.h b/include/naut/pipeline.h deleted file mode 100644 index 76305dd..0000000 --- a/include/naut/pipeline.h +++ /dev/null @@ -1,29 +0,0 @@ -/* pipeline.h - adaptive request window based on observed bandwidth-delay product. */ -#ifndef NAUT_PIPELINE_H -#define NAUT_PIPELINE_H - -#include "naut/common.h" - -typedef struct { - double rtt_seconds; - double bytes_per_second; - double last_sample_at; - uint32_t depth; - uint32_t min_depth; - uint32_t max_depth; - uint32_t block_size; -} naut_pipeline; - -void naut_pipeline_init(naut_pipeline *p, uint32_t block_size, - uint32_t min_depth, uint32_t max_depth, - uint32_t initial_depth); - -/* Record one completed request. sent_at and received_at are monotonic seconds. - * The controller smooths RTT and delivery rate, then targets 2x BDP to absorb - * scheduling jitter without allowing an unbounded request window. */ -void naut_pipeline_on_block(naut_pipeline *p, uint32_t bytes, - double sent_at, double received_at); - -uint32_t naut_pipeline_depth(const naut_pipeline *p); - -#endif /* NAUT_PIPELINE_H */ diff --git a/include/naut/rpc.h b/include/naut/rpc.h index eefefef..35f6335 100644 --- a/include/naut/rpc.h +++ b/include/naut/rpc.h @@ -8,7 +8,8 @@ #include <jansson.h> #define NAUT_RPC_VERSION 1 -#define NAUT_RPC_MAX_PAYLOAD (1u << 20) +/* Generous enough to carry a base64-encoded .torrent upload in add_torrent. */ +#define NAUT_RPC_MAX_PAYLOAD (8u << 20) typedef enum { NAUT_RPC_REQUEST = 1, diff --git a/include/naut/script.h b/include/naut/script.h index 2ec8bb8..3776ed8 100644 --- a/include/naut/script.h +++ b/include/naut/script.h @@ -11,6 +11,50 @@ typedef naut_err (*naut_script_move_file_cb)(void *context, uint32_t file_index, const char *destination); +/* Resolve a torrent's labels for `naut.get_labels(id)`. Returns a heap array of + * `*count` heap strings (caller frees each string then the array), or NULL with + * *count==0 if the torrent has no labels / is unknown. */ +typedef char **(*naut_script_labels_cb)(void *context, uint64_t torrent_id, + size_t *count); + +/* One user-configurable setting a script declares via naut.define_settings. */ +typedef struct { + const char *key; /* stable identifier read by naut.get_setting */ + const char *label; /* human label for the web UI form */ + const char *type; /* "string" | "bool" | "number" */ + const char *default_value; /* stringified default ("true"/"false" for bool)*/ +} naut_script_setting_def; + +/* The script (re)declared its settings schema. The host stores it and renders a + * form; `defs` is valid only for the duration of the call. */ +typedef void (*naut_script_define_settings_cb)(void *context, + const naut_script_setting_def *defs, + size_t count); + +/* Setting value kinds, so the Lua side can push the right type. */ +typedef enum { + NAUT_SETTING_STRING = 0, + NAUT_SETTING_BOOL = 1, + NAUT_SETTING_NUMBER = 2, +} naut_setting_type; + +/* Resolve a setting for `naut.get_setting(key)`: the user-set value if present, + * else the declared default. Returns a heap string (caller frees) and sets + * *type, or NULL if the key is unknown. */ +typedef char *(*naut_script_get_setting_cb)(void *context, const char *key, + naut_setting_type *type); + +/* Host callbacks the sandboxed script may invoke. Any may be NULL (the matching + * naut.* function then reports it is unavailable). `context` is passed back to + * each callback. */ +typedef struct { + naut_script_move_file_cb move_file; + naut_script_labels_cb labels; + naut_script_define_settings_cb define_settings; + naut_script_get_setting_cb get_setting; + void *context; +} naut_script_host; + typedef struct { uint64_t queued; uint64_t handled; @@ -20,12 +64,12 @@ typedef struct { } naut_script_stats; /* script_path is loaded before the worker starts. queue_capacity bounds copied - * events and must be non-zero. The VM owns no filesystem or process APIs. */ + * events and must be non-zero. The VM owns no filesystem or process APIs. `host` + * is copied; its callbacks are invoked from the script worker thread. */ naut_script *naut_script_create(naut_event_bus *events, const char *script_path, size_t queue_capacity, - naut_script_move_file_cb move_file, - void *move_context, + const naut_script_host *host, naut_err *error); void naut_script_destroy(naut_script *script); diff --git a/include/naut/storage.h b/include/naut/storage.h index af64fab..4e849d4 100644 --- a/include/naut/storage.h +++ b/include/naut/storage.h @@ -18,6 +18,12 @@ typedef struct naut_storage naut_storage; typedef struct { bool direct_io; bool preallocate; + /* Optional per-file path overrides, e.g. files moved out of `root` on a + * prior run. If non-NULL the array has one entry per file: where overrides[i] + * is non-NULL the file is opened at that path instead of `root`/<rel-path>, + * so a relocated file is picked up in place (no re-download, no placeholder + * recreated under `root`). A NULL entry uses the default location. */ + const char *const *overrides; } naut_storage_opts; /* Open (creating + preallocating) all files under `root`. */ @@ -33,11 +39,12 @@ naut_err naut_storage_write(naut_storage *s, int64_t offset, const void *buf, si naut_err naut_storage_read(naut_storage *s, int64_t offset, void *buf, size_t len); naut_err naut_storage_sync(naut_storage *s); -/* Move one completed file out to `dest` (rename, or copy+unlink across file - * systems). The caller must guarantee the file is complete — every piece - * overlapping it verified — so no further writes target it. After this the slot - * is "externalized": subsequent I/O to its region returns NAUT_ERR_RANGE. This - * is the storage half of the "move files as they finish" feature. */ +/* Move one file to `dest` (rename, or copy+unlink across file systems) and keep + * tracking it there: the slot's path is updated and its fd reopened, so the + * engine can still read/write/seed the file at its new location. Typically + * called the moment a file completes (the storage half of "move files as they + * finish"), but safe at any time. The owning process therefore never loses + * track of a moved file. Returns NAUT_ERR_IO if the move or reopen fails. */ naut_err naut_storage_relocate(naut_storage *s, size_t file_index, const char *dest); int64_t naut_storage_total(const naut_storage *s); diff --git a/include/naut/swarm.h b/include/naut/swarm.h new file mode 100644 index 0000000..dab0b3b --- /dev/null +++ b/include/naut/swarm.h @@ -0,0 +1,126 @@ +/* swarm.h - reusable multi-peer download driver. + * + * naut_swarm is a small CLI wrapper around this API. Long-running applications + * such as nautd own the worker thread and use callbacks for progress, control + * commands, cancellation, and event delivery. + */ +#ifndef NAUT_SWARM_H +#define NAUT_SWARM_H + +#include "naut/common.h" +#include "naut/event.h" +#include "naut/storage.h" + +#define NAUT_SWARM_MAX_PEER_STATS 64 +#define NAUT_SWARM_MAX_PIECE_STATS 4000 +#define NAUT_SWARM_MAX_TRACKER_STATS 32 +#define NAUT_SWARM_MAX_FILE_STATS 1024 + +typedef struct { + char ip[46]; + uint16_t port; + char client[64]; + char connection[16]; + char flags[16]; + double progress; + double relevance; + double dlspeed; + double upspeed; + uint64_t downloaded; + uint64_t uploaded; +} naut_swarm_peer_stats; + +typedef struct { + char url[256]; + int32_t tier; + char status[32]; + int32_t seeds; + int32_t peers; + int32_t leeches; + int32_t downloaded; + char message[128]; +} naut_swarm_tracker_stats; + +typedef struct { + char path[512]; + uint64_t size; + double progress; + int32_t priority; + double availability; +} naut_swarm_file_stats; + +typedef struct { + uint64_t total_bytes; + uint64_t bytes_done; + uint32_t total_pieces; + uint32_t pieces_done; + uint32_t peers_total; /* discovered endpoints */ + uint32_t peers_connecting; + uint32_t peers_active; + uint32_t peers_failed; + bool stalled; + double elapsed_seconds; + uint32_t peer_count; + naut_swarm_peer_stats peer_stats[NAUT_SWARM_MAX_PEER_STATS]; + uint32_t tracker_count; + naut_swarm_tracker_stats tracker_stats[NAUT_SWARM_MAX_TRACKER_STATS]; + uint32_t file_count; + naut_swarm_file_stats file_stats[NAUT_SWARM_MAX_FILE_STATS]; + uint32_t piece_state_count; + uint8_t piece_states[NAUT_SWARM_MAX_PIECE_STATS]; +} naut_swarm_stats; + +typedef void (*naut_swarm_progress_cb)(void *context, + const naut_swarm_stats *stats); + +/* Called on the swarm owner thread. The callback may safely operate on storage, + * including relocating completed files. */ +typedef void (*naut_swarm_control_cb)(void *context, naut_storage *storage); + +typedef bool (*naut_swarm_stop_cb)(void *context); + +/* Optional: return the desired engine-wide download cap in bytes/sec (0 = + * unlimited). Polled on the swarm owner thread; the engine limit is updated + * whenever the returned value changes. */ +typedef uint64_t (*naut_swarm_rate_cb)(void *context); + +/* Optional diagnostics. should_dump is polled on the swarm owner thread; when it + * returns true the swarm renders a full engine + piece-assembly state dump and + * hands the text to on_dump (also on the owner thread, where engine and download + * state can be read safely). Used by `nautctl dump` to investigate why a few + * pieces never finish downloading. */ +typedef bool (*naut_swarm_dump_cb)(void *context); +typedef void (*naut_swarm_dump_sink)(void *context, const char *text); + +/* A file's last known on-disk location, from a relocate on a prior run. Passed + * back in so the file is reopened in place instead of re-downloaded. */ +typedef struct { + uint32_t file_index; + const char *path; +} naut_swarm_file_location; + +typedef struct { + const char *source; /* .torrent path or magnet URI */ + const char *output_dir; + const char *const *peers; /* optional explicit ip:port endpoints */ + size_t num_peers; + const naut_swarm_file_location *locations; /* optional moved-file locations */ + size_t num_locations; + uint64_t torrent_id; + naut_event_bus *events; /* optional */ + bool keep_alive; /* retain completed storage until stopped */ + bool check_only; /* hash-verify existing data + report, then return; + * no peers, no engine, no download (paused recheck)*/ + naut_swarm_progress_cb on_progress; + naut_swarm_control_cb on_control; + naut_swarm_stop_cb should_stop; + naut_swarm_rate_cb download_rate; /* optional download throttle provider */ + naut_swarm_dump_cb should_dump; /* optional state-dump request poll */ + naut_swarm_dump_sink on_dump; /* optional rendered-dump sink */ + void *context; +} naut_swarm_config; + +/* Blocks until the torrent completes, is cancelled, or fails. */ +naut_err naut_swarm_run(const naut_swarm_config *config); + +#endif /* NAUT_SWARM_H */ diff --git a/include/naut/tracker.h b/include/naut/tracker.h index 3141e69..76ed8bb 100644 --- a/include/naut/tracker.h +++ b/include/naut/tracker.h @@ -1,9 +1,9 @@ -/* tracker.h — HTTP and UDP tracker clients (BEP-3/BEP-23, BEP-15). +/* tracker.h — HTTP and UDP tracker announce client. * - * Split into pure codec (URL building, bencode response parsing, UDP packet - * encode/decode — all unit-testable without a socket) and thin blocking fetch - * helpers used by the swarm app. HTTPS/TLS is deferred to a later transport - * backend; the built-ins in this phase are plaintext HTTP and UDP. + * The wire codec (query building, bencode/UDP packet encode+decode) lives in the + * sibling `torrent-tracker` library; the implementation here (src/discovery) + * owns only the socket glue. HTTPS/TLS is deferred to a later transport backend; + * the built-ins are plaintext HTTP and UDP. */ #ifndef NAUT_TRACKER_H #define NAUT_TRACKER_H @@ -29,6 +29,7 @@ typedef struct { typedef struct { int32_t interval; + int32_t min_interval; /* tracker's floor, 0 if not advertised */ int32_t seeders, leechers; /* -1 if absent */ naut_peer_addr *peers; size_t num_peers; @@ -37,25 +38,10 @@ typedef struct { void naut_tracker_response_free(naut_tracker_response *r); -/* --- HTTP --- */ /* Build the full announce GET URL (base?...params) with percent-encoded binary * info_hash/peer_id. Returns bytes written (excl NUL) or 0 on overflow. */ size_t naut_tracker_http_url(const char *base, const naut_announce_req *req, char *out, size_t outsz); -/* Parse a bencoded HTTP tracker response body (compact or dict peer list). */ -naut_err naut_tracker_parse_http(const uint8_t *body, size_t len, - naut_tracker_response *out); - -/* --- UDP (BEP-15): pure packet codec --- */ -#define NAUT_UDP_CONNECT_REQ_LEN 16 -#define NAUT_UDP_ANNOUNCE_REQ_LEN 98 -void naut_udp_build_connect(uint8_t out[16], uint32_t txid); -naut_err naut_udp_parse_connect(const uint8_t *in, size_t len, uint32_t txid, - uint64_t *connection_id); -void naut_udp_build_announce(uint8_t out[98], uint64_t connection_id, - uint32_t txid, const naut_announce_req *req); -naut_err naut_udp_parse_announce(const uint8_t *in, size_t len, uint32_t txid, - naut_tracker_response *out); /* --- live fetch helpers (blocking) --- */ /* HTTP GET the announce URL; fills out. Only http:// (no TLS yet). */ diff --git a/plugins/webui/webui.c b/plugins/webui/webui.c new file mode 100644 index 0000000..b3af7e0 --- /dev/null +++ b/plugins/webui/webui.c @@ -0,0 +1,3193 @@ +#include "naut/naut_plugin.h" +#include "naut/http_client.h" +#include "webui_store.h" + +#include <jansson.h> + +#include <arpa/inet.h> +#include <errno.h> +#include <fcntl.h> +#include <limits.h> +#include <regex.h> +#include <netinet/in.h> +#include <pthread.h> +#include <stdbool.h> +#include <stdint.h> +#include <stdatomic.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <sys/random.h> +#include <sys/socket.h> +#include <sys/stat.h> +#include <sys/time.h> +#include <time.h> +#include <unistd.h> + +#define DEFAULT_HOST "127.0.0.1" +#define DEFAULT_PORT 8080 +#define READ_LIMIT (8u << 20) +#define SESSION_COOKIE "naut_session" +#define SESSION_TTL_SECONDS (60 * 60 * 24 * 7) /* default; NAUT_SESSION_TTL */ +#define MAX_CONNECTIONS 128 +#define SPEED_SLOTS 256 +#define ETA_INFINITY 8640000 /* torrent-ui renders >= this as the infinity glyph */ + +/* Single-writer (sampler thread) running estimate of a torrent's download + * rate, derived from successive byte counts. */ +typedef struct { + uint64_t id; + uint64_t last_bytes; + double last_time; + double dlspeed; + bool used; +} speed_slot; + +typedef struct { + naut_host_api host; + char root[PATH_MAX]; + char host_name[64]; + char auth_user[64]; /* bootstrap admin name (for startup banner) */ + char auth_password[64]; /* generated bootstrap password (banner only) */ + webui_store *store; /* SQLite store: accounts, taxonomy, RSS */ + int port; + int listener; + bool generated_password; + atomic_bool stopping; + bool thread_started; + pthread_t thread; + + bool sampler_started; + pthread_t sampler; + + pthread_mutex_t conn_lock; + pthread_cond_t conn_cond; + size_t active_connections; + + /* Latest snapshot, published once per second by the sampler thread and + * shared by /api/snapshot, /api/torrents and every SSE stream. */ + pthread_mutex_t snap_lock; + pthread_cond_t snap_cond; + char *snapshot_str; + char *torrents_str; + uint64_t snap_seq; + + pthread_mutex_t speed_lock; + speed_slot speeds[SPEED_SLOTS]; + + /* Categories and tags are pure UI organization the engine knows nothing + * about, so the web layer owns them (in memory, like qBittorrent's own + * Web API does). assignments maps "<id>" -> {category, tags:[...]}. */ + pthread_mutex_t meta_lock; + json_t *categories; /* array of {name, savePath} */ + json_t *tags; /* array of tag name strings */ + json_t *assignments; /* object keyed by stringified torrent id */ + + /* RSS feeds, articles, auto-download rules and Torznab indexers all live in + * the webui database (g_webui.store). A background thread polls feeds; this + * lock/cond only guards the poller's wake-up, not any data. */ + pthread_mutex_t rss_lock; + pthread_t rss_thread; + bool rss_thread_started; + pthread_cond_t rss_cond; /* wakes the poller for an immediate refresh */ + bool rss_wake; /* set with rss_cond to force an early re-poll */ +} webui_state; + +typedef struct { + int fd; +} conn_arg; + +static webui_state g_webui; + +static double monotonic_seconds(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; +} + +static void log_msg(int level, const char *message) { + if (g_webui.host.log) + g_webui.host.log(g_webui.host.host_context, level, message); +} + +static bool send_all_fd(int fd, const char *buf, size_t len) { + while (len) { + ssize_t n = send(fd, buf, len, MSG_NOSIGNAL); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + if (n == 0) return false; + buf += n; + len -= (size_t)n; + } + return true; +} + +static void http_head_extra(int fd, int code, const char *status, + const char *ctype, size_t len, + const char *extra) { + char h[512]; + int n = snprintf(h, sizeof h, + "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\n" + "Cache-Control: no-cache\r\n%sConnection: close\r\n\r\n", + code, status, ctype, len, extra ? extra : ""); + if (n > 0) send_all_fd(fd, h, (size_t)n); +} + +static void http_head(int fd, int code, const char *status, + const char *ctype, size_t len) { + http_head_extra(fd, code, status, ctype, len, NULL); +} + +static void http_text(int fd, int code, const char *status, const char *body) { + if (!body) body = ""; + http_head(fd, code, status, "text/plain; charset=utf-8", strlen(body)); + send_all_fd(fd, body, strlen(body)); +} + +static void http_json(int fd, int code, json_t *json) { + char *txt = json ? json_dumps(json, JSON_COMPACT | JSON_ENCODE_ANY) : NULL; + if (!txt) { + http_text(fd, 500, "Internal Server Error", "json encode failed"); + return; + } + http_head(fd, code, code == 200 ? "OK" : "Error", + "application/json; charset=utf-8", strlen(txt)); + send_all_fd(fd, txt, strlen(txt)); + free(txt); +} + +static void http_json_extra(int fd, int code, json_t *json, const char *extra) { + char *txt = json ? json_dumps(json, JSON_COMPACT | JSON_ENCODE_ANY) : NULL; + if (!txt) { + http_text(fd, 500, "Internal Server Error", "json encode failed"); + return; + } + http_head_extra(fd, code, code == 200 ? "OK" : "Error", + "application/json; charset=utf-8", strlen(txt), extra); + send_all_fd(fd, txt, strlen(txt)); + free(txt); +} + +/* Serve an already-serialized JSON string under one lock copy. */ +static void http_json_str(int fd, const char *json, const char *fallback) { + const char *body = json ? json : fallback; + http_head(fd, 200, "OK", "application/json; charset=utf-8", strlen(body)); + send_all_fd(fd, body, strlen(body)); +} + +static const char *mime_type(const char *path) { + const char *dot = strrchr(path, '.'); + if (!dot) return "application/octet-stream"; + if (strcmp(dot, ".html") == 0) return "text/html; charset=utf-8"; + if (strcmp(dot, ".js") == 0) return "text/javascript; charset=utf-8"; + if (strcmp(dot, ".css") == 0) return "text/css; charset=utf-8"; + if (strcmp(dot, ".json") == 0) return "application/json; charset=utf-8"; + if (strcmp(dot, ".svg") == 0) return "image/svg+xml"; + if (strcmp(dot, ".ico") == 0) return "image/x-icon"; + return "application/octet-stream"; +} + +static void strip_query(char *path) { + char *q = strchr(path, '?'); + if (q) *q = 0; + char *hash = strchr(path, '#'); + if (hash) *hash = 0; +} + +/* Percent-decode a URL query component in place-style into `out`. */ +static void url_decode(char *out, size_t outsz, const char *in, size_t n) { + size_t o = 0; + for (size_t i = 0; i < n && o + 1 < outsz; i++) { + if (in[i] == '%' && i + 2 < n) { + char hex[3] = { in[i+1], in[i+2], 0 }; + char *e; long v = strtol(hex, &e, 16); + if (e == hex + 2) { out[o++] = (char)v; i += 2; continue; } + } + out[o++] = (in[i] == '+') ? ' ' : in[i]; + } + out[o] = 0; +} + +/* Extract a query parameter from a "k=v&k2=v2" string (the part after '?'), + * URL-decoding the value into `out`. Returns true if the key was present. */ +static bool query_get(const char *query, const char *key, char *out, size_t outsz) { + if (!query) { if (outsz) out[0] = 0; return false; } + size_t klen = strlen(key); + for (const char *p = query; p && *p; ) { + const char *amp = strchr(p, '&'); + size_t seg = amp ? (size_t)(amp - p) : strlen(p); + if (seg > klen && p[klen] == '=' && strncmp(p, key, klen) == 0) { + url_decode(out, outsz, p + klen + 1, seg - klen - 1); + return true; + } + p = amp ? amp + 1 : NULL; + } + if (outsz) out[0] = 0; + return false; +} + +/* Cryptographically strong hex. Fails closed: if the kernel CSPRNG is + * unavailable we refuse rather than fall back to predictable bytes (these + * feed session tokens). */ +static bool random_hex(char *out, size_t out_size, size_t bytes) { + static const char hex[] = "0123456789abcdef"; + if (out_size < bytes * 2 + 1) return false; + unsigned char buf[64]; + if (bytes > sizeof buf) return false; + size_t got = 0; + while (got < bytes) { + ssize_t n = getrandom(buf + got, bytes - got, 0); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + got += (size_t)n; + } + for (size_t i = 0; i < bytes; i++) { + out[i * 2] = hex[buf[i] >> 4]; + out[i * 2 + 1] = hex[buf[i] & 15]; + } + out[bytes * 2] = 0; + return true; +} + +/* mkdir -p for the account DB's parent directory (0700). */ +static int mkdir_p(const char *path, mode_t mode) { + char tmp[PATH_MAX]; + size_t len = snprintf(tmp, sizeof tmp, "%s", path); + if (len == 0 || len >= sizeof tmp) return -1; + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = 0; + if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1; + *p = '/'; + } + } + if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1; + return 0; +} + +/* Resolve the account database path: NAUT_WEBUI_DB, else an XDG/HOME default + * under naut/. Creates the parent directory. */ +static bool resolve_auth_db_path(char *out, size_t n) { + const char *env = getenv("NAUT_WEBUI_DB"); + if (env && *env) return (size_t)snprintf(out, n, "%s", env) < n; + const char *xdg = getenv("XDG_DATA_HOME"); + const char *home = getenv("HOME"); + char dir[PATH_MAX]; + if (xdg && *xdg) snprintf(dir, sizeof dir, "%s/naut", xdg); + else if (home && *home) snprintf(dir, sizeof dir, "%s/.local/share/naut", home); + else return false; + if (mkdir_p(dir, 0700) != 0) return false; + return (size_t)snprintf(out, n, "%s/webui.db", dir) < n; +} + +/* Open the account store and, on first run (no accounts), bootstrap an admin + * from NAUT_AUTH_USER/PASSWORD or a generated password (logged once). */ +static void init_auth(void) { + char db_path[PATH_MAX]; + if (!resolve_auth_db_path(db_path, sizeof db_path)) { + log_msg(0, "webui: cannot resolve account DB path; set NAUT_WEBUI_DB"); + return; + } + g_webui.store = webui_store_open(db_path); + if (!g_webui.store) { + log_msg(0, "webui: failed to open account database"); + return; + } + const char *user = getenv("NAUT_AUTH_USER"); + if (!user || !*user) user = getenv("NAUT_USER"); + if (!user || !*user) user = "admin"; + snprintf(g_webui.auth_user, sizeof g_webui.auth_user, "%s", user); + + if (webui_store_user_count(g_webui.store) > 0) return; /* already set up */ + + /* No accounts yet — create the initial admin. */ + const char *password = getenv("NAUT_AUTH_PASSWORD"); + if (!password || !*password) password = getenv("NAUT_PASSWORD"); + if (password && *password) { + g_webui.generated_password = false; + } else if (random_hex(g_webui.auth_password, sizeof g_webui.auth_password, 9)) { + password = g_webui.auth_password; + g_webui.generated_password = true; + } else { + log_msg(0, "webui: no CSPRNG; set NAUT_AUTH_PASSWORD to create the admin"); + return; + } + if (!webui_store_create_user(g_webui.store, user, password, "admin")) + log_msg(0, "webui: failed to create the initial admin account"); +} + +static const char *header_value(const char *headers, const char *end, + const char *name) { + size_t name_len = strlen(name); + for (const char *p = headers; p && p < end;) { + const char *line_end = memmem(p, (size_t)(end - p), "\r\n", 2); + if (!line_end) line_end = end; + if ((size_t)(line_end - p) > name_len && + strncasecmp(p, name, name_len) == 0 && p[name_len] == ':') { + const char *value = p + name_len + 1; + while (value < line_end && (*value == ' ' || *value == '\t')) + value++; + return value; + } + p = line_end + 2; + } + return NULL; +} + +static bool cookie_token(const char *headers, const char *end, + char *out, size_t out_size) { + const char *cookie = header_value(headers, end, "cookie"); + if (!cookie) return false; + const char *line_end = memmem(cookie, (size_t)(end - cookie), "\r\n", 2); + if (!line_end) line_end = end; + const char *p = cookie; + size_t key_len = strlen(SESSION_COOKIE); + while (p < line_end) { + while (p < line_end && (*p == ' ' || *p == ';')) p++; + if ((size_t)(line_end - p) > key_len && + strncmp(p, SESSION_COOKIE, key_len) == 0 && + p[key_len] == '=') { + p += key_len + 1; + size_t len = strcspn(p, "; \r\n"); + if (len >= out_size) len = out_size - 1; + memcpy(out, p, len); + out[len] = 0; + return true; + } + p = memchr(p, ';', (size_t)(line_end - p)); + if (!p) break; + } + return false; +} + +/* Look up the session for this request. On a live session, refreshes its TTL + * and (optionally) copies the account's username and role. Returns true if a + * valid session was found. */ +/* Session lifetime in seconds (sliding). Override with NAUT_SESSION_TTL. */ +static long session_ttl(void) { + const char *env = getenv("NAUT_SESSION_TTL"); + if (env && *env) { + char *e = NULL; + long v = strtol(env, &e, 10); + if (e && e != env && !*e && v > 0) return v; + } + return SESSION_TTL_SECONDS; +} + +/* Don't rewrite the session row on every request; only re-extend the sliding + * expiry once it has advanced by more than this. */ +#define SESSION_REFRESH_THRESHOLD 3600 + +static bool current_identity(const char *headers, const char *end, + char *user, size_t user_sz, + char *role, size_t role_sz) { + char token[96]; + if (!cookie_token(headers, end, token, sizeof token)) return false; + if (!g_webui.store) return false; + char u[64] = {0}, r[16] = {0}; + long expires = 0; + if (!webui_store_session_lookup(g_webui.store, token, u, sizeof u, + r, sizeof r, &expires)) + return false; + long now = (long)time(NULL); + if (expires <= now) { /* expired: clean it up */ + webui_store_session_delete(g_webui.store, token); + return false; + } + long fresh = now + session_ttl(); /* sliding window, throttled */ + if (fresh - expires > SESSION_REFRESH_THRESHOLD) + webui_store_session_touch(g_webui.store, token, fresh); + if (user) snprintf(user, user_sz, "%s", u); + if (role) snprintf(role, role_sz, "%s", r); + return true; +} + +static bool create_session(const char *user, const char *role, + char *out, size_t out_size) { + char token[96]; + if (!g_webui.store || !random_hex(token, sizeof token, 24)) return false; + long expires = (long)time(NULL) + session_ttl(); + if (!webui_store_session_create(g_webui.store, token, user, role, expires)) + return false; + snprintf(out, out_size, "%s", token); + return true; +} + +/* Invalidate every session belonging to `user` (after delete / password reset + * by an admin). */ +static void drop_user_sessions(const char *user) { + if (g_webui.store) webui_store_sessions_delete_user(g_webui.store, user); +} + +static void clear_session(const char *headers, const char *end) { + char token[96]; + if (g_webui.store && cookie_token(headers, end, token, sizeof token)) + webui_store_session_delete(g_webui.store, token); +} + +static bool bad_static_path(const char *path) { + return strstr(path, "..") || strchr(path, '\\'); +} + +static bool join_root_path(char *out, size_t out_size, const char *suffix) { + int n = snprintf(out, out_size, "%s%s", g_webui.root, suffix); + return n > 0 && (size_t)n < out_size; +} + +static bool serve_file(int fd, const char *request_path) { + char clean[PATH_MAX]; + snprintf(clean, sizeof clean, "%s", request_path && *request_path + ? request_path : "/"); + strip_query(clean); + if (strcmp(clean, "/") == 0) snprintf(clean, sizeof clean, "/index.html"); + if (bad_static_path(clean)) { + http_text(fd, 403, "Forbidden", "forbidden"); + return true; + } + + char path[PATH_MAX]; + if (!join_root_path(path, sizeof path, clean)) { + http_text(fd, 414, "URI Too Long", "path too long"); + return true; + } + int file = open(path, O_RDONLY); + if (file < 0 && strchr(clean + 1, '/') == NULL) { + if (!join_root_path(path, sizeof path, "/index.html")) { + http_text(fd, 500, "Internal Server Error", "root too long"); + return true; + } + file = open(path, O_RDONLY); + } + if (file < 0) return false; + struct stat st; + if (fstat(file, &st) != 0 || st.st_size < 0) { + close(file); + http_text(fd, 500, "Internal Server Error", "stat failed"); + return true; + } + http_head(fd, 200, "OK", mime_type(path), (size_t)st.st_size); + char buf[16384]; + for (;;) { + ssize_t n = read(file, buf, sizeof buf); + if (n < 0) { + if (errno == EINTR) continue; + break; + } + if (n == 0) break; + if (!send_all_fd(fd, buf, (size_t)n)) break; + } + close(file); + return true; +} + +static json_t *rpc_call_json_err(const char *method, json_t *params, + naut_err *out_err) { + if (out_err) *out_err = NAUT_ERR_INVAL; + if (!g_webui.host.call_rpc) return NULL; + char *request = json_dumps(params ? params : json_null(), + JSON_COMPACT | JSON_ENCODE_ANY); + if (!request) return NULL; + char *response = NULL; + naut_err error = g_webui.host.call_rpc(g_webui.host.host_context, + method, request, &response); + free(request); + if (out_err) *out_err = error; + if (error != NAUT_OK || !response) { + free(response); + return NULL; + } + json_error_t json_error; + json_t *json = json_loads(response, JSON_REJECT_DUPLICATES | + JSON_DECODE_ANY, &json_error); + free(response); + return json; +} + +static json_t *rpc_call_json(const char *method, json_t *params) { + return rpc_call_json_err(method, params, NULL); +} + +static const char *json_string_or(const json_t *obj, const char *key, + const char *fallback) { + const char *value = json_string_value(json_object_get(obj, key)); + return value ? value : fallback; +} + +static uint64_t json_u64(const json_t *obj, const char *key) { + json_t *value = json_object_get(obj, key); + return json_is_integer(value) && json_integer_value(value) > 0 + ? (uint64_t)json_integer_value(value) : 0; +} + +static int64_t json_i64_or(const json_t *obj, const char *key, + int64_t fallback) { + json_t *value = json_object_get(obj, key); + return json_is_integer(value) ? (int64_t)json_integer_value(value) + : fallback; +} + +static double json_number_or(const json_t *obj, const char *key, + double fallback) { + json_t *value = json_object_get(obj, key); + return json_is_number(value) ? json_number_value(value) : fallback; +} + +static const char *base_name(const char *path) { + if (!path || !*path) return "torrent"; + const char *slash = strrchr(path, '/'); + const char *name = slash ? slash + 1 : path; + return *name ? name : "torrent"; +} + +static char *torrent_name(const json_t *torrent) { + /* The daemon persists the display name captured at add time; prefer it so + * restored torrents (where the in-process name cache is empty) read right + * instead of falling back to an upload path's basename. */ + const char *saved = json_string_value(json_object_get(torrent, "name")); + if (saved && *saved) return strdup(saved); + const char *source = json_string_or(torrent, "source", "torrent"); + if (strncmp(source, "magnet:", 7) == 0) { + const char *dn = strstr(source, "dn="); + if (dn) { + dn += 3; + size_t len = strcspn(dn, "&"); + char *name = malloc(len + 1); + if (!name) return NULL; + memcpy(name, dn, len); + name[len] = 0; + return name; + } + } + return strdup(base_name(source)); +} + +static const char *ui_state(const char *state, double progress) { + if (!state) return "stalledDL"; + if (strcmp(state, "complete") == 0) return "uploading"; + if (strcmp(state, "paused") == 0) + return progress >= 1.0 ? "pausedUP" : "pausedDL"; + if (strcmp(state, "stopped") == 0) + return progress >= 1.0 ? "pausedUP" : "pausedDL"; + if (strcmp(state, "stopping") == 0) return "pausedDL"; + if (strcmp(state, "checking") == 0) + return progress >= 1.0 ? "checkingUP" : "checkingDL"; + if (strcmp(state, "stalled") == 0) + return progress >= 1.0 ? "stalledUP" : "stalledDL"; + if (strcmp(state, "queued") == 0) return "queuedDL"; + if (strcmp(state, "error") == 0) return "error"; + return "downloading"; +} + +/* ---- single-writer download-rate estimate keyed by torrent id ---- */ + +static double speed_sample(uint64_t id, uint64_t bytes) { + double now = monotonic_seconds(); + double result = 0.0; + pthread_mutex_lock(&g_webui.speed_lock); + speed_slot *slot = NULL, *spare = NULL; + for (size_t i = 0; i < SPEED_SLOTS; i++) { + speed_slot *s = &g_webui.speeds[i]; + if (s->used && s->id == id) { slot = s; break; } + if (!s->used && !spare) spare = s; + } + if (!slot) { + if (!spare) { + /* table full: evict least-recently-updated */ + spare = &g_webui.speeds[0]; + for (size_t i = 1; i < SPEED_SLOTS; i++) + if (g_webui.speeds[i].last_time < spare->last_time) + spare = &g_webui.speeds[i]; + } + slot = spare; + slot->used = true; + slot->id = id; + slot->last_bytes = bytes; + slot->last_time = now; + slot->dlspeed = 0.0; + pthread_mutex_unlock(&g_webui.speed_lock); + return 0.0; + } + double dt = now - slot->last_time; + if (dt > 0.0) { + double delta = bytes >= slot->last_bytes + ? (double)(bytes - slot->last_bytes) : 0.0; + double inst = delta / dt; + slot->dlspeed = slot->dlspeed * 0.6 + inst * 0.4; + if (slot->dlspeed < 0.0) slot->dlspeed = 0.0; + slot->last_bytes = bytes; + slot->last_time = now; + } + result = slot->dlspeed; + pthread_mutex_unlock(&g_webui.speed_lock); + return result; +} + +static double speed_peek(uint64_t id) { + double result = 0.0; + pthread_mutex_lock(&g_webui.speed_lock); + for (size_t i = 0; i < SPEED_SLOTS; i++) + if (g_webui.speeds[i].used && g_webui.speeds[i].id == id) { + result = g_webui.speeds[i].dlspeed; + break; + } + pthread_mutex_unlock(&g_webui.speed_lock); + return result; +} + +/* Drop slots for ids no longer present so a long-lived server doesn't hand a + * stale rate to a recycled id. */ +static void speed_retain(json_t *torrents) { + pthread_mutex_lock(&g_webui.speed_lock); + for (size_t i = 0; i < SPEED_SLOTS; i++) { + speed_slot *s = &g_webui.speeds[i]; + if (!s->used) continue; + bool found = false; + size_t index; + json_t *torrent; + json_array_foreach(torrents, index, torrent) + if (json_u64(torrent, "torrent_id") == s->id) { found = true; break; } + if (!found) s->used = false; + } + pthread_mutex_unlock(&g_webui.speed_lock); +} + +static json_t *tracker_hosts(json_t *trackers) { + json_t *hosts = json_array(); + if (!hosts || !json_is_array(trackers)) return hosts; + size_t index; + json_t *tracker; + json_array_foreach(trackers, index, tracker) { + const char *url = json_string_value(json_object_get(tracker, "url")); + if (!url || strstr(url, "**")) continue; + const char *start = strstr(url, "://"); + start = start ? start + 3 : url; + size_t len = strcspn(start, "/"); + char host[256]; + snprintf(host, sizeof host, "%.*s", (int)len, start); + json_array_append_new(hosts, json_string(host)); + } + return hosts; +} + +static bool tracker_host(char out[256], const char *url) { + if (!url || strstr(url, "**")) return false; + const char *start = strstr(url, "://"); + start = start ? start + 3 : url; + size_t len = strcspn(start, "/"); + if (len == 0 || len >= 256) return false; + snprintf(out, 256, "%.*s", (int)len, start); + return true; +} + +static json_t *tracker_summary(json_t *torrents) { + json_t *summary = json_array(); + if (!summary || !json_is_array(torrents)) return summary; + size_t tindex; + json_t *torrent; + json_array_foreach(torrents, tindex, torrent) { + json_t *trackers = json_object_get(torrent, "trackers"); + if (!json_is_array(trackers)) continue; + size_t index; + json_t *tracker; + json_array_foreach(trackers, index, tracker) { + int64_t tier = json_integer_value(json_object_get(tracker, "tier")); + if (tier < 0) continue; + char host[256]; + if (!tracker_host(host, json_string_value( + json_object_get(tracker, "url")))) + continue; + bool found = false; + size_t hindex; + json_t *entry; + json_array_foreach(summary, hindex, entry) { + if (strcmp(json_string_or(entry, "host", ""), host) != 0) + continue; + json_int_t count = + json_integer_value(json_object_get(entry, "count")); + json_object_set_new(entry, "count", json_integer(count + 1)); + found = true; + break; + } + if (!found) + json_array_append_new(summary, json_pack( + "{s:s,s:i}", "host", host, "count", 1)); + } + } + return summary; +} + +static json_t *map_peer_list(json_t *torrent) { + json_t *out = json_array(); + json_t *peers = json_object_get(torrent, "peer_list"); + if (!out || !json_is_array(peers)) return out; + size_t index; + json_t *peer; + json_array_foreach(peers, index, peer) { + if (!json_is_object(peer)) continue; + json_t *item = json_pack( + "{s:s,s:s,s:i,s:s,s:s,s:s,s:f,s:i,s:i,s:I,s:I,s:f}", + "country", "", + "ip", json_string_or(peer, "ip", ""), + "port", (int)json_u64(peer, "port"), + "client", json_string_or(peer, "client", "Unknown"), + "connection", json_string_or(peer, "connection", "TCP"), + "flags", json_string_or(peer, "flags", ""), + "progress", json_number_or(peer, "progress", 0.0), + "dlspeed", (int)json_u64(peer, "dlspeed"), + "upspeed", (int)json_u64(peer, "upspeed"), + "downloaded", (json_int_t)json_u64(peer, "downloaded"), + "uploaded", (json_int_t)json_u64(peer, "uploaded"), + "relevance", json_number_or(peer, "relevance", 0.0)); + if (item) json_array_append_new(out, item); + } + return out; +} + +static double torrent_progress_ratio(json_t *torrent, uint64_t *done_out, + uint64_t *total_out) { + uint64_t done = json_u64(torrent, "bytes_done"); + uint64_t total = json_u64(torrent, "total_bytes"); + uint64_t pieces = json_u64(torrent, "total_pieces"); + uint64_t pieces_done = json_u64(torrent, "pieces_done"); + double progress = total ? (double)done / (double)total : + (pieces ? (double)pieces_done / (double)pieces : 0.0); + if (progress > 1.0) progress = 1.0; + if (done_out) *done_out = done; + if (total_out) *total_out = total; + return progress; +} + +static json_int_t compute_eta(uint64_t done, uint64_t total, double dlspeed) { + if (total > done && dlspeed >= 1.0) + return (json_int_t)((double)(total - done) / dlspeed); + return ETA_INFINITY; +} + +/* ---- category / tag store (web-layer owned, guarded by meta_lock) ---- */ + +static int find_category(const char *name) { + size_t index; + json_t *value; + json_array_foreach(g_webui.categories, index, value) + if (strcmp(json_string_or(value, "name", ""), name) == 0) + return (int)index; + return -1; +} + +static int find_tag(const char *name) { + size_t index; + json_t *value; + json_array_foreach(g_webui.tags, index, value) + if (strcmp(json_string_value(value), name) == 0) return (int)index; + return -1; +} + +static void store_add_category(const char *name, const char *save_path) { + pthread_mutex_lock(&g_webui.meta_lock); + if (find_category(name) < 0) + json_array_append_new(g_webui.categories, json_pack( + "{s:s,s:s}", "name", name, "savePath", save_path ? save_path : "")); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static void store_remove_category(const char *name) { + pthread_mutex_lock(&g_webui.meta_lock); + int index = find_category(name); + if (index >= 0) json_array_remove(g_webui.categories, (size_t)index); + /* drop the category from any torrent that had it */ + const char *key; + json_t *entry; + json_object_foreach(g_webui.assignments, key, entry) + if (strcmp(json_string_or(entry, "category", ""), name) == 0) + json_object_set_new(entry, "category", json_string("")); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static void store_add_tag(const char *name) { + pthread_mutex_lock(&g_webui.meta_lock); + if (find_tag(name) < 0) + json_array_append_new(g_webui.tags, json_string(name)); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static void store_remove_tag(const char *name) { + pthread_mutex_lock(&g_webui.meta_lock); + int index = find_tag(name); + if (index >= 0) json_array_remove(g_webui.tags, (size_t)index); + const char *key; + json_t *entry; + json_object_foreach(g_webui.assignments, key, entry) { + json_t *tags = json_object_get(entry, "tags"); + size_t i = 0; + while (i < json_array_size(tags)) { + if (strcmp(json_string_value(json_array_get(tags, i)), name) == 0) + json_array_remove(tags, i); + else + i++; + } + } + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static json_t *assignment_locked(uint64_t id, bool create) { + char key[32]; + snprintf(key, sizeof key, "%llu", (unsigned long long)id); + json_t *entry = json_object_get(g_webui.assignments, key); + if (!entry && create) { + entry = json_pack("{s:s,s:o}", "category", "", "tags", json_array()); + json_object_set_new(g_webui.assignments, key, entry); + } + return entry; +} + +static void store_set_category(uint64_t id, const char *category) { + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = assignment_locked(id, true); + if (entry) + json_object_set_new(entry, "category", + json_string(category ? category : "")); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static void store_update_tags(uint64_t id, json_t *tags, bool add) { + if (!json_is_array(tags)) return; + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = assignment_locked(id, true); + json_t *have = entry ? json_object_get(entry, "tags") : NULL; + if (have) { + size_t index; + json_t *value; + json_array_foreach(tags, index, value) { + const char *name = json_string_value(value); + if (!name) continue; + size_t pos = 0; + bool present = false; + for (; pos < json_array_size(have); pos++) + if (strcmp(json_string_value(json_array_get(have, pos)), + name) == 0) { present = true; break; } + if (add && !present) + json_array_append_new(have, json_string(name)); + else if (!add && present) + json_array_remove(have, pos); + } + } + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static void store_set_name(uint64_t id, const char *name) { + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = assignment_locked(id, true); + if (entry) json_object_set_new(entry, "name", json_string(name)); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static bool parse_id(const char *text, uint64_t *id); + +/* Mirror a torrent's category + tags into the daemon (which persists them and + * exposes the flattened set to Lua via naut.get_labels). The web layer is the + * editing surface; the daemon is the source of truth. Snapshots the assignment + * under meta_lock, then RPCs without it held. */ +static void webui_sync_labels(uint64_t id) { + char key[32]; + snprintf(key, sizeof key, "%llu", (unsigned long long)id); + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = json_object_get(g_webui.assignments, key); + char *category = strdup(entry ? json_string_or(entry, "category", "") : ""); + json_t *tags_src = entry ? json_object_get(entry, "tags") : NULL; + json_t *tags = tags_src ? json_deep_copy(tags_src) : json_array(); + pthread_mutex_unlock(&g_webui.meta_lock); + + json_t *params = json_pack("{s:I,s:s,s:o}", "torrent_id", (json_int_t)id, + "category", category ? category : "", + "tags", tags); + free(category); + if (!params) { json_decref(tags); return; } + json_t *reply = rpc_call_json("set_labels", params); + json_decref(params); + if (reply) json_decref(reply); +} + +/* Re-push every torrent's labels (after a global category/tag removal that can + * touch many assignments at once). */ +static void webui_sync_all_labels(void) { + pthread_mutex_lock(&g_webui.meta_lock); + size_t n = json_object_size(g_webui.assignments); + uint64_t *ids = n ? malloc(n * sizeof *ids) : NULL; + size_t count = 0; + if (ids) { + const char *key; + json_t *entry; + json_object_foreach(g_webui.assignments, key, entry) { + uint64_t id = 0; + if (parse_id(key, &id)) ids[count++] = id; + } + } + pthread_mutex_unlock(&g_webui.meta_lock); + for (size_t i = 0; i < count; i++) webui_sync_labels(ids[i]); + free(ids); +} + +/* Persist the full category + tag lists (including unassigned ones) to the + * daemon so they survive restarts. */ +/* Persist the category + tag lists to the web-UI's own database. */ +static void webui_sync_taxonomy(void) { + if (!g_webui.store) return; + pthread_mutex_lock(&g_webui.meta_lock); + json_t *cats = json_deep_copy(g_webui.categories); + json_t *tags = json_deep_copy(g_webui.tags); + pthread_mutex_unlock(&g_webui.meta_lock); + if (cats) { webui_store_save_categories(g_webui.store, cats); json_decref(cats); } + if (tags) { webui_store_save_tags(g_webui.store, tags); json_decref(tags); } +} + +/* Seed the category + tag lists from the database at startup. */ +static void webui_load_taxonomy(void) { + if (!g_webui.store) return; + json_t *cats = json_array(), *tags = json_array(); + bool ok_c = webui_store_load_categories(g_webui.store, cats); + bool ok_t = webui_store_load_tags(g_webui.store, tags); + pthread_mutex_lock(&g_webui.meta_lock); + if (ok_c) { json_decref(g_webui.categories); g_webui.categories = cats; } + else json_decref(cats); + if (ok_t) { json_decref(g_webui.tags); g_webui.tags = tags; } + else json_decref(tags); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static bool store_get_name(uint64_t id, char *out, size_t out_size) { + bool found = false; + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = assignment_locked(id, false); + const char *name = entry ? json_string_value(json_object_get(entry, "name")) + : NULL; + if (name && *name) { + snprintf(out, out_size, "%s", name); + found = true; + } + pthread_mutex_unlock(&g_webui.meta_lock); + return found; +} + +static void store_forget(uint64_t id) { + char key[32]; + snprintf(key, sizeof key, "%llu", (unsigned long long)id); + pthread_mutex_lock(&g_webui.meta_lock); + json_object_del(g_webui.assignments, key); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +/* On first sight of a torrent (e.g. right after a restart, when the in-memory + * store is empty), seed its assignment + the global category/tag lists from the + * daemon's persisted category/tags. Only creates a missing entry, so live web + * edits are never clobbered. */ +static void seed_assignment_from_daemon(uint64_t id, json_t *torrent) { + const char *category = json_string_or(torrent, "category", ""); + json_t *tags = json_object_get(torrent, "tags"); + char key[32]; + snprintf(key, sizeof key, "%llu", (unsigned long long)id); + pthread_mutex_lock(&g_webui.meta_lock); + if (!json_object_get(g_webui.assignments, key)) { + json_t *entry = json_pack( + "{s:s,s:o}", "category", category, + "tags", json_is_array(tags) ? json_deep_copy(tags) : json_array()); + if (entry) json_object_set_new(g_webui.assignments, key, entry); + if (category && *category && find_category(category) < 0) + json_array_append_new(g_webui.categories, json_pack( + "{s:s,s:s}", "name", category, "savePath", "")); + size_t i; + json_t *v; + json_array_foreach(tags, i, v) { + const char *t = json_string_value(v); + if (t && *t && find_tag(t) < 0) + json_array_append_new(g_webui.tags, json_string(t)); + } + } + pthread_mutex_unlock(&g_webui.meta_lock); +} + +/* Fill in category + tags for a torrent from the assignment store. */ +static void apply_assignment(json_t *out, uint64_t id) { + pthread_mutex_lock(&g_webui.meta_lock); + json_t *entry = assignment_locked(id, false); + const char *category = entry ? json_string_or(entry, "category", "") : ""; + json_t *tags = entry ? json_object_get(entry, "tags") : NULL; + json_object_set_new(out, "category", json_string(category)); + json_object_set_new(out, "tags", + tags ? json_deep_copy(tags) : json_array()); + pthread_mutex_unlock(&g_webui.meta_lock); +} + +static json_t *map_torrent(json_t *torrent, bool detail, double dlspeed) { + uint64_t id = json_u64(torrent, "torrent_id"); + uint64_t done = 0, total = 0; + double progress = torrent_progress_ratio(torrent, &done, &total); + uint64_t pieces = json_u64(torrent, "total_pieces"); + uint64_t pieces_done = json_u64(torrent, "pieces_done"); + + char hash[32]; + snprintf(hash, sizeof hash, "%llu", (unsigned long long)id); + char *name = torrent_name(torrent); + if (!name) return NULL; + /* Prefer the display name the UI captured at add time over the daemon's + * temp upload path. */ + char override[256]; + if (store_get_name(id, override, sizeof override)) { + char *better = strdup(override); + if (better) { free(name); name = better; } + } + bool force_start = + json_boolean_value(json_object_get(torrent, "force_start")); + const char *state = ui_state(json_string_value(json_object_get(torrent, + "state")), + progress); + if (force_start && strcmp(state, "downloading") == 0) + state = progress >= 1.0 ? "forcedUP" : "forcedDL"; + + json_t *trackers = NULL; + json_t *files = NULL; + json_t *peers_list = NULL; + json_t *hosts = NULL; + json_t *source_trackers = json_object_get(torrent, "trackers"); + json_t *source_files = json_object_get(torrent, "files"); + hosts = tracker_hosts(source_trackers); + if (detail) { + trackers = json_is_array(source_trackers) + ? json_deep_copy(source_trackers) : json_array(); + files = json_is_array(source_files) + ? json_deep_copy(source_files) : json_array(); + if (files && json_array_size(files) == 0 && total > 0) + json_array_append_new(files, json_pack( + "{s:s,s:I,s:f,s:i,s:f}", "name", name, + "size", (json_int_t)total, "progress", progress, + "priority", 1, "availability", 1.0)); + peers_list = map_peer_list(torrent); + } + + uint64_t discovered = json_u64(torrent, "peers_discovered"); + const char *output = json_string_or(torrent, "output", ""); + /* Built field-by-field on purpose: a single 30-key json_pack drifts out of + * sync with its argument list silently and then crashes on a type mismatch. */ + json_t *out = json_object(); + if (!out) { + free(name); + json_decref(hosts); + if (detail) { + json_decref(trackers); + json_decref(files); + json_decref(peers_list); + } + return NULL; + } + json_object_set_new(out, "hash", json_string(hash)); + json_object_set_new(out, "name", json_string(name)); + json_object_set_new(out, "size", json_integer((json_int_t)total)); + json_object_set_new(out, "progress", json_real(progress)); + json_object_set_new(out, "dlspeed", json_integer((json_int_t)dlspeed)); + json_object_set_new(out, "upspeed", json_integer(0)); + json_object_set_new(out, "eta", json_integer(compute_eta(done, total, dlspeed))); + json_object_set_new(out, "seeds", + json_integer((json_int_t)json_u64(torrent, "peers"))); + json_object_set_new(out, "seedsTotal", json_integer((json_int_t)discovered)); + json_object_set_new(out, "peers", + json_integer((json_int_t)json_u64(torrent, "peers_connecting"))); + json_object_set_new(out, "peersTotal", json_integer((json_int_t)discovered)); + json_object_set_new(out, "ratio", json_real(0.0)); + json_object_set_new(out, "savePath", json_string(output)); + json_object_set_new(out, "addedOn", json_integer(0)); + json_object_set_new(out, "completionOn", + json_integer(progress >= 1.0 ? 0 : -1)); + json_object_set_new(out, "lastActivity", json_integer(0)); + json_object_set_new(out, "downloaded", json_integer((json_int_t)done)); + json_object_set_new(out, "uploaded", json_integer(0)); + json_object_set_new(out, "availability", json_real(1.0)); + int64_t queue_pos = json_i64_or(torrent, "queue_pos", 0); + json_object_set_new(out, "priority", + json_integer((json_int_t)(queue_pos < 0 + ? 1 : queue_pos + 1))); + json_object_set_new(out, "queuePos", json_integer((json_int_t)queue_pos)); + json_object_set_new(out, "trackerHosts", hosts ? hosts : json_array()); + json_object_set_new(out, "seqDl", json_false()); + json_object_set_new(out, "superSeeding", json_false()); + json_object_set_new(out, "forceStart", json_boolean(force_start)); + json_object_set_new(out, "timeActive", + json_integer((json_int_t)json_u64(torrent, "elapsed_seconds"))); + json_object_set_new(out, "pieceSize", + json_integer(pieces ? (json_int_t)(total / pieces) : 0)); + json_object_set_new(out, "state", json_string(state)); + json_object_set_new(out, "contentPath", json_string(output)); + /* Seed the web-layer store from the daemon's persisted category/tags the + * first time we see a torrent (survives restarts), then apply it. */ + seed_assignment_from_daemon(id, torrent); + apply_assignment(out, id); + if (detail) { + json_object_set_new(out, "comment", json_string("")); + json_object_set_new(out, "createdBy", json_string("Naut")); + json_object_set_new(out, "creationDate", json_integer(0)); + json_object_set_new(out, "private", json_false()); + json_object_set_new(out, "magnetUri", + json_string(json_string_or(torrent, "source", ""))); + json_object_set_new(out, "pieceCount", json_integer((json_int_t)pieces)); + json_object_set_new(out, "piecesDone", json_integer((json_int_t)pieces_done)); + json_t *piece_states = json_object_get(torrent, "piece_states"); + json_object_set_new(out, "pieceStates", + json_is_array(piece_states) + ? json_deep_copy(piece_states) + : json_array()); + json_object_set_new(out, "trackers", trackers ? trackers : json_array()); + json_object_set_new(out, "peersList", peers_list ? peers_list : json_array()); + json_object_set_new(out, "files", files ? files : json_array()); + } + free(name); + return out; +} + +static json_t *preferences_json(void); + +/* Build a fresh snapshot (grid + global stats) with live download rates. */ +static json_t *build_snapshot(void) { + json_t *params = json_object(); + json_t *torrents = rpc_call_json("torrents", params); + json_decref(params); + if (!json_is_array(torrents)) { + json_decref(torrents); + torrents = json_array(); + } + speed_retain(torrents); + + json_t *prefs = preferences_json(); + json_t *items = json_array(); + uint64_t active = 0; + uint64_t total_rate = 0; + uint64_t total_data = 0; + size_t index; + json_t *torrent; + json_array_foreach(torrents, index, torrent) { + uint64_t id = json_u64(torrent, "torrent_id"); + uint64_t done = json_u64(torrent, "bytes_done"); + double dlspeed = speed_sample(id, done); + json_t *mapped = map_torrent(torrent, false, dlspeed); + if (!mapped) continue; + const char *state = json_string_value(json_object_get(mapped, "state")); + if (state && (strcmp(state, "downloading") == 0 || + strcmp(state, "forcedDL") == 0)) active++; + total_rate += (uint64_t)dlspeed; + total_data += done; + int64_t q = json_i64_or(mapped, "queuePos", 0); + size_t pos = 0; + for (; pos < json_array_size(items); pos++) { + json_t *cur = json_array_get(items, pos); + if (q < json_i64_or(cur, "queuePos", 0)) break; + } + if (json_array_insert_new(items, pos, mapped) != 0) + json_decref(mapped); + } + json_decref(torrents); + bool alt_speed = prefs && + json_boolean_value(json_object_get(prefs, "alt_speed_enabled")); + uint64_t dl_limit = prefs ? json_u64(prefs, alt_speed ? "alt_dl_limit" + : "dl_limit") : 0; + uint64_t up_limit = prefs ? json_u64(prefs, alt_speed ? "alt_up_limit" + : "up_limit") : 0; + json_t *server = json_pack( + "{s:I,s:i,s:I,s:i,s:I,s:I,s:b,s:f,s:i,s:s,s:i,s:I,s:i,s:i,s:s,s:i}", + "dl_info_speed", (json_int_t)total_rate, + "up_info_speed", 0, + "dl_info_data", (json_int_t)total_data, + "up_info_data", 0, + "dl_rate_limit", (json_int_t)dl_limit, + "up_rate_limit", (json_int_t)up_limit, + "alt_speed_enabled", alt_speed, + "global_ratio", 0.0, + "dht_nodes", 0, + "connection_status", "connected", + "listen_port", g_webui.port, + "free_space", (json_int_t)0, + "active_torrents", (int)active, + "total_torrents", (int)json_array_size(items), + "read_cache_hits", "0.0", + "queued_io_jobs", 0); + json_decref(prefs); + return json_pack("{s:I,s:o,s:o}", "ts", (json_int_t)time(NULL) * 1000, + "server", server, "torrents", items); +} + +/* Publish a newly built snapshot for all readers; wakes SSE waiters. */ +static void publish_snapshot(void) { + json_t *snapshot = build_snapshot(); + if (!snapshot) return; + char *full = json_dumps(snapshot, JSON_COMPACT | JSON_ENCODE_ANY); + json_t *torrents = json_object_get(snapshot, "torrents"); + char *list = json_dumps(torrents ? torrents : json_array(), + JSON_COMPACT | JSON_ENCODE_ANY); + json_decref(snapshot); + if (!full || !list) { + free(full); + free(list); + return; + } + pthread_mutex_lock(&g_webui.snap_lock); + free(g_webui.snapshot_str); + free(g_webui.torrents_str); + g_webui.snapshot_str = full; + g_webui.torrents_str = list; + g_webui.snap_seq++; + pthread_cond_broadcast(&g_webui.snap_cond); + pthread_mutex_unlock(&g_webui.snap_lock); +} + +static void *sampler_thread(void *arg) { + (void)arg; + while (!atomic_load(&g_webui.stopping)) { + publish_snapshot(); + /* sleep ~1s but stay responsive to shutdown */ + for (int i = 0; i < 10 && !atomic_load(&g_webui.stopping); i++) { + struct timespec ts = { .tv_sec = 0, .tv_nsec = 100 * 1000 * 1000 }; + nanosleep(&ts, NULL); + } + } + return NULL; +} + +static bool parse_id(const char *text, uint64_t *id) { + if (!text || !*text) return false; + char *end = NULL; + unsigned long long value = strtoull(text, &end, 10); + if (!end || (*end && *end != '/')) return false; + *id = (uint64_t)value; + return true; +} + +static json_t *full_torrent_by_hash(const char *hash) { + uint64_t id = 0; + if (!parse_id(hash, &id)) return NULL; + json_t *params = json_pack("{s:I}", "torrent_id", (json_int_t)id); + json_t *torrent = rpc_call_json("torrent", params); + json_decref(params); + if (!torrent) return NULL; + json_t *mapped = map_torrent(torrent, true, speed_peek(id)); + json_decref(torrent); + return mapped; +} + +static json_t *preferences_json(void) { + json_t *params = json_object(); + json_t *prefs = rpc_call_json("get_preferences", params); + json_decref(params); + if (!json_is_object(prefs)) { + json_decref(prefs); + prefs = json_object(); + } + if (!prefs) return NULL; + + json_t *max_active = json_object_get(prefs, "max_active"); + if (json_is_integer(max_active) && + !json_object_get(prefs, "max_active_downloads")) { + json_object_set_new(prefs, "max_active_downloads", + json_integer(json_integer_value(max_active))); + } + if (!json_object_get(prefs, "save_path")) + json_object_set_new(prefs, "save_path", + json_string(getenv("NAUT_WEBUI_SAVE_PATH") + ? getenv("NAUT_WEBUI_SAVE_PATH") : ".")); + if (!json_object_get(prefs, "dl_limit")) + json_object_set_new(prefs, "dl_limit", json_integer(0)); + if (!json_object_get(prefs, "up_limit")) + json_object_set_new(prefs, "up_limit", json_integer(0)); + if (!json_object_get(prefs, "alt_dl_limit")) + json_object_set_new(prefs, "alt_dl_limit", json_integer(0)); + if (!json_object_get(prefs, "alt_up_limit")) + json_object_set_new(prefs, "alt_up_limit", json_integer(0)); + if (!json_object_get(prefs, "alt_speed_enabled")) + json_object_set_new(prefs, "alt_speed_enabled", json_false()); + json_object_set_new(prefs, "max_connec", json_integer(500)); + json_object_set_new(prefs, "max_connec_per_torrent", json_integer(100)); + json_object_set_new(prefs, "max_uploads", json_integer(20)); + json_object_set_new(prefs, "max_active_uploads", json_integer(10)); + json_object_set_new(prefs, "max_active_torrents", + json_integer((json_int_t)json_i64_or( + prefs, "max_active_downloads", 5))); + return prefs; +} + +static void api_meta(int fd) { + json_t *json = json_object(); + json_t *preferences = preferences_json(); + if (!json || !preferences) { + json_decref(json); + json_decref(preferences); + http_text(fd, 500, "Internal Server Error", "oom"); + return; + } + pthread_mutex_lock(&g_webui.meta_lock); + json_object_set_new(json, "categories", + json_deep_copy(g_webui.categories)); + json_object_set_new(json, "tags", json_deep_copy(g_webui.tags)); + pthread_mutex_unlock(&g_webui.meta_lock); + json_t *params = json_object(); + json_t *torrents = rpc_call_json("torrents", params); + json_decref(params); + json_t *trackers = tracker_summary(torrents); + json_decref(torrents); + json_object_set_new(json, "trackers", trackers ? trackers : json_array()); + json_object_set_new(json, "preferences", preferences); + /* searchPlugins mirrors the configured Torznab indexers for the Search tab. */ + json_t *plugins = json_array(); + if (g_webui.store) webui_store_indexer_list(g_webui.store, plugins); + json_object_set_new(json, "searchPlugins", plugins); + http_json(fd, 200, json); + json_decref(json); +} + +static void api_plugins(int fd) { + char path[PATH_MAX]; + if (!join_root_path(path, sizeof path, "/plugins/plugins.json")) { + http_text(fd, 500, "Internal Server Error", "root too long"); + return; + } + json_error_t error; + json_t *manifest = json_load_file(path, JSON_REJECT_DUPLICATES, &error); + json_t *modules = json_array(); + if (json_is_object(manifest)) { + json_t *raw = json_object_get(manifest, "modules"); + if (json_is_array(raw)) { + size_t index; + json_t *value; + json_array_foreach(raw, index, value) { + const char *module = json_string_value(value); + if (module && strncmp(module, "/plugins/", 9) == 0 && + strstr(module, ".js")) + json_array_append_new(modules, json_string(module)); + } + } + } + json_decref(manifest); + json_t *reply = json_pack("{s:o}", "modules", modules); + http_json(fd, 200, reply); + json_decref(reply); +} + +static json_t *read_body_json(const char *body, size_t len) { + if (!body || len == 0) return json_object(); + json_error_t error; + json_t *json = json_loadb(body, len, JSON_REJECT_DUPLICATES, &error); + return json ? json : json_object(); +} + +static const char *path_after(const char *path, const char *prefix) { + size_t len = strlen(prefix); + return strncmp(path, prefix, len) == 0 ? path + len : NULL; +} + +static void api_torrent_detail(int fd, const char *tail) { + char hash[64]; + size_t n = strcspn(tail, "/?"); + snprintf(hash, sizeof hash, "%.*s", (int)n, tail); + json_t *torrent = full_torrent_by_hash(hash); + if (!torrent) { + http_text(fd, 404, "Not Found", "not found"); + return; + } + char tab[64] = {0}; + if (tail[n] == '/') + snprintf(tab, sizeof tab, "%s", tail + n + 1); + strip_query(tab); + if (strcmp(tab, "trackers") == 0) { + json_t *value = json_incref(json_object_get(torrent, "trackers")); + http_json(fd, 200, value); + json_decref(value); + } else if (strcmp(tab, "peers") == 0) { + json_t *value = json_incref(json_object_get(torrent, "peersList")); + http_json(fd, 200, value); + json_decref(value); + } else if (strcmp(tab, "files") == 0) { + json_t *value = json_incref(json_object_get(torrent, "files")); + http_json(fd, 200, value); + json_decref(value); + } else if (strcmp(tab, "pieces") == 0) { + uint64_t count = json_u64(torrent, "pieceCount"); + uint64_t done = json_u64(torrent, "piecesDone"); + json_t *states = json_object_get(torrent, "pieceStates"); + json_t *pieces = json_is_array(states) ? json_deep_copy(states) + : json_array(); + if (pieces && json_array_size(pieces) == 0) + for (uint64_t i = 0; i < count && i < 4000; i++) + json_array_append_new(pieces, json_integer(i < done ? 2 : 0)); + json_t *value = json_pack("{s:I,s:I,s:o}", + "pieceSize", json_u64(torrent, "pieceSize"), + "pieceCount", count, "pieces", pieces); + http_json(fd, 200, value); + json_decref(value); + } else { + http_json(fd, 200, torrent); + } + json_decref(torrent); +} + +static void api_add(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *source = json_string_value(json_object_get(req, "source")); + const char *magnet = json_string_value(json_object_get(req, "magnet")); + const char *data = json_string_value(json_object_get(req, "data")); + const char *save_path = json_string_value(json_object_get(req, "savePath")); + /* The UI parses the .torrent client-side and sends a display name; the + * daemon only knows the temp upload path, so we keep the name here. Copy + * out of `req` since it is freed before these are used below. */ + const char *display = json_string_value(json_object_get(req, "name")); + char display_name[256] = {0}; + if (display && *display) + snprintf(display_name, sizeof display_name, "%s", display); + const char *category = json_string_value(json_object_get(req, "category")); + char category_name[256] = {0}; + if (category && *category) + snprintf(category_name, sizeof category_name, "%s", category); + if (!save_path || !*save_path) save_path = "."; + if (!source) source = magnet; + if ((!source || !*source) && (!data || !*data)) { + /* A request carrying only parsed metadata (name/files) but no bytes is + * the tell-tale of a stale UI that predates base64 upload support. */ + bool looks_stale = json_object_get(req, "name") || + json_object_get(req, "files"); + json_decref(req); + http_text(fd, 400, "Bad Request", looks_stale + ? "no torrent bytes in request: the page is running an old UI. " + "Hard-reload the browser (Ctrl+Shift+R) and add the file again." + : "send a magnet, a source path, or uploaded torrent bytes"); + return; + } + /* Own a copy of the tags (req is freed before they are applied below). */ + json_t *tags = json_is_array(json_object_get(req, "tags")) + ? json_deep_copy(json_object_get(req, "tags")) : NULL; + json_t *params = json_object(); + json_object_set_new(params, "output", json_string(save_path)); + if (source && *source) json_object_set_new(params, "source", json_string(source)); + if (data && *data) json_object_set_new(params, "data", json_string(data)); + if (json_object_get(req, "paused")) + json_object_set_new(params, "paused", + json_boolean(json_boolean_value( + json_object_get(req, "paused")))); + /* Forward the display name so the daemon persists it for restore. */ + if (display_name[0]) + json_object_set_new(params, "name", json_string(display_name)); + /* An empty category means "Uncategorized"; only forward a real one. The + * daemon (spawn_torrent) reads "category" and persists it. */ + if (category_name[0]) + json_object_set_new(params, "category", json_string(category_name)); + if (tags && json_array_size(tags) > 0) + json_object_set_new(params, "tags", json_deep_copy(tags)); + naut_err add_err = NAUT_OK; + json_t *result = rpc_call_json_err("add_torrent", params, &add_err); + json_decref(params); + json_decref(req); + if (!result) { + json_decref(tags); + if (add_err == NAUT_ERR_EXIST) + http_text(fd, 409, "Conflict", + "this torrent's data would overlap an existing torrent; " + "choose a different save path"); + else + http_text(fd, 502, "Bad Gateway", "add_torrent failed"); + return; + } + uint64_t new_id = json_u64(result, "torrent_id"); + if (display_name[0]) store_set_name(new_id, display_name); + if (category_name[0]) store_set_category(new_id, category_name); + if (tags && json_array_size(tags) > 0) { + store_update_tags(new_id, tags, true); /* assign to the new torrent */ + size_t ti; + json_t *tv; + json_array_foreach(tags, ti, tv) { /* register any new tag names */ + const char *t = json_string_value(tv); + if (t && *t) store_add_tag(t); + } + webui_sync_taxonomy(); /* persist the global tag list */ + } + json_decref(tags); + char id[32]; + snprintf(id, sizeof id, "%llu", (unsigned long long)new_id); + json_t *reply = json_pack("{s:b,s:s}", "ok", 1, "hash", id); + http_json(fd, 200, reply); + json_decref(reply); + json_decref(result); + /* refresh the shared snapshot so the new torrent shows up immediately */ + publish_snapshot(); +} + +static void api_delete(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + json_t *hashes = json_object_get(req, "hashes"); + size_t removed = 0; + if (json_is_array(hashes)) { + size_t index; + json_t *hash; + json_array_foreach(hashes, index, hash) { + uint64_t id = 0; + if (!parse_id(json_string_value(hash), &id)) continue; + json_t *params = json_pack("{s:I}", "torrent_id", (json_int_t)id); + json_t *result = rpc_call_json("remove_torrent", params); + json_decref(params); + if (result) { + removed++; + store_forget(id); + json_decref(result); + } + } + } + json_decref(req); + json_t *reply = json_pack("{s:b,s:i}", "ok", 1, "removed", (int)removed); + http_json(fd, 200, reply); + json_decref(reply); + if (removed) publish_snapshot(); +} + +static bool rpc_for_torrent(const char *method, uint64_t id, json_t *extra) { + json_t *params = json_object(); + if (!params) return false; + json_object_set_new(params, "torrent_id", json_integer((json_int_t)id)); + if (json_is_object(extra)) { + const char *key; + json_t *value; + json_object_foreach(extra, key, value) + json_object_set(params, key, value); + } + json_t *result = rpc_call_json(method, params); + json_decref(params); + if (!result) return false; + json_decref(result); + return true; +} + +static const char *queue_op_for_action(const char *action) { + if (strcmp(action, "topPriority") == 0) return "top"; + if (strcmp(action, "bottomPriority") == 0) return "bottom"; + if (strcmp(action, "increasePriority") == 0) return "up"; + if (strcmp(action, "decreasePriority") == 0) return "down"; + return NULL; +} + +/* Category/tag assignment is web-layer state. Engine-backed verbs delegate to + * nautd RPCs so toolbar actions mutate the real queue/lifecycle state. */ +static void api_action(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *raw_action = json_string_value(json_object_get(req, "action")); + char action[64]; + snprintf(action, sizeof action, "%s", raw_action ? raw_action : ""); + json_t *hashes = json_object_get(req, "hashes"); + json_t *params = json_object_get(req, "params"); + bool handled = false; + int affected = 0; + if (json_is_array(hashes) && + (strcmp(action, "setCategory") == 0 || + strcmp(action, "addTags") == 0 || + strcmp(action, "removeTags") == 0)) { + size_t index; + json_t *hash; + json_array_foreach(hashes, index, hash) { + uint64_t id = 0; + if (!parse_id(json_string_value(hash), &id)) continue; + if (strcmp(action, "setCategory") == 0) + store_set_category(id, + json_string_value(json_object_get(params, "category"))); + else + store_update_tags(id, json_object_get(params, "tags"), + strcmp(action, "addTags") == 0); + webui_sync_labels(id); /* mirror to the daemon (persist + Lua) */ + affected++; + } + handled = true; + } + if (json_is_array(hashes) && !handled) { + const char *rpc = NULL; + json_t *extra = NULL; + if (strcmp(action, "pause") == 0) { + rpc = "pause_torrent"; + } else if (strcmp(action, "resume") == 0) { + rpc = "resume_torrent"; + } else if (strcmp(action, "forceStart") == 0) { + rpc = "resume_torrent"; + extra = json_pack("{s:b}", "force", 1); + } else if (strcmp(action, "recheck") == 0) { + rpc = "recheck_torrent"; + } else if (strcmp(action, "setSavePath") == 0) { + const char *sp = + json_string_value(json_object_get(params, "savePath")); + if (sp && *sp) { + rpc = "set_save_path"; + extra = json_pack("{s:s,s:b}", "savePath", sp, "reset", + json_boolean_value( + json_object_get(params, "reset"))); + } + } else { + const char *op = queue_op_for_action(action); + if (op) { + rpc = "queue_move"; + extra = json_pack("{s:s}", "op", op); + } + } + if (rpc) { + size_t index; + json_t *hash; + json_array_foreach(hashes, index, hash) { + uint64_t id = 0; + if (!parse_id(json_string_value(hash), &id)) continue; + if (rpc_for_torrent(rpc, id, extra)) affected++; + } + json_decref(extra); + handled = true; + } + } + json_decref(req); + if (handled) { + json_t *json = json_pack("{s:b,s:i}", "ok", 1, "affected", affected); + http_json(fd, 200, json); + json_decref(json); + publish_snapshot(); + return; + } + char message[128]; + snprintf(message, sizeof message, + "action '%s' is not supported by the engine", action); + json_t *json = json_pack("{s:b,s:s}", "ok", 0, "error", message); + http_json(fd, 501, json); + json_decref(json); +} + +static void api_preferences(int fd, const char *method, + const char *body, size_t len) { + if (strcmp(method, "GET") == 0) { + json_t *prefs = preferences_json(); + if (!prefs) { + http_text(fd, 502, "Bad Gateway", "get_preferences failed"); + return; + } + http_json(fd, 200, prefs); + json_decref(prefs); + return; + } + if (strcmp(method, "POST") != 0) { + http_text(fd, 405, "Method Not Allowed", "method not allowed"); + return; + } + json_t *req = read_body_json(body, len); + json_t *params = json_object(); + if (!req || !params) { + json_decref(req); + json_decref(params); + http_text(fd, 500, "Internal Server Error", "oom"); + return; + } + const char *keys[] = { + "dl_limit", "up_limit", "alt_dl_limit", "alt_up_limit", + "alt_speed_enabled", "max_active" + }; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { + json_t *v = json_object_get(req, keys[i]); + if (v) json_object_set(params, keys[i], v); + } + json_t *max = json_object_get(req, "max_active_downloads"); + if (max) json_object_set(params, "max_active", max); + json_t *result = rpc_call_json("set_preferences", params); + json_decref(params); + json_decref(req); + if (!json_is_object(result)) { + json_decref(result); + http_text(fd, 502, "Bad Gateway", "set_preferences failed"); + return; + } + json_decref(result); + json_t *prefs = preferences_json(); + http_json(fd, 200, prefs); + json_decref(prefs); + publish_snapshot(); +} + +static void api_altspeed(int fd) { + json_t *params = json_object(); + json_t *result = rpc_call_json("toggle_altspeed", params); + json_decref(params); + if (!json_is_object(result)) { + json_decref(result); + http_text(fd, 502, "Bad Gateway", "toggle_altspeed failed"); + return; + } + http_json(fd, 200, result); + json_decref(result); + publish_snapshot(); +} + +/* POST /api/script/settings — persist user-edited script setting values. Body: + * { "settings": { "<key>": "<value>", ... } }. */ +static void api_script_settings(int fd, const char *method, const char *body, + size_t len) { + if (strcmp(method, "POST") != 0) { + http_text(fd, 405, "Method Not Allowed", "method not allowed"); + return; + } + json_t *req = read_body_json(body, len); + json_t *settings = req ? json_object_get(req, "settings") : NULL; + if (!json_is_object(settings)) { + json_decref(req); + http_text(fd, 400, "Bad Request", "missing settings object"); + return; + } + json_t *params = json_object(); + json_object_set(params, "settings", settings); + json_decref(req); + json_t *result = rpc_call_json("set_script_settings", params); + json_decref(params); + if (!json_is_object(result)) { + json_decref(result); + http_text(fd, 502, "Bad Gateway", "set_script_settings failed"); + return; + } + http_json(fd, 200, result); + json_decref(result); +} + +static void api_script(int fd, const char *method, const char *body, size_t len) { + json_t *params = NULL; + json_t *result = NULL; + const char *rpc_name = "script_status"; + if (strcmp(method, "GET") == 0) { + params = json_object(); + result = rpc_call_json("script_status", params); + } else if (strcmp(method, "POST") == 0) { + rpc_name = "update_script"; + json_t *req = read_body_json(body, len); + const char *source = json_string_value(json_object_get(req, "source")); + if (!source) { + json_decref(req); + http_text(fd, 400, "Bad Request", "missing source"); + return; + } + params = json_object(); + json_object_set(params, "source", json_object_get(req, "source")); + json_decref(req); + result = rpc_call_json("update_script", params); + } else { + http_text(fd, 405, "Method Not Allowed", "method not allowed"); + return; + } + json_decref(params); + if (!json_is_object(result)) { + json_decref(result); + char msg[96]; + snprintf(msg, sizeof msg, "%s failed", rpc_name); + http_text(fd, 502, "Bad Gateway", msg); + return; + } + http_json(fd, 200, result); + json_decref(result); +} + +/* ======================= RSS + Torznab search engine ====================== * + * The web layer owns RSS feeds, auto-download rules and search indexers; the + * daemon just persists them (blob store) and adds the torrents we hand it. A + * background thread polls feeds, parses items, and fires matching rules. */ + +#define RSS_POLL_INTERVAL_SEC (15 * 60) /* re-poll each feed every 15 min */ +#define RSS_MAX_ARTICLES 200 /* keep newest N per feed */ + +/* --- tiny XML helpers (scan, not a real parser; enough for RSS/Atom) ------ */ + +/* Decode the handful of XML entities feeds actually use, in place-ish. */ +static void xml_unescape(char *dst, size_t dstsz, const char *src, size_t n) { + size_t o = 0; + for (size_t i = 0; i < n && o + 1 < dstsz; i++) { + if (src[i] == '&') { + if (i + 4 < n && strncmp(src + i, "&", 5) == 0) { dst[o++] = '&'; i += 4; continue; } + if (i + 3 < n && strncmp(src + i, "<", 4) == 0) { dst[o++] = '<'; i += 3; continue; } + if (i + 3 < n && strncmp(src + i, ">", 4) == 0) { dst[o++] = '>'; i += 3; continue; } + if (i + 5 < n && strncmp(src + i, """, 6) == 0){ dst[o++] = '"'; i += 5; continue; } + if (i + 5 < n && strncmp(src + i, "'", 6) == 0){ dst[o++] = '\''; i += 5; continue; } + if (i + 4 < n && strncmp(src + i, "'", 5) == 0) { dst[o++] = '\''; i += 4; continue; } + if (i + 1 < n && src[i + 1] == '#') { /* numeric &#NN; */ + int base = 10, k = i + 2; + if (k < (int)n && (src[k] == 'x' || src[k] == 'X')) { base = 16; k++; } + long code = strtol(src + k, NULL, base); + const char *semi = memchr(src + i, ';', n - i); + if (semi && code > 0 && code < 128) { + dst[o++] = (char)code; + i = (size_t)(semi - src); + continue; + } + } + } + dst[o++] = src[i]; + } + dst[o] = 0; +} + +/* Find <tag>...</tag> within [item, item+len) and write its decoded text to + * out. Handles a single CDATA section. Returns true if found. */ +static bool xml_tag_text(const char *item, size_t len, const char *tag, + char *out, size_t outsz) { + char open[64]; + int on = snprintf(open, sizeof open, "<%s", tag); + if (on < 0 || (size_t)on >= sizeof open) return false; + const char *p = item, *end = item + len; + while (p < end) { + const char *o = memmem(p, (size_t)(end - p), open, (size_t)on); + if (!o) return false; + const char *after = o + on; + if (after < end && *after != '>' && *after != ' ' && + *after != '\t' && *after != '/' && *after != ':') { p = after; continue; } + const char *gt = memchr(o, '>', (size_t)(end - o)); + if (!gt) return false; + const char *content = gt + 1; + char close[64]; + snprintf(close, sizeof close, "</%s>", tag); + const char *c = memmem(content, (size_t)(end - content), close, strlen(close)); + if (!c) return false; + const char *s = content; size_t slen = (size_t)(c - content); + if (slen >= 12 && strncmp(s, "<![CDATA[", 9) == 0) { + s += 9; slen -= 9; + const char *cd = memmem(s, slen, "]]>", 3); + if (cd) slen = (size_t)(cd - s); + } + while (slen && (*s == ' ' || *s == '\n' || *s == '\r' || *s == '\t')) { s++; slen--; } + while (slen && (s[slen-1]==' '||s[slen-1]=='\n'||s[slen-1]=='\r'||s[slen-1]=='\t')) slen--; + xml_unescape(out, outsz, s, slen); + return true; + } + return false; +} + +/* Pull attribute value attr="..." from the first <tag ...> element in range. */ +static bool xml_attr(const char *item, size_t len, const char *tag, + const char *attr, char *out, size_t outsz) { + char open[64]; + int on = snprintf(open, sizeof open, "<%s", tag); + if (on < 0 || (size_t)on >= sizeof open) return false; + const char *o = memmem(item, len, open, (size_t)on); + if (!o) return false; + const char *gt = memchr(o, '>', (size_t)(item + len - o)); + if (!gt) return false; + char needle[64]; + int nn = snprintf(needle, sizeof needle, "%s=\"", attr); + if (nn < 0 || (size_t)nn >= sizeof needle) return false; + const char *a = memmem(o, (size_t)(gt - o), needle, (size_t)nn); + if (!a) return false; + a += nn; + const char *q = memchr(a, '"', (size_t)(gt - a)); + if (!q) return false; + xml_unescape(out, outsz, a, (size_t)(q - a)); + return true; +} + +/* Locate a magnet: URI anywhere inside the item element. */ +static bool find_magnet(const char *item, size_t len, char *out, size_t outsz) { + const char *m = memmem(item, len, "magnet:?", 8); + if (!m) return false; + size_t i = 0; + while (m < item + len && *m && *m != '<' && *m != '"' && *m != '\'' && + *m != ' ' && *m != '\n' && *m != '\r' && *m != '\t' && i + 1 < outsz) + out[i++] = *m++; + out[i] = 0; + /* decode & that often appears in magnet query separators */ + char tmp[2048]; + xml_unescape(tmp, sizeof tmp, out, strlen(out)); + snprintf(out, outsz, "%s", tmp); + return i > 8; +} + +/* --- base64 (for fetching .torrent enclosures and handing bytes to add) --- */ +static char *base64_encode(const unsigned char *in, size_t len) { + static const char tbl[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + char *out = malloc((len + 2) / 3 * 4 + 1); + if (!out) return NULL; + size_t o = 0; + for (size_t i = 0; i < len; i += 3) { + unsigned v = in[i] << 16; + if (i + 1 < len) v |= in[i+1] << 8; + if (i + 2 < len) v |= in[i+2]; + out[o++] = tbl[(v >> 18) & 63]; + out[o++] = tbl[(v >> 12) & 63]; + out[o++] = (i + 1 < len) ? tbl[(v >> 6) & 63] : '='; + out[o++] = (i + 2 < len) ? tbl[v & 63] : '='; + } + out[o] = 0; + return out; +} + +/* --- RSS persistence (via the daemon blob store) -------------------------- */ + +/* --- auto-download: hand a matched article to the daemon ------------------ */ + +/* Add a torrent from a magnet, or by fetching a .torrent enclosure URL and + * uploading its bytes. Applies category/save path/paused, mirrors the label. */ +static bool rss_download(const char *title, const char *magnet, + const char *torrent_url, const char *category, + const char *save_path, bool paused) { + json_t *params = json_object(); + if (!params) return false; + json_object_set_new(params, "output", + json_string(save_path && *save_path ? save_path : ".")); + if (paused) json_object_set_new(params, "paused", json_true()); + if (category && *category) json_object_set_new(params, "category", json_string(category)); + /* The article title is the real torrent name; without it the daemon falls + * back to the temp upload filename (upload-XXXXXX) for fetched .torrents. */ + if (title && *title) json_object_set_new(params, "name", json_string(title)); + + char *fetched = NULL; + if (magnet && *magnet) { + json_object_set_new(params, "source", json_string(magnet)); + } else if (torrent_url && *torrent_url) { + naut_http_response r; + if (naut_http_get(torrent_url, &r) != NAUT_OK || r.status / 100 != 2) { + naut_http_response_free(&r); + json_decref(params); + return false; + } + fetched = base64_encode((const unsigned char *)r.body, r.body_len); + naut_http_response_free(&r); + if (!fetched) { json_decref(params); return false; } + json_object_set_new(params, "data", json_string(fetched)); + } else { + json_decref(params); + return false; + } + naut_err err = NAUT_OK; + json_t *result = rpc_call_json_err("add_torrent", params, &err); + json_decref(params); + free(fetched); + if (!result) return false; + uint64_t id = json_u64(result, "torrent_id"); + if (id && title && *title) store_set_name(id, title); + if (id && category && *category) store_set_category(id, category); + json_decref(result); + publish_snapshot(); + return true; +} + +/* Does `article` satisfy `rule`? Substring or POSIX regex on the title. */ +static bool rule_matches(json_t *rule, const char *feed_name, const char *title) { + if (!json_boolean_value(json_object_get(rule, "enabled"))) return false; + /* affectedFeeds: empty array means "all feeds". */ + json_t *feeds = json_object_get(rule, "affectedFeeds"); + if (json_is_array(feeds) && json_array_size(feeds) > 0) { + bool listed = false; size_t i; json_t *v; + json_array_foreach(feeds, i, v) + if (strcmp(json_string_value(v) ? json_string_value(v) : "", feed_name) == 0) { listed = true; break; } + if (!listed) return false; + } + const char *must = json_string_or(rule, "mustContain", ""); + const char *mustnot = json_string_or(rule, "mustNotContain", ""); + bool regex = json_boolean_value(json_object_get(rule, "useRegex")); + if (regex) { + if (*must) { + regex_t re; + if (regcomp(&re, must, REG_EXTENDED | REG_ICASE | REG_NOSUB) != 0) return false; + int m = regexec(&re, title, 0, NULL, 0); + regfree(&re); + if (m != 0) return false; + } + if (*mustnot) { + regex_t re; + if (regcomp(&re, mustnot, REG_EXTENDED | REG_ICASE | REG_NOSUB) == 0) { + int m = regexec(&re, title, 0, NULL, 0); + regfree(&re); + if (m == 0) return false; + } + } + } else { + if (*must && !strcasestr(title, must)) return false; + if (*mustnot && strcasestr(title, mustnot)) return false; + } + return true; +} + +/* Mark every article with this key as grabbed, so a rule re-run skips it. */ +static void rss_mark_grabbed(const char *key) { + if (g_webui.store) webui_store_article_mark_grabbed(g_webui.store, key); +} + +/* Download an article and, on success, flag it grabbed by key. */ +static bool rss_grab_article(const char *key, const char *title, + const char *magnet, const char *torrent_url, + const char *cat, const char *path, bool paused) { + bool ok = rss_download(title, magnet, torrent_url, cat, path, paused); + if (ok) rss_mark_grabbed(key); + return ok; +} + +/* Run the auto-download rules against one freshly-seen article; download the + * first enabled rule that matches. */ +static void rss_run_rules(const char *feed_name, const char *key, + const char *title, const char *magnet, + const char *torrent_url) { + if (!g_webui.store) return; + json_t *rules = json_array(); + if (!webui_store_rule_list(g_webui.store, rules)) { json_decref(rules); return; } + char cat[128] = {0}, path[1024] = {0}, rule_name[128] = {0}; + bool paused = false, fire = false; + size_t i; json_t *rule; + json_array_foreach(rules, i, rule) { + if (rule_matches(rule, feed_name, title)) { + snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", "")); + snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", "")); + snprintf(rule_name, sizeof rule_name, "%s", json_string_or(rule, "name", "")); + paused = json_boolean_value(json_object_get(rule, "addPaused")); + fire = true; + break; + } + } + json_decref(rules); + if (!fire) return; + webui_store_rule_set_match(g_webui.store, rule_name, (long)time(NULL)); + if (rss_grab_article(key, title, magnet, torrent_url, cat, path, paused)) + log_msg(2, "rss: auto-downloaded a match"); +} + +/* Parse a feed body, inserting newly-seen articles into the store. Each new + * article is appended to out_new ({key,title,magnet,torrentUrl}) so the caller + * can fire rules afterward. Returns the number newly inserted. */ +static int rss_ingest(const char *feed_name, const char *xml, size_t len, + json_t *out_new) { + if (!g_webui.store) return 0; + int added = 0; + const char *p = xml, *end = xml + len; + for (;;) { + const char *open = memmem(p, (size_t)(end - p), "<item", 5); + const char *close_tag = "</item>"; + if (!open) { open = memmem(p, (size_t)(end - p), "<entry", 6); /* Atom */ + close_tag = "</entry>"; } + if (!open) break; + const char *close = memmem(open, (size_t)(end - open), close_tag, strlen(close_tag)); + if (!close) break; + size_t ilen = (size_t)(close - open); + + char title[512] = {0}, link[1024] = {0}, magnet[2048] = {0}; + char enclosure[1024] = {0}, lenstr[64] = {0}, pub[128] = {0}; + xml_tag_text(open, ilen, "title", title, sizeof title); + xml_tag_text(open, ilen, "link", link, sizeof link); + /* Atom (and some RSS) carry the URL as <link href="..."> instead. */ + if (!link[0]) xml_attr(open, ilen, "link", "href", link, sizeof link); + xml_tag_text(open, ilen, "pubDate", pub, sizeof pub); + if (!pub[0]) xml_tag_text(open, ilen, "published", pub, sizeof pub); + find_magnet(open, ilen, magnet, sizeof magnet); + xml_attr(open, ilen, "enclosure", "url", enclosure, sizeof enclosure); + if (!xml_attr(open, ilen, "enclosure", "length", lenstr, sizeof lenstr)) + xml_tag_text(open, ilen, "contentLength", lenstr, sizeof lenstr); + if (!magnet[0] && strncmp(link, "magnet:", 7) == 0) + snprintf(magnet, sizeof magnet, "%s", link); + /* A bare <link> to a .torrent is a valid download source too. */ + char dl_url[1024] = {0}; + if (enclosure[0]) snprintf(dl_url, sizeof dl_url, "%s", enclosure); + else if (strncmp(link, "http", 4) == 0 && strncmp(magnet, "magnet:", 7) != 0) + snprintf(dl_url, sizeof dl_url, "%s", link); + + const char *key = magnet[0] ? magnet : (enclosure[0] ? enclosure : link); + if (title[0] && key && *key) { + json_t *art = json_pack("{s:s,s:s,s:s,s:s,s:s,s:I,s:s}", + "key", key, "title", title, "magnet", magnet, "torrentUrl", dl_url, + "link", link, "size", (json_int_t)strtoll(lenstr, NULL, 10), + "pubDate", pub); + int rc = art ? webui_store_article_add(g_webui.store, feed_name, art) : -1; + json_decref(art); + if (rc == 1) { + added++; + if (out_new) + json_array_append_new(out_new, json_pack("{s:s,s:s,s:s,s:s}", + "key", key, "title", title, "magnet", magnet, + "torrentUrl", dl_url)); + } + } + p = close + strlen(close_tag); + } + webui_store_article_trim(g_webui.store, feed_name, RSS_MAX_ARTICLES); + webui_store_feed_set_updated(g_webui.store, feed_name, (long)time(NULL)); + return added; +} + +/* Poll one feed by name+url (network I/O done without any lock held). */ +static void rss_poll_one(const char *name, const char *url) { + if (!name || !*name || !url || !*url) return; + naut_http_response r; + if (naut_http_get(url, &r) != NAUT_OK || r.status / 100 != 2 || !r.body) { + naut_http_response_free(&r); + return; + } + json_t *new_articles = json_array(); + rss_ingest(name, r.body, r.body_len, new_articles); + naut_http_response_free(&r); + /* fire auto-download rules for the newly-seen articles */ + size_t i; json_t *a; + json_array_foreach(new_articles, i, a) + rss_run_rules(name, json_string_or(a, "key", ""), + json_string_or(a, "title", ""), + json_string_or(a, "magnet", ""), + json_string_or(a, "torrentUrl", "")); + json_decref(new_articles); +} + +static void rss_poll_all(void) { + if (!g_webui.store) return; + json_t *targets = json_array(); + webui_store_feed_targets(g_webui.store, targets); + size_t i; json_t *t; + json_array_foreach(targets, i, t) { + if (atomic_load(&g_webui.stopping)) break; + char name[256], url[1024]; + snprintf(name, sizeof name, "%s", json_string_or(t, "name", "")); + snprintf(url, sizeof url, "%s", json_string_or(t, "url", "")); + rss_poll_one(name, url); + } + json_decref(targets); +} + +static void *rss_thread_fn(void *arg) { + (void)arg; + while (!atomic_load(&g_webui.stopping)) { + rss_poll_all(); + pthread_mutex_lock(&g_webui.rss_lock); + g_webui.rss_wake = false; + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_sec += RSS_POLL_INTERVAL_SEC; + while (!atomic_load(&g_webui.stopping) && !g_webui.rss_wake) + if (pthread_cond_timedwait(&g_webui.rss_cond, &g_webui.rss_lock, &ts) == ETIMEDOUT) + break; + pthread_mutex_unlock(&g_webui.rss_lock); + } + return NULL; +} + +static void rss_signal_wake(void) { + pthread_mutex_lock(&g_webui.rss_lock); + g_webui.rss_wake = true; + pthread_cond_signal(&g_webui.rss_cond); + pthread_mutex_unlock(&g_webui.rss_lock); +} + +/* --- RSS HTTP API --------------------------------------------------------- */ + +/* GET /api/rss → array of feeds (with their articles). */ +static void api_rss_list(int fd) { + json_t *feeds = json_array(); + if (g_webui.store) webui_store_feed_list(g_webui.store, feeds); + http_json(fd, 200, feeds); + json_decref(feeds); +} + +/* POST /api/rss {name,url} adds a feed; POST /api/rss/delete {name} removes. */ +static void api_rss_feed(int fd, const char *body, size_t len, bool remove) { + json_t *req = read_body_json(body, len); + const char *name = json_string_value(json_object_get(req, "name")); + const char *url = json_string_value(json_object_get(req, "url")); + bool added = false; + if (g_webui.store && name && *name) { + if (remove) webui_store_feed_remove(g_webui.store, name); + else if (url && *url) added = webui_store_feed_upsert(g_webui.store, name, url); + } + json_decref(req); + if (added) rss_signal_wake(); /* re-poll the new feed now */ + api_rss_list(fd); +} + +/* GET /api/rss/rules → array of rules. */ +static void api_rss_rules_list(int fd) { + json_t *rules = json_array(); + if (g_webui.store) webui_store_rule_list(g_webui.store, rules); + http_json(fd, 200, rules); + json_decref(rules); +} + +/* POST /api/rss/rules upserts a rule; POST /api/rss/rules/delete removes one. */ +static void api_rss_rule(int fd, const char *body, size_t len, bool remove) { + json_t *req = read_body_json(body, len); + const char *name = json_string_value(json_object_get(req, "name")); + if (g_webui.store && name && *name) { + if (remove) { + webui_store_rule_remove(g_webui.store, name); + } else { + json_t *rule = json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:i}", + "name", name, + "enabled", json_boolean_value(json_object_get(req, "enabled")), + "useRegex", json_boolean_value(json_object_get(req, "useRegex")), + "addPaused", json_boolean_value(json_object_get(req, "addPaused")), + "mustContain", json_string_or(req, "mustContain", ""), + "mustNotContain", json_string_or(req, "mustNotContain", ""), + "assignedCategory", json_string_or(req, "assignedCategory", ""), + "savePath", json_string_or(req, "savePath", ""), + "lastMatch", 0); + if (rule) { + json_t *af = json_object_get(req, "affectedFeeds"); + json_object_set_new(rule, "affectedFeeds", + json_is_array(af) ? json_deep_copy(af) : json_array()); + webui_store_rule_upsert(g_webui.store, rule); + json_decref(rule); + } + } + } + json_decref(req); + api_rss_rules_list(fd); +} + +/* POST /api/rss/rules/run {name} — re-apply a rule to every stored article (not + * just newly-seen ones), grabbing matches not yet grabbed. Runs regardless of + * the rule's enabled flag. */ +static void api_rss_rule_run(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *rname = json_string_value(json_object_get(req, "name")); + char name[128] = {0}; + if (rname) snprintf(name, sizeof name, "%s", rname); + json_decref(req); + + json_t *rule = (g_webui.store && name[0]) ? webui_store_rule_get(g_webui.store, name) : NULL; + if (!rule) { http_text(fd, 404, "Not Found", "no such rule"); return; } + char cat[128], path[1024]; + snprintf(cat, sizeof cat, "%s", json_string_or(rule, "assignedCategory", "")); + snprintf(path, sizeof path, "%s", json_string_or(rule, "savePath", "")); + bool paused = json_boolean_value(json_object_get(rule, "addPaused")); + json_object_set_new(rule, "enabled", json_true()); /* manual run */ + + json_t *cands = json_array(); + webui_store_articles_ungrabbed(g_webui.store, cands); + json_t *todo = json_array(); + size_t i; json_t *a; + json_array_foreach(cands, i, a) + if (rule_matches(rule, json_string_or(a, "feed", ""), json_string_or(a, "title", ""))) + json_array_append(todo, a); + json_decref(cands); + json_decref(rule); + + int grabbed = 0; + json_array_foreach(todo, i, a) + if (rss_grab_article(json_string_or(a, "key", ""), json_string_or(a, "title", ""), + json_string_or(a, "magnet", ""), json_string_or(a, "torrentUrl", ""), + cat, path, paused)) + grabbed++; + size_t matched = json_array_size(todo); + json_decref(todo); + if (matched) webui_store_rule_set_match(g_webui.store, name, (long)time(NULL)); + + json_t *reply = json_pack("{s:b,s:i,s:i}", "ok", 1, + "matched", (int)matched, "grabbed", grabbed); + http_json(fd, 200, reply); + json_decref(reply); +} + +/* POST /api/rss/download {magnet|torrentUrl, title, key, category, savePath, + * paused} — manually grab a torrent from a feed article or search result. */ +static void api_rss_download(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *magnet = json_string_or(req, "magnet", ""); + const char *url = json_string_or(req, "torrentUrl", ""); + const char *cat = json_string_or(req, "category", ""); + const char *path = json_string_or(req, "savePath", ""); + const char *key = json_string_or(req, "key", ""); + const char *title = json_string_or(req, "title", ""); + bool paused = json_boolean_value(json_object_get(req, "paused")); + bool ok = rss_grab_article(key, title, magnet, url, cat, path, paused); + json_decref(req); + if (ok) { + json_t *reply = json_pack("{s:b}", "ok", 1); + http_json(fd, 200, reply); + json_decref(reply); + } else { + http_text(fd, 502, "Bad Gateway", "could not add torrent from this item"); + } +} + +/* POST /api/rss/refresh {name?} — re-poll a feed now (or all feeds). */ +static void api_rss_refresh(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *name = json_string_value(json_object_get(req, "name")); + char target[256] = {0}; + if (name) snprintf(target, sizeof target, "%s", name); + json_decref(req); + if (g_webui.store) { + json_t *targets = json_array(); + webui_store_feed_targets(g_webui.store, targets); + size_t i; json_t *t; + json_array_foreach(targets, i, t) { + if (atomic_load(&g_webui.stopping)) break; + const char *fn = json_string_or(t, "name", ""); + if (target[0] && strcmp(target, fn) != 0) continue; + rss_poll_one(fn, json_string_or(t, "url", "")); + } + json_decref(targets); + } + api_rss_list(fd); +} + +/* POST /api/indexers upserts a Torznab indexer; .../delete removes one. */ +static void api_indexer(int fd, const char *body, size_t len, bool remove) { + json_t *req = read_body_json(body, len); + const char *name = json_string_value(json_object_get(req, "name")); + if (g_webui.store && name && *name) { + if (remove) webui_store_indexer_remove(g_webui.store, name); + else webui_store_indexer_upsert(g_webui.store, req); + } + json_decref(req); + json_t *reply = json_array(); + if (g_webui.store) webui_store_indexer_list(g_webui.store, reply); + http_json(fd, 200, reply); + json_decref(reply); +} + +/* ----------------------------- Torznab search ----------------------------- */ + +/* Parse Torznab/newznab XML results into the UI's row schema. */ +static json_t *torznab_parse(const char *xml, size_t len, const char *engine) { + json_t *rows = json_array(); + const char *p = xml, *end = xml + len; + for (;;) { + const char *open = memmem(p, (size_t)(end - p), "<item", 5); + if (!open) break; + const char *close = memmem(open, (size_t)(end - open), "</item>", 7); + if (!close) break; + size_t ilen = (size_t)(close - open); + char title[512] = {0}, magnet[2048] = {0}, enclosure[1024] = {0}; + char pub[128] = {0}, lenstr[64] = {0}; + xml_tag_text(open, ilen, "title", title, sizeof title); + xml_tag_text(open, ilen, "pubDate", pub, sizeof pub); + find_magnet(open, ilen, magnet, sizeof magnet); + xml_attr(open, ilen, "enclosure", "url", enclosure, sizeof enclosure); + if (!xml_attr(open, ilen, "enclosure", "length", lenstr, sizeof lenstr)) + xml_tag_text(open, ilen, "size", lenstr, sizeof lenstr); + /* Torznab seeders/peers live in <torznab:attr name="seeders" value=.. /> */ + long seeds = 0, leech = 0; + const char *ap = open; + while (ap < close) { + const char *attr = memmem(ap, (size_t)(close - ap), "name=\"", 6); + if (!attr) break; + char an[32] = {0}, av[32] = {0}; + const char *aq = attr + 6; + const char *aqe = memchr(aq, '"', (size_t)(close - aq)); + if (!aqe) break; + snprintf(an, sizeof an, "%.*s", (int)(aqe - aq) < 31 ? (int)(aqe - aq) : 31, aq); + const char *valk = memmem(aqe, (size_t)(close - aqe), "value=\"", 7); + const char *gt = (const char *)memchr(aqe, '>', (size_t)(close - aqe)); + if (valk && (!gt || valk < gt)) { + const char *vs = valk + 7; + const char *ve = memchr(vs, '"', (size_t)(close - vs)); + if (ve) snprintf(av, sizeof av, "%.*s", (int)(ve - vs) < 31 ? (int)(ve - vs) : 31, vs); + } + if (strcmp(an, "seeders") == 0) seeds = strtol(av, NULL, 10); + else if (strcmp(an, "peers") == 0 || strcmp(an, "leechers") == 0) leech = strtol(av, NULL, 10); + ap = aqe + 1; + } + if (title[0]) { + json_array_append_new(rows, json_pack( + "{s:s,s:I,s:i,s:i,s:s,s:s,s:s,s:s}", + "name", title, "size", (json_int_t)strtoll(lenstr, NULL, 10), + "seeds", (int)seeds, "leeches", (int)leech, + "engine", engine, "pubDate", pub, + "magnet", magnet, "torrentUrl", enclosure)); + } + p = close + 7; + } + return rows; +} + +/* GET /api/search?q=… queries every enabled Torznab indexer and merges rows. */ +static void api_search(int fd, const char *query) { + json_t *results = json_array(); + json_t *indexers = json_array(); + if (g_webui.store) webui_store_indexer_list(g_webui.store, indexers); + + size_t i; json_t *ix; + json_array_foreach(indexers, i, ix) { + if (!json_boolean_value(json_object_get(ix, "enabled"))) continue; + const char *base = json_string_or(ix, "url", ""); + const char *key = json_string_or(ix, "apikey", ""); + const char *engine = json_string_or(ix, "name", "indexer"); + if (!*base) continue; + char url[2048]; + snprintf(url, sizeof url, "%s%st=search&q=%s%s%s", + base, strchr(base, '?') ? "&" : "?", + query ? query : "", + *key ? "&apikey=" : "", key); + naut_http_response r; + if (naut_http_get(url, &r) == NAUT_OK && r.status / 100 == 2 && r.body) { + json_t *rows = torznab_parse(r.body, r.body_len, engine); + size_t j; json_t *row; + json_array_foreach(rows, j, row) json_array_append(results, row); + json_decref(rows); + } + naut_http_response_free(&r); + } + json_decref(indexers); + http_json(fd, 200, results); + json_decref(results); +} + +/* POST /api/categories and /api/categories/delete */ +static void api_categories(int fd, const char *body, size_t len, bool remove) { + json_t *req = read_body_json(body, len); + const char *name = json_string_value(json_object_get(req, "name")); + if (name && *name) { + if (remove) { store_remove_category(name); webui_sync_all_labels(); } + else store_add_category(name, + json_string_value(json_object_get(req, "savePath"))); + webui_sync_taxonomy(); /* persist the category list via the daemon */ + } + json_decref(req); + pthread_mutex_lock(&g_webui.meta_lock); + json_t *reply = json_deep_copy(g_webui.categories); + pthread_mutex_unlock(&g_webui.meta_lock); + http_json(fd, 200, reply); + json_decref(reply); +} + +/* POST /api/categories/edit — rename a category and/or change its save path, + * reassigning every torrent that referenced the old name. The empty-named + * "Uncategorized" pseudo-category can have its savePath set but not renamed. */ +static void api_category_edit(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *name = json_string_value(json_object_get(req, "name")); + const char *new_name = json_string_value(json_object_get(req, "newName")); + const char *save_path = json_string_value(json_object_get(req, "savePath")); + if (!name) name = ""; + if (!save_path) save_path = ""; + bool rename = new_name && *new_name && *name && strcmp(new_name, name) != 0; + const char *target = rename ? new_name : name; + + pthread_mutex_lock(&g_webui.meta_lock); + int idx = find_category(name); + if (idx >= 0) { + json_t *cat = json_array_get(g_webui.categories, (size_t)idx); + json_object_set_new(cat, "name", json_string(target)); + json_object_set_new(cat, "savePath", json_string(save_path)); + } else if (*target || *save_path) { + /* The category didn't exist yet (e.g. setting Uncategorized's path, or + * editing a name that was only implied by assignments). An empty name + * is allowed here: it holds Uncategorized's default save path. */ + json_array_append_new(g_webui.categories, json_pack( + "{s:s,s:s}", "name", target, "savePath", save_path)); + } + if (rename) { + const char *key; + json_t *entry; + json_object_foreach(g_webui.assignments, key, entry) + if (strcmp(json_string_or(entry, "category", ""), name) == 0) + json_object_set_new(entry, "category", json_string(target)); + } + pthread_mutex_unlock(&g_webui.meta_lock); + + if (rename) webui_sync_all_labels(); + webui_sync_taxonomy(); + + json_decref(req); + pthread_mutex_lock(&g_webui.meta_lock); + json_t *reply = json_deep_copy(g_webui.categories); + pthread_mutex_unlock(&g_webui.meta_lock); + http_json(fd, 200, reply); + json_decref(reply); +} + +/* POST /api/tags and /api/tags/delete */ +static void api_tags(int fd, const char *body, size_t len, bool remove) { + json_t *req = read_body_json(body, len); + const char *name = json_string_value(json_object_get(req, "name")); + if (name && *name) { + if (remove) { store_remove_tag(name); webui_sync_all_labels(); } + else store_add_tag(name); + webui_sync_taxonomy(); /* persist the tag list via the daemon */ + } + json_decref(req); + pthread_mutex_lock(&g_webui.meta_lock); + json_t *reply = json_deep_copy(g_webui.tags); + pthread_mutex_unlock(&g_webui.meta_lock); + http_json(fd, 200, reply); + json_decref(reply); +} + +static void api_stream(int fd) { + const char *head = + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n" + "Cache-Control: no-cache\r\nConnection: keep-alive\r\n\r\n" + "retry: 2000\n\n"; + if (!send_all_fd(fd, head, strlen(head))) return; + uint64_t seen = 0; + while (!atomic_load(&g_webui.stopping)) { + char *payload = NULL; + pthread_mutex_lock(&g_webui.snap_lock); + while (!atomic_load(&g_webui.stopping) && g_webui.snap_seq == seen) { + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_nsec += 250 * 1000 * 1000; + if (ts.tv_nsec >= 1000000000) { ts.tv_sec++; ts.tv_nsec -= 1000000000; } + pthread_cond_timedwait(&g_webui.snap_cond, &g_webui.snap_lock, &ts); + } + if (!atomic_load(&g_webui.stopping) && g_webui.snapshot_str) { + payload = strdup(g_webui.snapshot_str); + seen = g_webui.snap_seq; + } + pthread_mutex_unlock(&g_webui.snap_lock); + if (!payload) break; + bool ok = send_all_fd(fd, "event: snapshot\ndata: ", 22) && + send_all_fd(fd, payload, strlen(payload)) && + send_all_fd(fd, "\n\n", 2); + free(payload); + if (!ok) break; + } +} + +static void serve_cached_snapshot(int fd) { + pthread_mutex_lock(&g_webui.snap_lock); + char *copy = g_webui.snapshot_str ? strdup(g_webui.snapshot_str) : NULL; + pthread_mutex_unlock(&g_webui.snap_lock); + http_json_str(fd, copy, "{\"server\":{},\"torrents\":[]}"); + free(copy); +} + +static void serve_cached_torrents(int fd) { + pthread_mutex_lock(&g_webui.snap_lock); + char *copy = g_webui.torrents_str ? strdup(g_webui.torrents_str) : NULL; + pthread_mutex_unlock(&g_webui.snap_lock); + http_json_str(fd, copy, "[]"); + free(copy); +} + +/* ============================ account management ========================== * + * Admin-only user CRUD plus a self-service password change. The web layer owns + * everything via webui_store; the daemon is not involved. */ + +static bool valid_username(const char *u) { + if (!u || !*u || strlen(u) >= 64) return false; + for (const char *p = u; *p; p++) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '_' || *p == '-' || *p == '.')) + return false; + return true; +} + +/* GET /api/users → [{username, role, createdAt}] (admin only). */ +static void api_users_list(int fd) { + json_t *users = json_array(); + if (g_webui.store) webui_store_list_users(g_webui.store, users); + http_json(fd, 200, users); + json_decref(users); +} + +/* POST /api/users {username, password, role} (admin only). */ +static void api_user_create(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *user = json_string_value(json_object_get(req, "username")); + const char *pass = json_string_value(json_object_get(req, "password")); + const char *role = json_string_value(json_object_get(req, "role")); + if (!valid_username(user) || !pass || !*pass) { + json_decref(req); + http_text(fd, 400, "Bad Request", + "username (letters/digits/._-) and password are required"); + return; + } + bool ok = g_webui.store && + webui_store_create_user(g_webui.store, user, pass, + role && *role ? role : "user"); + json_decref(req); + if (!ok) { http_text(fd, 409, "Conflict", "user already exists"); return; } + api_users_list(fd); +} + +/* POST /api/users/delete {username} (admin only). Refuses to remove the last + * admin so the instance can't lock everyone out. */ +static void api_user_delete(int fd, const char *body, size_t len, + const char *actor) { + json_t *req = read_body_json(body, len); + const char *uname = json_string_value(json_object_get(req, "username")); + if (!uname || !*uname) { json_decref(req); http_text(fd, 400, "Bad Request", "username required"); return; } + char user[64]; + snprintf(user, sizeof user, "%s", uname); /* own it before decref */ + char role[16] = {0}; + /* Look up the target's role to guard the last-admin rule. */ + json_t *list = json_array(); + if (g_webui.store) webui_store_list_users(g_webui.store, list); + size_t i; json_t *u; + json_array_foreach(list, i, u) + if (strcasecmp(json_string_or(u, "username", ""), user) == 0) + snprintf(role, sizeof role, "%s", json_string_or(u, "role", "")); + json_decref(list); + if (strcmp(role, "admin") == 0 && webui_store_admin_count(g_webui.store) <= 1) { + json_decref(req); + http_text(fd, 409, "Conflict", "cannot delete the last admin"); + return; + } + bool ok = g_webui.store && webui_store_delete_user(g_webui.store, user); + json_decref(req); + if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; } + drop_user_sessions(user); + (void)actor; + api_users_list(fd); +} + +/* POST /api/users/password {username, password} — admin reset. */ +static void api_user_set_password(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *uname = json_string_value(json_object_get(req, "username")); + const char *pass = json_string_value(json_object_get(req, "password")); + if (!uname || !*uname || !pass || !*pass) { + json_decref(req); + http_text(fd, 400, "Bad Request", "username and password required"); + return; + } + char user[64]; + snprintf(user, sizeof user, "%s", uname); + bool ok = g_webui.store && webui_store_set_password(g_webui.store, user, pass); + json_decref(req); + if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; } + drop_user_sessions(user); /* force re-login with the new password */ + json_t *reply = json_pack("{s:b}", "ok", 1); + http_json(fd, 200, reply); + json_decref(reply); +} + +/* POST /api/users/role {username, role} — admin; keeps at least one admin. */ +static void api_user_set_role(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *user = json_string_value(json_object_get(req, "username")); + const char *role = json_string_value(json_object_get(req, "role")); + if (!user || !*user || (strcmp(role ? role : "", "admin") && strcmp(role ? role : "", "user"))) { + json_decref(req); + http_text(fd, 400, "Bad Request", "username and role (admin|user) required"); + return; + } + if (strcmp(role, "user") == 0 && webui_store_admin_count(g_webui.store) <= 1) { + /* Only block if the target is currently the sole admin. */ + char cur[16] = {0}; + json_t *list = json_array(); + if (g_webui.store) webui_store_list_users(g_webui.store, list); + size_t i; json_t *u; + json_array_foreach(list, i, u) + if (strcasecmp(json_string_or(u, "username", ""), user) == 0) + snprintf(cur, sizeof cur, "%s", json_string_or(u, "role", "")); + json_decref(list); + if (strcmp(cur, "admin") == 0) { + json_decref(req); + http_text(fd, 409, "Conflict", "cannot demote the last admin"); + return; + } + } + bool ok = g_webui.store && webui_store_set_role(g_webui.store, user, role); + json_decref(req); + if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; } + api_users_list(fd); +} + +/* POST /api/account/password {oldPassword, newPassword} — change own password. */ +static void api_account_password(int fd, const char *body, size_t len, + const char *actor) { + json_t *req = read_body_json(body, len); + const char *oldp = json_string_value(json_object_get(req, "oldPassword")); + const char *newp = json_string_value(json_object_get(req, "newPassword")); + if (!oldp || !newp || !*newp) { + json_decref(req); + http_text(fd, 400, "Bad Request", "oldPassword and newPassword required"); + return; + } + char role[16] = {0}; + if (!g_webui.store || !webui_store_verify(g_webui.store, actor, oldp, role, sizeof role)) { + json_decref(req); + http_text(fd, 403, "Forbidden", "current password is incorrect"); + return; + } + bool ok = webui_store_set_password(g_webui.store, actor, newp); + json_decref(req); + if (!ok) { http_text(fd, 500, "Internal Server Error", "could not update password"); return; } + json_t *reply = json_pack("{s:b}", "ok", 1); + http_json(fd, 200, reply); + json_decref(reply); +} + +static void handle_api(int fd, const char *method, char *path, + const char *headers, const char *headers_end, + const char *body, size_t body_len) { + /* Snapshot the query string before strip_query() truncates it. */ + char query_str[1024] = {0}; + const char *qmark = strchr(path, '?'); + if (qmark) snprintf(query_str, sizeof query_str, "%s", qmark + 1); + strip_query(path); + char cur_user[64] = {0}, cur_role[16] = {0}; + if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) { + bool authed = current_identity(headers, headers_end, cur_user, + sizeof cur_user, cur_role, sizeof cur_role); + json_t *json = json_pack("{s:b,s:s,s:s,s:b}", + "authenticated", authed, + "user", authed ? cur_user : "", + "role", authed ? cur_role : "", + "generatedPassword", g_webui.generated_password); + http_json(fd, 200, json); + json_decref(json); + } else if (strcmp(path, "/api/login") == 0 && strcmp(method, "POST") == 0) { + json_t *req = read_body_json(body, body_len); + const char *user = json_string_value(json_object_get(req, "username")); + const char *password = + json_string_value(json_object_get(req, "password")); + char role[16] = {0}; + bool ok = g_webui.store && user && password && + webui_store_verify(g_webui.store, user, password, role, sizeof role); + if (!ok) { + json_t *json = json_pack("{s:b,s:s}", "ok", 0, + "error", "invalid credentials"); + http_json(fd, 401, json); + json_decref(json); + json_decref(req); + return; + } + char token[96]; + if (!create_session(user, role, token, sizeof token)) { + json_decref(req); + http_text(fd, 500, "Internal Server Error", "session failed"); + return; + } + char cookie[256]; + snprintf(cookie, sizeof cookie, + "Set-Cookie: %s=%s; Path=/; HttpOnly; SameSite=Lax; " + "Max-Age=%ld\r\n", + SESSION_COOKIE, token, session_ttl()); + json_t *json = json_pack("{s:b,s:s,s:s}", "ok", 1, + "user", user, "role", role); + http_json_extra(fd, 200, json, cookie); + json_decref(json); + json_decref(req); + } else if (strcmp(path, "/api/logout") == 0 && strcmp(method, "POST") == 0) { + clear_session(headers, headers_end); + json_t *json = json_pack("{s:b}", "ok", 1); + http_json_extra(fd, 200, json, + "Set-Cookie: naut_session=; Path=/; HttpOnly; " + "SameSite=Lax; Max-Age=0\r\n"); + json_decref(json); + } else if (!current_identity(headers, headers_end, cur_user, sizeof cur_user, + cur_role, sizeof cur_role)) { + json_t *json = json_pack("{s:s}", "error", + "authentication required"); + http_json(fd, 401, json); + json_decref(json); + } else if (strcmp(path, "/api/plugins") == 0 && strcmp(method, "GET") == 0) { + api_plugins(fd); + } else if (strcmp(path, "/api/stream") == 0 && strcmp(method, "GET") == 0) { + api_stream(fd); + } else if (strcmp(path, "/api/snapshot") == 0 && strcmp(method, "GET") == 0) { + serve_cached_snapshot(fd); + } else if (strcmp(path, "/api/meta") == 0 && strcmp(method, "GET") == 0) { + api_meta(fd); + } else if (strcmp(path, "/api/preferences") == 0) { + api_preferences(fd, method, body, body_len); + } else if (strcmp(path, "/api/altspeed") == 0 && strcmp(method, "POST") == 0) { + api_altspeed(fd); + } else if (strcmp(path, "/api/script/settings") == 0) { + api_script_settings(fd, method, body, body_len); + } else if (strcmp(path, "/api/script") == 0) { + api_script(fd, method, body, body_len); + } else if (strcmp(path, "/api/categories") == 0 && + strcmp(method, "POST") == 0) { + api_categories(fd, body, body_len, false); + } else if (strcmp(path, "/api/categories/delete") == 0 && + strcmp(method, "POST") == 0) { + api_categories(fd, body, body_len, true); + } else if (strcmp(path, "/api/categories/edit") == 0 && + strcmp(method, "POST") == 0) { + api_category_edit(fd, body, body_len); + } else if (strcmp(path, "/api/tags") == 0 && + strcmp(method, "POST") == 0) { + api_tags(fd, body, body_len, false); + } else if (strcmp(path, "/api/tags/delete") == 0 && + strcmp(method, "POST") == 0) { + api_tags(fd, body, body_len, true); + } else if (strcmp(path, "/api/torrents") == 0 && strcmp(method, "GET") == 0) { + serve_cached_torrents(fd); + } else if (path_after(path, "/api/torrents/") && strcmp(method, "GET") == 0) { + api_torrent_detail(fd, path_after(path, "/api/torrents/")); + } else if (strcmp(path, "/api/add") == 0 && strcmp(method, "POST") == 0) { + api_add(fd, body, body_len); + } else if (strcmp(path, "/api/delete") == 0 && strcmp(method, "POST") == 0) { + api_delete(fd, body, body_len); + } else if (strcmp(path, "/api/action") == 0 && strcmp(method, "POST") == 0) { + api_action(fd, body, body_len); + } else if (strcmp(path, "/api/account/password") == 0 && strcmp(method, "POST") == 0) { + api_account_password(fd, body, body_len, cur_user); + } else if (strncmp(path, "/api/users", 10) == 0) { + /* All user-management endpoints are admin-only. */ + if (strcmp(cur_role, "admin") != 0) { + http_text(fd, 403, "Forbidden", "admin privileges required"); + } else if (strcmp(path, "/api/users") == 0 && strcmp(method, "GET") == 0) { + api_users_list(fd); + } else if (strcmp(path, "/api/users") == 0 && strcmp(method, "POST") == 0) { + api_user_create(fd, body, body_len); + } else if (strcmp(path, "/api/users/delete") == 0 && strcmp(method, "POST") == 0) { + api_user_delete(fd, body, body_len, cur_user); + } else if (strcmp(path, "/api/users/password") == 0 && strcmp(method, "POST") == 0) { + api_user_set_password(fd, body, body_len); + } else if (strcmp(path, "/api/users/role") == 0 && strcmp(method, "POST") == 0) { + api_user_set_role(fd, body, body_len); + } else { + http_text(fd, 404, "Not Found", "not found"); + } + } else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "GET") == 0) { + api_rss_list(fd); + } else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "POST") == 0) { + api_rss_feed(fd, body, body_len, false); + } else if (strcmp(path, "/api/rss/delete") == 0 && strcmp(method, "POST") == 0) { + api_rss_feed(fd, body, body_len, true); + } else if (strcmp(path, "/api/rss/rules") == 0 && strcmp(method, "GET") == 0) { + api_rss_rules_list(fd); + } else if (strcmp(path, "/api/rss/rules") == 0 && strcmp(method, "POST") == 0) { + api_rss_rule(fd, body, body_len, false); + } else if (strcmp(path, "/api/rss/rules/delete") == 0 && strcmp(method, "POST") == 0) { + api_rss_rule(fd, body, body_len, true); + } else if (strcmp(path, "/api/rss/rules/run") == 0 && strcmp(method, "POST") == 0) { + api_rss_rule_run(fd, body, body_len); + } else if (strcmp(path, "/api/rss/refresh") == 0 && strcmp(method, "POST") == 0) { + api_rss_refresh(fd, body, body_len); + } else if (strcmp(path, "/api/rss/download") == 0 && strcmp(method, "POST") == 0) { + api_rss_download(fd, body, body_len); + } else if (strcmp(path, "/api/indexers") == 0 && strcmp(method, "POST") == 0) { + api_indexer(fd, body, body_len, false); + } else if (strcmp(path, "/api/indexers/delete") == 0 && strcmp(method, "POST") == 0) { + api_indexer(fd, body, body_len, true); + } else if (strcmp(path, "/api/search") == 0 && strcmp(method, "GET") == 0) { + char q[512] = {0}; + query_get(query_str, "q", q, sizeof q); + /* re-encode spaces for the upstream query (decode happened above) */ + char enc[1024]; size_t eo = 0; + for (size_t i = 0; q[i] && eo + 4 < sizeof enc; i++) { + unsigned char c = (unsigned char)q[i]; + if ((c >= 'a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')|| + c=='-'||c=='_'||c=='.'||c=='~') enc[eo++] = (char)c; + else eo += (size_t)snprintf(enc + eo, sizeof enc - eo, "%%%02X", c); + } + enc[eo] = 0; + api_search(fd, enc); + } else { + http_text(fd, 404, "Not Found", "not found"); + } +} + +static void handle_conn(int fd) { + char *request = malloc(READ_LIMIT + 1); + if (!request) { + http_text(fd, 500, "Internal Server Error", "oom"); + return; + } + size_t len = 0; + char *hdrend = NULL; + while (len < READ_LIMIT) { + ssize_t n = recv(fd, request + len, READ_LIMIT - len, 0); + if (n < 0) { + if (errno == EINTR) continue; + free(request); + return; + } + if (n == 0) break; + len += (size_t)n; + request[len] = 0; + hdrend = memmem(request, len, "\r\n\r\n", 4); + if (hdrend) break; + } + if (!hdrend) { + free(request); + http_text(fd, 400, "Bad Request", "malformed request"); + return; + } + char method[8] = {0}; + char path[PATH_MAX] = {0}; + if (sscanf(request, "%7s %4095s", method, path) != 2) { + free(request); + http_text(fd, 400, "Bad Request", "malformed request line"); + return; + } + size_t header_len = (size_t)(hdrend - request) + 4; + size_t content_length = 0; + char *cl = strcasestr(request, "content-length:"); + if (cl && cl < hdrend) content_length = strtoull(cl + 15, NULL, 10); + /* Reject bodies we can't buffer instead of silently truncating an upload + * into a corrupt torrent. */ + if (content_length > READ_LIMIT - header_len) { + free(request); + http_text(fd, 413, "Payload Too Large", + "request body exceeds the 8 MiB limit"); + return; + } + while (len - header_len < content_length && len < READ_LIMIT) { + ssize_t n = recv(fd, request + len, READ_LIMIT - len, 0); + if (n < 0) { + if (errno == EINTR) continue; + break; + } + if (n == 0) break; + len += (size_t)n; + request[len] = 0; + } + char *body = request + header_len; + size_t body_len = len > header_len ? len - header_len : 0; + if (strncmp(path, "/api/", 5) == 0) + handle_api(fd, method, path, request, hdrend, body, body_len); + else if (!serve_file(fd, path)) + http_text(fd, 404, "Not Found", "not found"); + free(request); +} + +static void finish_connection(void) { + pthread_mutex_lock(&g_webui.conn_lock); + if (g_webui.active_connections > 0) g_webui.active_connections--; + pthread_cond_signal(&g_webui.conn_cond); + pthread_mutex_unlock(&g_webui.conn_lock); +} + +static void *conn_thread(void *arg) { + conn_arg *conn = arg; + handle_conn(conn->fd); + close(conn->fd); + free(conn); + finish_connection(); + return NULL; +} + +static void *server_thread(void *arg) { + (void)arg; + for (;;) { + int fd = accept(g_webui.listener, NULL, NULL); + if (fd < 0) { + if (errno == EINTR) continue; + if (atomic_load(&g_webui.stopping)) break; + continue; + } + struct timeval timeout = { .tv_sec = 5, .tv_usec = 0 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof timeout); + + /* Bound concurrent connections so a client can't spawn unlimited + * threads (each SSE stream parks one). */ + pthread_mutex_lock(&g_webui.conn_lock); + bool full = g_webui.active_connections >= MAX_CONNECTIONS; + if (!full) g_webui.active_connections++; + pthread_mutex_unlock(&g_webui.conn_lock); + if (full) { + http_text(fd, 503, "Service Unavailable", "too many connections"); + close(fd); + continue; + } + + conn_arg *conn = malloc(sizeof(*conn)); + if (!conn) { + close(fd); + finish_connection(); + continue; + } + conn->fd = fd; + pthread_t thread; + if (pthread_create(&thread, NULL, conn_thread, conn) != 0) { + close(fd); + free(conn); + finish_connection(); + continue; + } + pthread_detach(thread); + } + return NULL; +} + +static bool dir_exists(const char *path) { + struct stat st; + return path && stat(path, &st) == 0 && S_ISDIR(st.st_mode); +} + +static const char *find_root(void) { + const char *env = getenv("NAUT_WEBUI_ROOT"); + if (dir_exists(env)) return env; + static const char *candidates[] = { + "../torrent-ui/public", + "torrent-ui/public", + "./public", + "/usr/share/naut/torrent-ui/public", + }; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) + if (dir_exists(candidates[i])) return candidates[i]; + return NULL; +} + +static int parse_port(void) { + const char *env = getenv("NAUT_WEBUI_PORT"); + if (!env || !*env) return DEFAULT_PORT; + char *end = NULL; + long port = strtol(env, &end, 10); + return end && !*end && port > 0 && port <= 65535 ? (int)port : DEFAULT_PORT; +} + +static naut_err start_server(void) { + const char *root = find_root(); + if (!root) { + log_msg(0, "webui: could not find torrent-ui public assets; set NAUT_WEBUI_ROOT"); + return NAUT_ERR_NOTFOUND; + } + snprintf(g_webui.root, sizeof g_webui.root, "%s", root); + const char *host = getenv("NAUT_WEBUI_HOST"); + if (!host || !*host || strcmp(host, "localhost") == 0) host = DEFAULT_HOST; + snprintf(g_webui.host_name, sizeof g_webui.host_name, "%s", host); + g_webui.port = parse_port(); + + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return NAUT_ERR_IO; + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + struct sockaddr_in addr; + memset(&addr, 0, sizeof addr); + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)g_webui.port); + if (inet_pton(AF_INET, g_webui.host_name, &addr.sin_addr) != 1) { + close(fd); + return NAUT_ERR_INVAL; + } + if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0 || + listen(fd, 64) != 0) { + close(fd); + return NAUT_ERR_IO; + } + g_webui.listener = fd; + + /* Prime the cache so the first request doesn't see an empty snapshot. */ + publish_snapshot(); + if (pthread_create(&g_webui.sampler, NULL, sampler_thread, NULL) != 0) { + close(fd); + g_webui.listener = -1; + return NAUT_ERR_NOMEM; + } + g_webui.sampler_started = true; + if (pthread_create(&g_webui.thread, NULL, server_thread, NULL) != 0) { + atomic_store(&g_webui.stopping, true); + pthread_join(g_webui.sampler, NULL); + g_webui.sampler_started = false; + close(fd); + g_webui.listener = -1; + return NAUT_ERR_NOMEM; + } + g_webui.thread_started = true; + char msg[PATH_MAX + 128]; + snprintf(msg, sizeof msg, "webui: serving http://%s:%d from %s", + g_webui.host_name, g_webui.port, g_webui.root); + log_msg(2, msg); + if (strcmp(g_webui.host_name, DEFAULT_HOST) != 0) + log_msg(1, "webui: bound to a non-loopback address; credentials cross " + "the network in plaintext (set NAUT_AUTH_PASSWORD)"); + if (!g_webui.store) { + log_msg(0, "webui: account store unavailable; logins will fail"); + } else if (g_webui.generated_password && g_webui.auth_password[0]) { + /* First run: surface the generated admin credentials once. */ + snprintf(msg, sizeof msg, + "webui: created initial admin '%s' with generated password %s", + g_webui.auth_user, g_webui.auth_password); + log_msg(1, msg); + } + return NAUT_OK; +} + +naut_err naut_plugin_register(const naut_host_api *host) { + if (!host || host->abi_version != NAUT_PLUGIN_ABI_VERSION || + host->struct_size < sizeof(*host) || !host->call_rpc) + return NAUT_ERR_INVAL; + naut_err error = NAUT_ERR_NOMEM; + memset(&g_webui, 0, sizeof g_webui); + g_webui.listener = -1; + g_webui.host = *host; + if (pthread_mutex_init(&g_webui.conn_lock, NULL) != 0) + return NAUT_ERR_NOMEM; + if (pthread_cond_init(&g_webui.conn_cond, NULL) != 0) + goto fail_conn_cond; + if (pthread_mutex_init(&g_webui.snap_lock, NULL) != 0) + goto fail_snap_lock; + if (pthread_cond_init(&g_webui.snap_cond, NULL) != 0) + goto fail_snap_cond; + if (pthread_mutex_init(&g_webui.speed_lock, NULL) != 0) + goto fail_speed_lock; + if (pthread_mutex_init(&g_webui.meta_lock, NULL) != 0) + goto fail_meta_lock; + g_webui.categories = json_array(); + g_webui.tags = json_array(); + g_webui.assignments = json_object(); + if (!g_webui.categories || !g_webui.tags || !g_webui.assignments) + goto fail_store; + pthread_mutex_init(&g_webui.rss_lock, NULL); + pthread_cond_init(&g_webui.rss_cond, NULL); + init_auth(); + if (g_webui.store) /* drop sessions that expired while we were down */ + webui_store_sessions_prune(g_webui.store, (long)time(NULL)); + error = g_webui.host.set_plugin_name(g_webui.host.host_context, + "webui"); + if (error != NAUT_OK) goto fail_store; + error = start_server(); + if (error != NAUT_OK) goto fail_store; + webui_load_taxonomy(); /* restore category + tag lists from the daemon */ + /* RSS poller: loads feeds/rules from the blob store and polls in the bg. */ + if (pthread_create(&g_webui.rss_thread, NULL, rss_thread_fn, NULL) == 0) + g_webui.rss_thread_started = true; + return NAUT_OK; + +fail_store: + json_decref(g_webui.categories); + json_decref(g_webui.tags); + json_decref(g_webui.assignments); + pthread_mutex_destroy(&g_webui.meta_lock); +fail_meta_lock: + pthread_mutex_destroy(&g_webui.speed_lock); +fail_speed_lock: + pthread_cond_destroy(&g_webui.snap_cond); +fail_snap_cond: + pthread_mutex_destroy(&g_webui.snap_lock); +fail_snap_lock: + pthread_cond_destroy(&g_webui.conn_cond); +fail_conn_cond: + pthread_mutex_destroy(&g_webui.conn_lock); + return error; +} + +naut_err naut_plugin_shutdown(void) { + atomic_store(&g_webui.stopping, true); + /* wake any SSE streams parked on the snapshot condition */ + pthread_mutex_lock(&g_webui.snap_lock); + pthread_cond_broadcast(&g_webui.snap_cond); + pthread_mutex_unlock(&g_webui.snap_lock); + if (g_webui.listener >= 0) { + shutdown(g_webui.listener, SHUT_RDWR); + close(g_webui.listener); + g_webui.listener = -1; + } + if (g_webui.thread_started) + pthread_join(g_webui.thread, NULL); + g_webui.thread_started = false; + if (g_webui.sampler_started) + pthread_join(g_webui.sampler, NULL); + g_webui.sampler_started = false; + if (g_webui.rss_thread_started) { + pthread_mutex_lock(&g_webui.rss_lock); + pthread_cond_signal(&g_webui.rss_cond); /* wake the poller to exit */ + pthread_mutex_unlock(&g_webui.rss_lock); + pthread_join(g_webui.rss_thread, NULL); + g_webui.rss_thread_started = false; + } + pthread_mutex_lock(&g_webui.conn_lock); + while (g_webui.active_connections > 0) + pthread_cond_wait(&g_webui.conn_cond, &g_webui.conn_lock); + pthread_mutex_unlock(&g_webui.conn_lock); + + free(g_webui.snapshot_str); + free(g_webui.torrents_str); + g_webui.snapshot_str = NULL; + g_webui.torrents_str = NULL; + + json_decref(g_webui.categories); + json_decref(g_webui.tags); + json_decref(g_webui.assignments); + g_webui.categories = NULL; + g_webui.tags = NULL; + g_webui.assignments = NULL; + pthread_cond_destroy(&g_webui.rss_cond); + pthread_mutex_destroy(&g_webui.rss_lock); + + pthread_mutex_destroy(&g_webui.meta_lock); + pthread_mutex_destroy(&g_webui.speed_lock); + pthread_cond_destroy(&g_webui.snap_cond); + pthread_mutex_destroy(&g_webui.snap_lock); + pthread_cond_destroy(&g_webui.conn_cond); + pthread_mutex_destroy(&g_webui.conn_lock); + webui_store_close(g_webui.store); + g_webui.store = NULL; + return NAUT_OK; +} diff --git a/plugins/webui/webui_store.c b/plugins/webui/webui_store.c new file mode 100644 index 0000000..9b69089 --- /dev/null +++ b/plugins/webui/webui_store.c @@ -0,0 +1,1135 @@ +/* webui_store.c — SQLite + PBKDF2 implementation of the web-UI account store. */ +#include "webui_store.h" + +#include <pthread.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +#include <sqlite3.h> +#include <openssl/evp.h> +#include <openssl/rand.h> +#include <openssl/crypto.h> + +#define PBKDF2_ITERS 210000 +#define SALT_BYTES 16 +#define HASH_BYTES 32 + +struct webui_store { + sqlite3 *db; + pthread_mutex_t lock; +}; + +/* One-time upgrade of a pre-normalization DB (feeds.articles / rules.affected_feeds + * JSON columns) to the relational articles + rule_feeds tables. Defined at the + * end of the file so it can use the row helpers. */ +static void legacy_migrate(webui_store *s); + +static void to_hex(const unsigned char *in, size_t n, char *out) { + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < n; i++) { + out[i * 2] = hex[in[i] >> 4]; + out[i * 2 + 1] = hex[in[i] & 0xf]; + } + out[n * 2] = 0; +} + +static int from_hex(const char *in, unsigned char *out, size_t out_n) { + size_t len = strlen(in); + if (len != out_n * 2) return -1; + for (size_t i = 0; i < out_n; i++) { + char c[3] = { in[i * 2], in[i * 2 + 1], 0 }; + char *end; + long v = strtol(c, &end, 16); + if (end != c + 2) return -1; + out[i] = (unsigned char)v; + } + return 0; +} + +/* Derive a hash for `password` with the given salt + iteration count. */ +static bool derive(const char *password, const unsigned char *salt, + size_t salt_n, int iters, unsigned char out[HASH_BYTES]) { + return PKCS5_PBKDF2_HMAC(password, (int)strlen(password), salt, (int)salt_n, + iters, EVP_sha256(), HASH_BYTES, out) == 1; +} + +static bool valid_role(const char *role) { + return role && (strcmp(role, "admin") == 0 || strcmp(role, "user") == 0); +} + +webui_store *webui_store_open(const char *path) { + webui_store *s = calloc(1, sizeof *s); + if (!s) return NULL; + if (pthread_mutex_init(&s->lock, NULL) != 0) { free(s); return NULL; } + if (sqlite3_open(path, &s->db) != SQLITE_OK) { + sqlite3_close(s->db); + pthread_mutex_destroy(&s->lock); + free(s); + return NULL; + } + sqlite3_busy_timeout(s->db, 4000); + const char *schema = + "PRAGMA journal_mode=WAL;" + "CREATE TABLE IF NOT EXISTS users (" + " id INTEGER PRIMARY KEY," + " username TEXT NOT NULL UNIQUE COLLATE NOCASE," + " pw_hash TEXT NOT NULL," + " pw_salt TEXT NOT NULL," + " pw_iters INTEGER NOT NULL," + " role TEXT NOT NULL DEFAULT 'user'," + " created_at INTEGER NOT NULL);" + "CREATE TABLE IF NOT EXISTS sessions (" + " token_hash TEXT PRIMARY KEY," + " username TEXT NOT NULL," + " role TEXT NOT NULL DEFAULT 'user'," + " expires INTEGER NOT NULL);" + "CREATE INDEX IF NOT EXISTS sessions_user ON sessions(username);" + "CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires);" + "CREATE TABLE IF NOT EXISTS categories (" + " name TEXT PRIMARY KEY," + " save_path TEXT NOT NULL DEFAULT '');" + "CREATE TABLE IF NOT EXISTS tags (name TEXT PRIMARY KEY);" + "CREATE TABLE IF NOT EXISTS feeds (" + " name TEXT PRIMARY KEY," + " url TEXT NOT NULL," + " last_update INTEGER NOT NULL DEFAULT 0);" + "CREATE TABLE IF NOT EXISTS articles (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " feed TEXT NOT NULL," + " key TEXT NOT NULL," + " title TEXT NOT NULL DEFAULT ''," + " magnet TEXT NOT NULL DEFAULT ''," + " torrent_url TEXT NOT NULL DEFAULT ''," + " link TEXT NOT NULL DEFAULT ''," + " size INTEGER NOT NULL DEFAULT 0," + " pub_date TEXT NOT NULL DEFAULT ''," + " is_read INTEGER NOT NULL DEFAULT 0," + " grabbed INTEGER NOT NULL DEFAULT 0," + " seen_at INTEGER NOT NULL DEFAULT 0," + " UNIQUE(feed, key));" + "CREATE INDEX IF NOT EXISTS articles_feed ON articles(feed);" + "CREATE INDEX IF NOT EXISTS articles_key ON articles(key);" + "CREATE INDEX IF NOT EXISTS articles_grabbed ON articles(grabbed);" + "CREATE TABLE IF NOT EXISTS rules (" + " name TEXT PRIMARY KEY," + " enabled INTEGER NOT NULL DEFAULT 1," + " use_regex INTEGER NOT NULL DEFAULT 0," + " add_paused INTEGER NOT NULL DEFAULT 0," + " must_contain TEXT NOT NULL DEFAULT ''," + " must_not_contain TEXT NOT NULL DEFAULT ''," + " assigned_category TEXT NOT NULL DEFAULT ''," + " save_path TEXT NOT NULL DEFAULT ''," + " last_match INTEGER NOT NULL DEFAULT 0);" + "CREATE TABLE IF NOT EXISTS rule_feeds (" + " rule TEXT NOT NULL," + " feed TEXT NOT NULL," + " PRIMARY KEY(rule, feed));" + "CREATE TABLE IF NOT EXISTS indexers (" + " name TEXT PRIMARY KEY," + " url TEXT NOT NULL DEFAULT ''," + " apikey TEXT NOT NULL DEFAULT ''," + " enabled INTEGER NOT NULL DEFAULT 1);"; + char *err = NULL; + if (sqlite3_exec(s->db, schema, NULL, NULL, &err) != SQLITE_OK) { + sqlite3_free(err); + webui_store_close(s); + return NULL; + } + legacy_migrate(s); /* upgrade an older DB's RSS schema in place */ + return s; +} + +void webui_store_close(webui_store *s) { + if (!s) return; + if (s->db) sqlite3_close(s->db); + pthread_mutex_destroy(&s->lock); + free(s); +} + +/* Run a "SELECT count(*) ... " style query returning a single integer. */ +static int count_query(webui_store *s, const char *sql) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) != SQLITE_OK) return -1; + int n = -1; + if (sqlite3_step(st) == SQLITE_ROW) n = sqlite3_column_int(st, 0); + sqlite3_finalize(st); + return n; +} + +int webui_store_user_count(webui_store *s) { + if (!s) return -1; + pthread_mutex_lock(&s->lock); + int n = count_query(s, "SELECT count(*) FROM users;"); + pthread_mutex_unlock(&s->lock); + return n; +} + +int webui_store_admin_count(webui_store *s) { + if (!s) return -1; + pthread_mutex_lock(&s->lock); + int n = count_query(s, "SELECT count(*) FROM users WHERE role='admin';"); + pthread_mutex_unlock(&s->lock); + return n; +} + +bool webui_store_user_exists(webui_store *s, const char *username) { + if (!s || !username) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool found = false; + if (sqlite3_prepare_v2(s->db, "SELECT 1 FROM users WHERE username=?;", -1, + &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); + found = sqlite3_step(st) == SQLITE_ROW; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return found; +} + +bool webui_store_verify(webui_store *s, const char *username, + const char *password, char *role_out, size_t role_sz) { + if (!s || !username || !password) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT pw_hash, pw_salt, pw_iters, role FROM users WHERE username=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) { + const char *hash_hex = (const char *)sqlite3_column_text(st, 0); + const char *salt_hex = (const char *)sqlite3_column_text(st, 1); + int iters = sqlite3_column_int(st, 2); + const char *role = (const char *)sqlite3_column_text(st, 3); + unsigned char salt[SALT_BYTES], want[HASH_BYTES], got[HASH_BYTES]; + if (hash_hex && salt_hex && + from_hex(salt_hex, salt, SALT_BYTES) == 0 && + from_hex(hash_hex, want, HASH_BYTES) == 0 && + derive(password, salt, SALT_BYTES, iters, got) && + CRYPTO_memcmp(want, got, HASH_BYTES) == 0) { + ok = true; + if (role_out && role) snprintf(role_out, role_sz, "%s", role); + } + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +/* Compute a fresh salt + hash for `password`, hex-encoded into the buffers. */ +static bool make_hash(const char *password, char salt_hex[SALT_BYTES * 2 + 1], + char hash_hex[HASH_BYTES * 2 + 1]) { + unsigned char salt[SALT_BYTES], hash[HASH_BYTES]; + if (RAND_bytes(salt, SALT_BYTES) != 1) return false; + if (!derive(password, salt, SALT_BYTES, PBKDF2_ITERS, hash)) return false; + to_hex(salt, SALT_BYTES, salt_hex); + to_hex(hash, HASH_BYTES, hash_hex); + return true; +} + +bool webui_store_create_user(webui_store *s, const char *username, + const char *password, const char *role) { + if (!s || !username || !*username || !password || !*password) return false; + if (!valid_role(role)) role = "user"; + char salt_hex[SALT_BYTES * 2 + 1], hash_hex[HASH_BYTES * 2 + 1]; + if (!make_hash(password, salt_hex, hash_hex)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "INSERT INTO users (username, pw_hash, pw_salt, pw_iters, role, created_at)" + " VALUES (?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, hash_hex, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 3, salt_hex, -1, SQLITE_STATIC); + sqlite3_bind_int(st, 4, PBKDF2_ITERS); + sqlite3_bind_text(st, 5, role, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 6, (sqlite3_int64)time(NULL)); + ok = sqlite3_step(st) == SQLITE_DONE; /* false on UNIQUE conflict */ + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_set_password(webui_store *s, const char *username, + const char *password) { + if (!s || !username || !password || !*password) return false; + char salt_hex[SALT_BYTES * 2 + 1], hash_hex[HASH_BYTES * 2 + 1]; + if (!make_hash(password, salt_hex, hash_hex)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "UPDATE users SET pw_hash=?, pw_salt=?, pw_iters=? WHERE username=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, hash_hex, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, salt_hex, -1, SQLITE_STATIC); + sqlite3_bind_int(st, 3, PBKDF2_ITERS); + sqlite3_bind_text(st, 4, username, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_set_role(webui_store *s, const char *username, const char *role) { + if (!s || !username || !valid_role(role)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "UPDATE users SET role=? WHERE username=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, role, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, username, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_delete_user(webui_store *s, const char *username) { + if (!s || !username) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "DELETE FROM users WHERE username=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_list_users(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT username, role, created_at FROM users ORDER BY username COLLATE NOCASE;", + -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *u = (const char *)sqlite3_column_text(st, 0); + const char *r = (const char *)sqlite3_column_text(st, 1); + json_array_append_new(out, json_pack("{s:s,s:s,s:I}", + "username", u ? u : "", "role", r ? r : "user", + "createdAt", (json_int_t)sqlite3_column_int64(st, 2))); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +/* --- sessions ------------------------------------------------------------- */ + +/* SHA-256 of a bearer token, hex-encoded. We persist only this, never the raw + * token, so a DB leak can't be replayed as a live cookie. */ +static void sha256_hex(const char *token, char out[65]) { + unsigned char d[32]; + unsigned int dl = 0; + EVP_Digest(token, strlen(token), d, &dl, EVP_sha256(), NULL); + to_hex(d, 32, out); +} + +bool webui_store_session_create(webui_store *s, const char *token, + const char *user, const char *role, + long expires) { + if (!s || !token || !*token || !user || !*user) return false; + char th[65]; + sha256_hex(token, th); + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "INSERT OR REPLACE INTO sessions (token_hash,username,role,expires)" + " VALUES (?,?,?,?);", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, user, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 3, role && *role ? role : "user", -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 4, (sqlite3_int64)expires); + ok = sqlite3_step(st) == SQLITE_DONE; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_session_lookup(webui_store *s, const char *token, + char *user, size_t user_sz, + char *role, size_t role_sz, long *expires_out) { + if (!s || !token || !*token) return false; + char th[65]; + sha256_hex(token, th); + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT username, role, expires FROM sessions WHERE token_hash=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) { + const char *u = (const char *)sqlite3_column_text(st, 0); + const char *r = (const char *)sqlite3_column_text(st, 1); + if (user) snprintf(user, user_sz, "%s", u ? u : ""); + if (role) snprintf(role, role_sz, "%s", r ? r : "user"); + if (expires_out) *expires_out = (long)sqlite3_column_int64(st, 2); + ok = true; + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_session_touch(webui_store *s, const char *token, long expires) { + if (!s || !token) return false; + char th[65]; + sha256_hex(token, th); + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "UPDATE sessions SET expires=? WHERE token_hash=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, (sqlite3_int64)expires); + sqlite3_bind_text(st, 2, th, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_session_delete(webui_store *s, const char *token) { + if (!s || !token) return false; + char th[65]; + sha256_hex(token, th); + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE token_hash=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_sessions_delete_user(webui_store *s, const char *user) { + if (!s || !user) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE username=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, user, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +void webui_store_sessions_prune(webui_store *s, long now) { + if (!s) return; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE expires<=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, (sqlite3_int64)now); + sqlite3_step(st); + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); +} + +/* --- taxonomy ------------------------------------------------------------- */ + +/* Replace one table's contents from a json array, inside a transaction. The + * `bind` callback binds each element's columns onto the prepared INSERT. */ +static bool replace_table(webui_store *s, const char *del_sql, + const char *ins_sql, json_t *items, + void (*bind)(sqlite3_stmt *, json_t *)) { + if (!s || !json_is_array(items)) return false; + pthread_mutex_lock(&s->lock); + bool ok = sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL) == SQLITE_OK && + sqlite3_exec(s->db, del_sql, NULL, NULL, NULL) == SQLITE_OK; + sqlite3_stmt *st = NULL; + if (ok && sqlite3_prepare_v2(s->db, ins_sql, -1, &st, NULL) == SQLITE_OK) { + size_t i; json_t *v; + json_array_foreach(items, i, v) { + bind(st, v); + if (sqlite3_step(st) != SQLITE_DONE) { ok = false; break; } + sqlite3_reset(st); + } + } else ok = false; + sqlite3_finalize(st); + sqlite3_exec(s->db, ok ? "COMMIT;" : "ROLLBACK;", NULL, NULL, NULL); + pthread_mutex_unlock(&s->lock); + return ok; +} + +static const char *str_or(json_t *o, const char *k, const char *fallback) { + const char *v = json_string_value(json_object_get(o, k)); + return v ? v : fallback; +} + +static void bind_category(sqlite3_stmt *st, json_t *c) { + sqlite3_bind_text(st, 1, str_or(c, "name", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, str_or(c, "savePath", ""), -1, SQLITE_TRANSIENT); +} + +bool webui_store_save_categories(webui_store *s, json_t *cats) { + return replace_table(s, "DELETE FROM categories;", + "INSERT OR REPLACE INTO categories (name, save_path) VALUES (?,?);", + cats, bind_category); +} + +bool webui_store_load_categories(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT name, save_path FROM categories ORDER BY name;", + -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *n = (const char *)sqlite3_column_text(st, 0); + const char *p = (const char *)sqlite3_column_text(st, 1); + json_array_append_new(out, json_pack("{s:s,s:s}", + "name", n ? n : "", "savePath", p ? p : "")); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +static void bind_tag(sqlite3_stmt *st, json_t *t) { + sqlite3_bind_text(st, 1, json_string_value(t) ? json_string_value(t) : "", + -1, SQLITE_TRANSIENT); +} + +bool webui_store_save_tags(webui_store *s, json_t *tags) { + return replace_table(s, "DELETE FROM tags;", + "INSERT OR REPLACE INTO tags (name) VALUES (?);", tags, bind_tag); +} + +bool webui_store_load_tags(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "SELECT name FROM tags ORDER BY name;", + -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *n = (const char *)sqlite3_column_text(st, 0); + json_array_append_new(out, json_string(n ? n : "")); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +/* --- RSS (fully relational) ----------------------------------------------- */ + +static int int_of(json_t *o, const char *k) { + return json_boolean_value(json_object_get(o, k)) ? 1 : 0; +} + +/* Parse a TEXT column holding a JSON array; returns a new array (never NULL). + * Only used by the legacy-schema migration. */ +static json_t *array_col(sqlite3_stmt *st, int col) { + const char *txt = (const char *)sqlite3_column_text(st, col); + if (txt) { + json_t *a = json_loads(txt, 0, NULL); + if (json_is_array(a)) return a; + json_decref(a); + } + return json_array(); +} + +/* ---- feeds ---- */ + +bool webui_store_feed_upsert(webui_store *s, const char *name, const char *url) { + if (!s || !name || !*name || !url || !*url) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "INSERT INTO feeds (name, url, last_update) VALUES (?,?,0)" + " ON CONFLICT(name) DO UPDATE SET url=excluded.url;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, url, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_feed_remove(webui_store *s, const char *name) { + if (!s || !name) return false; + pthread_mutex_lock(&s->lock); + bool ok = false; + sqlite3_stmt *st = NULL; + sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL); + if (sqlite3_prepare_v2(s->db, "DELETE FROM articles WHERE feed=?;", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); + sqlite3_step(st); + } + sqlite3_finalize(st); st = NULL; + if (sqlite3_prepare_v2(s->db, "DELETE FROM feeds WHERE name=?;", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; + } + sqlite3_finalize(st); + sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_feed_set_updated(webui_store *s, const char *name, long ts) { + if (!s || !name) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "UPDATE feeds SET last_update=? WHERE name=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, (sqlite3_int64)ts); + sqlite3_bind_text(st, 2, name, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_feed_exists(webui_store *s, const char *name) { + if (!s || !name) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool found = false; + if (sqlite3_prepare_v2(s->db, "SELECT 1 FROM feeds WHERE name=?;", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); + found = sqlite3_step(st) == SQLITE_ROW; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return found; +} + +bool webui_store_feed_targets(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "SELECT name, url FROM feeds ORDER BY name;", + -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *n = (const char *)sqlite3_column_text(st, 0); + const char *u = (const char *)sqlite3_column_text(st, 1); + json_array_append_new(out, json_pack("{s:s,s:s}", + "name", n ? n : "", "url", u ? u : "")); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +/* Build the article array for one feed (newest first). Caller holds the lock. */ +static json_t *feed_articles_locked(webui_store *s, const char *feed) { + json_t *arr = json_array(); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed" + " FROM articles WHERE feed=? ORDER BY id DESC;", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *k = (const char *)sqlite3_column_text(st, 0); + const char *t = (const char *)sqlite3_column_text(st, 1); + const char *m = (const char *)sqlite3_column_text(st, 2); + const char *tu = (const char *)sqlite3_column_text(st, 3); + const char *ln = (const char *)sqlite3_column_text(st, 4); + const char *pd = (const char *)sqlite3_column_text(st, 6); + json_array_append_new(arr, json_pack( + "{s:s,s:s,s:s,s:s,s:s,s:I,s:s,s:b,s:b}", + "key", k ? k : "", "title", t ? t : "", "magnet", m ? m : "", + "torrentUrl", tu ? tu : "", "link", ln ? ln : "", + "size", (json_int_t)sqlite3_column_int64(st, 5), + "pubDate", pd ? pd : "", "isRead", sqlite3_column_int(st, 7), + "grabbed", sqlite3_column_int(st, 8))); + } + } + sqlite3_finalize(st); + return arr; +} + +bool webui_store_feed_list(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT name, url, last_update FROM feeds ORDER BY name;", + -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *n = (const char *)sqlite3_column_text(st, 0); + const char *u = (const char *)sqlite3_column_text(st, 1); + json_array_append_new(out, json_pack("{s:s,s:s,s:I,s:o}", + "name", n ? n : "", "url", u ? u : "", + "lastUpdate", (json_int_t)sqlite3_column_int64(st, 2), + "articles", feed_articles_locked(s, n ? n : ""))); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +/* ---- articles ---- */ + +int webui_store_article_add(webui_store *s, const char *feed, json_t *a) { + if (!s || !feed || !json_is_object(a)) return -1; + const char *key = str_or(a, "key", ""); + if (!*key) return -1; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + int rc = -1; + if (sqlite3_prepare_v2(s->db, + "INSERT OR IGNORE INTO articles" + " (feed,key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed,seen_at)" + " VALUES (?,?,?,?,?,?,?,?,0,0,?);", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, str_or(a, "title", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, str_or(a, "magnet", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, str_or(a, "torrentUrl", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, str_or(a, "link", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 7, (sqlite3_int64)json_integer_value(json_object_get(a, "size"))); + sqlite3_bind_text(st, 8, str_or(a, "pubDate", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 9, (sqlite3_int64)time(NULL)); + if (sqlite3_step(st) == SQLITE_DONE) rc = sqlite3_changes(s->db) > 0 ? 1 : 0; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return rc; +} + +bool webui_store_article_trim(webui_store *s, const char *feed, int keep) { + if (!s || !feed || keep < 0) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "DELETE FROM articles WHERE feed=? AND id NOT IN" + " (SELECT id FROM articles WHERE feed=? ORDER BY id DESC LIMIT ?);", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, feed, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, feed, -1, SQLITE_STATIC); + sqlite3_bind_int(st, 3, keep); + ok = sqlite3_step(st) == SQLITE_DONE; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_article_mark_grabbed(webui_store *s, const char *key) { + if (!s || !key || !*key) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "UPDATE articles SET grabbed=1 WHERE key=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_articles_ungrabbed(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT feed,key,title,magnet,torrent_url FROM articles" + " WHERE grabbed=0 AND (magnet<>'' OR torrent_url<>'') ORDER BY id DESC;", + -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *f = (const char *)sqlite3_column_text(st, 0); + const char *k = (const char *)sqlite3_column_text(st, 1); + const char *t = (const char *)sqlite3_column_text(st, 2); + const char *m = (const char *)sqlite3_column_text(st, 3); + const char *u = (const char *)sqlite3_column_text(st, 4); + json_array_append_new(out, json_pack("{s:s,s:s,s:s,s:s,s:s}", + "feed", f ? f : "", "key", k ? k : "", "title", t ? t : "", + "magnet", m ? m : "", "torrentUrl", u ? u : "")); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +/* ---- rules ---- */ + +static void bind_rule_row(sqlite3_stmt *st, json_t *r) { + sqlite3_bind_text(st, 1, str_or(r, "name", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(st, 2, int_of(r, "enabled")); + sqlite3_bind_int(st, 3, int_of(r, "useRegex")); + sqlite3_bind_int(st, 4, int_of(r, "addPaused")); + sqlite3_bind_text(st, 5, str_or(r, "mustContain", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, str_or(r, "mustNotContain", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, str_or(r, "assignedCategory", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 8, str_or(r, "savePath", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 9, (sqlite3_int64)json_integer_value(json_object_get(r, "lastMatch"))); +} + +bool webui_store_rule_upsert(webui_store *s, json_t *r) { + if (!s || !json_is_object(r)) return false; + const char *name = str_or(r, "name", ""); + if (!*name) return false; + pthread_mutex_lock(&s->lock); + bool ok = sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL) == SQLITE_OK; + sqlite3_stmt *st = NULL; + if (ok && sqlite3_prepare_v2(s->db, + "INSERT OR REPLACE INTO rules (name,enabled,use_regex,add_paused," + "must_contain,must_not_contain,assigned_category,save_path,last_match)" + " VALUES (?,?,?,?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) { + bind_rule_row(st, r); + ok = sqlite3_step(st) == SQLITE_DONE; + } else ok = false; + sqlite3_finalize(st); st = NULL; + if (ok && sqlite3_prepare_v2(s->db, "DELETE FROM rule_feeds WHERE rule=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE; + } else ok = false; + sqlite3_finalize(st); st = NULL; + json_t *feeds = json_object_get(r, "affectedFeeds"); + if (ok && json_is_array(feeds) && sqlite3_prepare_v2(s->db, + "INSERT OR IGNORE INTO rule_feeds (rule,feed) VALUES (?,?);", + -1, &st, NULL) == SQLITE_OK) { + size_t i; json_t *v; + json_array_foreach(feeds, i, v) { + const char *fn = json_string_value(v); + if (!fn || !*fn) continue; + sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, fn, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_DONE) { ok = false; break; } + sqlite3_reset(st); + } + } + sqlite3_finalize(st); + sqlite3_exec(s->db, ok ? "COMMIT;" : "ROLLBACK;", NULL, NULL, NULL); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_rule_remove(webui_store *s, const char *name) { + if (!s || !name) return false; + pthread_mutex_lock(&s->lock); + sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "DELETE FROM rule_feeds WHERE rule=?;", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); + sqlite3_step(st); + } + sqlite3_finalize(st); st = NULL; + if (sqlite3_prepare_v2(s->db, "DELETE FROM rules WHERE name=?;", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; + } + sqlite3_finalize(st); + sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL); + pthread_mutex_unlock(&s->lock); + return ok; +} + +/* Build the affectedFeeds array for one rule. Caller holds the lock. */ +static json_t *rule_feeds_locked(webui_store *s, const char *rule) { + json_t *arr = json_array(); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT feed FROM rule_feeds WHERE rule=? ORDER BY feed;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, rule, -1, SQLITE_STATIC); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *f = (const char *)sqlite3_column_text(st, 0); + json_array_append_new(arr, json_string(f ? f : "")); + } + } + sqlite3_finalize(st); + return arr; +} + +static json_t *rule_row_to_json(sqlite3_stmt *st, webui_store *s) { + const char *n = (const char *)sqlite3_column_text(st, 0); + const char *mc = (const char *)sqlite3_column_text(st, 4); + const char *mn = (const char *)sqlite3_column_text(st, 5); + const char *ac = (const char *)sqlite3_column_text(st, 6); + const char *sp = (const char *)sqlite3_column_text(st, 7); + return json_pack("{s:s,s:b,s:b,s:b,s:s,s:s,s:s,s:s,s:o,s:I}", + "name", n ? n : "", + "enabled", sqlite3_column_int(st, 1), + "useRegex", sqlite3_column_int(st, 2), + "addPaused", sqlite3_column_int(st, 3), + "mustContain", mc ? mc : "", + "mustNotContain", mn ? mn : "", + "assignedCategory", ac ? ac : "", + "savePath", sp ? sp : "", + "affectedFeeds", rule_feeds_locked(s, n ? n : ""), + "lastMatch", (json_int_t)sqlite3_column_int64(st, 8)); +} + +static const char RULE_COLS[] = + "SELECT name,enabled,use_regex,add_paused,must_contain,must_not_contain," + "assigned_category,save_path,last_match FROM rules"; + +bool webui_store_rule_list(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + char sql[256]; + snprintf(sql, sizeof sql, "%s ORDER BY name;", RULE_COLS); + if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) + json_array_append_new(out, rule_row_to_json(st, s)); + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +json_t *webui_store_rule_get(webui_store *s, const char *name) { + if (!s || !name) return NULL; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + json_t *out = NULL; + char sql[256]; + snprintf(sql, sizeof sql, "%s WHERE name=?;", RULE_COLS); + if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) out = rule_row_to_json(st, s); + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return out; +} + +bool webui_store_rule_set_match(webui_store *s, const char *name, long ts) { + if (!s || !name) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "UPDATE rules SET last_match=? WHERE name=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, (sqlite3_int64)ts); + sqlite3_bind_text(st, 2, name, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +/* ---- indexers ---- */ + +bool webui_store_indexer_upsert(webui_store *s, json_t *x) { + if (!s || !json_is_object(x)) return false; + const char *name = str_or(x, "name", ""); + if (!*name) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "INSERT OR REPLACE INTO indexers (name,url,apikey,enabled) VALUES (?,?,?,?);", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, str_or(x, "url", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, str_or(x, "apikey", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(st, 4, json_object_get(x, "enabled") ? int_of(x, "enabled") : 1); + ok = sqlite3_step(st) == SQLITE_DONE; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_indexer_remove(webui_store *s, const char *name) { + if (!s || !name) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "DELETE FROM indexers WHERE name=?;", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool webui_store_indexer_list(webui_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT name, url, apikey, enabled FROM indexers ORDER BY name;", + -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *n = (const char *)sqlite3_column_text(st, 0); + const char *u = (const char *)sqlite3_column_text(st, 1); + const char *k = (const char *)sqlite3_column_text(st, 2); + json_array_append_new(out, json_pack("{s:s,s:s,s:s,s:b}", + "name", n ? n : "", "url", u ? u : "", "apikey", k ? k : "", + "enabled", sqlite3_column_int(st, 3))); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +/* --- legacy schema migration ---------------------------------------------- */ + +static bool table_has_column(sqlite3 *db, const char *table, const char *col) { + char sql[128]; + snprintf(sql, sizeof sql, "PRAGMA table_info(%s);", table); + sqlite3_stmt *st = NULL; + bool found = false; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK) { + while (sqlite3_step(st) == SQLITE_ROW) { + const char *n = (const char *)sqlite3_column_text(st, 1); /* 1 = name */ + if (n && strcmp(n, col) == 0) { found = true; break; } + } + } + sqlite3_finalize(st); + return found; +} + +static void legacy_migrate(webui_store *s) { + /* The tell-tale of the old schema: feeds carried an inline articles blob. */ + if (!table_has_column(s->db, "feeds", "articles")) return; + + /* Snapshot the legacy blobs first, then finalize before mutating. */ + json_t *feed_arts = json_object(); /* feed name -> articles array */ + json_t *rule_feeds = json_object(); /* rule name -> affectedFeeds array */ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, "SELECT name, articles FROM feeds;", -1, &st, NULL) == SQLITE_OK) + while (sqlite3_step(st) == SQLITE_ROW) { + const char *f = (const char *)sqlite3_column_text(st, 0); + json_object_set_new(feed_arts, f ? f : "", array_col(st, 1)); + } + sqlite3_finalize(st); st = NULL; + if (table_has_column(s->db, "rules", "affected_feeds") && + sqlite3_prepare_v2(s->db, "SELECT name, affected_feeds FROM rules;", -1, &st, NULL) == SQLITE_OK) + while (sqlite3_step(st) == SQLITE_ROW) { + const char *n = (const char *)sqlite3_column_text(st, 0); + json_object_set_new(rule_feeds, n ? n : "", array_col(st, 1)); + } + sqlite3_finalize(st); st = NULL; + + sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL); + + /* Articles: insert oldest-first so autoincrement id tracks recency (the + * legacy array is newest-first). Preserve is_read / grabbed flags. */ + if (sqlite3_prepare_v2(s->db, + "INSERT OR IGNORE INTO articles" + " (feed,key,title,magnet,torrent_url,link,size,pub_date,is_read,grabbed,seen_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) { + const char *feed; json_t *arts; + json_object_foreach(feed_arts, feed, arts) { + if (!json_is_array(arts)) continue; + for (long i = (long)json_array_size(arts) - 1; i >= 0; i--) { + json_t *a = json_array_get(arts, (size_t)i); + const char *key = str_or(a, "key", ""); + if (!*key) continue; + sqlite3_bind_text(st, 1, feed, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, key, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, str_or(a, "title", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, str_or(a, "magnet", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, str_or(a, "torrentUrl", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, str_or(a, "link", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 7, (sqlite3_int64)json_integer_value(json_object_get(a, "size"))); + sqlite3_bind_text(st, 8, str_or(a, "pubDate", ""), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(st, 9, int_of(a, "isRead")); + sqlite3_bind_int(st, 10, int_of(a, "grabbed")); + sqlite3_bind_int64(st, 11, (sqlite3_int64)time(NULL)); + sqlite3_step(st); + sqlite3_reset(st); + } + } + } + sqlite3_finalize(st); st = NULL; + + if (sqlite3_prepare_v2(s->db, + "INSERT OR IGNORE INTO rule_feeds (rule,feed) VALUES (?,?);", + -1, &st, NULL) == SQLITE_OK) { + const char *rule; json_t *feeds; + json_object_foreach(rule_feeds, rule, feeds) { + if (!json_is_array(feeds)) continue; + size_t i; json_t *v; + json_array_foreach(feeds, i, v) { + const char *fn = json_string_value(v); + if (!fn || !*fn) continue; + sqlite3_bind_text(st, 1, rule, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, fn, -1, SQLITE_TRANSIENT); + sqlite3_step(st); + sqlite3_reset(st); + } + } + } + sqlite3_finalize(st); st = NULL; + + /* Drop the legacy JSON columns by rebuilding feeds + rules. */ + sqlite3_exec(s->db, + "CREATE TABLE feeds_new (name TEXT PRIMARY KEY, url TEXT NOT NULL," + " last_update INTEGER NOT NULL DEFAULT 0);" + "INSERT INTO feeds_new (name,url,last_update) SELECT name,url,last_update FROM feeds;" + "DROP TABLE feeds;" + "ALTER TABLE feeds_new RENAME TO feeds;" + "CREATE TABLE rules_new (name TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1," + " use_regex INTEGER NOT NULL DEFAULT 0, add_paused INTEGER NOT NULL DEFAULT 0," + " must_contain TEXT NOT NULL DEFAULT '', must_not_contain TEXT NOT NULL DEFAULT ''," + " assigned_category TEXT NOT NULL DEFAULT '', save_path TEXT NOT NULL DEFAULT ''," + " last_match INTEGER NOT NULL DEFAULT 0);" + "INSERT INTO rules_new SELECT name,enabled,use_regex,add_paused,must_contain," + "must_not_contain,assigned_category,save_path,last_match FROM rules;" + "DROP TABLE rules;" + "ALTER TABLE rules_new RENAME TO rules;", + NULL, NULL, NULL); + + sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL); + json_decref(feed_arts); + json_decref(rule_feeds); +} diff --git a/plugins/webui/webui_store.h b/plugins/webui/webui_store.h new file mode 100644 index 0000000..7391b6f --- /dev/null +++ b/plugins/webui/webui_store.h @@ -0,0 +1,107 @@ +/* webui_store.h — SQLite-backed persistence for all web-UI-owned state: + * accounts, the category/tag taxonomy, and RSS feeds/rules/indexers. + * + * Owned entirely by the webui plugin (the daemon persists none of this). + * Account passwords are PBKDF2-HMAC-SHA256 with a per-user random salt. All + * calls are thread-safe (the store serializes access to its SQLite handle). */ +#ifndef NAUT_WEBUI_STORE_H +#define NAUT_WEBUI_STORE_H + +#include <stdbool.h> +#include <stddef.h> +#include <jansson.h> + +typedef struct webui_store webui_store; + +/* Open (creating if needed) the account database at `path`. Returns NULL on + * failure. The schema is created/migrated on open. */ +webui_store *webui_store_open(const char *path); +void webui_store_close(webui_store *s); + +/* Number of accounts, or -1 on error. */ +int webui_store_user_count(webui_store *s); +/* Number of admin accounts, or -1 on error. */ +int webui_store_admin_count(webui_store *s); +bool webui_store_user_exists(webui_store *s, const char *username); + +/* Verify a username/password pair (constant-time). On success, copies the + * account's role ("admin"/"user") into role_out. */ +bool webui_store_verify(webui_store *s, const char *username, + const char *password, char *role_out, size_t role_sz); + +/* Create an account. `role` must be "admin" or "user" (defaults to "user" if + * NULL/invalid). Returns false if the username already exists or on error. */ +bool webui_store_create_user(webui_store *s, const char *username, + const char *password, const char *role); + +bool webui_store_set_password(webui_store *s, const char *username, + const char *password); +/* Change an account's role ("admin"/"user"). */ +bool webui_store_set_role(webui_store *s, const char *username, const char *role); +bool webui_store_delete_user(webui_store *s, const char *username); + +/* Append {username, role, createdAt} objects (sorted by username) to the + * json array `out`. Returns false on error. */ +bool webui_store_list_users(webui_store *s, json_t *out); + +/* --- sessions (persisted so logins survive daemon restarts) --------------- * + * Only a SHA-256 of the bearer token is stored, so a DB read can't be replayed + * as a live cookie. `expires` is an absolute unix time. */ +bool webui_store_session_create(webui_store *s, const char *token, + const char *user, const char *role, long expires); +/* On a live (unexpired) session, copies username/role and the stored expiry. */ +bool webui_store_session_lookup(webui_store *s, const char *token, + char *user, size_t user_sz, + char *role, size_t role_sz, long *expires_out); +bool webui_store_session_touch(webui_store *s, const char *token, long expires); +bool webui_store_session_delete(webui_store *s, const char *token); +bool webui_store_sessions_delete_user(webui_store *s, const char *user); +void webui_store_sessions_prune(webui_store *s, long now); + +/* --- category / tag taxonomy (web-UI organization, owned here) ------------- * + * The save_* calls replace the whole list atomically; the load_* calls append + * to the (array) `out`. Categories are {name, savePath}; tags are strings. */ +bool webui_store_save_categories(webui_store *s, json_t *cats); +bool webui_store_load_categories(webui_store *s, json_t *out); +bool webui_store_save_tags(webui_store *s, json_t *tags); +bool webui_store_load_tags(webui_store *s, json_t *out); + +/* --- RSS: feeds, articles, auto-download rules, Torznab indexers ----------- * + * Fully relational: articles live in their own table (deduped by feed+key, + * indexed), and a rule's feed scope lives in a rule_feeds join table. The web + * layer operates on rows, not whole-list blobs. */ + +/* Feeds. upsert preserves an existing feed's lastUpdate (only the url changes); + * remove also drops the feed's articles. feed_list appends + * {name,url,lastUpdate,articles:[...]} (newest article first). feed_targets + * appends lightweight {name,url} objects for the poller. */ +bool webui_store_feed_upsert(webui_store *s, const char *name, const char *url); +bool webui_store_feed_remove(webui_store *s, const char *name); +bool webui_store_feed_set_updated(webui_store *s, const char *name, long ts); +bool webui_store_feed_list(webui_store *s, json_t *out); +bool webui_store_feed_targets(webui_store *s, json_t *out); +bool webui_store_feed_exists(webui_store *s, const char *name); + +/* Articles. add inserts unless (feed,key) already exists: returns 1 if newly + * inserted, 0 if a duplicate, -1 on error. trim keeps the newest `keep` for a + * feed. mark_grabbed flags every article with this key. ungrabbed appends + * {feed,key,title,magnet,torrentUrl} for not-yet-grabbed articles. */ +int webui_store_article_add(webui_store *s, const char *feed, json_t *article); +bool webui_store_article_trim(webui_store *s, const char *feed, int keep); +bool webui_store_article_mark_grabbed(webui_store *s, const char *key); +bool webui_store_articles_ungrabbed(webui_store *s, json_t *out); + +/* Rules. upsert replaces the rule row and its feed scope; list/get assemble the + * rule with its affectedFeeds array. */ +bool webui_store_rule_upsert(webui_store *s, json_t *rule); +bool webui_store_rule_remove(webui_store *s, const char *name); +bool webui_store_rule_list(webui_store *s, json_t *out); +json_t *webui_store_rule_get(webui_store *s, const char *name); +bool webui_store_rule_set_match(webui_store *s, const char *name, long ts); + +/* Torznab indexers. */ +bool webui_store_indexer_upsert(webui_store *s, json_t *indexer); +bool webui_store_indexer_remove(webui_store *s, const char *name); +bool webui_store_indexer_list(webui_store *s, json_t *out); + +#endif /* NAUT_WEBUI_STORE_H */ diff --git a/src/core/common.c b/src/core/common.c index af93026..0baed49 100644 --- a/src/core/common.c +++ b/src/core/common.c @@ -13,6 +13,7 @@ const char *naut_strerror(naut_err e) { case NAUT_ERR_FULL: return "full"; case NAUT_ERR_EMPTY: return "empty"; case NAUT_ERR_NOTFOUND: return "not found"; + case NAUT_ERR_EXIST: return "already exists / data would overlap"; default: return "unknown error"; } } diff --git a/src/dht/dht.c b/src/dht/dht.c deleted file mode 100644 index 2a9922d..0000000 --- a/src/dht/dht.c +++ /dev/null @@ -1,227 +0,0 @@ -#include "naut/dht.h" -#include "naut/bencode.h" - -#include <stdlib.h> -#include <string.h> - -static naut_err finish(naut_bc_writer *w, uint8_t **out, size_t *out_len) { - if (w->err != NAUT_OK) { - naut_err e = w->err; - naut_bc_w_free(w); - return e; - } - *out = w->buf; - *out_len = w->len; - w->buf = NULL; - naut_bc_w_free(w); - return NAUT_OK; -} - -static bool valid_common(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], uint8_t **out, size_t *out_len) { - return tx && tx_len > 0 && tx_len <= 8 && id && out && out_len; -} - -naut_err naut_dht_build_ping(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - uint8_t **out, size_t *out_len) { - if (!valid_common(tx, tx_len, id, out, out_len)) return NAUT_ERR_INVAL; - naut_bc_writer w; naut_bc_w_init(&w); - naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "a"); naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "id"); naut_bc_w_bytes(&w, id, 20); - naut_bc_w_end(&w); - naut_bc_w_cstr(&w, "q"); naut_bc_w_cstr(&w, "ping"); - naut_bc_w_cstr(&w, "t"); naut_bc_w_bytes(&w, tx, tx_len); - naut_bc_w_cstr(&w, "y"); naut_bc_w_cstr(&w, "q"); - naut_bc_w_end(&w); - return finish(&w, out, out_len); -} - -static naut_err build_target_query(const char *query, const char *target_key, - const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t target[20], - uint8_t **out, size_t *out_len) { - if (!query || !target_key || !target || - !valid_common(tx, tx_len, id, out, out_len)) return NAUT_ERR_INVAL; - naut_bc_writer w; naut_bc_w_init(&w); - naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "a"); naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "id"); naut_bc_w_bytes(&w, id, 20); - naut_bc_w_cstr(&w, target_key); naut_bc_w_bytes(&w, target, 20); - naut_bc_w_end(&w); - naut_bc_w_cstr(&w, "q"); naut_bc_w_cstr(&w, query); - naut_bc_w_cstr(&w, "t"); naut_bc_w_bytes(&w, tx, tx_len); - naut_bc_w_cstr(&w, "y"); naut_bc_w_cstr(&w, "q"); - naut_bc_w_end(&w); - return finish(&w, out, out_len); -} - -naut_err naut_dht_build_find_node(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t target[20], - uint8_t **out, size_t *out_len) { - return build_target_query("find_node", "target", tx, tx_len, id, target, - out, out_len); -} - -naut_err naut_dht_build_get_peers(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t info_hash[20], - uint8_t **out, size_t *out_len) { - return build_target_query("get_peers", "info_hash", tx, tx_len, id, - info_hash, out, out_len); -} - -naut_err naut_dht_build_announce_peer(const uint8_t *tx, size_t tx_len, - const uint8_t id[20], - const uint8_t info_hash[20], - uint16_t port, bool implied_port, - const void *token, size_t token_len, - uint8_t **out, size_t *out_len) { - if (!valid_common(tx, tx_len, id, out, out_len) || !info_hash || - !token || token_len == 0 || token_len > 64 || (!implied_port && port == 0)) - return NAUT_ERR_INVAL; - naut_bc_writer w; naut_bc_w_init(&w); - naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "a"); naut_bc_w_dict_begin(&w); - naut_bc_w_cstr(&w, "id"); naut_bc_w_bytes(&w, id, 20); - naut_bc_w_cstr(&w, "implied_port"); naut_bc_w_int(&w, implied_port ? 1 : 0); - naut_bc_w_cstr(&w, "info_hash"); naut_bc_w_bytes(&w, info_hash, 20); - naut_bc_w_cstr(&w, "port"); naut_bc_w_int(&w, port); - naut_bc_w_cstr(&w, "token"); naut_bc_w_bytes(&w, token, token_len); - naut_bc_w_end(&w); - naut_bc_w_cstr(&w, "q"); naut_bc_w_cstr(&w, "announce_peer"); - naut_bc_w_cstr(&w, "t"); naut_bc_w_bytes(&w, tx, tx_len); - naut_bc_w_cstr(&w, "y"); naut_bc_w_cstr(&w, "q"); - naut_bc_w_end(&w); - return finish(&w, out, out_len); -} - -static naut_err parse_nodes(const uint8_t *p, size_t n, - naut_dht_node **out, size_t *count) { - if (n % 26 != 0 || n / 26 > NAUT_DHT_MAX_NODES) return NAUT_ERR_PROTO; - size_t num = n / 26; - naut_dht_node *nodes = calloc(num ? num : 1, sizeof(*nodes)); - if (!nodes) return NAUT_ERR_NOMEM; - for (size_t i = 0; i < num; i++) { - const uint8_t *entry = p + i * 26; - memcpy(nodes[i].id, entry, 20); - memcpy(nodes[i].ip, entry + 20, 4); - nodes[i].port = ((uint16_t)entry[24] << 8) | entry[25]; - if (nodes[i].port == 0) { - free(nodes); - return NAUT_ERR_PROTO; - } - } - *out = nodes; - *count = num; - return NAUT_OK; -} - -static bool peer_duplicate(const naut_peer_addr *peers, size_t n, - const naut_peer_addr *candidate) { - for (size_t i = 0; i < n; i++) - if (peers[i].port == candidate->port && - memcmp(peers[i].ip, candidate->ip, 4) == 0) - return true; - return false; -} - -static naut_err parse_values(const naut_bc *values, - naut_peer_addr **out, size_t *count) { - if (!values || values->type != NAUT_BC_LIST || - values->v.list.count > NAUT_DHT_MAX_PEERS) return NAUT_ERR_PROTO; - naut_peer_addr *peers = calloc(values->v.list.count ? values->v.list.count : 1, - sizeof(*peers)); - if (!peers) return NAUT_ERR_NOMEM; - size_t num = 0; - for (size_t i = 0; i < values->v.list.count; i++) { - const uint8_t *p; size_t n; - if (!naut_bc_get_str(naut_bc_list_at(values, i), &p, &n) || n != 6) { - free(peers); - return NAUT_ERR_PROTO; - } - naut_peer_addr peer; - memcpy(peer.ip, p, 4); - peer.port = ((uint16_t)p[4] << 8) | p[5]; - if (peer.port && !peer_duplicate(peers, num, &peer)) - peers[num++] = peer; - } - *out = peers; - *count = num; - return NAUT_OK; -} - -naut_err naut_dht_parse_response(const uint8_t *data, size_t len, - naut_dht_response *out) { - if (!data || !out) return NAUT_ERR_INVAL; - memset(out, 0, sizeof(*out)); - naut_bc_doc *doc = NULL; - naut_err err = naut_bc_parse(data, len, &doc); - if (err != NAUT_OK) return err; - const naut_bc *root = naut_bc_root(doc); - const uint8_t *p; size_t n; - if (!root || root->type != NAUT_BC_DICT || - !naut_bc_get_str(naut_bc_dict_get(root, "t"), &p, &n) || - n == 0 || n > sizeof out->transaction) { - err = NAUT_ERR_PROTO; - goto done; - } - memcpy(out->transaction, p, n); - out->transaction_len = n; - const naut_bc *y = naut_bc_dict_get(root, "y"); - if (naut_bc_str_eq(y, "e")) { - const naut_bc *e = naut_bc_dict_get(root, "e"); - int64_t code; - if (!e || e->type != NAUT_BC_LIST || e->v.list.count < 1 || - !naut_bc_get_int(naut_bc_list_at(e, 0), &code)) { - err = NAUT_ERR_PROTO; - goto done; - } - out->type = NAUT_DHT_ERROR; - out->error_code = (int)code; - goto done; - } - if (!naut_bc_str_eq(y, "r")) { - err = NAUT_ERR_PROTO; - goto done; - } - out->type = NAUT_DHT_RESPONSE; - const naut_bc *r = naut_bc_dict_get(root, "r"); - if (!r || r->type != NAUT_BC_DICT) { - err = NAUT_ERR_PROTO; - goto done; - } - if (naut_bc_get_str(naut_bc_dict_get(r, "id"), &p, &n)) { - if (n != 20) { err = NAUT_ERR_PROTO; goto done; } - memcpy(out->id, p, 20); - out->has_id = true; - } - if (naut_bc_get_str(naut_bc_dict_get(r, "token"), &p, &n)) { - if (n == 0 || n > sizeof out->token) { err = NAUT_ERR_PROTO; goto done; } - memcpy(out->token, p, n); - out->token_len = n; - } - if (naut_bc_get_str(naut_bc_dict_get(r, "nodes"), &p, &n)) { - err = parse_nodes(p, n, &out->nodes, &out->num_nodes); - if (err != NAUT_OK) goto done; - } - const naut_bc *values = naut_bc_dict_get(r, "values"); - if (values) { - err = parse_values(values, &out->peers, &out->num_peers); - if (err != NAUT_OK) goto done; - } -done: - naut_bc_free(doc); - if (err != NAUT_OK) naut_dht_response_free(out); - return err; -} - -void naut_dht_response_free(naut_dht_response *response) { - if (!response) return; - free(response->nodes); - free(response->peers); - memset(response, 0, sizeof(*response)); -} diff --git a/src/dht/fetch.c b/src/discovery/dht_client.c similarity index 74% rename from src/dht/fetch.c rename to src/discovery/dht_client.c index c76b8bd..42437c8 100644 --- a/src/dht/fetch.c +++ b/src/discovery/dht_client.c @@ -1,5 +1,11 @@ +/* dht_client.c — bounded iterative BEP-5 get_peers traversal. + * + * The KRPC message codec comes from the sibling `torrent-tracker` library; this + * file owns the UDP socket, the candidate frontier, and the bounded walk. */ #include "naut/dht.h" +#include "tracker.h" /* torrent-tracker DHT codec (dht_*) */ + #include <arpa/inet.h> #include <errno.h> #include <fcntl.h> @@ -10,6 +16,8 @@ #include <sys/socket.h> #include <unistd.h> +#define DHT_MAX_QUERIES 64 + typedef struct { struct sockaddr_in addr; bool queried; @@ -91,6 +99,9 @@ naut_err naut_dht_get_peers(const char *const *bootstrap, size_t num_bootstrap, int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) return NAUT_ERR_IO; + dht_message *msg = malloc(sizeof *msg); + if (!msg) { close(fd); return NAUT_ERR_NOMEM; } + naut_peer_addr found[NAUT_DHT_MAX_PEERS]; size_t found_count = 0; uint8_t id[20]; @@ -98,7 +109,7 @@ naut_err naut_dht_get_peers(const char *const *bootstrap, size_t num_bootstrap, uint16_t tx_counter = 1; size_t queries = 0; - while (queries < 64 && found_count < NAUT_DHT_MAX_PEERS) { + while (queries < DHT_MAX_QUERIES && found_count < NAUT_DHT_MAX_PEERS) { size_t index = SIZE_MAX; for (size_t i = 0; i < node_count; i++) if (!nodes[i].queried) { index = i; break; } @@ -107,42 +118,46 @@ naut_err naut_dht_get_peers(const char *const *bootstrap, size_t num_bootstrap, queries++; uint8_t tx[2] = { (uint8_t)(tx_counter >> 8), (uint8_t)tx_counter }; tx_counter++; - uint8_t *query = NULL; size_t query_len = 0; - if (naut_dht_build_get_peers(tx, sizeof tx, id, info_hash, - &query, &query_len) != NAUT_OK) + uint8_t query[256]; + size_t query_len = 0; + if (dht_write_get_peers_query(tx, sizeof tx, id, info_hash, 1, 0, + query, sizeof query, &query_len) != + TRACKER_OK) continue; ssize_t sent = sendto(fd, query, query_len, 0, (struct sockaddr *)&nodes[index].addr, sizeof(nodes[index].addr)); - free(query); if (sent < 0) continue; struct pollfd pfd = { .fd = fd, .events = POLLIN }; if (poll(&pfd, 1, 1000) <= 0) continue; - uint8_t packet[65536]; + uint8_t packet[2048]; ssize_t received = recv(fd, packet, sizeof packet, 0); if (received <= 0) continue; - naut_dht_response response; - if (naut_dht_parse_response(packet, (size_t)received, &response) != NAUT_OK) + if (dht_parse_message(packet, (size_t)received, msg) != TRACKER_OK) continue; - if (response.transaction_len != sizeof tx || - memcmp(response.transaction, tx, sizeof tx) != 0 || - response.type != NAUT_DHT_RESPONSE) { - naut_dht_response_free(&response); + if (msg->type != DHT_MSG_RESPONSE || + msg->transaction_len != sizeof tx || + memcmp(msg->transaction, tx, sizeof tx) != 0) continue; + for (size_t i = 0; i < msg->peer_count; i++) { + if (msg->peers[i].family != TRACKER_ADDR_IPV4) continue; + naut_peer_addr p; + memcpy(p.ip, msg->peers[i].addr, 4); + p.port = msg->peers[i].port; + add_peer(found, &found_count, &p); } - for (size_t i = 0; i < response.num_peers; i++) - add_peer(found, &found_count, &response.peers[i]); - for (size_t i = 0; i < response.num_nodes; i++) { + for (size_t i = 0; i < msg->node_count; i++) { + if (msg->nodes[i].family != TRACKER_ADDR_IPV4) continue; struct sockaddr_in addr; memset(&addr, 0, sizeof addr); addr.sin_family = AF_INET; - memcpy(&addr.sin_addr, response.nodes[i].ip, 4); - addr.sin_port = htons(response.nodes[i].port); + memcpy(&addr.sin_addr, msg->nodes[i].addr, 4); + addr.sin_port = htons(msg->nodes[i].port); add_candidate(nodes, &node_count, &addr); } - naut_dht_response_free(&response); } + free(msg); close(fd); if (found_count == 0) return NAUT_ERR_EMPTY; naut_peer_addr *result = malloc(found_count * sizeof(*result)); diff --git a/src/discovery/tracker_client.c b/src/discovery/tracker_client.c new file mode 100644 index 0000000..ef975be --- /dev/null +++ b/src/discovery/tracker_client.c @@ -0,0 +1,258 @@ +/* tracker_client.c — HTTP/UDP tracker announce client. + * + * The wire codec (query building, bencode/UDP packet encode+decode) comes from + * the sibling `torrent-tracker` library; this file owns only the socket glue and + * the conversion between Naut's announce types and torrent-tracker's. */ +#include "naut/tracker.h" +#include "naut/log.h" + +#include "tracker.h" /* torrent-tracker public ABI */ + +#include <errno.h> +#include <netdb.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> +#include <sys/socket.h> +#include <sys/time.h> + +#define TRACKER_RESPONSE_MAX (16u << 20) + +void naut_tracker_response_free(naut_tracker_response *r) { + if (!r) return; + free(r->peers); + free(r->failure); + r->peers = NULL; + r->failure = NULL; + r->num_peers = 0; +} + +/* naut_announce_req -> torrent-tracker request (compact IPv4 announce). */ +static void to_tracker_request(const naut_announce_req *req, + tracker_announce_request *out) { + memset(out, 0, sizeof *out); + memcpy(out->info_hash, req->info_hash, 20); + memcpy(out->peer_id, req->peer_id, 20); + out->port = req->port; + out->uploaded = req->uploaded; + out->downloaded = req->downloaded; + out->left = req->left; + out->numwant = req->numwant; + out->key = req->key; + out->has_key = 1; + out->compact = 1; + out->event = (tracker_event)req->event; /* codes match BEP-15 */ +} + +/* Copy torrent-tracker IPv4 peers into a freshly malloc'd naut_peer_addr array. */ +static naut_err collect_peers(const tracker_peer *peers, size_t count, + const tracker_announce_response *resp, + naut_tracker_response *out) { + out->interval = (int32_t)resp->interval; + out->min_interval = (int32_t)resp->min_interval; + out->seeders = (int32_t)resp->complete; + out->leechers = (int32_t)resp->incomplete; + out->peers = NULL; + out->num_peers = 0; + if (count == 0) return NAUT_OK; + naut_peer_addr *v = malloc(count * sizeof *v); + if (!v) return NAUT_ERR_NOMEM; + size_t n = 0; + for (size_t i = 0; i < count; i++) { + if (peers[i].family != TRACKER_ADDR_IPV4) continue; /* IPv4 only */ + memcpy(v[n].ip, peers[i].addr, 4); + v[n].port = peers[i].port; + n++; + } + out->peers = v; + out->num_peers = n; + return NAUT_OK; +} + +size_t naut_tracker_http_url(const char *base, const naut_announce_req *req, + char *out, size_t outsz) { + tracker_announce_request treq; + to_tracker_request(req, &treq); + char query[2048]; + size_t qlen = 0; + if (tracker_http_write_announce_query(&treq, query, sizeof query, &qlen) != + TRACKER_OK) + return 0; + const char sep = strchr(base, '?') ? '&' : '?'; + int n = snprintf(out, outsz, "%s%c%.*s", base, sep, (int)qlen, query); + if (n < 0 || (size_t)n >= outsz) return 0; + return (size_t)n; +} + +/* --- HTTP --------------------------------------------------------------- */ + +static int dial(const char *host, const char *port, int socktype) { + struct addrinfo hints, *res = NULL, *ai; + memset(&hints, 0, sizeof hints); + hints.ai_family = AF_INET; /* IPv4 (compact peers are v4) */ + hints.ai_socktype = socktype; + if (getaddrinfo(host, port, &hints, &res) != 0) return -1; + int fd = -1; + for (ai = res; ai; ai = ai->ai_next) { + fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd < 0) continue; + struct timeval tv = { .tv_sec = 10, .tv_usec = 0 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break; + close(fd); fd = -1; + } + freeaddrinfo(res); + return fd; +} + +/* split "http://host[:port]/path" */ +static bool parse_http_url(const char *url, char *host, size_t hostsz, + char *port, size_t portsz, const char **path) { + if (strncmp(url, "http://", 7) != 0) return false; + const char *h = url + 7; + const char *slash = strchr(h, '/'); + const char *hostend = slash ? slash : h + strlen(h); + const char *colon = memchr(h, ':', (size_t)(hostend - h)); + size_t hlen = colon ? (size_t)(colon - h) : (size_t)(hostend - h); + if (hlen >= hostsz) return false; + memcpy(host, h, hlen); host[hlen] = 0; + if (colon) { + size_t plen = (size_t)(hostend - colon - 1); + if (plen >= portsz) return false; + memcpy(port, colon + 1, plen); port[plen] = 0; + } else { snprintf(port, portsz, "80"); } + *path = slash ? slash : "/"; + return true; +} + +static bool write_all(int fd, const void *data, size_t len) { + const uint8_t *p = data; + while (len) { + ssize_t n = write(fd, p, len); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + p += (size_t)n; + len -= (size_t)n; + } + return true; +} + +naut_err naut_tracker_announce_http(const char *url, naut_tracker_response *out) { + char host[256], port[16]; const char *path; + if (!parse_http_url(url, host, sizeof host, port, sizeof port, &path)) + return NAUT_ERR_INVAL; + int fd = dial(host, port, SOCK_STREAM); + if (fd < 0) { NAUT_WARN("tracker connect %s:%s failed", host, port); return NAUT_ERR_IO; } + + char req[4096]; + int rn = snprintf(req, sizeof req, + "GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: Naut/0.1\r\nAccept: */*\r\n\r\n", + path, host); + if (rn < 0 || (size_t)rn >= sizeof req || + !write_all(fd, req, (size_t)rn)) { + close(fd); + return NAUT_ERR_IO; + } + + /* read whole response (server closes on HTTP/1.0) */ + size_t cap = 1 << 16, len = 0; + uint8_t *buf = malloc(cap); + if (!buf) { close(fd); return NAUT_ERR_NOMEM; } + naut_err read_error = NAUT_OK; + for (;;) { + if (len == cap) { + if (cap == TRACKER_RESPONSE_MAX) { read_error = NAUT_ERR_FULL; break; } + size_t next_cap = NAUT_MIN(cap * 2, (size_t)TRACKER_RESPONSE_MAX); + uint8_t *next = realloc(buf, next_cap); + if (!next) { read_error = NAUT_ERR_NOMEM; break; } + buf = next; + cap = next_cap; + } + ssize_t r = read(fd, buf + len, cap - len); + if (r < 0) { + if (errno == EINTR) continue; + read_error = NAUT_ERR_IO; + break; + } + if (r == 0) break; + len += (size_t)r; + } + close(fd); + if (read_error != NAUT_OK) { free(buf); return read_error; } + + /* find body after CRLFCRLF */ + uint8_t *body = NULL; size_t blen = 0; + for (size_t i = 0; i + 3 < len; i++) + if (buf[i]=='\r'&&buf[i+1]=='\n'&&buf[i+2]=='\r'&&buf[i+3]=='\n') { + body = buf + i + 4; blen = len - (i + 4); break; + } + bool ok = len >= 12 && memcmp(buf, "HTTP/", 5) == 0 && buf[9] == '2'; + if (!ok || !body) { free(buf); return NAUT_ERR_PROTO; } + + tracker_peer peers[TRACKER_MAX_PEERS]; + tracker_announce_response resp; + memset(&resp, 0, sizeof resp); + naut_err e = NAUT_ERR_PROTO; + if (tracker_http_parse_announce_response(body, blen, peers, + TRACKER_MAX_PEERS, &resp) == + TRACKER_OK) + e = collect_peers(resp.peers, resp.peer_count, &resp, out); + free(buf); + return e; +} + +/* --- UDP (BEP-15) ------------------------------------------------------- */ + +naut_err naut_tracker_announce_udp(const char *host, uint16_t port, + const naut_announce_req *req, + naut_tracker_response *out) { + char portstr[16]; snprintf(portstr, sizeof portstr, "%u", port); + int fd = dial(host, portstr, SOCK_DGRAM); + if (fd < 0) return NAUT_ERR_IO; + + srand((unsigned)time(NULL) ^ (unsigned)getpid()); + uint32_t txid = (uint32_t)rand(); + + uint8_t pkt[128], resp[2048]; + size_t written = 0; + if (tracker_udp_write_connect_request(txid, pkt, sizeof pkt, &written) != + TRACKER_OK || + !write_all(fd, pkt, written)) { + close(fd); return NAUT_ERR_IO; + } + ssize_t r = read(fd, resp, sizeof resp); + uint64_t cid = 0; + if (r < 0 || + tracker_udp_parse_connect_response(resp, (size_t)r, txid, &cid) != + TRACKER_OK) { + close(fd); return NAUT_ERR_IO; + } + + txid++; + tracker_announce_request treq; + to_tracker_request(req, &treq); + if (tracker_udp_write_announce_request(cid, txid, &treq, pkt, sizeof pkt, + &written) != TRACKER_OK || + !write_all(fd, pkt, written)) { + close(fd); return NAUT_ERR_IO; + } + r = read(fd, resp, sizeof resp); + naut_err e = NAUT_ERR_IO; + if (r >= 0) { + tracker_peer peers[TRACKER_MAX_PEERS]; + tracker_announce_response tresp; + memset(&tresp, 0, sizeof tresp); + e = NAUT_ERR_PROTO; + if (tracker_udp_parse_announce_response(resp, (size_t)r, txid, + TRACKER_ADDR_IPV4, peers, + TRACKER_MAX_PEERS, &tresp) == + TRACKER_OK) + e = collect_peers(tresp.peers, tresp.peer_count, &tresp, out); + } + close(fd); + return e; +} diff --git a/src/metainfo/metainfo.c b/src/metainfo/metainfo.c index 84277a2..d9ed89e 100644 --- a/src/metainfo/metainfo.c +++ b/src/metainfo/metainfo.c @@ -20,9 +20,37 @@ static char *dup_cstr(const uint8_t *p, size_t n) { return s; } -/* collect a single announce string or an announce-list (list of tiers) */ -static void collect_trackers(const naut_bc *root, naut_metainfo *mi) { - size_t cap = 0; +static naut_err add_tracker(naut_metainfo *mi, size_t *capacity, + const uint8_t *url, size_t url_len, + uint32_t tier) { + for (size_t i = 0; i < mi->num_trackers; i++) + if (strlen(mi->trackers[i]) == url_len && + memcmp(mi->trackers[i], url, url_len) == 0) + return NAUT_OK; + if (mi->num_trackers == *capacity) { + size_t next_capacity = *capacity ? *capacity * 2 : 8; + char **next_trackers = + realloc(mi->trackers, next_capacity * sizeof(*next_trackers)); + if (!next_trackers) return NAUT_ERR_NOMEM; + mi->trackers = next_trackers; + uint32_t *next_tiers = + realloc(mi->tracker_tiers, + next_capacity * sizeof(*next_tiers)); + if (!next_tiers) return NAUT_ERR_NOMEM; + mi->tracker_tiers = next_tiers; + *capacity = next_capacity; + } + char *copy = dup_cstr(url, url_len); + if (!copy) return NAUT_ERR_NOMEM; + mi->trackers[mi->num_trackers] = copy; + mi->tracker_tiers[mi->num_trackers] = tier; + mi->num_trackers++; + return NAUT_OK; +} + +/* Preserve the outer announce-list as BEP-12 failover tiers. */ +static naut_err collect_trackers(const naut_bc *root, naut_metainfo *mi) { + size_t capacity = 0; const naut_bc *al = naut_bc_dict_get(root, "announce-list"); if (al && al->type == NAUT_BC_LIST) { for (size_t t = 0; t < al->v.list.count; t++) { @@ -32,21 +60,20 @@ static void collect_trackers(const naut_bc *root, naut_metainfo *mi) { const naut_bc *url = naut_bc_list_at(tier, u); const uint8_t *p; size_t n; if (!naut_bc_get_str(url, &p, &n)) continue; - if (mi->num_trackers == cap) { - cap = cap ? cap * 2 : 8; - mi->trackers = realloc(mi->trackers, cap * sizeof(char *)); - } - mi->trackers[mi->num_trackers++] = dup_cstr(p, n); + naut_err error = + add_tracker(mi, &capacity, p, n, (uint32_t)t); + if (error != NAUT_OK) return error; } } } if (mi->num_trackers == 0) { const uint8_t *p; size_t n; if (naut_bc_get_str(naut_bc_dict_get(root, "announce"), &p, &n)) { - mi->trackers = malloc(sizeof(char *)); - mi->trackers[mi->num_trackers++] = dup_cstr(p, n); + naut_err error = add_tracker(mi, &capacity, p, n, 0); + if (error != NAUT_OK) return error; } } + return NAUT_OK; } /* v1 file list: single-file (info.length) or multi-file (info.files[]) */ @@ -220,7 +247,12 @@ naut_err naut_metainfo_parse(const uint8_t *data, size_t len, naut_metainfo *out } else { collect_files_v2(info, out); /* v2-only: walk the file tree */ } - collect_trackers(root, out); + e = collect_trackers(root, out); + if (e != NAUT_OK) { + out->_owned = o; + naut_metainfo_free(out); + return e; + } out->_owned = o; return NAUT_OK; @@ -245,7 +277,9 @@ naut_err naut_metainfo_parse_info(const uint8_t *info, size_t info_len, if (num_trackers) { out->trackers = calloc(num_trackers, sizeof(*out->trackers)); - if (!out->trackers) { + out->tracker_tiers = + calloc(num_trackers, sizeof(*out->tracker_tiers)); + if (!out->trackers || !out->tracker_tiers) { naut_metainfo_free(out); return NAUT_ERR_NOMEM; } @@ -257,6 +291,9 @@ naut_err naut_metainfo_parse_info(const uint8_t *info, size_t info_len, naut_metainfo_free(out); return NAUT_ERR_NOMEM; } + /* Magnet tr= parameters have no tier metadata. Treat them as + * ordered failover entries instead of announcing to all at once. */ + out->tracker_tiers[i] = (uint32_t)i; } out->num_trackers = num_trackers; } @@ -270,6 +307,7 @@ void naut_metainfo_free(naut_metainfo *mi) { free(mi->files); for (size_t i = 0; i < mi->num_trackers; i++) free(mi->trackers[i]); free(mi->trackers); + free(mi->tracker_tiers); if (mi->_owned) { owned *o = mi->_owned; naut_bc_free(o->doc); diff --git a/src/net/http_client.c b/src/net/http_client.c new file mode 100644 index 0000000..99a2328 --- /dev/null +++ b/src/net/http_client.c @@ -0,0 +1,269 @@ +/* http_client.c — blocking HTTP/HTTPS GET with redirect handling. + * + * A small, dependency-light client: raw sockets for HTTP, OpenSSL for HTTPS. + * It reads the whole response into memory (capped), handles both Content-Length + * and chunked transfer-encoding, and follows 3xx redirects. This is deliberately + * simple — it serves RSS/Torznab fetches, not a general-purpose user agent. */ +#include "naut/http_client.h" + +#include <errno.h> +#include <netdb.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <unistd.h> +#include <sys/socket.h> +#include <sys/time.h> + +#include <stdio.h> + +#include <openssl/ssl.h> +#include <openssl/err.h> + +/* This lib is linked into the plugin module too, which can't resolve the host's + * naut_log symbols, so keep diagnostics dependency-free. */ +#define HTTP_WARN(...) (void)fprintf(stderr, "http: " __VA_ARGS__) + +#define HTTP_MAX_BODY (16 * 1024 * 1024) /* 16 MiB cap */ +#define HTTP_MAX_REDIR 5 + +/* A transport: either a plain fd or an SSL session over it. */ +typedef struct { + int fd; + SSL_CTX *ctx; + SSL *ssl; +} conn_t; + +static void conn_close(conn_t *c) { + if (c->ssl) { SSL_shutdown(c->ssl); SSL_free(c->ssl); c->ssl = NULL; } + if (c->ctx) { SSL_CTX_free(c->ctx); c->ctx = NULL; } + if (c->fd >= 0) { close(c->fd); c->fd = -1; } +} + +static int dial(const char *host, const char *port) { + struct addrinfo hints, *res = NULL, *ai; + memset(&hints, 0, sizeof hints); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + if (getaddrinfo(host, port, &hints, &res) != 0) return -1; + int fd = -1; + for (ai = res; ai; ai = ai->ai_next) { + fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd < 0) continue; + struct timeval tv = { .tv_sec = 15, .tv_usec = 0 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break; + close(fd); fd = -1; + } + freeaddrinfo(res); + return fd; +} + +static bool conn_open(conn_t *c, const char *host, const char *port, bool tls) { + memset(c, 0, sizeof *c); + c->fd = dial(host, port); + if (c->fd < 0) { HTTP_WARN("connect %s:%s failed\n", host, port); return false; } + if (!tls) return true; + + c->ctx = SSL_CTX_new(TLS_client_method()); + if (!c->ctx) { conn_close(c); return false; } + SSL_CTX_set_verify(c->ctx, SSL_VERIFY_NONE, NULL); /* best-effort fetch */ + c->ssl = SSL_new(c->ctx); + if (!c->ssl) { conn_close(c); return false; } + SSL_set_fd(c->ssl, c->fd); + SSL_set_tlsext_host_name(c->ssl, host); /* SNI */ + if (SSL_connect(c->ssl) != 1) { + HTTP_WARN("TLS handshake with %s failed\n", host); + conn_close(c); + return false; + } + return true; +} + +static bool conn_write(conn_t *c, const void *data, size_t len) { + const char *p = data; + while (len) { + int n = c->ssl ? SSL_write(c->ssl, p, (int)len) + : (int)write(c->fd, p, len); + if (n <= 0) { + if (!c->ssl && n < 0 && errno == EINTR) continue; + return false; + } + p += n; len -= (size_t)n; + } + return true; +} + +static int conn_read(conn_t *c, void *buf, size_t len) { + for (;;) { + int n = c->ssl ? SSL_read(c->ssl, buf, (int)len) + : (int)read(c->fd, buf, len); + if (n < 0 && !c->ssl && errno == EINTR) continue; + return n; + } +} + +/* Parse "scheme://host[:port]/path". Fills host/port/path; sets *tls. */ +static bool parse_url(const char *url, char *host, size_t hostsz, + char *port, size_t portsz, char *path, size_t pathsz, + bool *tls) { + const char *h; + if (strncasecmp(url, "https://", 8) == 0) { *tls = true; h = url + 8; } + else if (strncasecmp(url, "http://", 7) == 0) { *tls = false; h = url + 7; } + else return false; + + const char *slash = strchr(h, '/'); + const char *hostend = slash ? slash : h + strlen(h); + const char *colon = memchr(h, ':', (size_t)(hostend - h)); + size_t hlen = colon ? (size_t)(colon - h) : (size_t)(hostend - h); + if (hlen == 0 || hlen >= hostsz) return false; + memcpy(host, h, hlen); host[hlen] = 0; + if (colon) { + size_t plen = (size_t)(hostend - colon - 1); + if (plen == 0 || plen >= portsz) return false; + memcpy(port, colon + 1, plen); port[plen] = 0; + } else { + snprintf(port, portsz, "%s", *tls ? "443" : "80"); + } + if (slash) { if (strlen(slash) >= pathsz) return false; snprintf(path, pathsz, "%s", slash); } + else snprintf(path, pathsz, "/"); + return true; +} + +/* Decode a chunked-transfer body in place; returns new length. */ +static size_t dechunk(char *body, size_t len) { + char *out = body; + const char *in = body, *end = body + len; + while (in < end) { + char *nl = (char *)memchr(in, '\n', (size_t)(end - in)); + if (!nl) break; + long sz = strtol(in, NULL, 16); + in = nl + 1; + if (sz <= 0) break; + if (in + sz > end) sz = (long)(end - in); + memmove(out, in, (size_t)sz); + out += sz; + in += sz; + /* skip trailing CRLF after the chunk */ + if (in < end && *in == '\r') in++; + if (in < end && *in == '\n') in++; + } + *out = 0; + return (size_t)(out - body); +} + +/* One request/response round-trip. On a 3xx with Location, writes the target + * into `redirect` (caller retries) and returns NAUT_OK with out->body == NULL. */ +static naut_err fetch_once(const char *url, naut_http_response *out, + char *redirect, size_t redirsz) { + char host[256], port[16], path[2048]; + bool tls; + if (!parse_url(url, host, sizeof host, port, sizeof port, + path, sizeof path, &tls)) + return NAUT_ERR_INVAL; + + conn_t c; + if (!conn_open(&c, host, port, tls)) return NAUT_ERR_IO; + + char req[3072]; + int rn = snprintf(req, sizeof req, + "GET %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: Naut/0.1\r\n" + "Accept: */*\r\nConnection: close\r\n\r\n", path, host); + if (rn < 0 || (size_t)rn >= sizeof req || !conn_write(&c, req, (size_t)rn)) { + conn_close(&c); return NAUT_ERR_IO; + } + + size_t cap = 1 << 16, len = 0; + char *buf = malloc(cap); + if (!buf) { conn_close(&c); return NAUT_ERR_NOMEM; } + for (;;) { + if (len + 1 >= cap) { + if (cap >= HTTP_MAX_BODY) break; + size_t ncap = cap * 2 > HTTP_MAX_BODY ? HTTP_MAX_BODY : cap * 2; + char *nb = realloc(buf, ncap); + if (!nb) { free(buf); conn_close(&c); return NAUT_ERR_NOMEM; } + buf = nb; cap = ncap; + } + int r = conn_read(&c, buf + len, cap - len - 1); + if (r < 0) { free(buf); conn_close(&c); return NAUT_ERR_IO; } + if (r == 0) break; + len += (size_t)r; + } + conn_close(&c); + buf[len] = 0; + + if (len < 12 || memcmp(buf, "HTTP/", 5) != 0) { free(buf); return NAUT_ERR_PROTO; } + long status = strtol(buf + 9, NULL, 10); + + char *hdr_end = NULL; + for (size_t i = 0; i + 3 < len; i++) + if (buf[i]=='\r'&&buf[i+1]=='\n'&&buf[i+2]=='\r'&&buf[i+3]=='\n') { + hdr_end = buf + i + 4; break; + } + if (!hdr_end) { free(buf); return NAUT_ERR_PROTO; } + + /* Headers are everything before hdr_end; scan them case-insensitively. */ + size_t hdr_len = (size_t)(hdr_end - buf); + bool chunked = false; + char *loc = NULL; + for (char *p = buf; p < buf + hdr_len; ) { + char *eol = memchr(p, '\n', (size_t)(buf + hdr_len - p)); + size_t line = eol ? (size_t)(eol - p) : (size_t)(buf + hdr_len - p); + if (strncasecmp(p, "Transfer-Encoding:", 18) == 0 && + line < 256 && memmem(p, line, "chunked", 7)) + chunked = true; + if (strncasecmp(p, "Location:", 9) == 0) loc = p + 9; + if (!eol) break; + p = eol + 1; + } + + if (status >= 300 && status < 400 && loc && redirect) { + while (*loc == ' ' || *loc == '\t') loc++; + size_t n = strcspn(loc, "\r\n"); + if (n && n < redirsz) { memcpy(redirect, loc, n); redirect[n] = 0; } + else redirect[0] = 0; + free(buf); + out->body = NULL; out->status = status; out->body_len = 0; + return NAUT_OK; + } + + /* Move body to the front of the allocation so the caller owns one buffer. */ + size_t blen = len - hdr_len; + memmove(buf, hdr_end, blen); + buf[blen] = 0; + if (chunked) blen = dechunk(buf, blen); + + out->status = status; + out->body = buf; + out->body_len = blen; + if (redirect) redirect[0] = 0; + return NAUT_OK; +} + +naut_err naut_http_get(const char *url, naut_http_response *out) { + if (!url || !out) return NAUT_ERR_INVAL; + out->body = NULL; out->status = 0; out->body_len = 0; + + char current[2048]; + if (strlen(url) >= sizeof current) return NAUT_ERR_INVAL; + snprintf(current, sizeof current, "%s", url); + + for (int hop = 0; hop <= HTTP_MAX_REDIR; hop++) { + char redirect[2048] = {0}; + naut_err e = fetch_once(current, out, redirect, sizeof redirect); + if (e != NAUT_OK) return e; + if (out->body) return NAUT_OK; /* got a real response */ + if (!redirect[0]) return NAUT_ERR_PROTO; + /* Relative redirect: only absolute URLs are followed here. */ + if (strncasecmp(redirect, "http", 4) != 0) return NAUT_ERR_PROTO; + snprintf(current, sizeof current, "%s", redirect); + } + return NAUT_ERR_PROTO; /* too many redirects */ +} + +void naut_http_response_free(naut_http_response *r) { + if (!r) return; + free(r->body); + r->body = NULL; r->body_len = 0; r->status = 0; +} diff --git a/src/peer/mse.c b/src/peer/mse.c deleted file mode 100644 index 44df867..0000000 --- a/src/peer/mse.c +++ /dev/null @@ -1,466 +0,0 @@ -#include "naut/mse.h" -#include "naut/hash.h" - -#include <errno.h> -#include <openssl/bn.h> -#include <openssl/rand.h> -#include <stdlib.h> -#include <string.h> -#include <sys/socket.h> - -#define MSE_PAD_MAX 512 -#define MSE_CRYPTO_RC4 2u - -static const char DH_PRIME_HEX[] = - "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC" - "74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF2" - "5F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A63A3621000000" - "0000090563"; - -static void wr16(uint8_t *p, uint16_t value) { - p[0] = (uint8_t)(value >> 8); - p[1] = (uint8_t)value; -} - -static void wr32(uint8_t *p, uint32_t value) { - p[0] = (uint8_t)(value >> 24); - p[1] = (uint8_t)(value >> 16); - p[2] = (uint8_t)(value >> 8); - p[3] = (uint8_t)value; -} - -static uint16_t rd16(const uint8_t *p) { - return ((uint16_t)p[0] << 8) | p[1]; -} - -static uint32_t rd32(const uint8_t *p) { - return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | - ((uint32_t)p[2] << 8) | p[3]; -} - -static void hash_parts(const char label[4], - const uint8_t *first, size_t first_len, - const uint8_t *second, size_t second_len, - uint8_t out[20]) { - naut_sha1_ctx sha; - naut_sha1_init(&sha); - naut_sha1_update(&sha, label, 4); - naut_sha1_update(&sha, first, first_len); - if (second && second_len) naut_sha1_update(&sha, second, second_len); - naut_sha1_final(&sha, out); -} - -static void init_rc4(const uint8_t secret[NAUT_MSE_DH_LEN], - const uint8_t info_hash[20], - naut_mse_stream *stream) { - uint8_t key_a[20], key_b[20]; - hash_parts("keyA", secret, NAUT_MSE_DH_LEN, info_hash, 20, key_a); - hash_parts("keyB", secret, NAUT_MSE_DH_LEN, info_hash, 20, key_b); - naut_rc4_init(&stream->send, key_a, sizeof key_a, 1024); - naut_rc4_init(&stream->recv, key_b, sizeof key_b, 1024); - memset(key_a, 0, sizeof key_a); - memset(key_b, 0, sizeof key_b); -} - -/* ---- sans-IO handshake state machine ------------------------------------- */ - -enum { - PH_RECV_PUBKEY, /* waiting for the peer's 96-byte DH public key */ - PH_SYNC_VC, /* scanning past PadB for the encrypted VC */ - PH_RECV_SELECT, /* crypto_select + len(PadD) */ - PH_RECV_PAD, /* PadD bytes (discarded) */ - PH_RECV_HS, /* the peer's encrypted BitTorrent handshake */ -}; - -struct naut_mse_handshake { - int phase; - naut_err err; - bool done; - - uint8_t info_hash[20]; - uint8_t peer_id[NAUT_PEERID_LEN]; - uint64_t reserved; - - /* DH state retained until the shared secret is computed. */ - BN_CTX *ctx; - BIGNUM *prime; - BIGNUM *priv; - - naut_mse_stream stream; - uint8_t expected_vc[8]; - size_t vc_scanned; - size_t pad_remaining; - uint8_t remote_handshake[NAUT_HANDSHAKE_LEN]; - - uint8_t out[256]; - size_t out_len, out_off; - - uint8_t in[1024]; - size_t in_len; -}; - -static void dh_free(naut_mse_handshake *h) { - BN_CTX_free(h->ctx); h->ctx = NULL; - BN_free(h->prime); h->prime = NULL; - BN_clear_free(h->priv); h->priv = NULL; -} - -/* Generate our private key and public value, writing the 96-byte public key - * into the outgoing buffer. Retains prime/priv/ctx for dh_complete(). */ -static naut_err dh_begin(naut_mse_handshake *h) { - naut_err result = NAUT_ERR_IO; - BIGNUM *generator = BN_new(); - BIGNUM *local = BN_new(); - h->ctx = BN_CTX_new(); - h->priv = BN_new(); - if (!generator || !local || !h->ctx || !h->priv || - !BN_hex2bn(&h->prime, DH_PRIME_HEX) || !BN_set_word(generator, 2)) - goto done; - do { - if (!BN_rand_range(h->priv, h->prime)) goto done; - } while (BN_cmp(h->priv, generator) < 0); - if (!BN_mod_exp(local, generator, h->priv, h->prime, h->ctx) || - BN_bn2binpad(local, h->out, NAUT_MSE_DH_LEN) != NAUT_MSE_DH_LEN) - goto done; - h->out_len = NAUT_MSE_DH_LEN; - h->out_off = 0; - result = NAUT_OK; -done: - BN_free(generator); - BN_free(local); - if (result != NAUT_OK) dh_free(h); - return result; -} - -/* Validate the peer's public key and derive the shared secret. */ -static naut_err dh_complete(naut_mse_handshake *h, const uint8_t remote_bytes[96], - uint8_t secret[NAUT_MSE_DH_LEN]) { - naut_err result = NAUT_ERR_IO; - BIGNUM *remote = BN_new(); - BIGNUM *shared = BN_new(); - BIGNUM *limit = BN_new(); - BIGNUM *two = BN_new(); - if (!remote || !shared || !limit || !two || - !BN_bin2bn(remote_bytes, NAUT_MSE_DH_LEN, remote) || - !BN_set_word(two, 2) || !BN_copy(limit, h->prime) || - !BN_sub_word(limit, 1)) - goto done; - if (BN_cmp(remote, two) < 0 || BN_cmp(remote, limit) >= 0) { - result = NAUT_ERR_PROTO; - goto done; - } - if (!BN_mod_exp(shared, remote, h->priv, h->prime, h->ctx) || - BN_bn2binpad(shared, secret, NAUT_MSE_DH_LEN) != NAUT_MSE_DH_LEN) - goto done; - result = NAUT_OK; -done: - BN_free(remote); - BN_clear_free(shared); - BN_free(limit); - BN_free(two); - return result; -} - -naut_mse_handshake *naut_mse_handshake_begin( - const uint8_t info_hash[20], - const uint8_t peer_id[NAUT_PEERID_LEN], - uint64_t reserved) { - if (!info_hash || !peer_id) return NULL; - naut_mse_handshake *h = calloc(1, sizeof(*h)); - if (!h) return NULL; - memcpy(h->info_hash, info_hash, 20); - memcpy(h->peer_id, peer_id, NAUT_PEERID_LEN); - h->reserved = reserved; - h->phase = PH_RECV_PUBKEY; - if (dh_begin(h) != NAUT_OK) { - naut_mse_handshake_free(h); - return NULL; - } - return h; -} - -void naut_mse_handshake_free(naut_mse_handshake *h) { - if (!h) return; - dh_free(h); - /* keystream state is sensitive; scrub before release */ - memset(h, 0, sizeof(*h)); - free(h); -} - -static void consume(naut_mse_handshake *h, size_t n) { - memmove(h->in, h->in + n, h->in_len - n); - h->in_len -= n; -} - -/* Build req1/req2 + encrypted offer (VC, crypto_provide, PadC, IA) into out. */ -static void build_request(naut_mse_handshake *h, const uint8_t secret[96]) { - uint8_t req1[20], req2[20], req3[20]; - hash_parts("req1", secret, NAUT_MSE_DH_LEN, NULL, 0, req1); - hash_parts("req2", h->info_hash, 20, NULL, 0, req2); - hash_parts("req3", secret, NAUT_MSE_DH_LEN, NULL, 0, req3); - for (size_t i = 0; i < sizeof req2; i++) req2[i] ^= req3[i]; - - init_rc4(secret, h->info_hash, &h->stream); - - uint8_t *p = h->out; - memcpy(p, req1, 20); - memcpy(p + 20, req2, 20); - p += 40; - - uint8_t *offer = p; /* VC(8) crypto_provide(4) padlen(2) ialen(2) IA */ - memset(offer, 0, 8); - wr32(offer + 8, MSE_CRYPTO_RC4); - wr16(offer + 12, 0); - wr16(offer + 14, NAUT_HANDSHAKE_LEN); - naut_peer_handshake_build(offer + 16, h->info_hash, h->peer_id, h->reserved); - size_t offer_len = 16 + NAUT_HANDSHAKE_LEN; - naut_rc4_xor(&h->stream.send, offer, offer_len); - - h->out_len = 40 + offer_len; - h->out_off = 0; - - /* expected_vc = our recv keystream applied to 8 zero bytes at position 0, - * without advancing the real recv state (we resync on it). */ - naut_rc4 probe = h->stream.recv; - uint8_t vc[8] = {0}; - naut_rc4_xor(&probe, vc, sizeof vc); - memcpy(h->expected_vc, vc, sizeof vc); - h->vc_scanned = 0; -} - -static void advance(naut_mse_handshake *h) { - for (;;) { - switch (h->phase) { - case PH_RECV_PUBKEY: { - if (h->in_len < NAUT_MSE_DH_LEN) return; - uint8_t secret[NAUT_MSE_DH_LEN]; - naut_err e = dh_complete(h, h->in, secret); - if (e != NAUT_OK) { h->err = e; return; } - consume(h, NAUT_MSE_DH_LEN); - dh_free(h); /* DH no longer needed */ - build_request(h, secret); - memset(secret, 0, sizeof secret); - h->phase = PH_SYNC_VC; - return; /* out now holds req+offer: NEED_WRITE */ - } - case PH_SYNC_VC: { - while (h->in_len >= sizeof h->expected_vc) { - if (memcmp(h->in, h->expected_vc, sizeof h->expected_vc) == 0) { - uint8_t vc[8]; - memcpy(vc, h->in, sizeof vc); - naut_rc4_xor(&h->stream.recv, vc, sizeof vc); - static const uint8_t zero8[8] = {0}; - if (memcmp(vc, zero8, sizeof vc) != 0) { - h->err = NAUT_ERR_PROTO; - return; - } - consume(h, sizeof vc); - h->phase = PH_RECV_SELECT; - break; - } - consume(h, 1); - if (++h->vc_scanned > MSE_PAD_MAX) { - h->err = NAUT_ERR_PROTO; - return; - } - } - if (h->phase == PH_SYNC_VC) return; /* need more bytes */ - continue; - } - case PH_RECV_SELECT: { - if (h->in_len < 6) return; - uint8_t hdr[6]; - memcpy(hdr, h->in, sizeof hdr); - naut_rc4_xor(&h->stream.recv, hdr, sizeof hdr); - consume(h, sizeof hdr); - if (rd32(hdr) != MSE_CRYPTO_RC4) { h->err = NAUT_ERR_PROTO; return; } - h->pad_remaining = rd16(hdr + 4); - if (h->pad_remaining > MSE_PAD_MAX) { h->err = NAUT_ERR_PROTO; return; } - h->phase = PH_RECV_PAD; - continue; - } - case PH_RECV_PAD: { - if (h->pad_remaining > 0) { - size_t n = h->pad_remaining < h->in_len ? h->pad_remaining - : h->in_len; - if (n == 0) return; - naut_rc4_xor(&h->stream.recv, h->in, n); /* advance keystream */ - consume(h, n); - h->pad_remaining -= n; - if (h->pad_remaining > 0) return; - } - h->phase = PH_RECV_HS; - continue; - } - case PH_RECV_HS: { - if (h->in_len < NAUT_HANDSHAKE_LEN) return; - memcpy(h->remote_handshake, h->in, NAUT_HANDSHAKE_LEN); - naut_rc4_xor(&h->stream.recv, h->remote_handshake, NAUT_HANDSHAKE_LEN); - consume(h, NAUT_HANDSHAKE_LEN); - uint8_t remote_hash[20], remote_id[20]; - if (!naut_peer_handshake_parse(h->remote_handshake, remote_hash, - remote_id, NULL) || - memcmp(remote_hash, h->info_hash, 20) != 0) { - h->err = NAUT_ERR_PROTO; - return; - } - h->stream.active = true; - h->done = true; - return; - } - default: - h->err = NAUT_ERR_PROTO; - return; - } - } -} - -naut_mse_hs_status naut_mse_handshake_status(const naut_mse_handshake *h) { - if (!h || h->err != NAUT_OK) return NAUT_MSE_HS_ERROR; - if (h->done) return NAUT_MSE_HS_DONE; - if (h->out_off < h->out_len) return NAUT_MSE_HS_NEED_WRITE; - return NAUT_MSE_HS_NEED_READ; -} - -size_t naut_mse_handshake_pull(naut_mse_handshake *h, uint8_t *buf, size_t cap) { - if (!h || !buf) return 0; - size_t avail = h->out_len - h->out_off; - size_t n = avail < cap ? avail : cap; - if (n) { - memcpy(buf, h->out + h->out_off, n); - h->out_off += n; - if (h->out_off == h->out_len) h->out_len = h->out_off = 0; - } - return n; -} - -naut_mse_hs_status naut_mse_handshake_feed(naut_mse_handshake *h, - const uint8_t *data, size_t len, - size_t *consumed) { - if (consumed) *consumed = 0; - if (!h) return NAUT_MSE_HS_ERROR; - if (h->err == NAUT_OK && !h->done && data && len) { - size_t space = sizeof h->in - h->in_len; - size_t take = len < space ? len : space; - memcpy(h->in + h->in_len, data, take); - h->in_len += take; - if (consumed) *consumed = take; - advance(h); - } - return naut_mse_handshake_status(h); -} - -naut_err naut_mse_handshake_finish(naut_mse_handshake *h, - naut_mse_stream *stream, - uint8_t remote_handshake[NAUT_HANDSHAKE_LEN]) { - if (!h || !stream || !remote_handshake) return NAUT_ERR_INVAL; - if (h->err != NAUT_OK) return h->err; - if (!h->done) return NAUT_ERR_AGAIN; - *stream = h->stream; - memcpy(remote_handshake, h->remote_handshake, NAUT_HANDSHAKE_LEN); - return NAUT_OK; -} - -/* ---- blocking I/O helpers + convenience wrapper -------------------------- */ - -static bool raw_send_all(int fd, const void *data, size_t len) { - const uint8_t *p = data; - while (len) { - ssize_t n = send(fd, p, len, MSG_NOSIGNAL); - if (n < 0) { - if (errno == EINTR) continue; - return false; - } - if (n == 0) return false; - p += n; - len -= (size_t)n; - } - return true; -} - -static bool raw_recv_exact(int fd, void *data, size_t len) { - uint8_t *p = data; - while (len) { - ssize_t n = recv(fd, p, len, 0); - if (n < 0) { - if (errno == EINTR) continue; - return false; - } - if (n == 0) return false; - p += n; - len -= (size_t)n; - } - return true; -} - -naut_err naut_mse_client_handshake( - int fd, - const uint8_t info_hash[20], - const uint8_t peer_id[NAUT_PEERID_LEN], - uint64_t reserved, - naut_mse_stream *stream, - uint8_t remote_handshake[NAUT_HANDSHAKE_LEN]) { - if (fd < 0 || !info_hash || !peer_id || !stream || !remote_handshake) - return NAUT_ERR_INVAL; - memset(stream, 0, sizeof(*stream)); - - naut_mse_handshake *h = - naut_mse_handshake_begin(info_hash, peer_id, reserved); - if (!h) return NAUT_ERR_NOMEM; - - naut_err rc = NAUT_ERR_PROTO; - for (;;) { - naut_mse_hs_status st = naut_mse_handshake_status(h); - if (st == NAUT_MSE_HS_NEED_WRITE) { - uint8_t buf[256]; - size_t n; - bool ok = true; - while ((n = naut_mse_handshake_pull(h, buf, sizeof buf)) > 0) - if (!raw_send_all(fd, buf, n)) { ok = false; break; } - if (!ok) { rc = NAUT_ERR_IO; break; } - } else if (st == NAUT_MSE_HS_NEED_READ) { - /* One byte at a time: the handshake is tiny and one-shot, and this - * keeps the wrapper from over-reading into the payload stream. */ - uint8_t byte; - if (!raw_recv_exact(fd, &byte, 1)) { rc = NAUT_ERR_IO; break; } - naut_mse_handshake_feed(h, &byte, 1, NULL); - } else if (st == NAUT_MSE_HS_DONE) { - rc = naut_mse_handshake_finish(h, stream, remote_handshake); - break; - } else { - rc = h->err != NAUT_OK ? h->err : NAUT_ERR_PROTO; - break; - } - } - naut_mse_handshake_free(h); - return rc; -} - -/* ---- post-handshake stream I/O ------------------------------------------- */ - -bool naut_mse_send_all(int fd, naut_mse_stream *stream, - const void *data, size_t len) { - if (!stream || !stream->active) return raw_send_all(fd, data, len); - const uint8_t *p = data; - uint8_t block[16 * 1024]; - while (len) { - size_t n = len < sizeof block ? len : sizeof block; - memcpy(block, p, n); - naut_rc4_xor(&stream->send, block, n); - if (!raw_send_all(fd, block, n)) return false; - p += n; - len -= n; - } - return true; -} - -ssize_t naut_mse_recv(int fd, naut_mse_stream *stream, - void *data, size_t len) { - ssize_t n; - do { - n = recv(fd, data, len, 0); - } while (n < 0 && errno == EINTR); - if (n > 0 && stream && stream->active) - naut_rc4_xor(&stream->recv, data, (size_t)n); - return n; -} diff --git a/src/peer/pipeline.c b/src/peer/pipeline.c deleted file mode 100644 index 6b0989b..0000000 --- a/src/peer/pipeline.c +++ /dev/null @@ -1,64 +0,0 @@ -#include "naut/pipeline.h" - -#include <math.h> - -static uint32_t clamp_depth(const naut_pipeline *p, uint32_t depth) { - if (depth < p->min_depth) return p->min_depth; - if (depth > p->max_depth) return p->max_depth; - return depth; -} - -void naut_pipeline_init(naut_pipeline *p, uint32_t block_size, - uint32_t min_depth, uint32_t max_depth, - uint32_t initial_depth) { - if (!p) return; - if (block_size == 0) block_size = NAUT_BLOCK; - if (min_depth == 0) min_depth = 1; - if (max_depth < min_depth) max_depth = min_depth; - p->rtt_seconds = 0; - p->bytes_per_second = 0; - p->last_sample_at = 0; - p->min_depth = min_depth; - p->max_depth = max_depth; - p->block_size = block_size; - p->depth = clamp_depth(p, initial_depth); -} - -void naut_pipeline_on_block(naut_pipeline *p, uint32_t bytes, - double sent_at, double received_at) { - if (!p || bytes == 0 || sent_at <= 0 || received_at <= sent_at) return; - double rtt = received_at - sent_at; - if (rtt > 60.0) return; - - if (p->rtt_seconds == 0) p->rtt_seconds = rtt; - else p->rtt_seconds = p->rtt_seconds * 0.875 + rtt * 0.125; - - double interval = p->last_sample_at > 0 - ? received_at - p->last_sample_at : rtt; - if (interval <= 0) interval = rtt; - double rate = bytes / interval; - if (p->bytes_per_second == 0) p->bytes_per_second = rate; - else p->bytes_per_second = p->bytes_per_second * 0.8 + rate * 0.2; - p->last_sample_at = received_at; - - double blocks = (2.0 * p->bytes_per_second * p->rtt_seconds) / - p->block_size; - uint32_t target = blocks >= UINT32_MAX ? UINT32_MAX : - (uint32_t)ceil(blocks); - target = clamp_depth(p, target); - - /* Grow quickly enough to fill a fast path; shrink one eighth at a time so - * transient delayed samples do not collapse the pipe. */ - if (target > p->depth) { - uint32_t step = p->depth / 4 + 1; - p->depth = clamp_depth(p, NAUT_MIN(target, p->depth + step)); - } else if (target < p->depth) { - uint32_t step = p->depth / 8 + 1; - p->depth = clamp_depth(p, target > p->depth - step - ? target : p->depth - step); - } -} - -uint32_t naut_pipeline_depth(const naut_pipeline *p) { - return p ? p->depth : 0; -} diff --git a/src/piece/piece.c b/src/piece/piece.c index fc59ce2..1fc2f02 100644 --- a/src/piece/piece.c +++ b/src/piece/piece.c @@ -9,6 +9,8 @@ #define BLK NAUT_BLOCK /* 16 KiB */ #define ENDGAME_BLOCKS 8 /* switch to endgame when this few remain */ #define ENDGAME_COPIES 2 /* at most two peers race a missing block */ +#define PIECE_INFLIGHT_SOFT_CAP 64 +#define ACTIVE_PIECE_SOFT_CAP 64 /* per-piece in-progress state, lazily allocated and freed on completion */ typedef struct { @@ -35,8 +37,10 @@ struct naut_download { naut_bitfield have; uint32_t *avail; /* [num_pieces] swarm availability count */ pstate **ps; /* [num_pieces] in-progress state or NULL */ + uint32_t active_pieces; uint32_t cur_piece; /* sequential cursor for next_request() */ + uint32_t pick_cursor; /* rotating start point for rarest-first ties */ uint64_t total_blocks, recv_blocks; uint32_t pieces_done; @@ -49,6 +53,8 @@ struct naut_download { bool *file_done; naut_file_complete_cb file_cb; void *file_cb_ctx; + naut_piece_complete_cb piece_cb; + void *piece_cb_ctx; }; static bool bget(const uint8_t *a, uint32_t i) { return (a[i>>3] >> (i&7)) & 1; } @@ -84,6 +90,7 @@ static pstate *ensure_ps(naut_download *d, uint32_t p) { return NULL; } d->ps[p] = s; + d->active_pieces++; return s; } static void free_ps(naut_download *d, uint32_t p) { @@ -91,6 +98,7 @@ static void free_ps(naut_download *d, uint32_t p) { if (!s) return; free(s->recv_bits); free(s->req_count); free(s->buf); free(s); d->ps[p] = NULL; + if (d->active_pieces) d->active_pieces--; } naut_download *naut_download_create(const naut_metainfo *mi, naut_storage *st) { @@ -150,6 +158,48 @@ void naut_download_destroy(naut_download *d) { free(d); } +static void mark_piece_complete(naut_download *d, uint32_t p, + bool count_blocks, bool emit); + +naut_err naut_download_resume(naut_download *d) { + if (!d) return NAUT_ERR_INVAL; + uint64_t max_piece = d->piece_len; + uint64_t last_piece = piece_size(d, d->num_pieces - 1); + if (last_piece > max_piece) max_piece = last_piece; + if (max_piece > (uint64_t)SIZE_MAX) return NAUT_ERR_INVAL; + + uint8_t *buf = malloc((size_t)max_piece); + if (!buf) return NAUT_ERR_NOMEM; + + uint32_t resumed = 0; + uint8_t digest[NAUT_SHA1_LEN]; + for (uint32_t p = 0; p < d->num_pieces; p++) { + uint64_t ps = piece_size(d, p); + if (ps > (uint64_t)SIZE_MAX) { + free(buf); + return NAUT_ERR_INVAL; + } + naut_err e = naut_storage_read( + d->st, (int64_t)p * (int64_t)d->piece_len, buf, (size_t)ps); + if (e != NAUT_OK) { + free(buf); + return e; + } + naut_sha1(buf, ps, digest); + if (memcmp(digest, + d->mi->piece_hashes + (size_t)p * NAUT_SHA1_LEN, + NAUT_SHA1_LEN) != 0) + continue; + mark_piece_complete(d, p, true, false); + resumed++; + } + free(buf); + if (resumed) + NAUT_INFO("resume: verified %u/%u pieces from disk", + resumed, d->num_pieces); + return NAUT_OK; +} + void naut_download_set_worker_pool(naut_download *d, naut_worker_pool *pool) { if (d) d->workers = pool; } @@ -157,11 +207,16 @@ void naut_download_set_worker_pool(naut_download *d, naut_worker_pool *pool) { void naut_download_set_file_cb(naut_download *d, naut_file_complete_cb cb, void *ctx) { d->file_cb = cb; d->file_cb_ctx = ctx; } +void naut_download_set_piece_cb(naut_download *d, naut_piece_complete_cb cb, + void *ctx) { + d->piece_cb = cb; + d->piece_cb_ctx = ctx; +} bool naut_download_file_complete(const naut_download *d, uint32_t f) { return f < d->num_files && d->file_done[f]; } -static void notify_files(naut_download *d, uint32_t p) { +static void notify_files(naut_download *d, uint32_t p, bool emit) { size_t lo = 0, hi = d->num_files; while (lo < hi) { size_t mid = (lo + hi) / 2; if (d->file_last[mid] < p) lo = mid + 1; else hi = mid; } @@ -169,11 +224,26 @@ static void notify_files(naut_download *d, uint32_t p) { if (d->file_done[f]) continue; if (--d->file_remain[f] == 0) { d->file_done[f] = true; - if (d->file_cb) d->file_cb(d->file_cb_ctx, (uint32_t)f, d->mi->files[f].path); + if (emit && d->file_cb) + d->file_cb(d->file_cb_ctx, (uint32_t)f, + d->mi->files[f].path); } } } +static void mark_piece_complete(naut_download *d, uint32_t p, + bool count_blocks, bool emit) { + if (naut_bitfield_test(&d->have, p)) return; + naut_bitfield_set(&d->have, p); + d->pieces_done++; + d->bytes_done += piece_size(d, p); + if (count_blocks) + d->recv_blocks += nblocks(d, p); + if (emit && d->piece_cb) + d->piece_cb(d->piece_cb_ctx, p); + notify_files(d, p, emit); +} + /* --- availability -------------------------------------------------------- */ void naut_download_inc_avail(naut_download *d, uint32_t p) { if (p < d->num_pieces) d->avail[p]++; @@ -198,6 +268,12 @@ static uint32_t first_unreq(const pstate *s) { return UINT32_MAX; } +static uint32_t piece_inflight(const pstate *s) { + uint32_t n = 0; + for (uint32_t b = 0; b < s->nblocks; b++) n += s->req_count[b]; + return n; +} + static bool hand_out(naut_download *d, uint32_t p, uint32_t b, uint32_t *index, uint32_t *begin, uint32_t *length) { d->ps[p]->req_count[b]++; @@ -211,15 +287,22 @@ bool naut_download_pick_for_peer(naut_download *d, const naut_bitfield *peer_hav d->endgame = (d->total_blocks - d->recv_blocks) <= ENDGAME_BLOCKS; /* pass 1: finish an in-progress piece the peer has (reduces fragmentation) */ - for (uint32_t p = 0; p < d->num_pieces; p++) { + for (uint32_t n = 0; n < d->num_pieces; n++) { + uint32_t p = (d->pick_cursor + n) % d->num_pieces; if (naut_bitfield_test(&d->have, p) || !d->ps[p]) continue; if (p >= peer_have->nbits || !naut_bitfield_test(peer_have, p)) continue; + if (!d->endgame && d->active_pieces < ACTIVE_PIECE_SOFT_CAP && + piece_inflight(d->ps[p]) >= PIECE_INFLIGHT_SOFT_CAP) + continue; uint32_t b = first_unreq(d->ps[p]); if (b != UINT32_MAX) return hand_out(d, p, b, index, begin, length); } /* pass 2: start the rarest new piece the peer has */ + if (!d->endgame && d->active_pieces >= ACTIVE_PIECE_SOFT_CAP) + return false; uint32_t best = UINT32_MAX, best_av = UINT32_MAX; - for (uint32_t p = 0; p < d->num_pieces; p++) { + for (uint32_t n = 0; n < d->num_pieces; n++) { + uint32_t p = (d->pick_cursor + n) % d->num_pieces; if (naut_bitfield_test(&d->have, p) || d->ps[p]) continue; if (p >= peer_have->nbits || !naut_bitfield_test(peer_have, p) || d->avail[p] == 0) continue; @@ -227,12 +310,14 @@ bool naut_download_pick_for_peer(naut_download *d, const naut_bitfield *peer_hav } if (best != UINT32_MAX) { if (!ensure_ps(d, best)) return false; + d->pick_cursor = (best + 1) % d->num_pieces; return hand_out(d, best, 0, index, begin, length); } /* pass 3: endgame — race each missing block on at most two distinct peers */ if (d->endgame) { for (uint8_t copies = 1; copies < ENDGAME_COPIES; copies++) { - for (uint32_t p = 0; p < d->num_pieces; p++) { + for (uint32_t n = 0; n < d->num_pieces; n++) { + uint32_t p = (d->pick_cursor + n) % d->num_pieces; if (naut_bitfield_test(&d->have, p) || p >= peer_have->nbits || !naut_bitfield_test(peer_have, p)) continue; @@ -295,12 +380,9 @@ static naut_err finish_verified(naut_download *d, uint32_t p, } naut_err e = naut_storage_write(d->st, (int64_t)p * (int64_t)d->piece_len, s->buf, ps); if (e != NAUT_OK) return e; - naut_bitfield_set(&d->have, p); - d->pieces_done++; - d->bytes_done += ps; + mark_piece_complete(d, p, false, true); free_ps(d, p); *done = true; - notify_files(d, p); return NAUT_OK; } @@ -383,3 +465,51 @@ bool naut_download_in_endgame(const naut_download *d) { return d->endgame; } uint32_t naut_download_num_pieces(const naut_download *d) { return d->num_pieces; } uint32_t naut_download_pieces_done(const naut_download *d) { return d->pieces_done; } uint64_t naut_download_bytes_done(const naut_download *d) { return d->bytes_done; } +void naut_download_dump(const naut_download *d, FILE *out) { + if (!d || !out) return; + fprintf(out, "=== download dump: %u/%u pieces verified, %llu/%llu bytes ===\n", + d->pieces_done, d->num_pieces, + (unsigned long long)d->bytes_done, (unsigned long long)d->total); + fprintf(out, "blocks: %llu/%llu received, active_pieces=%u, endgame=%d\n", + (unsigned long long)d->recv_blocks, + (unsigned long long)d->total_blocks, + d->active_pieces, d->endgame); + + /* Per-piece assembly state for everything not yet verified. The pieces with + * blocks stuck in flight (or none requested at all) are the ones to chase. */ + uint32_t missing = 0, in_progress = 0; + for (uint32_t p = 0; p < d->num_pieces; p++) { + if (naut_bitfield_test(&d->have, p)) continue; + missing++; + pstate *s = d->ps[p]; + if (!s) continue; + in_progress++; + uint32_t requested = 0, idle = 0; + for (uint32_t b = 0; b < s->nblocks; b++) { + if (bget(s->recv_bits, b)) continue; + if (s->req_count[b]) requested++; + else idle++; + } + fprintf(out, + " piece %u: %u/%u blocks in, %u requested, %u not requested%s\n", + p, s->nrecv, s->nblocks, requested, idle, + s->verifying ? ", verifying" : ""); + } + fprintf(out, "incomplete pieces: %u (%u being assembled, %u untouched)\n", + missing, in_progress, missing - in_progress); +} + +size_t naut_download_piece_states(const naut_download *d, uint8_t *out, + size_t capacity) { + if (!d || !out || capacity == 0) return 0; + size_t count = NAUT_MIN((size_t)d->num_pieces, capacity); + for (size_t i = 0; i < count; i++) { + if (naut_bitfield_test(&d->have, i)) + out[i] = 2; + else if (d->ps[i]) + out[i] = 1; + else + out[i] = 0; + } + return count; +} diff --git a/src/plugin/plugin.c b/src/plugin/plugin.c index d9f42ab..aff5423 100644 --- a/src/plugin/plugin.c +++ b/src/plugin/plugin.c @@ -20,6 +20,7 @@ typedef struct { void *handle; char *path; char *name; + naut_plugin_shutdown_fn shutdown; uint64_t *subscriptions; size_t subscription_count; size_t subscription_capacity; @@ -209,6 +210,36 @@ static void host_log(void *opaque, int level, const char *message) { else NAUT_INFO("plugin: %s", message); } +static naut_err host_call_rpc(void *opaque, const char *method, + const char *request_json, + char **response_json) { + naut_plugin_manager *manager = opaque; + if (!manager || !method || !*method || !response_json) + return NAUT_ERR_INVAL; + *response_json = NULL; + json_t *params = NULL; + if (request_json && *request_json) { + json_error_t json_error; + params = json_loads(request_json, + JSON_REJECT_DUPLICATES | JSON_DECODE_ANY, + &json_error); + if (!params) return NAUT_ERR_PROTO; + } + naut_err error = NAUT_OK; + json_t *result = naut_rpc_dispatch(manager->rpc, method, params, &error); + json_decref(params); + if (error != NAUT_OK) { + json_decref(result); + return error; + } + char *text = json_dumps(result ? result : json_null(), + JSON_COMPACT | JSON_ENCODE_ANY); + json_decref(result); + if (!text) return NAUT_ERR_NOMEM; + *response_json = text; + return NAUT_OK; +} + naut_plugin_manager *naut_plugin_manager_create( naut_rpc_registry *rpc, naut_event_bus *events) { if (!rpc || !events) return NULL; @@ -225,6 +256,7 @@ void naut_plugin_manager_destroy(naut_plugin_manager *manager) { naut_rpc_unregister(manager->rpc, manager->rpc_adapters[i]->method); for (size_t i = 0; i < manager->plugin_count; i++) { loaded_plugin *plugin = &manager->plugins[i]; + if (plugin->shutdown) plugin->shutdown(); for (size_t s = 0; s < plugin->subscription_count; s++) naut_event_unsubscribe(manager->events, plugin->subscriptions[s]); free(plugin->subscriptions); @@ -280,6 +312,10 @@ naut_err naut_plugin_load(naut_plugin_manager *manager, const char *path) { memset(plugin, 0, sizeof(*plugin)); return NAUT_ERR_PROTO; } + dlerror(); + plugin->shutdown = (naut_plugin_shutdown_fn)dlsym(plugin->handle, + "naut_plugin_shutdown"); + dlerror(); naut_host_api host = { .abi_version = NAUT_PLUGIN_ABI_VERSION, .struct_size = sizeof(host), @@ -290,6 +326,7 @@ naut_err naut_plugin_load(naut_plugin_manager *manager, const char *path) { .subscribe_event = host_subscribe_event, .emit_event = host_emit_event, .log = host_log, + .call_rpc = host_call_rpc, }; size_t rpc_start = manager->rpc_count; size_t event_start = manager->event_count; diff --git a/src/script/script.c b/src/script/script.c index 7988633..d72009e 100644 --- a/src/script/script.c +++ b/src/script/script.c @@ -29,8 +29,7 @@ struct naut_script { size_t head; size_t count; bool stopping; - naut_script_move_file_cb move_file; - void *move_context; + naut_script_host host; _Atomic uint64_t queued; _Atomic uint64_t handled; _Atomic uint64_t dropped; @@ -67,12 +66,12 @@ static int lua_move_file(lua_State *lua) { if (torrent_id < 0 || file_index < 0 || (uint64_t)file_index > UINT32_MAX) return luaL_error(lua, "move_file arguments out of range"); - if (!script->move_file) + if (!script->host.move_file) return luaL_error(lua, "move_file is unavailable"); - naut_err error = script->move_file(script->move_context, - (uint64_t)torrent_id, - (uint32_t)file_index, - destination); + naut_err error = script->host.move_file(script->host.context, + (uint64_t)torrent_id, + (uint32_t)file_index, + destination); if (error != NAUT_OK) return luaL_error(lua, "move_file failed: %d", error); atomic_fetch_add_explicit(&script->move_requests, 1, @@ -80,6 +79,98 @@ static int lua_move_file(lua_State *lua) { return 0; } +/* naut.get_labels(torrent_id) -> { "label", ... } (empty table if none). */ +static int lua_get_labels(lua_State *lua) { + naut_script *script = lua_script(lua); + lua_Integer torrent_id = luaL_checkinteger(lua, 1); + if (torrent_id < 0) + return luaL_error(lua, "get_labels: torrent id out of range"); + size_t count = 0; + char **labels = script->host.labels + ? script->host.labels(script->host.context, (uint64_t)torrent_id, &count) + : NULL; + lua_createtable(lua, (int)count, 0); + for (size_t i = 0; i < count; i++) { + lua_pushstring(lua, labels[i]); + lua_rawseti(lua, -2, (int)i + 1); + free(labels[i]); + } + free(labels); + return 1; +} + +/* naut.define_settings({ {key=,label=,type=,default=}, ... }) — declare the + * user-configurable variables this script reads, so the host can render a form + * and persist values. Re-declaring replaces the schema. */ +static int lua_define_settings(lua_State *lua) { + naut_script *script = lua_script(lua); + luaL_checktype(lua, 1, LUA_TTABLE); + if (!script->host.define_settings) return 0; + + size_t count = lua_rawlen(lua, 1); + naut_script_setting_def *defs = + count ? calloc(count, sizeof *defs) : NULL; + /* Stringified defaults need to outlive the per-entry stack churn. */ + char **owned = count ? calloc(count, sizeof *owned) : NULL; + if (count && (!defs || !owned)) { + free(defs); free(owned); + return luaL_error(lua, "define_settings: out of memory"); + } + + size_t n = 0; + for (size_t i = 0; i < count; i++) { + lua_rawgeti(lua, 1, (int)i + 1); /* entry table */ + if (!lua_istable(lua, -1)) { lua_pop(lua, 1); continue; } + lua_getfield(lua, -1, "key"); + const char *key = lua_tostring(lua, -1); + lua_getfield(lua, -2, "label"); + const char *label = lua_tostring(lua, -1); + lua_getfield(lua, -3, "type"); + const char *type = lua_tostring(lua, -1); + lua_getfield(lua, -4, "default"); + const char *defv; + if (lua_isboolean(lua, -1)) + defv = lua_toboolean(lua, -1) ? "true" : "false"; + else + defv = lua_tostring(lua, -1); /* nil -> NULL */ + + if (key) { + defs[n].key = key; /* table strings stay valid while the entry + * table is on the stack (popped after call) */ + defs[n].label = label ? label : key; + defs[n].type = type ? type : "string"; + owned[n] = defv ? strdup(defv) : NULL; + defs[n].default_value = owned[n]; + n++; + } + lua_pop(lua, 5); /* default,type,label,key,entry */ + } + script->host.define_settings(script->host.context, defs, n); + for (size_t i = 0; i < count; i++) free(owned[i]); + free(owned); + free(defs); + return 0; +} + +/* naut.get_setting(key) -> value (typed) or nil. */ +static int lua_get_setting(lua_State *lua) { + naut_script *script = lua_script(lua); + const char *key = luaL_checkstring(lua, 1); + if (!script->host.get_setting) { lua_pushnil(lua); return 1; } + naut_setting_type type = NAUT_SETTING_STRING; + char *value = script->host.get_setting(script->host.context, key, &type); + if (!value) { lua_pushnil(lua); return 1; } + if (type == NAUT_SETTING_BOOL) + lua_pushboolean(lua, strcmp(value, "true") == 0 || + strcmp(value, "1") == 0); + else if (type == NAUT_SETTING_NUMBER) + lua_pushnumber(lua, strtod(value, NULL)); + else + lua_pushstring(lua, value); + free(value); + return 1; +} + static void sandbox(lua_State *lua) { /* Remove every documented route to the filesystem, subprocesses, native * module loading, and raw chunk compilation. `load`/`loadstring` are @@ -103,6 +194,15 @@ static void install_api(naut_script *script) { lua_pushlightuserdata(lua, script); lua_pushcclosure(lua, lua_move_file, 1); lua_setfield(lua, -2, "move_file"); + lua_pushlightuserdata(lua, script); + lua_pushcclosure(lua, lua_get_labels, 1); + lua_setfield(lua, -2, "get_labels"); + lua_pushlightuserdata(lua, script); + lua_pushcclosure(lua, lua_define_settings, 1); + lua_setfield(lua, -2, "define_settings"); + lua_pushlightuserdata(lua, script); + lua_pushcclosure(lua, lua_get_setting, 1); + lua_setfield(lua, -2, "get_setting"); lua_setglobal(lua, "naut"); } @@ -206,8 +306,7 @@ static void queue_event(void *opaque, const naut_event *event) { naut_script *naut_script_create(naut_event_bus *events, const char *script_path, size_t queue_capacity, - naut_script_move_file_cb move_file, - void *move_context, + const naut_script_host *host, naut_err *error) { if (error) *error = NAUT_ERR_INVAL; if (!events || !script_path || !*script_path || queue_capacity == 0) @@ -219,8 +318,7 @@ naut_script *naut_script_create(naut_event_bus *events, } script->events = events; script->capacity = queue_capacity; - script->move_file = move_file; - script->move_context = move_context; + if (host) script->host = *host; script->queue = calloc(queue_capacity, sizeof(*script->queue)); if (!script->queue) { if (error) *error = NAUT_ERR_NOMEM; diff --git a/src/storage/storage.c b/src/storage/storage.c index 23b137f..44b1f72 100644 --- a/src/storage/storage.c +++ b/src/storage/storage.c @@ -15,8 +15,7 @@ typedef struct { int direct_fd; int64_t start; /* global offset of this file's first byte */ int64_t length; - char *path; /* full on-disk path (for relocate) */ - bool externalized; /* moved out; region no longer backed here */ + char *path; /* current on-disk path (updated by relocate) */ } file_slot; struct naut_storage { @@ -63,11 +62,24 @@ naut_storage *naut_storage_open_opts(const naut_file *files, size_t nfiles, s->files[i].fd = -1; s->files[i].direct_fd = -1; char path[4096]; - int n = snprintf(path, sizeof path, "%s/%s", root, files[i].path); + const char *override = opts->overrides ? opts->overrides[i] : NULL; + int n = override + ? snprintf(path, sizeof path, "%s", override) + : snprintf(path, sizeof path, "%s/%s", root, files[i].path); if (n < 0 || n >= (int)sizeof path) goto fail_io; if (make_parents(path) != NAUT_OK) goto fail_io; int fd = open(path, O_RDWR | O_CREAT, 0666); + if (fd < 0 && override) { + /* The relocated copy is gone (e.g. external drive absent); fall back + * to the default location and let resume re-download it. */ + NAUT_WARN("open relocated %s: %s; falling back to %s root", + path, strerror(errno), files[i].path); + n = snprintf(path, sizeof path, "%s/%s", root, files[i].path); + if (n < 0 || n >= (int)sizeof path || make_parents(path) != NAUT_OK) + goto fail_io; + fd = open(path, O_RDWR | O_CREAT, 0666); + } if (fd < 0) { NAUT_ERROR("open %s: %s", path, strerror(errno)); goto fail_io; } if (ftruncate(fd, files[i].length) != 0) { NAUT_ERROR("ftruncate %s: %s", path, strerror(errno)); @@ -140,7 +152,6 @@ static naut_err io_at(naut_storage *s, int64_t offset, void *buf, size_t len, bo while (len > 0) { const file_slot *f = locate(s, offset); if (!f) return NAUT_ERR_RANGE; /* zero-length file region */ - if (f->externalized) return NAUT_ERR_RANGE; /* moved out; not backed here */ off_t fo = (off_t)(offset - f->start); size_t chunk = len; int64_t avail = f->length - fo; @@ -193,11 +204,31 @@ done: return e; } +static naut_err reopen_slot(file_slot *file, const char *path, + bool direct_io) { + file->fd = open(path, O_RDWR); + if (file->fd < 0) return NAUT_ERR_IO; +#ifdef O_DIRECT + if (direct_io && file->length > 0) { + file->direct_fd = open(path, O_RDWR | O_DIRECT); + if (file->direct_fd < 0) + NAUT_WARN("O_DIRECT reopen unavailable for %s: %s", path, + strerror(errno)); + } +#else + (void)direct_io; +#endif + return NAUT_OK; +} + naut_err naut_storage_relocate(naut_storage *s, size_t file_index, const char *dest) { + if (!s || !dest || !*dest) return NAUT_ERR_INVAL; if (file_index >= s->nfiles) return NAUT_ERR_RANGE; file_slot *f = &s->files[file_index]; - if (f->externalized) return NAUT_ERR_INVAL; + char *newpath = strdup(dest); + if (!newpath) return NAUT_ERR_NOMEM; + bool had_direct = f->direct_fd >= 0; if (f->direct_fd >= 0) { fsync(f->direct_fd); close(f->direct_fd); @@ -207,17 +238,45 @@ naut_err naut_storage_relocate(naut_storage *s, size_t file_index, const char *d /* ensure the destination directory exists */ char dcopy[4096]; - if ((size_t)snprintf(dcopy, sizeof dcopy, "%s", dest) >= sizeof dcopy) return NAUT_ERR_INVAL; - if (make_parents(dcopy) != NAUT_OK) return NAUT_ERR_IO; + if ((size_t)snprintf(dcopy, sizeof dcopy, "%s", dest) >= sizeof dcopy) { + free(newpath); + (void)reopen_slot(f, f->path, had_direct); + return NAUT_ERR_INVAL; + } + if (make_parents(dcopy) != NAUT_OK) { + free(newpath); + (void)reopen_slot(f, f->path, had_direct); + return NAUT_ERR_IO; + } if (rename(f->path, dest) != 0) { - if (errno != EXDEV) { NAUT_ERROR("rename %s -> %s: %s", f->path, dest, strerror(errno)); return NAUT_ERR_IO; } + if (errno != EXDEV) { + NAUT_ERROR("rename %s -> %s: %s", f->path, dest, + strerror(errno)); + free(newpath); + (void)reopen_slot(f, f->path, had_direct); + return NAUT_ERR_IO; + } naut_err e = copy_file(f->path, dest); /* cross-filesystem */ - if (e != NAUT_OK) return e; + if (e != NAUT_OK) { + free(newpath); + (void)reopen_slot(f, f->path, had_direct); + return e; + } if (unlink(f->path) != 0) NAUT_WARN("unlink %s after copy: %s", f->path, strerror(errno)); } - f->externalized = true; - NAUT_INFO("relocated file %zu -> %s", file_index, dest); + + /* Keep tracking the file at its new home: update the path and reopen so the + * engine can still read/write/seed it from the new location (no externalize, + * so the owning process never loses track of a moved file). */ + free(f->path); + f->path = newpath; + + if (reopen_slot(f, dest, had_direct) != NAUT_OK) { + NAUT_ERROR("reopen %s after move: %s", dest, strerror(errno)); + return NAUT_ERR_IO; + } + NAUT_INFO("relocated file %zu -> %s (still tracked)", file_index, dest); return NAUT_OK; } diff --git a/src/tracker/fetch.c b/src/tracker/fetch.c deleted file mode 100644 index ac52faa..0000000 --- a/src/tracker/fetch.c +++ /dev/null @@ -1,162 +0,0 @@ -#include "naut/tracker.h" -#include "naut/log.h" - -#include <errno.h> -#include <netdb.h> -#include <stdio.h> -#include <stdlib.h> -#include <string.h> -#include <time.h> -#include <unistd.h> -#include <sys/socket.h> -#include <sys/time.h> - -#define TRACKER_RESPONSE_MAX (16u << 20) - -static int dial(const char *host, const char *port, int socktype) { - struct addrinfo hints, *res = NULL, *ai; - memset(&hints, 0, sizeof hints); - hints.ai_family = AF_INET; /* IPv4 for now (compact peers are v4) */ - hints.ai_socktype = socktype; - if (getaddrinfo(host, port, &hints, &res) != 0) return -1; - int fd = -1; - for (ai = res; ai; ai = ai->ai_next) { - fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); - if (fd < 0) continue; - struct timeval tv = { .tv_sec = 10, .tv_usec = 0 }; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); - if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break; - close(fd); fd = -1; - } - freeaddrinfo(res); - return fd; -} - -/* split "http://host[:port]/path" */ -static bool parse_http_url(const char *url, char *host, size_t hostsz, - char *port, size_t portsz, const char **path) { - if (strncmp(url, "http://", 7) != 0) return false; - const char *h = url + 7; - const char *slash = strchr(h, '/'); - const char *hostend = slash ? slash : h + strlen(h); - const char *colon = memchr(h, ':', (size_t)(hostend - h)); - size_t hlen = colon ? (size_t)(colon - h) : (size_t)(hostend - h); - if (hlen >= hostsz) return false; - memcpy(host, h, hlen); host[hlen] = 0; - if (colon) { - size_t plen = (size_t)(hostend - colon - 1); - if (plen >= portsz) return false; - memcpy(port, colon + 1, plen); port[plen] = 0; - } else { snprintf(port, portsz, "80"); } - *path = slash ? slash : "/"; - return true; -} - -static bool write_all(int fd, const void *data, size_t len) { - const uint8_t *p = data; - while (len) { - ssize_t n = write(fd, p, len); - if (n < 0) { - if (errno == EINTR) continue; - return false; - } - p += (size_t)n; - len -= (size_t)n; - } - return true; -} - -naut_err naut_tracker_announce_http(const char *url, naut_tracker_response *out) { - char host[256], port[16]; const char *path; - if (!parse_http_url(url, host, sizeof host, port, sizeof port, &path)) - return NAUT_ERR_INVAL; - int fd = dial(host, port, SOCK_STREAM); - if (fd < 0) { NAUT_WARN("tracker connect %s:%s failed", host, port); return NAUT_ERR_IO; } - - char req[2048]; - int rn = snprintf(req, sizeof req, - "GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: Naut/0.1\r\nAccept: */*\r\n\r\n", - path, host); - if (rn < 0 || (size_t)rn >= sizeof req || - !write_all(fd, req, (size_t)rn)) { - close(fd); - return NAUT_ERR_IO; - } - - /* read whole response (server closes on HTTP/1.0) */ - size_t cap = 1 << 16, len = 0; - uint8_t *buf = malloc(cap); - if (!buf) { close(fd); return NAUT_ERR_NOMEM; } - naut_err read_error = NAUT_OK; - for (;;) { - if (len == cap) { - if (cap == TRACKER_RESPONSE_MAX) { - read_error = NAUT_ERR_FULL; - break; - } - size_t next_cap = NAUT_MIN(cap * 2, (size_t)TRACKER_RESPONSE_MAX); - uint8_t *next = realloc(buf, next_cap); - if (!next) { - read_error = NAUT_ERR_NOMEM; - break; - } - buf = next; - cap = next_cap; - } - ssize_t r = read(fd, buf + len, cap - len); - if (r < 0) { - if (errno == EINTR) continue; - read_error = NAUT_ERR_IO; - break; - } - if (r == 0) break; - len += (size_t)r; - } - close(fd); - if (read_error != NAUT_OK) { - free(buf); - return read_error; - } - - /* find body after CRLFCRLF */ - uint8_t *body = NULL; size_t blen = 0; - for (size_t i = 0; i + 3 < len; i++) - if (buf[i]=='\r'&&buf[i+1]=='\n'&&buf[i+2]=='\r'&&buf[i+3]=='\n') { - body = buf + i + 4; blen = len - (i + 4); break; - } - bool success = len >= 12 && memcmp(buf, "HTTP/", 5) == 0 && - buf[9] == '2'; - naut_err e = success && body - ? naut_tracker_parse_http(body, blen, out) - : NAUT_ERR_PROTO; - free(buf); - return e; -} - -naut_err naut_tracker_announce_udp(const char *host, uint16_t port, - const naut_announce_req *req, - naut_tracker_response *out) { - char portstr[16]; snprintf(portstr, sizeof portstr, "%u", port); - int fd = dial(host, portstr, SOCK_DGRAM); - if (fd < 0) return NAUT_ERR_IO; - - srand((unsigned)time(NULL) ^ (unsigned)getpid()); - uint32_t txid = (uint32_t)rand(); - - uint8_t pkt[98], resp[1500]; - naut_udp_build_connect(pkt, txid); - if (write(fd, pkt, 16) != 16) { close(fd); return NAUT_ERR_IO; } - ssize_t r = read(fd, resp, sizeof resp); - uint64_t cid; - if (r < 0 || naut_udp_parse_connect(resp, (size_t)r, txid, &cid) != NAUT_OK) { - close(fd); return NAUT_ERR_IO; - } - txid++; - naut_udp_build_announce(pkt, cid, txid, req); - if (write(fd, pkt, 98) != 98) { close(fd); return NAUT_ERR_IO; } - r = read(fd, resp, sizeof resp); - naut_err e = (r < 0) ? NAUT_ERR_IO - : naut_udp_parse_announce(resp, (size_t)r, txid, out); - close(fd); - return e; -} diff --git a/src/tracker/tracker.c b/src/tracker/tracker.c deleted file mode 100644 index cf6be74..0000000 --- a/src/tracker/tracker.c +++ /dev/null @@ -1,120 +0,0 @@ -#include "naut/tracker.h" -#include "naut/bencode.h" - -#include <stdio.h> -#include <stdlib.h> -#include <string.h> - -void naut_tracker_response_free(naut_tracker_response *r) { - free(r->peers); r->peers = NULL; r->num_peers = 0; - free(r->failure); r->failure = NULL; -} - -/* percent-encode raw bytes per RFC 3986 (unreserved chars pass through) */ -static size_t pct_encode(const uint8_t *in, size_t n, char *out, size_t outsz) { - static const char hx[] = "0123456789ABCDEF"; - size_t o = 0; - for (size_t i = 0; i < n; i++) { - uint8_t c = in[i]; - bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || c == '-' || c == '_' || - c == '.' || c == '~'; - if (unreserved) { - if (o + 1 >= outsz) return 0; - out[o++] = (char)c; - } else { - if (o + 3 >= outsz) return 0; - out[o++] = '%'; out[o++] = hx[c >> 4]; out[o++] = hx[c & 15]; - } - } - return o; -} - -size_t naut_tracker_http_url(const char *base, const naut_announce_req *req, - char *out, size_t outsz) { - static const char *ev[] = { "", "completed", "started", "stopped" }; - if (req->event < NAUT_TEV_NONE || req->event > NAUT_TEV_STOPPED) return 0; - char ih[61], pid[61]; /* 20*3 = 60 worst case + NUL */ - size_t ihn = pct_encode(req->info_hash, 20, ih, sizeof ih); - size_t pidn = pct_encode(req->peer_id, 20, pid, sizeof pid); - if (!ihn || !pidn) return 0; - ih[ihn] = 0; pid[pidn] = 0; - - const char *sep = strchr(base, '?') ? "&" : "?"; - int n = snprintf(out, outsz, - "%s%sinfo_hash=%s&peer_id=%s&port=%u&uploaded=%llu&downloaded=%llu" - "&left=%llu&compact=1&numwant=%d%s%s&key=%u", - base, sep, ih, pid, req->port, - (unsigned long long)req->uploaded, (unsigned long long)req->downloaded, - (unsigned long long)req->left, req->numwant < 0 ? 50 : req->numwant, - req->event ? "&event=" : "", ev[req->event], req->key); - if (n < 0 || (size_t)n >= outsz) return 0; - return (size_t)n; -} - -static naut_err parse_peers(const naut_bc *peers, naut_tracker_response *out) { - const uint8_t *p; size_t n; - if (naut_bc_get_str(peers, &p, &n)) { /* compact: 6 bytes each */ - if (n % 6 != 0) return NAUT_ERR_PROTO; - out->num_peers = n / 6; - out->peers = calloc(out->num_peers ? out->num_peers : 1, sizeof(naut_peer_addr)); - if (!out->peers) return NAUT_ERR_NOMEM; - for (size_t i = 0; i < out->num_peers; i++) { - memcpy(out->peers[i].ip, p + i*6, 4); - out->peers[i].port = ((uint16_t)p[i*6+4] << 8) | p[i*6+5]; - } - return NAUT_OK; - } - if (peers && peers->type == NAUT_BC_LIST) { /* dict form */ - out->peers = calloc(peers->v.list.count ? peers->v.list.count : 1, sizeof(naut_peer_addr)); - if (!out->peers) return NAUT_ERR_NOMEM; - for (size_t i = 0; i < peers->v.list.count; i++) { - const naut_bc *pe = naut_bc_list_at(peers, i); - const uint8_t *ips; size_t ipn; int64_t port; - if (!naut_bc_get_str(naut_bc_dict_get(pe, "ip"), &ips, &ipn)) continue; - if (!naut_bc_get_int(naut_bc_dict_get(pe, "port"), &port)) continue; - unsigned a, b, c, dd; - char tmp[64]; - if (ipn >= sizeof tmp) continue; - memcpy(tmp, ips, ipn); tmp[ipn] = 0; - if (sscanf(tmp, "%u.%u.%u.%u", &a, &b, &c, &dd) != 4) continue; - if (a > 255 || b > 255 || c > 255 || dd > 255 || - port <= 0 || port > UINT16_MAX) continue; - naut_peer_addr *pa = &out->peers[out->num_peers++]; - pa->ip[0]=(uint8_t)a; pa->ip[1]=(uint8_t)b; pa->ip[2]=(uint8_t)c; pa->ip[3]=(uint8_t)dd; - pa->port = (uint16_t)port; - } - return NAUT_OK; - } - return NAUT_ERR_PROTO; -} - -naut_err naut_tracker_parse_http(const uint8_t *body, size_t len, - naut_tracker_response *out) { - memset(out, 0, sizeof(*out)); - out->seeders = out->leechers = -1; - naut_bc_doc *doc = NULL; - naut_err e = naut_bc_parse(body, len, &doc); - if (e != NAUT_OK) return e; - const naut_bc *root = naut_bc_root(doc); - - const uint8_t *fp; size_t fn; - if (naut_bc_get_str(naut_bc_dict_get(root, "failure reason"), &fp, &fn)) { - out->failure = malloc(fn + 1); - if (out->failure) { memcpy(out->failure, fp, fn); out->failure[fn] = 0; } - naut_bc_free(doc); - return NAUT_ERR_PROTO; /* tracker reported failure */ - } - - int64_t iv = 0; - naut_bc_get_int(naut_bc_dict_get(root, "interval"), &iv); - out->interval = (int32_t)iv; - int64_t sc; - if (naut_bc_get_int(naut_bc_dict_get(root, "complete"), &sc)) out->seeders = (int32_t)sc; - if (naut_bc_get_int(naut_bc_dict_get(root, "incomplete"), &sc)) out->leechers = (int32_t)sc; - - e = parse_peers(naut_bc_dict_get(root, "peers"), out); - naut_bc_free(doc); - if (e != NAUT_OK) { naut_tracker_response_free(out); return e; } - return NAUT_OK; -} diff --git a/src/tracker/udp.c b/src/tracker/udp.c deleted file mode 100644 index adb7c26..0000000 --- a/src/tracker/udp.c +++ /dev/null @@ -1,81 +0,0 @@ -#include "naut/tracker.h" -#include <stdlib.h> -#include <string.h> - -#define UDP_PROTOCOL_ID 0x41727101980ULL /* BEP-15 magic */ -#define ACTION_CONNECT 0 -#define ACTION_ANNOUNCE 1 -#define ACTION_ERROR 3 - -static void wr16(uint8_t *p, uint16_t v) { p[0]=(uint8_t)(v>>8); p[1]=(uint8_t)v; } -static void wr32(uint8_t *p, uint32_t v) { - p[0]=(uint8_t)(v>>24); p[1]=(uint8_t)(v>>16); p[2]=(uint8_t)(v>>8); p[3]=(uint8_t)v; -} -static void wr64(uint8_t *p, uint64_t v) { wr32(p, (uint32_t)(v>>32)); wr32(p+4, (uint32_t)v); } -static uint32_t rd32(const uint8_t *p) { - return ((uint32_t)p[0]<<24)|((uint32_t)p[1]<<16)|((uint32_t)p[2]<<8)|p[3]; -} -static uint64_t rd64(const uint8_t *p) { return ((uint64_t)rd32(p)<<32) | rd32(p+4); } - -void naut_udp_build_connect(uint8_t out[16], uint32_t txid) { - wr64(out, UDP_PROTOCOL_ID); - wr32(out + 8, ACTION_CONNECT); - wr32(out + 12, txid); -} - -naut_err naut_udp_parse_connect(const uint8_t *in, size_t len, uint32_t txid, - uint64_t *connection_id) { - if (len < 16) return NAUT_ERR_PROTO; - if (rd32(in) != ACTION_CONNECT) return NAUT_ERR_PROTO; - if (rd32(in + 4) != txid) return NAUT_ERR_PROTO; - *connection_id = rd64(in + 8); - return NAUT_OK; -} - -void naut_udp_build_announce(uint8_t out[98], uint64_t connection_id, - uint32_t txid, const naut_announce_req *req) { - wr64(out + 0, connection_id); - wr32(out + 8, ACTION_ANNOUNCE); - wr32(out + 12, txid); - memcpy(out + 16, req->info_hash, 20); - memcpy(out + 36, req->peer_id, 20); - wr64(out + 56, req->downloaded); - wr64(out + 64, req->left); - wr64(out + 72, req->uploaded); - wr32(out + 80, (uint32_t)req->event); - wr32(out + 84, 0); /* IP: 0 = source */ - wr32(out + 88, req->key); - wr32(out + 92, (uint32_t)(req->numwant < 0 ? 50 : req->numwant)); - wr16(out + 96, req->port); -} - -naut_err naut_udp_parse_announce(const uint8_t *in, size_t len, uint32_t txid, - naut_tracker_response *out) { - memset(out, 0, sizeof(*out)); - out->seeders = out->leechers = -1; - if (len < 8) return NAUT_ERR_PROTO; - uint32_t action = rd32(in); - if (rd32(in + 4) != txid) return NAUT_ERR_PROTO; - if (action == ACTION_ERROR) { - size_t mn = len - 8; - out->failure = malloc(mn + 1); - if (out->failure) { memcpy(out->failure, in + 8, mn); out->failure[mn] = 0; } - return NAUT_ERR_PROTO; - } - if (action != ACTION_ANNOUNCE || len < 20 || (len - 20) % 6 != 0) - return NAUT_ERR_PROTO; - out->interval = (int32_t)rd32(in + 8); - out->leechers = (int32_t)rd32(in + 12); - out->seeders = (int32_t)rd32(in + 16); - - size_t avail = (len - 20) / 6; - out->peers = calloc(avail ? avail : 1, sizeof(naut_peer_addr)); - if (!out->peers) return NAUT_ERR_NOMEM; - for (size_t i = 0; i < avail; i++) { - const uint8_t *p = in + 20 + i*6; - memcpy(out->peers[i].ip, p, 4); - out->peers[i].port = ((uint16_t)p[4] << 8) | p[5]; - } - out->num_peers = avail; - return NAUT_OK; -} diff --git a/tests/fixtures/phase7.lua b/tests/fixtures/phase7.lua index 922cf8f..fddfea1 100644 --- a/tests/fixtures/phase7.lua +++ b/tests/fixtures/phase7.lua @@ -1,7 +1,10 @@ function on_torrent_finished(event) - naut.move_file(event.torrent_id, 0, "/tmp/naut-phase7-finished") + print("torrent " .. event.torrent_id .. " finished") end function on_file_complete(event) - naut.move_file(event.torrent_id, event.index, event.path .. ".moved") + -- Labels surface to Lua as a plain array of strings; use the first to route. + local labels = naut.get_labels(event.torrent_id) + local suffix = labels[1] or "moved" + naut.move_file(event.torrent_id, event.index, event.path .. "." .. suffix) end diff --git a/tests/integration/run_phase7.sh b/tests/integration/run_phase7.sh index 5811ad6..994bee5 100644 --- a/tests/integration/run_phase7.sh +++ b/tests/integration/run_phase7.sh @@ -9,6 +9,11 @@ tmp=$(mktemp -d) socket="$tmp/nautd.sock" daemon_log="$tmp/nautd.log" events_log="$tmp/events.log" +seeder_log="$tmp/seeder.log" +root_dir=$(cd "$(dirname "$script")/../.." && pwd) +seeder="$root_dir/tests/integration/seeder.py" +torrent="$(dirname "$script")/single_v1.torrent" +seed_data="$(dirname "$script")/data" cleanup() { result=$? @@ -20,16 +25,36 @@ cleanup() { kill "$events_pid" 2>/dev/null || true wait "$events_pid" 2>/dev/null || true fi + if [[ -n "${seeder_pid:-}" ]]; then + kill "$seeder_pid" 2>/dev/null || true + wait "$seeder_pid" 2>/dev/null || true + fi if [[ "$result" -ne 0 ]]; then cat "$daemon_log" >&2 2>/dev/null || true cat "$events_log" >&2 2>/dev/null || true + cat "$seeder_log" >&2 2>/dev/null || true fi rm -rf "$tmp" return "$result" } trap cleanup EXIT -"$daemon" --socket "$socket" --plugin "$plugin" --script "$script" \ +python3 -c 'import libtorrent' 2>/dev/null || { + echo "SKIP: python libtorrent not available" + exit 77 +} + +python3 "$seeder" "$torrent" "$seed_data" >"$seeder_log" 2>&1 & +seeder_pid=$! +port= +for _ in $(seq 1 100); do + port=$(grep -oP 'PORT \K[0-9]+' "$seeder_log" 2>/dev/null || true) + [[ -n "$port" && "$port" != 0 ]] && break + sleep 0.05 +done +[[ -n "$port" && "$port" != 0 ]] + +"$daemon" --socket "$socket" --plugin "$plugin" --state-dir "$tmp/state" \ >"$daemon_log" 2>&1 & daemon_pid=$! @@ -41,24 +66,28 @@ done "$ctl" --socket "$socket" ping | grep -q '"service": "nautd"' "$ctl" --socket "$socket" plugins | grep -q '"memory"' +"$ctl" --socket "$socket" script "$script" | grep -q '"ok": true' timeout 5 "$ctl" --socket "$socket" events >"$events_log" & events_pid=$! sleep 0.1 -"$ctl" --socket "$socket" emit \ - '{"type":"torrent_finished","torrent_id":7}' >/dev/null +root="$tmp/torrent-data" +"$ctl" --socket "$socket" add "$torrent" "$root" "127.0.0.1:$port" \ + | grep -q '"ok": true' -status= -for _ in $(seq 1 100); do - status=$("$ctl" --socket "$socket" status) - if grep -q '"move_commands": 1' <<<"$status" && - grep -q '"handled": 1' <<<"$status"; then - break - fi - sleep 0.02 +listing= +for _ in $(seq 1 200); do + listing=$("$ctl" --socket "$socket" list) + grep -q '"state": "complete"' <<<"$listing" && + [[ -f "$root/single.bin.moved" ]] && break + sleep 0.05 done +grep -q '"state": "complete"' <<<"$listing" +cmp "$root/single.bin.moved" "$seed_data/single.bin" + +status=$("$ctl" --socket "$socket" status) grep -q '"move_commands": 1' <<<"$status" -grep -q '"handled": 1' <<<"$status" +grep -q '"handled": 2' <<<"$status" grep -q '"errors": 0' <<<"$status" plugin_status=$("$ctl" --socket "$socket" example.events) @@ -70,28 +99,13 @@ for _ in $(seq 1 100); do done grep -q '"event": "torrent_finished"' "$events_log" -# --- end-to-end move-as-you-finish: register a real torrent's storage, fire a -# file_complete event, and confirm the script-driven move actually relocates the -# file on disk (script thread -> bounded queue -> owner thread -> storage). ---- -fixtures=$(dirname "$script") -root="$tmp/torrent-data" -"$ctl" --socket "$socket" add_torrent \ - "{\"torrent_id\":42,\"torrent\":\"$fixtures/single_v1.torrent\",\"root\":\"$root\"}" \ - | grep -q '"ok": true' - -src=$(find "$root" -type f | head -n1) -[[ -n "$src" ]] - -"$ctl" --socket "$socket" emit \ - "{\"type\":\"file_complete\",\"torrent_id\":42,\"index\":0,\"path\":\"$src\"}" \ - >/dev/null - +"$ctl" --socket "$socket" remove 1 >/dev/null for _ in $(seq 1 100); do - [[ -f "$src.moved" ]] && break + listing=$("$ctl" --socket "$socket" list) + grep -q '"result": \[\]' <<<"$listing" && break sleep 0.02 done -[[ -f "$src.moved" ]] -[[ ! -f "$src" ]] +grep -q '"result": \[\]' <<<"$listing" "$ctl" --socket "$socket" shutdown >/dev/null wait "$daemon_pid" diff --git a/tests/integration/run_tracker_swarm.sh b/tests/integration/run_tracker_swarm.sh index 869526b..fa23e49 100644 --- a/tests/integration/run_tracker_swarm.sh +++ b/tests/integration/run_tracker_swarm.sh @@ -70,7 +70,8 @@ if [ "$MODE" = "udp" ]; then else announce="http://127.0.0.1:$tracker_port/announce" fi -python3 - "$SOURCE_TOR" "$tor" "$announce" <<'PY' +backup="http://127.0.0.1:1/lower-tier-should-not-be-contacted" +python3 - "$SOURCE_TOR" "$tor" "$announce" "$backup" <<'PY' import sys @@ -90,7 +91,8 @@ def skip(data, pos): return colon + 1 + size -source, target, announce = sys.argv[1], sys.argv[2], sys.argv[3].encode() +source, target = sys.argv[1], sys.argv[2] +announce, backup = sys.argv[3].encode(), sys.argv[4].encode() data = open(source, "rb").read() pos = 1 raw_info = None @@ -107,6 +109,8 @@ while data[pos] != ord("e"): assert raw_info is not None rewritten = ( b"d8:announce" + str(len(announce)).encode() + b":" + announce + + b"13:announce-listll" + str(len(announce)).encode() + b":" + announce + + b"el" + str(len(backup)).encode() + b":" + backup + b"ee" + b"4:info" + raw_info + b"e" ) open(target, "wb").write(rewritten) @@ -125,5 +129,10 @@ if ! grep -q "REQUEST" "$tracker_log"; then echo "FAIL: tracker received no announce" exit 1 fi +if grep -q "$backup" "$swarm_log"; then + echo "FAIL: lower tracker tier was contacted after primary success" + cat "$swarm_log" + exit 1 +fi echo "PASS: $MODE tracker discovery produced byte-identical output" diff --git a/tests/unit/test_dht.c b/tests/unit/test_dht.c deleted file mode 100644 index c8370d1..0000000 --- a/tests/unit/test_dht.c +++ /dev/null @@ -1,98 +0,0 @@ -#include "naut/bencode.h" -#include "naut/dht.h" -#include "test.h" - -#include <stdlib.h> -#include <string.h> - -static void check_get_peers_query(void) { - uint8_t tx[2] = { 0x12, 0x34 }; - uint8_t id[20], hash[20]; - for (size_t i = 0; i < 20; i++) { - id[i] = (uint8_t)i; - hash[i] = (uint8_t)(0x80 + i); - } - - uint8_t *query = NULL; - size_t query_len = 0; - CHECK(naut_dht_build_get_peers(tx, sizeof tx, id, hash, - &query, &query_len) == NAUT_OK); - - naut_bc_doc *doc = NULL; - CHECK(naut_bc_parse(query, query_len, &doc) == NAUT_OK); - const naut_bc *root = naut_bc_root(doc); - CHECK(naut_bc_str_eq(naut_bc_dict_get(root, "y"), "q")); - CHECK(naut_bc_str_eq(naut_bc_dict_get(root, "q"), "get_peers")); - - const naut_bc *args = naut_bc_dict_get(root, "a"); - const uint8_t *p = NULL; - size_t n = 0; - CHECK(naut_bc_get_str(naut_bc_dict_get(args, "id"), &p, &n)); - CHECK(n == 20 && memcmp(p, id, 20) == 0); - CHECK(naut_bc_get_str(naut_bc_dict_get(args, "info_hash"), &p, &n)); - CHECK(n == 20 && memcmp(p, hash, 20) == 0); - - naut_bc_free(doc); - free(query); -} - -static void check_response(void) { - uint8_t packet[256]; - size_t len = 0; - const char *prefix = "d1:rd2:id20:"; - memcpy(packet + len, prefix, strlen(prefix)); - len += strlen(prefix); - for (size_t i = 0; i < 20; i++) packet[len++] = (uint8_t)(0x20 + i); - - const char *nodes = "5:nodes26:"; - memcpy(packet + len, nodes, strlen(nodes)); - len += strlen(nodes); - for (size_t i = 0; i < 20; i++) packet[len++] = (uint8_t)(0x40 + i); - packet[len++] = 192; packet[len++] = 0; packet[len++] = 2; packet[len++] = 9; - packet[len++] = 0x1a; packet[len++] = 0xe1; - - const char *suffix = "5:token3:abc6:valuesl6:"; - memcpy(packet + len, suffix, strlen(suffix)); - len += strlen(suffix); - packet[len++] = 203; packet[len++] = 0; packet[len++] = 113; packet[len++] = 7; - packet[len++] = 0xc8; packet[len++] = 0xd5; - const char *tail = "ee1:t2:aa1:y1:re"; - memcpy(packet + len, tail, strlen(tail)); - len += strlen(tail); - - naut_dht_response response; - CHECK(naut_dht_parse_response(packet, len, &response) == NAUT_OK); - CHECK(response.type == NAUT_DHT_RESPONSE); - CHECK(response.transaction_len == 2 && - memcmp(response.transaction, "aa", 2) == 0); - CHECK(response.has_id && response.id[0] == 0x20); - CHECK(response.token_len == 3 && - memcmp(response.token, "abc", 3) == 0); - CHECK(response.num_nodes == 1); - CHECK(response.nodes[0].ip[0] == 192 && - response.nodes[0].port == 6881); - CHECK(response.num_peers == 1); - CHECK(response.peers[0].ip[0] == 203 && - response.peers[0].port == 51413); - naut_dht_response_free(&response); -} - -int main(void) { - check_get_peers_query(); - check_response(); - - const char error[] = "d1:eli203e12:Server errore1:t2:zz1:y1:ee"; - naut_dht_response response; - CHECK(naut_dht_parse_response((const uint8_t *)error, sizeof error - 1, - &response) == NAUT_OK); - CHECK(response.type == NAUT_DHT_ERROR && response.error_code == 203); - naut_dht_response_free(&response); - - const char malformed[] = - "d1:rd2:id20:abcdefghijklmnopqrst5:nodes1:xe1:t1:a1:y1:re"; - CHECK(naut_dht_parse_response((const uint8_t *)malformed, - sizeof malformed - 1, - &response) == NAUT_ERR_PROTO); - - TEST_MAIN_END(); -} diff --git a/tests/unit/test_download.c b/tests/unit/test_download.c index 51ab06e..1868a30 100644 --- a/tests/unit/test_download.c +++ b/tests/unit/test_download.c @@ -69,6 +69,20 @@ int main(void) { CHECK(got && glen == olen && memcmp(got, orig, olen) == 0); free(got); + /* Existing verified data should be reflected in progress before any peer + * requests are made. */ + st = naut_storage_open(mi.files, mi.num_files, root, &err); + CHECK(st && err == NAUT_OK); + d = naut_download_create(&mi, st); + CHECK(d != NULL); + CHECK(naut_download_resume(d) == NAUT_OK); + CHECK(naut_download_complete(d)); + CHECK_EQ(naut_download_pieces_done(d), naut_download_num_pieces(d)); + CHECK_EQ((long long)naut_download_bytes_done(d), (long long)olen); + CHECK(!naut_download_next_request(d, &idx, &begin, &len)); + naut_download_destroy(d); + naut_storage_close(st); + /* The same path with SHA-1 verification offloaded to bounded workers. */ { char t2[] = "/tmp/naut_async_XXXXXX"; diff --git a/tests/unit/test_filemove.c b/tests/unit/test_filemove.c index 8b2b79b..7d3560f 100644 --- a/tests/unit/test_filemove.c +++ b/tests/unit/test_filemove.c @@ -108,6 +108,22 @@ int main(void) { memcmp(got1, global + mi.files[0].length, l1) == 0); free(got1); + /* Simulate a daemon restart: file 0 now lives at `dest`, not under `root`. + * Reopening with its saved location as an override must pick it up in place + * so resume verifies every piece -- if the override were ignored, file 0 + * would open as an empty placeholder under root and resume would fail. */ + const char *overrides[2] = { dest, NULL }; + naut_storage_opts ropts = { .preallocate = true, .overrides = overrides }; + naut_storage *st2 = + naut_storage_open_opts(mi.files, mi.num_files, root, &ropts, &err); + CHECK(st2 && err == NAUT_OK); + naut_download *d2 = naut_download_create(&mi, st2); + CHECK(d2 != NULL); + CHECK(naut_download_resume(d2) == NAUT_OK); + CHECK(naut_download_complete(d2)); /* both files verified from their homes */ + naut_download_destroy(d2); + naut_storage_close(st2); + naut_download_destroy(d); free(global); free(tor); naut_metainfo_free(&mi); char cmd[600]; snprintf(cmd, sizeof cmd, "rm -rf '%s' '%s'", root, destdir); diff --git a/tests/unit/test_metainfo.c b/tests/unit/test_metainfo.c index 2a8f9cc..55eed46 100644 --- a/tests/unit/test_metainfo.c +++ b/tests/unit/test_metainfo.c @@ -47,6 +47,9 @@ int main(void) { CHECK_EQ(mi.total_length, 200000); CHECK_EQ(mi.num_files, 1); CHECK_EQ(mi.num_trackers, 2); + CHECK(mi.tracker_tiers != NULL); + CHECK_EQ(mi.tracker_tiers[0], 0); + CHECK_EQ(mi.tracker_tiers[1], 1); char hex[41]; naut_infohash_v1_hex(&mi, hex); CHECK(strcmp(hex, "7f2555dfd18ba1c4a024264e939d7a5f8b25311b") == 0); naut_metainfo_free(&mi); @@ -134,6 +137,7 @@ int main(void) { "7f2555dfd18ba1c4a024264e939d7a5f8b25311b", 20)); CHECK(mi.num_trackers == 1 && strcmp(mi.trackers[0], trackers[0]) == 0); + CHECK(mi.tracker_tiers && mi.tracker_tiers[0] == 0); naut_metainfo_free(&mi); naut_bc_free(doc); free(torrent); diff --git a/tests/unit/test_mse.c b/tests/unit/test_mse.c deleted file mode 100644 index abb6849..0000000 --- a/tests/unit/test_mse.c +++ /dev/null @@ -1,56 +0,0 @@ -/* Drives the MSE handshake state machine without a socket. Full-handshake - * correctness is proven against libtorrent in interop_mse; this guards the - * sans-IO plumbing (state transitions, fragmented pull, DH validation) so it - * stays covered even where libtorrent is unavailable. */ -#include "naut/mse.h" -#include "test.h" -#include <string.h> - -int main(void) { - uint8_t info_hash[20], peer_id[NAUT_PEERID_LEN]; - memset(info_hash, 0xAB, sizeof info_hash); - memset(peer_id, 0xCD, sizeof peer_id); - - /* begin → must want to write its 96-byte public key first. */ - naut_mse_handshake *h = naut_mse_handshake_begin(info_hash, peer_id, 0); - CHECK(h != NULL); - CHECK_EQ(naut_mse_handshake_status(h), NAUT_MSE_HS_NEED_WRITE); - - /* Drain the public key one byte at a time; it must be exactly 96 bytes, - * after which the machine flips to waiting for the peer's key. */ - uint8_t pub[128]; - size_t total = 0, n; - while ((n = naut_mse_handshake_pull(h, pub + total, 1)) > 0) total += n; - CHECK_EQ((int)total, NAUT_MSE_DH_LEN); - CHECK_EQ(naut_mse_handshake_status(h), NAUT_MSE_HS_NEED_READ); - /* A real DH public key is never all-zero. */ - uint8_t zero[NAUT_MSE_DH_LEN] = {0}; - CHECK(memcmp(pub, zero, NAUT_MSE_DH_LEN) != 0); - - /* finish() before completion must refuse rather than hand out junk. */ - naut_mse_stream stream; - uint8_t remote_hs[NAUT_HANDSHAKE_LEN]; - CHECK_EQ(naut_mse_handshake_finish(h, &stream, remote_hs), NAUT_ERR_AGAIN); - - /* Feed an invalid (zero) peer public key fragmented across calls; the DH - * validation must reject it (0 < 2) and latch the error state. */ - size_t consumed_total = 0; - naut_mse_hs_status st = NAUT_MSE_HS_NEED_READ; - for (int i = 0; i < NAUT_MSE_DH_LEN; i++) { - size_t consumed = 0; - uint8_t b = 0; - st = naut_mse_handshake_feed(h, &b, 1, &consumed); - consumed_total += consumed; - if (st == NAUT_MSE_HS_ERROR) break; - } - CHECK_EQ(st, NAUT_MSE_HS_ERROR); - CHECK(consumed_total <= NAUT_MSE_DH_LEN); - CHECK_EQ(naut_mse_handshake_finish(h, &stream, remote_hs), NAUT_ERR_PROTO); - naut_mse_handshake_free(h); - - /* Bad arguments are rejected, not crashed on. */ - CHECK(naut_mse_handshake_begin(NULL, peer_id, 0) == NULL); - CHECK(naut_mse_handshake_begin(info_hash, NULL, 0) == NULL); - - TEST_MAIN_END(); -} diff --git a/tests/unit/test_picker.c b/tests/unit/test_picker.c index c2e5cce..ae5401f 100644 --- a/tests/unit/test_picker.c +++ b/tests/unit/test_picker.c @@ -90,6 +90,36 @@ int main(void) { naut_download_destroy(d); + /* The picker should not open the entire torrent at once. A large swarm can + * keep many requests in flight, but new-piece fanout is bounded so the + * piece map does not show most pieces "downloading" while few verify. */ + enum { CAP_NP = 80 }; + uint64_t cap_total = (uint64_t)CAP_NP * NAUT_BLOCK; + uint8_t *cap_hashes = malloc(CAP_NP * NAUT_SHA1_LEN); + CHECK(cap_hashes != NULL); + for (int p = 0; p < CAP_NP; p++) + naut_sha1(data, NAUT_BLOCK, cap_hashes + p * NAUT_SHA1_LEN); + naut_file cap_file[1] = { { (char *)"cap.bin", (int64_t)cap_total } }; + naut_metainfo cap_mi; memset(&cap_mi, 0, sizeof cap_mi); + cap_mi.has_v1 = true; cap_mi.num_pieces = CAP_NP; + cap_mi.piece_length = NAUT_BLOCK; cap_mi.total_length = cap_total; + cap_mi.piece_hashes = cap_hashes; cap_mi.files = cap_file; + cap_mi.num_files = 1; cap_mi.name = (char *)"cap"; + d = naut_download_create(&cap_mi, st); + CHECK(d != NULL); + naut_bitfield all; + naut_bitfield_init(&all, CAP_NP); + for (int p = 0; p < CAP_NP; p++) naut_bitfield_set(&all, p); + naut_download_add_bitfield(d, &all); + int opened = 0; + while (naut_download_pick(d, &all, &idx, &begin, &len)) + opened++; + CHECK(opened > 0); + CHECK(opened < CAP_NP); + naut_bitfield_free(&all); + naut_download_destroy(d); + free(cap_hashes); + /* full multi-peer download: alternate peers, all pieces verify */ d = naut_download_create(&mi, st); naut_download_add_bitfield(d, &hb); /* one peer that has everything */ diff --git a/tests/unit/test_pipeline.c b/tests/unit/test_pipeline.c deleted file mode 100644 index 220fcb8..0000000 --- a/tests/unit/test_pipeline.c +++ /dev/null @@ -1,30 +0,0 @@ -#include "naut/pipeline.h" -#include "test.h" - -int main(void) { - naut_pipeline pipeline; - naut_pipeline_init(&pipeline, NAUT_BLOCK, 4, 1024, 32); - CHECK_EQ(naut_pipeline_depth(&pipeline), 32); - - /* 16 KiB every 100 us with 20 ms RTT is about 164 MB/s and a 200-block - * BDP. Repeated samples should grow the window substantially. */ - double now = 1.0; - for (int i = 0; i < 100; i++) { - now += 0.0001; - naut_pipeline_on_block(&pipeline, NAUT_BLOCK, now - 0.020, now); - } - CHECK(naut_pipeline_depth(&pipeline) > 128); - CHECK(naut_pipeline_depth(&pipeline) <= 1024); - - uint32_t high = naut_pipeline_depth(&pipeline); - for (int i = 0; i < 100; i++) { - now += 0.050; - naut_pipeline_on_block(&pipeline, NAUT_BLOCK, now - 0.005, now); - } - CHECK(naut_pipeline_depth(&pipeline) < high); - CHECK(naut_pipeline_depth(&pipeline) >= 4); - - naut_pipeline_init(&pipeline, 0, 0, 0, 0); - CHECK_EQ(naut_pipeline_depth(&pipeline), 1); - TEST_MAIN_END(); -} diff --git a/tests/unit/test_script.c b/tests/unit/test_script.c index ce55b01..a131248 100644 --- a/tests/unit/test_script.c +++ b/tests/unit/test_script.c @@ -3,6 +3,7 @@ #include <pthread.h> #include <stdatomic.h> +#include <stdlib.h> #include <string.h> #include <unistd.h> @@ -30,13 +31,29 @@ static naut_err capture_move(void *opaque, uint64_t torrent_id, return NAUT_OK; } +/* Hand the script a single label "anime" so the fixture can route on it. */ +static char **capture_labels(void *opaque, uint64_t torrent_id, size_t *count) { + (void)opaque; + (void)torrent_id; + char **labels = malloc(sizeof *labels); + if (!labels) { *count = 0; return NULL; } + labels[0] = strdup("anime"); + *count = labels[0] ? 1 : 0; + return labels; +} + int main(void) { pthread_t owner = pthread_self(); move_capture capture = {0}; naut_event_bus *events = naut_event_bus_create(); naut_err error; + naut_script_host host = { + .move_file = capture_move, + .labels = capture_labels, + .context = &capture, + }; naut_script *script = naut_script_create( - events, NAUT_PHASE7_SCRIPT, 8, capture_move, &capture, &error); + events, NAUT_PHASE7_SCRIPT, 8, &host, &error); CHECK(script && error == NAUT_OK); naut_event event = { @@ -44,13 +61,13 @@ int main(void) { .torrent_id = 42, }; naut_event_emit(events, &event); - for (unsigned i = 0; i < 100 && atomic_load(&capture.calls) == 0; i++) + for (unsigned i = 0; i < 100; i++) { + naut_script_stats pending; + naut_script_get_stats(script, &pending); + if (pending.handled == 1) break; usleep(1000); - CHECK_EQ(atomic_load(&capture.calls), 1); - CHECK(!pthread_equal(owner, capture.caller)); - CHECK_EQ(capture.torrent_id, 42); - CHECK_EQ(capture.file_index, 0); - CHECK(strcmp(capture.destination, "/tmp/naut-phase7-finished") == 0); + } + CHECK_EQ(atomic_load(&capture.calls), 0); event = (naut_event) { .type = NAUT_EVENT_FILE_COMPLETE, @@ -59,18 +76,21 @@ int main(void) { .path = "/tmp/completed-file", }; naut_event_emit(events, &event); - for (unsigned i = 0; i < 100 && atomic_load(&capture.calls) < 2; i++) + for (unsigned i = 0; i < 100 && atomic_load(&capture.calls) < 1; i++) usleep(1000); - CHECK_EQ(atomic_load(&capture.calls), 2); + CHECK_EQ(atomic_load(&capture.calls), 1); + CHECK(!pthread_equal(owner, capture.caller)); + CHECK_EQ(capture.torrent_id, 42); CHECK_EQ(capture.file_index, 3); - CHECK(strcmp(capture.destination, "/tmp/completed-file.moved") == 0); + /* Proves naut.get_labels surfaced the label string into Lua. */ + CHECK(strcmp(capture.destination, "/tmp/completed-file.anime") == 0); naut_script_stats stats; naut_script_get_stats(script, &stats); CHECK_EQ(stats.queued, 2); CHECK_EQ(stats.handled, 2); CHECK_EQ(stats.errors, 0); - CHECK_EQ(stats.move_requests, 2); + CHECK_EQ(stats.move_requests, 1); naut_script_destroy(script); naut_event_bus_destroy(events); diff --git a/tests/unit/test_tracker.c b/tests/unit/test_tracker.c deleted file mode 100644 index 0d6cf2c..0000000 --- a/tests/unit/test_tracker.c +++ /dev/null @@ -1,115 +0,0 @@ -#include "naut/tracker.h" -#include "naut/bencode.h" -#include "test.h" -#include <string.h> - -int main(void) { - naut_announce_req req; - memset(&req, 0, sizeof req); - for (int i = 0; i < 20; i++) { req.info_hash[i] = (uint8_t)i; req.peer_id[i] = (uint8_t)(0x80 + i); } - req.port = 6881; req.left = 1000; req.numwant = -1; req.key = 0xdeadbeef; - req.event = NAUT_TEV_STARTED; - - /* --- HTTP announce URL --- */ - char url[1024]; - size_t n = naut_tracker_http_url("http://t.example/announce", &req, url, sizeof url); - CHECK(n > 0); - CHECK(strstr(url, "info_hash=%00%01%02") != NULL); /* binary pct-encoded */ - CHECK(strstr(url, "port=6881") != NULL); - CHECK(strstr(url, "compact=1") != NULL); - CHECK(strstr(url, "event=started") != NULL); - /* base already having a query uses '&' */ - naut_tracker_http_url("http://t.example/announce?x=1", &req, url, sizeof url); - CHECK(strstr(url, "announce?x=1&info_hash=") != NULL); - req.event = (naut_tracker_event)99; - CHECK(naut_tracker_http_url("http://t.example/announce", &req, - url, sizeof url) == 0); - req.event = NAUT_TEV_STARTED; - - /* --- HTTP response parse: compact peers --- */ - { - /* d8:intervali1800e5:peers12:<two 6-byte peers>e */ - uint8_t body[128]; size_t b = 0; - const char *pre = "d8:intervali1800e8:completei5e10:incompletei2e5:peers12:"; - memcpy(body, pre, strlen(pre)); b = strlen(pre); - uint8_t peers[12] = { 1,2,3,4, 0x1a,0xe1, 10,0,0,1, 0x1a,0xe2 }; - memcpy(body + b, peers, 12); b += 12; - body[b++] = 'e'; - - naut_tracker_response r; - CHECK(naut_tracker_parse_http(body, b, &r) == NAUT_OK); - CHECK_EQ(r.interval, 1800); - CHECK_EQ(r.seeders, 5); - CHECK_EQ(r.leechers, 2); - CHECK_EQ(r.num_peers, 2); - CHECK(r.peers[0].ip[0]==1 && r.peers[0].ip[3]==4 && r.peers[0].port==0x1ae1); - CHECK(r.peers[1].ip[0]==10 && r.peers[1].port==0x1ae2); - naut_tracker_response_free(&r); - } - - /* --- failure reason --- */ - { - const char *body = "d14:failure reason17:torrent not founde"; - naut_tracker_response r; - CHECK(naut_tracker_parse_http((const uint8_t *)body, strlen(body), &r) == NAUT_ERR_PROTO); - CHECK(r.failure && strcmp(r.failure, "torrent not found") == 0); - naut_tracker_response_free(&r); - } - - /* --- UDP connect codec --- */ - { - uint8_t pkt[98]; - naut_udp_build_connect(pkt, 0x11223344); - /* protocol id 0x41727101980, action 0, txid */ - CHECK(pkt[0]==0 && pkt[1]==0 && pkt[2]==0x04 && pkt[3]==0x17 && - pkt[4]==0x27 && pkt[5]==0x10 && pkt[6]==0x19 && pkt[7]==0x80); - CHECK(pkt[8]==0 && pkt[11]==0); /* action connect */ - CHECK(pkt[12]==0x11 && pkt[15]==0x44); /* txid */ - - /* build a fake connect response and parse it */ - uint8_t resp[16] = {0}; - resp[3] = 0; /* action connect */ - resp[4]=0x11; resp[5]=0x22; resp[6]=0x33; resp[7]=0x44; /* txid */ - for (int i = 0; i < 8; i++) resp[8+i] = (uint8_t)(0xA0 + i); /* conn id */ - uint64_t cid = 0; - CHECK(naut_udp_parse_connect(resp, 16, 0x11223344, &cid) == NAUT_OK); - CHECK(cid == 0xA0A1A2A3A4A5A6A7ULL); - CHECK(naut_udp_parse_connect(resp, 16, 0x99999999, &cid) == NAUT_ERR_PROTO); /* wrong txid */ - } - - /* --- UDP announce codec round-trip --- */ - { - uint8_t pkt[98]; - naut_udp_build_announce(pkt, 0xA0A1A2A3A4A5A6A7ULL, 0x55667788, &req); - CHECK(pkt[11] == 1); /* action announce */ - CHECK(memcmp(pkt + 16, req.info_hash, 20) == 0); - CHECK(memcmp(pkt + 36, req.peer_id, 20) == 0); - CHECK(pkt[83] == NAUT_TEV_STARTED); /* event low byte */ - CHECK((pkt[96]<<8 | pkt[97]) == 6881); /* port */ - - /* fake announce response: action=1, txid, interval, leech, seed, 1 peer */ - uint8_t resp[26] = {0}; - resp[3] = 1; - resp[4]=0x55; resp[5]=0x66; resp[6]=0x77; resp[7]=0x88; - resp[11] = 0x84; /* interval 0x84 = 132 */ - resp[15] = 3; /* leechers */ - resp[19] = 7; /* seeders */ - resp[20]=192; resp[21]=168; resp[22]=0; resp[23]=5; resp[24]=0x1a; resp[25]=0xe1; - naut_tracker_response r; - CHECK(naut_udp_parse_announce(resp, 26, 0x55667788, &r) == NAUT_OK); - CHECK_EQ(r.interval, 132); - CHECK_EQ(r.leechers, 3); - CHECK_EQ(r.seeders, 7); - CHECK_EQ(r.num_peers, 1); - CHECK(r.peers[0].ip[0]==192 && r.peers[0].ip[3]==5 && r.peers[0].port==0x1ae1); - naut_tracker_response_free(&r); - - uint8_t malformed[27]; - memcpy(malformed, resp, sizeof resp); - malformed[26] = 0; - CHECK(naut_udp_parse_announce(malformed, sizeof malformed, - 0x55667788, &r) == NAUT_ERR_PROTO); - } - - TEST_MAIN_END(); -}