499 lines
14 KiB
HTML
499 lines
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>
|