From 2f2004a0fff0a0388cfa4161267ae2cd8125dc0b Mon Sep 17 00:00:00 2001 From: Tony James Date: Sat, 1 Aug 2026 22:19:50 +0100 Subject: [PATCH] feat: add self-hosted connector reference --- README.md | 12 ++++++++++++ package.json | 6 ++++++ server.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 README.md create mode 100644 package.json create mode 100644 server.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef85603 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# CleanStream Media Connector Reference + +Self-hosted reference implementation of the v1 connector protocol. + +```sh +PAIRING_SECRET='choose-a-long-secret' MEDIA_ROOT='/path/to/videos' node server.js +``` + +For production, place the service behind HTTPS and set `PUBLIC_BASE_URL` and +`APPROVAL_BASE_URL` to its HTTPS address. Scanning the TV QR code opens a phone +approval form protected by `PAIRING_SECRET`; replace that form with your own +identity provider for a production connector. diff --git a/package.json b/package.json new file mode 100644 index 0000000..e005615 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "cleanstream-media-connector-reference", + "private": true, + "type": "module", + "scripts": { "start": "node server.js" } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..4897579 --- /dev/null +++ b/server.js @@ -0,0 +1,43 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; + +const port = Number(process.env.PORT || 8787); +const mediaRoot = path.resolve(process.env.MEDIA_ROOT || './media'); +const publicBaseUrl = (process.env.PUBLIC_BASE_URL || `http://localhost:${port}`).replace(/\/$/, ''); +const approvalBaseUrl = (process.env.APPROVAL_BASE_URL || publicBaseUrl).replace(/\/$/, ''); +const pairingSecret = process.env.PAIRING_SECRET; +if (!pairingSecret) throw new Error('PAIRING_SECRET is required'); + +const sessions = new Map(); +const tokens = new Map(); +const videos = () => fs.existsSync(mediaRoot) ? fs.readdirSync(mediaRoot, { recursive: true }) + .filter(file => /\.(mkv|mp4|webm|m4v)$/i.test(file)) + .map(file => ({ id: Buffer.from(file).toString('base64url'), title: path.basename(file, path.extname(file)), type: 'video', description: file })) : []; +const json = (res, code, value) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); }; +const readJson = req => new Promise((resolve, reject) => { let raw = ''; req.on('data', d => raw += d); req.on('end', () => { try { resolve(JSON.parse(raw || '{}')); } catch { reject(new Error('Invalid JSON')); } }); }); +const bearer = req => req.headers.authorization?.replace(/^Bearer\s+/i, ''); +const authenticated = req => tokens.has(bearer(req)); +const id = () => crypto.randomBytes(24).toString('base64url'); + +http.createServer(async (req, res) => { + const url = new URL(req.url, publicBaseUrl); + if (req.method === 'GET' && url.pathname === '/.well-known/cleanstream-connector.json') return json(res, 200, { + protocolVersion: 1, id: 'reference.home-library', name: 'Reference Home Library', capabilities: ['catalog', 'stream'], + endpoints: { catalog: '/v1/catalog', stream: '/v1/stream', pairStart: '/v1/pair/start', pairStatus: '/v1/pair/{sessionId}', pairToken: '/v1/pair/token' } + }); + if (req.method === 'POST' && url.pathname === '/v1/pair/start') { + const body = await readJson(req); if (body.codeChallengeMethod !== 'S256' || !body.codeChallenge) return json(res, 400, { error: 'PKCE S256 required' }); + const sessionId = id(); sessions.set(sessionId, { challenge: body.codeChallenge, expires: Date.now() + 300000 }); + return json(res, 200, { sessionId, approvalUri: `${approvalBaseUrl}/pair/approve?session=${sessionId}`, expiresAt: new Date(Date.now() + 300000).toISOString(), pollIntervalSeconds: 2 }); + } + if (req.method === 'GET' && url.pathname === '/pair/approve') return res.end('
'); + if (req.method === 'POST' && url.pathname === '/pair/approve') { let raw=''; req.on('data', d => raw += d); req.on('end', () => { const session=sessions.get(url.searchParams.get('session')); if (!session || new URLSearchParams(raw).get('secret') !== pairingSecret) { res.statusCode=403; return res.end('Denied'); } session.code=id(); res.end('Approved. Return to your TV.'); }); return; } + if (req.method === 'GET' && url.pathname.startsWith('/v1/pair/')) { const session=sessions.get(url.pathname.split('/').pop()); if (!session || session.expires < Date.now()) return json(res, 200, { status: 'expired' }); return json(res, 200, session.code ? { status: 'approved', authorizationCode: session.code } : { status: 'pending' }); } + if (req.method === 'POST' && url.pathname === '/v1/pair/token') { const body=await readJson(req), session=sessions.get(body.sessionId); const challenge=crypto.createHash('sha256').update(body.codeVerifier || '').digest('base64url'); if (!session || session.code !== body.authorizationCode || session.challenge !== challenge) return json(res, 403, { error: 'Invalid pairing exchange' }); const token=id(); tokens.set(token, Date.now()+2592000000); sessions.delete(body.sessionId); return json(res, 200, { accessToken: token, expiresAt: new Date(tokens.get(token)).toISOString(), scopes: ['catalog','stream'] }); } + if (req.method === 'GET' && url.pathname === '/v1/catalog') return authenticated(req) ? json(res, 200, { items: videos() }) : json(res, 401, { error: 'Unauthorized' }); + if (req.method === 'POST' && url.pathname === '/v1/stream') { if (!authenticated(req)) return json(res,401,{error:'Unauthorized'}); const body=await readJson(req); const file=Buffer.from(body.id || '', 'base64url').toString(); if (!videos().some(v => v.id === body.id) || file.includes('..')) return json(res,404,{error:'Not found'}); return json(res,200,{streams:[{id:body.id,url:`${publicBaseUrl}/v1/media/${body.id}?token=${bearer(req)}`,filename:path.basename(file),headers:{}}]}); } + if (req.method === 'GET' && url.pathname.startsWith('/v1/media/')) { const token=url.searchParams.get('token'); const file=Buffer.from(url.pathname.split('/').pop(), 'base64url').toString(); const target=path.resolve(mediaRoot,file); if (!tokens.has(token) || !target.startsWith(`${mediaRoot}${path.sep}`) || !fs.existsSync(target)) return json(res,404,{error:'Not found'}); res.writeHead(200,{'content-type':'application/octet-stream','content-length':fs.statSync(target).size}); return fs.createReadStream(target).pipe(res); } + json(res, 404, { error: 'Not found' }); +}).listen(port, () => console.log(`Connector listening on ${publicBaseUrl}`));