ovenstarter/ome.html
2024-06-21 18:40:58 -04:00

499 lines
No EOL
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Generate signed URL</title>
<link rel=stylesheet href="https://rsms.me/inter/inter.css">
</head>
<body>
<main>
<div class="pad-x">
<h2>OvenMediaEngine utility (unofficial)</h2>
<h1>Generate signed URL</h1>
<p>
This webpage saves you from needing to run the official <code>signed_policy_url_generator.sh</code>
shell script by using web browser's built-in Crypto APIs. If you want a more in-depth breakdown on how
to generate signed URLs, check the <a
href="https://airensoft.gitbook.io/ovenmediaengine/access-control/signedpolicy">official
documentation</a> on SignedPolicy.
</p>
<p>
<b>Note:</b>
This page works completely in your browser (check the source, it's not minimized or anything!) so your
keys should never reach my server, not even in logs!
</p>
<section class="input">
<fieldset>
<legend>URL to sign</legend>
<input type="url" name="url" required
placeholder="wss://your.ome.endpoint:3334/app/stream/webrtc" />
</fieldset>
<fieldset>
<legend>Secret key</legend>
<input type="password" name="secret" required placeholder="your secret key" />
</fieldset>
<fieldset>
<legend>Policy</legend>
<div>
<input type="radio" id="policy-unlimited" name="policy-preset" value="unlimited" checked />
<label for="policy-unlimited">Unlimited (very high <code>url_expire</code>)</label>
</div>
<div>
<input type="radio" id="policy-custom" name="policy-preset" value="custom" />
<label for="policy-custom">Custom (specify below as JSON object)</label>
</div>
<textarea name="custom-policy" disabled rows="5">{
"url_expire" : 0
}</textarea>
</fieldset>
<button id="generate">Generate signed URL</button>
<div id="error" class="output-box hidden"></div>
<div id="result" class="output-box hidden"></div>
</section>
</div>
</main>
<script>
// Using a form element would be really nice but I don't wanna risk javascript shenanigans
// sending the data to my server by adding a form tag that might be faulty, so
// I'll do it the boring way and re-implement form stuff in Javascript
/** @type {HTMLInputElement} */
const urlElement = document.querySelector("input[name=url]");
/** @type {HTMLInputElement} */
const secretElement = document.querySelector("input[name=secret]");
/** @type {HTMLInputElement} */
const policyElements = [...document.querySelectorAll("input[name=policy-preset]")];
/** @type {HTMLTextAreaElement} */
const customPolicyElement = document.querySelector("textarea[name=custom-policy]");
/** @type {HTMLButtonElement} */
const buttonElement = document.querySelector("button#generate");
/** @type {HTMLDivElement} */
const errorElement = document.querySelector("div#error");
/** @type {HTMLDivElement} */
const resultElement = document.querySelector("div#result");
function showError(message) {
errorElement.innerHTML = message;
errorElement.classList.remove("hidden");
}
function hideError() {
errorElement.classList.add("hidden");
}
function showResult(message) {
resultElement.innerHTML = message;
resultElement.classList.remove("hidden");
}
function hideResult() {
resultElement.classList.add("hidden");
}
/**
* Runs a function and catches any errors it might throw,
* then shows an error message if one is thrown.
* @template T
* @param {() => T} fn - The function to run
* @param {string} message - The error message to show
* @returns {T} - The return value of the function
*/
function must(fn, message) {
try {
return fn();
} catch (e) {
showError(`<b>${message}:</b> ${e.message}`);
}
}
const policyPresets = {
"unlimited": {
"url_expire": 6289318800000 // remember to update this before 2169-4-20
},
};
async function generateURL() {
hideError();
if (!urlElement.reportValidity()) return;
if (!secretElement.reportValidity()) return;
if (!customPolicyElement.reportValidity()) return;
const url = must(() => new URL(urlElement.value), "URL is not valid");
let toSign = new URL(url.href);
// Check if URL is SRT, if so, extract the sub-URL (streamid) and use that instead
if (url.protocol === "srt:") {
const streamid = url.searchParams.get("streamid");
if (!streamid) {
showError("Missing streamid in SRT url");
return;
}
toSign = must(() => new URL(streamid), "streamid is not a valid URL");
}
// If custom policy, parse and validate it, otherwise use the chosen preset
const policyPreset = policyElements.find((el) => el.checked).value;
const policy = policyPreset === "custom"
? must(() => JSON.parse(customPolicyElement.value), "Custom policy is not valid JSON")
: policyPresets[policyPreset];
// Convert policy to Base64
const binaryPolicy = new TextEncoder().encode(JSON.stringify(policy));
const encodedPolicy = new Base64UrlSafe(false).encode(binaryPolicy);
// Append policy to URL
toSign.searchParams.append("policy", encodedPolicy);
// Get secret as bytes
const secret = new TextEncoder().encode(secretElement.value);
// Create HMAC-SHA1
const key = await crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(toSign.href));
// Append policy and signature to original URL
url.searchParams.append("policy", encodedPolicy);
debugger;
url.searchParams.append("signature", new Base64UrlSafe(false).encode(new Uint8Array(signature)));
// Copy URL to clipboard
showResult(`URL generated: <code>${url.href}</code>`);
}
// Run URL generation on button click
buttonElement.addEventListener("click", () => generateURL());
// Toggle custom policy box (and requirement) on policy preset change
policyElements.forEach((el) => {
el.addEventListener("change", () => {
const isCustom = el.value === "custom";
customPolicyElement.required = isCustom;
customPolicyElement.disabled = !isCustom;
});
});
</script>
<script>
// https://github.com/jedisct1/js-base64-ct
// Copyright (c) 2021 Frank Denis
// License: MIT, full text: https://github.com/jedisct1/js-base64-ct/blob/master/LICENSE
function eq(x, y) { return (((0 - (x ^ y)) >> 16) & 0xffff) ^ 0xffff; }
function gt(x, y) { return ((y - x) >> 8) & 0xffff; }
function lt(x, y) { return gt(y, x); }
function ge(x, y) { return gt(y, x) ^ 0xffff; }
function le(x, y) { return ge(y, x); }
function byteToCharOriginal(x) {
const c = (lt(x, 26) & (x + 'A'.charCodeAt(0))) |
(ge(x, 26) & lt(x, 52) & (x + ('a'.charCodeAt(0) - 26))) |
(ge(x, 52) & lt(x, 62) & (x + ('0'.charCodeAt(0) - 52))) | (eq(x, 62) & '+'.charCodeAt(0)) |
(eq(x, 63) & '/'.charCodeAt(0));
return String.fromCharCode(c);
}
function charToByteOriginal(c) {
const x = (ge(c, 'A'.charCodeAt(0)) & le(c, 'Z'.charCodeAt(0)) & (c - 'A'.charCodeAt(0))) |
(ge(c, 'a'.charCodeAt(0)) & le(c, 'z'.charCodeAt(0)) & (c - ('a'.charCodeAt(0) - 26))) |
(ge(c, '0'.charCodeAt(0)) & le(c, '9'.charCodeAt(0)) & (c - ('0'.charCodeAt(0) - 52))) | (eq(c, '+'.charCodeAt(0)) & 62) |
(eq(c, '/'.charCodeAt(0)) & 63);
return x | (eq(x, 0) & (eq(c, 'A'.charCodeAt(0)) ^ 0xffff));
}
function byteToCharUrlSafe(x) {
const c = (lt(x, 26) & (x + 'A'.charCodeAt(0))) |
(ge(x, 26) & lt(x, 52) & (x + ('a'.charCodeAt(0) - 26))) |
(ge(x, 52) & lt(x, 62) & (x + ('0'.charCodeAt(0) - 52))) | (eq(x, 62) & '-'.charCodeAt(0)) |
(eq(x, 63) & '_'.charCodeAt(0));
return String.fromCharCode(c);
}
function charToByteUrlSafe(c) {
const x = (ge(c, 'A'.charCodeAt(0)) & le(c, 'Z'.charCodeAt(0)) & (c - 'A'.charCodeAt(0))) |
(ge(c, 'a'.charCodeAt(0)) & le(c, 'z'.charCodeAt(0)) & (c - ('a'.charCodeAt(0) - 26))) |
(ge(c, '0'.charCodeAt(0)) & le(c, '9'.charCodeAt(0)) & (c - ('0'.charCodeAt(0) - 52))) | (eq(c, '-'.charCodeAt(0)) & 62) |
(eq(c, '_'.charCodeAt(0)) & 63);
return x | (eq(x, 0) & (eq(c, 'A'.charCodeAt(0)) ^ 0xffff));
}
function bin2Base64(bin, padding, byteToChar) {
let bin_len = bin.length;
let nibbles = Math.floor(bin_len / 3);
let remainder = bin_len - 3 * nibbles;
let b64_len = nibbles * 4;
if (remainder) {
if (padding) {
b64_len += 4;
}
else {
b64_len += 2 + (remainder >> 1);
}
}
let b64 = "";
let acc = 0, acc_len = 0, bin_pos = 0;
while (bin_pos < bin_len) {
acc = ((acc << 8) + bin[bin_pos++]) & 0xfff;
acc_len += 8;
while (acc_len >= 6) {
acc_len -= 6;
b64 += byteToChar((acc >> acc_len) & 0x3F);
}
}
if (acc_len > 0) {
b64 += byteToChar((acc << (6 - acc_len)) & 0x3F);
}
while (b64.length < b64_len) {
b64 += '=';
}
return b64;
}
function skipPadding(b64, ignore, padding_len) {
let i = 0;
while (padding_len > 0) {
let c = b64[i++];
if (c == '=') {
padding_len--;
}
else if (!ignore || ignore.indexOf(c) < 0) {
throw new Error("Invalid base64 padding");
}
}
if (i !== b64.length) {
throw new Error("Invalid base64 padding length");
}
}
function base642Bin(b64, padding, ignore, charToByte) {
let b64_len = b64.length;
let bin = new Uint8Array(Math.ceil(b64_len * 3 / 4));
let acc = 0, acc_len = 0, bin_len = 0, b64_pos = 0;
while (b64_pos < b64_len) {
const c = b64[b64_pos];
const d = charToByte(c.charCodeAt(0));
if (d == 0xffff) {
if (ignore && ignore.indexOf(c) >= 0) {
b64_pos++;
continue;
}
break;
}
acc = ((acc << 6) + d) & 0xfff;
acc_len += 6;
if (acc_len >= 8) {
acc_len -= 8;
bin[bin_len++] = (acc >> acc_len) & 0xff;
}
b64_pos++;
}
if (acc_len > 4 || (acc & ((1 << acc_len) - 1)) != 0) {
throw new Error("Non-canonical base64 encoding");
}
if (padding) {
skipPadding(b64.slice(b64_pos), ignore, acc_len / 2);
}
return new Uint8Array(bin.buffer, 0, bin_len);
}
/**
* Custom Base64 encoding/decoding, with support for arbitrary char<->maps.
*/
class Base64Codec {
/**
* Custom Base64 encoding/decoding.
*
* @param padding - whether padding is used.
* @param ignore - string of characters to ignore.
* @param charToByte - function to convert Base64 char to a byte.
* @param byteToChar - function to convert byte to a Base64 char.
*/
constructor(padding = false, ignore = null, charToByte, byteToChar) {
this._ignore = null;
this._padding = false;
this._padding = padding;
this._ignore = ignore;
this._charToByte = charToByte;
this._byteToChar = byteToChar;
}
/**
* Encode a buffer to Base64.
*
* @param data - a buffer to encode.
* @returns a Base64 encoded string.
*/
encode(data) {
return bin2Base64(data, this._padding, this._byteToChar);
}
/**
* Decode a Base64 string to a buffer.
*
* @param data - a Base64 string to decode.
* @returns a buffer.
* @throws an error if the string is not a valid Base64 string.
*/
decode(data) {
return base642Bin(data, this._padding, this._ignore, this._charToByte);
}
}
/**
* URL-safe Base64 encoding/decoding.
*/
class Base64UrlSafe extends Base64Codec {
/**
* URL-safe Base64 encoding/decoding.
*
* @param padding - whether padding is used.
* @param ignore - string of characters to ignore.
*/
constructor(padding = false, ignore = null) {
super(padding, ignore, charToByteUrlSafe, byteToCharUrlSafe);
}
}
</script>
<style>
:root {
background-color: #111;
color: #f3f3f3;
--copy-font: inter, gill sans, gill sans mt, segoe ui, sans-serif;
--monospace-font: monospace;
--link-color: #e9d;
--link-color-border: #848;
--link-color-bg: #414;
--header-color: #ffc;
--bold-text: #dcf;
font-family: var(--copy-font);
}
section {
display: flex;
flex-direction: column;
gap: 1rem;
}
fieldset {
display: flex;
flex-direction: column;
border-radius: 8px;
border-color: #666;
gap: 0.5rem;
}
input,
textarea,
button {
font-size: 1.25rem;
background-color: #333;
color: #eee;
border: 0;
border-radius: 5px;
padding: 8px 10px;
}
textarea {
font-size: 1rem;
font-family: var(--monospace-font);
}
input:disabled,
textarea:disabled {
opacity: 0.3;
}
input[type="radio"],
label {
cursor: pointer;
}
button {
border: 1px solid var(--link-color-border);
background-color: var(--link-color-bg);
color: var(--link-color);
cursor: pointer;
margin: 0 1rem;
}
main {
margin: 0 auto;
width: 100%;
max-width: 960px;
.pad-x {
padding: 0 1rem;
}
}
b {
color: var(--bold-text);
}
.hidden {
display: none;
}
.output-box {
position: relative;
border-radius: 5px;
padding: 5px 8px;
margin-top: 1rem;
&::before {
text-transform: uppercase;
font-weight: bold;
font-size: 10pt;
position: absolute;
left: 0px;
top: -20px;
}
& b {
color: #fff;
}
code {
user-select: all;
}
}
#error {
background-color: #512;
border: 1px solid #b23;
color: #fdd;
&::before {
content: "Error";
color: #f46;
}
}
#result {
background-color: #154;
border: 1px solid #afe;
color: #dff;
&::before {
content: "Result";
color: #bfe9e0;
}
}
h2 {
color: var(--header-color);
}
code {
background-color: #333;
display: inline-block;
padding: 2px 4px;
font-size: 1rem;
border-radius: 4px;
}
a,
a:visited {
color: var(--link-color);
}
</style>
</body>
</html>