All checks were successful
Publier l'image Docker / publish (push) Successful in 6s
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>
211 lines
7.1 KiB
JavaScript
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'})`);
|
|
});
|