<!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>