kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'nostr-machina' into 'main'
Implement Nostr Machina See merge request soapbox-pub/soapbox!2775environments/review-main-yi2y9f/deployments/4091
commit
f02255ff6a
|
@ -123,6 +123,7 @@
|
|||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.7.11",
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"nostr-machina": "^0.1.0",
|
||||
"nostr-tools": "^1.14.2",
|
||||
"path-browserify": "^1.0.1",
|
||||
"postcss": "^8.4.29",
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { importEntities } from 'soapbox/entity-store/actions';
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { getPublicKey } from 'soapbox/features/nostr/sign';
|
||||
import { selectAccount } from 'soapbox/selectors';
|
||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||
import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features';
|
||||
|
@ -128,9 +131,15 @@ const maybeRedirectLogin = (error: AxiosError, history?: History) => {
|
|||
const noOp = () => new Promise(f => f(undefined));
|
||||
|
||||
const createAccount = (params: Record<string, any>) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const { instance } = getState();
|
||||
const { nostrSignup } = getFeatures(instance);
|
||||
const pubkey = nostrSignup ? await getPublicKey() : undefined;
|
||||
|
||||
dispatch({ type: ACCOUNT_CREATE_REQUEST, params });
|
||||
return api(getState, 'app').post('/api/v1/accounts', params).then(({ data: token }) => {
|
||||
return api(getState, 'app').post('/api/v1/accounts', params, {
|
||||
headers: pubkey ? { authorization: `Bearer ${nip19.npubEncode(pubkey)}` } : undefined,
|
||||
}).then(({ data: token }) => {
|
||||
return dispatch({ type: ACCOUNT_CREATE_SUCCESS, params, token });
|
||||
}).catch(error => {
|
||||
dispatch({ type: ACCOUNT_CREATE_FAIL, error, params });
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { getPublicKey } from 'soapbox/features/nostr/sign';
|
||||
import { type AppDispatch } from 'soapbox/store';
|
||||
|
||||
import { verifyCredentials } from './auth';
|
||||
|
||||
/** Log in with a Nostr pubkey. */
|
||||
function nostrLogIn() {
|
||||
return async (dispatch: AppDispatch) => {
|
||||
const pubkey = await getPublicKey();
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
|
||||
return dispatch(verifyCredentials(npub));
|
||||
};
|
||||
}
|
||||
|
||||
export { nostrLogIn };
|
|
@ -1,6 +1,7 @@
|
|||
import { relayInit, type Relay } from 'nostr-tools';
|
||||
import { useEffect } from 'react';
|
||||
import { NiceRelay } from 'nostr-machina';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { nip04, signEvent } from 'soapbox/features/nostr/sign';
|
||||
import { useInstance } from 'soapbox/hooks';
|
||||
import { connectRequestSchema } from 'soapbox/schemas/nostr';
|
||||
import { jsonSchema } from 'soapbox/schemas/utils';
|
||||
|
@ -11,47 +12,50 @@ function useSignerStream() {
|
|||
const relayUrl = instance.nostr?.relay;
|
||||
const pubkey = instance.nostr?.pubkey;
|
||||
|
||||
useEffect(() => {
|
||||
let relay: Relay | undefined;
|
||||
|
||||
if (relayUrl && pubkey && window.nostr?.nip04) {
|
||||
relay = relayInit(relayUrl);
|
||||
relay.connect();
|
||||
|
||||
relay
|
||||
.sub([{ kinds: [24133], authors: [pubkey], limit: 0 }])
|
||||
.on('event', async (event) => {
|
||||
if (!relay || !window.nostr?.nip04) return;
|
||||
|
||||
const decrypted = await window.nostr.nip04.decrypt(pubkey, event.content);
|
||||
const reqMsg = jsonSchema.pipe(connectRequestSchema).safeParse(decrypted);
|
||||
|
||||
if (!reqMsg.success) {
|
||||
console.warn(decrypted);
|
||||
console.warn(reqMsg.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const signed = await window.nostr.signEvent(reqMsg.data.params[0]);
|
||||
const respMsg = {
|
||||
id: reqMsg.data.id,
|
||||
result: signed,
|
||||
};
|
||||
|
||||
const respEvent = await window.nostr.signEvent({
|
||||
kind: 24133,
|
||||
content: await window.nostr.nip04.encrypt(pubkey, JSON.stringify(respMsg)),
|
||||
tags: [['p', pubkey]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
relay.publish(respEvent);
|
||||
});
|
||||
const relay = useMemo(() => {
|
||||
if (relayUrl) {
|
||||
return new NiceRelay(relayUrl);
|
||||
}
|
||||
}, [relayUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!relay || !pubkey) return;
|
||||
|
||||
const sub = relay.req([{ kinds: [24133], authors: [pubkey], limit: 0 }]);
|
||||
|
||||
const readEvents = async () => {
|
||||
for await (const event of sub) {
|
||||
const decrypted = await nip04.decrypt(pubkey, event.content);
|
||||
|
||||
const reqMsg = jsonSchema.pipe(connectRequestSchema).safeParse(decrypted);
|
||||
if (!reqMsg.success) {
|
||||
console.warn(decrypted);
|
||||
console.warn(reqMsg.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const respMsg = {
|
||||
id: reqMsg.data.id,
|
||||
result: await signEvent(reqMsg.data.params[0]),
|
||||
};
|
||||
|
||||
const respEvent = await signEvent({
|
||||
kind: 24133,
|
||||
content: await nip04.encrypt(pubkey, JSON.stringify(respMsg)),
|
||||
tags: [['p', pubkey]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
relay.send(['EVENT', respEvent]);
|
||||
}
|
||||
};
|
||||
|
||||
readEvents();
|
||||
|
||||
return () => {
|
||||
relay?.close();
|
||||
};
|
||||
}, [relayUrl, pubkey]);
|
||||
}, [relay, pubkey]);
|
||||
}
|
||||
|
||||
export { useSignerStream };
|
||||
|
|
|
@ -245,46 +245,52 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
|
|||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Input
|
||||
type='email'
|
||||
name='email'
|
||||
placeholder={intl.formatMessage(messages.email)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onInputChange}
|
||||
value={params.get('email', '')}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type='password'
|
||||
name='password'
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onPasswordChange}
|
||||
value={params.get('password', '')}
|
||||
required
|
||||
/>
|
||||
|
||||
<FormGroup
|
||||
errors={passwordMismatch ? [intl.formatMessage(messages.passwordMismatch)] : undefined}
|
||||
>
|
||||
{!features.nostrSignup && (
|
||||
<Input
|
||||
type='password'
|
||||
name='password_confirmation'
|
||||
placeholder={intl.formatMessage(messages.confirm)}
|
||||
type='email'
|
||||
name='email'
|
||||
placeholder={intl.formatMessage(messages.email)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onPasswordConfirmChange}
|
||||
onBlur={onPasswordConfirmBlur}
|
||||
value={passwordConfirmation}
|
||||
onChange={onInputChange}
|
||||
value={params.get('email', '')}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
{!features.nostrSignup && (
|
||||
<>
|
||||
<Input
|
||||
type='password'
|
||||
name='password'
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onPasswordChange}
|
||||
value={params.get('password', '')}
|
||||
required
|
||||
/>
|
||||
|
||||
<FormGroup
|
||||
errors={passwordMismatch ? [intl.formatMessage(messages.passwordMismatch)] : undefined}
|
||||
>
|
||||
<Input
|
||||
type='password'
|
||||
name='password_confirmation'
|
||||
placeholder={intl.formatMessage(messages.confirm)}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
onChange={onPasswordConfirmChange}
|
||||
onBlur={onPasswordConfirmBlur}
|
||||
value={passwordConfirmation}
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{birthdayRequired && (
|
||||
<BirthdayInput
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
type Event,
|
||||
type EventTemplate,
|
||||
generatePrivateKey,
|
||||
getPublicKey as _getPublicKey,
|
||||
finishEvent,
|
||||
nip04 as _nip04,
|
||||
} from 'nostr-tools';
|
||||
|
||||
/** localStorage key for the Nostr private key (if not using NIP-07). */
|
||||
const LOCAL_KEY = 'soapbox:nostr:privateKey';
|
||||
|
||||
/** Get the private key from the browser, or generate one. */
|
||||
const getPrivateKey = (): string => {
|
||||
const local = localStorage.getItem(LOCAL_KEY);
|
||||
|
||||
if (!local) {
|
||||
const key = generatePrivateKey();
|
||||
localStorage.setItem(LOCAL_KEY, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
return local;
|
||||
};
|
||||
|
||||
/** Get the user's public key from NIP-07, or generate one. */
|
||||
async function getPublicKey(): Promise<string> {
|
||||
return window.nostr ? window.nostr.getPublicKey() : _getPublicKey(getPrivateKey());
|
||||
}
|
||||
|
||||
/** Sign an event with NIP-07, or the locally generated key. */
|
||||
async function signEvent<K extends number>(event: EventTemplate<K>): Promise<Event<K>> {
|
||||
return window.nostr ? window.nostr.signEvent(event) as Promise<Event<K>> : finishEvent(event, getPrivateKey()) ;
|
||||
}
|
||||
|
||||
/** Crypto function with NIP-07, or the local key. */
|
||||
const nip04 = {
|
||||
/** Encrypt with NIP-07, or the local key. */
|
||||
encrypt: async (pubkey: string, content: string) => {
|
||||
return window.nostr?.nip04
|
||||
? window.nostr.nip04.encrypt(pubkey, content)
|
||||
: _nip04.encrypt(getPrivateKey(), pubkey, content);
|
||||
},
|
||||
/** Decrypt with NIP-07, or the local key. */
|
||||
decrypt: async (pubkey: string, content: string) => {
|
||||
return window.nostr?.nip04
|
||||
? window.nostr.nip04.decrypt(pubkey, content)
|
||||
: _nip04.decrypt(getPrivateKey(), pubkey, content);
|
||||
},
|
||||
};
|
||||
|
||||
export { getPublicKey, signEvent, nip04 };
|
|
@ -5,6 +5,7 @@ import { Link, Redirect } from 'react-router-dom';
|
|||
|
||||
import { logIn, verifyCredentials } from 'soapbox/actions/auth';
|
||||
import { fetchInstance } from 'soapbox/actions/instance';
|
||||
import { nostrLogIn } from 'soapbox/actions/nostr';
|
||||
import { openSidebar } from 'soapbox/actions/sidebar';
|
||||
import SiteLogo from 'soapbox/components/site-logo';
|
||||
import { Avatar, Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui';
|
||||
|
@ -38,6 +39,12 @@ const Navbar = () => {
|
|||
|
||||
const onOpenSidebar = () => dispatch(openSidebar());
|
||||
|
||||
const handleNostrLogin = async () => {
|
||||
setLoading(true);
|
||||
await dispatch(nostrLogIn()).catch(console.error);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSubmit: React.FormEventHandler = (event) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
|
@ -107,50 +114,66 @@ const Navbar = () => {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Form className='hidden items-center space-x-2 rtl:space-x-reverse lg:flex' onSubmit={handleSubmit}>
|
||||
<Input
|
||||
required
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)}
|
||||
className='max-w-[200px]'
|
||||
/>
|
||||
{features.nostrSignup ? (
|
||||
<div className='hidden items-center xl:flex'>
|
||||
<Button
|
||||
theme='primary'
|
||||
onClick={handleNostrLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{intl.formatMessage(messages.login)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Form className='hidden items-center space-x-2 rtl:space-x-reverse xl:flex' onSubmit={handleSubmit}>
|
||||
<Input
|
||||
required
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)}
|
||||
className='max-w-[200px]'
|
||||
/>
|
||||
|
||||
<Input
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
type='password'
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
className='max-w-[200px]'
|
||||
/>
|
||||
<Input
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
type='password'
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
className='max-w-[200px]'
|
||||
/>
|
||||
|
||||
<Link to='/reset-password'>
|
||||
<Tooltip text={intl.formatMessage(messages.forgotPassword)}>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/help.svg')}
|
||||
className='cursor-pointer bg-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200'
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
<Link to='/reset-password'>
|
||||
<Tooltip text={intl.formatMessage(messages.forgotPassword)}>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/help.svg')}
|
||||
className='cursor-pointer bg-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200'
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
theme='primary'
|
||||
type='submit'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{intl.formatMessage(messages.login)}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className='space-x-1.5 xl:hidden'>
|
||||
<Button
|
||||
theme='primary'
|
||||
type='submit'
|
||||
disabled={isLoading}
|
||||
theme='tertiary'
|
||||
size='sm'
|
||||
{...(features.nostrSignup ? { onClick: handleNostrLogin } : { to: '/login' })}
|
||||
>
|
||||
{intl.formatMessage(messages.login)}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<div className='space-x-1.5 lg:hidden'>
|
||||
<Button theme='tertiary' to='/login' size='sm'>
|
||||
<FormattedMessage id='account.login' defaultMessage='Log In' />
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
{(isOpen) && (
|
||||
<Button theme='primary' to='/signup' size='sm'>
|
||||
<FormattedMessage id='account.register' defaultMessage='Sign up' />
|
||||
</Button>
|
||||
|
|
|
@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||
/** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */
|
||||
const nostrIdSchema = z.string().regex(/^[0-9a-f]{64}$/);
|
||||
/** Nostr kinds are positive integers. */
|
||||
const kindSchema = z.number().int().positive();
|
||||
const kindSchema = z.number().int().nonnegative();
|
||||
|
||||
/** Nostr event template schema. */
|
||||
const eventTemplateSchema = z.object({
|
||||
|
|
|
@ -685,6 +685,12 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
nostrSign: v.software === DITTO,
|
||||
|
||||
/**
|
||||
* Whether the backend uses Ditto's Nosteric way of registration.
|
||||
* @see POST /api/v1/accounts
|
||||
*/
|
||||
nostrSignup: v.software === DITTO,
|
||||
|
||||
/**
|
||||
* Add private notes to accounts.
|
||||
* @see POST /api/v1/accounts/:id/note
|
||||
|
|
25
yarn.lock
25
yarn.lock
|
@ -6512,6 +6512,26 @@ normalize-url@^6.0.1:
|
|||
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
|
||||
integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
|
||||
|
||||
nostr-machina@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/nostr-machina/-/nostr-machina-0.1.0.tgz#e111e86eb51655e5de31862174d23de184e6e98a"
|
||||
integrity sha512-sNswM9vgq7R/96YIJKZOlG0M/m2mZrb1TiPA7hpOMrnWHBGdDuAeON0vLWJaGbvpuDKYQ1b5ZiLZ8HM3EZPevw==
|
||||
dependencies:
|
||||
nostr-tools "^1.14.0"
|
||||
zod "^3.21.0"
|
||||
|
||||
nostr-tools@^1.14.0:
|
||||
version "1.16.0"
|
||||
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.16.0.tgz#5867f1d8bd055a5a3b27aadb199457dceb244314"
|
||||
integrity sha512-sx/aOl0gmkeHVoIVbyOhEQhzF88NsrBXMC8bsjhPASqA6oZ8uSOAyEGgRLMfC3SKgzQD5Gr6KvDoAahaD6xKcg==
|
||||
dependencies:
|
||||
"@noble/ciphers" "^0.2.0"
|
||||
"@noble/curves" "1.1.0"
|
||||
"@noble/hashes" "1.3.1"
|
||||
"@scure/base" "1.1.1"
|
||||
"@scure/bip32" "1.3.1"
|
||||
"@scure/bip39" "1.2.1"
|
||||
|
||||
nostr-tools@^1.14.2:
|
||||
version "1.14.2"
|
||||
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.14.2.tgz#161c9401467725e87c07fcf1c9924d31b12fd45c"
|
||||
|
@ -9674,6 +9694,11 @@ yocto-queue@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
|
||||
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
|
||||
|
||||
zod@^3.21.0:
|
||||
version "3.22.3"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.3.tgz#2fbc96118b174290d94e8896371c95629e87a060"
|
||||
integrity sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==
|
||||
|
||||
zod@^3.21.4:
|
||||
version "3.21.4"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
|
||||
|
|
Ładowanie…
Reference in New Issue