Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled

- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
/**
* Minimal DPoP (RFC 9449) helper for fp-tv-app extended security.
*
* Frontend evidence: USE_DPOP === usesExtendedSecurity === true for clientId fp-tv-app.
* Token + resource requests require a DPoP proof JWT (ES256 / P-256).
*/
import { webcrypto } from 'node:crypto';
import { randomBytes, createHash } from 'node:crypto';
const subtle = webcrypto.subtle;
/**
* @returns {Promise<{ publicKey: CryptoKey, privateKey: CryptoKey, publicJwk: JsonWebKey, privateJwk: JsonWebKey }>}
*/
export async function generateDpopKeyPair() {
const pair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
'sign',
'verify',
]);
const publicJwk = await subtle.exportKey('jwk', pair.publicKey);
const privateJwk = await subtle.exportKey('jwk', pair.privateKey);
// Strip private material from public jwk for JWT header
const publicOnly = {
kty: publicJwk.kty,
crv: publicJwk.crv,
x: publicJwk.x,
y: publicJwk.y,
};
return {
publicKey: pair.publicKey,
privateKey: pair.privateKey,
publicJwk: publicOnly,
privateJwk,
};
}
/**
* Import a persisted JWK key pair.
* @param {{ publicJwk: JsonWebKey, privateJwk: JsonWebKey }} stored
*/
export async function importDpopKeyPair(stored) {
const publicKey = await subtle.importKey(
'jwk',
stored.publicJwk,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['verify'],
);
const privateKey = await subtle.importKey(
'jwk',
stored.privateJwk,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign'],
);
return {
publicKey,
privateKey,
publicJwk: {
kty: stored.publicJwk.kty,
crv: stored.publicJwk.crv,
x: stored.publicJwk.x,
y: stored.publicJwk.y,
},
privateJwk: stored.privateJwk,
};
}
/**
* Create a DPoP proof JWT and return the header value.
* @param {{
* keyPair: { publicJwk: JsonWebKey, privateKey: CryptoKey },
* htm: string,
* htu: string | URL,
* accessToken?: string | null,
* nonce?: string | null,
* }} opts
*/
export async function createDpopProof(opts) {
const url = typeof opts.htu === 'string' ? new URL(opts.htu) : opts.htu;
const htu = `${url.origin}${url.pathname}`;
const header = {
alg: 'ES256',
typ: 'dpop+jwt',
jwk: opts.keyPair.publicJwk,
};
/** @type {Record<string, unknown>} */
const payload = {
iat: Math.floor(Date.now() / 1000),
jti: base64Url(randomBytes(16)),
htm: opts.htm.toUpperCase(),
htu,
};
if (opts.nonce) payload.nonce = opts.nonce;
if (opts.accessToken) {
payload.ath = base64Url(createHash('sha256').update(opts.accessToken, 'ascii').digest());
}
const data = `${base64UrlJson(header)}.${base64UrlJson(payload)}`;
const sig = await subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
opts.keyPair.privateKey,
new TextEncoder().encode(data),
);
return `${data}.${base64Url(Buffer.from(sig))}`;
}
/**
* Attach DPoP proof to headers; returns Authorization scheme for bound tokens.
* @param {{
* headers: Record<string, string>,
* keyPair: { publicJwk: JsonWebKey, privateKey: CryptoKey },
* method: string,
* url: string | URL,
* accessToken?: string | null,
* nonce?: string | null,
* }} opts
*/
export async function attachDpopHeaders(opts) {
const proof = await createDpopProof({
keyPair: opts.keyPair,
htm: opts.method,
htu: opts.url,
accessToken: opts.accessToken || null,
nonce: opts.nonce || null,
});
opts.headers.dpop = proof;
if (opts.accessToken) {
opts.headers.authorization = `DPoP ${opts.accessToken}`;
}
return opts.headers;
}
/**
* Read dpop-nonce from a fetch Response (case-insensitive).
* @param {{ headers?: { get?: (name: string) => string | null } } | null} res
*/
export function readDpopNonce(res) {
try {
return res?.headers?.get?.('dpop-nonce') || res?.headers?.get?.('DPoP-Nonce') || null;
} catch {
return null;
}
}
/** @param {object} obj */
function base64UrlJson(obj) {
return base64Url(Buffer.from(JSON.stringify(obj), 'utf8'));
}
/** @param {Buffer|Uint8Array} buf */
function base64Url(buf) {
return Buffer.from(buf)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}