diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml new file mode 100644 index 00000000..5de8db2f --- /dev/null +++ b/.github/workflows/deno.yml @@ -0,0 +1,49 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow will install Deno then run: +# - `deno fmt` +# - `deno lint` +# - `deno check` +# For more information see: https://github.com/denoland/setup-deno + +name: Deno + +on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - name: Setup repo + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: latest + cache: true + + - name: Verify formatting + run: deno fmt --check + + - name: Run linter + run: deno lint + + - name: Check types + run: deno check --frozen=true *.ts + + # - name: Run tests + # run: deno test -A diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0b8eed2f..86124530 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,6 +14,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + submodules: recursive - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -41,4 +43,4 @@ jobs: platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index defe4625..1d74e219 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -ejs/ -.vscode/ \ No newline at end of file +.vscode/ diff --git a/Dockerfile b/Dockerfile index 2a19a511..6e1086a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,42 @@ -FROM denoland/deno:latest AS builder +ARG XDG_CACHE_HOME -WORKDIR /usr/src/app - -RUN apt-get update && apt-get install -y git - -RUN git clone https://github.com/yt-dlp/ejs.git -# Pin to a specific commit -RUN cd ejs && git checkout 2655b1f55f98e5870d4e124704a21f4d793b4e1c && cd .. - -COPY scripts/patch-ejs.ts ./scripts/patch-ejs.ts -RUN deno run --allow-read --allow-write ./scripts/patch-ejs.ts +FROM denoland/deno:debian AS builder -RUN rm -rf ./ejs/.git ./ejs/node_modules || true +WORKDIR /usr/src/app COPY . . RUN deno compile \ - --no-check \ --output server \ --allow-net --allow-read --allow-write --allow-env \ --include worker.ts \ server.ts -RUN mkdir -p /usr/src/app/player_cache && \ - chown -R deno:deno /usr/src/app/player_cache +FROM ghcr.io/tcely/docker-tini:main@sha256:0b16fede939249d3783966eba573bac03cf106721df5a63e3555f6b8b0cef074 AS tini-bin +FROM scratch AS tini +ARG TARGETARCH TINI_VERSION="0.19.0" +COPY --from=tini-bin "/releases/v${TINI_VERSION}/tini-static-${TARGETARCH}" /tini -FROM gcr.io/distroless/cc-debian12 +FROM gcr.io/distroless/cc-debian13:debug +SHELL ["/busybox/busybox", "sh", "-c"] WORKDIR /app +COPY --from=tini /tini /tini COPY --from=builder /usr/src/app/server /app/server -COPY --from=builder --chown=nonroot:nonroot /usr/src/app/player_cache /app/player_cache COPY --from=builder --chown=nonroot:nonroot /usr/src/app/docs /app/docs -USER nonroot +ARG XDG_CACHE_HOME +ENV XDG_CACHE_HOME="${XDG_CACHE_HOME}" +# Create the fall-back cache directories +RUN install -v -d -o nonroot -g nonroot -m 750 \ + /app/player_cache /home/nonroot/.cache && \ + test -z "${XDG_CACHE_HOME}" || install -v -d -m 1777 "${XDG_CACHE_HOME}" + EXPOSE 8001 -ENTRYPOINT ["/app/server"] \ No newline at end of file +USER nonroot +ENTRYPOINT ["/tini", "--"] +# Run the server as nonroot even when /tini runs as root +# CMD ["/busybox/busybox", "su", "-s", "/app/server", "nonroot"] +CMD ["/app/server"] diff --git a/README.md b/README.md index ee4a3746..7080b7dc 100644 --- a/README.md +++ b/README.md @@ -39,19 +39,16 @@ docker compose up If you have Deno installed, you can run the service directly. -Clone the repository and patch the `ejs` dependency: +Clone the repository: ```bash git clone https://github.com/kikkia/yt-cipher.git -cd yt-cipher -git clone https://github.com/yt-dlp/ejs.git -cd ejs -git checkout 5d7bf090bb9a2a8f3e2dd13ded4a21a009224f87 -cd .. -deno run --allow-read --allow-write ./scripts/patch-ejs.ts ``` +Run the server: + ```bash +cd yt-cipher && \ deno run --allow-net --allow-read --allow-write --allow-env server.ts ``` NOTE: If using an `.env` file then also add the `--env` flag diff --git a/deno.json b/deno.json new file mode 100644 index 00000000..9c02f708 --- /dev/null +++ b/deno.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": [ "deno.worker" ] + }, + "fmt": { + "include": ["src/", "*.ts"], + "indentWidth": 4 + }, + "imports": { + "@std/cache": "jsr:@std/cache@^0.2.1", + "@std/crypto": "jsr:@std/crypto@^0.224.0", + "@std/fs": "jsr:@std/fs@^0.224.0", + "@std/http": "jsr:@std/http@0.224.5", + "@std/path": "jsr:@std/path@^0.224.0", + "ejs/": "https://esm.sh/gh/yt-dlp/ejs@0.3.2&standalone/", + "ts_prometheus/": "https://esm.sh/gh/marcopacini/ts_prometheus@v0.3.0/" + }, + "lint": { + "include": ["src/", "*.ts"] + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 00000000..5773e2d5 --- /dev/null +++ b/deno.lock @@ -0,0 +1,105 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@0.224": "0.224.0", + "jsr:@std/async@^1.0.0-rc.1": "1.0.15", + "jsr:@std/cache@~0.2.1": "0.2.1", + "jsr:@std/cli@~0.224.7": "0.224.7", + "jsr:@std/crypto@0.224": "0.224.0", + "jsr:@std/encoding@0.224": "0.224.3", + "jsr:@std/encoding@1.0.0-rc.2": "1.0.0-rc.2", + "jsr:@std/fmt@~0.225.4": "0.225.6", + "jsr:@std/fs@0.224": "0.224.0", + "jsr:@std/http@0.224.5": "0.224.5", + "jsr:@std/media-types@^1.0.0-rc.1": "1.1.0", + "jsr:@std/net@~0.224.3": "0.224.5", + "jsr:@std/path@0.224": "0.224.0", + "jsr:@std/path@1.0.0-rc.2": "1.0.0-rc.2", + "jsr:@std/streams@~0.224.5": "0.224.5" + }, + "jsr": { + "@std/assert@0.224.0": { + "integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f" + }, + "@std/async@1.0.15": { + "integrity": "55d1d9d04f99403fe5730ab16bdcc3c47f658a6bf054cafb38a50f046238116e" + }, + "@std/cache@0.2.1": { + "integrity": "b6f1abfd118d35b1c4ca90f2b3f4c709a2014ae368f244bdc7533bf1c169d759" + }, + "@std/cli@0.224.7": { + "integrity": "654ca6477518e5e3a0d3fabafb2789e92b8c0febf1a1d24ba4b567aba94b5977" + }, + "@std/crypto@0.224.0": { + "integrity": "154ef3ff08ef535562ef1a718718c5b2c5fc3808f0f9100daad69e829bfcdf2d", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/encoding@0.224" + ] + }, + "@std/encoding@0.224.3": { + "integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf" + }, + "@std/encoding@1.0.0-rc.2": { + "integrity": "160d7674a20ebfbccdf610b3801fee91cf6e42d1c106dd46bbaf46e395cd35ef" + }, + "@std/fmt@0.225.6": { + "integrity": "aba6aea27f66813cecfd9484e074a9e9845782ab0685c030e453a8a70b37afc8" + }, + "@std/fs@0.224.0": { + "integrity": "52a5ec89731ac0ca8f971079339286f88c571a4d61686acf75833f03a89d8e69", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/path@0.224" + ] + }, + "@std/http@0.224.5": { + "integrity": "b03b5d1529f6c423badfb82f6640f9f2557b4034cd7c30655ba5bb447ff750a4", + "dependencies": [ + "jsr:@std/async", + "jsr:@std/cli", + "jsr:@std/encoding@1.0.0-rc.2", + "jsr:@std/fmt", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path@1.0.0-rc.2", + "jsr:@std/streams" + ] + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@0.224.5": { + "integrity": "9c2ae90a5c3dc7771da5ae5e13b6f7d5d0b316c1954c5d53f2bfc1129fb757ff" + }, + "@std/path@0.224.0": { + "integrity": "55bca6361e5a6d158b9380e82d4981d82d338ec587de02951e2b7c3a24910ee6", + "dependencies": [ + "jsr:@std/assert" + ] + }, + "@std/path@1.0.0-rc.2": { + "integrity": "39f20d37a44d1867abac8d91c169359ea6e942237a45a99ee1e091b32b921c7d" + }, + "@std/streams@0.224.5": { + "integrity": "bcde7818dd5460d474cdbd674b15f6638b9cd73cd64e52bd852fba2bd4d8ec91" + } + }, + "remote": { + "https://esm.sh/gh/marcopacini/ts_prometheus@v0.3.0/denonext/mod.ts.mjs": "3a2e38e83add647f9758c140a30b3ef84f326cbc00ef75ca6c2331c81415ae58", + "https://esm.sh/gh/marcopacini/ts_prometheus@v0.3.0/mod.ts": "2a754040ada85e5dc78e77c1c619cca9992be7d63a90994861aeaec49ed467f3", + "https://esm.sh/gh/yt-dlp/ejs@0.3.0&standalone/src/yt/solver/solvers.ts": "269a7e74a2f021621402fbee5872168564196151df892df8fb17229b593dd0b3", + "https://esm.sh/gh/yt-dlp/ejs@0.3.0/denonext/src/yt/solver/solvers.ts.bundle.mjs": "88c7e36c180c2f9416fd26ba9a7db344d8008a7f1069e5d7a91c0bdde44a5656", + "https://esm.sh/gh/yt-dlp/ejs@0.3.2&standalone/src/yt/solver/solvers.ts": "72a4211a3a6a3d0f0da77a32080d20743ebc90971aa7298fcc2b3eb099315157", + "https://esm.sh/gh/yt-dlp/ejs@0.3.2/denonext/src/yt/solver/solvers.ts.bundle.mjs": "f4d41475fd3dd459ab5f9bdaac55fffe57239a13554234a0ae3217949baad899" + }, + "workspace": { + "dependencies": [ + "jsr:@std/cache@~0.2.1", + "jsr:@std/crypto@0.224", + "jsr:@std/fs@0.224", + "jsr:@std/http@0.224.5", + "jsr:@std/path@0.224" + ] + } +} diff --git a/scripts/patch-ejs.ts b/scripts/patch-ejs.ts deleted file mode 100644 index 11f6890d..00000000 --- a/scripts/patch-ejs.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { walk } from "https://deno.land/std@0.224.0/fs/walk.ts"; -import { join } from "https://deno.land/std@0.224.0/path/mod.ts"; - -const EJS_SRC_DIR = join(Deno.cwd(), "ejs/src"); - -async function patchFile(path: string) { - let content = await Deno.readTextFile(path); - let changed = false; - - const replacements = [ - { from: /from ["']meriyah["']/g, to: 'from "npm:meriyah"' }, - { from: /from ["']astring["']/g, to: 'from "npm:astring"' } - ]; - - for (const replacement of replacements) { - if (replacement.from.test(content)) { - content = content.replace(replacement.from, replacement.to); - changed = true; - } - } - - if (changed) { - await Deno.writeTextFile(path, content); - console.log(`Patched ${path}`); - } -} - -console.log(`Starting to patch files in ${EJS_SRC_DIR}...`); - -for await (const entry of walk(EJS_SRC_DIR, { exts: [".ts"] })) { - if (entry.isFile) { - await patchFile(entry.path); - } -} - -console.log("Patching complete."); \ No newline at end of file diff --git a/server.ts b/server.ts index cafe62f6..1169256e 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,4 @@ -import { serve } from "https://deno.land/std@0.224.0/http/server.ts"; +import { serve } from "@std/http"; import { initializeWorkers } from "./src/workerPool.ts"; import { initializeCache } from "./src/playerCache.ts"; import { handleDecryptSignature } from "./src/handlers/decryptSignature.ts"; @@ -8,6 +8,7 @@ import { withMetrics } from "./src/middleware.ts"; import { withValidation } from "./src/validation.ts"; import { registry } from "./src/metrics.ts"; import type { ApiRequest, RequestContext } from "./src/types.ts"; +import { getTimestamp } from "./src/utils.ts"; const API_TOKEN = Deno.env.get("API_TOKEN"); @@ -28,7 +29,7 @@ async function baseHandler(req: Request): Promise { { status: 404, headers: { "Content-Type": "text/plain" }, - } + }, ); } } @@ -48,7 +49,7 @@ async function baseHandler(req: Request): Promise { } } - if (pathname === '/metrics') { + if (pathname === "/metrics") { return new Response(registry.metrics(), { headers: { "Content-Type": "text/plain" }, }); @@ -57,28 +58,39 @@ async function baseHandler(req: Request): Promise { const authHeader = req.headers.get("authorization"); if (API_TOKEN && API_TOKEN !== "") { if (authHeader !== API_TOKEN) { - const error = authHeader ? 'Invalid API token' : 'Missing API token'; - return new Response(JSON.stringify({ error }), { status: 401, headers: { "Content-Type": "application/json" } }); + const error = authHeader + ? "Invalid API token" + : "Missing API token"; + return new Response(JSON.stringify({ error }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); } } let handle: (ctx: RequestContext) => Promise; - if (pathname === '/decrypt_signature') { + if (pathname === "/decrypt_signature") { handle = handleDecryptSignature; - } else if (pathname === '/get_sts') { + } else if (pathname === "/get_sts") { handle = handleGetSts; - } else if (pathname === '/resolve_url') { + } else if (pathname === "/resolve_url") { handle = handleResolveUrl; } else { - return new Response(JSON.stringify({ error: 'Not Found' }), { status: 404, headers: { "Content-Type": "application/json" } }); + return new Response(JSON.stringify({ error: "Not Found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); } let body; try { body = await req.json() as ApiRequest; } catch { - return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400, headers: { "Content-Type": "application/json" } }); + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } const ctx: RequestContext = { req, body }; @@ -88,11 +100,12 @@ async function baseHandler(req: Request): Promise { const handler = baseHandler; -const port = Deno.env.get("PORT") || 8001; -const host = Deno.env.get("HOST") || '0.0.0.0'; +const port = Deno.env.get("PORT") || "8001"; +const host = Deno.env.get("HOST") || "0.0.0.0"; await initializeCache(); initializeWorkers(); -console.log(`Server listening on http://${host}:${port}`); -await serve(handler, { port: Number(port), hostname: host }); \ No newline at end of file +console.log(`[${getTimestamp()}] Server listening on http://${host}:${port}`); + +await serve(handler, { port: Number(port), hostname: host }); diff --git a/src/handlers/decryptSignature.ts b/src/handlers/decryptSignature.ts index d51ce8c6..ea33ceb8 100644 --- a/src/handlers/decryptSignature.ts +++ b/src/handlers/decryptSignature.ts @@ -1,21 +1,33 @@ import { getSolvers } from "../solver.ts"; -import type { RequestContext, SignatureRequest, SignatureResponse } from "../types.ts"; +import type { + RequestContext, + SignatureRequest, + SignatureResponse, +} from "../types.ts"; -export async function handleDecryptSignature(ctx: RequestContext): Promise { - const { encrypted_signature, n_param, player_url } = ctx.body as SignatureRequest; +export async function handleDecryptSignature( + ctx: RequestContext, +): Promise { + const { encrypted_signature, n_param, player_url } = ctx + .body as SignatureRequest; const solvers = await getSolvers(player_url); if (!solvers) { - return new Response(JSON.stringify({ error: "Failed to generate solvers from player script" }), { status: 500, headers: { "Content-Type": "application/json" } }); + return new Response( + JSON.stringify({ + error: "Failed to generate solvers from player script", + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); } - let decrypted_signature = ''; + let decrypted_signature = ""; if (encrypted_signature && solvers.sig) { decrypted_signature = solvers.sig(encrypted_signature); } - let decrypted_n_sig = ''; + let decrypted_n_sig = ""; if (n_param && solvers.n) { decrypted_n_sig = solvers.n(n_param); } @@ -25,5 +37,8 @@ export async function handleDecryptSignature(ctx: RequestContext): Promise { const response: StsResponse = { sts: cachedSts }; return new Response(JSON.stringify(response), { status: 200, - headers: { "Content-Type": "application/json", "X-Cache-Hit": "true" }, + headers: { + "Content-Type": "application/json", + "X-Cache-Hit": "true", + }, }); } @@ -26,12 +29,18 @@ export async function handleGetSts(ctx: RequestContext): Promise { const response: StsResponse = { sts }; return new Response(JSON.stringify(response), { status: 200, - headers: { "Content-Type": "application/json", "X-Cache-Hit": "false" }, + headers: { + "Content-Type": "application/json", + "X-Cache-Hit": "false", + }, }); } else { - return new Response(JSON.stringify({ error: "Timestamp not found in player script" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Timestamp not found in player script" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } -} \ No newline at end of file +} diff --git a/src/handlers/resolveUrl.ts b/src/handlers/resolveUrl.ts index 8f17a30b..7bd32d5e 100644 --- a/src/handlers/resolveUrl.ts +++ b/src/handlers/resolveUrl.ts @@ -1,23 +1,46 @@ import { getSolvers } from "../solver.ts"; -import type { RequestContext, ResolveUrlRequest, ResolveUrlResponse } from "../types.ts"; +import type { + RequestContext, + ResolveUrlRequest, + ResolveUrlResponse, +} from "../types.ts"; export async function handleResolveUrl(ctx: RequestContext): Promise { - const { stream_url, player_url, encrypted_signature, signature_key, n_param: nParamFromRequest } = ctx.body as ResolveUrlRequest; + const { + stream_url, + player_url, + encrypted_signature, + signature_key, + n_param: nParamFromRequest, + } = ctx.body as ResolveUrlRequest; const solvers = await getSolvers(player_url); if (!solvers) { - return new Response(JSON.stringify({ error: "Failed to generate solvers from player script" }), { status: 500, headers: { "Content-Type": "application/json" } }); + return new Response( + JSON.stringify({ + error: "Failed to generate solvers from player script", + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); } const url = new URL(stream_url); if (encrypted_signature) { if (!solvers.sig) { - return new Response(JSON.stringify({ error: "No signature solver found for this player" }), { status: 500, headers: { "Content-Type": "application/json" } }); + return new Response( + JSON.stringify({ + error: "No signature solver found for this player", + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); } const decryptedSig = solvers.sig(encrypted_signature); - const sigKey = signature_key || 'sig'; + const sigKey = signature_key || "sig"; url.searchParams.set(sigKey, decryptedSig); url.searchParams.delete("s"); } @@ -29,15 +52,26 @@ export async function handleResolveUrl(ctx: RequestContext): Promise { if (solvers.n) { if (!nParam) { - return new Response(JSON.stringify({ error: "n_param not found in request or stream_url" }), { status: 400, headers: { "Content-Type": "application/json" } }); + return new Response( + JSON.stringify({ + error: "n_param not found in request or stream_url", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const decryptedN = solvers.n(nParam); url.searchParams.set("n", decryptedN); } - + const response: ResolveUrlResponse = { resolved_url: url.toString(), }; - return new Response(JSON.stringify(response), { status: 200, headers: { "Content-Type": "application/json" } }); -} \ No newline at end of file + return new Response(JSON.stringify(response), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/instrumentedCache.ts b/src/instrumentedCache.ts index d91a7cff..2f950fcd 100644 --- a/src/instrumentedCache.ts +++ b/src/instrumentedCache.ts @@ -1,5 +1,5 @@ import { cacheSize } from "./metrics.ts"; -import { LruCache } from "jsr:@std/cache"; +import { LruCache } from "@std/cache"; export class InstrumentedLRU extends LruCache { constructor(private cacheName: string, maxSize: number) { diff --git a/src/metrics.ts b/src/metrics.ts index d5537e9b..3c850703 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -1,9 +1,4 @@ -import { - Counter, - Gauge, - Histogram, - Registry, -} from "https://deno.land/x/ts_prometheus/mod.ts"; +import { Counter, Gauge, Histogram, Registry } from "ts_prometheus/mod.ts"; export const registry = new Registry(); @@ -20,7 +15,14 @@ export const endpointHits = Counter.with({ export const responseCodes = Counter.with({ name: "http_responses_total", help: "Total number of HTTP responses.", - labels: ["method", "pathname", "status", "player_id", "plugin_version", "user_agent"], + labels: [ + "method", + "pathname", + "status", + "player_id", + "plugin_version", + "user_agent", + ], registry: [registry], }); @@ -58,4 +60,4 @@ export const playerScriptFetches = Counter.with({ help: "Total number of player script fetches.", labels: ["player_url", "status"], registry: [registry], -}); \ No newline at end of file +}); diff --git a/src/middleware.ts b/src/middleware.ts index 11b3ad6a..2ad421de 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,5 @@ import { extractPlayerId } from "./utils.ts"; -import { endpointHits, responseCodes, endpointLatency } from "./metrics.ts"; +import { endpointHits, endpointLatency, responseCodes } from "./metrics.ts"; import type { RequestContext } from "./types.ts"; type Next = (ctx: RequestContext) => Promise; @@ -8,10 +8,17 @@ export function withMetrics(handler: Next): Next { return async (ctx: RequestContext) => { const { pathname } = new URL(ctx.req.url); const playerId = extractPlayerId(ctx.body.player_url); - const pluginVersion = ctx.req.headers.get("Plugin-Version") ?? "unknown"; + const pluginVersion = ctx.req.headers.get("Plugin-Version") ?? + "unknown"; const userAgent = ctx.req.headers.get("User-Agent") ?? "unknown"; - endpointHits.labels({ method: ctx.req.method, pathname, player_id: playerId, plugin_version: pluginVersion, user_agent: userAgent }).inc(); + endpointHits.labels({ + method: ctx.req.method, + pathname, + player_id: playerId, + plugin_version: pluginVersion, + user_agent: userAgent, + }).inc(); const start = performance.now(); let response: Response; @@ -19,13 +26,30 @@ export function withMetrics(handler: Next): Next { response = await handler(ctx); } catch (e) { const message = e instanceof Error ? e.message : String(e); - response = new Response(JSON.stringify({ error: message }), { status: 500, headers: { "Content-Type": "application/json" } }); + response = new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); } const duration = (performance.now() - start) / 1000; - const cached = response.headers.get("X-Cache-Hit") === "true" ? "true" : "false"; - endpointLatency.labels({ method: ctx.req.method, pathname, player_id: playerId, cached}).observe(duration); - responseCodes.labels({ method: ctx.req.method, pathname, status: String(response.status), player_id: playerId, plugin_version: pluginVersion, user_agent: userAgent }).inc(); + const cached = response.headers.get("X-Cache-Hit") === "true" + ? "true" + : "false"; + endpointLatency.labels({ + method: ctx.req.method, + pathname, + player_id: playerId, + cached, + }).observe(duration); + responseCodes.labels({ + method: ctx.req.method, + pathname, + status: String(response.status), + player_id: playerId, + plugin_version: pluginVersion, + user_agent: userAgent, + }).inc(); return response; }; diff --git a/src/playerCache.ts b/src/playerCache.ts index 994858ef..edf04201 100644 --- a/src/playerCache.ts +++ b/src/playerCache.ts @@ -1,25 +1,69 @@ -import { crypto } from "https://deno.land/std@0.224.0/crypto/mod.ts"; -import { ensureDir } from "https://deno.land/std@0.224.0/fs/ensure_dir.ts"; -import { join } from "https://deno.land/std@0.224.0/path/mod.ts"; +import { ensureDir } from "@std/fs"; +import { join } from "@std/path"; import { cacheSize, playerScriptFetches } from "./metrics.ts"; -import { extractPlayerId } from "./utils.ts"; +import { digestPlayerUrl, extractPlayerId, getTimestamp } from "./utils.ts"; -const ignorePlayerScriptRegion = Deno.env.get("IGNORE_SCRIPT_REGION") === "true"; +const inFlightPlayerFetches = new Map>(); +const ignorePlayerScriptRegion = ["1", "true", "yes", "on"].includes( + (Deno.env.get("IGNORE_SCRIPT_REGION") ?? "").trim().toLowerCase(), +); -export const CACHE_HOME = Deno.env.get("XDG_CACHE_HOME") || join(Deno.env.get("HOME"), '.cache'); -export const CACHE_DIR = join(CACHE_HOME, 'yt-cipher', 'player_cache'); +function getCachePrefix(): string { + // Windows + if (Deno.build.os === "windows") { + const localAppData = Deno.env.get("LOCALAPPDATA"); + const userProfile = Deno.env.get("USERPROFILE"); + const TEMP = Deno.env.get("TEMP"); + const TMP = Deno.env.get("TMP"); + + if (localAppData) return join(localAppData, "yt-cipher"); + if (userProfile) { + return join(userProfile, "AppData", "Local", "yt-cipher"); + } + if (TEMP) return join(TEMP, "yt-cipher"); + if (TMP) return join(TMP, "yt-cipher"); + + // Last-resort fallback to avoid hard crash. + return join(Deno.cwd(), "yt-cipher"); + } + + // XDG standard (Linux, optional on others) + const XDG_CACHE_HOME = Deno.env.get("XDG_CACHE_HOME"); + + // macOS / Linux fallback + const HOME = Deno.env.get("HOME"); + + if (XDG_CACHE_HOME) { + return join(XDG_CACHE_HOME, "yt-cipher"); + } else if (HOME) { + return join(HOME, ".cache", "yt-cipher"); + } + + return Deno.cwd(); +} + +export const CACHE_DIR = join(getCachePrefix(), "player_cache"); export async function getPlayerFilePath(playerUrl: string): Promise { let cacheKey: string; if (ignorePlayerScriptRegion) { // I have not seen any scripts that differ between regions so this should be safe - cacheKey = extractPlayerId(playerUrl); + const playerId = extractPlayerId(playerUrl); + // If we can't reliably extract an id, fall back to hashing the full URL to avoid cache key collisions. + if (playerId === "unknown") { + cacheKey = await digestPlayerUrl(playerUrl); + } else { + cacheKey = playerId.replace(/[^a-zA-Z0-9_-]/g, "_"); + // Ensure that the file name is below the 128 character limit + if (cacheKey.length > 120) { + cacheKey = await digestPlayerUrl(playerUrl); + } + } } else { // This hash of the player script url will mean that diff region scripts are treated as unequals, even for the same version # // I dont think I have ever seen 2 scripts of the same version differ between regions but if they ever do this will catch it // As far as player script access, I haven't ever heard about YT ratelimiting those either so ehh - const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(playerUrl)); - cacheKey = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); + cacheKey = await digestPlayerUrl(playerUrl); } const filePath = join(CACHE_DIR, `${cacheKey}.js`); @@ -29,27 +73,82 @@ export async function getPlayerFilePath(playerUrl: string): Promise { await Deno.utime(filePath, new Date(), stat.mtime ?? new Date()); return filePath; } catch (error) { - if (error instanceof Deno.errors.NotFound) { - console.log(`Cache miss for player: ${playerUrl}. Fetching...`); - const response = await fetch(playerUrl); - playerScriptFetches.labels({ player_url: playerUrl, status: response.statusText }).inc(); + if (!(error instanceof Deno.errors.NotFound)) throw error; + + const existing = inFlightPlayerFetches.get(filePath); + if (existing) { + try { + return await existing; + } catch (err) { + console.warn( + `[${getTimestamp()}] Previous fetch failed for player: ${playerUrl} (${filePath}); retrying...`, + err, + ); + + // Allow a retry if the shared fetch failed. + inFlightPlayerFetches.delete(filePath); + } + } + + const fetchPromise = (async () => { + console.log( + `[${getTimestamp()}] Cache miss for player: ${playerUrl}. Fetching...`, + ); + const response = await fetch(playerUrl, { + signal: AbortSignal.timeout(60_000), + }); + playerScriptFetches.labels({ + player_url: playerUrl, + status: response.statusText, + }).inc(); if (!response.ok) { - throw new Error(`Failed to fetch player from ${playerUrl}: ${response.statusText}`); + throw new Error( + `Failed to fetch player from ${playerUrl}: ${response.statusText}`, + ); } const playerContent = await response.text(); - await Deno.writeTextFile(filePath, playerContent); + + // Ensure cache dir still exists (it may be deleted between startup and a request). + await ensureDir(CACHE_DIR); + + // use a temporary directory to allow atomic file updates + const tempDirPath = await Deno.makeTempDir({ dir: CACHE_DIR }); + const tempFilePath = join(tempDirPath, "file.js"); + try { + await Deno.writeTextFile(tempFilePath, playerContent); + + // Remove anything that might be there now. + await Deno.remove(filePath, { recursive: true }).catch( + () => {}, + ); + // Now this rename either succeeds or fails. + await Deno.rename(tempFilePath, filePath); + } finally { + await Deno.remove(tempDirPath, { recursive: true }).catch( + () => {}, + ); + } // Update cache size for metrics let fileCount = 0; for await (const _ of Deno.readDir(CACHE_DIR)) { fileCount++; } - cacheSize.labels({ cache_name: 'player' }).set(fileCount); - - console.log(`Saved player to cache: ${filePath}`); + cacheSize.labels({ cache_name: "player" }).set(fileCount); + + console.log( + `[${getTimestamp()}] Saved player to cache: ${filePath}`, + ); return filePath; + })(); + + inFlightPlayerFetches.set(filePath, fetchPromise); + try { + return await fetchPromise; + } finally { + // ensure map doesn’t leak entries on success/failure + inFlightPlayerFetches.delete(filePath); } - throw error; } } @@ -58,21 +157,36 @@ export async function initializeCache() { // Since these accumulate over time just cleanout 14 day unused ones let fileCount = 0; - const thirtyDays = 14 * 24 * 60 * 60 * 1000; - console.log(`Cleaning up player cache directory: ${CACHE_DIR}`); + const twoWeeks = 14 * 24 * 60 * 60 * 1000; + console.log( + `[${getTimestamp()}] Cleaning up player cache directory: ${CACHE_DIR}`, + ); for await (const dirEntry of Deno.readDir(CACHE_DIR)) { - if (dirEntry.isFile) { - const filePath = join(CACHE_DIR, dirEntry.name); + if (!dirEntry.isFile) continue; + const filePath = join(CACHE_DIR, dirEntry.name); + try { const stat = await Deno.stat(filePath); - const lastAccessed = stat.atime?.getTime() ?? stat.mtime?.getTime() ?? stat.birthtime?.getTime(); - if (lastAccessed && (Date.now() - lastAccessed > thirtyDays)) { - console.log(`Deleting stale player cache file: ${filePath}`); + const lastAccessed = stat.atime?.getTime() ?? + stat.mtime?.getTime() ?? stat.birthtime?.getTime(); + if (lastAccessed && (Date.now() - lastAccessed > twoWeeks)) { + console.log( + `[${getTimestamp()}] Deleting stale player cache file: ${filePath}`, + ); await Deno.remove(filePath); } else { fileCount++; } + } catch (err) { + // File may have disappeared or be unreadable; don't fail startup. + console.warn( + `[${getTimestamp()}] Skipping cache entry during cleanup: ${filePath}`, + err, + ); + continue; } } - cacheSize.labels({ cache_name: 'player' }).set(fileCount); - console.log(`Player cache directory ensured at: ${CACHE_DIR}`); + cacheSize.labels({ cache_name: "player" }).set(fileCount); + console.log( + `[${getTimestamp()}] Player cache directory ensured at: ${CACHE_DIR}`, + ); } diff --git a/src/preprocessedCache.ts b/src/preprocessedCache.ts index 45cbc400..c6c0b2bf 100644 --- a/src/preprocessedCache.ts +++ b/src/preprocessedCache.ts @@ -1,6 +1,9 @@ import { InstrumentedLRU } from "./instrumentedCache.ts"; // The key is the hash of the player URL, and the value is the preprocessed script content. -const cacheSizeEnv = Deno.env.get('PREPROCESSED_CACHE_SIZE'); +const cacheSizeEnv = Deno.env.get("PREPROCESSED_CACHE_SIZE"); const maxCacheSize = cacheSizeEnv ? parseInt(cacheSizeEnv, 10) : 150; -export const preprocessedCache = new InstrumentedLRU('preprocessed', maxCacheSize); \ No newline at end of file +export const preprocessedCache = new InstrumentedLRU( + "preprocessed", + maxCacheSize, +); diff --git a/src/solver.ts b/src/solver.ts index a87e99c7..6d5f8161 100644 --- a/src/solver.ts +++ b/src/solver.ts @@ -2,7 +2,7 @@ import { execInPool } from "./workerPool.ts"; import { getPlayerFilePath } from "./playerCache.ts"; import { preprocessedCache } from "./preprocessedCache.ts"; import { solverCache } from "./solverCache.ts"; -import { getFromPrepared } from "../ejs/src/yt/solver/solvers.ts"; +import { getFromPrepared } from "ejs/src/yt/solver/solvers.ts"; import type { Solvers } from "./types.ts"; import { workerErrors } from "./metrics.ts"; import { extractPlayerId } from "./utils.ts"; @@ -29,7 +29,7 @@ export async function getSolvers(player_url: string): Promise { } preprocessedCache.set(playerCacheKey, preprocessedPlayer); } - + solvers = getFromPrepared(preprocessedPlayer); if (solvers) { solverCache.set(playerCacheKey, solvers); @@ -37,4 +37,4 @@ export async function getSolvers(player_url: string): Promise { } return null; -} \ No newline at end of file +} diff --git a/src/solverCache.ts b/src/solverCache.ts index b74f5044..b23fef24 100644 --- a/src/solverCache.ts +++ b/src/solverCache.ts @@ -2,6 +2,6 @@ import { InstrumentedLRU } from "./instrumentedCache.ts"; import type { Solvers } from "./types.ts"; // key = hash of the player url -const cacheSizeEnv = Deno.env.get('SOLVER_CACHE_SIZE'); +const cacheSizeEnv = Deno.env.get("SOLVER_CACHE_SIZE"); const maxCacheSize = cacheSizeEnv ? parseInt(cacheSizeEnv, 10) : 50; -export const solverCache = new InstrumentedLRU('solver', maxCacheSize); \ No newline at end of file +export const solverCache = new InstrumentedLRU("solver", maxCacheSize); diff --git a/src/stsCache.ts b/src/stsCache.ts index 43aea5d5..6ae804dd 100644 --- a/src/stsCache.ts +++ b/src/stsCache.ts @@ -1,6 +1,6 @@ import { InstrumentedLRU } from "./instrumentedCache.ts"; // key = hash of player URL -const cacheSizeEnv = Deno.env.get('STS_CACHE_SIZE'); +const cacheSizeEnv = Deno.env.get("STS_CACHE_SIZE"); const maxCacheSize = cacheSizeEnv ? parseInt(cacheSizeEnv, 10) : 150; -export const stsCache = new InstrumentedLRU('sts', maxCacheSize); \ No newline at end of file +export const stsCache = new InstrumentedLRU("sts", maxCacheSize); diff --git a/src/types.ts b/src/types.ts index 1f8c5e93..2919752b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,3 @@ -import type { Input as MainInput, Output as MainOutput } from "../ejs/src/yt/solver/main.ts"; - export interface Solvers { n: ((val: string) => string) | null; sig: ((val: string) => string) | null; @@ -43,14 +41,15 @@ export interface WorkerWithStatus extends Worker { export interface Task { data: string; resolve: (output: string) => void; + // deno-lint-ignore no-explicit-any reject: (error: any) => void; } export type ApiRequest = SignatureRequest | StsRequest | ResolveUrlRequest; // Parsing into this context helps avoid multi copies of requests -// since request body can only be read once. +// since request body can only be read once. export interface RequestContext { req: Request; body: ApiRequest; -} \ No newline at end of file +} diff --git a/src/utils.ts b/src/utils.ts index 43405f1c..e5ab5973 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,10 +1,12 @@ +import { crypto } from "@std/crypto"; + const ALLOWED_HOSTNAMES = ["youtube.com", "www.youtube.com", "m.youtube.com"]; export function validateAndNormalizePlayerUrl(playerUrl: string): string { // Handle relative paths - if (playerUrl.startsWith('/')) { - if (playerUrl.startsWith('/s/player/')) { - return `https://www.youtube.com${playerUrl}`; + if (playerUrl.startsWith("/")) { + if (playerUrl.startsWith("/s/player/")) { + return `https://www.youtube.com${playerUrl}`; } throw new Error(`Invalid player path: ${playerUrl}`); } @@ -17,7 +19,7 @@ export function validateAndNormalizePlayerUrl(playerUrl: string): string { } else { throw new Error(`Player URL from invalid host: ${url.hostname}`); } - } catch (e) { + } catch (_e) { // Not a valid URL, and not a valid path. throw new Error(`Invalid player URL: ${playerUrl}`); } @@ -25,17 +27,31 @@ export function validateAndNormalizePlayerUrl(playerUrl: string): string { export function extractPlayerId(playerUrl: string): string { try { const url = new URL(playerUrl); - const pathParts = url.pathname.split('/'); - const playerIndex = pathParts.indexOf('player'); + const pathParts = url.pathname.split("/"); + const playerIndex = pathParts.indexOf("player"); if (playerIndex !== -1 && playerIndex + 1 < pathParts.length) { return pathParts[playerIndex + 1]; } - } catch (e) { + } catch (_e) { // Fallback for relative paths const match = playerUrl.match(/\/s\/player\/([^\/]+)/); if (match) { return match[1]; } } - return 'unknown'; -} \ No newline at end of file + return "unknown"; +} + +export async function digestPlayerUrl(playerUrl: string): Promise { + const hashBuffer = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(playerUrl), + ); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export function getTimestamp() { + return new Date().toISOString().slice(5, 19).replace("T", " "); +} diff --git a/src/validation.ts b/src/validation.ts index 097f0a59..934ba4b0 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -3,26 +3,34 @@ import { validateAndNormalizePlayerUrl } from "./utils.ts"; type Next = (ctx: RequestContext) => Promise; type ValidationSchema = { + // deno-lint-ignore no-explicit-any [key: string]: (value: any) => boolean; }; const signatureRequestSchema: ValidationSchema = { - player_url: (val) => typeof val === 'string', + player_url: (val) => typeof val === "string", }; const stsRequestSchema: ValidationSchema = { - player_url: (val) => typeof val === 'string', + player_url: (val) => typeof val === "string", }; const resolveUrlRequestSchema: ValidationSchema = { - player_url: (val) => typeof val === 'string', - stream_url: (val) => typeof val === 'string', + player_url: (val) => typeof val === "string", + stream_url: (val) => typeof val === "string", }; -function validateObject(obj: any, schema: ValidationSchema): { isValid: boolean, errors: string[] } { +function validateObject( + // deno-lint-ignore no-explicit-any + obj: any, + schema: ValidationSchema, +): { isValid: boolean; errors: string[] } { const errors: string[] = []; for (const key in schema) { - if (!obj.hasOwnProperty(key) || !schema[key](obj[key])) { + if ( + !Object.prototype.hasOwnProperty.call(obj, key) || + !schema[key](obj[key]) + ) { errors.push(`'${key}' is missing or invalid`); } } @@ -30,42 +38,53 @@ function validateObject(obj: any, schema: ValidationSchema): { isValid: boolean, } export function withValidation(handler: Next): Next { + // deno-lint-ignore require-await return async (ctx: RequestContext) => { const { pathname } = new URL(ctx.req.url); let schema: ValidationSchema; - if (pathname === '/decrypt_signature') { + if (pathname === "/decrypt_signature") { schema = signatureRequestSchema; - } else if (pathname === '/get_sts') { + } else if (pathname === "/get_sts") { schema = stsRequestSchema; - } else if (pathname === '/resolve_url') { + } else if (pathname === "/resolve_url") { schema = resolveUrlRequestSchema; } else { return handler(ctx); } - + const body = ctx.body as ApiRequest; const { isValid, errors } = validateObject(body, schema); if (!isValid) { - return new Response(JSON.stringify({ error: `Invalid request body: ${errors.join(', ')}` }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ + error: `Invalid request body: ${errors.join(", ")}`, + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } - + try { - const normalizedUrl = validateAndNormalizePlayerUrl(body.player_url); + const normalizedUrl = validateAndNormalizePlayerUrl( + body.player_url, + ); // mutate the context with the normalized URL ctx.body.player_url = normalizedUrl; } catch (e) { - return new Response(JSON.stringify({ error: (e as Error).message }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: (e as Error).message }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } return handler(ctx); }; -} \ No newline at end of file +} diff --git a/src/workerPool.ts b/src/workerPool.ts index 79b6a9b2..77c9bf4b 100644 --- a/src/workerPool.ts +++ b/src/workerPool.ts @@ -1,12 +1,14 @@ -import type { WorkerWithStatus, Task } from "./types.ts"; +import type { Task, WorkerWithStatus } from "./types.ts"; +import { getTimestamp } from "./utils.ts"; -const CONCURRENCY = parseInt(Deno.env.get("MAX_THREADS") || "", 10) || navigator.hardwareConcurrency || 1; +const CONCURRENCY = parseInt(Deno.env.get("MAX_THREADS") || "", 10) || + navigator.hardwareConcurrency || 1; const workers: WorkerWithStatus[] = []; const taskQueue: Task[] = []; function dispatch() { - const idleWorker = workers.find(w => w.isIdle); + const idleWorker = workers.find((w) => w.isIdle); if (!idleWorker || taskQueue.length === 0) { return; } @@ -19,7 +21,7 @@ function dispatch() { idleWorker.isIdle = true; const { type, data } = e.data; - if (type === 'success') { + if (type === "success") { task.resolve(data); } else { console.error("Received error from worker:", data); @@ -43,9 +45,12 @@ export function execInPool(data: string): Promise { export function initializeWorkers() { for (let i = 0; i < CONCURRENCY; i++) { - const worker: WorkerWithStatus = new Worker(new URL("../worker.ts", import.meta.url).href, { type: "module" }); + const worker: WorkerWithStatus = new Worker( + new URL("../worker.ts", import.meta.url).href, + { type: "module" }, + ); worker.isIdle = true; workers.push(worker); } - console.log(`Initialized ${CONCURRENCY} workers`); -} \ No newline at end of file + console.log(`[${getTimestamp()}] Initialized ${CONCURRENCY} workers`); +} diff --git a/worker.ts b/worker.ts index 7448192c..e541684a 100644 --- a/worker.ts +++ b/worker.ts @@ -1,15 +1,15 @@ -import { preprocessPlayer } from "./ejs/src/yt/solver/solvers.ts"; +import { preprocessPlayer } from "ejs/src/yt/solver/solvers.ts"; self.onmessage = (e: MessageEvent) => { try { const output = preprocessPlayer(e.data); - self.postMessage({ type: 'success', data: output }); + self.postMessage({ type: "success", data: output }); } catch (error) { self.postMessage({ - type: 'error', + type: "error", data: { message: error, - } + }, }); } -}; \ No newline at end of file +};