From 547f2b8a6d44b23c30d829f9f80815dcf9079090 Mon Sep 17 00:00:00 2001 From: valeboth <109695782+valeboth@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:58:49 +0300 Subject: [PATCH] feat: PIN-gate Overseerr requests - POST /rooms/:id/request now requires a shared REQUEST_PIN (the app itself is public): no PIN configured -> requests_disabled; wrong PIN -> invalid_pin; else request proceeds - frontend: Add to Overseerr prompts for the PIN once, stores it locally, re-prompts on invalid_pin - Env + .dev.vars.example: REQUEST_PIN Tested local: unset -> requests_disabled (403, closed by default). --- .dev.vars.example | 2 ++ public/app.js | 19 +++++++++++++++++-- src/routes/rooms.ts | 6 ++++++ src/types.ts | 2 ++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.dev.vars.example b/.dev.vars.example index e031814..0476e19 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -15,3 +15,5 @@ OVERSEERR_API_KEY="" # Only if the Overseerr hostname is behind Cloudflare Access (Zero Trust) — service token. CF_ACCESS_CLIENT_ID="" CF_ACCESS_CLIENT_SECRET="" +# Shared PIN required to request in Overseerr (the app is public). Unset = requests disabled. +REQUEST_PIN="" diff --git a/public/app.js b/public/app.js index 3cd1549..6a3f23a 100644 --- a/public/app.js +++ b/public/app.js @@ -628,16 +628,31 @@ function showMatch(card, reason) { async function addToOverseerr() { if (!currentMatchCard) return; + let pin = localStorage.getItem("cinemate_request_pin"); + if (!pin) { + pin = (window.prompt("Request PIN") || "").trim(); + if (!pin) return; + localStorage.setItem("cinemate_request_pin", pin); + } const btn = $("add-overseerr-btn"); btn.disabled = true; try { await api(`/api/rooms/${state.room.id}/request`, { method: "POST", - body: JSON.stringify({ user_id: state.userId, tmdb_id: currentMatchCard.tmdb_id }), + body: JSON.stringify({ user_id: state.userId, tmdb_id: currentMatchCard.tmdb_id, pin }), }); toast("✅ Requested in Overseerr"); } catch (e) { - toast(e.message === "not_configured" ? "Overseerr not set up" : "Overseerr request failed"); + if (e.message === "invalid_pin") { + localStorage.removeItem("cinemate_request_pin"); + toast("Wrong PIN — try again"); + } else if (e.message === "requests_disabled") { + toast("Requests are disabled"); + } else if (e.message === "not_configured") { + toast("Overseerr not set up"); + } else { + toast("Overseerr request failed"); + } } finally { btn.disabled = false; } diff --git a/src/routes/rooms.ts b/src/routes/rooms.ts index 6294697..67d6d8c 100644 --- a/src/routes/rooms.ts +++ b/src/routes/rooms.ts @@ -414,6 +414,12 @@ rooms.post("/:id/request", async (c) => { return c.json({ error: "forbidden" }, 403); } + // Overseerr requests are gated by a shared PIN (the app itself is public). + // If no PIN is configured, requests are disabled (closed by default). + if (!c.env.REQUEST_PIN) return c.json({ error: "requests_disabled" }, 403); + const pin = typeof body?.pin === "string" ? body.pin : ""; + if (pin !== c.env.REQUEST_PIN) return c.json({ error: "invalid_pin" }, 403); + const result = await createRequest(c.env, room.media_type, tmdbId); if (!result.ok) return c.json({ error: result.error ?? "request_failed" }, 502); return c.json({ ok: true, tmdb_id: tmdbId }, 200); diff --git a/src/types.ts b/src/types.ts index 0378ea4..2aecd1b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,8 @@ export interface Env { /** Secret — Cloudflare Access service token (optional; if the Overseerr hostname is behind Access). */ CF_ACCESS_CLIENT_ID: string; CF_ACCESS_CLIENT_SECRET: string; + /** Secret — shared PIN required to request in Overseerr. If unset, requests are disabled. */ + REQUEST_PIN: string; } export type MediaType = "movie" | "tv";