From 8ac8f941c4d4e3f20a98ddd8fcad446c9bdc4d92 Mon Sep 17 00:00:00 2001 From: Marcin Czenko Date: Mon, 19 May 2025 20:21:03 +0200 Subject: [PATCH] convenience feature - download content using magnet links --- codex/bittorrent/magnetlink.nim | 108 +++++++++++++++++++++ codex/rest/api.nim | 38 ++++++++ tests/codex/bittorrent/testmagnetlink.nim | 113 ++++++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 codex/bittorrent/magnetlink.nim create mode 100644 tests/codex/bittorrent/testmagnetlink.nim diff --git a/codex/bittorrent/magnetlink.nim b/codex/bittorrent/magnetlink.nim new file mode 100644 index 00000000..a5669f33 --- /dev/null +++ b/codex/bittorrent/magnetlink.nim @@ -0,0 +1,108 @@ +import std/strutils +import std/sequtils + +import pkg/stew/byteutils +import pkg/libp2p/[multicodec, multihash] +import pkg/questionable +import pkg/questionable/results + +import ../errors +import ../codextypes +import ./manifest/manifest + +type + TorrentVersion* = enum + v1 + v2 + hybrid + + MagnetLink* = ref object + version: TorrentVersion + infoHashV1: ?MultiHash + infoHashV2: ?MultiHash + +proc version*(self: MagnetLink): TorrentVersion = + ## Get the version of the magnet link + ## + ## returns: the version of the magnet link + result = self.version + +proc infoHashV1*(self: MagnetLink): ?MultiHash = + ## Get the info hash of the magnet link + ## + ## returns: the info hash of the magnet link + result = self.infoHashV1 + +proc infoHashV2*(self: MagnetLink): ?MultiHash = + ## Get the info hash of the magnet link + ## + ## returns: the info hash of the magnet link + result = self.infoHashV2 + +proc parseMagnetLink(link: string): ?!MagnetLink = + let prefix = "magnet:?" + if not link.startsWith(prefix): + return failure("Invalid magnet link format (missing 'magnet:?' prefix)") + let infoHashParts = link[prefix.len .. ^1].split("&").filterIt(it.startsWith("xt=")) + if infoHashParts.len < 1: + return + failure("Invalid magnet link format (at least one info hash part is required)") + let v1Prefix = "xt=urn:btih:" + let v2Prefix = "xt=urn:btmh:" + var infoHashV1 = none(MultiHash) + var infoHashV2 = none(MultiHash) + for infoHashPart in infoHashParts: + # var a = infoHashPart[v1Prefix.len .. ^1] + if infoHashPart.startsWith(v1Prefix): + without infoHash =? BitTorrentInfo.buildMultiHash( + infoHashPart[v1Prefix.len .. ^1] + ), err: + return failure("Error parsing info hash: " & err.msg) + infoHashV1 = some(infoHash) + elif infoHashPart.startsWith(v2Prefix): + without infoHash =? BitTorrentInfo.buildMultiHash( + infoHashPart[v2Prefix.len .. ^1] + ), err: + return failure("Error parsing info hash: " & err.msg) + infoHashV2 = some(infoHash) + + if infoHashV1.isNone and infoHashV2.isNone: + return failure("Invalid magnet link format (missing info hash part)") + + var version: TorrentVersion + if infoHashV1.isSome and infoHashV2.isSome: + version = TorrentVersion.hybrid + elif infoHashV1.isSome: + version = TorrentVersion.v1 + else: + version = TorrentVersion.v2 + + let magnetLink = + MagnetLink(version: version, infoHashV1: infoHashV1, infoHashV2: infoHashV2) + return success(magnetLink) + +proc getHashHex(multiHash: MultiHash): string = + ## Get the info hash of the magnet link as a hex string + result = byteutils.toHex(multiHash.data.buffer[multiHash.dpos .. ^1]).toUpperAscii() + +proc `$`*(self: MagnetLink): string = + ## Convert the magnet link to a string + ## + ## returns: the magnet link as a string + if self.version == TorrentVersion.hybrid: + result = + "magnet:?xt=urn:btih:" & (!self.infoHashV1).getHashHex() & "&xt=urn:btmh:" & + (!self.infoHashV2).hex + elif self.version == v1: + result = "magnet:?xt=urn:btih:" & (!self.infoHashV1).getHashHex() + else: + result = "magnet:?xt=urn:btmh:" & (!self.infoHashV2).hex + +proc newMagnetLink*(magnetLinkString: string): ?!MagnetLink = + ## Create a new magnet link + ## + ## version: the version of the magnet link + ## magnetLinkString: text containing the magnet link + ## + ## returns: a Result containing a magnet link object or a failure + parseMagnetLink(magnetLinkString) diff --git a/codex/rest/api.nim b/codex/rest/api.nim index 8de2964b..d41781f3 100644 --- a/codex/rest/api.nim +++ b/codex/rest/api.nim @@ -42,6 +42,7 @@ import ../utils/safeasynciter import ../utils/options import ../bittorrent/manifest import ../bittorrent/torrentdownloader +import ../bittorrent/magnetlink import ../tarballs/[directorymanifest, directorydownloader, tarballnodeextensions] @@ -644,6 +645,43 @@ proc initDataApi(node: CodexNodeRef, repoStore: RepoStore, router: var RestRoute resp.setHeader("Access-Control-Expose-Headers", "Content-Disposition") await node.retrieveDirectory(cid.get(), resp = resp) + router.api(MethodOptions, "/api/codex/v1/torrent/magnet") do( + resp: HttpResponseRef + ) -> RestApiResponse: + if corsOrigin =? allowedOrigin: + resp.setCorsHeaders("POST", corsOrigin) + resp.setHeader( + "Access-Control-Allow-Headers", "content-type, content-disposition" + ) + + resp.status = Http204 + await resp.sendBody("") + + router.api(MethodPost, "/api/codex/v1/torrent/magnet") do( + contentBody: Option[ContentBody], resp: HttpResponseRef + ) -> RestApiResponse: + let mimeType = request.headers.getString(ContentTypeHeader) + echo "mimeType: ", mimeType + if mimeType != "text/plain" and mimeType != "application/octet-stream": + return RestApiResponse.error( + Http422, + "Missing \"Content-Type\" header: expected either \"text/plain\" or \"application/octet-stream\".", + ) + without magnetLinkBytes =? contentBody .? data: + return RestApiResponse.error(Http422, "No magnet link provided.") + echo "magnetLinkBytes: ", bytesToString(magnetLinkBytes).strip + without magnetLink =? newMagnetLink(bytesToString(magnetLinkBytes).strip), err: + return RestApiResponse.error(Http422, err.msg) + if magnetLink.version != TorrentVersion.v1: + return RestApiResponse.error( + Http422, "Only torrents version 1 are currently supported!" + ) + without infoHash =? magnetLink.infoHashV1: + return RestApiResponse.error( + Http422, "The magnet link does not contain a valid info hash." + ) + await node.retrieveInfoHash(infoHash, resp = resp) + router.api(MethodGet, "/api/codex/v1/torrent/{infoHash}/network/stream") do( infoHash: MultiHash, resp: HttpResponseRef ) -> RestApiResponse: diff --git a/tests/codex/bittorrent/testmagnetlink.nim b/tests/codex/bittorrent/testmagnetlink.nim new file mode 100644 index 00000000..decd6716 --- /dev/null +++ b/tests/codex/bittorrent/testmagnetlink.nim @@ -0,0 +1,113 @@ +import std/strformat + +import pkg/unittest2 + +import pkg/libp2p/[multicodec, multihash] +import pkg/questionable/results +import pkg/stew/byteutils + +import ../examples + +import pkg/codex/bittorrent/magnetlink + +suite "bittorrent magnet links": + test "tt": + let magnetLinkStr = "magnet:?xt=urn:btih:1902d602db8c350f4f6d809ed01eff32f030da95" + let magnetLink = newMagnetLink(magnetLinkStr).tryGet() + check $magnetLink == magnetLinkStr + test "correctly parses magnet link version 1": + let multiHash = MultiHash.example(Sha1HashCodec) + let hash = multiHash.data.buffer[multiHash.dpos .. ^1] + # echo byteutils.toHex(hash) + # echo multiHash.hex + let magnetLinkStr = + fmt"magnet:?xt=urn:btih:{byteutils.toHex(hash).toUpperAscii}&dn=example.txt&tr=udp://tracker.example.com/announce&x.pe=31.205.250.200:8080" + let magnetLink = newMagnetLink(magnetLinkStr).tryGet() + check $magnetLink == magnetLinkStr.split("&")[0] + + test "correctly parses magnet link version 2": + let multiHash = MultiHash.example() + let magnetLinkStr = + fmt"magnet:?xt=urn:btmh:{multihash.hex}&dn=example.txt&tr=udp://tracker.example.com/announce&x.pe=31.205.250.200:8080" + let magnetLink = newMagnetLink(magnetLinkStr).tryGet() + check $magnetLink == magnetLinkStr.split("&")[0] + + test "correctly parses hybrid magnet links": + let multiHashV1 = MultiHash.example(Sha1HashCodec) + let hash = multiHashV1.data.buffer[multiHashV1.dpos .. ^1] + let multiHash = MultiHash.example() + let magnetLinkStr = + fmt"magnet:?xt=urn:btih:{byteutils.toHex(hash).toUpperAscii}&xt=urn:btmh:{multihash.hex}&dn=example.txt&tr=udp://tracker.example.com/announce&x.pe=31.205.250.200:8080" + let magnetLink = newMagnetLink(magnetLinkStr).tryGet() + check $magnetLink == magnetLinkStr.split("&")[0 .. 1].join("&") + + test "accepts hybrid magnet links with one info hash part incorrect (v1 part correct)": + let multiHashV1 = MultiHash.example(Sha1HashCodec) + let hash = multiHashV1.data.buffer[multiHashV1.dpos .. ^1] + let magnetLinkStr = + fmt"magnet:?xt=urn:btih:{byteutils.toHex(hash).toUpperAscii}&xt=urn:btmh&dn=example.txt&tr=udp://tracker.example.com/announce&x.pe=31.205.250.200:8080" + let magnetLink = newMagnetLink(magnetLinkStr).tryGet() + check $magnetLink == magnetLinkStr.split("&")[0] + + test "accepts hybrid magnet links with one info hash part incorrect (v2 part correct)": + let multiHash = MultiHash.example() + let magnetLinkStr = + fmt"magnet:?xt=urn:btih&xt=urn:btmh:{multihash.hex}&dn=example.txt&tr=udp://tracker.example.com/announce&x.pe=31.205.250.200:8080" + let magnetLink = newMagnetLink(magnetLinkStr).tryGet() + check $magnetLink == "magnet:?" & magnetLinkStr.split("&")[1] + + test "fails for magnet links without 'magnet' prefix": + let magnetLinkStr = "invalid_magnet_link" + let magnetLink = newMagnetLink(magnetLinkStr) + check magnetLink.isFailure + check magnetLink.error.msg == + "Invalid magnet link format (missing 'magnet:?' prefix)" + + test "fails for magnet links without 'infoHash' part": + let magnetLinkStr = + "magnet:?dn=example.txt&tr=udp://tracker.example.com/announce&x.pe=31.205.250.200:8080" + let magnetLink = newMagnetLink(magnetLinkStr) + check magnetLink.isFailure + check magnetLink.error.msg == + "Invalid magnet link format (at least one info hash part is required)" + + for (magnetLinkStr, errorMsg) in [ + ( + "magnet:?xt=urn:btih:", + "Error parsing info hash: given bytes is not a correct multihash", + ), + ( + "magnet:?xt=urn:btmh:", + "Error parsing info hash: given bytes is not a correct multihash", + ), + ( + "magnet:?xt=urn:btih:1234567890&xt=urn:btmh:", + "Error parsing info hash: given bytes is not a correct multihash", + ), + ( + "magnet:?xt=urn:btih:1234567890&xt=urn:btmh:1234567890", + "Error parsing info hash: given bytes is not a correct multihash", + ), + ( + "magnet:?xt=urn:btmh:1234567890&xt=urn:btih:", + "Error parsing info hash: given bytes is not a correct multihash", + ), + ( + "magnet:?xt=urn:btmh:1234567890&xt=urn:btih:1234567890", + "Error parsing info hash: given bytes is not a correct multihash", + ), + ( + "magnet:?xt=urn:btmh:&xt=urn:btih:1234567890", + "Error parsing info hash: given bytes is not a correct multihash", + ), + ( + "magnet:?xt=urn:btih:&xt=urn:btmh:1234567890", + "Error parsing info hash: given bytes is not a correct multihash", + ), + ("magnet:?xt=urn:btih", "Invalid magnet link format (missing info hash part)"), + ("magnet:?xt=urn:btmh", "Invalid magnet link format (missing info hash part)"), + ]: + test fmt"fails for magnet links with invalid hashes: {magnetLinkStr}": + let magnetLink = newMagnetLink(magnetLinkStr) + check magnetLink.isFailure + check magnetLink.error.msg == errorMsg