watchparty/server/src/index.js
Nicolas b6818655b1
All checks were successful
Publier l'image Docker / publish (push) Successful in 6s
Proxy all Emby access through our own backend; drop server-url field; use Emby's real theme colors
Three changes requested:

1. Server proxies Emby, browser never talks to it directly. Login is
   now POST /api/auth/login {username,password} on our own backend;
   the backend calls Emby's AuthenticateByName server-side and mints
   its own opaque session (httpOnly cookie), storing
   {embyToken,userId,userName} in memory. The browser never sees or
   holds a real Emby access token. Every other call - search, item
   details, images, HLS streaming, party actions - goes to our own
   /api/* origin, authenticated by that cookie; server/src/embyProxy.js
   forwards to Emby server-side and streams the response back
   (including a playlist-rewrite pass for HLS .m3u8 so hls.js never
   gets handed a URL pointing at Emby directly). This also sidesteps
   the CORS problem the previous direct-to-Emby design had.

2. EMBY_SERVER_URL is backend-only config (.env.example); the login
   form no longer asks for a server address at all.

3. client/css/app.css now uses colors read directly off the live Emby
   server's own dark theme (--theme-primary-color-* etc, fetched via a
   JS probe against the running client, not guessed): accent green
   #52b54b/#6ccf65, near-black background #151216.

Tested for real: ran the server locally against the actual
EMBY_SERVER_URL and submitted intentionally-wrong credentials through
the UI - confirmed the full round trip (browser -> our backend -> real
Emby -> real 401 -> our error message) with no CORS issue, since it's
all server-to-server now. Login itself hasn't been exercised with real
credentials, so catalog/streaming remain unverified - see
docs/architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:36:26 +02:00

211 lines
7.1 KiB
JavaScript

'use strict';
const path = require('path');
const http = require('http');
const express = require('express');
const { WebSocketServer } = require('ws');
const {
EMBY_SERVER_URL,
SESSION_COOKIE,
SESSION_TTL_MS,
login,
getSession,
endSession,
parseCookies,
sessionFromRequest
} = require('./embyAuth');
const { embyFetch, proxyEmbyResponse } = require('./embyProxy');
const { PartyManager } = require('./partyManager');
const { attachWebSocketServer } = require('./wsHandler');
const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
app.use(express.json());
// Everything the browser needs - the app shell AND every Emby call - is
// same-origin from here on. See docs/architecture.md for why: the previous
// version had the browser call Emby directly and hit real CORS problems.
app.use(express.static(path.join(__dirname, '..', '..', 'client')));
function requireSession(req, res, next) {
const session = sessionFromRequest(req);
if (!session) {
res.status(401).json({ error: 'Session expirée, reconnecte-toi.' });
return;
}
req.embySession = session;
next();
}
// --- Auth ----------------------------------------------------------------
// The browser only ever sends a username/password once, to us. From then on
// it holds an opaque session cookie - never Emby's own access token.
app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
res.status(400).json({ error: "Nom d'utilisateur et mot de passe requis." });
return;
}
const sessionId = await login(username, password);
if (!sessionId) {
res.status(401).json({ error: 'Identifiants refusés.' });
return;
}
res.cookie(SESSION_COOKIE, sessionId, {
httpOnly: true,
sameSite: 'lax',
secure: req.secure,
maxAge: SESSION_TTL_MS
});
res.json({ userName: getSession(sessionId).userName });
});
app.post('/api/auth/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie);
if (cookies[SESSION_COOKIE]) {
endSession(cookies[SESSION_COOKIE]);
}
res.clearCookie(SESSION_COOKIE);
res.json({ ok: true });
});
app.get('/api/auth/me', requireSession, (req, res) => {
res.json({ userName: req.embySession.userName });
});
// --- Catalog (proxied - the browser never talks to Emby directly) --------
app.get('/api/catalog/search', requireSession, async (req, res) => {
const q = (req.query.q || '').trim();
if (q.length < 2) {
res.json({ items: [] });
return;
}
const params = new URLSearchParams({
searchTerm: q,
IncludeItemTypes: 'Movie,Episode,Series',
Recursive: 'true',
Fields: 'Overview,ProductionYear',
ImageTypeLimit: '1',
Limit: '24'
});
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Recherche impossible côté Emby.' });
return;
}
const data = await embyRes.json();
res.json({ items: (data.Items || []).map(toItemDto) });
});
app.get('/api/catalog/item/:itemId', requireSession, async (req, res) => {
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/${req.params.itemId}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(embyRes.status).json({ error: 'Titre introuvable.' });
return;
}
res.json(toItemDto(await embyRes.json()));
});
// Images/video are requested by <img>/<video> elements, which send the
// session cookie (same-origin) but can't carry a bearer header - so these
// two routes check the cookie directly instead of using the requireSession
// middleware's JSON error response, which a browser media element ignores.
app.get('/api/catalog/image/:itemId', async (req, res) => {
const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
await proxyEmbyResponse(req, res, `/Items/${req.params.itemId}/Images/Primary?maxWidth=400`, session.embyToken, req.params.itemId);
});
app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
const params = new URLSearchParams({ VideoCodec: 'h264', AudioCodec: 'aac' });
await proxyEmbyResponse(req, res, `/Videos/${req.params.itemId}/master.m3u8?${params}`, session.embyToken, req.params.itemId);
});
// Everything an HLS playlist references (variant playlists, segments) comes
// back through here - see embyProxy.rewritePlaylist.
app.get('/api/catalog/stream/:itemId/raw', async (req, res) => {
const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
const target = req.query.u || '';
const itemId = req.params.itemId;
let pathAndQuery;
if (/^https?:\/\//i.test(target)) {
if (!target.startsWith(EMBY_SERVER_URL)) {
res.status(400).end(); // never proxy an arbitrary URL, only our own Emby server
return;
}
pathAndQuery = target;
} else {
pathAndQuery = `/Videos/${itemId}/${target}`;
}
await proxyEmbyResponse(req, res, pathAndQuery, session.embyToken, itemId);
});
function toItemDto(item) {
return {
id: item.Id,
name: item.Name,
overview: item.Overview,
year: item.ProductionYear,
imageUrl: `/api/catalog/image/${item.Id}`
};
}
// --- Parties ---------------------------------------------------------------
app.post('/api/parties', requireSession, (req, res) => {
const itemId = req.body && req.body.itemId;
if (!itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
const party = PartyManager.create(req.embySession.userId, req.embySession.userName, itemId);
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
app.post('/api/parties/:joinCode/join', requireSession, (req, res) => {
const party = PartyManager.getByJoinCode(req.params.joinCode);
if (!party) {
res.status(404).json({ error: 'Aucune party avec ce code (elle est peut-être terminée).' });
return;
}
PartyManager.join(party, req.embySession.userId, req.embySession.userName);
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (party) {
PartyManager.leave(party, req.embySession.userId);
}
res.json({ ok: true });
});
app.get('/api/parties/:partyId', requireSession, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (!party) {
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
return;
}
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
attachWebSocketServer(wss, { parseCookies, getSession });
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
const port = process.env.PORT || 3000;
server.listen(port, () => {
console.log(`[watchparty] listening on :${port} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
});