adds torrent parser to support native torrent files when downloading

This commit is contained in:
Marcin Czenko 2025-05-28 16:21:23 +02:00
parent 03ff25bbb1
commit 0f62624613
No known key found for this signature in database
GPG Key ID: 33DEA0C8E30937C0
3 changed files with 141 additions and 0 deletions

View File

@ -0,0 +1,41 @@
import std/strutils
import std/re
import pkg/questionable/results
import pkg/stew/byteutils
import pkg/stew/base10
import ../errors
proc extractInfoFromTorrent*(torrentBytes: seq[byte]): ?!seq[byte] =
## Extract the info from a torrent file
##
## params:
## torrentBytes: the torrent file bytes
##
## returns: the bytes containing only the content of the info dictionary
## or a failure if info is not found or invalid
let torrentStr = string.fromBytes(torrentBytes)
if torrentStr.contains("file tree") or torrentStr.contains("piece layers"):
return failure("Torrent v2 provided. Only v1 is currently supported.")
let infoKeyPos = torrentStr.find("info")
if infoKeyPos == -1:
return failure("Torrent file does not contain info dictionary.")
let infoStartPos = infoKeyPos + "info".len
if torrentStr[infoStartPos] != 'd':
return failure("Torrent file does not contain valid info dictionary.")
var matches = newSeq[tuple[first, last: int]](1)
let (_, piecesEndIndex) = torrentStr.findBounds(re"pieces(\d+):", matches)
if matches.len == 1:
let (first, last) = matches[0]
let piecesLenStr = torrentStr[first .. last]
without piecesLen =? Base10.decode(uint, piecesLenStr).mapFailure, err:
return failure("Error decoding pieces length: " & err.msg)
let piecesEndMarkerPos = piecesEndIndex + 1 + piecesLen.int
if torrentStr[piecesEndMarkerPos] != 'e':
return failure("Torrent file does not contain valid pieces.")
let infoDirStr = torrentStr[infoStartPos .. piecesEndMarkerPos]
infoDirStr.toBytes().success
else:
return failure("Torrent file does not contain valid pieces.")

View File

@ -43,6 +43,7 @@ import ../utils/options
import ../bittorrent/manifest
import ../bittorrent/torrentdownloader
import ../bittorrent/magnetlink
import ../bittorrent/torrentparser
import ../tarballs/[directorymanifest, directorydownloader, tarballnodeextensions]
@ -681,6 +682,57 @@ proc initDataApi(node: CodexNodeRef, repoStore: RepoStore, router: var RestRoute
Http422, "The magnet link does not contain a valid info hash."
)
await node.retrieveInfoHash(infoHash, resp = resp)
# return
# RestApiResponse.response($magnetLink, Http200, "text/plain")
router.api(MethodOptions, "/api/codex/v1/torrent/torrent-file") 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/torrent-file") do(
contentBody: Option[ContentBody], resp: HttpResponseRef
) -> RestApiResponse:
let mimeType = request.headers.getString(ContentTypeHeader)
echo "mimeType: ", mimeType
if mimeType != "application/json" and mimeType != "application/octet-stream":
return RestApiResponse.error(
Http422,
"Missing \"Content-Type\" header: expected either \"application/json\" or \"application/octet-stream\".",
)
without torrentBytes =? contentBody .? data:
return RestApiResponse.error(Http422, "No torrent file content provided.")
var infoBytes: seq[byte]
if mimeType == "application/json":
without torrentManifest =? BitTorrentManifest.fromJson(torrentBytes):
return RestApiResponse.error(Http422, "Invalid torrent JSON file content.")
echo "torrentManifest: ", torrentManifest
let torrentInfo = torrentManifest.info
# very basic validation for now
if torrentInfo.length == 0 or torrentInfo.pieceLength == 0 or
torrentInfo.pieces.len == 0:
return
RestApiResponse.error(Http422, "The torrent file is invalid or incomplete.")
# return RestApiResponse.response($torrentInfo, contentType = "text/plain")
infoBytes = bencode(torrentInfo)
else:
without infoBencoded =? extractInfoFromTorrent(torrentBytes), err:
return RestApiResponse.error(
Http422, "Failed extracting info directory from the torrent file."
)
infoBytes = infoBencoded
without infoHash =? MultiHash.digest($Sha1HashCodec, infoBytes).mapFailure, err:
return RestApiResponse.error(
Http422, "The torrent file does not contain a valid info hash."
)
return await node.retrieveInfoHash(infoHash, resp = resp)
router.api(MethodGet, "/api/codex/v1/torrent/{infoHash}/network/stream") do(
infoHash: MultiHash, resp: HttpResponseRef

View File

@ -0,0 +1,48 @@
import std/sequtils
import pkg/unittest2
import pkg/libp2p/[multicodec, multihash]
import pkg/questionable/results
import pkg/stew/byteutils
import ../examples
import pkg/codex/bittorrent/manifest/manifest
import pkg/codex/bittorrent/torrentParser
suite "torrentParser":
test "extracts info directory bytes from the torrent binary data":
let pieces = @[
"21FEBA308CD51E9ACF88417193A9EA60F0F84646",
"3D4A8279853DA2DA355A574740217D446506E8EB",
"1AD686B48B9560B15B8843FD00E7EC1B59624B09",
"5015E7DA0C40350624C6B5A1FED1DB39720B726C",
].map(
proc(hash: string): MultiHash =
let bytes = hash.hexToSeqByte.catch.tryGet()
MultiHash.init($Sha1HashCodec, bytes).mapFailure.tryGet()
)
let info = BitTorrentInfo(
length: 1048576, pieceLength: 262144, pieces: pieces, name: some("data1M.bin")
)
let encodedInfo = info.bencode()
let infoHash = MultiHash.digest($Sha1HashCodec, encodedInfo).mapFailure.tryGet()
let torrentBytes = ("d4:info" & string.fromBytes(encodedInfo) & "e").toBytes()
# let torrentBytesHex = byteutils.toHex(torrentBytes)
# check torrentBytesHex == "64343a696e666f64363a6c656e677468693130343835373665343a6e616d6531303a64617461314d2e62696e31323a7069656365206c656e6774686932363231343465363a70696563657338303a21feba308cd51e9acf88417193a9ea60f0f846463d4a8279853da2da355a574740217d446506e8eb1ad686b48b9560b15b8843fd00e7ec1b59624b095015e7da0c40350624c6b5a1fed1db39720b726c6565"
let infoBytes = extractInfoFromTorrent(torrentBytes).tryGet()
# echo string.fromBytes(infoBytes)
# let infoBytesHex = byteutils.toHex(infoBytes)
# check infoBytesHex == "64363a6c656e677468693130343835373665343a6e616d6531303a64617461314d2e62696e31323a7069656365206c656e6774686932363231343465363a70696563657338303a21feba308cd51e9acf88417193a9ea60f0f846463d4a8279853da2da355a574740217d446506e8eb1ad686b48b9560b15b8843fd00e7ec1b59624b095015e7da0c40350624c6b5a1fed1db39720b726c65"
let extractedInfoHash =
MultiHash.digest($Sha1HashCodec, infoBytes).mapFailure.tryGet()
check extractedInfoHash == infoHash