init develop branch

This commit is contained in:
Bekbolsun
2023-12-19 22:06:18 +06:00
parent c292ab2990
commit a72038ae04
63 changed files with 33821 additions and 30585 deletions

View File

@@ -1,25 +1,39 @@
const webpack = require('webpack');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const webpack = require('webpack')
const path = require('path')
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin')
module.exports = function override(config) {
const fallback = config.resolve.fallback || {};
const fallback = config.resolve.fallback || {}
Object.assign(fallback, {
"crypto": require.resolve("crypto-browserify"),
"stream": require.resolve("stream-browserify"),
"assert": require.resolve("assert"),
"http": require.resolve("stream-http"),
"https": require.resolve("https-browserify"),
"os": require.resolve("os-browserify"),
"url": require.resolve("url")
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
assert: require.resolve('assert'),
http: require.resolve('stream-http'),
https: require.resolve('https-browserify'),
os: require.resolve('os-browserify'),
url: require.resolve('url'),
})
config.resolve.fallback = fallback;
config.resolve.fallback = fallback
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer']
})
Buffer: ['buffer', 'Buffer'],
}),
])
config.module.rules.unshift({
test: /\.m?js$/,
resolve: {
fullySpecified: false, // disable the behavior
},
})
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
config.resolve.plugins = config.resolve.plugins.filter(plugin => !(plugin instanceof ModuleScopePlugin));
return config;
config.resolve.plugins = config.resolve.plugins.filter((plugin) => {
return !(plugin instanceof ModuleScopePlugin)
})
config.resolve.alias = {
'@': path.resolve(__dirname, 'src'),
}
return config
}

1998
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,12 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@nostr-dev-kit/ndk": "^2.0.5",
"@reduxjs/toolkit": "^2.0.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
@@ -14,9 +19,13 @@
"crypto": "^1.0.1",
"dexie": "^3.2.4",
"nostr-tools": "^1.17.0",
"notistack": "^3.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^9.0.3",
"react-router-dom": "^6.20.1",
"react-scripts": "5.0.1",
"redux-persist": "^6.0.0",
"typescript": "^5.3.2",
"web-vitals": "^2.1.4",
"workbox-background-sync": "^6.6.0",
@@ -41,7 +50,8 @@
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
"eject": "react-app-rewired eject",
"serve": "npm run build && serve -s build"
},
"eslintConfig": {
"extends": [
@@ -65,10 +75,12 @@
"assert": "^2.1.0",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.0",
"customize-cra": "^1.0.0",
"https-browserify": "^1.0.0",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"react-app-rewired": "^2.2.1",
"serve": "^14.2.1",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"url": "^0.11.3"

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -5,13 +5,34 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<meta
name="description"
content="Web site created using create-react-app"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="%PUBLIC_URL%/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicon-16x16.png"
/>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
@@ -21,7 +42,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Noauth</title>
<title>Nsec.app</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
@@ -35,6 +56,5 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
--></body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,25 +1,20 @@
{
"short_name": "Noauth",
"name": "Noauth Nostr key manager",
"name": "Noauth",
"short_name": "Noauth Nostr key manager",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -1,3 +0,0 @@
.App {
text-align: center;
}

View File

@@ -1,27 +1,34 @@
import './App.css';
import { nip19 } from 'nostr-tools'
import { DbApp, DbKey, DbPending, DbPerm, dbi } from './db';
import { useEffect, useState } from 'react';
import { swicCall, swicOnRender } from './swic';
import { NIP46_RELAYS } from './consts';
import { DbPending, dbi } from './modules/db'
import { useEffect, useState } from 'react'
import { swicOnRender } from './modules/swic'
import { useAppDispatch } from './store/hooks/redux'
import {
setApps,
setKeys,
setPending,
setPerms,
} from './store/reducers/content.slice'
import AppRoutes from './routes/AppRoutes'
import { ndk } from './modules/nostr'
function App() {
const [render, setRender] = useState(0)
const [keys, setKeys] = useState<DbKey[]>([])
const [apps, setApps] = useState<DbApp[]>([])
const [perms, setPerms] = useState<DbPerm[]>([])
const [pending, setPending] = useState<DbPending[]>([])
// const [keys, setKeys] = useState<DbKey[]>([])
// const [apps, setApps] = useState<DbApp[]>([])
// const [perms, setPerms] = useState<DbPerm[]>([])
// const [pending, setPending] = useState<DbPending[]>([])
const dispatch = useAppDispatch()
const load = async () => {
const keys = await dbi.listKeys()
setKeys(keys)
dispatch(setKeys({ keys }))
const apps = await dbi.listApps()
setApps(apps)
dispatch(setApps({ apps }))
const perms = await dbi.listPerms()
setPerms(perms)
dispatch(setPerms({ perms }))
const pending = await dbi.listPending()
const firstPending = new Map<string, DbPending>()
@@ -32,198 +39,98 @@ function App() {
// @ts-ignore
setPending([...firstPending.values()])
dispatch(setPending({ pending }))
// rerender
setRender(r => r + 1)
setRender((r) => r + 1)
}
useEffect(() => {
load()
// eslint-disable-next-line
}, [render])
async function log(s: string) {
const log = document.getElementById('log')
if (log) log.innerHTML = s
}
useEffect(() => {
ndk.connect().then(() => console.log('NDK connected'))
}, [])
async function askNotificationPermission() {
return new Promise<void>((ok, rej) => {
// Let's check if the browser supports notifications
if (!("Notification" in window)) {
log("This browser does not support notifications.")
rej()
} else {
Notification.requestPermission().then(() => {
log("notifications perm" + Notification.permission)
if (Notification.permission === 'granted') ok()
else rej()
});
}
})
}
// async function askNotificationPermission() {
// return new Promise<void>((ok, rej) => {
// // Let's check if the browser supports notifications
// if (!('Notification' in window)) {
// log('This browser does not support notifications.')
// rej()
// } else {
// Notification.requestPermission().then(() => {
// log('notifications perm' + Notification.permission)
// if (Notification.permission === 'granted') ok()
// else rej()
// })
// }
// })
// }
async function enableNotifications() {
await askNotificationPermission()
try {
const r = await swicCall('enablePush')
if (!r) {
log(`Failed to enable push subscription`)
return
}
// async function enableNotifications() {
// await askNotificationPermission()
// try {
// const r = await swicCall('enablePush')
// if (!r) {
// log(`Failed to enable push subscription`)
// return
// }
log(`enabled!`)
} catch (e) {
log(`Error: ${e}`)
}
}
async function call(cb: () => any) {
try {
return await cb()
} catch (e) {
log(`Error: ${e}`)
}
}
async function generateKey() {
call(async () => {
const k: any = await swicCall('generateKey');
log("New key " + k.npub)
})
}
async function confirmPending(id: string, allow: boolean, remember: boolean) {
call(async () => {
await swicCall('confirm', id, allow, remember);
console.log("confirmed", id, allow, remember)
})
}
async function deleteApp(appNpub: string) {
call(async () => {
await swicCall('deleteApp', appNpub);
log('App deleted')
})
}
async function deletePerm(id: string) {
call(async () => {
await swicCall('deletePerm', id);
log('Perm deleted')
})
}
async function saveKey(npub: string) {
call(async () => {
// @ts-ignore
const passphrase = document.getElementById(`passphrase${npub}`)?.value
await swicCall('saveKey', npub, passphrase)
log('Key saved')
})
}
async function importKey() {
call(async () => {
// @ts-ignore
const nsec = document.getElementById(`nsec`)?.value
await swicCall('importKey', nsec)
log('Key imported')
})
}
async function fetchNewKey() {
call(async () => {
// @ts-ignore
const npub = document.getElementById('npub')?.value
// @ts-ignore
const passphrase = document.getElementById('passphrase')?.value
console.log("fetch", npub, passphrase)
const k: any = await swicCall('fetchKey', npub, passphrase)
log("Fetched " + k.npub)
})
}
// log(`enabled!`)
// } catch (e) {
// log(`Error: ${e}`)
// }
// }
// subscribe to updates from the service worker
swicOnRender(() => {
console.log("render")
setRender(r => r + 1)
console.log('render')
setRender((r) => r + 1)
})
return (
<div className="App">
<header className="App-header">
Nostr Login
</header>
<div>
<h4>Keys:</h4>
{keys.map((k) => {
const { data: pubkey } = nip19.decode(k.npub)
const str = `bunker://${pubkey}?relay=${NIP46_RELAYS[0]}`
return (
<div key={k.npub} style={{ marginBottom: "10px" }}>
{k.npub}
<div>{str}</div>
<div>
<input id={`passphrase${k.npub}`} placeholder='save password' />
<button onClick={() => saveKey(k.npub)}>save</button>
</div>
</div>
)
})}
return <AppRoutes />
<div>
<button onClick={generateKey}>generate key</button>
</div>
<div>
<input id='nsec' placeholder='nsec' />
<button onClick={importKey}>import key (DANGER!)</button>
</div>
<div>
<input id='npub' placeholder='npub' />
<input id='passphrase' placeholder='password' />
<button onClick={fetchNewKey}>fetch key</button>
</div>
<hr />
// return (
// <div>
// <div>
// <h4>Connected apps:</h4>
// {apps.map((a) => (
// <div key={a.npub} style={{ marginTop: '10px' }}>
// <div>
// {a.npub} =&gt; {a.appNpub}
// <button onClick={() => deleteApp(a.appNpub)}>
// x
// </button>
// </div>
// <h5>Perms:</h5>
// {perms
// .filter((p) => p.appNpub === a.appNpub)
// .map((p) => (
// <div key={p.id}>
// {p.perm}: {p.value}
// <button onClick={() => deletePerm(p.id)}>
// x
// </button>
// </div>
// ))}
// <hr />
// </div>
// ))}
<h4>Connected apps:</h4>
{apps.map((a) => (
<div key={a.npub} style={{ marginTop: "10px" }}>
<div>
{a.npub} =&gt; {a.appNpub}
<button onClick={() => deleteApp(a.appNpub)}>x</button>
</div>
<h5>Perms:</h5>
{perms.filter(p => p.appNpub === a.appNpub).map(p => (
<div key={p.id}>
{p.perm}: {p.value}
<button onClick={() => deletePerm(p.id)}>x</button>
</div>
))}
<hr />
</div>
))}
<h4>Pending requests:</h4>
{pending.map((p) => (
<div key={p.id}>
{p.appNpub} =&gt; {p.npub} ({p.method})
<button onClick={() => confirmPending(p.id, true, false)}>yes</button>
<button onClick={() => confirmPending(p.id, false, false)}>no</button>
<button onClick={() => confirmPending(p.id, true, true)}>yes all</button>
<button onClick={() => confirmPending(p.id, false, true)}>no all</button>
</div>
))}
<hr />
<div>
<button onClick={enableNotifications}>enable background signing</button>
</div>
<div>
<textarea id='log'></textarea>
</div>
</div>
</div>
);
// <div>
// <button onClick={enableNotifications}>
// enable background signing
// </button>
// </div>
// <div>
// <textarea id='log'></textarea>
// </div>
// </div>
// </div>
// )
}
export default App;
export default App

View File

@@ -0,0 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.5479 20.2413C24.4588 19.0649 22.6437 18.9473 21.3126 19.7708L18.8925 17.8885C19.8606 16.2416 19.9816 14.2417 19.0135 12.5948L21.4337 10.8302C23.0067 12.1242 25.3059 12.0066 26.7579 10.5949C28.331 9.06555 28.452 6.59511 26.8789 5.06579C25.3059 3.53647 22.7647 3.41883 21.1916 4.94815C19.9816 6.12455 19.6186 7.88915 20.3446 9.41847L18.0455 11.1831C16.1094 9.41847 13.0842 9.18319 11.0271 10.5949L8.72796 8.35971C10.059 6.59511 9.93803 4.00703 8.24394 2.36007C6.42884 0.477835 3.28267 0.477835 1.46757 2.24243C-0.468534 4.00703 -0.468534 7.06567 1.34656 8.83027C3.04066 10.4772 5.58179 10.7125 7.5179 9.53611L9.81702 11.7713C8.36494 13.7712 8.36495 16.4769 9.93803 18.3591L7.27589 21.1825C6.06582 20.5943 4.61374 20.8295 3.64569 21.7707C2.43562 22.9471 2.31462 24.9469 3.52468 26.1233C4.73475 27.2997 6.79186 27.4174 8.00192 26.241C9.09098 25.1822 9.21199 23.6529 8.48595 22.4765L11.0271 19.6531C13.0842 20.9472 15.8674 20.8295 17.6824 19.1826L20.1026 21.0648C19.6186 22.2412 19.7396 23.6529 20.7076 24.594C21.9177 25.8881 24.0958 25.8881 25.3059 24.7117C26.7579 23.5353 26.7579 21.5354 25.5479 20.2413Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

3
src/assets/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import { ReactComponent as AppLogo } from './icons/logo.svg'
export { AppLogo }

View File

@@ -1,778 +0,0 @@
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'
import { dbi, DbKey, DbPending, DbPerm } from './db'
import { Keys } from './keys'
import NDK, { IEventHandlingStrategy, NDKEvent, NDKNip46Backend, NDKPrivateKeySigner, NDKSigner } from '@nostr-dev-kit/ndk'
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS } from './consts'
import { Nip04 } from './nip04'
//import { PrivateKeySigner } from './signer'
//const PERF_TEST = false
export interface KeyInfo {
npub: string
nip05?: string
locked: boolean
}
interface Key {
npub: string
ndk: NDK
backoff: number
signer: NDKSigner
backend: NDKNip46Backend
}
interface Pending {
req: DbPending
cb: (allow: boolean, remember: boolean) => void
}
interface IAllowCallbackParams {
npub: string,
id: string,
method: string,
remotePubkey: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params?: any
}
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
private privkey: string
private nip04 = new Nip04()
constructor(privkey: string) {
this.privkey = privkey
}
private async getKey(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
recipientPubkey: string
) {
if (
!(await backend.pubkeyAllowed({
id,
pubkey: remotePubkey,
// @ts-ignore
method: "get_nip04_key",
params: recipientPubkey,
}))
) {
backend.debug(`get_nip04_key request from ${remotePubkey} rejected`);
return undefined;
}
return Buffer.from(
this.nip04.createKey(this.privkey, recipientPubkey)
).toString('hex')
}
async handle(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
params: string[]
) {
const [recipientPubkey] = params
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
}
}
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
readonly npub: string
readonly method: string
private body: IEventHandlingStrategy
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
constructor(
npub: string,
method: string,
body: IEventHandlingStrategy,
allowCb: (params: IAllowCallbackParams) => Promise<boolean>
) {
this.npub = npub
this.method = method
this.body = body
this.allowCb = allowCb
}
async handle(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
params: string[]
): Promise<string | undefined> {
console.log(Date.now(), "handle", { method: this.method, id, remotePubkey, params })
const allow = await this.allowCb({
npub: this.npub,
id,
method: this.method,
remotePubkey,
params
})
if (!allow) return undefined
return this.body.handle(backend, id, remotePubkey, params)
.then(r => {
console.log(Date.now(), "req", id, "method", this.method, "result", r)
return r
})
}
}
export class NoauthBackend {
readonly swg: ServiceWorkerGlobalScope
private keysModule: Keys
private enckeys: DbKey[] = []
private keys: Key[] = []
private perms: DbPerm[] = []
private doneReqIds: string[] = []
private confirmBuffer: Pending[] = []
private accessBuffer: DbPending[] = []
private notifCallback: (() => void) | null = null
public constructor(swg: ServiceWorkerGlobalScope) {
this.swg = swg
this.keysModule = new Keys(swg.crypto.subtle)
const self = this
swg.addEventListener('activate', (event) => {
console.log("activate")
})
swg.addEventListener('install', (event) => {
console.log("install")
})
swg.addEventListener('push', (event) => {
console.log("got push", event)
self.onPush(event)
event.waitUntil(new Promise((ok: any) => {
self.setNotifCallback(ok)
}))
})
swg.addEventListener('message', (event) => {
self.onMessage(event)
})
swg.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action.startsWith("allow:")) {
self.confirm(event.action.split(':')[1], true, false)
} else if (event.action.startsWith("allow-remember:")) {
self.confirm(event.action.split(':')[1], true, true)
} else if (event.action.startsWith("disallow:")) {
self.confirm(event.action.split(':')[1], false, false)
} else {
event.waitUntil(
self.swg.clients.matchAll({ type: "window" })
.then((clientList) => {
console.log("clients", clientList.length)
for (const client of clientList) {
console.log("client", client.url)
if (new URL(client.url).pathname === "/" && "focus" in client)
return client.focus();
}
// if (self.swg.clients.openWindow)
// return self.swg.clients.openWindow("/");
}),
);
}
},
false // ???
);
}
public async start() {
this.enckeys = await dbi.listKeys()
console.log("started encKeys", this.listKeys())
this.perms = await dbi.listPerms()
console.log("started perms", this.perms)
const sub = await this.swg.registration.pushManager.getSubscription()
for (const k of this.enckeys) {
await this.unlock(k.npub)
// ensure we're subscribed on the server
if (sub)
await this.sendSubscriptionToServer(k.npub, sub)
}
}
public setNotifCallback(cb: () => void) {
if (this.notifCallback) {
this.notify()
}
this.notifCallback = cb
}
public listKeys(): KeyInfo[] {
return this.enckeys.map<KeyInfo>((k) => this.keyInfo(k))
}
public isLocked(npub: string): boolean {
return !this.keys.find(k => k.npub === npub)
}
public hasKey(npub: string): boolean {
return !!this.enckeys.find(k => k.npub === npub)
}
private async sha256(s: string) {
return Buffer.from(
await this.swg.crypto.subtle.digest('SHA-256', Buffer.from(s)))
.toString('hex')
}
private async sendPost({
url,
method,
headers,
body
}: {
url: string,
method: string,
headers: any,
body: string
}) {
const r = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...headers
},
body,
})
if (r.status !== 200 && r.status !== 201) {
console.log("Fetch error", url, method, r.status)
throw new Error("Failed to fetch" + url)
}
return await r.json();
}
private async sendPostAuthd({
npub,
url,
method = 'GET',
body = ''
}: {
npub: string,
url: string,
method: string,
body: string
}) {
const { data: pubkey } = nip19.decode(npub)
const key = this.keys.find(k => k.npub === npub)
if (!key) throw new Error("Unknown key")
const authEvent = new NDKEvent(key.ndk, {
pubkey: pubkey as string,
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', url],
['method', method],
]
})
if (body)
authEvent.tags.push(['payload', await this.sha256(body)])
authEvent.sig = await authEvent.sign(key.signer)
const auth = this.swg.btoa(JSON.stringify(authEvent.rawEvent()))
return await this.sendPost({
url,
method,
headers: {
'Authorization': `Nostr ${auth}`,
},
body
})
}
private async sendSubscriptionToServer(
npub: string,
pushSubscription: PushSubscription
) {
const body = JSON.stringify({
npub,
relays: NIP46_RELAYS,
pushSubscription
})
const method = 'POST'
const url = `${NOAUTHD_URL}/subscribe`
return this.sendPostAuthd({
npub,
url,
method,
body
})
}
private async sendKeyToServer(
npub: string,
enckey: string,
pwh: string
) {
const body = JSON.stringify({
npub,
data: enckey,
pwh
})
const method = 'POST'
const url = `${NOAUTHD_URL}/put`
return this.sendPostAuthd({
npub,
url,
method,
body
})
}
private async fetchKeyFromServer(
npub: string,
pwh: string
) {
const body = JSON.stringify({
npub,
pwh
})
const method = 'POST'
const url = `${NOAUTHD_URL}/get`
return await this.sendPost({
url,
method,
headers: {},
body
})
}
private notify() {
// FIXME collect info from accessBuffer and confirmBuffer
// and update the notifications
for (const r of this.confirmBuffer) {
const text = `Confirm "${r.req.method}" by "${r.req.appNpub}"`
this.swg.registration.showNotification('Signer access', {
body: text,
tag: "confirm-" + r.req.appNpub,
actions: [
{
action: "allow:" + r.req.id,
title: "Yes"
},
{
action: "disallow:" + r.req.id,
title: "No"
},
]
})
}
if (this.notifCallback)
this.notifCallback()
}
private keyInfo(k: DbKey): KeyInfo {
return {
npub: k.npub,
nip05: k.nip05,
locked: this.isLocked(k.npub)
}
}
private async generateGoodKey(): Promise<string> {
return generatePrivateKey()
}
public async addKey(nsec?: string): Promise<KeyInfo> {
let sk = ''
if (nsec) {
const { type, data } = nip19.decode(nsec)
if (type !== 'nsec') throw new Error('Bad nsec')
sk = data
} else {
sk = await this.generateGoodKey()
}
const pubkey = getPublicKey(sk)
const npub = nip19.npubEncode(pubkey)
const localKey = await this.keysModule.generateLocalKey()
const enckey = await this.keysModule.encryptKeyLocal(sk, localKey)
// @ts-ignore
const dbKey: DbKey = { npub, enckey, localKey }
await dbi.addKey(dbKey)
this.enckeys.push(dbKey)
await this.startKey({ npub, sk })
const sub = await this.swg.registration.pushManager.getSubscription()
if (sub)
await this.sendSubscriptionToServer(npub, sub)
return this.keyInfo(dbKey)
}
private getPerm(req: DbPending): string {
return this.perms.find(p => p.npub === req.npub
&& p.appNpub === req.appNpub
&& p.perm === req.method)?.value || ''
}
private async allowPermitCallback({
npub,
id,
method,
remotePubkey,
params
}: IAllowCallbackParams): Promise<boolean> {
// same reqs usually come on reconnects
if (this.doneReqIds.includes(id)) {
console.log("request already done", id)
// FIXME maybe repeat the reply, but without the Notification?
return false
}
const appNpub = nip19.npubEncode(remotePubkey)
const req: DbPending = {
id,
npub,
appNpub,
method,
params: JSON.stringify(params),
timestamp: Date.now()
}
const self = this
return new Promise(async (ok) => {
// called when it's decided whether to allow this or not
const onAllow = async (manual: boolean, allow: boolean, remember: boolean) => {
// confirm
console.log(Date.now(), allow ? "allowed" : "disallowed", npub, method, params)
if (manual) {
await dbi.confirmPending(id, allow)
if (!await dbi.getApp(req.appNpub)) {
await dbi.addApp({
appNpub: req.appNpub,
npub: req.npub,
timestamp: Date.now(),
name: '',
icon: '',
url: ''
})
}
} else {
// just send to db w/o waiting for it
// if (!PERF_TEST)
dbi.addConfirmed({
...req,
allowed: allow
})
}
// for notifications
self.accessBuffer.push(req)
// clear from pending
const index = self.confirmBuffer.findIndex(r => r.req.id === id)
if (index >= 0)
self.confirmBuffer.splice(index, 1)
if (remember) {
await dbi.addPerm({
id: req.id,
npub: req.npub,
appNpub: req.appNpub,
perm: method,
value: allow ? '1' : '0',
timestamp: Date.now(),
})
this.perms = await dbi.listPerms()
const otherReqs = self.confirmBuffer.filter(r => r.req.appNpub === req.appNpub)
for (const r of otherReqs) {
if (r.req.method === req.method) {
r.cb(allow, false)
}
}
}
// notify UI that it was confirmed
// if (!PERF_TEST)
this.updateUI()
// return to let nip46 flow proceed
ok(allow)
}
// check perms
const perm = this.getPerm(req)
console.log(Date.now(), "perm", req.id, perm)
// have perm?
if (perm) {
// reply immediately
onAllow(false, perm === '1', false)
} else {
// put pending req to db
await dbi.addPending(req)
// need manual confirmation
console.log("need confirm", req)
// put to a list of pending requests
this.confirmBuffer.push({
req,
cb: (allow, remember) => onAllow(true, allow, remember)
})
// show notifs
this.notify()
// notify main thread to ask for user concent
// FIXME show a 'confirm' notification?
this.updateUI()
}
})
}
private async startKey({ npub, sk, backoff = 1000 }: { npub: string, sk: string, backoff?: number }) {
const ndk = new NDK({
explicitRelayUrls: NIP46_RELAYS
})
// init relay objects but dont wait until we connect
ndk.connect()
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
const backend = new NDKNip46Backend(ndk, sk, () => Promise.resolve(true))
this.keys.push({ npub, backend, signer, ndk, backoff })
// new method
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
// assign our own permission callback
for (const method in backend.handlers) {
backend.handlers[method] = new EventHandlingStrategyWrapper(npub, method, backend.handlers[method], this.allowPermitCallback.bind(this))
}
// start
backend.start()
console.log("started", npub)
// backoff reset on successfull connection
const self = this
const onConnect = () => {
// reset backoff
const key = self.keys.find(k => k.npub === npub)
if (key) key.backoff = 0
console.log("reset backoff for", npub)
}
// reconnect handling
let reconnected = false
const onDisconnect = () => {
if (reconnected) return
if (ndk.pool.connectedRelays().length > 0) return
reconnected = true
console.log(new Date(), "all relays are down for key", npub)
// run full restart after a pause
const bo = self.keys.find(k => k.npub === npub)?.backoff || 1000
setTimeout(() => {
console.log(new Date(), "reconnect relays for key", npub, "backoff", bo)
// @ts-ignore
for (const r of ndk.pool.relays.values())
r.disconnect()
// make sure it no longer activates
backend.handlers = {}
self.keys = self.keys.filter(k => k.npub !== npub)
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
}, bo)
}
// @ts-ignore
for (const r of ndk.pool.relays.values()) {
r.on('connect', onConnect)
r.on('disconnect', onDisconnect)
}
}
public async unlock(npub: string) {
console.log("unlocking", npub)
if (!this.isLocked(npub)) throw new Error(`Key ${npub} already unlocked`)
const info = this.enckeys.find(k => k.npub === npub)
if (!info) throw new Error(`Key ${npub} not found`)
const { type } = nip19.decode(npub)
if (type !== "npub") throw new Error(`Invalid npub ${npub}`)
const sk = await this.keysModule.decryptKeyLocal({
enckey: info.enckey,
// @ts-ignore
localKey: info.localKey
})
await this.startKey({ npub, sk })
}
private async generateKey() {
const k = await this.addKey()
this.updateUI()
return k
}
private async importKey(nsec: string) {
const k = await this.addKey(nsec)
this.updateUI()
return k
}
private async saveKey(npub: string, passphrase: string) {
const info = this.enckeys.find(k => k.npub === npub)
if (!info) throw new Error(`Key ${npub} not found`)
const sk = await this.keysModule.decryptKeyLocal({
enckey: info.enckey,
// @ts-ignore
localKey: info.localKey
})
const { enckey, pwh } = await this.keysModule.encryptKeyPass({ key: sk, passphrase })
await this.sendKeyToServer(npub, enckey, pwh)
}
private async fetchKey(npub: string, passphrase: string) {
const { type, data: pubkey } = nip19.decode(npub)
if (type !== "npub") throw new Error(`Invalid npub ${npub}`)
const { pwh } = await this.keysModule.generatePassKey(pubkey, passphrase)
const { data: enckey } = await this.fetchKeyFromServer(npub, pwh);
// key already exists?
const key = this.enckeys.find(k => k.npub === npub)
if (key) return this.keyInfo(key)
// add new key
const nsec = await this.keysModule.decryptKeyPass({ pubkey, enckey, passphrase })
const k = await this.addKey(nsec)
this.updateUI()
return k
}
private async confirm(id: string, allow: boolean, remember: boolean) {
const req = this.confirmBuffer.find(r => r.req.id === id)
if (!req) {
console.log("req ", id, "not found")
await dbi.removePending(id)
this.updateUI()
} else {
console.log("confirming", id, allow, remember)
req.cb(allow, remember)
}
}
private async deleteApp(appNpub: string) {
this.perms = this.perms.filter(p => p.appNpub !== appNpub)
await dbi.removeApp(appNpub)
await dbi.removeAppPerms(appNpub)
this.updateUI()
}
private async deletePerm(id: string) {
this.perms = this.perms.filter(p => p.id !== id)
await dbi.removePerm(id)
this.updateUI()
}
private async enablePush(): Promise<boolean> {
const options = {
userVisibleOnly: true,
applicationServerKey: WEB_PUSH_PUBKEY,
}
const pushSubscription = await this.swg.registration.pushManager.subscribe(options);
console.log("push endpoint", JSON.stringify(pushSubscription));
if (!pushSubscription) {
console.log("failed to enable push subscription")
return false
}
// subscribe to all pubkeys
for (const k of this.keys) {
await this.sendSubscriptionToServer(k.npub, pushSubscription);
}
console.log("push enabled")
return true
}
public async onMessage(event: any) {
const { id, method, args } = event.data
try {
//console.log("UI message", id, method, args)
let result = undefined
if (method === 'generateKey') {
result = await this.generateKey()
} else if (method === 'importKey') {
result = await this.importKey(args[0])
} else if (method === 'saveKey') {
result = await this.saveKey(args[0], args[1])
} else if (method === 'fetchKey') {
result = await this.fetchKey(args[0], args[1])
} else if (method === 'confirm') {
result = await this.confirm(args[0], args[1], args[2])
} else if (method === 'deleteApp') {
result = await this.deleteApp(args[0])
} else if (method === 'deletePerm') {
result = await this.deletePerm(args[0])
} else if (method === 'enablePush') {
result = await this.enablePush()
} else {
console.log("unknown method from UI ", method)
}
event.source.postMessage({
id, result
})
} catch (e: any) {
event.source.postMessage({
id, error: e.toString()
})
}
}
private async updateUI() {
const clients = await this.swg.clients.matchAll()
console.log("updateUI clients", clients.length)
for (const client of clients) {
client.postMessage({})
}
}
public async onPush(event: any) {
console.log("push", { data: event.data });
// noop - we just need browser to launch this worker
// FIXME use event.waitUntil and and unblock after we
// show a notification
}
}

View File

@@ -0,0 +1,25 @@
import { IconButton, Typography } from '@mui/material'
import { forwardRef } from 'react'
import { useSnackbar } from 'notistack'
import CloseIcon from '@mui/icons-material/Close'
import { NotificationProps } from './types'
import { StyledAlert, StyledContainer } from './styled'
export const Notification = forwardRef<HTMLDivElement, NotificationProps>(
({ message, alertvariant, id }, ref) => {
const { closeSnackbar } = useSnackbar()
const closeSnackBarHandler = () => closeSnackbar(id)
return (
<StyledAlert alertvariant={alertvariant} ref={ref}>
<StyledContainer>
<Typography variant='body1'>{message}</Typography>
<IconButton onClick={closeSnackBarHandler} color='inherit'>
<CloseIcon color='inherit' />
</IconButton>
</StyledContainer>
</StyledAlert>
)
},
)

View File

@@ -0,0 +1,9 @@
import { VariantType } from 'notistack'
type Variant = Exclude<VariantType, 'default' | 'info'>
export const BORDER_STYLES: Record<Variant, string> = {
error: '#b90e0a',
success: '#32cd32',
warning: '#FF9500',
}

View File

@@ -0,0 +1,46 @@
import { Alert, Box, styled } from '@mui/material'
import { StyledAlertProps } from './types'
import { BORDER_STYLES } from './const'
import { forwardRef } from 'react'
export const StyledAlert = styled(
forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => (
<Alert {...props} ref={ref} icon={false} />
)),
)(({ alertvariant }) => ({
width: '100%',
maxHeight: 56,
padding: '0.5rem 1rem',
backgroundColor: '#FFF',
borderRadius: 4,
border: `solid ${BORDER_STYLES[alertvariant]} 1px`,
color: BORDER_STYLES[alertvariant],
fontSize: 12,
fontWeight: '500',
'& .MuiAlert-message': {
display: 'flex',
minWidth: '100%',
justifyContent: 'space-between',
overflow: 'hidden',
padding: 0,
},
}))
export const StyledContainer = styled(Box)(() => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
width: '100%',
'& > .MuiTypography-root': {
flex: 1,
width: '100%',
wordBreak: 'break-word',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontWeight: 500,
},
}))

View File

@@ -0,0 +1,11 @@
import { AlertProps } from '@mui/material'
import { SnackbarKey, VariantType } from 'notistack'
export type StyledAlertProps = Omit<AlertProps, 'id'> & {
alertvariant: Exclude<VariantType, 'default' | 'info'>
}
export type NotificationProps = {
message: string
id: SnackbarKey
} & StyledAlertProps

View File

@@ -0,0 +1,33 @@
import {
useSnackbar as useDefaultSnackbar,
OptionsObject,
VariantType,
} from 'notistack'
import { Notification } from '../components/Notification/Notification'
export const useEnqueueSnackbar = () => {
const { enqueueSnackbar } = useDefaultSnackbar()
const showSnackbar = (
message: string,
variant: Exclude<VariantType, 'default' | 'info'> = 'success',
) => {
enqueueSnackbar(message, {
anchorOrigin: {
vertical: 'top',
horizontal: 'right',
},
content: (id) => {
return (
<Notification
id={id}
message={message}
alertvariant={variant}
/>
)
},
} as OptionsObject)
}
return showSnackbar
}

View File

@@ -1,8 +1,12 @@
body {
* {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -11,3 +15,9 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
html,
body,
#root {
height: 100%;
}

View File

@@ -1,18 +1,32 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { swicRegister } from './swic';
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import App from './App'
import reportWebVitals from './reportWebVitals'
import { swicRegister } from './modules/swic'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import { persistor, store } from './store'
import ThemeProvider from './modules/theme/ThemeProvider'
import { PersistGate } from 'redux-persist/integration/react'
import { SnackbarProvider } from 'notistack'
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
<BrowserRouter>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ThemeProvider>
<SnackbarProvider maxSnack={3} autoHideDuration={3000}>
<App />
</React.StrictMode>
);
</SnackbarProvider>
</ThemeProvider>
</PersistGate>
</Provider>
</BrowserRouter>
</React.StrictMode>,
)
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
@@ -22,4 +36,4 @@ swicRegister()
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
reportWebVitals()

View File

@@ -0,0 +1,20 @@
import { Toolbar } from '@mui/material'
import { AppLogo } from '../../assets'
import { StyledAppBar, StyledAppName } from './styled'
import { Menu } from './components/Menu'
export const Header = () => {
return (
<StyledAppBar position='static'>
<Toolbar>
<StyledAppName>
<AppLogo />
<span>Nsec.app</span>
</StyledAppName>
<Menu />
</Toolbar>
</StyledAppBar>
)
}

View File

@@ -0,0 +1,94 @@
import {
IconButton,
IconButtonProps,
ListItemIcon,
MenuItem,
MenuItemProps,
Menu as MuiMenu,
Typography,
styled,
} from '@mui/material'
import MenuIcon from '@mui/icons-material/Menu'
import { useNavigate } from 'react-router-dom'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import LoginIcon from '@mui/icons-material/Login'
import { setThemeMode } from '@/store/reducers/ui.slice'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { ReactNode, useState } from 'react'
const renderMenuItem = (
Icon: ReactNode,
handler: () => void,
title: string | ReactNode,
) => {
return (
<StyledMenuItem onClick={handler}>
<ListItemIcon>{Icon}</ListItemIcon>
<Typography fontWeight={500} variant='body2' noWrap>
{title}
</Typography>
</StyledMenuItem>
)
}
export const Menu = () => {
const themeMode = useAppSelector((state) => state.ui.themeMode)
const dispatch = useAppDispatch()
const navigate = useNavigate()
const isDarkMode = themeMode === 'dark'
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const open = Boolean(anchorEl)
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
const handleNavigateToAuth = () => {
navigate('/sign-up')
handleClose()
}
const themeIcon = isDarkMode ? (
<DarkModeIcon htmlColor='#fff' />
) : (
<LightModeIcon htmlColor='#feb94a' />
)
return (
<>
<BurgerButton onClick={handleClick} />
<MuiMenu anchorEl={anchorEl} open={open} onClose={handleClose}>
{renderMenuItem(<LoginIcon />, handleNavigateToAuth, 'Sign up')}
{renderMenuItem(themeIcon, handleChangeMode, 'Change theme')}
</MuiMenu>
</>
)
}
const BurgerButton = styled((props: IconButtonProps) => (
<IconButton {...props}>
<MenuIcon color='inherit' />
</IconButton>
))(({ theme }) => {
const isDark = theme.palette.mode === 'dark'
return {
borderRadius: '1rem',
background: isDark ? '#333333A8' : 'transparent',
color: isDark ? '#FFFFFFA8' : 'initial',
}
})
const StyledMenuItem = styled((props: MenuItemProps) => (
<MenuItem {...props} />
))(() => ({
padding: '0.5rem 1rem',
}))

View File

@@ -0,0 +1,27 @@
import { AppBar, Typography, TypographyProps, styled } from '@mui/material'
import { Link } from 'react-router-dom'
export const StyledAppBar = styled(AppBar)(({ theme }) => {
return {
background: 'transparent',
color: theme.palette.primary.main,
boxShadow: 'none',
borderBottom: '1.4px solid ' + theme.palette.primary.main,
marginBottom: '1rem',
}
})
export const StyledAppName = styled((props: TypographyProps) => (
<Typography component={Link} to={'/'} flexGrow={1} {...props} />
))(() => ({
'&:not(:hover)': {
textDecoration: 'initial',
},
color: 'inherit',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
fontWeight: 600,
fontSize: '1rem',
lineHeight: '22.4px',
}))

27
src/layout/Layout.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { FC } from 'react'
import { Outlet } from 'react-router-dom'
import { Header } from './Header/Header'
import { Container, ContainerProps, styled } from '@mui/material'
export const Layout: FC = () => {
return (
<StyledContainer maxWidth='md'>
<Header />
<main>
<Outlet />
</main>
</StyledContainer>
)
}
const StyledContainer = styled((props: ContainerProps) => (
<Container maxWidth='sm' {...props} />
))({
height: '100%',
display: 'flex',
flexDirection: 'column',
paddingBottom: '1rem',
'& > main': {
flex: 1,
},
})

View File

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

833
src/modules/backend.ts Normal file
View File

@@ -0,0 +1,833 @@
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'
import { dbi, DbKey, DbPending, DbPerm } from './db'
import { Keys } from './keys'
import NDK, {
IEventHandlingStrategy,
NDKEvent,
NDKNip46Backend,
NDKPrivateKeySigner,
NDKSigner,
} from '@nostr-dev-kit/ndk'
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS } from '../utils/consts'
import { Nip04 } from './nip04'
//import { PrivateKeySigner } from './signer'
//const PERF_TEST = false
export interface KeyInfo {
npub: string
nip05?: string
locked: boolean
}
interface Key {
npub: string
ndk: NDK
backoff: number
signer: NDKSigner
backend: NDKNip46Backend
}
interface Pending {
req: DbPending
cb: (allow: boolean, remember: boolean) => void
}
interface IAllowCallbackParams {
npub: string
id: string
method: string
remotePubkey: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params?: any
}
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
private privkey: string
private nip04 = new Nip04()
constructor(privkey: string) {
this.privkey = privkey
}
private async getKey(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
recipientPubkey: string,
) {
if (
!(await backend.pubkeyAllowed({
id,
pubkey: remotePubkey,
// @ts-ignore
method: 'get_nip04_key',
params: recipientPubkey,
}))
) {
backend.debug(`get_nip04_key request from ${remotePubkey} rejected`)
return undefined
}
return Buffer.from(
this.nip04.createKey(this.privkey, recipientPubkey),
).toString('hex')
}
async handle(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
params: string[],
) {
const [recipientPubkey] = params
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
}
}
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
readonly npub: string
readonly method: string
private body: IEventHandlingStrategy
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
constructor(
npub: string,
method: string,
body: IEventHandlingStrategy,
allowCb: (params: IAllowCallbackParams) => Promise<boolean>,
) {
this.npub = npub
this.method = method
this.body = body
this.allowCb = allowCb
}
async handle(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
params: string[],
): Promise<string | undefined> {
console.log(Date.now(), 'handle', {
method: this.method,
id,
remotePubkey,
params,
})
const allow = await this.allowCb({
npub: this.npub,
id,
method: this.method,
remotePubkey,
params,
})
if (!allow) return undefined
return this.body.handle(backend, id, remotePubkey, params).then((r) => {
console.log(
Date.now(),
'req',
id,
'method',
this.method,
'result',
r,
)
return r
})
}
}
export class NoauthBackend {
readonly swg: ServiceWorkerGlobalScope
private keysModule: Keys
private enckeys: DbKey[] = []
private keys: Key[] = []
private perms: DbPerm[] = []
private doneReqIds: string[] = []
private confirmBuffer: Pending[] = []
private accessBuffer: DbPending[] = []
private notifCallback: (() => void) | null = null
public constructor(swg: ServiceWorkerGlobalScope) {
this.swg = swg
this.keysModule = new Keys(swg.crypto.subtle)
const self = this
swg.addEventListener('activate', (event) => {
console.log('activate')
})
swg.addEventListener('install', (event) => {
console.log('install')
})
swg.addEventListener('push', (event) => {
console.log('got push', event)
self.onPush(event)
event.waitUntil(
new Promise((ok: any) => {
self.setNotifCallback(ok)
}),
)
})
swg.addEventListener('message', (event) => {
self.onMessage(event)
})
swg.addEventListener(
'notificationclick',
(event) => {
event.notification.close()
if (event.action.startsWith('allow:')) {
self.confirm(event.action.split(':')[1], true, false)
} else if (event.action.startsWith('allow-remember:')) {
self.confirm(event.action.split(':')[1], true, true)
} else if (event.action.startsWith('disallow:')) {
self.confirm(event.action.split(':')[1], false, false)
} else {
event.waitUntil(
self.swg.clients
.matchAll({ type: 'window' })
.then((clientList) => {
console.log('clients', clientList.length)
for (const client of clientList) {
console.log('client', client.url)
if (
new URL(client.url).pathname === '/' &&
'focus' in client
)
return client.focus()
}
// if (self.swg.clients.openWindow)
// return self.swg.clients.openWindow("/");
}),
)
}
},
false, // ???
)
}
public async start() {
this.enckeys = await dbi.listKeys()
console.log('started encKeys', this.listKeys())
this.perms = await dbi.listPerms()
console.log('started perms', this.perms)
const sub = await this.swg.registration.pushManager.getSubscription()
for (const k of this.enckeys) {
await this.unlock(k.npub)
// ensure we're subscribed on the server
if (sub) await this.sendSubscriptionToServer(k.npub, sub)
}
}
public setNotifCallback(cb: () => void) {
if (this.notifCallback) {
this.notify()
}
this.notifCallback = cb
}
public listKeys(): KeyInfo[] {
return this.enckeys.map<KeyInfo>((k) => this.keyInfo(k))
}
public isLocked(npub: string): boolean {
return !this.keys.find((k) => k.npub === npub)
}
public hasKey(npub: string): boolean {
return !!this.enckeys.find((k) => k.npub === npub)
}
private async sha256(s: string) {
return Buffer.from(
await this.swg.crypto.subtle.digest('SHA-256', Buffer.from(s)),
).toString('hex')
}
private async sendPost({
url,
method,
headers,
body,
}: {
url: string
method: string
headers: any
body: string
}) {
const r = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...headers,
},
body,
})
if (r.status !== 200 && r.status !== 201) {
console.log('Fetch error', url, method, r.status)
throw new Error('Failed to fetch' + url)
}
return await r.json()
}
private async sendPostAuthd({
npub,
url,
method = 'GET',
body = '',
}: {
npub: string
url: string
method: string
body: string
}) {
const { data: pubkey } = nip19.decode(npub)
const key = this.keys.find((k) => k.npub === npub)
if (!key) throw new Error('Unknown key')
const authEvent = new NDKEvent(key.ndk, {
pubkey: pubkey as string,
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', url],
['method', method],
],
})
if (body) authEvent.tags.push(['payload', await this.sha256(body)])
authEvent.sig = await authEvent.sign(key.signer)
const auth = this.swg.btoa(JSON.stringify(authEvent.rawEvent()))
return await this.sendPost({
url,
method,
headers: {
Authorization: `Nostr ${auth}`,
},
body,
})
}
private async sendSubscriptionToServer(
npub: string,
pushSubscription: PushSubscription,
) {
const body = JSON.stringify({
npub,
relays: NIP46_RELAYS,
pushSubscription,
})
const method = 'POST'
const url = `${NOAUTHD_URL}/subscribe`
return this.sendPostAuthd({
npub,
url,
method,
body,
})
}
private async sendKeyToServer(npub: string, enckey: string, pwh: string) {
const body = JSON.stringify({
npub,
data: enckey,
pwh,
})
const method = 'POST'
const url = `${NOAUTHD_URL}/put`
return this.sendPostAuthd({
npub,
url,
method,
body,
})
}
private async fetchKeyFromServer(npub: string, pwh: string) {
const body = JSON.stringify({
npub,
pwh,
})
const method = 'POST'
const url = `${NOAUTHD_URL}/get`
return await this.sendPost({
url,
method,
headers: {},
body,
})
}
private notify() {
// FIXME collect info from accessBuffer and confirmBuffer
// and update the notifications
for (const r of this.confirmBuffer) {
const text = `Confirm "${r.req.method}" by "${r.req.appNpub}"`
this.swg.registration.showNotification('Signer access', {
body: text,
tag: 'confirm-' + r.req.appNpub,
actions: [
{
action: 'allow:' + r.req.id,
title: 'Yes',
},
{
action: 'disallow:' + r.req.id,
title: 'No',
},
],
})
}
if (this.notifCallback) this.notifCallback()
}
private keyInfo(k: DbKey): KeyInfo {
return {
npub: k.npub,
nip05: k.nip05,
locked: this.isLocked(k.npub),
}
}
private async generateGoodKey(): Promise<string> {
return generatePrivateKey()
}
public async addKey(nsec?: string): Promise<KeyInfo> {
let sk = ''
if (nsec) {
const { type, data } = nip19.decode(nsec)
if (type !== 'nsec') throw new Error('Bad nsec')
sk = data
} else {
sk = await this.generateGoodKey()
}
const pubkey = getPublicKey(sk)
const npub = nip19.npubEncode(pubkey)
const localKey = await this.keysModule.generateLocalKey()
const enckey = await this.keysModule.encryptKeyLocal(sk, localKey)
// @ts-ignore
const dbKey: DbKey = { npub, enckey, localKey }
await dbi.addKey(dbKey)
this.enckeys.push(dbKey)
await this.startKey({ npub, sk })
const sub = await this.swg.registration.pushManager.getSubscription()
if (sub) await this.sendSubscriptionToServer(npub, sub)
return this.keyInfo(dbKey)
}
private getPerm(req: DbPending): string {
return (
this.perms.find(
(p) =>
p.npub === req.npub &&
p.appNpub === req.appNpub &&
p.perm === req.method,
)?.value || ''
)
}
private async allowPermitCallback({
npub,
id,
method,
remotePubkey,
params,
}: IAllowCallbackParams): Promise<boolean> {
// same reqs usually come on reconnects
if (this.doneReqIds.includes(id)) {
console.log('request already done', id)
// FIXME maybe repeat the reply, but without the Notification?
return false
}
const appNpub = nip19.npubEncode(remotePubkey)
const req: DbPending = {
id,
npub,
appNpub,
method,
params: JSON.stringify(params),
timestamp: Date.now(),
}
const self = this
return new Promise(async (ok) => {
// called when it's decided whether to allow this or not
const onAllow = async (
manual: boolean,
allow: boolean,
remember: boolean,
) => {
// confirm
console.log(
Date.now(),
allow ? 'allowed' : 'disallowed',
npub,
method,
params,
)
if (manual) {
await dbi.confirmPending(id, allow)
if (!(await dbi.getApp(req.appNpub))) {
await dbi.addApp({
appNpub: req.appNpub,
npub: req.npub,
timestamp: Date.now(),
name: '',
icon: '',
url: '',
})
}
} else {
// just send to db w/o waiting for it
// if (!PERF_TEST)
dbi.addConfirmed({
...req,
allowed: allow,
})
}
// for notifications
self.accessBuffer.push(req)
// clear from pending
const index = self.confirmBuffer.findIndex(
(r) => r.req.id === id,
)
if (index >= 0) self.confirmBuffer.splice(index, 1)
if (remember) {
await dbi.addPerm({
id: req.id,
npub: req.npub,
appNpub: req.appNpub,
perm: method,
value: allow ? '1' : '0',
timestamp: Date.now(),
})
this.perms = await dbi.listPerms()
const otherReqs = self.confirmBuffer.filter(
(r) => r.req.appNpub === req.appNpub,
)
for (const r of otherReqs) {
if (r.req.method === req.method) {
r.cb(allow, false)
}
}
}
// notify UI that it was confirmed
// if (!PERF_TEST)
this.updateUI()
// return to let nip46 flow proceed
ok(allow)
}
// check perms
const perm = this.getPerm(req)
console.log(Date.now(), 'perm', req.id, perm)
// have perm?
if (perm) {
// reply immediately
onAllow(false, perm === '1', false)
} else {
// put pending req to db
await dbi.addPending(req)
// need manual confirmation
console.log('need confirm', req)
// put to a list of pending requests
this.confirmBuffer.push({
req,
cb: (allow, remember) => onAllow(true, allow, remember),
})
// show notifs
this.notify()
// notify main thread to ask for user concent
// FIXME show a 'confirm' notification?
this.updateUI()
}
})
}
private async startKey({
npub,
sk,
backoff = 1000,
}: {
npub: string
sk: string
backoff?: number
}) {
const ndk = new NDK({
explicitRelayUrls: NIP46_RELAYS,
})
// init relay objects but dont wait until we connect
ndk.connect()
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
const backend = new NDKNip46Backend(ndk, sk, () =>
Promise.resolve(true),
)
this.keys.push({ npub, backend, signer, ndk, backoff })
// new method
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
// assign our own permission callback
for (const method in backend.handlers) {
backend.handlers[method] = new EventHandlingStrategyWrapper(
npub,
method,
backend.handlers[method],
this.allowPermitCallback.bind(this),
)
}
// start
backend.start()
console.log('started', npub)
// backoff reset on successfull connection
const self = this
const onConnect = () => {
// reset backoff
const key = self.keys.find((k) => k.npub === npub)
if (key) key.backoff = 0
console.log('reset backoff for', npub)
}
// reconnect handling
let reconnected = false
const onDisconnect = () => {
if (reconnected) return
if (ndk.pool.connectedRelays().length > 0) return
reconnected = true
console.log(new Date(), 'all relays are down for key', npub)
// run full restart after a pause
const bo = self.keys.find((k) => k.npub === npub)?.backoff || 1000
setTimeout(() => {
console.log(
new Date(),
'reconnect relays for key',
npub,
'backoff',
bo,
)
// @ts-ignore
for (const r of ndk.pool.relays.values()) r.disconnect()
// make sure it no longer activates
backend.handlers = {}
self.keys = self.keys.filter((k) => k.npub !== npub)
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
}, bo)
}
// @ts-ignore
for (const r of ndk.pool.relays.values()) {
r.on('connect', onConnect)
r.on('disconnect', onDisconnect)
}
}
public async unlock(npub: string) {
console.log('unlocking', npub)
if (!this.isLocked(npub))
throw new Error(`Key ${npub} already unlocked`)
const info = this.enckeys.find((k) => k.npub === npub)
if (!info) throw new Error(`Key ${npub} not found`)
const { type } = nip19.decode(npub)
if (type !== 'npub') throw new Error(`Invalid npub ${npub}`)
const sk = await this.keysModule.decryptKeyLocal({
enckey: info.enckey,
// @ts-ignore
localKey: info.localKey,
})
await this.startKey({ npub, sk })
}
private async generateKey() {
const k = await this.addKey()
this.updateUI()
return k
}
private async importKey(nsec: string) {
const k = await this.addKey(nsec)
this.updateUI()
return k
}
private async saveKey(npub: string, passphrase: string) {
const info = this.enckeys.find((k) => k.npub === npub)
if (!info) throw new Error(`Key ${npub} not found`)
const sk = await this.keysModule.decryptKeyLocal({
enckey: info.enckey,
// @ts-ignore
localKey: info.localKey,
})
const { enckey, pwh } = await this.keysModule.encryptKeyPass({
key: sk,
passphrase,
})
await this.sendKeyToServer(npub, enckey, pwh)
}
private async fetchKey(npub: string, passphrase: string) {
const { type, data: pubkey } = nip19.decode(npub)
if (type !== 'npub') throw new Error(`Invalid npub ${npub}`)
const { pwh } = await this.keysModule.generatePassKey(
pubkey,
passphrase,
)
const { data: enckey } = await this.fetchKeyFromServer(npub, pwh)
// key already exists?
const key = this.enckeys.find((k) => k.npub === npub)
if (key) return this.keyInfo(key)
// add new key
const nsec = await this.keysModule.decryptKeyPass({
pubkey,
enckey,
passphrase,
})
const k = await this.addKey(nsec)
this.updateUI()
return k
}
private async confirm(id: string, allow: boolean, remember: boolean) {
const req = this.confirmBuffer.find((r) => r.req.id === id)
if (!req) {
console.log('req ', id, 'not found')
await dbi.removePending(id)
this.updateUI()
} else {
console.log('confirming', id, allow, remember)
req.cb(allow, remember)
}
}
private async deleteApp(appNpub: string) {
this.perms = this.perms.filter((p) => p.appNpub !== appNpub)
await dbi.removeApp(appNpub)
await dbi.removeAppPerms(appNpub)
this.updateUI()
}
private async deletePerm(id: string) {
this.perms = this.perms.filter((p) => p.id !== id)
await dbi.removePerm(id)
this.updateUI()
}
private async enablePush(): Promise<boolean> {
const options = {
userVisibleOnly: true,
applicationServerKey: WEB_PUSH_PUBKEY,
}
const pushSubscription =
await this.swg.registration.pushManager.subscribe(options)
console.log('push endpoint', JSON.stringify(pushSubscription))
if (!pushSubscription) {
console.log('failed to enable push subscription')
return false
}
// subscribe to all pubkeys
for (const k of this.keys) {
await this.sendSubscriptionToServer(k.npub, pushSubscription)
}
console.log('push enabled')
return true
}
public async onMessage(event: any) {
const { id, method, args } = event.data
try {
//console.log("UI message", id, method, args)
let result = undefined
if (method === 'generateKey') {
result = await this.generateKey()
} else if (method === 'importKey') {
result = await this.importKey(args[0])
} else if (method === 'saveKey') {
result = await this.saveKey(args[0], args[1])
} else if (method === 'fetchKey') {
result = await this.fetchKey(args[0], args[1])
} else if (method === 'confirm') {
result = await this.confirm(args[0], args[1], args[2])
} else if (method === 'deleteApp') {
result = await this.deleteApp(args[0])
} else if (method === 'deletePerm') {
result = await this.deletePerm(args[0])
} else if (method === 'enablePush') {
result = await this.enablePush()
} else {
console.log('unknown method from UI ', method)
}
event.source.postMessage({
id,
result,
})
} catch (e: any) {
event.source.postMessage({
id,
error: e.toString(),
})
}
}
private async updateUI() {
const clients = await this.swg.clients.matchAll()
console.log('updateUI clients', clients.length)
for (const client of clients) {
client.postMessage({})
}
}
public async onPush(event: any) {
console.log('push', { data: event.data })
// noop - we just need browser to launch this worker
// FIXME use event.waitUntil and and unblock after we
// show a notification
}
}

17
src/modules/nostr.ts Normal file
View File

@@ -0,0 +1,17 @@
import NDK from '@nostr-dev-kit/ndk'
export const ndk = new NDK({
explicitRelayUrls: ['wss://relay.nostr.band/all'],
})
export async function fetchProfile(pubkey: string) {
const event = await ndk.fetchEvent({ kinds: [0], authors: [pubkey] })
if (event) {
return {
...event,
info: JSON.parse(event.content),
}
}
return event
}

74
src/modules/signer.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { UnsignedEvent } from 'nostr-tools'
import { generatePrivateKey, getPublicKey, getSignature } from 'nostr-tools'
import type { NostrEvent } from '@nostr-dev-kit/ndk' // "./ndk-dist";
import { NDKUser } from '@nostr-dev-kit/ndk' // "./ndk-dist";
import type { NDKSigner } from '@nostr-dev-kit/ndk' // "./ndk-dist";
import { Nip04 } from './nip04'
//import { decrypt, encrypt } from "./ende";
export class PrivateKeySigner implements NDKSigner {
private _user: NDKUser | undefined
privateKey?: string
private nip04: Nip04
public constructor(privateKey?: string) {
if (privateKey) {
this.privateKey = privateKey
this._user = new NDKUser({
hexpubkey: getPublicKey(this.privateKey),
})
}
this.nip04 = new Nip04()
}
public static generate() {
const privateKey = generatePrivateKey()
return new PrivateKeySigner(privateKey)
}
public async blockUntilReady(): Promise<NDKUser> {
if (!this._user) {
throw new Error('NDKUser not initialized')
}
return this._user
}
public async user(): Promise<NDKUser> {
await this.blockUntilReady()
return this._user as NDKUser
}
public async sign(event: NostrEvent): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to sign without a private key')
}
return getSignature(event as UnsignedEvent, this.privateKey)
}
public async encrypt(recipient: NDKUser, value: string): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to encrypt without a private key')
}
const recipientHexPubKey = recipient.hexpubkey
return await this.nip04.encrypt(
this.privateKey,
recipientHexPubKey,
value,
)
// return await encrypt(recipientHexPubKey, value, this.privateKey);
}
public async decrypt(sender: NDKUser, value: string): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to decrypt without a private key')
}
const senderHexPubKey = sender.hexpubkey
// console.log("nip04_decrypt", value)
return await this.nip04.decrypt(this.privateKey, senderHexPubKey, value)
// return await decrypt(this.privateKey, senderHexPubKey, value) as string;
}
}

72
src/modules/swic.ts Normal file
View File

@@ -0,0 +1,72 @@
// service-worker client interface
import * as serviceWorkerRegistration from '../serviceWorkerRegistration'
let swr: ServiceWorkerRegistration | null = null
const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>()
let nextReqId = 1
let onRender: (() => void) | null = null
export async function swicRegister() {
serviceWorkerRegistration.register({
onSuccess(registration) {
console.log('sw registered')
swr = registration
},
onError(e) {
console.log(`error ${e}`)
console.log(e, 'HISH')
},
})
navigator.serviceWorker.ready.then((r) => (swr = r))
navigator.serviceWorker.addEventListener('message', (event) => {
onMessage((event as MessageEvent).data)
})
}
function onMessage(data: any) {
const { id, result, error } = data
console.log('SW message', id, result, error)
if (!id) {
if (onRender) onRender()
return
}
const r = reqs.get(id)
if (!r) {
console.log('Unexpected message from service worker', data)
return
}
reqs.delete(id)
if (error) r.rej(error)
else r.ok(result)
}
export async function swicCall(method: string, ...args: any[]) {
const id = nextReqId
nextReqId++
return new Promise((ok, rej) => {
if (!swr || !swr.active) {
rej(new Error('No active service worker'))
return
}
reqs.set(id, { ok, rej })
const msg = {
id,
method,
args: [...args],
}
//console.log("sending to SW", msg)
swr.active.postMessage(msg)
})
}
export function swicOnRender(cb: () => void) {
onRender = cb
}

View File

@@ -0,0 +1,18 @@
import { FC, PropsWithChildren } from 'react'
import { ThemeProvider as ThemeMuiProvider, CssBaseline } from '@mui/material'
import { darkTheme, lightTheme } from './theme'
import { useAppSelector } from '../../store/hooks/redux'
const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
const themeMode = useAppSelector((state) => state.ui.themeMode)
const isDarkMode = themeMode === 'dark'
return (
<ThemeMuiProvider theme={isDarkMode ? darkTheme : lightTheme}>
<CssBaseline />
{children}
</ThemeMuiProvider>
)
}
export default ThemeProvider

View File

@@ -0,0 +1,90 @@
import { createTheme, Theme } from '@mui/material'
// declare module '@mui/material/styles' {
// interface Palette {
// light: Palette['primary']
// decorate: Palette['primary']
// actionPrimary: Palette['primary']
// textPrimaryDecorate: Palette['primary']
// textSecondaryDecorate: Palette['primary']
// }
// interface PaletteOptions {
// light?: Palette['primary']
// decorate?: Palette['primary']
// actionPrimary?: Palette['primary']
// textPrimaryDecorate?: Palette['primary']
// textSecondaryDecorate?: Palette['primary']
// }
// }
const commonTheme: Theme = createTheme({
typography: {
fontFamily: ['Inter', 'sans-serif'].join(','),
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'initial',
},
},
},
},
})
const lightTheme: Theme = createTheme({
...commonTheme,
palette: {
mode: 'light',
primary: {
main: '#000000',
},
secondary: {
main: '#9c27b0',
light: '#00000029',
dark: '#333333',
},
error: {
main: '#f44336',
},
background: {
default: '#f7f7f7',
paper: '#f7f7f7',
},
text: {
primary: '#333333',
secondary: '#757575',
},
},
})
const darkTheme: Theme = createTheme({
...commonTheme,
palette: {
mode: 'dark',
primary: {
main: '#FFFFFF',
},
secondary: {
main: '#f48fb1',
light: '#FFFFFF29',
dark: '#333333A8',
},
error: {
main: '#ef9a9a',
},
background: {
default: '#121212',
paper: '#28282B',
},
text: {
primary: '#ffffff',
secondary: '#b3b3b3',
},
},
})
console.log({ lightTheme, darkTheme })
export { lightTheme, darkTheme }

7
src/pages/App.Page.tsx Normal file
View File

@@ -0,0 +1,7 @@
import React from 'react'
const AppPage = () => {
return <div>AppPage</div>
}
export default AppPage

View File

@@ -0,0 +1,62 @@
import { Input } from '@/shared/Input/Input'
import {
Button,
Stack,
InputAdornment,
useMediaQuery,
Typography,
} from '@mui/material'
import { StyledAppLogo, StyledContent } from './styled'
const AuthPage = () => {
const isMobile = useMediaQuery('(max-width:600px)')
const commonContent = (
<>
<Input
label='Enter a Username'
fullWidth
placeholder='Username'
helperText="Don't worry, username can be changed later."
endAdornment={
<InputAdornment position='end'>@nsec.app</InputAdornment>
}
/>
<Button variant='contained'>Sign up</Button>
</>
)
const renderContent = () => {
if (isMobile) {
return (
<StyledContent>
<Stack
direction={'row'}
gap={'1rem'}
alignItems={'center'}
alignSelf={'flex-start'}
>
<StyledAppLogo />
<Typography fontWeight={600} variant='h5'>
Sign up
</Typography>
</Stack>
{commonContent}
</StyledContent>
)
}
return (
<Stack gap={'1rem'} alignItems={'center'}>
{commonContent}
</Stack>
)
}
return (
<Stack height={'100%'} position={'relative'}>
{renderContent()}
</Stack>
)
}
export default AuthPage

View File

@@ -0,0 +1,34 @@
import { AppLogo } from '@/assets'
import { Stack, styled, StackProps, Box } from '@mui/material'
export const StyledContent = styled((props: StackProps) => (
<Stack {...props} gap={'1rem'} alignItems={'center'} />
))(({ theme }) => {
const isDark = theme.palette.mode === 'dark'
return {
background: isDark ? '#333333A8' : '#dddddda8',
position: 'absolute',
bottom: '-1rem',
left: '-1rem',
width: 'calc(100% + 2rem)',
height: '70%',
borderTopLeftRadius: '2rem',
borderTopRightRadius: '2rem',
padding: '1rem',
maxWidth: '50rem',
margin: '0 auto',
}
})
export const StyledAppLogo = styled((props) => (
<Box {...props}>
<AppLogo />
</Box>
))({
background: '#00000054',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
})

View File

@@ -0,0 +1,7 @@
import React from 'react'
const ConfirmPage = () => {
return <div>ConfirmPage</div>
}
export default ConfirmPage

View File

@@ -0,0 +1,65 @@
import { useRef } from 'react'
import { useAppSelector } from '../../store/hooks/redux'
import { selectKeys } from '../../store'
import { ItemKey } from './components/ItemKey'
import { Stack } from '@mui/material'
import { call } from '../../utils/helpers'
import { swicCall } from '../../modules/swic'
import { SectionTitle } from '../../shared/SectionTitle/SectionTitle'
import { useEnqueueSnackbar } from '../../hooks/useEnqueueSnackbar'
const HomePage = () => {
const keys = useAppSelector(selectKeys)
const notify = useEnqueueSnackbar()
const nsecInputRef = useRef<HTMLInputElement | null>(null)
// eslint-disable-next-line
async function importKey() {
call(async () => {
const nsec = nsecInputRef.current?.value
if (!nsec) return
await swicCall('importKey', nsec)
notify('Key imported!', 'success')
})
}
// eslint-disable-next-line
async function generateKey() {
call(async () => {
const k: any = await swicCall('generateKey')
notify(`New key ${k.npub}`, 'success')
})
}
return (
<Stack>
{/* <Box alignSelf={'center'} marginBottom={'1rem'}>
<Button size='small' variant='contained' onClick={generateKey}>
add key
</Button>
</Box>
<Stack alignItems={'center'} gap='0.5rem'>
<TextField
variant='outlined'
ref={nsecInputRef}
placeholder='Enter nsec...'
fullWidth
size='small'
/>
<Button size='small' variant='contained' onClick={importKey}>
import key (DANGER!)
</Button>
</Stack> */}
<SectionTitle>Keys:</SectionTitle>
<Stack gap={'0.5rem'}>
{keys.map((key) => (
<ItemKey {...key} key={key.npub} />
))}
</Stack>
</Stack>
)
}
export default HomePage

View File

@@ -0,0 +1,87 @@
import { FC, useRef } from 'react'
import { DbKey } from '../../../modules/db'
import { nip19 } from 'nostr-tools'
import { NIP46_RELAYS } from '../../../utils/consts'
import {
Box,
IconButton,
Stack,
StackProps,
Typography,
TypographyProps,
styled,
} from '@mui/material'
import { call, log } from '../../../utils/helpers'
import { swicCall } from '../../../modules/swic'
import { useNavigate } from 'react-router-dom'
import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt'
type ItemKeyProps = DbKey
export const ItemKey: FC<ItemKeyProps> = ({ npub }) => {
const navigate = useNavigate()
const { data: pubkey } = nip19.decode(npub)
const str = `bunker://${pubkey}?relay=${NIP46_RELAYS[0]}`
const passPhraseInputRef = useRef<HTMLInputElement | null>(null)
// eslint-disable-next-line
async function saveKey(npub: string) {
call(async () => {
const passphrase = passPhraseInputRef.current?.value
await swicCall('saveKey', npub, passphrase)
log('Key saved')
})
}
const handleNavigate = () => {
navigate('/key/' + npub)
}
return (
<StyledKeyContainer>
<StyledText variant='body1'>{npub}</StyledText>
<StyledText variant='body2' color={'#757575'}>
{str}
</StyledText>
{/* <Stack direction={'row'} alignItems={'center'} gap={'0.5rem'}>
<TextField
ref={passPhraseInputRef}
placeholder='save password'
fullWidth
size='small'
/>
<Button variant='contained' onClick={() => saveKey(npub)}>
save
</Button>
</Stack> */}
<Box alignSelf={'flex-end'}>
<IconButton onClick={handleNavigate}>
<ArrowRightAltIcon />
</IconButton>
</Box>
</StyledKeyContainer>
)
}
const StyledKeyContainer = styled((props: StackProps) => (
<Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} />
))(({ theme }) => {
return {
boxShadow:
theme.palette.mode === 'dark'
? '4px 3px 10px 2px rgba(92, 92, 92, 0.2)'
: '4px 3px 10px 3px rgba(0, 0, 0, 0.2)',
borderRadius: theme.shape.borderRadius,
padding: '0.5rem 1rem',
background: theme.palette.background.paper,
}
})
export const StyledText = styled((props: TypographyProps) => (
<Typography {...props} />
))({
fontWeight: 500,
width: '100%',
wordBreak: 'break-all',
})

155
src/pages/Key.Page.tsx Normal file
View File

@@ -0,0 +1,155 @@
import { useEffect, useState } from 'react'
import { swicCall } from '../modules/swic'
import { SectionTitle } from '../shared/SectionTitle/SectionTitle'
import { useAppSelector } from '../store/hooks/redux'
import { call } from '../utils/helpers'
import { Link, useParams } from 'react-router-dom'
import { fetchProfile } from '../modules/nostr'
import { nip19 } from 'nostr-tools'
import { useEnqueueSnackbar } from '../hooks/useEnqueueSnackbar'
import { Box, Stack, Typography } from '@mui/material'
const KeyPage = () => {
const { apps, perms, pending } = useAppSelector((state) => state.content)
const { npub = '' } = useParams<{ npub: string }>()
const filteredApps = apps.filter((a) => a.npub === npub)
const filteredPerms = perms.filter((p) => p.npub === npub)
const filteredPendingRequests = pending.filter((p) => p.npub === npub)
const notify = useEnqueueSnackbar()
const [profile, setProfile] = useState(null)
useEffect(() => {
const load = async () => {
try {
const npubToken = npub.includes('#') ? npub.split('#')[0] : npub
const { type, data: pubkey } = nip19.decode(npubToken)
if (type !== 'npub') return undefined
const response = await fetchProfile(pubkey)
console.log({ response, pubkey, npub, npubToken, profile })
setProfile(response as any)
} catch (e) {
return undefined
}
}
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// eslint-disable-next-line
async function deleteApp(appNpub: string) {
call(async () => {
await swicCall('deleteApp', appNpub)
notify('App deleted!', 'success')
})
}
async function deletePerm(id: string) {
call(async () => {
await swicCall('deletePerm', id)
notify('Perm deleted!', 'success')
})
}
async function confirmPending(
id: string,
allow: boolean,
remember: boolean,
) {
call(async () => {
await swicCall('confirm', id, allow, remember)
console.log('confirmed', id, allow, remember)
})
}
return (
<Stack gap={'1rem'}>
<Box>
<SectionTitle>Connected apps:</SectionTitle>
{!filteredApps.length && (
<Typography textAlign={'center'}>
No connected apps
</Typography>
)}
{filteredApps.map((a) => (
<div key={a.npub} style={{ marginTop: '10px' }}>
<Typography
component={Link}
to={`/key/${npub}/app/${a.appNpub}`}
noWrap
>
App: {a.appNpub}
{/* <button onClick={() => deleteApp(a.appNpub)}>
x
</button> */}
</Typography>
<SectionTitle>Permissions:</SectionTitle>
{!filteredPerms.filter((p) => p.appNpub === a.appNpub)
.length && (
<Typography textAlign={'center'}>
No permissions
</Typography>
)}
{filteredPerms
.filter((p) => p.appNpub === a.appNpub)
.map((p) => (
<div key={p.id}>
{p.perm}: {p.value}
<button onClick={() => deletePerm(p.id)}>
x
</button>
</div>
))}
<hr />
</div>
))}
</Box>
<Box>
<SectionTitle>Pending requests:</SectionTitle>
{!filteredPendingRequests.length && (
<Typography textAlign={'center'}>
No pending requests
</Typography>
)}
{filteredPendingRequests.map((p) => (
<div key={p.id}>
<Typography
component={Link}
to={`/key/${npub}/${p.id}`}
noWrap
>
Request details
</Typography>
APP: {p.appNpub} ({p.method})
<button
onClick={() => confirmPending(p.id, true, false)}
>
yes
</button>
<button
onClick={() => confirmPending(p.id, false, false)}
>
no
</button>
<button
onClick={() => confirmPending(p.id, true, true)}
>
yes all
</button>
<button
onClick={() => confirmPending(p.id, false, true)}
>
no all
</button>
</div>
))}
</Box>
</Stack>
)
}
export default KeyPage

View File

@@ -0,0 +1,97 @@
import { useRef } from 'react'
import { useAppSelector } from '../store/hooks/redux'
import { Navigate } from 'react-router-dom'
import { swicCall } from '../modules/swic'
import { Box, Button, Stack, TextField } from '@mui/material'
import { useEnqueueSnackbar } from '../hooks/useEnqueueSnackbar'
const WelcomePage = () => {
const keys = useAppSelector((state) => state.content.keys)
const notify = useEnqueueSnackbar()
const isKeysExists = keys.length > 0
const nsecInputRef = useRef<HTMLInputElement | null>(null)
const npubInputRef = useRef<HTMLInputElement | null>(null)
const passwordInputRef = useRef<HTMLInputElement | null>(null)
if (isKeysExists) return <Navigate to={'/home'} />
async function generateKey() {
try {
const k: any = await swicCall('generateKey')
notify(`New key ${k.npub}`, 'success')
} catch (error: any) {
notify(error.message, 'error')
}
}
async function importKey() {
try {
const nsec = nsecInputRef.current?.value
if (!nsec) return
await swicCall('importKey', nsec)
} catch (error: any) {
notify(error.message, 'error')
}
}
async function fetchNewKey() {
try {
const npub = npubInputRef.current?.value
const passphrase = passwordInputRef.current?.value
console.log('fetch', npub, passphrase)
const k: any = await swicCall('fetchKey', npub, passphrase)
notify(`Fetched ${k.npub}`, 'success')
} catch (error: any) {
notify(error.message, 'error')
}
}
return (
<Stack gap={'1.5rem'}>
<Box alignSelf={'center'}>
<Button size='small' variant='contained' onClick={generateKey}>
generate key
</Button>
</Box>
<Stack alignItems={'center'} gap='0.5rem'>
<TextField
variant='outlined'
ref={nsecInputRef}
placeholder='Enter nsec...'
fullWidth
size='small'
/>
<Button size='small' variant='contained' onClick={importKey}>
import key (DANGER!)
</Button>
</Stack>
<Stack alignItems={'center'} gap='0.5rem'>
<Stack width={'100%'} gap='0.5rem'>
<TextField
variant='outlined'
ref={npubInputRef}
placeholder='Enter npub...'
fullWidth
size='small'
/>
<TextField
variant='outlined'
ref={passwordInputRef}
placeholder='Enter password'
fullWidth
size='small'
/>
</Stack>
<Button size='small' variant='contained' onClick={fetchNewKey}>
fetch key
</Button>
</Stack>
</Stack>
)
}
export default WelcomePage

44
src/routes/AppRoutes.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { Suspense, lazy } from 'react'
import { Route, Routes, Navigate } from 'react-router-dom'
import HomePage from '../pages/HomePage/Home.Page'
import WelcomePage from '../pages/Welcome.Page'
import { Layout } from '../layout/Layout'
import { CircularProgress, Stack } from '@mui/material'
const KeyPage = lazy(() => import('../pages/Key.Page'))
const ConfirmPage = lazy(() => import('../pages/Confirm.Page'))
const AppPage = lazy(() => import('../pages/App.Page'))
const AuthPage = lazy(() => import('../pages/AuthPage/Auth.Page'))
const LoadingSpinner = () => (
<Stack height={'100%'} justifyContent={'center'} alignItems={'center'}>
<CircularProgress />
</Stack>
)
const AppRoutes = () => {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path='/' element={<Layout />}>
<Route path='/' element={<Navigate to={'/welcome'} />} />
<Route path='/welcome' element={<WelcomePage />} />
<Route path='/home' element={<HomePage />} />
<Route path='/sign-up' element={<AuthPage />} />
<Route path='/key/:npub' element={<KeyPage />} />
<Route
path='/key/:npub/app/:appNpub'
element={<AppPage />}
/>
<Route
path='/key/:npub/:req_id'
element={<ConfirmPage />}
/>
</Route>
<Route path='*' element={<Navigate to={'/welcome'} />} />
</Routes>
</Suspense>
)
}
export default AppRoutes

View File

@@ -8,57 +8,58 @@
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
import { NoauthBackend } from './backend';
import { clientsClaim } from 'workbox-core'
import { ExpirationPlugin } from 'workbox-expiration'
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { StaleWhileRevalidate } from 'workbox-strategies'
import { NoauthBackend } from './modules/backend'
declare const self: ServiceWorkerGlobalScope;
declare const self: ServiceWorkerGlobalScope
clientsClaim();
clientsClaim()
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST);
precacheAndRoute(self.__WB_MANIFEST)
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }: { request: Request; url: URL }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false;
return false
}
// If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false;
return false
}
// If this looks like a URL for a resource, because it contains
// a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
return false
}
// Return true to signal that we want to use the handler.
return true;
return true
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'),
)
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
({ url }) =>
url.origin === self.location.origin && url.pathname.endsWith('.png'),
// Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'images',
@@ -67,24 +68,23 @@ registerRoute(
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
);
}),
)
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
self.skipWaiting()
}
});
})
// Any other custom service worker logic can go here.
async function start() {
console.log("worker starting")
console.log('worker starting')
const backend = new NoauthBackend(self)
await backend.start()
}
start()

View File

@@ -0,0 +1,17 @@
import { styled, Button as MuiButton, ButtonProps } from '@mui/material'
const BUTTON_VARIANTS = {
PRIMARY: 'primary',
SECONDARY: 'secondary',
TERTIARY: 'tertiary',
}
export const Button = () => {
return <StyledButton>Button</StyledButton>
}
const StyledButton = styled((props: ButtonProps) => <MuiButton {...props} />)(
() => {
return {}
},
)

View File

@@ -0,0 +1,68 @@
import { FC } from 'react'
import {
Box,
BoxProps,
FormHelperText,
FormLabel,
InputBase,
InputBaseProps,
styled,
} from '@mui/material'
type InputProps = InputBaseProps & {
helperText?: string
containerProps?: BoxProps
label?: string
}
export const Input: FC<InputProps> = ({
helperText,
containerProps,
label,
...props
}) => {
return (
<StyledInputContainer {...containerProps}>
{label ? (
<FormLabel className='label' htmlFor={props.id}>
{label}
</FormLabel>
) : null}
<InputBase className='input' {...props} />
{helperText ? (
<FormHelperText className='helper_text'>
{helperText}
</FormHelperText>
) : null}
</StyledInputContainer>
)
}
const StyledInputContainer = styled((props: BoxProps) => <Box {...props} />)(
({ theme }) => {
const isDark = theme.palette.mode === 'dark'
return {
width: '100%',
'& > .input': {
background: isDark ? '#000' : '#000',
color: theme.palette.common.white,
padding: '0.75rem 1rem',
borderRadius: '1rem',
border: '0.3px solid #FFFFFF54',
fontSize: '0.875rem',
'& input::placeholder': {
color: theme.palette.common.white,
},
},
'& > .helper_text': {
margin: '0.5rem 1rem 0',
},
'& > .label': {
margin: '0 1rem 0.5rem',
display: 'block',
color: theme.palette.primary.main,
fontSize: '0.875rem',
},
}
},
)

View File

@@ -0,0 +1,16 @@
import { Typography, TypographyProps, styled } from '@mui/material'
import { FC, PropsWithChildren } from 'react'
export const SectionTitle: FC<PropsWithChildren> = ({ children }) => {
return <StyledTypography>{children}</StyledTypography>
}
const StyledTypography = styled((props: TypographyProps) => (
<Typography {...props} variant='body1' />
))(({ theme }) => ({
textTransform: 'uppercase',
letterSpacing: '3px',
display: 'block',
marginBottom: '0.5rem',
color: theme.palette.text.secondary,
}))

View File

@@ -1,70 +0,0 @@
import type { UnsignedEvent } from "nostr-tools";
import { generatePrivateKey, getPublicKey, getSignature } from "nostr-tools";
import type { NostrEvent } from '@nostr-dev-kit/ndk' // "./ndk-dist";
import { NDKUser } from '@nostr-dev-kit/ndk' // "./ndk-dist";
import type { NDKSigner } from '@nostr-dev-kit/ndk' // "./ndk-dist";
import { Nip04 } from "./nip04";
//import { decrypt, encrypt } from "./ende";
export class PrivateKeySigner implements NDKSigner {
private _user: NDKUser | undefined;
privateKey?: string;
private nip04: Nip04
public constructor(privateKey?: string) {
if (privateKey) {
this.privateKey = privateKey;
this._user = new NDKUser({
hexpubkey: getPublicKey(this.privateKey),
});
}
this.nip04 = new Nip04()
}
public static generate() {
const privateKey = generatePrivateKey();
return new PrivateKeySigner(privateKey);
}
public async blockUntilReady(): Promise<NDKUser> {
if (!this._user) {
throw new Error("NDKUser not initialized");
}
return this._user;
}
public async user(): Promise<NDKUser> {
await this.blockUntilReady();
return this._user as NDKUser;
}
public async sign(event: NostrEvent): Promise<string> {
if (!this.privateKey) {
throw Error("Attempted to sign without a private key");
}
return getSignature(event as UnsignedEvent, this.privateKey);
}
public async encrypt(recipient: NDKUser, value: string): Promise<string> {
if (!this.privateKey) {
throw Error("Attempted to encrypt without a private key");
}
const recipientHexPubKey = recipient.hexpubkey;
return await this.nip04.encrypt(this.privateKey, recipientHexPubKey, value);
// return await encrypt(recipientHexPubKey, value, this.privateKey);
}
public async decrypt(sender: NDKUser, value: string): Promise<string> {
if (!this.privateKey) {
throw Error("Attempted to decrypt without a private key");
}
const senderHexPubKey = sender.hexpubkey;
// console.log("nip04_decrypt", value)
return await this.nip04.decrypt(this.privateKey, senderHexPubKey, value);
// return await decrypt(this.privateKey, senderHexPubKey, value) as string;
}
}

5
src/store/hooks/redux.ts Normal file
View File

@@ -0,0 +1,5 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import { AppDispatch, RootState } from '..'
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

52
src/store/index.ts Normal file
View File

@@ -0,0 +1,52 @@
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import { contentSlice } from './reducers/content.slice'
import { uiSlice } from './reducers/ui.slice'
import {
persistStore,
persistReducer,
FLUSH,
REGISTER,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
} from 'redux-persist'
import storage from 'redux-persist/lib/storage' // defaults to localStorage for web
const persistConfig = {
key: 'root',
storage,
whiteList: [uiSlice.name],
}
const rootReducer = combineReducers({
[contentSlice.name]: contentSlice.reducer,
[uiSlice.name]: uiSlice.reducer,
})
const persistedReducer = persistReducer(persistConfig, rootReducer)
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
],
},
}),
})
export const persistor = persistStore(store)
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const selectKeys = (state: RootState) => state.content.keys

View File

@@ -0,0 +1,37 @@
import { createSlice } from '@reduxjs/toolkit'
import { DbApp, DbKey, DbPerm, DbPending } from '../../modules/db'
export interface IContentState {
keys: DbKey[]
apps: DbApp[]
perms: DbPerm[]
pending: DbPending[]
}
const initialState: IContentState = {
keys: [],
apps: [],
perms: [],
pending: [],
}
export const contentSlice = createSlice({
name: 'content',
initialState,
reducers: {
setKeys: (state, action) => {
state.keys = action.payload.keys
},
setApps: (state, action) => {
state.apps = action.payload.apps
},
setPerms: (state, action) => {
state.perms = action.payload.perms
},
setPending: (state, action) => {
state.pending = action.payload.pending
},
},
})
export const { setKeys, setApps, setPerms, setPending } = contentSlice.actions

View File

@@ -0,0 +1,23 @@
import { createSlice } from '@reduxjs/toolkit'
type ThemeMode = 'light' | 'dark'
export interface UIState {
themeMode: ThemeMode
}
const initialState: UIState = {
themeMode: 'light',
}
export const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
setThemeMode: (state, action) => {
state.themeMode = action.payload.mode
},
},
})
export const { setThemeMode } = uiSlice.actions

View File

@@ -1,70 +0,0 @@
// service-worker client interface
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
let swr: ServiceWorkerRegistration | null = null
const reqs = new Map<number,{ ok: (r: any) => void, rej: (r: any) => void }>()
let nextReqId = 1
let onRender: (() => void) | null = null
export async function swicRegister() {
serviceWorkerRegistration.register({
onSuccess(registration) {
console.log("sw registered")
swr = registration
},
onError(e) {
console.log(`error ${e}`)
}
});
navigator.serviceWorker.ready.then(r => swr = r)
navigator.serviceWorker.addEventListener('message', (event) => {
onMessage((event as MessageEvent).data)
})
}
function onMessage(data: any) {
const { id, result, error } = data
console.log("SW message", id, result, error)
if (!id) {
if (onRender) onRender()
return
}
const r = reqs.get(id)
if (!r) {
console.log("Unexpected message from service worker", data)
return
}
reqs.delete(id)
if (error) r.rej(error)
else r.ok(result)
}
export async function swicCall(method: string, ...args: any[]) {
const id = nextReqId
nextReqId++
return new Promise((ok, rej) => {
if (!swr || !swr.active) {
rej(new Error("No active service worker"))
return
}
reqs.set(id, { ok, rej })
const msg = {
id,
method,
args: [...args],
}
//console.log("sending to SW", msg)
swr.active.postMessage(msg)
})
}
export function swicOnRender(cb: () => void) {
onRender = cb
}

View File

@@ -0,0 +1,6 @@
import { Event } from 'nostr-tools'
export interface AugmentedEvent extends Event {
order: number
identifier: string
}

12
src/utils/helpers.ts Normal file
View File

@@ -0,0 +1,12 @@
export async function log(s: string) {
const log = document.getElementById('log')
if (log) log.innerHTML = s
}
export async function call(cb: () => any) {
try {
return await cb()
} catch (e) {
log(`Error: ${e}`)
}
}

View File

@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@@ -18,9 +14,11 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"src"
]
"include": ["src"]
}