Plex Home: the owner's PIN is asked for and checked by plex.tv when switching back, wrong guesses are slowed down
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d813884074
commit
b171f35be6
3 files changed
+36
-9
No files matched your search
@@ -82,10 +82,10 @@ async function plexHomeBadge(st) {
|
|||||||
function plexSwitchSheet(users) {
|
function plexSwitchSheet(users) {
|
||||||
const el = openSheet('Who is listening?', 'People in your Plex Home. Playlists, ratings and what you have played follow the person you pick.',
|
const el = openSheet('Who is listening?', 'People in your Plex Home. Playlists, ratings and what you have played follow the person you pick.',
|
||||||
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(u.thumb)}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
|
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(u.thumb)}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
|
||||||
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected && !u.admin ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
|
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
|
||||||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||||
el.querySelector('#sx').onclick = closeSheet;
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => { const u = users[+b.dataset.u]; if (u.current) return closeSheet(); if (u.protected && !u.admin) plexPinSheet(u); else plexSwitchTo(u, ''); });
|
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => { const u = users[+b.dataset.u]; if (u.current) return closeSheet(); if (u.protected) plexPinSheet(u); else plexSwitchTo(u, ''); });
|
||||||
}
|
}
|
||||||
function plexPinSheet(u) {
|
function plexPinSheet(u) {
|
||||||
const el = openSheet('PIN for ' + u.title, '', `<form id="ppf"><label for="ppi">PIN</label><input type="password" id="ppi" inputmode="numeric" autocomplete="off" maxlength="8" required>
|
const el = openSheet('PIN for ' + u.title, '', `<form id="ppf"><label for="ppi">PIN</label><input type="password" id="ppi" inputmode="numeric" autocomplete="off" maxlength="8" required>
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ async function plexTvAny(path, token, opts = {}) {
|
|||||||
}
|
}
|
||||||
const truthy = v => v === true || v === 1 || v === '1' || v === 'true';
|
const truthy = v => v === true || v === 1 || v === '1' || v === 'true';
|
||||||
/** The people in this Plex Home: [{ id, title, thumb, admin, protected, restricted, guest }]. */
|
/** The people in this Plex Home: [{ id, title, thumb, admin, protected, restricted, guest }]. */
|
||||||
|
const plexPinFails = new Map(); // hub person + Plex person -> wrong guesses
|
||||||
async function plexHomeUsers(token) {
|
async function plexHomeUsers(token) {
|
||||||
const { json, text } = await plexTvAny('/api/home/users', token);
|
const { json, text } = await plexTvAny('/api/home/users', token);
|
||||||
const raw = json ? (json.users || json.MediaContainer?.User || json.User || []) : [...text.matchAll(/<User\b[^>]*>/g)].map(m => attrs(m[0]));
|
const raw = json ? (json.users || json.MediaContainer?.User || json.User || []) : [...text.matchAll(/<User\b[^>]*>/g)].map(m => attrs(m[0]));
|
||||||
@@ -363,19 +364,32 @@ async function plexApi(req, res) {
|
|||||||
const owner = st.mainToken || token;
|
const owner = st.mainToken || token;
|
||||||
const target = (await plexHomeUsers(owner)).find(x => x.id === String(id));
|
const target = (await plexHomeUsers(owner)).find(x => x.id === String(id));
|
||||||
if (!target) return json(res, 404, { error: 'That person is not in your Plex Home' });
|
if (!target) return json(res, 404, { error: 'That person is not in your Plex Home' });
|
||||||
if (target.admin && !st.mainToken) { plexSave({ ...st, user: { id: target.id, title: target.title, thumb: target.thumb, admin: true } }); return json(res, 200, { ok: true, user: { id: target.id, title: target.title, thumb: target.thumb, admin: true } }); } // already the account that signed in
|
const meNow = st.user ? String(st.user.id) : (target.admin && !st.mainToken ? target.id : '');
|
||||||
|
const same = meNow === target.id; // already using Plex as this person: nothing to prove
|
||||||
|
// Anyone with a PIN in Plex (the owner included) needs it every time you switch to them; plex.tv checks it, and wrong guesses are slowed down here.
|
||||||
|
const gate = (users.current() ? users.current().id : '') + ':' + target.id, fails = plexPinFails.get(gate) || { n: 0, until: 0 };
|
||||||
|
if (target.protected && !same) {
|
||||||
|
if (fails.until > Date.now()) return json(res, 429, { error: `Too many wrong PINs. Try again in ${Math.ceil((fails.until - Date.now()) / 60000)} min.` });
|
||||||
|
if (!pin) return json(res, 403, { error: 'That PIN is not right' });
|
||||||
|
}
|
||||||
let userToken;
|
let userToken;
|
||||||
if (target.admin && st.mainToken) userToken = st.mainToken; // back to the account that signed in: no PIN needed
|
if (same) userToken = target.admin && st.mainToken ? st.mainToken : st.token;
|
||||||
|
else if (target.admin && st.mainToken && !target.protected) userToken = st.mainToken; // back to the account that signed in, which has no PIN
|
||||||
else {
|
else {
|
||||||
try {
|
try {
|
||||||
const q = pin ? '?' + new URLSearchParams({ pin: String(pin).slice(0, 8) }) : '';
|
const q = pin ? '?' + new URLSearchParams({ pin: String(pin).slice(0, 8) }) : '';
|
||||||
const { json: j, text } = await plexTvAny(`/api/home/users/${encodeURIComponent(target.id)}/switch${q}`, owner, { method: 'POST' });
|
const { json: j, text } = await plexTvAny(`/api/home/users/${encodeURIComponent(target.id)}/switch${q}`, owner, { method: 'POST' });
|
||||||
userToken = (j && (j.authToken || j.authenticationToken || (j.user && (j.user.authToken || j.user.authenticationToken)))) || (text.match(/authenticationToken="([^"]+)"/) || [])[1];
|
userToken = (j && (j.authToken || j.authenticationToken || (j.user && (j.user.authToken || j.user.authenticationToken)))) || (text.match(/authenticationToken="([^"]+)"/) || [])[1];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401 || e.status === 403) return json(res, 403, { error: target.protected || pin ? 'That PIN is not right' : 'Plex would not switch to that person' });
|
if (e.status === 401 || e.status === 403) {
|
||||||
|
if (target.protected) { fails.n++; if (fails.n >= 5) { fails.until = Date.now() + 10 * 60e3; fails.n = 0; } plexPinFails.set(gate, fails); }
|
||||||
|
return json(res, 403, { error: target.protected || pin ? 'That PIN is not right' : 'Plex would not switch to that person' });
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
if (!userToken && target.admin && st.mainToken) userToken = st.mainToken; // plex.tv accepted the PIN for the owner and gave no new token: the one that signed in is theirs
|
||||||
if (!userToken) return json(res, 502, { error: 'Plex did not give a sign-in for that person' });
|
if (!userToken) return json(res, 502, { error: 'Plex did not give a sign-in for that person' });
|
||||||
|
plexPinFails.delete(gate);
|
||||||
}
|
}
|
||||||
const rs = await plexTv(RESOURCES, userToken), r = rs.find(x => x.clientIdentifier === st.sid);
|
const rs = await plexTv(RESOURCES, userToken), r = rs.find(x => x.clientIdentifier === st.sid);
|
||||||
if (!r) return json(res, 403, { error: target.title + ' cannot use this Plex server' });
|
if (!r) return json(res, 403, { error: target.title + ' cannot use this Plex server' });
|
||||||
|
|||||||
+17
-4
@@ -16,20 +16,21 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
// ---- fake plex.tv
|
// ---- fake plex.tv
|
||||||
const tv = { switchCalls: [], homeJson: false, accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 };
|
const tv = { adminPin: false, switchCalls: [], homeJson: false, accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 };
|
||||||
const plexTv = await listen((rq, rs) => {
|
const plexTv = await listen((rq, rs) => {
|
||||||
const u = new URL(rq.url, 'http://x'); let m;
|
const u = new URL(rq.url, 'http://x'); let m;
|
||||||
if (rq.method === 'POST' && u.pathname === '/api/v2/pins') return jsonOut(rs, 201, { id: 42, code: 'ABCD' });
|
if (rq.method === 'POST' && u.pathname === '/api/v2/pins') return jsonOut(rs, 201, { id: 42, code: 'ABCD' });
|
||||||
if (u.pathname === '/api/v2/pins/42') return jsonOut(rs, 200, { authToken: ++tv.pinPolls >= 2 ? tv.accountToken : null });
|
if (u.pathname === '/api/v2/pins/42') return jsonOut(rs, 200, { authToken: ++tv.pinPolls >= 2 ? tv.accountToken : null });
|
||||||
if (u.pathname === '/api/home/users' && rq.method === 'GET') {
|
if (u.pathname === '/api/home/users' && rq.method === 'GET') {
|
||||||
if (!['ACCOUNT'].includes(rq.headers['x-plex-token'])) { rs.writeHead(401); return rs.end(); }
|
if (!['ACCOUNT'].includes(rq.headers['x-plex-token'])) { rs.writeHead(401); return rs.end(); }
|
||||||
if (tv.homeJson) return jsonOut(rs, 200, { users: [{ id: 1, title: 'Nate', admin: true, protected: false, thumb: 'https://plex.example/a.png' }, { id: 2, title: 'Kid', admin: false, protected: true, restricted: true }, { id: 3, title: 'Guest', admin: false, protected: false }] });
|
if (tv.homeJson) return jsonOut(rs, 200, { users: [{ id: 1, title: 'Nate', admin: true, protected: tv.adminPin, thumb: 'https://plex.example/a.png' }, { id: 2, title: 'Kid', admin: false, protected: true, restricted: true }, { id: 3, title: 'Guest', admin: false, protected: false }] });
|
||||||
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end('<?xml version="1.0"?><MediaContainer size="3"><User id="1" title="Nate" admin="1" protected="0" thumb="https://plex.example/a.png"/><User id="2" title="Kid" admin="0" protected="1" restricted="1"/><User id="3" title="Guest" admin="0" protected="0"/></MediaContainer>');
|
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end('<?xml version="1.0"?><MediaContainer size="3"><User id="1" title="Nate" admin="1" protected="' + (tv.adminPin ? 1 : 0) + '" thumb="https://plex.example/a.png"/><User id="2" title="Kid" admin="0" protected="1" restricted="1"/><User id="3" title="Guest" admin="0" protected="0"/></MediaContainer>');
|
||||||
}
|
}
|
||||||
if ((m = /^\/api\/home\/users\/(\d+)\/switch$/.exec(u.pathname)) && rq.method === 'POST') {
|
if ((m = /^\/api\/home\/users\/(\d+)\/switch$/.exec(u.pathname)) && rq.method === 'POST') {
|
||||||
tv.switchCalls.push(m[1] + ':' + (u.searchParams.get('pin') || '') + ':' + rq.headers['x-plex-token']);
|
tv.switchCalls.push(m[1] + ':' + (u.searchParams.get('pin') || '') + ':' + rq.headers['x-plex-token']);
|
||||||
|
if (m[1] === '1' && u.searchParams.get('pin') !== '9999') { rs.writeHead(403); return rs.end('<errors><error>bad pin</error></errors>'); }
|
||||||
if (m[1] === '2' && u.searchParams.get('pin') !== '1234') { rs.writeHead(403); return rs.end('<errors><error>bad pin</error></errors>'); }
|
if (m[1] === '2' && u.searchParams.get('pin') !== '1234') { rs.writeHead(403); return rs.end('<errors><error>bad pin</error></errors>'); }
|
||||||
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end(`<user id="${m[1]}" authenticationToken="${m[1] === '2' ? 'KIDTOKEN' : 'GUESTTOKEN'}"/>`);
|
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end(`<user id="${m[1]}" authenticationToken="${m[1] === '2' ? 'KIDTOKEN' : m[1] === '1' ? 'ACCOUNT' : 'GUESTTOKEN'}"/>`);
|
||||||
}
|
}
|
||||||
if (u.pathname === '/api/v2/resources') {
|
if (u.pathname === '/api/v2/resources') {
|
||||||
tv.resourceCalls++;
|
tv.resourceCalls++;
|
||||||
@@ -112,6 +113,18 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
|||||||
tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); r = await call('/api/plex/status'); r2 = await call('/plex/library/sections');
|
tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); r = await call('/api/plex/status'); r2 = await call('/plex/library/sections');
|
||||||
ok('switching back to the owner needs no PIN and no call to plex.tv', r.json.user.title === 'Nate' && tv.switchCalls.length === 0 && r2.status === 200 && seen[seen.length - 1] === 'SRV1', [r.json, tv.switchCalls, seen.slice(-2)]);
|
ok('switching back to the owner needs no PIN and no call to plex.tv', r.json.user.title === 'Nate' && tv.switchCalls.length === 0 && r2.status === 200 && seen[seen.length - 1] === 'SRV1', [r.json, tv.switchCalls, seen.slice(-2)]);
|
||||||
s = savedPlex(); ok('and uses the owner\'s own tokens again', s.token === 'ACCOUNT' && s.stk === 'SRV1', s);
|
s = savedPlex(); ok('and uses the owner\'s own tokens again', s.token === 'ACCOUNT' && s.stk === 'SRV1', s);
|
||||||
|
// the owner has a PIN in Plex: the hub asks for it too, plex.tv checks it, and guessing is slowed down
|
||||||
|
tv.adminPin = true; await call('/api/plex/switch', { id: '3' });
|
||||||
|
r = await call('/api/plex/users'); ok('an owner with a PIN is marked protected in the list', r.json.users.find(x => x.id === '1').protected === true, r.json);
|
||||||
|
tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); ok('going back to the owner asks for the PIN', r.status === 403 && /PIN/.test(r.json.error) && !tv.switchCalls.length, [r, tv.switchCalls]);
|
||||||
|
r = await call('/api/plex/switch', { id: '1', pin: '1111' }); ok('a wrong owner PIN is refused by plex.tv and nothing changes', r.status === 403 && (await call('/api/plex/status')).json.user.title === 'Guest', r);
|
||||||
|
r = await call('/api/plex/switch', { id: '1', pin: '9999' }); ok('the right owner PIN switches back, and the wrong-guess count starts over', r.status === 200 && r.json.user.title === 'Nate', r);
|
||||||
|
await call('/api/plex/switch', { id: '3' }); tv.switchCalls.length = 0;
|
||||||
|
r = await call('/api/plex/switch', { id: '1', pin: '1111' });
|
||||||
|
for (let i = 0; i < 4; i++) r = await call("/api/plex/switch", { id: "1", pin: "2222" });
|
||||||
|
r = await call('/api/plex/switch', { id: '1', pin: '9999' }); ok('after five wrong guesses even the right PIN waits', r.status === 429 && /Try again/.test(r.json.error), r);
|
||||||
|
ok('and plex.tv is not asked while waiting', tv.switchCalls.length === 5, tv.switchCalls);
|
||||||
|
tv.adminPin = false;
|
||||||
hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
||||||
await call('/api/plex/switch', { id: '2', pin: '1234' }); hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
await call('/api/plex/switch', { id: '2', pin: '1234' }); hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
||||||
r = await call('/api/plex/status'); ok('AFTER A RESTART: still using Plex as the chosen person', r.json.user && r.json.user.title === 'Kid', r.json);
|
r = await call('/api/plex/status'); ok('AFTER A RESTART: still using Plex as the chosen person', r.json.user && r.json.user.title === 'Kid', r.json);
|
||||||
|
|||||||
Reference in new issue
Block a user