Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions netlify-auth-site/netlify/functions/_backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2314,6 +2387,7 @@ module.exports = {
handleCloudAuthReset,
handleTmdbProxy,
handleTraktProxy,
handleSimklProxy,
handleTvAuthApprove,
handleTvAuthComplete,
handleTvAuthStart,
Expand Down
3 changes: 3 additions & 0 deletions netlify-auth-site/netlify/functions/simkl-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { handleSimklProxy } = require("./_backend");

exports.handler = handleSimklProxy;
151 changes: 151 additions & 0 deletions supabase/functions/simkl-proxy/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, { count: number; resetTime: number }>()

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<string, string> = {
'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<string, unknown> = {}
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,
})
}
})
73 changes: 73 additions & 0 deletions web/app/api/simkl/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading