diff --git a/src/common/crypto.ts b/src/common/crypto.ts new file mode 100644 index 0000000..2b80a45 --- /dev/null +++ b/src/common/crypto.ts @@ -0,0 +1,56 @@ +export async function generateKeys() { + const keys = await window.crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + // Consider using a 4096-bit key for systems that require long-term security + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"] + ) + + return { + publicKey: await exportPublicKey(keys.publicKey), + privateKey: await exportPrivateKey(keys.privateKey) + } +} + +async function exportPrivateKey(key: CryptoKey) { + const exported = await window.crypto.subtle.exportKey( + "pkcs8", + key + ) + return Buffer.from(exported).toString('base64') +} + +async function exportPublicKey(key: CryptoKey) { + const exported = await window.crypto.subtle.exportKey( + "spki", + key + ) + return Buffer.from(exported).toString('base64') +} + +function importPrivateKey(base64: string) { + + return window.crypto.subtle.importKey( + "pkcs8", + Buffer.from(base64, 'base64'), + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + }, + true, + ["sign"] + ) +} + +export async function sign(data: string, privateKey: string) { + return Buffer.from(await window.crypto.subtle.sign( + { name: "RSASSA-PKCS1-v1_5" }, + await importPrivateKey(privateKey), + Buffer.from(data) + )) +} \ No newline at end of file diff --git a/src/common/settings.ts b/src/common/settings.ts index 5eb0210..56cb35e 100644 --- a/src/common/settings.ts +++ b/src/common/settings.ts @@ -4,16 +4,22 @@ export interface ExtensionSettings { redirect: boolean targetPlatform: TargetPlatformName urlResolver: YTUrlResolverName + publicKey?: string, + privateKey?: string } -export const DEFAULT_SETTINGS: ExtensionSettings = { redirect: true, targetPlatform: 'odysee', urlResolver: 'lbryInc' } + + +export const DEFAULT_SETTINGS: ExtensionSettings = { + redirect: true, + targetPlatform: 'odysee', + urlResolver: 'odyseeApi' +} export function getExtensionSettingsAsync(): Promise { return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any))) } - - const targetPlatform = (o: { domainPrefix: string displayName: string @@ -69,8 +75,6 @@ export const targetPlatformSettings = { - - const sourcePlatform = (o: { hostnames: string[] htmlQueries: { @@ -111,12 +115,16 @@ export type YTUrlResolver = ReturnType export type YTUrlResolverName = Extract export const getYtUrlResolversSettingsEntiries = () => Object.entries(ytUrlResolversSettings) as any as [Extract, YTUrlResolver][] export const ytUrlResolversSettings = { - lbryInc: ytUrlResolver({ + odyseeApi: ytUrlResolver({ name: "Odysee", href: "https://api.odysee.com/yt/resolve" }), madiatorFinder: ytUrlResolver({ name: "Madiator Finder", href: "https://finder.madiator.com/api/v1/resolve" + }), + local: ytUrlResolver({ + name: "Local", + href: "http://localhost:3000/api/v1/resolve" }) } \ No newline at end of file diff --git a/src/common/useSettings.ts b/src/common/useSettings.ts index 26e519d..8578a08 100644 --- a/src/common/useSettings.ts +++ b/src/common/useSettings.ts @@ -1,13 +1,14 @@ import { useEffect, useReducer } from 'preact/hooks' -import { DEFAULT_SETTINGS } from './settings' +import { generateKeys } from './crypto' +import { DEFAULT_SETTINGS, ExtensionSettings } from './settings' /** * A hook to read the settings from local storage * * @param initial the default value. Must have all relevant keys present and should not change */ -export function useSettings(initial: T) { - const [state, dispatch] = useReducer((state, nstate: Partial) => ({ ...state, ...nstate }), initial) +export function useSettings(initial: ExtensionSettings) { + const [state, dispatch] = useReducer((state, nstate: Partial) => ({ ...state, ...nstate }), initial) // register change listeners, gets current values, and cleans up the listeners on unload useEffect(() => { const changeListener = (changes: Record, areaName: string) => { @@ -19,12 +20,22 @@ export function useSettings(initial: T) { dispatch(Object.fromEntries(changeSet)) } chrome.storage.onChanged.addListener(changeListener) - chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial)) + chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial)) + + generateKeys().then((keys) => { + setSetting('publicKey', keys.publicKey) + setSetting('privateKey', keys.privateKey) + }) + return () => chrome.storage.onChanged.removeListener(changeListener) }, []) return state } +/** Utilty to set a setting in the browser */ +export const setSetting = (setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value }) + + /** A hook to read watch on lbry settings from local storage */ -export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS) +export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS) \ No newline at end of file diff --git a/src/common/yt/urlCache.ts b/src/common/yt/urlCache.ts index 8adcc63..9d2535d 100644 --- a/src/common/yt/urlCache.ts +++ b/src/common/yt/urlCache.ts @@ -37,8 +37,7 @@ async function clearExpired() { }) } -async function clearAll() -{ +async function clearAll() { return await new Promise((resolve, reject) => { const store = db?.transaction("store", "readwrite").objectStore("store") if (!store) return resolve() diff --git a/src/common/yt/urlResolve.ts b/src/common/yt/urlResolve.ts index 559b52c..b7f7b68 100644 --- a/src/common/yt/urlResolve.ts +++ b/src/common/yt/urlResolve.ts @@ -1,11 +1,10 @@ import { chunk } from "lodash" +import { sign } from "../crypto" import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../settings" import { LbryPathnameCache } from "./urlCache" -// const LBRY_API_HOST = 'https://api.odysee.com'; MOVED TO SETTINGS const QUERY_CHUNK_SIZE = 100 - export type YtUrlResolveItem = { type: 'video' | 'channel', id: string } type Results = Record type Paramaters = YtUrlResolveItem[] @@ -16,7 +15,7 @@ interface ApiResponse { } export async function resolveById(params: Paramaters, progressCallback?: (progress: number) => void): Promise { - const { urlResolver: urlResolverSettingName } = await getExtensionSettingsAsync() + const { urlResolver: urlResolverSettingName, privateKey, publicKey } = await getExtensionSettingsAsync() const urlResolverSetting = ytUrlResolversSettings[urlResolverSettingName] async function requestChunk(params: Paramaters) { @@ -43,6 +42,11 @@ export async function resolveById(params: Paramaters, progressCallback?: (progre const url = new URL(`${urlResolverSetting.href}`) url.searchParams.set('video_ids', params.filter((item) => item.type === 'video').map((item) => item.id).join(',')) url.searchParams.set('channel_ids', params.filter((item) => item.type === 'channel').map((item) => item.id).join(',')) + if (publicKey && privateKey) + url.searchParams.set('keys', JSON.stringify({ + signature: await sign(url.searchParams.toString(), privateKey), + publicKey + })) const apiResponse = await fetch(url.toString(), { cache: 'no-store' }) if (apiResponse.ok) { diff --git a/src/manifest.json b/src/manifest.json index 343d3f3..34b2ec6 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -12,6 +12,7 @@ "https://odysee.com/", "https://madiator.com/", "https://finder.madiator.com/", + "http://localhost:3000", "tabs", "storage" ], @@ -53,4 +54,4 @@ "128": "icons/wol/icon128.png" }, "manifest_version": 2 -} +} \ No newline at end of file diff --git a/src/popup/popup.tsx b/src/popup/popup.tsx index b9e506d..e550e46 100644 --- a/src/popup/popup.tsx +++ b/src/popup/popup.tsx @@ -1,13 +1,11 @@ import { h, render } from 'preact' import { useState } from 'preact/hooks' import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio' -import { ExtensionSettings, getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, TargetPlatformName, YTUrlResolverName } from '../common/settings' -import { useLbrySettings } from '../common/useSettings' +import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, TargetPlatformName, YTUrlResolverName } from '../common/settings' +import { setSetting, useLbrySettings } from '../common/useSettings' import { LbryPathnameCache } from '../common/yt/urlCache' import './popup.sass' -/** Utilty to set a setting in the browser */ -const setSetting = (setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value }) /** Gets all the options for redirect destinations as selection options */ const platformOptions: SelectionOption[] = getTargetPlatfromSettingsEntiries() @@ -21,6 +19,7 @@ function WatchOnLbryPopup() { let [clearingCache, updateClearingCache] = useState(() => false) return
+ { }
} chrome.runtime.onMessage.addListener(({ videoId }: { videoId: string }, sender, sendResponse) => { - lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname)).catch((err) => - { + lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname)).catch((err) => { sendResponse('error') console.error(err) }) diff --git a/src/scripts/ytContent.tsx b/src/scripts/ytContent.tsx index 5a0de20..1d47618 100644 --- a/src/scripts/ytContent.tsx +++ b/src/scripts/ytContent.tsx @@ -117,32 +117,30 @@ async function requestLbryPathname(videoId: string) { async function redirectTo({ lbryPathname, platfrom, time }: Target) { const url = new URL(`${platfrom.domainPrefix}${lbryPathname}`) - + if (time) url.searchParams.set('t', time.toFixed(0)) - + findVideoElement().then((videoElement) => { videoElement.addEventListener('play', () => videoElement.pause(), { once: true }) videoElement.pause() }) - + if (platfrom === targetPlatformSettings.app) { if (document.hidden) await new Promise((resolve) => document.addEventListener('visibilitychange', resolve, { once: true })) // On redirect with app, people might choose to cancel browser's dialog // So we dont destroy the current window automatically for them // And also we are keeping the same window for less distiraction - if (settings.redirect) - { + if (settings.redirect) { location.replace(url.toString()) } - else - { + else { open(url.toString(), '_blank') if (window.history.length === 1) window.close() else window.history.back() } } - else + else location.replace(url.toString()) }