diff --git a/src/common/common.css b/src/common/common.css index 3cfe324..294b5f5 100644 --- a/src/common/common.css +++ b/src/common/common.css @@ -1,6 +1,6 @@ :root { - --color-master: #488e77; - --color-slave: #458593; + --color-master: #499375; + --color-slave: #43889d; --color-error: rgb(245, 81, 69); --color-gradient-0: linear-gradient(130deg, var(--color-master), var(--color-slave)); --color-gradient-1: linear-gradient(130deg, #ff7a18, #af002d 41.07%, #319197 76.05%); diff --git a/src/common/crypto.ts b/src/common/crypto.ts index c6d27d6..c5b5d39 100644 --- a/src/common/crypto.ts +++ b/src/common/crypto.ts @@ -1,7 +1,7 @@ import { getExtensionSettingsAsync } from "./settings" import { setSetting } from "./useSettings" -export async function generateKeys() { +async function generateKeys() { const keys = await window.crypto.subtle.generateKey( { name: "RSASSA-PKCS1-v1_5", @@ -58,31 +58,154 @@ export async function sign(data: string, privateKey: string) { )).toString('base64') } -export async function loginAndSetNickname() { - const settings = await getExtensionSettingsAsync() +export function resetProfileSettings() { + setSetting('publicKey', null) + setSetting('privateKey', null) +} - let nickname; - while (true) - { +export async function generateProfileAndSetNickname(overwrite = false) { + let { publicKey, privateKey } = await getExtensionSettingsAsync() + + let nickname + while (true) { nickname = prompt("Pick a nickname") if (nickname) break if (nickname === null) return alert("Invalid nickname") } - if (!settings.privateKey || !settings.publicKey) + if (overwrite || !privateKey || !publicKey) { + resetProfileSettings() await generateKeys().then((keys) => { - setSetting('publicKey', keys.publicKey) - setSetting('privateKey', keys.privateKey) + publicKey = keys.publicKey + privateKey = keys.privateKey }) - + } + const url = new URL('https://finder.madiator.com/api/v1/profile') url.searchParams.set('data', JSON.stringify({ nickname })) url.searchParams.set('keys', JSON.stringify({ - signature: await sign(url.searchParams.toString(), settings.privateKey!), - publicKey: settings.publicKey + signature: await sign(url.searchParams.toString(), privateKey!), + publicKey })) const respond = await fetch(url.href, { method: "POST" }) - if (respond.ok) alert(`Your nickname has been set to ${nickname}`) + if (respond.ok) { + setSetting('publicKey', publicKey) + setSetting('privateKey', privateKey) + alert(`Your nickname has been set to ${nickname}`) + } else alert((await respond.json()).message) +} + +export async function purgeProfile() { + try { + if (!confirm("This will purge all of your online and offline profile data.\nStill wanna continue?")) return + const settings = await getExtensionSettingsAsync() + + if (!settings.privateKey || !settings.publicKey) + throw new Error('There is no profile to be purged.') + + const url = new URL('https://finder.madiator.com/api/v1/profile/purge') + url.searchParams.set('keys', JSON.stringify({ + signature: await sign(url.searchParams.toString(), settings.privateKey!), + publicKey: settings.publicKey + })) + const respond = await fetch(url.href, { method: "POST" }) + if (respond.ok) { + resetProfileSettings() + alert(`Your profile has been purged`) + } + else throw new Error((await respond.json()).message) + } catch (error: any) { + alert(error.message) + } +} + +export async function getProfile() { + try { + const settings = await getExtensionSettingsAsync() + + if (!settings.privateKey || !settings.publicKey) + throw new Error('There is no profile.') + + const url = new URL('https://finder.madiator.com/api/v1/profile') + url.searchParams.set('data', JSON.stringify({ publicKey: settings.publicKey })) + url.searchParams.set('keys', JSON.stringify({ + signature: await sign(url.searchParams.toString(), settings.privateKey!), + publicKey: settings.publicKey + })) + const respond = await fetch(url.href, { method: "GET" }) + if (respond.ok) { + const profile = await respond.json() as { nickname: string, score: number, publickKey: string } + return profile + } + else throw new Error((await respond.json()).message) + } catch (error: any) { + console.error(error) + } +} + +export function friendlyPublicKey(publicKey: string | null) { + // This is copy paste of Madiator Finder's friendly public key + return publicKey?.substring(publicKey.length - 64, publicKey.length - 32) +} + +function download(data: string, filename: string, type: string) { + const file = new Blob([data], { type: type }) + const a = document.createElement("a") + const url = URL.createObjectURL(file) + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + setTimeout(() => { + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + }) +} + +async function readFile() { + return await new Promise((resolve) => { + const input = document.createElement("input") + input.type = 'file' + input.accept = '.wol-keys.json' + + input.click() + input.addEventListener("change", () => { + if (!input.files?.[0]) return + const myFile = input.files[0] + const reader = new FileReader() + + reader.addEventListener('load', () => resolve(reader.result?.toString() ?? null)) + reader.readAsBinaryString(myFile) + }) + }) +} + +interface ExportedProfileKeysFile { + publicKey: string + privateKey: string +} + +export async function exportProfileKeysAsFile() { + const { publicKey, privateKey } = await getExtensionSettingsAsync() + + const json = JSON.stringify({ + publicKey, + privateKey + }) + + download(json, `watch-on-lbry-profile-export-${friendlyPublicKey(publicKey)}.wol-keys.json`, 'application/json') +} + +export async function importProfileKeysFromFile() { + try { + const json = await readFile() + if (!json) throw new Error("Invalid") + const { publicKey, privateKey } = JSON.parse(json) as ExportedProfileKeysFile + setSetting('publicKey', publicKey) + setSetting('privateKey', privateKey) + } catch (error: any) { + alert(error.message) + } } \ No newline at end of file diff --git a/src/common/useSettings.ts b/src/common/useSettings.ts index 8199f35..5d7b8f1 100644 --- a/src/common/useSettings.ts +++ b/src/common/useSettings.ts @@ -1,5 +1,4 @@ import { useEffect, useReducer } from 'preact/hooks' -import { generateKeys } from './crypto' import { DEFAULT_SETTINGS, ExtensionSettings } from './settings' /** @@ -7,7 +6,7 @@ import { DEFAULT_SETTINGS, ExtensionSettings } from './settings' * * @param defaultSettings the default value. Must have all relevant keys present and should not change */ -export function useSettings(defaultSettings: ExtensionSettings) { +function useSettings(defaultSettings: ExtensionSettings) { const [state, dispatch] = useReducer((state, nstate: Partial) => ({ ...state, ...nstate }), defaultSettings) const settingsKeys = Object.keys(defaultSettings) // register change listeners, gets current values, and cleans up the listeners on unload diff --git a/src/popup/popup.css b/src/popup/popup.css index 66cc6a6..2089cb9 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -1,15 +1,55 @@ -.popup { - width: 35em; - max-width: 100%; - overflow: hidden; +:root { + --color-master: #499375; + --color-slave: #43889d; + --color-error: rgb(245, 81, 69); + --color-gradient-0: linear-gradient(130deg, var(--color-master), var(--color-slave)); + --color-gradient-1: linear-gradient(130deg, #ff7a18, #af002d 41.07%, #319197 76.05%); + --color-dark: #0e1117; + --color-light: rgb(235, 237, 241); + --gradient-animation: gradient-animation 5s linear infinite alternate; +} + +:root { + letter-spacing: .2ch; +} + +body { + background: linear-gradient(to left top, var(--color-master), var(--color-slave)); + background-attachment: fixed; + color: var(--color-light); + padding: 0; + margin: 0; +} + +body::before { + content: ""; + position: absolute; + inset: 0; + background: rgba(19, 19, 19, 0.75); +} + +*, +*::before, +*::after { + box-sizing: border-box; + position: relative; } label { - font-size: 2em; + font-size: 1.75em; font-weight: bold; text-align: center; } +a { + cursor: pointer; +} + +p { + margin: 0; + text-align: center; +} + header { display: grid; position: sticky; @@ -19,27 +59,99 @@ header { padding: .5em .2em; } +.button { + display: inline-flex; + place-items: center; + text-align: center; + padding: .5em 1em; + + background: var(--color-dark); + color: var(--color-light); + + cursor: pointer; + border-radius: .5em; +} + +.filled { + background: var(--color-gradient-0); + background-clip: text; + -webkit-background-clip: text; + font-weight: bold; + color: transparent; +} + +.button.active { + background: var(--color-gradient-0); +} + +.button.active::before { + content: ""; + position: absolute; + inset: 0; + background: var(--color-gradient-0); + filter: blur(.5em); + z-index: -1; + border-radius: .5em; +} + +.button.disabled { + filter: saturate(0); + pointer-events: none; +} + +.go-back { + color: currentColor; + text-decoration: none; + font-size: 1.5em; +} + +.popup { + width: 35em; + max-width: 100%; + overflow: hidden; +} + +.overlay { + display: grid; + place-items: center; + position: fixed; + inset: 0; + font-size: 2em; + font-weight: bold; +} + +.overlay::before { + content: ''; + position: absolute; + inset: 0; + background-color: #0e1117; + opacity: .75; +} + .profile { display: grid; grid-template-columns: auto auto; align-items: center; gap: .25em; + font-size: 1.5em; } .profile .name { - display: inline-block; - width: 10em; - max-width: 100%; + display: inline-flex; + gap: .5em; + place-items: center; + max-width: min(10em, 100%); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: bold; + color: currentColor; } -.profile::before { +.profile .name::before { content: ''; display: inline-block; - width: 4ch; + width: 1ch; aspect-ratio: 1/1; background: var(--color-gradient-0); border-radius: 100000%; @@ -47,17 +159,14 @@ header { main { display: grid; - gap: 1em; - padding: .25em; -} + gap: 2em; + padding: 1.5em 0.5em; +} section { display: grid; justify-items: center; gap: .75em; - background: rgba(19, 19, 19, 0.75); - border-radius: 1em; - padding: .5em; } .options { @@ -66,4 +175,5 @@ section { grid-template-columns: repeat(auto-fit, minmax(3em, 1fr)); justify-content: center; gap: .25em; + padding: 0 1.5em; } \ No newline at end of file diff --git a/src/popup/popup.tsx b/src/popup/popup.tsx index 54dd406..9357fab 100644 --- a/src/popup/popup.tsx +++ b/src/popup/popup.tsx @@ -1,72 +1,162 @@ import { h, render } from 'preact' import { useState } from 'preact/hooks' +import '../common/common.css' +import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, importProfileKeysFromFile, purgeProfile, resetProfileSettings } from '../common/crypto' import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries } from '../common/settings' import { setSetting, useLbrySettings } from '../common/useSettings' -import '../common/common.css' -import './popup.css' import { LbryPathnameCache } from '../common/yt/urlCache' -import { loginAndSetNickname } from '../common/crypto' +import './popup.css' /** Gets all the options for redirect destinations as selection options */ const targetPlatforms = getTargetPlatfromSettingsEntiries() - const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries() -function WatchOnLbryPopup() { +function WatchOnLbryPopup(params: { profile: Awaited> | null }) { const { redirect, targetPlatform, urlResolver, privateKey, publicKey } = useLbrySettings() - let [clearingCache, updateClearingCache] = useState(() => false) + let [loading, updateLoading] = useState(() => false) + let [popupRoute, updateRoute] = useState(() => null) + + const nickname = params.profile ? params.profile.nickname ?? 'No Nickname' : '...' + + async function startAsyncOperation(operation: Promise) { + try { + updateLoading(true) + await operation + } catch (error) { + console.error(error) + } + finally { + updateLoading(false) + } + } return
-
-
- - -
-
- -
- {targetPlatforms.map(([name, value]) => - setSetting('targetPlatform', name)} className={`button ${targetPlatform === name ? 'active' : ''}`}> - {value.displayName} + { + popupRoute === 'profile' ? + publicKey ? +
+ updateRoute('')} className="go-back link">⇐ Back +
+ +

{friendlyPublicKey(publicKey)}

+ Score: {params.profile?.score ?? '...'} - 🔗Leaderboard + +
+
+ +

Import and export your unique keypair.

+ +
+
+ +

Purge your profile data online and offline.

+ +
+
+ +

Generate a new keypair.

+ +
+
+ : +
+
+ +

You can either import keypair for an existing profile or generate a new profile keypair.

+ +
+
+ : +
+
+ + +
+
+ + +
+
+ + + startAsyncOperation(LbryPathnameCache.clearAll()).then(() => alert("Cleared Cache!"))} className={`button active`}> + Clear Resolver Cache - )} -
-
-
- -
+
+ + + Subscription Converter - )} -
- { - updateClearingCache(true) - await LbryPathnameCache.clearAll() - updateClearingCache(false) - alert("Cleared Cache!") - }} className={`button active ${clearingCache ? 'disabled' : ''}`}> - Clear Resolver Cache - - - + + + } + {loading &&
+ Loading... +
} } -render(, document.getElementById('root')!) +function renderPopup() { + render(, document.getElementById('root')!) + getProfile().then((profile) => render(, document.getElementById('root')!)) +} + +renderPopup() \ No newline at end of file diff --git a/src/scripts/storageSetup.ts b/src/scripts/storageSetup.ts index 7f77360..e79a159 100644 --- a/src/scripts/storageSetup.ts +++ b/src/scripts/storageSetup.ts @@ -1,6 +1,4 @@ -import { generateKeys } from '../common/crypto' import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync } from '../common/settings' -import { setSetting } from '../common/useSettings' /** Reset settings to default value and update the browser badge text */ async function initSettings() {