feat: test auth

This commit is contained in:
thomas
2024-03-29 21:29:52 +01:00
parent 73a81f4831
commit 18b1dc0a70
18 changed files with 1749 additions and 386 deletions

View File

@@ -1,6 +1,7 @@
import starlight from '@astrojs/starlight'; import starlight from '@astrojs/starlight';
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import svelte from '@astrojs/svelte'; import svelte from '@astrojs/svelte';
import vercel from '@astrojs/vercel/serverless';
export const locales = { export const locales = {
root: { root: {
@@ -66,7 +67,7 @@ export default defineConfig({
// ru: 'Leaderboard' // ru: 'Leaderboard'
// } // }
// }, // },
{ {
label: 'Challenges', label: 'Challenges',
autogenerate: { autogenerate: {
directory: 'challenges' directory: 'challenges'
@@ -109,5 +110,7 @@ export default defineConfig({
}, },
defaultLocale: 'root', defaultLocale: 'root',
locales locales
}), svelte()] }), svelte()],
output: "hybrid",
adapter: vercel()
}); });

1559
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@astrojs/starlight": "^0.15.1", "@astrojs/starlight": "^0.15.1",
"@astrojs/svelte": "^5.2.0", "@astrojs/svelte": "^5.2.0",
"@astrojs/vercel": "^7.5.0",
"@fontsource/ibm-plex-serif": "^5.0.8", "@fontsource/ibm-plex-serif": "^5.0.8",
"astro": "^4.0.0", "astro": "^4.0.0",
"sharp": "^0.32.5", "sharp": "^0.32.5",

View File

@@ -6,6 +6,7 @@ import { getEntry } from 'astro:content';
const { lang } = Astro.props; const { lang } = Astro.props;
const { data } = await getEntry('i18n', lang); const { data } = await getEntry('i18n', lang);
--- ---
<div class="action-footer"> <div class="action-footer">
@@ -37,7 +38,6 @@ const { data } = await getEntry('i18n', lang);
text-decoration: none; text-decoration: none;
font-size: var(--sl-text-sm) !important; font-size: var(--sl-text-sm) !important;
border: 1px solid; border: 1px solid;
font-size: var(--sl-text-base);
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
} }

View File

@@ -1,67 +0,0 @@
<script>
import { onMount } from 'svelte';
let error = false;
let loading = true;
let stargazersCount = 0;
let forksCount = 0;
async function fetchStats() {
try {
const response = await fetch(`https://api.github.com/repos/tomalaforge/angular-challenges`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const { stargazers_count, forks } = await response.json();
stargazersCount = stargazers_count;
forksCount = forks;
} catch (e) {
error = true;
} finally {
loading = false;
}
}
onMount(() => {
fetchStats();
});
</script>
{#if !error && !loading}
<div class="github">
<a class="category" href="https://github.com/tomalaforge/angular-challenges" target="_blank">
<slot name="star"/>
<div>{stargazersCount}</div>
</a>
<a class="category" href="https://github.com/tomalaforge/angular-challenges/fork" target="_blank">
<slot name="fork"/>
<div>{forksCount}</div>
</a>
</div>
{/if}
<style>
.github {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-left: var(--sl-nav-gap);
}
.category {
display: flex;
align-items: center;
font-size: 12px;
gap: 0.25rem;
color: var(--sl-color-text);
text-decoration: none;
}
.category:hover {
color: var(--sl-color-accent-high);
}
</style>

View File

@@ -1,19 +1,20 @@
--- ---
import Default from '@astrojs/starlight/components/SiteTitle.astro'; import Default from '@astrojs/starlight/components/SiteTitle.astro';
import GitHubStats from './GitHubStats.svelte';
import MyIcon from './MyIcon.astro'; import MyIcon from './MyIcon.astro';
import SignUp from './github/SignUp.svelte'; import SignUp from './github/SignUp.svelte';
import { Icon } from '@astrojs/starlight/components';
--- ---
<Default {...Astro.props}> <Default {...Astro.props}>
<slot /> <slot />
</Default> </Default>
<SignUp client:load />
<!--<GitHubStats client:load >--> <SignUp client:only="svelte" >
<!-- <MyIcon name="star" slot="star" />--> <Icon name="github" slot="github" />
<!-- <MyIcon name="fork" viewBox="0 0 16 16" slot="fork"/>--> <MyIcon name="fullStar" slot="fullStar" viewBox="0 0 16 16" fill="#e3b341"/>
<!--</GitHubStats>--> <MyIcon name="star" slot="star" viewBox="0 0 16 16"/>
<MyIcon name="fork" viewBox="0 0 16 16" slot="fork"/>
</SignUp>

View File

@@ -1,18 +1,27 @@
<script> <script>
import { onMount } from 'svelte';
import { data, error, isLoaded, isLoading, token, totalCount } from './github-store'; import { data, error, isLoaded, isLoading, token, totalCount } from './github-store';
export let challengeNumber; export let challengeNumber;
let page = 1; let page = 1;
token.subscribe(token => {
if (token) {
fetchTotalCount();
}
})
async function fetchTotalCount() { async function fetchTotalCount() {
isLoading.set(true); isLoading.set(true);
try { try {
while (true) { while (true) {
const response = await fetch(`https://api.github.com/search/issues?q=repo:tomalaforge/angular-challenges+is:pr+label:"${challengeNumber}"+label:"answer"&per_page=100&page=${page}`); const response = await fetch(`https://api.github.com/search/issues?q=repo:tomalaforge/angular-challenges+is:pr+label:"${challengeNumber}"+label:"answer"&per_page=100&page=${page}`, {
headers: {
Authorization: `Bearer ${$token}`,
},
});
if (!response.ok) { if (!response.ok) {
throw new Error('Network response was not ok'); throw new Error('Failed to fetch data');
} }
const { items: new_items, total_count } = await response.json(); const { items: new_items, total_count } = await response.json();
if (!new_items || new_items.length === 0) break; if (!new_items || new_items.length === 0) break;
@@ -30,13 +39,8 @@
} }
} }
onMount(() => {
fetchTotalCount();
});
</script> </script>
token: {$token}
{#if $isLoaded} {#if $isLoaded}
<div class="solution-container" id="answers"> <div class="solution-container" id="answers">
<div>Answered by</div> <div>Answered by</div>

View File

@@ -0,0 +1,144 @@
<script>
import { token } from './github-store';
let error = false;
let loading = true;
let loadingStar = true;
let stargazersCount = 0;
let forksCount = 0;
let isStarByUser = false;
token.subscribe(token => {
if (token) {
fetchStats();
isStar();
}
});
async function starRepo() {
try {
const response = await fetch(`https://api.github.com/user/starred/tomalaforge/angular-challenges`, {
method: 'PUT',
headers: {
Authorization: `token ${$token}`
}
});
if (response.ok) {
isStarByUser = !isStarByUser;
console.log('Starred', isStarByUser);
}
} catch (e) {
console.error(e);
}
}
async function isStar() {
try {
const response = await fetch(`https://api.github.com/user/starred/tomalaforge/angular-challenges`, {
method: 'GET',
headers: {
Authorization: `token ${$token}`
}
});
if (response.ok && response.status === 204) {
isStarByUser = true;
}
} catch (e) {
console.error(e);
} finally {
loadingStar = false;
}
}
async function fetchStats() {
try {
const response = await fetch(`https://api.github.com/repos/tomalaforge/angular-challenges`);
if (!response.ok) {
if (response.status === 401) {
const refresh = await fetch('/auth/refresh');
if (refresh.ok) {
const data = await refresh.json();
token.set(data.token);
return;
}
} else {
throw new Error('Failed to fetch data');
}
}
const { stargazers_count, forks } = await response.json();
stargazersCount = stargazers_count;
forksCount = forks;
} catch (e) {
error = true;
} finally {
loading = false;
}
}
</script>
{#if !error && !loading && !loadingStar}
<div class="github">
{#if isStarByUser}
<div class="category starred">
<slot name="fullStar" />
<span>{stargazersCount}</span>
</div>
{:else}
<button class="button-star link" on:click={starRepo}>
<slot name="star" />
<span>{stargazersCount}</span>
</button>
{/if}
<a class="category link" href="https://github.com/tomalaforge/angular-challenges/fork" target="_blank">
<slot name="fork" />
<div>{forksCount}</div>
</a>
</div>
{/if}
<style>
.github {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-left: var(--sl-nav-gap);
}
.button-star {
display: flex !important;
justify-content: center;
gap: 0.5em;
align-items: center;
border-radius: 999rem;
color: var(--sl-color-white) !important;
background-color: transparent;
line-height: 1.1875;
text-decoration: none;
font-size: var(--sl-text-xs);
border: 1px solid;
padding: 0.2rem 0.4rem;
margin-left: -6px
}
.category {
display: flex;
align-items: center;
font-size: 12px;
gap: 0.25rem;
color: var(--sl-color-text);
text-decoration: none;
}
.link:hover {
color: var(--sl-color-accent-high);
}
.starred {
color: #e3b341;
}
</style>

View File

@@ -1,38 +1,61 @@
<script> <script>
import { loadToken, test } from './github-store'; import GitHubStats from './GitHubStats.svelte';
import { loadToken, token } from './github-store';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
// Function to redirect the user to GitHub's signup page
async function redirectToGitHubSignup() {
// token.set('test-token');
window.location.href = `/auth/authorize`;
// await fetch(`/auth/authorize?redirect_uri=${window.location.href}&state=jsqhd&client_id=Iv1.711903007f608691`)
}
onMount(() => { onMount(() => {
loadToken(); const searchParams = new URLSearchParams(window.location.search);
const t = searchParams.get('token');
if(t) {
token.set(t);
window.location.href = `${window.location.origin}${window.location.pathname}`;
} else {
loadToken();
}
}); });
</script> </script>
{$test} {#if !$token}
<button on:click={redirectToGitHubSignup}> <a href={`/auth/authorize?redirect_uri=${window.location.href}`}>
Sign Up for GitHub <slot name="github"/>
</button> <span class="github-sign-in">Sign in</span>
</a>
{:else}
<GitHubStats>
<slot name="fullStar" slot="fullStar"/>
<slot name="star" slot="star"/>
<slot name="fork" slot="fork"/>
</GitHubStats>
{/if}
<style> <style>
button { a {
background-color: #2ea44f; background-color: #238636;
display: flex;
gap: 0.25rem;
text-decoration: none;
color: white; color: white;
border: none; border: none;
padding: 10px 20px; padding: 8px 8px;
cursor: pointer; cursor: pointer;
font-size: 16px;
border-radius: 5px; border-radius: 5px;
align-items: center;
height: fit-content;
font-size:14px;
line-height: 14px;
margin-left: var(--sl-nav-gap);
} }
button:hover { a:hover {
background-color: #218838; background-color: #218838;
} }
@media (width < 450px) {
.github-sign-in {
display: none;
}
}
</style> </style>

View File

@@ -15,20 +15,18 @@ export const isLoaded = derived(
const TOKEN_KEY = 'TOKEN'; const TOKEN_KEY = 'TOKEN';
export function loadToken() { export function loadToken() {
// Get the current value from localStorage if it exists, otherwise use the startValue const persistedToken = localStorage.getItem(TOKEN_KEY);
const persistedValue = localStorage.getItem(TOKEN_KEY); if (persistedToken) {
if (persistedValue) { token.set(JSON.parse(persistedToken));
token.set(JSON.parse(persistedValue));
return;
} }
token.set('API call');
} }
token.subscribe((value) => { token.subscribe((value) => {
if (value) { if (value) {
if (value === 'delete') {
localStorage.removeItem(TOKEN_KEY);
return;
}
localStorage.setItem(TOKEN_KEY, JSON.stringify(value)); localStorage.setItem(TOKEN_KEY, JSON.stringify(value));
} }
}); });
export const test = writable('test');

View File

@@ -0,0 +1,45 @@
export function tooltip(element) {
let div;
let title;
function mouseOver(event) {
// NOTE: remove the `title` attribute, to prevent showing the default browser tooltip
// remember to set it back on `mouseleave`
title = element.getAttribute('title');
element.removeAttribute('title');
div = document.createElement('div');
div.textContent = title;
div.style = `
border: 1px solid #ddd;
box-shadow: 1px 1px 1px #ddd;
background: white;
border-radius: 4px;
padding: 4px;
position: absolute;
top: ${event.pageX + 5}px;
left: ${event.pageY + 5}px;
`;
document.body.appendChild(div);
}
function mouseMove(event) {
div.style.left = `${event.pageX + 5}px`;
div.style.top = `${event.pageY + 5}px`;
}
function mouseLeave() {
document.body.removeChild(div);
// NOTE: restore the `title` attribute
element.setAttribute('title', title);
}
element.addEventListener('mouseover', mouseOver);
element.addEventListener('mouseleave', mouseLeave);
element.addEventListener('mousemove', mouseMove);
return {
destroy() {
element.removeEventListener('mouseover', mouseOver);
element.removeEventListener('mouseleave', mouseLeave);
element.removeEventListener('mousemove', mouseMove);
}
}
}

View File

@@ -1,6 +1,8 @@
export const Icons = { export const Icons = {
heart: heart:
'<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>', '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>',
star: '<path d="M22 9.67a1 1 0 0 0-.86-.67l-5.69-.83L12.9 3a1 1 0 0 0-1.8 0L8.55 8.16 2.86 9a1 1 0 0 0-.81.68 1 1 0 0 0 .25 1l4.13 4-1 5.68a1 1 0 0 0 1.45 1.07L12 18.76l5.1 2.68c.14.08.3.12.46.12a1 1 0 0 0 .99-1.19l-1-5.68 4.13-4A1 1 0 0 0 22 9.67Zm-6.15 4a1 1 0 0 0-.29.89l.72 4.19-3.76-2a1 1 0 0 0-.94 0l-3.76 2 .72-4.19a1 1 0 0 0-.29-.89l-3-3 4.21-.61a1 1 0 0 0 .76-.55L12 5.7l1.88 3.82a1 1 0 0 0 .76.55l4.21.61-3 2.99Z"/>', star: '<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z"/>',
fullStar:
'<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/>',
fork: '<path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z"/>', fork: '<path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z"/>',
}; };

View File

@@ -1,32 +1,8 @@
import { defineMiddleware } from 'astro/middleware'; import { defineMiddleware } from 'astro:middleware';
const GITHUB_OAUTH_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize';
// `context` and `next` are automatically typed
export const onRequest = defineMiddleware((context, next) => { export const onRequest = defineMiddleware((context, next) => {
console.log(context.url.pathname); console.log(context.cookies);
if (context.url.pathname !== '/auth') {
return next();
}
const appReturnUrl = context.request.url; return next();
const url = new URL(context.request.url);
console.log('je rentre ici');
console.log(context.url);
console.log(context.site);
// console.dir(context.params);
// if (!appReturnUrl) {
// res.status(400).json({ error: '`redirect_uri` is required.' });
// return;
// }
// const { client_id } = env;
// const redirect_uri = `http://${context.headers.host}/api/oauth/authorized`;
// const state = await encodeState(appReturnUrl, env.encryption_password);
//
// const oauthParams = new URLSearchParams({ client_id, redirect_uri, state });
// context.redirect(`${GITHUB_OAUTH_AUTHORIZE_URL}?${oauthParams}`, 302);
return Response.redirect(new URL('/', context.url));
}); });

View File

@@ -1,11 +1,21 @@
import { toHexString } from '../../utils/encrypt';
export const prerender = false;
const GITHUB_OAUTH_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_OAUTH_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize';
export async function GET({params, redirect}) { export async function GET({url, redirect}) {
console.log('Authorize request', params); const myUrl = new URL(url);
const params = new URLSearchParams(myUrl.search);
const redirectUrl = params.get('redirect_uri');
const redirect_uri = 'http://localhost:4321/auth/localized' const { GITHUB_CLIENT_ID } = import.meta.env;
const oauthParams = new URLSearchParams({ client_id:'Iv1.711903007f608691' , redirect_uri, state: 'lqsksqd' });
const redirect_uri = 'http://localhost:4321/auth/authorized'
const state = toHexString(redirectUrl);
const oauthParams = new URLSearchParams({ client_id:GITHUB_CLIENT_ID , redirect_uri, state });
return redirect(`${GITHUB_OAUTH_AUTHORIZE_URL}?${oauthParams}`, 302) return redirect(`${GITHUB_OAUTH_AUTHORIZE_URL}?${oauthParams}`, 302)
} }

View File

@@ -1,12 +1,64 @@
const GITHUB_OAUTH_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'; import { fromHexString } from '../../utils/encrypt';
export async function GET({params, request}) { export const prerender = false;
console.log('Authorized', params); const GITHUB_OAUTH_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const TOKEN_VALIDITY_PERIOD = 1000 * 60 * 60 * 24 * 365; // 1 year;
export async function GET({ url, redirect, cookies}) {
return new Response({ const myUrl = new URL(url);
status: 302, const params = new URLSearchParams(myUrl.search);
path: `/`, const code = params.get('code');
}); const state = params.get('state');
const error = params.get('error');
const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } = import.meta.env;
const redirectUrl = new URL(fromHexString(state));
console.log('Authorized', GITHUB_CLIENT_ID);
if (error && error === 'access_denied') {
redirect(redirectUrl.href, 302);
return;
}
const init = {
method: 'POST',
body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, client_secret: GITHUB_CLIENT_SECRET , code, state }),
headers: {
Accept: 'application/json'
},
};
let accessToken = '';
let refreshToken = '';
try {
const response = await fetch(GITHUB_OAUTH_ACCESS_TOKEN_URL, init);
if (response.ok) {
const data = await response.json();
accessToken = data.access_token;
refreshToken = data.refresh_token;
} else {
throw new Error(`Access token response had status ${response.status}.`);
}
} catch (err) {
return new Response(
JSON.stringify({
error: err.message
}), {
status: 503
}
)
return;
}
// cookies.set('token', accessToken, { expires: new Date(Date.now() + TOKEN_VALIDITY_PERIOD), secure: true, httpOnly: true, path: '/' });
cookies.set('refresh', refreshToken, { secure: true, httpOnly: true, path: '/' });
redirectUrl.searchParams.set('token', accessToken);
// redirectUrl.searchParams.set('refresh', refreshToken);
return redirect(redirectUrl.href, 302);
} }

View File

@@ -0,0 +1,62 @@
export const prerender = false;
const GITHUB_OAUTH_REFRESH_TOKEN = 'https://github.com/login/oauth/access_token';
export async function GET({cookies}) {
const refresh_token = cookies.get('refresh').value;
const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } = import.meta.env;
const init = {
method: 'POST',
body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, client_secret: GITHUB_CLIENT_SECRET, grant_type: "refresh_token" , refresh_token }),
headers: {
Accept: 'application/json'
},
};
let accessToken = '';
let refreshToken = '';
try {
const response = await fetch(GITHUB_OAUTH_REFRESH_TOKEN, init);
if (response.ok) {
const data = await response.json();
if(data.error) {
cookies.delete('refresh');
return new Response(
JSON.stringify({
token: 'delete'
}), {
status: 200
}
)
}
accessToken = data.access_token;
refreshToken = data.refresh_token;
} else {
throw new Error(`Access token response had status ${response.status}.`);
}
} catch (err) {
return new Response(
JSON.stringify({
error: err.message
}), {
status: 503
}
)
}
cookies.set('refresh', refreshToken, { secure: true, httpOnly: true, path: '/' });
return new Response(
JSON.stringify({
token: accessToken
}), {
status: 200
}
)
}

View File

@@ -58,11 +58,13 @@ html {
} }
a.action, a.action,
.button-star,
.article-footer > a, .article-footer > a,
.action-footer > a.action-button { .action-footer > a.action-button {
transition: var(--button-transition); transition: var(--button-transition);
&:hover { &:hover {
cursor: pointer;
opacity: var(--button-hover-opacity); opacity: var(--button-hover-opacity);
transition: var(--button-transition); transition: var(--button-transition);
@@ -189,3 +191,8 @@ details {
.body:where(.astro-v5tidmuc) { .body:where(.astro-v5tidmuc) {
height: 100%; height: 100%;
} }
.astro-kmkmnagf {
align-items: center;
}

15
docs/src/utils/encrypt.ts Normal file
View File

@@ -0,0 +1,15 @@
export function toHexString(value: string): string {
return value
.split('')
.map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
.join('');
}
export function fromHexString(hexString: string): string {
return (
hexString
.match(/.{1,2}/g)
?.map((byte) => String.fromCharCode(parseInt(byte, 16)))
.join('') ?? ''
);
}