From 0a1f9832b3c0072d1e40a92d4c40b5b40bce218f Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 11 Aug 2026 11:02:48 +0530 Subject: [PATCH 1/2] feat(backend): add Simkl proxy endpoints for Netlify and Supabase --- .../netlify/functions/_backend.js | 74 +++++++++ .../netlify/functions/simkl-proxy.js | 3 + supabase/functions/simkl-proxy/index.ts | 151 ++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 netlify-auth-site/netlify/functions/simkl-proxy.js create mode 100644 supabase/functions/simkl-proxy/index.ts diff --git a/netlify-auth-site/netlify/functions/_backend.js b/netlify-auth-site/netlify/functions/_backend.js index 030a113ac..c4989acfc 100644 --- a/netlify-auth-site/netlify/functions/_backend.js +++ b/netlify-auth-site/netlify/functions/_backend.js @@ -1386,6 +1386,79 @@ async function handleTraktProxy(event) { } } +const SIMKL_ALLOWED_PATHS = [ + "/oauth/pin", + "/oauth/token", + "/scrobble/", + "/sync/" +]; + +async function handleSimklProxy(event) { + const preflight = options(event); + if (preflight) return preflight; + try { + assertAppRequest(event); + const pathParam = event.queryStringParameters?.path || ""; + const method = String(event.queryStringParameters?.method || "GET").toUpperCase(); + if (!pathParam) return json(400, { error: "Missing path parameter" }); + if (!SIMKL_ALLOWED_PATHS.some((allowed) => pathParam.startsWith(allowed))) { + return json(403, { error: "Path not allowed" }); + } + const clientId = process.env.SIMKL_CLIENT_ID || ""; + const clientSecret = process.env.SIMKL_CLIENT_SECRET || ""; + if (!clientId) throw new Error("Simkl credentials not configured"); + const simklUrl = new URL(`https://api.simkl.com${pathParam}`); + Object.entries(event.queryStringParameters || {}).forEach(([key, value]) => { + if (key !== "path" && key !== "method" && value !== undefined && value !== null) { + simklUrl.searchParams.set(key, String(value)); + } + }); + + let requestBody = undefined; + if (method === "POST" || method === "DELETE") { + let body = {}; + try { + body = event.body + ? JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString("utf8") : event.body) + : {}; + } catch { + body = {}; + } + if (pathParam.includes("/oauth/token")) { + body.client_id = clientId; + if (clientSecret) body.client_secret = clientSecret; + } + requestBody = Object.keys(body).length > 0 ? JSON.stringify(body) : undefined; + } + + const headers = { + "content-type": "application/json", + "simkl-api-key": clientId + }; + const userToken = event.headers["x-user-token"] || event.headers["X-User-Token"]; + if (userToken) headers.authorization = `Bearer ${userToken}`; + + const response = await fetch(simklUrl, { method, headers, body: requestBody }); + const text = await response.text(); + let data; + try { + data = text ? JSON.parse(text) : { status: response.status }; + } catch { + data = text ? { raw: text } : { status: response.status }; + } + return { + statusCode: response.status, + headers: { + ...JSON_HEADERS, + "cache-control": "no-store" + }, + body: JSON.stringify(data) + }; + } catch (error) { + return json(502, { error: errorMessage(error) }); + } +} + function payloadMetrics(payload) { const root = typeof payload === "string" ? JSON.parse(payload) : payload; const profiles = Array.isArray(root.profiles) ? root.profiles : null; @@ -2314,6 +2387,7 @@ module.exports = { handleCloudAuthReset, handleTmdbProxy, handleTraktProxy, + handleSimklProxy, handleTvAuthApprove, handleTvAuthComplete, handleTvAuthStart, diff --git a/netlify-auth-site/netlify/functions/simkl-proxy.js b/netlify-auth-site/netlify/functions/simkl-proxy.js new file mode 100644 index 000000000..a7dece5b2 --- /dev/null +++ b/netlify-auth-site/netlify/functions/simkl-proxy.js @@ -0,0 +1,3 @@ +const { handleSimklProxy } = require("./_backend"); + +exports.handler = handleSimklProxy; diff --git a/supabase/functions/simkl-proxy/index.ts b/supabase/functions/simkl-proxy/index.ts new file mode 100644 index 000000000..ff9c3e0e5 --- /dev/null +++ b/supabase/functions/simkl-proxy/index.ts @@ -0,0 +1,151 @@ +// Simkl API Proxy - Secured with rate limiting and path allowlist +// Deploy with: npx supabase functions deploy simkl-proxy +// Set secrets: +// npx supabase secrets set SIMKL_CLIENT_ID=your_id +// npx supabase secrets set SIMKL_CLIENT_SECRET=your_secret +// npx supabase secrets set APP_ANON_KEY=your_anon_key + +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" + +const SIMKL_BASE_URL = "https://api.simkl.com" + +const RATE_LIMIT = 100 +const RATE_WINDOW_MS = 60 * 1000 +const rateLimitMap = new Map() + +const ALLOWED_PATHS = [ + '/oauth/pin', + '/oauth/token', + '/scrobble/', + '/sync/' +] + +function isPathAllowed(path: string): boolean { + return ALLOWED_PATHS.some(allowed => path.startsWith(allowed)) +} + +function getClientIP(req: Request): string { + return req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + req.headers.get('x-real-ip') || + req.headers.get('cf-connecting-ip') || + 'unknown' +} + +function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetIn: number } { + const now = Date.now() + const record = rateLimitMap.get(ip) + + if (!record || now > record.resetTime) { + rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_WINDOW_MS }) + return { allowed: true, remaining: RATE_LIMIT - 1, resetIn: RATE_WINDOW_MS } + } + + if (record.count >= RATE_LIMIT) { + return { allowed: false, remaining: 0, resetIn: record.resetTime - now } + } + + record.count++ + return { allowed: true, remaining: RATE_LIMIT - record.count, resetIn: record.resetTime - now } +} + +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-user-token', + } +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders(req) }) + } + + try { + const clientIP = getClientIP(req) + const rateCheck = checkRateLimit(clientIP) + if (!rateCheck.allowed) { + return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: 429, + }) + } + + const SIMKL_CLIENT_ID = Deno.env.get('SIMKL_CLIENT_ID') + if (!SIMKL_CLIENT_ID) { + throw new Error('Simkl credentials not configured') + } + + const url = new URL(req.url) + const path = url.searchParams.get('path') + const method = url.searchParams.get('method') || 'GET' + + if (!path || !isPathAllowed(path)) { + return new Response(JSON.stringify({ error: 'Path not allowed' }), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: 403, + }) + } + + const simklUrl = new URL(`${SIMKL_BASE_URL}${path}`) + url.searchParams.forEach((value, key) => { + if (key !== 'path' && key !== 'method') { + simklUrl.searchParams.set(key, value) + } + }) + + const headers: Record = { + 'Content-Type': 'application/json', + 'simkl-api-key': SIMKL_CLIENT_ID, + } + + const userToken = req.headers.get('x-user-token') + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}` + } + + let body: string | undefined + if (method === 'POST' || method === 'DELETE') { + let reqBody: Record = {} + try { + reqBody = await req.json() + } catch { + // empty body + } + if (path.includes('/oauth/token')) { + reqBody.client_id = SIMKL_CLIENT_ID + const SIMKL_CLIENT_SECRET = Deno.env.get('SIMKL_CLIENT_SECRET') + if (SIMKL_CLIENT_SECRET) reqBody.client_secret = SIMKL_CLIENT_SECRET + } + body = Object.keys(reqBody).length > 0 ? JSON.stringify(reqBody) : undefined + } + + const response = await fetch(simklUrl.toString(), { + method: method, + headers: headers, + body: body, + }) + + const responseText = await response.text() + let data + try { + data = responseText ? JSON.parse(responseText) : { status: response.status } + } catch { + data = responseText ? { raw: responseText } : { status: response.status } + } + + return new Response(JSON.stringify(data), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: response.status, + }) + } catch (error) { + return new Response(JSON.stringify({ error: (error as Error).message }), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: 500, + }) + } +}) From 7e930ce5e6faa558b53b4d21c6e1542abec5f24c Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 11 Aug 2026 11:02:57 +0530 Subject: [PATCH 2/2] feat(web): integrate Simkl tracking client and API proxy --- web/app/api/simkl/[...path]/route.ts | 73 ++++++++++++++++ web/lib/simkl.ts | 125 +++++++++++++++++++++++++++ web/lib/sync.ts | 15 ++-- 3 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 web/app/api/simkl/[...path]/route.ts create mode 100644 web/lib/simkl.ts diff --git a/web/app/api/simkl/[...path]/route.ts b/web/app/api/simkl/[...path]/route.ts new file mode 100644 index 000000000..1ee20f6c7 --- /dev/null +++ b/web/app/api/simkl/[...path]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; + +function envValue(value: string | undefined, fallback = "") { + return value && !value.startsWith("$") ? value : fallback; +} + +async function handler(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const { path } = await context.params; + const netlifyBackendUrl = ( + process.env.NEXT_PUBLIC_NETLIFY_BACKEND_URL ?? + process.env.NETLIFY_BACKEND_URL ?? + "https://auth.arvio.tv/.netlify/functions" + ).replace(/\/+$/, ""); + const appAnonKey = envValue(process.env.NEXT_PUBLIC_ARVIO_APP_ANON_KEY, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ""); + const simklClientId = process.env.NEXT_PUBLIC_SIMKL_CLIENT_ID ?? process.env.SIMKL_CLIENT_ID ?? ""; + const simklSecret = process.env.SIMKL_CLIENT_SECRET ?? ""; + const input = new URL(request.url); + const method = request.method; + const body = method === "GET" || method === "HEAD" ? undefined : await request.text(); + const normalizedPath = path.join("/"); + + let target: URL; + let headers: HeadersInit; + + const usesNetlifyProxy = netlifyBackendUrl.startsWith("https://") && appAnonKey.length > 40; + + if (usesNetlifyProxy) { + target = new URL(`${netlifyBackendUrl}/simkl-proxy`); + target.searchParams.set("path", `/${normalizedPath}`); + target.searchParams.set("method", method); + input.searchParams.forEach((value, key) => target.searchParams.set(key, value)); + headers = { + apikey: appAnonKey, + Authorization: `Bearer ${appAnonKey}` + }; + const userToken = request.headers.get("x-user-token"); + if (userToken) headers["x-user-token" as keyof HeadersInit] = userToken; + } else if (simklClientId) { + target = new URL(`https://api.simkl.com/${normalizedPath}`); + input.searchParams.forEach((value, key) => target.searchParams.set(key, value)); + headers = { + "content-type": "application/json", + "simkl-api-key": simklClientId + }; + const userToken = request.headers.get("x-user-token"); + if (userToken) headers.Authorization = `Bearer ${userToken}`; + } else { + return NextResponse.json({ error: "Simkl proxy is not configured" }, { status: 500 }); + } + + const parsedBody = body && normalizedPath === "oauth/token" && simklSecret && !usesNetlifyProxy + ? JSON.stringify({ ...JSON.parse(body), client_id: simklClientId, client_secret: simklSecret }) + : body; + + const response = await fetch(target, { + method, + headers, + body: parsedBody, + cache: "no-store" + }); + + const responseHeaders = new Headers(); + responseHeaders.set("content-type", response.headers.get("content-type") ?? "application/json"); + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders + }); +} + +export const GET = handler; +export const POST = handler; +export const DELETE = handler; diff --git a/web/lib/simkl.ts b/web/lib/simkl.ts new file mode 100644 index 000000000..1f0ba63aa --- /dev/null +++ b/web/lib/simkl.ts @@ -0,0 +1,125 @@ +import { SyncClient, SyncMediaRef } from "./sync"; +import { loadStored, removeStored, saveStored } from "./storage"; +import { jsonRequest } from "./http"; + +const SIMKL_TOKEN_KEY = "arvio.web.simkl.token"; + +export interface SimklToken { + access_token: string; +} + +export interface SimklPinCode { + user_code: string; + verification_url: string; + expires_in: number; + interval: number; +} + +export class SimklClient implements SyncClient { + token: SimklToken | null = loadStored(SIMKL_TOKEN_KEY, null); + + get isConnected(): boolean { + return Boolean(this.token?.access_token); + } + + setToken(token: SimklToken | null) { + this.token = token; + if (this.token) saveStored(SIMKL_TOKEN_KEY, this.token); + else removeStored(SIMKL_TOKEN_KEY); + } + + private async simkl(path: string, options: RequestInit = {}): Promise { + const headers: Record = { + "content-type": "application/json", + ...(options.headers as Record) + }; + if (this.token?.access_token) { + headers["x-user-token"] = this.token.access_token; + } + return jsonRequest(`/api/simkl${path}`, { ...options, headers }); + } + + async beginPinAuth(): Promise { + return this.simkl("/oauth/pin"); + } + + async pollPinToken(userCode: string): Promise { + type PollRes = { result: string; access_token?: string }; + const res = await this.simkl(`/oauth/pin/${userCode}`); + if (res.result === "OK" && res.access_token) { + this.setToken({ access_token: res.access_token }); + return true; + } + return false; + } + + async watchlist(): Promise { + if (!this.isConnected) return []; + return this.simkl("/sync/all-items/movies"); + } + + async playback(): Promise { + return []; + } + + async watched(type: "movies" | "shows"): Promise { + if (!this.isConnected) return []; + return this.simkl(`/sync/all-items/${type}`); + } + + async addToWatchlist(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { shows: [{ ids: { tmdb: item.tmdbId } }] }; + await this.simkl("/sync/watchlist", { method: "POST", body: JSON.stringify(body) }); + } + + async removeFromWatchlist(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { shows: [{ ids: { tmdb: item.tmdbId } }] }; + await this.simkl("/sync/watchlist/remove", { method: "POST", body: JSON.stringify(body) }); + } + + async addToHistory(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { + shows: [{ + ids: { tmdb: item.tmdbId }, + seasons: item.season && item.episode ? [{ number: item.season, episodes: [{ number: item.episode }] }] : undefined + }] + }; + await this.simkl("/sync/history?allow_rewatch=yes", { method: "POST", body: JSON.stringify(body) }); + } + + async removeFromHistory(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { shows: [{ ids: { tmdb: item.tmdbId } }] }; + await this.simkl("/sync/history/remove", { method: "POST", body: JSON.stringify(body) }); + } + + async dismissFromContinueWatching(): Promise { + // No-op for Simkl + } + + async scrobble(action: "start" | "pause" | "stop", item: SyncMediaRef & { progress: number }): Promise { + if (!this.isConnected) return; + const normProgress = item.progress <= 1.0 ? item.progress * 100 : item.progress; + const body = item.mediaType === "movie" + ? { movie: { ids: { tmdb: item.tmdbId } }, progress: normProgress } + : { + show: { ids: { tmdb: item.tmdbId } }, + episode: item.episode ? { number: item.episode } : undefined, + progress: normProgress + }; + await this.simkl(`/scrobble/${action}`, { method: "POST", body: JSON.stringify(body) }); + } +} + +export const simklClient = new SimklClient(); diff --git a/web/lib/sync.ts b/web/lib/sync.ts index 530c016c1..959592b16 100644 --- a/web/lib/sync.ts +++ b/web/lib/sync.ts @@ -1,7 +1,8 @@ import { mdblistClient } from "./mdblist"; +import { simklClient } from "./simkl"; import { traktClient } from "./store"; -export type SyncProvider = "trakt" | "mdblist" | "none"; +export type SyncProvider = "trakt" | "mdblist" | "simkl" | "none"; export interface SyncMediaRef { mediaType: "movie" | "tv"; @@ -11,8 +12,7 @@ export interface SyncMediaRef { } /** - * The read/write surface shared by Trakt and MDBList. Both clients return reads - * in Trakt-compatible shapes, so the store's mappers work with either provider. + * The read/write surface shared by Trakt, MDBList, and Simkl. */ export interface SyncClient { readonly isConnected: boolean; @@ -27,14 +27,17 @@ export interface SyncClient { scrobble(action: "start" | "pause" | "stop", item: SyncMediaRef & { progress: number }): Promise; } -/** Which remote a profile is actively connected to (MDBList takes precedence). */ +/** Which remote a profile is actively connected to. */ export function activeSyncProvider(): SyncProvider { if (mdblistClient.isConnected) return "mdblist"; + if (simklClient.isConnected) return "simkl"; if (traktClient.isConnected) return "trakt"; return "none"; } -/** The active provider client, or the Trakt client when neither is connected. */ +/** The active provider client, or the Trakt client when none is connected. */ export function syncClient(): SyncClient { - return (mdblistClient.isConnected ? mdblistClient : traktClient) as unknown as SyncClient; + if (mdblistClient.isConnected) return mdblistClient as unknown as SyncClient; + if (simklClient.isConnected) return simklClient as unknown as SyncClient; + return traktClient as unknown as SyncClient; }