"""Минимальные HTTP-хелперы на urllib (без сторонних библиотек). Поддерживает прокси: обычный HTTP-прокси и SOCKS5 (собственная реализация рукопожатия — stdlib не умеет SOCKS без PySocks). """ import hashlib import http.client import json import os import socket import struct import time import urllib.request USER_AGENT = "PixelLauncher/2.1.2 (lightweight Minecraft launcher)" ATTEMPTS = 4 _opener = None # настроенный opener (прокси) или None — напрямую в сеть class Cancelled(Exception): """Операция отменена пользователем.""" # --------------------------------------------------------------- зеркала API # Своё зеркало на VPS (nginx-прокси): используется одной попыткой — не # ответило, зеркало отключается до конца сессии и запросы идут напрямую. MIRRORS = { "https://api.modrinth.com/": "https://pixellauncher.ru/proxy/modrinth/", "https://piston-meta.mojang.com/": "https://pixellauncher.ru/proxy/mojang/", } _mirror_broken = set() # префиксы с отключённым зеркалом (до конца сессии) def _mirror(url): """(зеркальный url, префикс) либо (None, None).""" for prefix, mirror in MIRRORS.items(): if url.startswith(prefix) and prefix not in _mirror_broken: return mirror + url[len(prefix):], prefix return None, None def _attempt_urls(url): """Очередь URL для попыток: зеркало один раз, затем оригинал.""" mirror, prefix = _mirror(url) if mirror: yield mirror, prefix for _ in range(ATTEMPTS): yield url, None def reset_mirrors(): """Возвращает зеркала в строй (для тестов/новой сессии).""" _mirror_broken.clear() # ------------------------------------------------------------------ прокси def configure(proxy=None): """Настраивает исходящие запросы. proxy — dict из settings["proxy"]. {"enabled": True, "type": "http"|"socks5", "host", "port", "user", "pass"} Пусто/выключено — прямое соединение. """ global _opener _opener = None if not isinstance(proxy, dict) or not proxy.get("enabled"): return host = (proxy.get("host") or "").strip() try: port = int(proxy.get("port") or 0) except (TypeError, ValueError): port = 0 if not host or not port: return user = (proxy.get("user") or "").strip() passwd = proxy.get("pass") or "" if (proxy.get("type") or "http") == "socks5": _opener = urllib.request.build_opener( *_make_socks_handlers(host, port, user, passwd)) else: # креды в URL: их подхватывает _parse_proxy и шлёт в туннеле CONNECT addr = f"http://{host}:{port}" if user: from urllib.parse import quote addr = f"http://{quote(user, safe='')}:{quote(passwd, safe='')}@" \ f"{host}:{port}" _opener = urllib.request.build_opener( urllib.request.ProxyHandler({"http": addr, "https": addr})) def _socks_handshake(sock, host, port, auth): """SOCKS5: рукопожатие + CONNECT к host:port. Оставляет sock готовым.""" if auth and auth[0]: sock.sendall(b"\x05\x02\x00\x02") # предлагаем без аутентификации и с логином resp = _socks_recv(sock, 2) if resp == b"\x05\x02": u, p = auth[0].encode(), (auth[1] or "").encode() sock.sendall(b"\x01" + bytes([len(u)]) + u + bytes([len(p)]) + p) r = _socks_recv(sock, 2) if r[1] != 0: raise OSError("SOCKS5: неверный логин/пароль") elif resp != b"\x05\x00": raise OSError("SOCKS5: сервер отклонил метод аутентификации") else: sock.sendall(b"\x05\x01\x00") resp = _socks_recv(sock, 2) if resp != b"\x05\x00": raise OSError("SOCKS5: сервер не поддержал без аутентификации") hb = host.encode() sock.sendall(b"\x05\x01\x00\x03" + bytes([len(hb)]) + hb + struct.pack("!H", port or 0)) head = _socks_recv(sock, 4) if head[1] != 0: raise OSError(f"SOCKS5: отказ подключения (код {head[1]})") atyp = head[3] if atyp == 1: # IPv4 _socks_recv(sock, 4) elif atyp == 3: # домен _socks_recv(sock, 1 + _socks_recv(sock, 1)[0]) elif atyp == 4: # IPv6 _socks_recv(sock, 16) _socks_recv(sock, 2) def _socks_recv(sock, n): buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise OSError("SOCKS5: соединение закрыто") buf += chunk return buf def _make_socks_handlers(host, port, user, passwd): """Handler'ы, подменяющие соединение на SOCKS5-туннель.""" class _SocksConn: PROXY = (host, port) AUTH = (user, passwd) def connect(self): sock = socket.create_connection(self.PROXY, timeout=self.timeout) _socks_handshake(sock, self.host, self.port or 0, self.AUTH) self.sock = sock class C(_SocksConn, http.client.HTTPConnection): pass class HC(_SocksConn, http.client.HTTPSConnection): def connect(self): _SocksConn.connect(self) self.sock = self._context.wrap_socket(self.sock, server_hostname=self.host) class H1(urllib.request.HTTPHandler): def http_open(self, req): return self.do_open(C, req) class H2(urllib.request.HTTPSHandler): def https_open(self, req): return self.do_open(HC, req) return H1(), H2() def _open(req, timeout=30): """urlopen через настроенный opener (прокси, если задан).""" if _opener is not None: return _opener.open(req, timeout=timeout) return urllib.request.urlopen(req, timeout=timeout) def get_bytes(url, timeout=30, headers=None): last = None n = 0 hdrs = {"User-Agent": USER_AGENT} if headers: try: hdrs.update(headers) except Exception: pass for u, mprefix in _attempt_urls(url): try: req = urllib.request.Request(u, headers=hdrs) with _open(req, timeout=timeout) as r: return r.read() except Exception as e: # noqa: BLE001 last = e if mprefix: _mirror_broken.add(mprefix) # молчит — до конца сессии continue n += 1 time.sleep(0.7 * n) raise RuntimeError(f"Не удалось скачать {url}: {last}") def post_json(url, obj, timeout=30): """POST с JSON-телом. Возвращает распарсенный JSON.""" body = json.dumps(obj).encode("utf-8") last = None n = 0 for u, mprefix in _attempt_urls(url): try: req = urllib.request.Request( u, data=body, method="POST", headers={"User-Agent": USER_AGENT, "Content-Type": "application/json"}) with _open(req, timeout=timeout) as r: return json.loads(r.read().decode("utf-8")) except Exception as e: # noqa: BLE001 last = e if mprefix: _mirror_broken.add(mprefix) # молчит — до конца сессии continue n += 1 time.sleep(0.7 * n) raise RuntimeError(f"Не удалось отправить запрос {url}: {last}") def head_size(url, timeout=20): """Размер файла по HEAD-запросу (для прогресса), None если неизвестен.""" for method in ("HEAD", "GET"): try: req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}, method=method) with _open(req, timeout=timeout) as r: n = r.headers.get("Content-Length") if n: return int(n) if method == "HEAD": continue # пробуем GET: часть серверов не даёт длину в HEAD return None except Exception: # noqa: BLE001 continue return None def sha1_file(path): h = hashlib.sha1() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 16), b""): h.update(chunk) return h.hexdigest() def download_file(url, path, sha1=None, size=None, verify_sha1=False, progress=None, cancel=None): """Скачивает файл только если он отсутствует/битый. Существующий файл проверяется по размеру (быстро, без чтения диска); полная проверка SHA-1 выполняется только при verify_sha1 (принудительная проверка целостности) и всегда — для только что скачанных файлов. progress(done_bytes, total_bytes|None) — вызывается по мере скачивания. cancel — threading.Event: если задан, скачивание прерывается с Cancelled. True — файл был скачан, False — уже был на месте. """ if os.path.isfile(path): if verify_sha1 and sha1: ok = sha1_file(path) == sha1.lower() elif size: ok = os.path.getsize(path) == size else: ok = True if ok: return False os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".part" last = None n = 0 for u, mprefix in _attempt_urls(url): if cancel is not None and cancel.is_set(): raise Cancelled(url) try: req = urllib.request.Request(u, headers={"User-Agent": USER_AGENT}) with _open(req, timeout=90) as r: total = size if total is None: n = r.headers.get("Content-Length") total = int(n) if n else None done = 0 with open(tmp, "wb") as f: while True: if cancel is not None and cancel.is_set(): raise Cancelled(url) chunk = r.read(1 << 18) if not chunk: break f.write(chunk) done += len(chunk) if progress: progress(done, total) if sha1 and sha1_file(tmp) != sha1.lower(): raise RuntimeError("SHA-1 не совпал, файл битый") os.replace(tmp, path) return True except Cancelled: _remove_tmp(tmp) raise except Exception as e: # noqa: BLE001 last = e if mprefix: _mirror_broken.add(mprefix) # молчит — до конца сессии _remove_tmp(tmp) continue n += 1 time.sleep(0.7 * n) _remove_tmp(tmp) raise RuntimeError(f"Не удалось скачать {url}: {last}") def _remove_tmp(tmp): try: if os.path.exists(tmp): os.remove(tmp) except OSError: pass