Add signer
This commit is contained in:
@@ -131,6 +131,43 @@ export const tryCatch = async <T>(f: () => Promise<T | void> | T | void, onError
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const equals = (a: any, b: any) => {
|
||||||
|
if (a === b) return true
|
||||||
|
|
||||||
|
if (Array.isArray(a)) {
|
||||||
|
if (!Array.isArray(b) || a.length !== b.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (!equals(a[i], b[i])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof a === 'object') {
|
||||||
|
const aKeys = Object.keys(a)
|
||||||
|
const bKeys = Object.keys(b)
|
||||||
|
|
||||||
|
if (typeof b !== 'object' || aKeys.length !== bKeys.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of aKeys) {
|
||||||
|
if (!equals(a[k], b[k])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Curried utils
|
// Curried utils
|
||||||
|
|
||||||
export const nth = (i: number) => <T>(xs: T[], ...args: unknown[]) => xs[i]
|
export const nth = (i: number) => <T>(xs: T[], ...args: unknown[]) => xs[i]
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
build
|
||||||
|
normalize-url
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# @welshman/store [](https://npmjs.com/package/@welshman/store)
|
||||||
|
|
||||||
|
Utilities for dealing with svelte stores when using welshman.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@welshman/signer",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"author": "hodlbod",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "A nostr signer implemenation supporting several login methods.",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"build"
|
||||||
|
],
|
||||||
|
"types": "./build/src/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./build/src/index.d.ts",
|
||||||
|
"import": "./build/src/index.mjs",
|
||||||
|
"require": "./build/src/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"pub": "npm run lint && npm run build && npm publish",
|
||||||
|
"build": "gts clean && tsc-multi",
|
||||||
|
"lint": "gts lint",
|
||||||
|
"fix": "gts fix"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"gts": "^5.0.1",
|
||||||
|
"tsc-multi": "^1.1.0",
|
||||||
|
"typescript": "~5.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './util'
|
||||||
|
export * from './wrapper'
|
||||||
|
export * from './signers/secret'
|
||||||
|
export * from './signers/connect'
|
||||||
|
export * from './signers/extension'
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import {nip04, finalizeEvent, getPublicKey} from "nostr-tools"
|
||||||
|
import {hexToBytes} from '@noble/hashes/utils'
|
||||||
|
import {Emitter, tryCatch, randomId, sleep, equals, now} from "@welshman/lib"
|
||||||
|
import {createEvent, TrustedEvent, EventTemplate, NOSTR_CONNECT} from "@welshman/util"
|
||||||
|
import {subscribe, publish, Subscription} from "@welshman/net"
|
||||||
|
import {ISigner, decrypt} from '../util'
|
||||||
|
import {SecretSigner} from './secret'
|
||||||
|
|
||||||
|
export type NostrConnectHandler = {
|
||||||
|
pubkey: string
|
||||||
|
relays: string[]
|
||||||
|
domain?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectResponse = {
|
||||||
|
id: string
|
||||||
|
error?: string
|
||||||
|
result?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let singleton: NostrConnectBroker
|
||||||
|
|
||||||
|
// FIXME set the full list of requested perms
|
||||||
|
const Perms =
|
||||||
|
"nip04_encrypt,nip04_decrypt,sign_event:0,sign_event:1,sign_event:4,sign_event:6,sign_event:7"
|
||||||
|
|
||||||
|
export class NostrConnectBroker extends Emitter {
|
||||||
|
#sub: Subscription
|
||||||
|
#signer: ISigner
|
||||||
|
#ready = sleep(500)
|
||||||
|
#closed = false
|
||||||
|
#connectResult: any
|
||||||
|
|
||||||
|
static get(pubkey: string, secret: string, handler: NostrConnectHandler) {
|
||||||
|
if (
|
||||||
|
singleton?.pubkey !== pubkey ||
|
||||||
|
singleton?.secret !== secret ||
|
||||||
|
!equals(singleton?.handler, handler)
|
||||||
|
) {
|
||||||
|
singleton?.teardown()
|
||||||
|
singleton = new NostrConnectBroker(pubkey, secret, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly pubkey: string,
|
||||||
|
readonly secret: string,
|
||||||
|
readonly handler: NostrConnectHandler,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.#signer = new SecretSigner(secret)
|
||||||
|
this.#sub = this.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe() {
|
||||||
|
const sub = subscribe({
|
||||||
|
relays: this.handler.relays,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
since: now() - 30,
|
||||||
|
kinds: [NOSTR_CONNECT],
|
||||||
|
"#p": [getPublicKey(hexToBytes(this.secret))],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
sub.emitter.on("event", async (url: string, e: TrustedEvent) => {
|
||||||
|
const json = await decrypt(this.#signer, e.pubkey, e.content)
|
||||||
|
const res = await tryCatch(() => JSON.parse(json))
|
||||||
|
|
||||||
|
if (!res.id) {
|
||||||
|
console.error(`Invalid nostr-connect response: ${json}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.result === "auth_url") {
|
||||||
|
this.emit(`auth-${res.id}`, res)
|
||||||
|
} else {
|
||||||
|
this.emit(`res-${res.id}`, res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
sub.emitter.on("complete", () => {
|
||||||
|
if (!this.#closed) {
|
||||||
|
this.#sub = this.subscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method: string, params: string[], admin = false) {
|
||||||
|
// nsecbunker has a race condition
|
||||||
|
await this.#ready
|
||||||
|
|
||||||
|
const id = randomId()
|
||||||
|
const pubkey = admin ? this.handler.pubkey : this.pubkey
|
||||||
|
const payload = JSON.stringify({id, method, params})
|
||||||
|
const content = await nip04.encrypt(this.secret, pubkey, payload)
|
||||||
|
const template = createEvent(NOSTR_CONNECT, {content, tags: [["p", pubkey]]})
|
||||||
|
const event = finalizeEvent(template, this.secret as any)
|
||||||
|
|
||||||
|
publish({event, relays: this.handler.relays})
|
||||||
|
|
||||||
|
this.once(`auth-${id}`, res => {
|
||||||
|
window.open(res.result, "Coracle", "width=600,height=800,popup=yes")
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
this.once(`res-${id}`, ({result, error}: ConnectResponse) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error as string)
|
||||||
|
} else {
|
||||||
|
resolve(result as string)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createAccount(username: string) {
|
||||||
|
if (!this.handler.domain) {
|
||||||
|
throw new Error("Unable to create an account without a handler domain")
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.request("create_account", [username, this.handler.domain, "", Perms], true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(token = "") {
|
||||||
|
if (!this.#connectResult) {
|
||||||
|
const params = [this.pubkey, token, Perms]
|
||||||
|
|
||||||
|
this.#connectResult = await this.request("connect", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.#connectResult === "ack"
|
||||||
|
}
|
||||||
|
|
||||||
|
async signEvent(event: EventTemplate) {
|
||||||
|
return JSON.parse(await this.request("sign_event", [JSON.stringify(event)]) as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
nip04Encrypt(pk: string, message: string) {
|
||||||
|
return this.request("nip04_encrypt", [pk, message])
|
||||||
|
}
|
||||||
|
|
||||||
|
nip04Decrypt(pk: string, message: string) {
|
||||||
|
return this.request("nip04_decrypt", [pk, message])
|
||||||
|
}
|
||||||
|
|
||||||
|
nip44Encrypt(pk: string, message: string) {
|
||||||
|
return this.request("nip44_encrypt", [pk, message])
|
||||||
|
}
|
||||||
|
|
||||||
|
nip44Decrypt(pk: string, message: string) {
|
||||||
|
return this.request("nip44_decrypt", [pk, message])
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
this.#closed = true
|
||||||
|
this.#sub?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConnectSigner implements ISigner {
|
||||||
|
constructor(private broker: NostrConnectBroker) {}
|
||||||
|
|
||||||
|
isEnabled = () => true
|
||||||
|
|
||||||
|
getPubkey = async () => this.broker.pubkey
|
||||||
|
|
||||||
|
sign = (event: EventTemplate) => this.broker.signEvent(event)
|
||||||
|
|
||||||
|
nip04 = {
|
||||||
|
encrypt: this.broker.nip04Encrypt,
|
||||||
|
decrypt: this.broker.nip04Decrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
nip44 = {
|
||||||
|
encrypt: this.broker.nip44Encrypt,
|
||||||
|
decrypt: this.broker.nip44Decrypt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {EventTemplate} from '@welshman/util'
|
||||||
|
import {hash, own, Sign, ISigner, EncryptionImplementation} from '../util'
|
||||||
|
|
||||||
|
export type Extension = {
|
||||||
|
sign: Sign
|
||||||
|
nip04: EncryptionImplementation
|
||||||
|
nip44: EncryptionImplementation
|
||||||
|
getPublicKey: () => string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExtensionSigner implements ISigner {
|
||||||
|
#lock = Promise.resolve()
|
||||||
|
|
||||||
|
#ext = () => (window as {nostr?: Extension}).nostr
|
||||||
|
|
||||||
|
#then = async <T>(f: (ext: Extension) => T | Promise<T>) => {
|
||||||
|
const promise = this.#lock.then(() => {
|
||||||
|
const ext = this.#ext()
|
||||||
|
|
||||||
|
if (!ext) throw new Error("Extension is not enabled")
|
||||||
|
|
||||||
|
return f(ext)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recover from errors
|
||||||
|
this.#lock = promise.then(() => undefined, () => undefined)
|
||||||
|
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled = () => Boolean(this.#ext())
|
||||||
|
|
||||||
|
getPubkey = () =>
|
||||||
|
this.#then(ext => {
|
||||||
|
const pubkey = ext.getPublicKey()
|
||||||
|
|
||||||
|
if (!pubkey) throw new Error("Failed to retrieve pubkey")
|
||||||
|
|
||||||
|
return pubkey as string
|
||||||
|
})
|
||||||
|
|
||||||
|
sign = (event: EventTemplate) =>
|
||||||
|
this.#then(ext => ext.sign(hash(own(ext.getPublicKey() as string, event))))
|
||||||
|
|
||||||
|
nip04 = {
|
||||||
|
encrypt: (pubkey: string, message: string) =>
|
||||||
|
this.#then(ext => ext.nip04.encrypt(pubkey, message)),
|
||||||
|
decrypt: (pubkey: string, message: string) =>
|
||||||
|
this.#then(ext => ext.nip04.decrypt(pubkey, message)),
|
||||||
|
}
|
||||||
|
|
||||||
|
nip44 = {
|
||||||
|
encrypt: (pubkey: string, message: string) =>
|
||||||
|
this.#then(ext => ext.nip44.encrypt(pubkey, message)),
|
||||||
|
decrypt: (pubkey: string, message: string) =>
|
||||||
|
this.#then(ext => ext.nip44.decrypt(pubkey, message)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {EventTemplate} from '@welshman/util'
|
||||||
|
import {nip04, nip44, own, hash, sign, getPubkey, ISigner} from "../util"
|
||||||
|
|
||||||
|
export class SecretSigner implements ISigner {
|
||||||
|
private pubkey: string
|
||||||
|
|
||||||
|
constructor(private secret: string) {
|
||||||
|
this.pubkey = getPubkey(this.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled = () => true
|
||||||
|
|
||||||
|
getPubkey = async () => this.pubkey
|
||||||
|
|
||||||
|
sign = async (event: EventTemplate) => sign(hash(own(this.pubkey, event)), this.secret)
|
||||||
|
|
||||||
|
nip04 = {
|
||||||
|
encrypt: async (pubkey: string, message: string) =>
|
||||||
|
nip04.encrypt(pubkey, this.secret, message),
|
||||||
|
decrypt: async (pubkey: string, message: string) =>
|
||||||
|
nip04.decrypt(pubkey, this.secret, message),
|
||||||
|
}
|
||||||
|
|
||||||
|
nip44 = {
|
||||||
|
encrypt: async (pubkey: string, message: string) =>
|
||||||
|
nip44.encrypt(pubkey, this.secret, message),
|
||||||
|
decrypt: async (pubkey: string, message: string) =>
|
||||||
|
nip44.decrypt(pubkey, this.secret, message),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {schnorr} from '@noble/curves/secp256k1'
|
||||||
|
import {bytesToHex, hexToBytes} from '@noble/hashes/utils'
|
||||||
|
import {nip04 as nt04, nip44 as nt44, generateSecretKey, getPublicKey, getEventHash} from "nostr-tools"
|
||||||
|
import {cached} from '@welshman/lib'
|
||||||
|
import {SignedEvent, HashedEvent, EventTemplate, OwnedEvent} from '@welshman/util'
|
||||||
|
|
||||||
|
export const getSecret = () => bytesToHex(generateSecretKey())
|
||||||
|
|
||||||
|
export const getPubkey = (secret: string) => getPublicKey(hexToBytes(secret))
|
||||||
|
|
||||||
|
export const getHash = (event: OwnedEvent) => getEventHash(event)
|
||||||
|
|
||||||
|
export const getSig = (event: HashedEvent, secret: string) =>
|
||||||
|
bytesToHex(schnorr.sign(event.id, secret))
|
||||||
|
|
||||||
|
export const own = (pubkey: string, event: EventTemplate) => ({...event, pubkey})
|
||||||
|
|
||||||
|
export const hash = (event: OwnedEvent): HashedEvent => ({...event, id: getHash(event)})
|
||||||
|
|
||||||
|
export const sign = (event: HashedEvent, secret: string) => ({...event, sig: getSig(event, secret)})
|
||||||
|
|
||||||
|
export const nip04 = {
|
||||||
|
detect: (m: string) => m.includes("?iv="),
|
||||||
|
encrypt: (pubkey: string, secret: string, m: string) => nt04.encrypt(secret, pubkey, m),
|
||||||
|
decrypt: (pubkey: string, secret: string, m: string) => nt04.decrypt(secret, pubkey, m),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nip44 = {
|
||||||
|
getSharedSecret: cached({
|
||||||
|
maxSize: 10000,
|
||||||
|
getKey: ([secret, pubkey]) => [secret, pubkey].join(":"),
|
||||||
|
getValue: ([secret, pubkey]: string[]) => nt44.v2.utils.getConversationKey(hexToBytes(secret), pubkey),
|
||||||
|
}),
|
||||||
|
encrypt: (pubkey: string, secret: string, m: string) => nt44.v2.encrypt(m, nip44.getSharedSecret(secret, pubkey)),
|
||||||
|
decrypt: (pubkey: string, secret: string, m: string) => nt44.v2.decrypt(m, nip44.getSharedSecret(secret, pubkey)),
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Sign = (event: EventTemplate) => Promise<SignedEvent>
|
||||||
|
|
||||||
|
export type Encrypt = (pubkey: string, message: string) => Promise<string>
|
||||||
|
|
||||||
|
export type Decrypt = (pubkey: string, message: string) => Promise<string>
|
||||||
|
|
||||||
|
export type EncryptionImplementation = {
|
||||||
|
encrypt: Encrypt
|
||||||
|
decrypt: Decrypt
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISigner {
|
||||||
|
isEnabled: () => boolean
|
||||||
|
getPubkey: () => Promise<string>
|
||||||
|
sign: Sign
|
||||||
|
nip04: EncryptionImplementation
|
||||||
|
nip44: EncryptionImplementation
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decrypt = async (signer: ISigner, pubkey: string, message: string) =>
|
||||||
|
nip04.detect(message)
|
||||||
|
? signer.nip04.decrypt(pubkey, message)
|
||||||
|
: signer.nip44.decrypt(pubkey, message)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {UnwrappedEvent, SignedEvent, HashedEvent, EventTemplate, WRAP, SEAL} from '@welshman/util'
|
||||||
|
import {own, hash, decrypt, ISigner} from './util'
|
||||||
|
|
||||||
|
// Wrapper
|
||||||
|
|
||||||
|
export type WrapperParams = {
|
||||||
|
author?: string
|
||||||
|
wrap?: {
|
||||||
|
author: string
|
||||||
|
recipient: string
|
||||||
|
tags?: string[][]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Wrapper {
|
||||||
|
seen = new Map<string, UnwrappedEvent | Error>()
|
||||||
|
|
||||||
|
constructor(readonly userSigner: ISigner, readonly wrapSigner: ISigner) {}
|
||||||
|
|
||||||
|
now = (drift = 0) =>
|
||||||
|
Math.round(Date.now() / 1000 - Math.random() * Math.pow(10, drift))
|
||||||
|
|
||||||
|
getSeal = async (pk: string, rumor: HashedEvent) =>
|
||||||
|
this.userSigner.sign(hash({
|
||||||
|
kind: SEAL,
|
||||||
|
pubkey: await this.userSigner.getPubkey(),
|
||||||
|
content: await this.userSigner.nip44.encrypt(pk, JSON.stringify(rumor)),
|
||||||
|
created_at: this.now(5),
|
||||||
|
tags: [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
getWrap = async (pk: string, seal: SignedEvent) =>
|
||||||
|
this.wrapSigner.sign(hash({
|
||||||
|
kind: WRAP,
|
||||||
|
pubkey: await this.wrapSigner.getPubkey(),
|
||||||
|
content: await this.wrapSigner.nip44.encrypt(pk, JSON.stringify(seal)),
|
||||||
|
created_at: this.now(5),
|
||||||
|
tags: [["p", pk]],
|
||||||
|
}))
|
||||||
|
|
||||||
|
wrap = async (pk: string, template: EventTemplate) => {
|
||||||
|
const pubkey = await this.userSigner.getPubkey()
|
||||||
|
const rumor = hash(own(pubkey, template))
|
||||||
|
const seal = await this.getSeal(pk, rumor)
|
||||||
|
const wrap = await this.getWrap(pk, seal)
|
||||||
|
|
||||||
|
return wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrap = async (wrap: SignedEvent) => {
|
||||||
|
// Avoid decrypting the same event multiple times
|
||||||
|
if (this.seen.has(wrap.id)) {
|
||||||
|
const rumorOrError = this.seen.get(wrap.id)
|
||||||
|
|
||||||
|
if (rumorOrError instanceof Error) {
|
||||||
|
throw rumorOrError
|
||||||
|
} else {
|
||||||
|
return rumorOrError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seal = JSON.parse(await decrypt(this.wrapSigner, wrap.pubkey, wrap.content))
|
||||||
|
const rumor = JSON.parse(await decrypt(this.wrapSigner, seal.pubkey, seal.content))
|
||||||
|
|
||||||
|
if (seal.pubkey !== rumor.pubkey) throw new Error("Seal pubkey does not match rumor pubkey")
|
||||||
|
|
||||||
|
this.seen.set(wrap.id, rumor)
|
||||||
|
|
||||||
|
return rumor
|
||||||
|
} catch (error) {
|
||||||
|
this.seen.set(wrap.id, error as Error)
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"targets": [
|
||||||
|
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
|
||||||
|
],
|
||||||
|
"projects": ["tsconfig.json"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../node_modules/gts/tsconfig-google.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "build",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["esnext", "dom", "dom.iterable"]
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user