WIP nostr nip5 extension

This commit is contained in:
Lee Salminen 2022-12-20 06:28:43 -06:00
parent c5fdd35078
commit 3ca9edeee6
13 changed files with 1409 additions and 0 deletions

View file

@ -0,0 +1,19 @@
# Invoices
## Create invoices that you can send to your client to pay online over Lightning.
This extension allows users to create "traditional" invoices (not in the lightning sense) that contain one or more line items. Line items are denominated in a user-configurable fiat currency. Each invoice contains one or more payments up to the total of the invoice. Each invoice creates a public link that can be shared with a customer that they can use to (partially or in full) pay the invoice.
## Usage
1. Create an invoice by clicking "NEW INVOICE"\
![create new invoice](https://imgur.com/a/Dce3wrr.png)
2. Fill the options for your INVOICE
- select the wallet
- select the fiat currency the invoice will be denominated in
- select a status for the invoice (default is draft)
- enter a company name, first name, last name, email, phone & address (optional)
- add one or more line items
- enter a name & price for each line item
3. You can then use share your invoice link with your customer to receive payment\
![invoice link](https://imgur.com/a/L0JOj4T.png)

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,162 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import (
CreateDomainData,
Domain,
Address,
CreateAddressData,
)
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,)
)
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_domain(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 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,
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,42 @@
from enum import Enum
from sqlite3 import Row
from typing import List, Optional
from fastapi.param_functions import Query
from pydantic import BaseModel
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,33 @@
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 (
get_domain,
activate_domain,
)
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")
active = await activate_domain(domain_id, address_id)
return

View file

@ -0,0 +1,153 @@
<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 Invoices">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /invoices/api/v1/invoices</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;invoice_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}invoices/api/v1/invoices -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 Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/invoices/api/v1/invoice/{invoice_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>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}invoices/api/v1/invoice/{invoice_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 Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /invoices/api/v1/invoice</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>{invoice_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 -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="Update Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_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>{invoice_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} -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>
/invoices/api/v1/invoice/{invoice_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
}}invoices/api/v1/invoice/{invoice_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,535 @@
{% 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-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="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="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"
v-if="typeof formDialog.data.id == 'undefined'"
>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>
</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: {},
}
}
},
methods: {
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)
})
})
},
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)
})
})
},
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()
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,169 @@
{% 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">
<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 || '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
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</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 {
domain: '{{ domain.domain }}',
domain_id: '{{ domain_id }}',
wallet: '{{ domain.wallet }}',
currency: '{{ domain.currency }}',
amount: '{{ domain.amount }}',
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
setTimeout(function () {
alert(`Success! Your username is now active at ${localPart}@${self.domain}. Please add this to your nostr profile accordingly.`)
}, 500)
}
})
}, 3000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
},
computed: {
},
created: function () {
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,44 @@
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_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,
},
)

View file

@ -0,0 +1,175 @@
from http import HTTPStatus
from fastapi import Query, Request, Response
from fastapi.params import Depends
from loguru import logger
from starlette.exceptions import HTTPException
from typing import Optional
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 (
get_domains,
get_domain,
create_domain_internal,
create_address_internal,
delete_domain,
get_domain_by_name,
get_address_by_local_part,
get_addresses,
get_all_addresses,
delete_address,
)
from .models import CreateDomainData, CreateAddressData
@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", status_code=HTTPStatus.CREATED)
async def api_address_create(
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."
)
exists = await get_address_by_local_part(domain_id, data.local_part)
if exists:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Local part already exists."
)
if len(data.pubkey) != 64:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pubkey must be in hex format."
)
address = await create_address_internal(domain_id=domain_id, data=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 domain {domain_id}",
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}
@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:
if address.get("active") == False:
continue
if name and name != address.get("local_part"):
continue
output[address.get("local_part")] = address.get("pubkey")
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS"
return {
"names": output
}