Merge pull request #1225 from leesalminen/main
New Extension: NIP-05 Verification
This commit is contained in:
commit
b1e08744ff
44
lnbits/extensions/nostrnip5/README.md
Normal file
44
lnbits/extensions/nostrnip5/README.md
Normal 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;
|
||||
}
|
||||
```
|
36
lnbits/extensions/nostrnip5/__init__.py
Normal file
36
lnbits/extensions/nostrnip5/__init__.py
Normal 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
|
6
lnbits/extensions/nostrnip5/config.json
Normal file
6
lnbits/extensions/nostrnip5/config.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "Nostr NIP-5",
|
||||
"short_description": "Verify addresses for Nostr NIP-5",
|
||||
"icon": "request_quote",
|
||||
"contributors": ["leesalminen"]
|
||||
}
|
186
lnbits/extensions/nostrnip5/crud.py
Normal file
186
lnbits/extensions/nostrnip5/crud.py
Normal 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
|
35
lnbits/extensions/nostrnip5/migrations.py
Normal file
35
lnbits/extensions/nostrnip5/migrations.py
Normal 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)
|
||||
);
|
||||
"""
|
||||
)
|
50
lnbits/extensions/nostrnip5/models.py
Normal file
50
lnbits/extensions/nostrnip5/models.py
Normal 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))
|
0
lnbits/extensions/nostrnip5/static/css/signup.css
Normal file
0
lnbits/extensions/nostrnip5/static/css/signup.css
Normal file
34
lnbits/extensions/nostrnip5/tasks.py
Normal file
34
lnbits/extensions/nostrnip5/tasks.py
Normal 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
|
174
lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html
Normal file
174
lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html
Normal 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": <invoice_key>}</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/domains -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</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": <invoice_key>}</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 GET {{ request.base_url }}nostrnip5/api/v1/addresses -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</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": <invoice_key>}</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: <invoice_key>"
|
||||
</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": <invoice_key>}</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: <invoice_key>"
|
||||
</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": <invoice_key>}</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:
|
||||
<invoice_key>"
|
||||
</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:
|
||||
<invoice_key>"
|
||||
</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: <invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
703
lnbits/extensions/nostrnip5/templates/nostrnip5/index.html
Normal file
703
lnbits/extensions/nostrnip5/templates/nostrnip5/index.html
Normal 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 %}
|
88
lnbits/extensions/nostrnip5/templates/nostrnip5/rotate.html
Normal file
88
lnbits/extensions/nostrnip5/templates/nostrnip5/rotate.html
Normal 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 %}
|
202
lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html
Normal file
202
lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html
Normal 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 %}
|
69
lnbits/extensions/nostrnip5/views.py
Normal file
69
lnbits/extensions/nostrnip5/views.py
Normal 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,
|
||||
},
|
||||
)
|
249
lnbits/extensions/nostrnip5/views_api.py
Normal file
249
lnbits/extensions/nostrnip5/views_api.py
Normal 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}
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue
Block a user