Plex: edit a title's details (fields locked on save) and choose its poster or background, owner only, through a narrow write rule in the proxy
build-container / build (push) Successful in 19m31s
build-container / build (push) Successful in 19m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b171f35be6
commit
5c5a7b5155
5 files changed
+113
-5
No files matched your search
@@ -320,7 +320,7 @@ async function plexApi(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), st = plexLoad(), token = st.token;
|
||||
try {
|
||||
if (u.pathname === '/api/plex/status') {
|
||||
return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null, user: st.user ? { id: st.user.id, title: st.user.title, thumb: st.user.thumb, admin: st.user.admin } : null });
|
||||
return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null, user: st.user ? { id: st.user.id, title: st.user.title, thumb: st.user.thumb, admin: st.user.admin } : null, canEdit: !!token && await plexCanEdit(st) });
|
||||
}
|
||||
if (u.pathname === '/api/plex/pin' && req.method === 'POST') {
|
||||
const pin = await plexTv('/api/v2/pins?strong=true', null, { method: 'POST' });
|
||||
@@ -407,8 +407,40 @@ async function plexApi(req, res) {
|
||||
} catch (e) { json(res, 502, { error: e.message }); }
|
||||
}
|
||||
|
||||
// Only playlists may be changed through the proxy (create, add, remove, rename, delete); everything else on the Plex server stays read-only.
|
||||
const PLEX_WRITABLE = /^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?\?|^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?$/;
|
||||
// Only playlists and a title's own details (the fields Plex's Edit dialog has, and its poster/background choice) may be changed through the proxy;
|
||||
// everything else on the Plex server stays read-only. Plex itself refuses edits from anyone who is not allowed, and the hub checks too (plexCanEdit).
|
||||
const PLEX_PLAYLISTS = /^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?\?|^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?$/;
|
||||
const EDIT_FIELDS = 'title|titleSort|originalTitle|originallyAvailableAt|contentRating|studio|tagline|summary';
|
||||
const EDIT_TAGS = 'genre|label|collection';
|
||||
const EDIT_KEY = new RegExp(`^(type|id|includeExternalMedia|(${EDIT_FIELDS})\\.(value|locked)|(${EDIT_TAGS})\\.locked|(${EDIT_TAGS})\\[\\d{1,3}\\]\\.tag\\.tag)$`);
|
||||
/** Is this request one of the allowed edits of a title's details? */
|
||||
function plexEditRequest(method, url) {
|
||||
const u = new URL(url, 'http://x'), p = u.pathname;
|
||||
if (u.href.length > 12000) return false;
|
||||
let m;
|
||||
if (method === 'PUT' && (m = /^\/plex\/library\/sections\/\d+\/all$/.exec(p))) { // save fields: ?type=1&id=<key>&title.value=..&title.locked=1
|
||||
const q = u.searchParams;
|
||||
return /^[1-4]$/.test(q.get('type') || '') && /^\d+$/.test(q.get('id') || '') && [...q.keys()].every(k => EDIT_KEY.test(k));
|
||||
}
|
||||
if ((m = /^\/plex\/library\/metadata\/\d+\/(poster|art|posters|arts)$/.exec(p))) { // choose or add a poster / background
|
||||
const img = u.searchParams.get('url') || '';
|
||||
return [...u.searchParams.keys()].every(k => k === 'url') && /^(https:\/\/[^\s]{4,2000}|\/library\/metadata\/\d+\/(file|thumb|art)[^\s]*|(upload|metadata|media):\/\/[\w./%-]{1,300})$/.test(img)
|
||||
&& (m[1].length > 3 ? ['POST', 'PUT'] : ['PUT']).includes(method) && (method === 'PUT' ? /^(poster|art)$/.test(m[1]) : /^(posters|arts)$/.test(m[1]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const PLEX_WRITABLE_URL = (method, url) => PLEX_PLAYLISTS.test(url) || plexEditRequest(method, url);
|
||||
const editOk = new Map(); // token + server -> { t, ok }
|
||||
/** Only the server's owner (or an admin of the Home) may edit details: the person's own plex.tv view of the server says so. */
|
||||
async function plexCanEdit(st) {
|
||||
if (!st.token || !st.sid) return false;
|
||||
const k = st.token + '|' + st.sid, c = editOk.get(k);
|
||||
if (c && Date.now() - c.t < 5 * 60e3) return c.ok;
|
||||
let ok = false;
|
||||
try { const rs = await plexTv(RESOURCES, st.token); ok = !!rs.find(x => x.clientIdentifier === st.sid && truthy(x.owned)); } catch { return false; }
|
||||
editOk.set(k, { t: Date.now(), ok });
|
||||
return ok;
|
||||
}
|
||||
async function plexProxy(req, res) {
|
||||
const st = plexLoad();
|
||||
if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); }
|
||||
@@ -579,7 +611,7 @@ async function handleRequest(req, res) {
|
||||
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
|
||||
if (pathname.startsWith('/api/mail/')) return mail.handle(req, res);
|
||||
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
||||
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE.test(req.url)))) return plexProxy(req, res);
|
||||
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE_URL(req.method, req.url) && (PLEX_PLAYLISTS.test(req.url) || await plexCanEdit(plexLoad()))))) return plexProxy(req, res);
|
||||
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
|
||||
return serveStatic(req, res);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user