feat: nb initial click and load

This commit is contained in:
2025-12-10 12:49:30 +01:00
parent 99ac2ea3b0
commit 5f300d9e7b
4 changed files with 279 additions and 34 deletions

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
Click'n'Load proxy - receives CNL requests and forwards to pyLoad API.
Implements the Click'n'Load protocol:
1. GET /jdcheck.js -> responds with jdownloader=true
2. POST /flash/addcrypted2 -> decrypts links and sends to pyLoad
"""
import argparse
import base64
import json
import re
import sys
import traceback
import urllib.request
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
from Crypto.Cipher import AES
def log(msg):
"""Log with immediate flush for systemd journal visibility."""
print(f"[CNL] {msg}", flush=True)
class ClickNLoadHandler(BaseHTTPRequestHandler):
pyload_url = None
pyload_user = None
pyload_pass = None
def log_message(self, format, *args):
log(f"{self.command} {self.path}")
def send_cors_headers(self):
"""Add CORS headers to allow cross-origin requests."""
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def do_OPTIONS(self):
"""Handle CORS preflight requests."""
self.send_response(200)
self.send_cors_headers()
self.end_headers()
def do_GET(self):
if self.path == "/jdcheck.js":
self.send_response(200)
self.send_header("Content-Type", "text/javascript")
self.send_cors_headers()
self.end_headers()
self.wfile.write(b"jdownloader=true;")
else:
self.send_response(404)
self.send_cors_headers()
self.end_headers()
def do_POST(self):
if self.path == "/flash/addcrypted2":
content_length = int(self.headers.get("Content-Length", 0))
post_data = self.rfile.read(content_length).decode("utf-8")
params = urllib.parse.parse_qs(post_data)
jk = params.get("jk", [""])[0]
crypted = params.get("crypted", [""])[0]
source = params.get("source", ["Click'n'Load"])[0]
log(f"Received addcrypted2: source={source}, jk_len={len(jk)}, crypted_len={len(crypted)}")
try:
links = self.decrypt_links(jk, crypted)
if links:
self.add_to_pyload(links, source)
self.send_response(200)
self.send_cors_headers()
self.end_headers()
self.wfile.write(b"success")
else:
log("Error: No links found after decryption")
self.send_response(500)
self.send_cors_headers()
self.end_headers()
self.wfile.write(b"No links found")
except Exception as e:
log(f"Error: {e}")
log(traceback.format_exc())
self.send_response(500)
self.send_cors_headers()
self.end_headers()
self.wfile.write(str(e).encode())
else:
self.send_response(404)
self.send_cors_headers()
self.end_headers()
def decrypt_links(self, jk, crypted):
"""Decrypt Click'n'Load encrypted links."""
# Extract hex key from JavaScript function
# Format: function f(){ return '...hex...'; }
match = re.search(r"return\s*['\"]([0-9a-fA-F]+)['\"]", jk)
if not match:
log(f"Could not extract key from jk: {jk[:100]}...")
raise ValueError("Could not extract key from jk")
key_hex = match.group(1)
key = bytes.fromhex(key_hex)
log(f"Extracted key: {len(key)} bytes")
# Decrypt (AES-128-CBC, key is also IV)
cipher = AES.new(key, AES.MODE_CBC, iv=key)
encrypted = base64.b64decode(crypted)
decrypted = cipher.decrypt(encrypted)
# Remove PKCS7 padding
pad_len = decrypted[-1]
decrypted = decrypted[:-pad_len]
# Split into links
links = decrypted.decode("utf-8").strip().split("\n")
links = [l.strip() for l in links if l.strip()]
log(f"Decrypted {len(links)} links")
return links
def add_to_pyload(self, links, package_name):
"""Add links to pyLoad via API using HTTP Basic Auth."""
if not self.pyload_url:
log("No pyLoad URL configured, printing links:")
for link in links:
log(f" {link}")
return
log(f"Adding package to pyLoad at {self.pyload_url}")
# Build Basic Auth header
credentials = f"{self.pyload_user}:{self.pyload_pass}"
auth_header = base64.b64encode(credentials.encode()).decode()
# Add package using new API endpoint with HTTP Basic Auth
add_url = f"{self.pyload_url}/api/add_package"
add_data = json.dumps({
"name": package_name or "Click'n'Load",
"links": links
}).encode()
req = urllib.request.Request(
add_url,
data=add_data,
headers={
"Content-Type": "application/json",
"Authorization": f"Basic {auth_header}"
}
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
result = resp.read().decode()
log(f"Added package to pyLoad: {result}")
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else ""
log(f"Failed to add package: {e.code} {e.reason} - {body}")
raise
except urllib.error.URLError as e:
log(f"Failed to add package: {e}")
raise
def main():
parser = argparse.ArgumentParser(description="Click'n'Load proxy for pyLoad")
parser.add_argument("--port", type=int, default=9666, help="Port to listen on")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind to")
parser.add_argument("--pyload-url", help="pyLoad URL (e.g., https://pyload.example.com)")
parser.add_argument("--pyload-user", help="pyLoad username")
parser.add_argument("--pyload-pass", help="pyLoad password")
parser.add_argument("--config", help="Config file with pyLoad credentials (JSON)")
args = parser.parse_args()
# Load config from file if provided
if args.config:
with open(args.config) as f:
config = json.load(f)
args.pyload_url = config.get("pyloadUrl", args.pyload_url)
args.pyload_user = config.get("pyloadUser", args.pyload_user)
args.pyload_pass = config.get("pyloadPW", args.pyload_pass)
ClickNLoadHandler.pyload_url = args.pyload_url
ClickNLoadHandler.pyload_user = args.pyload_user
ClickNLoadHandler.pyload_pass = args.pyload_pass
server = HTTPServer((args.host, args.port), ClickNLoadHandler)
log(f"Click'n'Load proxy listening on {args.host}:{args.port}")
if args.pyload_url:
log(f"Forwarding to pyLoad at {args.pyload_url}")
else:
log("No pyLoad URL configured, will print links to stdout")
try:
server.serve_forever()
except KeyboardInterrupt:
log("Shutting down")
server.shutdown()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,34 @@
{ lib
, python3
, makeWrapper
}:
let
python = python3.withPackages (ps: [ ps.pycryptodome ]);
in
python3.pkgs.buildPythonApplication {
pname = "clicknload-proxy";
version = "1.0.0";
format = "other";
src = ./.;
nativeBuildInputs = [ makeWrapper ];
propagatedBuildInputs = [ python3.pkgs.pycryptodome ];
installPhase = ''
mkdir -p $out/bin
cp clicknload-proxy.py $out/bin/clicknload-proxy
chmod +x $out/bin/clicknload-proxy
wrapProgram $out/bin/clicknload-proxy \
--prefix PYTHONPATH : "${python}/${python.sitePackages}"
'';
meta = with lib; {
description = "Click'n'Load proxy that forwards links to pyLoad";
license = licenses.mit;
mainProgram = "clicknload-proxy";
};
}