convenience feature - download content using magnet links

This commit is contained in:
Marcin Czenko 2025-05-19 20:21:03 +02:00
parent 108370ebc9
commit 8ac8f941c4
No known key found for this signature in database
GPG Key ID: 33DEA0C8E30937C0
3 changed files with 259 additions and 0 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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