From f60d09eb8f58a042f7d9430b3d75ef078ee95561 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 26 Jun 2023 15:02:50 +0200 Subject: [PATCH] :tada: Add uuid->short-id helper Mainly helps encode a safer subset of bits (96) of an uuid using a more compact encoding (base62) which is compatible with CSS and URL's --- common/src/app/common/encoding_impl.js | 211 +++++++++++++++++++++++++ common/src/app/common/uuid.cljc | 11 +- common/src/app/common/uuid_impl.js | 93 +++++++---- 3 files changed, 279 insertions(+), 36 deletions(-) create mode 100644 common/src/app/common/encoding_impl.js diff --git a/common/src/app/common/encoding_impl.js b/common/src/app/common/encoding_impl.js new file mode 100644 index 000000000..9af7d0fd5 --- /dev/null +++ b/common/src/app/common/encoding_impl.js @@ -0,0 +1,211 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ +"use strict"; + +goog.require("cljs.core"); +goog.provide("app.common.encoding_impl"); + +goog.scope(function() { + const core = cljs.core; + const global = goog.global; + const self = app.common.encoding_impl; + + const hexMap = []; + for (let i = 0; i < 256; i++) { + hexMap[i] = (i + 0x100).toString(16).substr(1); + } + + function hexToBuffer(input) { + if (typeof input !== "string") { + throw new TypeError("Expected input to be a string"); + } + + // Accept UUID hex format + input = input.replace(/-/g, ""); + + if ((input.length % 2) !== 0) { + throw new RangeError("Expected string to be an even number of characters") + } + + const view = new Uint8Array(input.length / 2); + + for (let i = 0; i < input.length; i += 2) { + view[i / 2] = parseInt(input.substring(i, i + 2), 16); + } + + return view.buffer; + } + + function bufferToHex(source, isUuid) { + if (source instanceof Uint8Array) { + } else if (ArrayBuffer.isView(source)) { + source = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + } else if (Array.isArray(source)) { + source = Uint8Array.from(source); + } + + if (source.length != 16) { + throw new RangeError("only 16 bytes array is allowed"); + } + + const spacer = isUuid ? "-" : ""; + + let i = 0; + return (hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]]); + } + + self.hexToBuffer = hexToBuffer; + self.bufferToHex = bufferToHex; + + // base-x encoding / decoding + // Copyright (c) 2018 base-x contributors + // Copyright (c) 2014-2018 The Bitcoin Core developers (base58.cpp) + // Distributed under the MIT software license, see the accompanying + // file LICENSE or http://www.opensource.org/licenses/mit-license.php. + + // WARNING: This module is NOT RFC3548 compliant, it cannot be used + // for base16 (hex), base32, or base64 encoding in a standards + // compliant manner. + + function getBaseCodec (ALPHABET) { + if (ALPHABET.length >= 255) { throw new TypeError("Alphabet too long"); } + let BASE_MAP = new Uint8Array(256); + for (let j = 0; j < BASE_MAP.length; j++) { + BASE_MAP[j] = 255; + } + for (let i = 0; i < ALPHABET.length; i++) { + let x = ALPHABET.charAt(i); + let xc = x.charCodeAt(0); + if (BASE_MAP[xc] !== 255) { throw new TypeError(x + " is ambiguous"); } + BASE_MAP[xc] = i; + } + let BASE = ALPHABET.length; + let LEADER = ALPHABET.charAt(0); + let FACTOR = Math.log(BASE) / Math.log(256); // log(BASE) / log(256), rounded up + let iFACTOR = Math.log(256) / Math.log(BASE); // log(256) / log(BASE), rounded up + function encode (source) { + if (source instanceof Uint8Array) { + } else if (ArrayBuffer.isView(source)) { + source = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + } else if (Array.isArray(source)) { + source = Uint8Array.from(source); + } + if (!(source instanceof Uint8Array)) { throw new TypeError("Expected Uint8Array"); } + if (source.length === 0) { return ""; } + // Skip & count leading zeroes. + let zeroes = 0; + let length = 0; + let pbegin = 0; + let pend = source.length; + while (pbegin !== pend && source[pbegin] === 0) { + pbegin++; + zeroes++; + } + // Allocate enough space in big-endian base58 representation. + let size = ((pend - pbegin) * iFACTOR + 1) >>> 0; + let b58 = new Uint8Array(size); + // Process the bytes. + while (pbegin !== pend) { + let carry = source[pbegin]; + // Apply "b58 = b58 * 256 + ch". + let i = 0; + for (let it1 = size - 1; (carry !== 0 || i < length) && (it1 !== -1); it1--, i++) { + carry += (256 * b58[it1]) >>> 0; + b58[it1] = (carry % BASE) >>> 0; + carry = (carry / BASE) >>> 0; + } + if (carry !== 0) { throw new Error("Non-zero carry"); } + length = i; + pbegin++; + } + // Skip leading zeroes in base58 result. + let it2 = size - length; + while (it2 !== size && b58[it2] === 0) { + it2++; + } + // Translate the result into a string. + let str = LEADER.repeat(zeroes); + for (; it2 < size; ++it2) { str += ALPHABET.charAt(b58[it2]); } + return str; + } + + function decodeUnsafe (source) { + if (typeof source !== "string") { throw new TypeError("Expected String"); } + if (source.length === 0) { return new Uint8Array(); } + let psz = 0; + // Skip and count leading '1's. + let zeroes = 0; + let length = 0; + while (source[psz] === LEADER) { + zeroes++; + psz++; + } + // Allocate enough space in big-endian base256 representation. + let size = (((source.length - psz) * FACTOR) + 1) >>> 0; // log(58) / log(256), rounded up. + let b256 = new Uint8Array(size); + // Process the characters. + while (source[psz]) { + // Decode character + let carry = BASE_MAP[source.charCodeAt(psz)]; + // Invalid character + if (carry === 255) { return; } + let i = 0; + for (let it3 = size - 1; (carry !== 0 || i < length) && (it3 !== -1); it3--, i++) { + carry += (BASE * b256[it3]) >>> 0; + b256[it3] = (carry % 256) >>> 0; + carry = (carry / 256) >>> 0; + } + if (carry !== 0) { throw new Error("Non-zero carry"); } + length = i; + psz++; + } + // Skip leading zeroes in b256. + let it4 = size - length; + while (it4 !== size && b256[it4] === 0) { + it4++; + } + let vch = new Uint8Array(zeroes + (size - it4)); + let j = zeroes; + while (it4 !== size) { + vch[j++] = b256[it4++]; + } + return vch; + } + + function decode (string) { + let buffer = decodeUnsafe(string); + if (buffer) { return buffer; } + throw new Error("Non-base" + BASE + " character"); + } + + return { + encode: encode, + decodeUnsafe: decodeUnsafe, + decode: decode + }; + } + // MORE bases here: https://github.com/cryptocoinjs/base-x/tree/master + const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + self.bufferToBase62 = getBaseCodec(BASE62).encode; + +}); diff --git a/common/src/app/common/uuid.cljc b/common/src/app/common/uuid.cljc index 0e713976e..a474e9bb8 100644 --- a/common/src/app/common/uuid.cljc +++ b/common/src/app/common/uuid.cljc @@ -4,9 +4,11 @@ ;; ;; Copyright (c) KALEIDOS INC +#_:clj-kondo/ignore (ns app.common.uuid - (:refer-clojure :exclude [next uuid zero?]) + (:refer-clojure :exclude [next uuid zero? short]) (:require + [app.common.data.macros :as dm] #?(:clj [clojure.core :as c]) #?(:cljs [app.common.uuid-impl :as impl]) #?(:cljs [cljs.core :as c])) @@ -66,3 +68,10 @@ (let [buf (ByteBuffer/wrap o)] (UUID. ^long (.getLong buf) ^long (.getLong buf))))) + +#?(:cljs + (defn uuid->short-id + "Return a shorter string of a safe subset of bytes of an uuid encoded + with base62. It is only safe to use with uuid v4 and penpot custom v8" + [id] + (impl/short-v8 (dm/str id)))) diff --git a/common/src/app/common/uuid_impl.js b/common/src/app/common/uuid_impl.js index fd257f6ab..8746d6ab2 100644 --- a/common/src/app/common/uuid_impl.js +++ b/common/src/app/common/uuid_impl.js @@ -8,13 +8,17 @@ "use strict"; goog.require("cljs.core"); +goog.require("app.common.encoding_impl"); goog.provide("app.common.uuid_impl"); goog.scope(function() { const core = cljs.core; const global = goog.global; + const encoding = app.common.encoding_impl; const self = app.common.uuid_impl; + const timeRef = 1640995200000; // ms since 2022-01-01T00:00:00 + const fill = (() => { if (typeof global.crypto !== "undefined" && typeof global.crypto.getRandomValues !== "undefined") { @@ -45,12 +49,8 @@ goog.scope(function() { } })(); - const hexMap = []; - for (let i = 0; i < 256; i++) { - hexMap[i] = (i + 0x100).toString(16).substr(1); - } - function toHexString(buf) { + const hexMap = encoding.hexMap; let i = 0; return (hexMap[buf[i++]] + hexMap[buf[i++]] + @@ -68,18 +68,7 @@ goog.scope(function() { hexMap[buf[i++]] + hexMap[buf[i++]] + hexMap[buf[i++]]); - } - - self.v4 = (function () { - const buff8 = new Uint8Array(16); - - return function v4() { - fill(buff8); - buff8[6] = (buff8[6] & 0x0f) | 0x40; - buff8[8] = (buff8[8] & 0x3f) | 0x80; - return core.uuid(toHexString(buff8)); - }; - })(); + }; function getBigUint64(view, byteOffset, le) { const a = view.getUint32(byteOffset, le); @@ -103,17 +92,54 @@ goog.scope(function() { } } - self.v8 = (function () { - const buff = new ArrayBuffer(16); + function currentTimestamp(timeRef) { + return BigInt.asUintN(64, "" + (Date.now() - timeRef)); + } + + const tmpBuff = new ArrayBuffer(8); + const tmpView = new DataView(tmpBuff); + const tmpInt8 = new Uint8Array(tmpBuff); + + function nextLong() { + fill(tmpInt8); + return getBigUint64(tmpView, 0, false); + } + + self.shortID = (function () { + const buff = new ArrayBuffer(8); const int8 = new Uint8Array(buff); const view = new DataView(buff); - const tmpBuff = new ArrayBuffer(8); - const tmpView = new DataView(tmpBuff); - const tmpInt8 = new Uint8Array(tmpBuff); + const base = 0x0000_0000_0000_0000n; - const timeRef = 1640995200000; // ms since 2022-01-01T00:00:00 - const maxCs = 0x0000_0000_0000_3fffn; // 14 bits space + return function shortID(ts) { + const tss = currentTimestamp(timeRef); + const msb = (base + | (nextLong() & 0xffff_ffff_0000_0000n) + | (tss & 0x0000_0000_ffff_ffffn)); + setBigUint64(view, 0, msb, false); + return encoding.toBase62(int8); + }; + })(); + + + self.v4 = (function () { + const arr = new Uint8Array(16); + + return function v4() { + fill(arr); + arr[6] = (arr[6] & 0x0f) | 0x40; + arr[8] = (arr[8] & 0x3f) | 0x80; + return core.uuid(encoding.bufferToHex(arr, true)); + }; + })(); + + self.v8 = (function () { + const buff = new ArrayBuffer(16); + const int8 = new Uint8Array(buff); + const view = new DataView(buff); + + const maxCs = 0x0000_0000_0000_3fffn; // 14 bits space let countCs = 0n; let lastRd = 0n; @@ -122,15 +148,6 @@ goog.scope(function() { let baseMsb = 0x0000_0000_0000_8000n; let baseLsb = 0x8000_0000_0000_0000n; - const currentTimestamp = () => { - return BigInt.asUintN(64, "" + (Date.now() - timeRef)); - }; - - const nextLong = () => { - fill(tmpInt8); - return getBigUint64(tmpView, 0, false); - }; - lastRd = nextLong() & 0xffff_ffff_ffff_f0ffn; lastCs = nextLong() & maxCs; @@ -145,12 +162,12 @@ goog.scope(function() { setBigUint64(view, 0, msb, false); setBigUint64(view, 8, lsb, false); - return core.uuid(toHexString(int8)); + return core.uuid(encoding.bufferToHex(int8, true)); }; const factory = function v8() { while (true) { - let ts = currentTimestamp(); + let ts = currentTimestamp(timeRef); // Protect from clock regression if ((ts - lastTs) < 0) { @@ -195,6 +212,12 @@ goog.scope(function() { })(); + self.short_v8 = function(uuid) { + const buff = encoding.hexToBuffer(uuid); + const short = new Uint8Array(buff, 4); + return encoding.bufferToBase62(short); + }; + self.custom = function formatAsUUID(mostSigBits, leastSigBits) { const most = mostSigBits.toString("16").padStart(16, "0"); const least = leastSigBits.toString("16").padStart(16, "0");