mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-09 20:33:03 +02:00
Add NIP-46 connection initiated by client
This commit is contained in:
5
.changeset/strong-balloons-wash.md
Normal file
5
.changeset/strong-balloons-wash.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add NIP-46 connection initiated by client
|
@@ -8,7 +8,6 @@ import { getPubkeyFromDecodeResult, isHexKey, normalizeToHexPubkey } from "../he
|
|||||||
import { logger } from "../helpers/debug";
|
import { logger } from "../helpers/debug";
|
||||||
import { DraftNostrEvent, NostrEvent, isPTag } from "../types/nostr-event";
|
import { DraftNostrEvent, NostrEvent, isPTag } from "../types/nostr-event";
|
||||||
import createDefer, { Deferred } from "../classes/deferred";
|
import createDefer, { Deferred } from "../classes/deferred";
|
||||||
import { truncatedId } from "../helpers/nostr/event";
|
|
||||||
import { NostrConnectAccount } from "./account";
|
import { NostrConnectAccount } from "./account";
|
||||||
import { safeRelayUrl } from "../helpers/relay";
|
import { safeRelayUrl } from "../helpers/relay";
|
||||||
import { alwaysVerify } from "./verify-event";
|
import { alwaysVerify } from "./verify-event";
|
||||||
@@ -65,7 +64,7 @@ export class NostrConnectClient {
|
|||||||
log = logger.extend("NostrConnectClient");
|
log = logger.extend("NostrConnectClient");
|
||||||
|
|
||||||
isConnected = false;
|
isConnected = false;
|
||||||
pubkey: string;
|
pubkey?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
relays: string[];
|
relays: string[];
|
||||||
|
|
||||||
@@ -74,8 +73,8 @@ export class NostrConnectClient {
|
|||||||
|
|
||||||
supportedMethods: NostrConnectMethod[] | undefined;
|
supportedMethods: NostrConnectMethod[] | undefined;
|
||||||
|
|
||||||
constructor(pubkey: string, relays: string[], secretKey?: string, provider?: string) {
|
constructor(pubkey?: string, relays: string[] = [], secretKey?: string, provider?: string) {
|
||||||
this.sub = new NostrMultiSubscription(`${pubkey}-nostr-connect`);
|
this.sub = new NostrMultiSubscription();
|
||||||
this.pubkey = pubkey;
|
this.pubkey = pubkey;
|
||||||
this.relays = relays;
|
this.relays = relays;
|
||||||
this.provider = provider;
|
this.provider = provider;
|
||||||
@@ -122,6 +121,17 @@ export class NostrConnectClient {
|
|||||||
try {
|
try {
|
||||||
const responseStr = await nip04.decrypt(this.secretKey, event.pubkey, event.content);
|
const responseStr = await nip04.decrypt(this.secretKey, event.pubkey, event.content);
|
||||||
const response = JSON.parse(responseStr);
|
const response = JSON.parse(responseStr);
|
||||||
|
|
||||||
|
// handle client connections
|
||||||
|
if (!this.pubkey && response.result === "ack") {
|
||||||
|
this.log("Got ack response from", event.pubkey);
|
||||||
|
this.pubkey = event.pubkey;
|
||||||
|
this.isConnected = true;
|
||||||
|
this.listenPromise?.resolve(response.result);
|
||||||
|
this.listenPromise = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (response.id) {
|
if (response.id) {
|
||||||
const p = this.requests.get(response.id);
|
const p = this.requests.get(response.id);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
@@ -146,6 +156,7 @@ export class NostrConnectClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createEvent(content: string, target = this.pubkey, kind = kinds.NostrConnect) {
|
private createEvent(content: string, target = this.pubkey, kind = kinds.NostrConnect) {
|
||||||
|
if (!target) throw new Error("invalid target pubkey");
|
||||||
return finalizeEvent(
|
return finalizeEvent(
|
||||||
{
|
{
|
||||||
kind,
|
kind,
|
||||||
@@ -161,6 +172,7 @@ export class NostrConnectClient {
|
|||||||
params: RequestParams[T],
|
params: RequestParams[T],
|
||||||
kind = kinds.NostrConnect,
|
kind = kinds.NostrConnect,
|
||||||
): Promise<ResponseResults[T]> {
|
): Promise<ResponseResults[T]> {
|
||||||
|
if (!this.pubkey) throw new Error("pubkey not set");
|
||||||
const id = nanoid(8);
|
const id = nanoid(8);
|
||||||
const request: NostrConnectRequest<T> = { id, method, params };
|
const request: NostrConnectRequest<T> = { id, method, params };
|
||||||
const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request));
|
const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request));
|
||||||
@@ -191,6 +203,7 @@ export class NostrConnectClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async connect(token?: string) {
|
async connect(token?: string) {
|
||||||
|
if (!this.pubkey) throw new Error("pubkey not set");
|
||||||
await this.open();
|
await this.open();
|
||||||
try {
|
try {
|
||||||
const result = await this.makeRequest(NostrConnectMethod.Connect, [this.pubkey, token || "", Perms]);
|
const result = await this.makeRequest(NostrConnectMethod.Connect, [this.pubkey, token || "", Perms]);
|
||||||
@@ -203,6 +216,14 @@ export class NostrConnectClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listenPromise: Deferred<"ack"> | null = null;
|
||||||
|
listen(): Promise<"ack"> {
|
||||||
|
if (this.pubkey) throw new Error("Cant listen if there is already a pubkey");
|
||||||
|
this.open();
|
||||||
|
this.listenPromise = createDefer();
|
||||||
|
return this.listenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
async createAccount(name: string, domain: string, email?: string) {
|
async createAccount(name: string, domain: string, email?: string) {
|
||||||
await this.open();
|
await this.open();
|
||||||
|
|
||||||
@@ -228,6 +249,8 @@ export class NostrConnectClient {
|
|||||||
disconnect() {
|
disconnect() {
|
||||||
return this.makeRequest(NostrConnectMethod.Disconnect, []);
|
return this.makeRequest(NostrConnectMethod.Disconnect, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// methods
|
||||||
getPublicKey() {
|
getPublicKey() {
|
||||||
return this.makeRequest(NostrConnectMethod.GetPublicKey, []);
|
return this.makeRequest(NostrConnectMethod.GetPublicKey, []);
|
||||||
}
|
}
|
||||||
|
@@ -1,13 +1,14 @@
|
|||||||
import { useState } from "react";
|
import { ReactNode, useCallback, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Divider,
|
||||||
Flex,
|
Flex,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormHelperText,
|
|
||||||
FormLabel,
|
FormLabel,
|
||||||
IconButton,
|
|
||||||
Input,
|
Input,
|
||||||
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
|
useDisclosure,
|
||||||
useToast,
|
useToast,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -15,11 +16,87 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import accountService from "../../services/account";
|
import accountService from "../../services/account";
|
||||||
import nostrConnectService, { NostrConnectClient } from "../../services/nostr-connect";
|
import nostrConnectService, { NostrConnectClient } from "../../services/nostr-connect";
|
||||||
import QRCodeScannerButton from "../../components/qr-code/qr-code-scanner-button";
|
import QRCodeScannerButton from "../../components/qr-code/qr-code-scanner-button";
|
||||||
|
import { RelayUrlInput } from "../../components/relay-url-input";
|
||||||
|
import QrCodeSvg from "../../components/qr-code/qr-code-svg";
|
||||||
|
import { CopyIconButton } from "../../components/copy-icon-button";
|
||||||
|
|
||||||
|
function ClientConnectForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const urlInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [relay, setRelay] = useState("wss://relay.nsec.app/");
|
||||||
|
const [client, setClient] = useState<NostrConnectClient>();
|
||||||
|
const [listening, setListening] = useState(false);
|
||||||
|
|
||||||
|
const connectionURL = useMemo(() => {
|
||||||
|
if (!client || !relay) return "";
|
||||||
|
|
||||||
|
const host = location.protocol + "//" + location.host;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("relay", relay);
|
||||||
|
params.set("name", "noStrudel");
|
||||||
|
params.set("url", host);
|
||||||
|
params.set("image", new URL("/apple-touch-icon.png", host).toString());
|
||||||
|
|
||||||
|
return `nostrconnect://${client.publicKey}?` + params.toString();
|
||||||
|
}, [relay, client]);
|
||||||
|
|
||||||
|
const create = useCallback(() => {
|
||||||
|
const c = new NostrConnectClient(undefined, [relay]);
|
||||||
|
setClient(c);
|
||||||
|
c.listen().then(() => {
|
||||||
|
nostrConnectService.saveClient(c);
|
||||||
|
accountService.addFromNostrConnect(c);
|
||||||
|
accountService.switchAccount(c.pubkey!);
|
||||||
|
});
|
||||||
|
setListening(true);
|
||||||
|
}, [relay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{client ? (
|
||||||
|
<>
|
||||||
|
<QrCodeSvg content={connectionURL} />
|
||||||
|
<Flex gap="2">
|
||||||
|
<Input
|
||||||
|
value={connectionURL}
|
||||||
|
ref={urlInputRef}
|
||||||
|
onFocus={() => urlInputRef.current?.select()}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
<CopyIconButton value={connectionURL} aria-label="Copy connection URL" />
|
||||||
|
</Flex>
|
||||||
|
{listening && (
|
||||||
|
<Flex gap="2" justifyContent="center" alignItems="center" py="2">
|
||||||
|
<Spinner />
|
||||||
|
<Text>Waiting for connection</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel htmlFor="input">Connection Relay</FormLabel>
|
||||||
|
<RelayUrlInput value={relay} onChange={(e) => setRelay(e.target.value)} />
|
||||||
|
</FormControl>
|
||||||
|
<Button colorScheme="primary" onClick={create}>
|
||||||
|
Create Connection URL
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Flex justifyContent="space-between" gap="2">
|
||||||
|
<Button variant="link" onClick={() => navigate("../")} py="2">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginNostrConnectView() {
|
export default function LoginNostrConnectView() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [uri, setUri] = useState("");
|
const [connection, setConnection] = useState("");
|
||||||
|
const fromClient = useDisclosure();
|
||||||
|
|
||||||
const [loading, setLoading] = useState<string | undefined>();
|
const [loading, setLoading] = useState<string | undefined>();
|
||||||
const handleSubmit: React.FormEventHandler<HTMLDivElement> = async (e) => {
|
const handleSubmit: React.FormEventHandler<HTMLDivElement> = async (e) => {
|
||||||
@@ -28,55 +105,74 @@ export default function LoginNostrConnectView() {
|
|||||||
try {
|
try {
|
||||||
setLoading("Connecting...");
|
setLoading("Connecting...");
|
||||||
let client: NostrConnectClient;
|
let client: NostrConnectClient;
|
||||||
if (uri.startsWith("bunker://")) {
|
if (connection.startsWith("bunker://")) {
|
||||||
if (uri.includes("@")) client = nostrConnectService.fromBunkerAddress(uri);
|
if (connection.includes("@")) client = nostrConnectService.fromBunkerAddress(connection);
|
||||||
else client = nostrConnectService.fromBunkerURI(uri);
|
else client = nostrConnectService.fromBunkerURI(connection);
|
||||||
|
|
||||||
await client.connect(new URL(uri).searchParams.get('secret') ?? undefined);
|
await client.connect(new URL(connection).searchParams.get("secret") ?? undefined);
|
||||||
} else if (uri.startsWith("npub")) {
|
} else if (connection.startsWith("npub")) {
|
||||||
client = nostrConnectService.fromBunkerToken(uri);
|
client = nostrConnectService.fromBunkerToken(connection);
|
||||||
const [npub, hexToken] = uri.split("#");
|
const [npub, hexToken] = connection.split("#");
|
||||||
await client.connect(hexToken);
|
await client.connect(hexToken);
|
||||||
} else throw new Error("Unknown format");
|
} else throw new Error("Unknown format");
|
||||||
|
|
||||||
nostrConnectService.saveClient(client);
|
nostrConnectService.saveClient(client);
|
||||||
accountService.addFromNostrConnect(client);
|
accountService.addFromNostrConnect(client);
|
||||||
accountService.switchAccount(client.pubkey);
|
accountService.switchAccount(client.pubkey!);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) toast({ status: "error", description: e.message });
|
if (e instanceof Error) toast({ status: "error", description: e.message });
|
||||||
}
|
}
|
||||||
setLoading(undefined);
|
setLoading(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let content: ReactNode = null;
|
||||||
|
if (fromClient.isOpen) {
|
||||||
|
content = <ClientConnectForm />;
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
{loading && <Text fontSize="lg">{loading}</Text>}
|
||||||
|
{!loading && (
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel htmlFor="input">Remote Signer URL</FormLabel>
|
||||||
|
<Flex gap="2">
|
||||||
|
<Input
|
||||||
|
id="nostr-connect"
|
||||||
|
name="nostr-connect"
|
||||||
|
placeholder="bunker://<pubkey>?relay=wss://relay.example.com"
|
||||||
|
isRequired
|
||||||
|
value={connection}
|
||||||
|
onChange={(e) => setConnection(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<QRCodeScannerButton onData={(v) => setConnection(v)} />
|
||||||
|
</Flex>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
<Flex justifyContent="space-between" gap="2">
|
||||||
|
<Button variant="link" onClick={() => navigate("../")} py="2">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button colorScheme="primary" type="submit" isLoading={!!loading}>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex w="full" alignItems="center" gap="4">
|
||||||
|
<Divider />
|
||||||
|
<Text fontWeight="bold">OR</Text>
|
||||||
|
<Divider />
|
||||||
|
</Flex>
|
||||||
|
<Button variant="outline" onClick={fromClient.onOpen}>
|
||||||
|
Create Connection URL
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} w="full">
|
<Flex as="form" direction="column" gap="2" onSubmit={handleSubmit} w="full">
|
||||||
{loading && <Text fontSize="lg">{loading}</Text>}
|
{content}
|
||||||
{!loading && (
|
|
||||||
<FormControl>
|
|
||||||
<FormLabel htmlFor="input">Connect URI</FormLabel>
|
|
||||||
<Flex gap="2">
|
|
||||||
<Input
|
|
||||||
id="nostr-connect"
|
|
||||||
name="nostr-connect"
|
|
||||||
placeholder="bunker://<pubkey>?relay=wss://relay.example.com"
|
|
||||||
isRequired
|
|
||||||
value={uri}
|
|
||||||
onChange={(e) => setUri(e.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<QRCodeScannerButton onData={(v) => setUri(v)} />
|
|
||||||
</Flex>
|
|
||||||
<FormHelperText>A bunker connect URI</FormHelperText>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
<Flex justifyContent="space-between" gap="2">
|
|
||||||
<Button variant="link" onClick={() => navigate("../")}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button colorScheme="primary" ml="auto" type="submit" isLoading={!!loading}>
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,22 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { Badge, Button, ButtonGroup, Divider, Flex, IconButton, Link, Spinner, Text, useToast } from "@chakra-ui/react";
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
Flex,
|
|
||||||
IconButton,
|
|
||||||
Link,
|
|
||||||
Spinner,
|
|
||||||
Text,
|
|
||||||
useDisclosure,
|
|
||||||
useToast,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import Key01 from "../../components/icons/key-01";
|
import Key01 from "../../components/icons/key-01";
|
||||||
import Diamond01 from "../../components/icons/diamond-01";
|
import Diamond01 from "../../components/icons/diamond-01";
|
||||||
import ChevronDown from "../../components/icons/chevron-down";
|
|
||||||
import ChevronUp from "../../components/icons/chevron-up";
|
|
||||||
import UsbFlashDrive from "../../components/icons/usb-flash-drive";
|
import UsbFlashDrive from "../../components/icons/usb-flash-drive";
|
||||||
import HelpCircle from "../../components/icons/help-circle";
|
import HelpCircle from "../../components/icons/help-circle";
|
||||||
|
|
||||||
@@ -27,12 +14,13 @@ import amberSignerService from "../../services/amber-signer";
|
|||||||
import { AtIcon } from "../../components/icons";
|
import { AtIcon } from "../../components/icons";
|
||||||
import { getRelaysFromExt } from "../../helpers/nip07";
|
import { getRelaysFromExt } from "../../helpers/nip07";
|
||||||
import { safeRelayUrls } from "../../helpers/relay";
|
import { safeRelayUrls } from "../../helpers/relay";
|
||||||
|
import Package from "../../components/icons/package";
|
||||||
|
import Eye from "../../components/icons/eye";
|
||||||
|
|
||||||
export default function LoginStartView() {
|
export default function LoginStartView() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const advanced = useDisclosure();
|
|
||||||
|
|
||||||
const signinWithExtension = async () => {
|
const signinWithExtension = async () => {
|
||||||
if (window.nostr) {
|
if (window.nostr) {
|
||||||
@@ -150,31 +138,49 @@ export default function LoginStartView() {
|
|||||||
/>
|
/>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Flex w="full" alignItems="center" gap="4">
|
||||||
variant="link"
|
<Divider />
|
||||||
onClick={advanced.onToggle}
|
<Text fontWeight="bold">OR</Text>
|
||||||
py="2"
|
<Divider />
|
||||||
w="full"
|
</Flex>
|
||||||
rightIcon={advanced.isOpen ? <ChevronUp /> : <ChevronDown />}
|
<Flex gap="2">
|
||||||
>
|
<Button
|
||||||
Show Advanced
|
flexDirection="column"
|
||||||
</Button>
|
h="auto"
|
||||||
{advanced.isOpen && (
|
p="4"
|
||||||
<>
|
as={RouterLink}
|
||||||
<Button as={RouterLink} to="./nostr-connect" state={location.state} w="full">
|
to="./nostr-connect"
|
||||||
Nostr Connect / Bunker
|
state={location.state}
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button as={RouterLink} to="./npub" state={location.state} w="full">
|
>
|
||||||
Public key (npub)
|
<Package boxSize={12} />
|
||||||
<Badge ml="2" colorScheme="blue">
|
Nostr Connect
|
||||||
read-only
|
</Button>
|
||||||
</Badge>
|
<Button
|
||||||
</Button>
|
flexDirection="column"
|
||||||
<Button as={RouterLink} to="./nsec" state={location.state} w="full">
|
h="auto"
|
||||||
Secret key (nsec)
|
p="4"
|
||||||
</Button>
|
as={RouterLink}
|
||||||
</>
|
to="./nsec"
|
||||||
)}
|
state={location.state}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Key01 boxSize={12} />
|
||||||
|
Private key
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
flexDirection="column"
|
||||||
|
h="auto"
|
||||||
|
p="4"
|
||||||
|
as={RouterLink}
|
||||||
|
to="./npub"
|
||||||
|
state={location.state}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Eye boxSize={12} />
|
||||||
|
Public key
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
<Text fontWeight="bold" mt="4">
|
<Text fontWeight="bold" mt="4">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
</Text>
|
</Text>
|
||||||
|
Reference in New Issue
Block a user