Spaces:
Running
Running
File size: 12,469 Bytes
b059157 bd3cb33 b059157 1c833d2 5f80ef1 1c833d2 5bd5d41 b059157 5f80ef1 b059157 5f80ef1 b059157 bd3cb33 b059157 5f80ef1 bd3cb33 5bd5d41 bd3cb33 b059157 1c833d2 5bd5d41 1c833d2 5bd5d41 1c833d2 b059157 5bd5d41 b059157 bd3cb33 b059157 5bd5d41 b059157 bd3cb33 b059157 5bd5d41 b059157 bd3cb33 b059157 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | /**
* proxy.ts — единая точка выхода для запросов, которые сервер бота (HF Spaces)
* не может сделать напрямую, потому что датацентровые IP HF Spaces блокируются
* некоторыми сервисами на уровне сети (см. README проекта Lumen):
* - Telegram Bot API (полностью заблокирован на уровне TLS-handshake — без
* этого прокси бот вообще не может ходить в Telegram).
* - TikWM (стабильно отвечает HTTP 403 с пустым телом на исходящие запросы
* с IP HF Spaces — см. историю отладки, /logs с тегом [tikwm][diag]).
*
* ЗАМЕНЯЕТ собой прежний tg-proxy (умел проксировать только Telegram Bot API
* по формату /bot<token>/<method>) — переименован и обобщён. Лимиты Deno
* Deploy (free-тариф: 1 млн запросов/мес, 20 ГБ исходящего трафика/мес, 15ч
* CPU/мес) общие на ВЕСЬ АККАУНТ, а не на отдельный проект — значит держать
* два раздельных Deno-приложения (одно под Telegram, другое под TikWM) не
* даёт вообще никакой отдельной квоты, только лишняя сущность для поддержки.
* Один универсальный прокси проще: один домен, один секрет, один деплой.
*
* ── Формат ──
* GET/POST/... https://<домен>/fetch/<host>/<путь...>?<query>
* -> https://<host>/<путь...>?<query>
* Метод, заголовки и тело запроса передаются как есть; статус, заголовки и
* тело ответа — тоже как есть (без буферизации целиком в память — тело
* стримится напрямую, это важно для больших file-загрузок в Telegram, см.
* sendVideo/sendPhoto/sendMediaGroup у бота).
*
* ── Почему нужен allowlist хостов ──
* Без ограничения на разрешённые хосты это был бы открытый анонимный релей на
* ЛЮБОЙ адрес в интернете — кто угодно, узнав домен, мог бы использовать его
* для проксирования куда захочет, тратя общую квоту трафика аккаунта (те же
* 20 ГБ/мес) и потенциально привлекая к аккаунту внимание как к источнику
* абьюза/скана. ALLOWED_HOSTS ниже — единственные хосты, которые реально
* нужны боту; расширять список нужно только по факту новой подтверждённой
* необходимости (см. тот же принцип "не добавляй заранее" в остальном проекте).
*
* ── Как настроить бота на использование этого прокси ──
* В HF Spaces secrets/variables:
* TELEGRAM_API_BASE_URL = https://<домен>/fetch/api.telegram.org
* TIKWM_API_BASE_URL = https://<домен>/fetch/www.tikwm.com
* bot.py дальше сам достраивает нужные пути (/bot<token>/<method>,
* /file/bot<token>/<path>, /api/?url=...) поверх этой базы — никаких других
* изменений в Python-коде для смены адреса прокси не требуется.
*/
export const PROXY_AUTH_HEADER = "X-Lumen-Proxy-Secret";
export const ALLOWED_HOSTS = new Set([
"api.telegram.org",
"www.tikwm.com",
"tikwm.com",
]);
// Кап объявленного тела запроса (аудит 26.09.2026). Telegram сам режет загрузки
// на 50 МБ, а прокси — общий трафик аккаунта, поэтому граница взята с запасом
// под честно большие файлы, но не «без предела».
export const MAX_REQUEST_BODY_BYTES = 100 * 1024 * 1024;
export function limitStreamBytes(
stream: ReadableStream<Uint8Array> | null,
maxBytes: number,
): ReadableStream<Uint8Array> | null {
// Потоковый кап без буферизации: считаем байты на лету и рвём стрим при
// превышении. Нужен и для тела запроса без Content-Length, и для тела ответа:
// иначе объявленный кап обходился chunked-потоком любого размера.
if (!stream) return stream;
let total = 0;
return stream.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
total += chunk.byteLength;
if (total > maxBytes) {
controller.error(new Error(`Body exceeds ${maxBytes} byte cap`));
} else {
controller.enqueue(chunk);
}
},
}));
}
// Заголовки, которые нельзя слепо пробрасывать дальше как есть — Host/Connection
// в запросе относятся к соединению с ЭТИМ (Deno) сервером, а не с реальным
// апстримом, апстрим сам выставит правильные. Content-Encoding/Content-Length в
// ОТВЕТЕ — fetch() в Deno уже сам распаковывает gzip/br к моменту, когда тело
// становится нам доступно, поэтому исходный Content-Encoding больше не описывает
// реальное тело — клиент, попытавшийся распаковать уже распакованное, получил бы
// битые данные. Content-Length по той же причине может не совпадать с реальным
// размером — рантайм сам выставит корректный Transfer-Encoding для стрима.
const HOP_BY_HOP_REQUEST_HEADERS = ["host", "connection"];
const HOP_BY_HOP_RESPONSE_HEADERS = ["content-encoding", "content-length", "connection", "transfer-encoding"];
// Разложено на чистые тестируемые функции вместо одного большого обработчика:
// маршрутизацию и заголовки проверяют юниты без сети, сетевую часть —
// handleRequest через подмену fetch.
export type TargetResolution =
| { ok: true; host: string; url: string }
| { ok: false; status: 404 | 403; message: string };
export function resolveTarget(pathname: string, search: string): TargetResolution {
// pathname всегда начинается с "/", поэтому после split("/") первый элемент —
// всегда пустая строка, а реальные сегменты — начиная с индекса 1. Намеренно
// Пустые сегменты намеренно не фильтруем: filter(Boolean) съедал завершающий "/"
// у путей вида "/fetch/host/api/", и запрос TikWM уходил без слеша (дорогая
// отладка в истории проекта).
const parts = pathname.split("/");
if (parts.length < 3 || parts[1] !== "fetch" || parts[2] === "") {
return { ok: false, status: 404, message: "Not found — ожидаемый формат пути: /fetch/<host>/<путь>" };
}
const host = parts[2];
if (!ALLOWED_HOSTS.has(host)) {
return { ok: false, status: 403, message: "Host not allowed" };
}
const path = "/" + parts.slice(3).join("/");
return { ok: true, host, url: `https://${host}${path}${search}` };
}
export function buildForwardHeaders(reqHeaders: Headers): Headers {
const headers = new Headers(reqHeaders);
for (const name of HOP_BY_HOP_REQUEST_HEADERS) headers.delete(name);
headers.delete(PROXY_AUTH_HEADER);
return headers;
}
export function buildResponseHeaders(upstreamHeaders: Headers): Headers {
const headers = new Headers(upstreamHeaders);
for (const name of HOP_BY_HOP_RESPONSE_HEADERS) headers.delete(name);
return headers;
}
// fetchImpl — точка подмены для тестов: реальная сеть юнитам не нужна.
export async function handleRequest(
req: Request,
fetchImpl: typeof fetch = fetch,
proxySecret: string | undefined = undefined,
maxBodyBytes: number = MAX_REQUEST_BODY_BYTES,
): Promise<Response> {
if (!proxySecret || !/^[\x21-\x7e]+$/.test(proxySecret)) {
return new Response("Proxy authentication unavailable", { status: 503 });
}
const suppliedSecret = req.headers.get(PROXY_AUTH_HEADER) ?? "";
const encoder = new TextEncoder();
const [expected, supplied] = await Promise.all([
crypto.subtle.digest("SHA-256", encoder.encode(proxySecret)),
crypto.subtle.digest("SHA-256", encoder.encode(suppliedSecret)),
]);
const expectedBytes = new Uint8Array(expected);
const suppliedBytes = new Uint8Array(supplied);
let difference = 0;
for (let i = 0; i < expectedBytes.length; i++) {
difference |= expectedBytes[i] ^ suppliedBytes[i];
}
if (!suppliedSecret || difference !== 0) {
return new Response("Unauthorized", { status: 401 });
}
const url = new URL(req.url);
const target = resolveTarget(url.pathname, url.search);
if (!target.ok) {
return new Response(target.message, { status: target.status });
}
// Кап тела запроса по объявленному content-length: без него любой, у кого есть
// секрет, лил в прокси поток любого размера и съедал общий лимит Deno-аккаунта
// (ровно то, от чего прокси и защищает — аудит 26.09.2026). Потоковое тело без
// заголовка тоже капаем на лету, а не пропускаем: иначе кап обходился chunked.
const declaredLength = Number(req.headers.get("content-length") ?? "0");
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
return new Response("Request body too large", { status: 413 });
}
const forwardHeaders = buildForwardHeaders(req.headers);
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const cappedRequestBody = hasBody ? limitStreamBytes(req.body, maxBodyBytes) : undefined;
let upstreamResp: Response;
try {
upstreamResp = await fetchImpl(target.url, {
method: req.method,
headers: forwardHeaders,
redirect: "error",
// GET/HEAD не могут иметь тело запроса (fetch бросит исключение, если
// передать body для них) — для остальных методов пробрасываем тело
// напрямую как поток, не буферизуя целиком в памяти (важно для
// multipart file-загрузок в Telegram, см. докстринг выше).
body: cappedRequestBody,
// @ts-ignore — Deno требует duplex:"half" для потокового тела запроса
// (часть стандарта WHATWG fetch для body типа ReadableStream).
duplex: "half",
});
} catch {
return new Response("Upstream fetch failed", { status: 502 });
}
const respHeaders = buildResponseHeaders(upstreamResp.headers);
return new Response(limitStreamBytes(upstreamResp.body, maxBodyBytes), {
status: upstreamResp.status,
headers: respHeaders,
});
}
// Реальный сервер стартует только при прямом запуске файла (deno run/deploy),
// не при импорте из proxy_test.ts — иначе тесты пытались бы забиндить порт.
if (import.meta.main) {
const proxySecret = Deno.env.get("LUMEN_PROXY_SECRET");
Deno.serve((req) => handleRequest(req, fetch, proxySecret));
}
|