Merge pull request #1225 from leesalminen/main

New Extension: NIP-05 Verification
This commit is contained in:
Arc 2022-12-29 22:32:42 +00:00 committed by GitHub
commit b1e08744ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1877 additions and 0 deletions

View File

@ -0,0 +1,44 @@
# Nostr NIP-05
## Allow users to NIP-05 verify themselves at a domain you control
This extension allows users to sell NIP-05 verification to other nostr users on a domain they control.
## Usage
1. Create a Domain by clicking "NEW DOMAIN"\
2. Fill the options for your DOMAIN
- select the wallet
- select the fiat currency the invoice will be denominated in
- select an amount in fiat to charge users for verification
- enter the domain (or subdomain) you want to provide verification for
- Note, you must own this domain and have access to a web server
3. You can then use share your signup link with your users to allow them to sign up
## Installation
In order for this to work, you need to have ownership of a domain name, and access to a web server that this domain is pointed to.
Then, you'll need to set up a proxy that points `https://{your_domain}/.well-known/nostr.json` to `https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json`
Example nginx configuration
```
## Proxy Server Caching
proxy_cache_path /tmp/nginx_cache keys_zone=nip5_cache:5m levels=1:2 inactive=300s max_size=100m use_temp_path=off;
location /.well-known/nostr.json {
proxy_pass https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json;
proxy_set_header Host {your_lnbits};
proxy_ssl_server_name on;
expires 5m;
add_header Cache-Control "public, no-transform";
proxy_cache nip5_cache;
proxy_cache_lock on;
proxy_cache_valid 200 300s;
proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504;
}
```

View File

@ -0,0 +1,36 @@
import asyncio
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_nostrnip5")
nostrnip5_static_files = [
{
"path": "/nostrnip5/static",
"app": StaticFiles(directory="lnbits/extensions/nostrnip5/static"),
"name": "nostrnip5_static",
}
]
nostrnip5_ext: APIRouter = APIRouter(prefix="/nostrnip5", tags=["nostrnip5"])
def nostrnip5_renderer():
return template_renderer(["lnbits/extensions/nostrnip5/templates"])
from .tasks import wait_for_paid_invoices
def nostrnip5_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
from .views import * # noqa
from .views_api import * # noqa

View File

@ -0,0 +1,6 @@
{
"name": "Nostr NIP-5",
"short_description": "Verify addresses for Nostr NIP-5",
"icon": "request_quote",
"contributors": ["leesalminen"]
}

View File

@ -0,0 +1,186 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Address, CreateAddressData, CreateDomainData, Domain
async def get_domain(domain_id: str) -> Optional[Domain]:
row = await db.fetchone(
"SELECT * FROM nostrnip5.domains WHERE id = ?", (domain_id,)
)
return Domain.from_row(row) if row else None
async def get_domain_by_name(domain: str) -> Optional[Domain]:
row = await db.fetchone(
"SELECT * FROM nostrnip5.domains WHERE domain = ?", (domain,)
)
return Domain.from_row(row) if row else None
async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domain]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM nostrnip5.domains WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Domain.from_row(row) for row in rows]
async def get_address(domain_id: str, address_id: str) -> Optional[Address]:
row = await db.fetchone(
"SELECT * FROM nostrnip5.addresses WHERE domain_id = ? AND id = ?",
(
domain_id,
address_id,
),
)
return Address.from_row(row) if row else None
async def get_address_by_local_part(
domain_id: str, local_part: str
) -> Optional[Address]:
row = await db.fetchone(
"SELECT * FROM nostrnip5.addresses WHERE domain_id = ? AND local_part = ?",
(
domain_id,
local_part.lower(),
),
)
return Address.from_row(row) if row else None
async def get_addresses(domain_id: str) -> List[Address]:
rows = await db.fetchall(
f"SELECT * FROM nostrnip5.addresses WHERE domain_id = ?", (domain_id,)
)
return [Address.from_row(row) for row in rows]
async def get_all_addresses(wallet_ids: Union[str, List[str]]) -> List[Address]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"""
SELECT a.*
FROM nostrnip5.addresses a
JOIN nostrnip5.domains d ON d.id = a.domain_id
WHERE d.wallet IN ({q})
""",
(*wallet_ids,),
)
return [Address.from_row(row) for row in rows]
async def activate_address(domain_id: str, address_id: str) -> Address:
await db.execute(
"""
UPDATE nostrnip5.addresses
SET active = true
WHERE domain_id = ?
AND id = ?
""",
(
domain_id,
address_id,
),
)
address = await get_address(domain_id, address_id)
assert address, "Newly updated address couldn't be retrieved"
return address
async def rotate_address(domain_id: str, address_id: str, pubkey: str) -> Address:
await db.execute(
"""
UPDATE nostrnip5.addresses
SET pubkey = ?
WHERE domain_id = ?
AND id = ?
""",
(
pubkey,
domain_id,
address_id,
),
)
address = await get_address(domain_id, address_id)
assert address, "Newly updated address couldn't be retrieved"
return address
async def delete_domain(domain_id) -> bool:
await db.execute(
"""
DELETE FROM nostrnip5.addresses WHERE domain_id = ?
""",
(domain_id,),
)
await db.execute(
"""
DELETE FROM nostrnip5.domains WHERE id = ?
""",
(domain_id,),
)
return True
async def delete_address(address_id) -> bool:
await db.execute(
"""
DELETE FROM nostrnip5.addresses WHERE id = ?
""",
(address_id,),
)
async def create_address_internal(domain_id: str, data: CreateAddressData) -> Address:
address_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO nostrnip5.addresses (id, domain_id, local_part, pubkey, active)
VALUES (?, ?, ?, ?, ?)
""",
(
address_id,
domain_id,
data.local_part.lower(),
data.pubkey,
False,
),
)
address = await get_address(domain_id, address_id)
assert address, "Newly created address couldn't be retrieved"
return address
async def create_domain_internal(wallet_id: str, data: CreateDomainData) -> Domain:
domain_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO nostrnip5.domains (id, wallet, currency, amount, domain)
VALUES (?, ?, ?, ?, ?)
""",
(domain_id, wallet_id, data.currency, int(data.amount * 100), data.domain),
)
domain = await get_domain(domain_id)
assert domain, "Newly created domain couldn't be retrieved"
return domain

View File

@ -0,0 +1,35 @@
async def m001_initial_invoices(db):
await db.execute(
f"""
CREATE TABLE nostrnip5.domains (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
currency TEXT NOT NULL,
amount INTEGER NOT NULL,
domain TEXT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
f"""
CREATE TABLE nostrnip5.addresses (
id TEXT PRIMARY KEY,
domain_id TEXT NOT NULL,
local_part TEXT NOT NULL,
pubkey TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT false,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
FOREIGN KEY(domain_id) REFERENCES {db.references_schema}domains(id)
);
"""
)

View File

@ -0,0 +1,50 @@
from enum import Enum
from sqlite3 import Row
from typing import List, Optional
from fastapi.param_functions import Query
from pydantic import BaseModel
class RotateAddressData(BaseModel):
pubkey: str
class CreateAddressData(BaseModel):
domain_id: str
local_part: str
pubkey: str
active: bool = False
class CreateDomainData(BaseModel):
wallet: str
currency: str
amount: float = Query(..., ge=0.01)
domain: str
class Domain(BaseModel):
id: str
wallet: str
currency: str
amount: int
domain: str
time: int
@classmethod
def from_row(cls, row: Row) -> "Domain":
return cls(**dict(row))
class Address(BaseModel):
id: str
domain_id: str
local_part: str
pubkey: str
active: bool
time: int
@classmethod
def from_row(cls, row: Row) -> "Address":
return cls(**dict(row))

View File

@ -0,0 +1,34 @@
import asyncio
import json
from lnbits.core.models import Payment
from lnbits.helpers import urlsafe_short_hash
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
from .crud import activate_address
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "nostrnip5":
# not relevant
return
domain_id = payment.extra.get("domain_id")
address_id = payment.extra.get("address_id")
print("Activating NOSTR NIP-05")
print(domain_id)
print(address_id)
active = await activate_address(domain_id, address_id)
return

View File

@ -0,0 +1,174 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List Domains">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /nostrnip5/api/v1/domains</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;domain_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}nostrnip5/api/v1/domains -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="List Addresses">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /nostrnip5/api/v1/addresses</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;address_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}nostrnip5/api/v1/addresses -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Fetch Domain">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/nostrnip5/api/v1/domain/{domain_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{domain_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}nostrnip5/api/v1/domain/{domain_id}
-H "X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create Domain">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /nostrnip5/api/v1/domain</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{domain_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}nostrnip5/api/v1/domain -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create Address">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/nostrnip5/api/v1/domain/{domain_id}/address</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{address_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}nostrnip5/api/v1/domain/{domain_id}/address -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create Invoice Payment"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_id}/payments</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{payment_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check Invoice Payment Status"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/nostrnip5/api/v1/domain/{domain_id}/payments/{payment_hash}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}nostrnip5/api/v1/domain/{domain_id}/payments/{payment_hash} -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,703 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New Domain</q-btn
>
<q-btn unelevated color="primary" @click="addressFormDialog.show = true"
>New Address</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Domains</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="domains"
row-key="id"
:columns="domainsTable.columns"
:pagination.sync="domainsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'signup/' + props.row.id"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'api/v1/domain/' + props.row.id + '/nostr.json'"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="delete"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="deleteDomain(props.row.id)"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Addresses</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportAddressesCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="addresses"
row-key="id"
:columns="addressesTable.columns"
:pagination.sync="addressesTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="edit"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
target="_blank"
:href="'rotate/' + props.row.domain_id + '/' + props.row.id"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="check"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
:disable="props.row.active == true"
@click="activateAddress(props.row.domain_id, props.row.id)"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="delete"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="deleteAddress(props.row.id)"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Nostr NIP-5 extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "nostrnip5/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="saveDomain" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.currency"
:options="currencyOptions"
label="Currency *"
></q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.amount"
label="Amount"
placeholder="10.00"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.domain"
label="Domain"
placeholder="nostr.com"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
type="submit"
>Create Domain</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog
v-model="addressFormDialog.show"
position="top"
@hide="closeAddressFormDialog"
>
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="saveAddress" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="addressFormDialog.data.domain_id"
:options="domainOptions"
label="Domain *"
></q-select>
<q-input
filled
dense
v-model.trim="addressFormDialog.data.pubkey"
label="Public Key"
placeholder="npub..."
></q-input>
<q-input
filled
dense
v-model.trim="addressFormDialog.data.local_part"
label="Local Part"
placeholder="benarc"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="addressFormDialog.data.domain_id == null || addressFormDialog.data.pubkey == null || addressFormDialog.data.local_part == null"
type="submit"
>Create Address</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapDomain = function (obj) {
obj.time = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
domains: [],
addresses: [],
currencyOptions: [
'USD',
'EUR',
'GBP',
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'IRR',
'IRT',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KMF',
'KPW',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRO',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLL',
'SOS',
'SRD',
'SSP',
'STD',
'SVC',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VEF',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XAG',
'XAU',
'XCD',
'XDR',
'XOF',
'XPD',
'XPF',
'XPT',
'YER',
'ZAR',
'ZMW',
'ZWL'
],
domainsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'domain', align: 'left', label: 'Domain', field: 'domain'},
{
name: 'currency',
align: 'left',
label: 'Currency',
field: 'currency'
},
{name: 'amount', align: 'left', label: 'Amount', field: 'amount'},
{name: 'time', align: 'left', label: 'Created At', field: 'time'}
],
pagination: {
rowsPerPage: 10
}
},
addressesTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'active', align: 'left', label: 'Active', field: 'active'},
{
name: 'domain_id',
align: 'left',
label: 'Domain',
field: 'domain_id'
},
{
name: 'local_part',
align: 'left',
label: 'Local Part',
field: 'local_part'
},
{name: 'pubkey', align: 'left', label: 'Pubkey', field: 'pubkey'},
{name: 'time', align: 'left', label: 'Created At', field: 'time'}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {}
},
addressFormDialog: {
show: false,
data: {}
}
}
},
methods: {
closeAddressFormDialog: function () {
this.formDialog.data = {}
},
closeFormDialog: function () {
this.formDialog.data = {}
},
getDomains: function () {
var self = this
LNbits.api
.request(
'GET',
'/nostrnip5/api/v1/domains?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.domains = response.data.map(function (obj) {
return mapDomain(obj)
})
})
},
getAddresses: function () {
var self = this
LNbits.api
.request(
'GET',
'/nostrnip5/api/v1/addresses?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.addresses = response.data.map(function (obj) {
return mapDomain(obj)
})
})
},
saveDomain: function () {
var data = this.formDialog.data
var self = this
LNbits.api
.request(
'POST',
'/nostrnip5/api/v1/domain',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.domains.push(mapDomain(response.data))
self.formDialog.show = false
self.formDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteDomain: function (domain_id) {
var self = this
var domain = _.findWhere(this.domains, {id: domain_id})
LNbits.utils
.confirmDialog('Are you sure you want to delete this domain?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/nostrnip5/api/v1/domain/' + domain_id,
_.findWhere(self.g.user.wallets, {id: domain.wallet}).adminkey
)
.then(function (response) {
self.domains = _.reject(self.domain, function (obj) {
return obj.id == domain_id
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
saveAddress: function () {
var self = this
var formDialog = this.addressFormDialog
var domain = _.findWhere(this.domains, {id: formDialog.data.domain_id})
axios
.post(
'/nostrnip5/api/v1/domain/' +
formDialog.data.domain_id +
'/address',
formDialog.data
)
.then(function (response) {
return LNbits.api.request(
'POST',
'/nostrnip5/api/v1/domain/' +
formDialog.data.domain_id +
'/address/' +
response.data.address_id +
'/activate',
_.findWhere(self.g.user.wallets, {id: domain.wallet}).adminkey
)
})
.then(function (response) {
self.addressFormDialog.data = {}
self.addressFormDialog.show = false
self.getAddresses()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteAddress: function (address_id) {
var self = this
var address = _.findWhere(this.addresses, {id: address_id})
var domain = _.findWhere(this.domains, {id: address.domain_id})
LNbits.utils
.confirmDialog('Are you sure you want to delete this address?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/nostrnip5/api/v1/address/' + address_id,
_.findWhere(self.g.user.wallets, {id: domain.wallet}).adminkey
)
.then(function (response) {
self.addresses = _.reject(self.addresses, function (obj) {
return obj.id == address_id
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
activateAddress: function (domain_id, address_id) {
var self = this
var address = _.findWhere(this.addresses, {id: address_id})
var domain = _.findWhere(this.domains, {id: address.domain_id})
LNbits.utils
.confirmDialog(
'Are you sure you want to manually activate this address?'
)
.onOk(function () {
return LNbits.api
.request(
'POST',
'/nostrnip5/api/v1/domain/' +
domain_id +
'/address/' +
address_id +
'/activate',
_.findWhere(self.g.user.wallets, {id: domain.wallet}).adminkey
)
.then(function (response) {
self.getAddresses()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.domainsTable.columns, this.domains)
},
exportAddressesCSV: function () {
LNbits.utils.exportCSV(this.addressesTable.columns, this.addresses)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getDomains()
this.getAddresses()
}
},
computed: {
domainOptions: function () {
return this.domains.map(el => {
return {
label: el.domain,
value: el.id
}
})
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,88 @@
{% extends "public.html" %} {% block toolbar_title %} Rotate Keys For {{
domain.domain }} {% endblock %} {% from "macros.jinja" import window_vars with
context %} {% block page %}
<link rel="stylesheet" href="/nostrnip5/static/css/signup.css" />
<div>
<q-card class="q-pa-lg q-pt-lg">
<q-form @submit="updateAddress" class="q-gutter-md">
<p>
You can use this page to change the public key associated with your
NIP-5 identity.
</p>
<p>
Your current NIP-5 identity is {{ address.local_part }}@{{ domain.domain
}} with nostr public key {{ address.pubkey }}.
</p>
<p>Input your new pubkey below to update it.</p>
<q-input
filled
dense
v-model.trim="formDialog.data.pubkey"
label="Pub Key"
placeholder="abc234"
:rules="[ val => val.length = 64 || val.indexOf('npub') === 0 ||'Please enter a hex pubkey' ]"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.pubkey == null"
type="submit"
>Rotate Keys</q-btn
>
</div>
</q-form>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
domain: '{{ domain.domain }}',
domain_id: '{{ domain_id }}',
address_id: '{{ address_id }}',
formDialog: {
data: {
pubkey: null
}
}
}
},
methods: {
updateAddress: function () {
var self = this
var formDialog = this.formDialog
var newPubKey = this.formDialog.data.pubkey
axios
.post(
'/nostrnip5/api/v1/domain/' +
this.domain_id +
'/address/' +
this.address_id +
'/rotate',
formDialog.data
)
.then(function (response) {
formDialog.data = {}
alert(
`Success! Your pubkey has been updated. Please allow clients time to refresh the data.`
)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,202 @@
{% extends "public.html" %} {% block toolbar_title %} Verify NIP-5 For {{
domain.domain }} {% endblock %} {% from "macros.jinja" import window_vars with
context %} {% block page %}
<link rel="stylesheet" href="/nostrnip5/static/css/signup.css" />
<div>
<q-card class="q-pa-lg q-pt-lg" v-if="success == true">
{% raw %}
<p>
Success! Your username is now active at {{ successData.local_part }}@{{
domain }}. Please add this to your nostr profile accordingly. If you ever
need to rotate your keys, you can still keep your identity!
</p>
<h3>Important!</h3>
<p>
Bookmark this link:
<a
v-bind:href="'/nostrnip5/rotate/' + domain_id + '/' + successData.address_id"
target="_blank"
>{{ base_url }}nostrnip5/rotate/{{ domain_id }}/{{
successData.address_id }}</a
>
</p>
<p>
In case you ever need to change your pubkey, you can still keep this NIP-5
identity. Just come back to the above linked page to change the pubkey
associated to your identity.
</p>
{% endraw %}
</q-card>
<q-card class="q-pa-lg q-pt-lg" v-if="success == false">
<q-form @submit="createAddress" class="q-gutter-md">
<p>
You can use this page to get NIP-5 verified on the nostr protocol under
the {{ domain.domain }} domain.
</p>
<p>
The current price is
<b
>{{ "{:0,.2f}".format(domain.amount / 100) }} {{ domain.currency }}</b
>
for a <b>lifetime</b> account.
</p>
<p>After submitting payment, your address will be</p>
<q-input
filled
dense
v-model.trim="formDialog.data.local_part"
label="Local Part"
placeholder="benarc"
>
<template v-slot:append>
<span style="font-size: 18px">@{{ domain.domain }} </span>
</template>
</q-input>
<p>and will be tied to this nostr pubkey</p>
<q-input
filled
dense
v-model.trim="formDialog.data.pubkey"
label="Pub Key"
placeholder="abc234"
:rules="[ val => val.length = 64 || val.indexOf('npub') === 0 ||'Please enter a hex pubkey' ]"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.local_part == null || formDialog.data.pubkey == null"
type="submit"
>Create Address</q-btn
>
</div>
</q-form>
</q-card>
<q-dialog
v-model="qrCodeDialog.show"
position="top"
@hide="closeQrCodeDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
<q-responsive :ratio="1" class="q-mx-xs">
<qrcode
:value="qrCodeDialog.data.payment_request"
:options="{width: 400}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<br />
<q-btn
outline
color="grey"
@click="copyText('lightning:' + qrCodeDialog.data.payment_request, 'Invoice copied to clipboard!')"
>Copy Invoice</q-btn
>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
base_url: '{{ request.base_url }}',
domain: '{{ domain.domain }}',
domain_id: '{{ domain_id }}',
wallet: '{{ domain.wallet }}',
currency: '{{ domain.currency }}',
amount: '{{ domain.amount }}',
success: false,
successData: {
local_part: null,
address_id: null
},
qrCodeDialog: {
data: {
payment_request: null
},
show: false
},
formDialog: {
data: {
local_part: null,
pubkey: null
}
},
urlDialog: {
show: false
}
}
},
methods: {
closeQrCodeDialog: function () {
this.qrCodeDialog.show = false
},
createAddress: function () {
var self = this
var qrCodeDialog = this.qrCodeDialog
var formDialog = this.formDialog
formDialog.data.domain_id = this.domain_id
var localPart = formDialog.data.local_part
axios
.post(
'/nostrnip5/api/v1/domain/' + this.domain_id + '/address',
formDialog.data
)
.then(function (response) {
formDialog.data = {}
qrCodeDialog.data = response.data
qrCodeDialog.show = true
console.log(qrCodeDialog.data)
qrCodeDialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
qrCodeDialog.paymentChecker = setInterval(function () {
axios
.get(
'/nostrnip5/api/v1/domain/' +
self.domain_id +
'/payments/' +
response.data.payment_hash
)
.then(function (res) {
if (res.data.paid) {
clearInterval(qrCodeDialog.paymentChecker)
qrCodeDialog.dismissMsg()
qrCodeDialog.show = false
self.successData.local_part = localPart
self.successData.address_id = qrCodeDialog.data.address_id
self.success = true
}
})
}, 3000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,69 @@
from datetime import datetime
from http import HTTPStatus
from fastapi import FastAPI, Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import nostrnip5_ext, nostrnip5_renderer
from .crud import get_address, get_domain
templates = Jinja2Templates(directory="templates")
@nostrnip5_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return nostrnip5_renderer().TemplateResponse(
"nostrnip5/index.html", {"request": request, "user": user.dict()}
)
@nostrnip5_ext.get("/signup/{domain_id}", response_class=HTMLResponse)
async def index(request: Request, domain_id: str):
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
)
return nostrnip5_renderer().TemplateResponse(
"nostrnip5/signup.html",
{
"request": request,
"domain_id": domain_id,
"domain": domain,
},
)
@nostrnip5_ext.get("/rotate/{domain_id}/{address_id}", response_class=HTMLResponse)
async def index(request: Request, domain_id: str, address_id: str):
domain = await get_domain(domain_id)
address = await get_address(domain_id, address_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
)
if not address:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Address does not exist."
)
return nostrnip5_renderer().TemplateResponse(
"nostrnip5/rotate.html",
{
"request": request,
"domain_id": domain_id,
"domain": domain,
"address_id": address_id,
"address": address,
},
)

View File

@ -0,0 +1,249 @@
import re
from http import HTTPStatus
from typing import Optional
from bech32 import bech32_decode, convertbits
from fastapi import Query, Request, Response
from fastapi.params import Depends
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import nostrnip5_ext
from .crud import (
activate_address,
create_address_internal,
create_domain_internal,
delete_address,
delete_domain,
get_address_by_local_part,
get_addresses,
get_all_addresses,
get_domain,
get_domain_by_name,
get_domains,
rotate_address,
)
from .models import CreateAddressData, CreateDomainData, RotateAddressData
@nostrnip5_ext.get("/api/v1/domains", status_code=HTTPStatus.OK)
async def api_domains(
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [domain.dict() for domain in await get_domains(wallet_ids)]
@nostrnip5_ext.get("/api/v1/addresses", status_code=HTTPStatus.OK)
async def api_addresses(
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [address.dict() for address in await get_all_addresses(wallet_ids)]
@nostrnip5_ext.get("/api/v1/domain/{domain_id}", status_code=HTTPStatus.OK)
async def api_invoice(domain_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
)
return domain
@nostrnip5_ext.post("/api/v1/domain", status_code=HTTPStatus.CREATED)
async def api_domain_create(
data: CreateDomainData, wallet: WalletTypeInfo = Depends(get_key_type)
):
exists = await get_domain_by_name(data.domain)
logger.error(exists)
if exists:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain already exists."
)
domain = await create_domain_internal(wallet_id=wallet.wallet.id, data=data)
return domain
@nostrnip5_ext.delete("/api/v1/domain/{domain_id}", status_code=HTTPStatus.CREATED)
async def api_domain_delete(
domain_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
await delete_domain(domain_id)
return True
@nostrnip5_ext.delete("/api/v1/address/{address_id}", status_code=HTTPStatus.CREATED)
async def api_address_delete(
address_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
await delete_address(address_id)
return True
@nostrnip5_ext.post(
"/api/v1/domain/{domain_id}/address/{address_id}/activate",
status_code=HTTPStatus.OK,
)
async def api_address_activate(
domain_id: str,
address_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
await activate_address(domain_id, address_id)
return True
@nostrnip5_ext.post(
"/api/v1/domain/{domain_id}/address/{address_id}/rotate",
status_code=HTTPStatus.OK,
)
async def api_address_rotate(
domain_id: str,
address_id: str,
post_data: RotateAddressData,
):
if post_data.pubkey.startswith("npub"):
hrp, data = bech32_decode(post_data.pubkey)
decoded_data = convertbits(data, 5, 8, False)
post_data.pubkey = bytes(decoded_data).hex()
if len(bytes.fromhex(post_data.pubkey)) != 32:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pubkey must be in hex format."
)
await rotate_address(domain_id, address_id, post_data.pubkey)
return True
@nostrnip5_ext.post(
"/api/v1/domain/{domain_id}/address", status_code=HTTPStatus.CREATED
)
async def api_address_create(
post_data: CreateAddressData,
domain_id: str,
):
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
)
if post_data.local_part == "_":
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="You're sneaky, nice try."
)
regex = re.compile(r"^[a-z0-9_.]+$")
if not re.fullmatch(regex, post_data.local_part.lower()):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Only a-z, 0-9 and .-_ are allowed characters, case insensitive.",
)
exists = await get_address_by_local_part(domain_id, post_data.local_part)
if exists:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Local part already exists."
)
if post_data.pubkey.startswith("npub"):
hrp, data = bech32_decode(post_data.pubkey)
decoded_data = convertbits(data, 5, 8, False)
post_data.pubkey = bytes(decoded_data).hex()
if len(bytes.fromhex(post_data.pubkey)) != 32:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pubkey must be in hex format."
)
address = await create_address_internal(domain_id=domain_id, data=post_data)
price_in_sats = await fiat_amount_as_satoshis(domain.amount / 100, domain.currency)
try:
payment_hash, payment_request = await create_invoice(
wallet_id=domain.wallet,
amount=price_in_sats,
memo=f"Payment for NIP-05 for {address.local_part}@{domain.domain}",
extra={
"tag": "nostrnip5",
"domain_id": domain_id,
"address_id": address.id,
},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {
"payment_hash": payment_hash,
"payment_request": payment_request,
"address_id": address.id,
}
@nostrnip5_ext.get(
"/api/v1/domain/{domain_id}/payments/{payment_hash}", status_code=HTTPStatus.OK
)
async def api_nostrnip5_check_payment(domain_id: str, payment_hash: str):
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
)
try:
status = await api_payment(payment_hash)
except Exception as exc:
logger.error(exc)
return {"paid": False}
return status
@nostrnip5_ext.get("/api/v1/domain/{domain_id}/nostr.json", status_code=HTTPStatus.OK)
async def api_get_nostr_json(
response: Response, domain_id: str, name: str = Query(None)
):
addresses = [address.dict() for address in await get_addresses(domain_id)]
output = {}
for address in addresses:
local_part = address.get("local_part").lower()
if address.get("active") == False:
continue
if name and name.lower() != local_part:
continue
output[local_part] = address.get("pubkey")
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS"
return {"names": output}

View File

@ -103,6 +103,7 @@ exclude = """(?x)(
| ^lnbits/extensions/lnurldevice.
| ^lnbits/extensions/lnurlp.
| ^lnbits/extensions/lnurlpayout.
| ^lnbits/extensions/nostrnip5.
| ^lnbits/extensions/offlineshop.
| ^lnbits/extensions/paywall.
| ^lnbits/extensions/satspay.