#!/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()