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));
}