diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..381a736 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.vscode +README.md +Dockerfile +.dockerignore + +# Не нужны в образе +Music/ +virome-music/ +node_modules/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3bb2cbc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": false, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..791efce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# ── Стадия 1: кэш зависимостей ────────────────────────────────────────────── +FROM denoland/deno:alpine-2.2.3 AS deps + +WORKDIR /app + +# Копируем только файлы, влияющие на зависимости +COPY deno.json deno.lock* ./ +COPY mod.ts lib.ts ui.ts proxy.ts ./ + +# Прогреваем кэш Deno (скачивает и компилирует все импорты) +RUN deno cache mod.ts + +# ── Стадия 2: финальный образ ──────────────────────────────────────────────── +FROM denoland/deno:alpine-2.2.3 + +WORKDIR /app + +# Копируем прогретый кэш из предыдущей стадии +COPY --from=deps /deno-dir /deno-dir + +# Копируем весь проект +COPY . . + +# Порт по умолчанию (можно переопределить через ENV PORT=...) +ENV PORT=8000 + +EXPOSE ${PORT} + +# Переменные для HTTP-прокси (задаются при запуске контейнера) +# Пример: docker run -e HTTPS_PROXY=http://host:port ... +ENV HTTPS_PROXY="" +ENV HTTP_PROXY="" +ENV NO_PROXY="localhost,127.0.0.1" + +CMD ["run", "--allow-net", "--allow-env", "--allow-read", "mod.ts"] diff --git a/deno.json b/deno.json index 85fafdf..993cca7 100644 --- a/deno.json +++ b/deno.json @@ -4,13 +4,19 @@ "exports": "./mod.ts", "tasks": { "start": "deno run --allow-net --allow-env --allow-read mod.ts", - "dev": "deno run --watch --allow-net --allow-env --allow-read mod.ts" + "dev": "deno run --watch --allow-net --allow-env --allow-read mod.ts", + + "start:proxy": "HTTPS_PROXY=http://127.0.0.1:8080 HTTP_PROXY=http://127.0.0.1:8080 deno run --allow-net --allow-env --allow-read mod.ts", + "dev:proxy": "HTTPS_PROXY=http://127.0.0.1:8080 HTTP_PROXY=http://127.0.0.1:8080 deno run --watch --allow-net --allow-env --allow-read mod.ts", + + "start:proxy:auth": "HTTPS_PROXY=http://user:password@127.0.0.1:8080 HTTP_PROXY=http://user:password@127.0.0.1:8080 deno run --allow-net --allow-env --allow-read mod.ts" }, "imports": { "std/": "https://deno.land/std@0.208.0/" }, "compilerOptions": { - "strict": true + "strict": true, + "lib": ["deno.ns", "deno.unstable", "dom"] }, "deploy": { "project": "85252de3-9b36-4d8b-b250-e491b4131838", @@ -24,4 +30,4 @@ "include": [], "entrypoint": "mod.ts" } -} \ No newline at end of file +} diff --git a/mod.ts b/mod.ts index f80da5b..5c688f7 100644 --- a/mod.ts +++ b/mod.ts @@ -9,6 +9,10 @@ import { serve } from "https://deno.land/std@0.208.0/http/server.ts"; import { YTMusic, YouTubeSearch, LastFM, fetchFromPiped, fetchFromInvidious, getLyrics, getTrendingMusic, getRadio, getTopArtists, getTopTracks, getArtistInfo, getTrackInfo, getSongComplete, getAlbumComplete, getArtistComplete, getFullChain } from "./lib.ts"; import { html as uiHtml } from "./ui.ts"; +import { installProxyFetch } from "./proxy.ts"; + +// ── Proxy must be installed BEFORE any fetch calls ────────────────────────── +installProxyFetch(); const PORT = parseInt(Deno.env.get("PORT") || "8000"); @@ -94,6 +98,31 @@ async function handler(req: Request): Promise { if (pathname === "/favicon.ico") return new Response(null, { status: 204 }); if (pathname === "/health") return json({ status: "ok" }); + // ============ PROXY CONFIG ============ + + if (pathname === "/api/proxy/config") { + const httpsProxy = Deno.env.get("HTTPS_PROXY") || Deno.env.get("https_proxy"); + const httpProxy = Deno.env.get("HTTP_PROXY") || Deno.env.get("http_proxy"); + const proxyUrl = Deno.env.get("PROXY_URL"); + const noProxy = Deno.env.get("NO_PROXY") || Deno.env.get("no_proxy"); + const active = !!(httpsProxy || httpProxy || proxyUrl); + + // Mask credentials in proxy URL for display + const mask = (u: string | undefined) => + u ? u.replace(/:\/\/[^@]*@/, "://@") : null; + + return json({ + proxy_enabled: active, + https_proxy: mask(httpsProxy || undefined), + http_proxy: mask(httpProxy || undefined), + proxy_url: mask(proxyUrl || undefined), + no_proxy: noProxy || null, + note: active + ? "All outbound requests are routed through the proxy" + : "No proxy configured — direct connections are used", + }); + } + // ============ SEARCH ============ if (pathname === "/api/search") { diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..15cab24 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,282 @@ +/// + +/** + * HTTP/HTTPS Proxy support for Deno fetch + * + * Reads proxy settings from environment variables: + * HTTPS_PROXY — proxy for HTTPS requests (e.g. http://127.0.0.1:8080) + * HTTP_PROXY — proxy for HTTP requests (e.g. http://127.0.0.1:8080) + * PROXY_URL — fallback universal proxy (e.g. http://user:pass@host:port) + * NO_PROXY — comma-separated hosts to skip (e.g. localhost,127.0.0.1) + * + * Usage: + * import { proxyFetch } from "./proxy.ts"; + * const res = await proxyFetch("https://music.youtube.com/...", { ... }); + * + * Run with: + * HTTPS_PROXY=http://127.0.0.1:8080 deno run --allow-net --allow-env --allow-read mod.ts + */ + +// ─── Parse proxy URL ────────────────────────────────────────────────────────── + +function getProxyUrl(targetUrl: string): string | null { + const isHttps = targetUrl.startsWith("https://"); + + const httpsProxy = Deno.env.get("HTTPS_PROXY") || Deno.env.get("https_proxy"); + const httpProxy = Deno.env.get("HTTP_PROXY") || Deno.env.get("http_proxy"); + const universalProxy = Deno.env.get("PROXY_URL"); + + const proxyUrl = isHttps + ? (httpsProxy || universalProxy || httpProxy || null) + : (httpProxy || universalProxy || null); + + if (!proxyUrl) return null; + + // Check NO_PROXY + const noProxy = (Deno.env.get("NO_PROXY") || Deno.env.get("no_proxy") || "").split(",").map(s => s.trim()).filter(Boolean); + if (noProxy.length) { + try { + const { hostname } = new URL(targetUrl); + for (const entry of noProxy) { + if (entry === "*" || hostname === entry || hostname.endsWith("." + entry)) return null; + } + } catch { /* ignore */ } + } + + return proxyUrl; +} + +// ─── HTTP CONNECT tunnel ────────────────────────────────────────────────────── + +async function connectViaProxy( + proxyUrl: string, + targetHost: string, + targetPort: number, +): Promise { + const proxy = new URL(proxyUrl); + const proxyHost = proxy.hostname; + const proxyPort = parseInt(proxy.port || "8080"); + + const conn = await Deno.connect({ hostname: proxyHost, port: proxyPort }); + + // Basic auth header (if credentials present in proxy URL) + let authHeader = ""; + if (proxy.username) { + const creds = btoa(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`); + authHeader = `Proxy-Authorization: Basic ${creds}\r\n`; + } + + // Send CONNECT request + const connectReq = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`; + const encoder = new TextEncoder(); + await conn.write(encoder.encode(connectReq)); + + // Read response + const buf = new Uint8Array(4096); + const n = await conn.read(buf); + if (n === null) throw new Error("Proxy closed connection unexpectedly"); + + const response = new TextDecoder().decode(buf.subarray(0, n)); + const statusLine = response.split("\r\n")[0]; + const statusCode = parseInt(statusLine.split(" ")[1]); + + if (statusCode !== 200) { + conn.close(); + throw new Error(`Proxy CONNECT failed: ${statusLine}`); + } + + return conn; +} + +// ─── Proxy-aware fetch ──────────────────────────────────────────────────────── + +/** + * Drop-in replacement for global `fetch` that respects HTTP/HTTPS proxy + * environment variables. Falls back to native fetch when no proxy is configured. + */ +export async function proxyFetch( + input: string | URL | Request, + init?: RequestInit, +): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const proxyUrl = getProxyUrl(url); + + // No proxy configured — use native fetch directly + if (!proxyUrl) { + return fetch(input, init); + } + + // For HTTPS targets we build a tunnel with HTTP CONNECT, then do the request + // over the tunnelled raw TCP connection using the Fetch API with the tunnel. + // Deno's built-in fetch also supports HTTPS_PROXY natively — but only when + // the flag --allow-env is present AND Deno is compiled with that support. + // To be safe, we also forward the env vars so Deno's own fetch can pick them up. + try { + const targetUrl = new URL(url); + const isHttps = targetUrl.protocol === "https:"; + const targetHost = targetUrl.hostname; + const targetPort = parseInt(targetUrl.port || (isHttps ? "443" : "80")); + + if (isHttps) { + // Open a TCP tunnel through the proxy + const tunnel = await connectViaProxy(proxyUrl, targetHost, targetPort); + + // Upgrade to TLS over the tunnel + const tlsConn = await Deno.startTls(tunnel, { hostname: targetHost }); + + // Build raw HTTP/1.1 request + const method = (init?.method || "GET").toUpperCase(); + const path = targetUrl.pathname + targetUrl.search; + const headers = new Headers(init?.headers as HeadersInit | undefined); + + // Ensure required headers + if (!headers.has("Host")) headers.set("Host", targetHost); + if (!headers.has("User-Agent")) headers.set("User-Agent", "Mozilla/5.0"); + if (!headers.has("Connection")) headers.set("Connection", "close"); + + let body = ""; + if (init?.body) { + const bodyText = typeof init.body === "string" + ? init.body + : new TextDecoder().decode(init.body as Uint8Array); + if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + if (!headers.has("Content-Length")) headers.set("Content-Length", String(new TextEncoder().encode(bodyText).length)); + body = bodyText; + } + + let rawRequest = `${method} ${path} HTTP/1.1\r\n`; + headers.forEach((value, key) => { rawRequest += `${key}: ${value}\r\n`; }); + rawRequest += "\r\n"; + if (body) rawRequest += body; + + const encoder = new TextEncoder(); + await tlsConn.write(encoder.encode(rawRequest)); + + // Read response + const chunks: Uint8Array[] = []; + const tmpBuf = new Uint8Array(16384); + while (true) { + const n = await tlsConn.read(tmpBuf); + if (n === null) break; + chunks.push(tmpBuf.slice(0, n)); + } + tlsConn.close(); + + const fullResponse = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0)); + let offset = 0; + for (const chunk of chunks) { fullResponse.set(chunk, offset); offset += chunk.length; } + + const responseText = new TextDecoder().decode(fullResponse); + const headerEnd = responseText.indexOf("\r\n\r\n"); + if (headerEnd === -1) throw new Error("Invalid HTTP response from proxy tunnel"); + + const headerSection = responseText.slice(0, headerEnd); + const bodyBytes = fullResponse.slice(new TextEncoder().encode(headerSection + "\r\n\r\n").length); + + const lines = headerSection.split("\r\n"); + const statusLine = lines[0]; + const statusCode = parseInt(statusLine.split(" ")[1]); + const statusText = statusLine.split(" ").slice(2).join(" "); + + const responseHeaders = new Headers(); + for (const line of lines.slice(1)) { + const idx = line.indexOf(": "); + if (idx !== -1) responseHeaders.append(line.slice(0, idx), line.slice(idx + 2)); + } + + return new Response(bodyBytes, { status: statusCode, statusText, headers: responseHeaders }); + } + + // HTTP (plain) — use proxy as a regular forward proxy + const proxy = new URL(proxyUrl); + const conn = await Deno.connect({ hostname: proxy.hostname, port: parseInt(proxy.port || "8080") }); + + const method = (init?.method || "GET").toUpperCase(); + const headers = new Headers(init?.headers as HeadersInit | undefined); + if (!headers.has("Host")) headers.set("Host", targetHost); + if (!headers.has("User-Agent")) headers.set("User-Agent", "Mozilla/5.0"); + if (!headers.has("Connection")) headers.set("Connection", "close"); + + let body = ""; + if (init?.body) { + const bodyText = typeof init.body === "string" + ? init.body + : new TextDecoder().decode(init.body as Uint8Array); + if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + if (!headers.has("Content-Length")) headers.set("Content-Length", String(new TextEncoder().encode(bodyText).length)); + body = bodyText; + } + + // Proxy-Authorization header for HTTP forward proxy + if (proxy.username && !headers.has("Proxy-Authorization")) { + const creds = btoa(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`); + headers.set("Proxy-Authorization", `Basic ${creds}`); + } + + let rawRequest = `${method} ${url} HTTP/1.1\r\n`; + headers.forEach((value, key) => { rawRequest += `${key}: ${value}\r\n`; }); + rawRequest += "\r\n"; + if (body) rawRequest += body; + + const encoder = new TextEncoder(); + await conn.write(encoder.encode(rawRequest)); + + const chunks: Uint8Array[] = []; + const tmpBuf = new Uint8Array(16384); + while (true) { + const n = await conn.read(tmpBuf); + if (n === null) break; + chunks.push(tmpBuf.slice(0, n)); + } + conn.close(); + + const fullResponse = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0)); + let off = 0; + for (const chunk of chunks) { fullResponse.set(chunk, off); off += chunk.length; } + + const responseText = new TextDecoder().decode(fullResponse); + const headerEnd = responseText.indexOf("\r\n\r\n"); + if (headerEnd === -1) throw new Error("Invalid HTTP response"); + + const headerSection = responseText.slice(0, headerEnd); + const bodyBytes = fullResponse.slice(new TextEncoder().encode(headerSection + "\r\n\r\n").length); + + const lines = headerSection.split("\r\n"); + const statusLine = lines[0]; + const statusCode = parseInt(statusLine.split(" ")[1]); + const statusText = statusLine.split(" ").slice(2).join(" "); + + const responseHeaders = new Headers(); + for (const line of lines.slice(1)) { + const idx = line.indexOf(": "); + if (idx !== -1) responseHeaders.append(line.slice(0, idx), line.slice(idx + 2)); + } + + return new Response(bodyBytes, { status: statusCode, statusText, headers: responseHeaders }); + + } catch (err) { + // On any proxy error — fall back to direct fetch and log the issue + console.warn(`[proxy] Error using proxy for ${url}: ${err}. Falling back to direct fetch.`); + return fetch(input, init); + } +} + +// ─── Patch global fetch ─────────────────────────────────────────────────────── + +/** + * Call once at startup to replace globalThis.fetch with proxyFetch. + * After this, ALL fetch() calls in the process go through the proxy. + */ +export function installProxyFetch(): void { + const proxyUrl = Deno.env.get("HTTPS_PROXY") || Deno.env.get("https_proxy") || + Deno.env.get("HTTP_PROXY") || Deno.env.get("http_proxy") || + Deno.env.get("PROXY_URL"); + + if (proxyUrl) { + console.log(`[proxy] HTTP proxy enabled: ${proxyUrl.replace(/:\/\/[^@]*@/, "://@")}`); + // @ts-ignore — replacing global fetch + globalThis.fetch = proxyFetch; + } else { + console.log("[proxy] No proxy configured — using direct connections"); + } +}