From 3ca9edeee6943688ee241b8bcbe0e757c8492c1d Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 06:28:43 -0600 Subject: [PATCH 01/18] WIP nostr nip5 extension --- lnbits/extensions/nostrnip5/README.md | 19 + lnbits/extensions/nostrnip5/__init__.py | 36 ++ lnbits/extensions/nostrnip5/config.json | 6 + lnbits/extensions/nostrnip5/crud.py | 162 ++++++ lnbits/extensions/nostrnip5/migrations.py | 35 ++ lnbits/extensions/nostrnip5/models.py | 42 ++ .../nostrnip5/static/css/signup.css | 0 lnbits/extensions/nostrnip5/tasks.py | 33 ++ .../templates/nostrnip5/_api_docs.html | 153 +++++ .../nostrnip5/templates/nostrnip5/index.html | 535 ++++++++++++++++++ .../nostrnip5/templates/nostrnip5/signup.html | 169 ++++++ lnbits/extensions/nostrnip5/views.py | 44 ++ lnbits/extensions/nostrnip5/views_api.py | 175 ++++++ 13 files changed, 1409 insertions(+) create mode 100644 lnbits/extensions/nostrnip5/README.md create mode 100644 lnbits/extensions/nostrnip5/__init__.py create mode 100644 lnbits/extensions/nostrnip5/config.json create mode 100644 lnbits/extensions/nostrnip5/crud.py create mode 100644 lnbits/extensions/nostrnip5/migrations.py create mode 100644 lnbits/extensions/nostrnip5/models.py create mode 100644 lnbits/extensions/nostrnip5/static/css/signup.css create mode 100644 lnbits/extensions/nostrnip5/tasks.py create mode 100644 lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html create mode 100644 lnbits/extensions/nostrnip5/templates/nostrnip5/index.html create mode 100644 lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html create mode 100644 lnbits/extensions/nostrnip5/views.py create mode 100644 lnbits/extensions/nostrnip5/views_api.py diff --git a/lnbits/extensions/nostrnip5/README.md b/lnbits/extensions/nostrnip5/README.md new file mode 100644 index 00000000..2b5bd538 --- /dev/null +++ b/lnbits/extensions/nostrnip5/README.md @@ -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) \ No newline at end of file diff --git a/lnbits/extensions/nostrnip5/__init__.py b/lnbits/extensions/nostrnip5/__init__.py new file mode 100644 index 00000000..a9a2ea1c --- /dev/null +++ b/lnbits/extensions/nostrnip5/__init__.py @@ -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 diff --git a/lnbits/extensions/nostrnip5/config.json b/lnbits/extensions/nostrnip5/config.json new file mode 100644 index 00000000..658723aa --- /dev/null +++ b/lnbits/extensions/nostrnip5/config.json @@ -0,0 +1,6 @@ +{ + "name": "Nostr NIP-5", + "short_description": "Verify addresses for Nostr NIP-5", + "icon": "request_quote", + "contributors": ["leesalminen"] +} diff --git a/lnbits/extensions/nostrnip5/crud.py b/lnbits/extensions/nostrnip5/crud.py new file mode 100644 index 00000000..eff99074 --- /dev/null +++ b/lnbits/extensions/nostrnip5/crud.py @@ -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 \ No newline at end of file diff --git a/lnbits/extensions/nostrnip5/migrations.py b/lnbits/extensions/nostrnip5/migrations.py new file mode 100644 index 00000000..f1ce4ca9 --- /dev/null +++ b/lnbits/extensions/nostrnip5/migrations.py @@ -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) + ); + """ + ) \ No newline at end of file diff --git a/lnbits/extensions/nostrnip5/models.py b/lnbits/extensions/nostrnip5/models.py new file mode 100644 index 00000000..31010639 --- /dev/null +++ b/lnbits/extensions/nostrnip5/models.py @@ -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)) \ No newline at end of file diff --git a/lnbits/extensions/nostrnip5/static/css/signup.css b/lnbits/extensions/nostrnip5/static/css/signup.css new file mode 100644 index 00000000..e69de29b diff --git a/lnbits/extensions/nostrnip5/tasks.py b/lnbits/extensions/nostrnip5/tasks.py new file mode 100644 index 00000000..d27682c4 --- /dev/null +++ b/lnbits/extensions/nostrnip5/tasks.py @@ -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 diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html new file mode 100644 index 00000000..6e2a6355 --- /dev/null +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html @@ -0,0 +1,153 @@ + + + + + GET /invoices/api/v1/invoices +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<invoice_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + GET + /invoices/api/v1/invoice/{invoice_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X GET {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + POST /invoices/api/v1/invoice +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + POST + /invoices/api/v1/invoice/{invoice_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X POST {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + POST + /invoices/api/v1/invoice/{invoice_id}/payments +
Headers
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {payment_object} +
Curl example
+ curl -X POST {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + GET + /invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} +
Headers
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+
Curl example
+ curl -X GET {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H + "X-Api-Key: <invoice_key>" + +
+
+
+
diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html new file mode 100644 index 00000000..c40342a7 --- /dev/null +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html @@ -0,0 +1,535 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Domain + + + + + +
+
+
Domains
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + +
+
+ + + +
+
+
Addresses
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} Nostr NIP-5 extension +
+
+ + + {% include "nostrnip5/_api_docs.html" %} + +
+
+ + + + + + + + + + +
+ Create Domain + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html new file mode 100644 index 00000000..a48432d2 --- /dev/null +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html @@ -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 %} + +
+ + + +

You can use this page to get NIP-5 verified on the nostr protocol under the {{ domain.domain }} domain.

+

The current price is {{ "{:0,.2f}".format(domain.amount / 100) }} {{ domain.currency }} for a lifetime account.

+ +

After submitting payment, your address will be

+ + + + + +

and will be tied to this nostr pubkey

+ + + + +
+ Create Address + Cancel +
+
+
+ + + + + + + + +
+ Copy Invoice +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/nostrnip5/views.py b/lnbits/extensions/nostrnip5/views.py new file mode 100644 index 00000000..20daf2ad --- /dev/null +++ b/lnbits/extensions/nostrnip5/views.py @@ -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, + }, + ) diff --git a/lnbits/extensions/nostrnip5/views_api.py b/lnbits/extensions/nostrnip5/views_api.py new file mode 100644 index 00000000..511ae469 --- /dev/null +++ b/lnbits/extensions/nostrnip5/views_api.py @@ -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 + } \ No newline at end of file From 9e857e63291a2320a7d4654b5aa2d62591862f57 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 07:14:03 -0600 Subject: [PATCH 02/18] better validation on pubkeys --- lnbits/extensions/nostrnip5/views_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/nostrnip5/views_api.py b/lnbits/extensions/nostrnip5/views_api.py index 511ae469..2cfb88a4 100644 --- a/lnbits/extensions/nostrnip5/views_api.py +++ b/lnbits/extensions/nostrnip5/views_api.py @@ -114,7 +114,7 @@ async def api_address_create( status_code=HTTPStatus.NOT_FOUND, detail="Local part already exists." ) - if len(data.pubkey) != 64: + if len(bytes.fromhex(data.pubkey)) != 32: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Pubkey must be in hex format." ) From 048ccd6d652bf11aafe333edb3906c0220cbe52f Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 07:15:54 -0600 Subject: [PATCH 03/18] formatting --- lnbits/extensions/nostrnip5/crud.py | 60 ++++++++++--------- lnbits/extensions/nostrnip5/migrations.py | 2 +- lnbits/extensions/nostrnip5/models.py | 6 +- lnbits/extensions/nostrnip5/tasks.py | 5 +- .../nostrnip5/templates/nostrnip5/index.html | 36 +++++++---- .../nostrnip5/templates/nostrnip5/signup.html | 57 ++++++++++-------- lnbits/extensions/nostrnip5/views.py | 4 +- lnbits/extensions/nostrnip5/views_api.py | 42 +++++++------ 8 files changed, 123 insertions(+), 89 deletions(-) diff --git a/lnbits/extensions/nostrnip5/crud.py b/lnbits/extensions/nostrnip5/crud.py index eff99074..9a8a074a 100644 --- a/lnbits/extensions/nostrnip5/crud.py +++ b/lnbits/extensions/nostrnip5/crud.py @@ -3,12 +3,7 @@ from typing import List, Optional, Union from lnbits.helpers import urlsafe_short_hash from . import db -from .models import ( - CreateDomainData, - Domain, - Address, - CreateAddressData, -) +from .models import Address, CreateAddressData, CreateDomainData, Domain async def get_domain(domain_id: str) -> Optional[Domain]: @@ -17,12 +12,14 @@ async def get_domain(domain_id: str) -> Optional[Domain]: ) 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] @@ -34,18 +31,31 @@ async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domain]: 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,) + "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]: + +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,) + "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,) @@ -53,6 +63,7 @@ async def get_addresses(domain_id: str) -> List[Address]: 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] @@ -64,12 +75,13 @@ async def get_all_addresses(wallet_ids: Union[str, List[str]]) -> List[Address]: FROM nostrnip5.addresses a JOIN nostrnip5.domains d ON d.id = a.domain_id WHERE d.wallet IN ({q}) - """, - (*wallet_ids,) + """, + (*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( """ @@ -88,37 +100,34 @@ async def activate_domain(domain_id: str, address_id: str) -> Address: 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, - ), + (domain_id,), ) await db.execute( """ DELETE FROM nostrnip5.domains WHERE id = ? """, - ( - domain_id, - ), + (domain_id,), ) return True + async def delete_address(address_id) -> bool: await db.execute( """ DELETE FROM nostrnip5.addresses WHERE id = ? """, - ( - address_id, - ), + (address_id,), ) + async def create_address_internal(domain_id: str, data: CreateAddressData) -> Address: address_id = urlsafe_short_hash() @@ -140,6 +149,7 @@ async def create_address_internal(domain_id: str, data: CreateAddressData) -> Ad 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() @@ -148,15 +158,9 @@ async def create_domain_internal(wallet_id: str, data: CreateDomainData) -> Doma INSERT INTO nostrnip5.domains (id, wallet, currency, amount, domain) VALUES (?, ?, ?, ?, ?) """, - ( - domain_id, - wallet_id, - data.currency, - int(data.amount * 100), - data.domain - ), + (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 \ No newline at end of file + return domain diff --git a/lnbits/extensions/nostrnip5/migrations.py b/lnbits/extensions/nostrnip5/migrations.py index f1ce4ca9..8e81a1a4 100644 --- a/lnbits/extensions/nostrnip5/migrations.py +++ b/lnbits/extensions/nostrnip5/migrations.py @@ -32,4 +32,4 @@ async def m001_initial_invoices(db): FOREIGN KEY(domain_id) REFERENCES {db.references_schema}domains(id) ); """ - ) \ No newline at end of file + ) diff --git a/lnbits/extensions/nostrnip5/models.py b/lnbits/extensions/nostrnip5/models.py index 31010639..604b88b1 100644 --- a/lnbits/extensions/nostrnip5/models.py +++ b/lnbits/extensions/nostrnip5/models.py @@ -5,18 +5,21 @@ 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 @@ -29,6 +32,7 @@ class Domain(BaseModel): def from_row(cls, row: Row) -> "Domain": return cls(**dict(row)) + class Address(BaseModel): id: str domain_id: str @@ -39,4 +43,4 @@ class Address(BaseModel): @classmethod def from_row(cls, row: Row) -> "Address": - return cls(**dict(row)) \ No newline at end of file + return cls(**dict(row)) diff --git a/lnbits/extensions/nostrnip5/tasks.py b/lnbits/extensions/nostrnip5/tasks.py index d27682c4..48c2aa9a 100644 --- a/lnbits/extensions/nostrnip5/tasks.py +++ b/lnbits/extensions/nostrnip5/tasks.py @@ -5,10 +5,7 @@ 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, -) +from .crud import activate_domain, get_domain async def wait_for_paid_invoices(): diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html index c40342a7..760ad944 100644 --- a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html @@ -78,7 +78,9 @@
Addresses
- Export to CSV + Export to CSV
- - +
- -

You can use this page to get NIP-5 verified on the nostr protocol under the {{ domain.domain }} domain.

-

The current price is {{ "{:0,.2f}".format(domain.amount / 100) }} {{ domain.currency }} for a lifetime account.

+

+ You can use this page to get NIP-5 verified on the nostr protocol under + the {{ domain.domain }} domain. +

+

+ The current price is + {{ "{:0,.2f}".format(domain.amount / 100) }} {{ domain.currency }} + for a lifetime account. +

After submitting payment, your address will be

@@ -43,9 +51,7 @@ block page %} type="submit" >Create Address - Cancel + Cancel
@@ -91,23 +97,23 @@ block page %} amount: '{{ domain.amount }}', qrCodeDialog: { data: { - payment_request: null, + payment_request: null }, - show: false, + show: false }, formDialog: { data: { local_part: null, - pubkey: null, - }, + pubkey: null + } }, urlDialog: { - show: false, - }, + show: false + } } }, methods: { - closeQrCodeDialog: function() { + closeQrCodeDialog: function () { this.qrCodeDialog.show = false }, createAddress: function () { @@ -118,7 +124,10 @@ block page %} var localPart = formDialog.data.local_part axios - .post('/nostrnip5/api/v1/domain/' + this.domain_id + '/address', formDialog.data) + .post( + '/nostrnip5/api/v1/domain/' + this.domain_id + '/address', + formDialog.data + ) .then(function (response) { formDialog.data = {} @@ -147,7 +156,9 @@ block page %} qrCodeDialog.show = false setTimeout(function () { - alert(`Success! Your username is now active at ${localPart}@${self.domain}. Please add this to your nostr profile accordingly.`) + alert( + `Success! Your username is now active at ${localPart}@${self.domain}. Please add this to your nostr profile accordingly.` + ) }, 500) } }) @@ -156,14 +167,10 @@ block page %} .catch(function (error) { LNbits.utils.notifyApiError(error) }) - }, + } }, - computed: { - - }, - created: function () { - - } + computed: {}, + created: function () {} }) {% endblock %} diff --git a/lnbits/extensions/nostrnip5/views.py b/lnbits/extensions/nostrnip5/views.py index 20daf2ad..3001a20f 100644 --- a/lnbits/extensions/nostrnip5/views.py +++ b/lnbits/extensions/nostrnip5/views.py @@ -11,9 +11,7 @@ from lnbits.core.models import User from lnbits.decorators import check_user_exists from . import nostrnip5_ext, nostrnip5_renderer -from .crud import ( - get_domain, -) +from .crud import get_domain templates = Jinja2Templates(directory="templates") diff --git a/lnbits/extensions/nostrnip5/views_api.py b/lnbits/extensions/nostrnip5/views_api.py index 2cfb88a4..9d46e6e9 100644 --- a/lnbits/extensions/nostrnip5/views_api.py +++ b/lnbits/extensions/nostrnip5/views_api.py @@ -1,10 +1,10 @@ from http import HTTPStatus +from typing import Optional 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 @@ -14,18 +14,18 @@ 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, + create_domain_internal, + delete_address, delete_domain, - get_domain_by_name, get_address_by_local_part, get_addresses, get_all_addresses, - delete_address, + get_domain, + get_domain_by_name, + get_domains, ) -from .models import CreateDomainData, CreateAddressData +from .models import CreateAddressData, CreateDomainData @nostrnip5_ext.get("/api/v1/domains", status_code=HTTPStatus.OK) @@ -38,6 +38,7 @@ async def api_domains( 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) @@ -56,7 +57,7 @@ async def api_invoice(domain_id: str, wallet: WalletTypeInfo = Depends(get_key_t raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist." ) - + return domain @@ -72,7 +73,7 @@ async def api_domain_create( ) domain = await create_domain_internal(wallet_id=wallet.wallet.id, data=data) - + return domain @@ -82,20 +83,23 @@ async def api_domain_delete( 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) +@nostrnip5_ext.post( + "/api/v1/domain/{domain_id}/address", status_code=HTTPStatus.CREATED +) async def api_address_create( data: CreateAddressData, domain_id: str, @@ -127,7 +131,11 @@ async def api_address_create( 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,}, + 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)) @@ -154,7 +162,9 @@ async def api_nostrnip5_check_payment(domain_id: str, payment_hash: str): @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)): +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 = {} @@ -170,6 +180,4 @@ async def api_get_nostr_json(response: Response, domain_id: str, name: str = Que response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS" - return { - "names": output - } \ No newline at end of file + return {"names": output} From 632636ff0a7f778186e1eeea5d139a93a3c3e628 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 07:16:55 -0600 Subject: [PATCH 04/18] rename function --- lnbits/extensions/nostrnip5/crud.py | 2 +- lnbits/extensions/nostrnip5/tasks.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lnbits/extensions/nostrnip5/crud.py b/lnbits/extensions/nostrnip5/crud.py index 9a8a074a..27dffa98 100644 --- a/lnbits/extensions/nostrnip5/crud.py +++ b/lnbits/extensions/nostrnip5/crud.py @@ -82,7 +82,7 @@ async def get_all_addresses(wallet_ids: Union[str, List[str]]) -> List[Address]: return [Address.from_row(row) for row in rows] -async def activate_domain(domain_id: str, address_id: str) -> Address: +async def activate_address(domain_id: str, address_id: str) -> Address: await db.execute( """ UPDATE nostrnip5.addresses diff --git a/lnbits/extensions/nostrnip5/tasks.py b/lnbits/extensions/nostrnip5/tasks.py index 48c2aa9a..b494dbdf 100644 --- a/lnbits/extensions/nostrnip5/tasks.py +++ b/lnbits/extensions/nostrnip5/tasks.py @@ -5,7 +5,7 @@ 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_domain, get_domain +from .crud import activate_address, get_domain async def wait_for_paid_invoices(): @@ -25,6 +25,6 @@ async def on_invoice_paid(payment: Payment) -> None: domain_id = payment.extra.get("domain_id") address_id = payment.extra.get("address_id") - active = await activate_domain(domain_id, address_id) + active = await activate_address(domain_id, address_id) return From f7b4aa91abdaccc4a2b7bb254d06e69f04b2e0c3 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 07:49:35 -0600 Subject: [PATCH 05/18] API docs --- .../templates/nostrnip5/_api_docs.html | 60 +++++++++++++------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html index 6e2a6355..3135641d 100644 --- a/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html @@ -4,11 +4,11 @@ label="API info" :content-inset-level="0.5" > - + GET /invoices/api/v1/invoicesGET /nostrnip5/api/v1/domains
Headers
{"X-Api-Key": <invoice_key>}
@@ -16,22 +16,21 @@
Returns 200 OK (application/json)
- [<invoice_object>, ...] + [<domain_object>, ...]
Curl example
curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H + >curl -X GET {{ request.base_url }}nostrnip5/api/v1/domains -H "X-Api-Key: <invoice_key>"
- + GET - /invoices/api/v1/invoice/{invoice_id}GET /nostrnip5/api/v1/addresses
Headers
{"X-Api-Key": <invoice_key>}
@@ -39,22 +38,45 @@
Returns 200 OK (application/json)
- {invoice_object} + [<address_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}nostrnip5/api/v1/addresses -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + GET + /nostrnip5/api/v1/domain/{domain_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {domain_object}
Curl example
curl -X GET {{ request.base_url - }}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key: + }}nostrnip5/api/v1/domain/{domain_id} -H "X-Api-Key: <invoice_key>"
- + POST /invoices/api/v1/invoicePOST /nostrnip5/api/v1/domain
Headers
{"X-Api-Key": <invoice_key>}
@@ -62,22 +84,22 @@
Returns 200 OK (application/json)
- {invoice_object} + {domain_object}
Curl example
curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H + >curl -X POST {{ request.base_url }}nostrnip5/api/v1/domain -H "X-Api-Key: <invoice_key>"
- + POST - /invoices/api/v1/invoice/{invoice_id}
Headers
{"X-Api-Key": <invoice_key>}
@@ -85,11 +107,11 @@
Returns 200 OK (application/json)
- {invoice_object} + {address_object}
Curl example
curl -X POST {{ request.base_url - }}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key: + }}nostrnip5/api/v1/domain/{domain_id}/address -H "X-Api-Key: <invoice_key>"
@@ -134,7 +156,7 @@ GET - /invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}
Headers
Body (application/json)
@@ -144,7 +166,7 @@
Curl example
curl -X GET {{ request.base_url - }}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H + }}nostrnip5/api/v1/domain/{domain_id}/payments/{payment_hash} -H "X-Api-Key: <invoice_key>"
From 51076634bdadc0a514ba72ffe29409affd58a836 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 07:49:59 -0600 Subject: [PATCH 06/18] allow users to enter their npub bech32 encoded too --- .../nostrnip5/templates/nostrnip5/signup.html | 2 +- lnbits/extensions/nostrnip5/views_api.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html index 2efc1c34..3485acca 100644 --- a/lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html @@ -39,7 +39,7 @@ context %} {% block page %} v-model.trim="formDialog.data.pubkey" label="Pub Key" placeholder="abc234" - :rules="[ val => val.length = 64 || 'Please enter a hex pubkey' ]" + :rules="[ val => val.length = 64 || val.indexOf('npub') === 0 ||'Please enter a hex pubkey' ]" > diff --git a/lnbits/extensions/nostrnip5/views_api.py b/lnbits/extensions/nostrnip5/views_api.py index 9d46e6e9..c972a0bf 100644 --- a/lnbits/extensions/nostrnip5/views_api.py +++ b/lnbits/extensions/nostrnip5/views_api.py @@ -11,6 +11,7 @@ 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 bech32 import bech32_decode, convertbits from . import nostrnip5_ext from .crud import ( @@ -101,7 +102,7 @@ async def api_address_delete( "/api/v1/domain/{domain_id}/address", status_code=HTTPStatus.CREATED ) async def api_address_create( - data: CreateAddressData, + post_data: CreateAddressData, domain_id: str, ): domain = await get_domain(domain_id) @@ -111,19 +112,25 @@ async def api_address_create( status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist." ) - exists = await get_address_by_local_part(domain_id, data.local_part) + 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 len(bytes.fromhex(data.pubkey)) != 32: + + 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=data) + 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: From 189347ee3994a60d07ff12383c9803900fa1634e Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 08:06:25 -0600 Subject: [PATCH 07/18] readme --- lnbits/extensions/nostrnip5/README.md | 38 +++++++++++++------ .../nostrnip5/templates/nostrnip5/index.html | 12 +++++- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/lnbits/extensions/nostrnip5/README.md b/lnbits/extensions/nostrnip5/README.md index 2b5bd538..fd846ef6 100644 --- a/lnbits/extensions/nostrnip5/README.md +++ b/lnbits/extensions/nostrnip5/README.md @@ -1,19 +1,33 @@ -# Invoices +# Nostr NIP-05 -## Create invoices that you can send to your client to pay online over Lightning. +## Allow users to NIP-05 verify themselves at a domain you control -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. +This extension allows users to sell NIP-05 verification to other nostr users on a domain they control. ## 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 +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 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) \ No newline at end of file + - 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 + +``` +location /.well-known/nostr.json { + proxy_pass https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json; + proxy_set_header Host lnbits.fly.dev; + proxy_ssl_server_name on; +} +``` \ No newline at end of file diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html index 760ad944..6a532d2c 100644 --- a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html @@ -52,6 +52,16 @@ :href="'signup/' + props.row.id" target="_blank" >
+ Date: Tue, 20 Dec 2022 08:44:28 -0600 Subject: [PATCH 08/18] format and mypy --- .../extensions/nostrnip5/templates/nostrnip5/_api_docs.html | 5 ++--- lnbits/extensions/nostrnip5/views_api.py | 3 +-- pyproject.toml | 1 + 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html index 3135641d..17197d5a 100644 --- a/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html @@ -64,9 +64,8 @@ {domain_object}
Curl example
curl -X GET {{ request.base_url - }}nostrnip5/api/v1/domain/{domain_id} -H "X-Api-Key: - <invoice_key>" + >curl -X GET {{ request.base_url }}nostrnip5/api/v1/domain/{domain_id} + -H "X-Api-Key: <invoice_key>" diff --git a/lnbits/extensions/nostrnip5/views_api.py b/lnbits/extensions/nostrnip5/views_api.py index c972a0bf..fdc236be 100644 --- a/lnbits/extensions/nostrnip5/views_api.py +++ b/lnbits/extensions/nostrnip5/views_api.py @@ -1,6 +1,7 @@ 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 @@ -11,7 +12,6 @@ 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 bech32 import bech32_decode, convertbits from . import nostrnip5_ext from .crud import ( @@ -119,7 +119,6 @@ async def api_address_create( 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) diff --git a/pyproject.toml b/pyproject.toml index a08e5f71..fe56aa96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,7 @@ exclude = """(?x)( | ^lnbits/extensions/lnurldevice. | ^lnbits/extensions/lnurlp. | ^lnbits/extensions/lnurlpayout. + | ^lnbits/extensions/nostrnip5. | ^lnbits/extensions/offlineshop. | ^lnbits/extensions/paywall. | ^lnbits/extensions/satspay. From 45a69a113938ab2da93d72b72f0cbbe143f9083f Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 10:32:28 -0600 Subject: [PATCH 09/18] fix readme --- lnbits/extensions/nostrnip5/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/nostrnip5/README.md b/lnbits/extensions/nostrnip5/README.md index fd846ef6..3117ab99 100644 --- a/lnbits/extensions/nostrnip5/README.md +++ b/lnbits/extensions/nostrnip5/README.md @@ -27,7 +27,7 @@ Example nginx configuration ``` location /.well-known/nostr.json { proxy_pass https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json; - proxy_set_header Host lnbits.fly.dev; + proxy_set_header Host {your_lnbits}; proxy_ssl_server_name on; } ``` \ No newline at end of file From 0f417110cb15c0a68422888b095fd0c51906e326 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 10:32:45 -0600 Subject: [PATCH 10/18] improved validation --- lnbits/extensions/nostrnip5/views_api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lnbits/extensions/nostrnip5/views_api.py b/lnbits/extensions/nostrnip5/views_api.py index fdc236be..1156531f 100644 --- a/lnbits/extensions/nostrnip5/views_api.py +++ b/lnbits/extensions/nostrnip5/views_api.py @@ -112,6 +112,11 @@ async def api_address_create( 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." + ) + exists = await get_address_by_local_part(domain_id, post_data.local_part) if exists: From 6787e4916e92ab88624d5e62be12f2331a6fa577 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 10:33:08 -0600 Subject: [PATCH 11/18] allow admins to manually create addresses without paying --- .../nostrnip5/templates/nostrnip5/index.html | 89 ++++++++++++++++++- lnbits/extensions/nostrnip5/views_api.py | 12 ++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html index 6a532d2c..fff82a4d 100644 --- a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html @@ -7,6 +7,9 @@ New Domain + New Address @@ -189,7 +192,6 @@ color="primary" :disable="formDialog.data.wallet == null || formDialog.data.currency == null" type="submit" - v-if="typeof formDialog.data.id == 'undefined'" >Create Domain
+ + + + + + + +
+ Create Address + Cancel +
+
+
+
{% endblock %} {% block scripts %} {{ window_vars(user) }} diff --git a/lnbits/extensions/nostrnip5/views_api.py b/lnbits/extensions/nostrnip5/views_api.py index 1156531f..92731e8a 100644 --- a/lnbits/extensions/nostrnip5/views_api.py +++ b/lnbits/extensions/nostrnip5/views_api.py @@ -25,6 +25,7 @@ from .crud import ( get_domain, get_domain_by_name, get_domains, + activate_address, ) from .models import CreateAddressData, CreateDomainData @@ -97,6 +98,15 @@ async def api_address_delete( 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", status_code=HTTPStatus.CREATED @@ -151,7 +161,7 @@ async def api_address_create( except Exception as e: raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) - return {"payment_hash": payment_hash, "payment_request": payment_request} + return {"payment_hash": payment_hash, "payment_request": payment_request, "address_id": address.id} @nostrnip5_ext.get( From 6996155cae1161e576939165157a7e897b62fdce Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Tue, 20 Dec 2022 10:40:01 -0600 Subject: [PATCH 12/18] format --- .../nostrnip5/templates/nostrnip5/index.html | 27 ++++++++++++------- lnbits/extensions/nostrnip5/views_api.py | 15 ++++++++--- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html index fff82a4d..077c16da 100644 --- a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html @@ -201,7 +201,11 @@
- + { return { - label: el.domain, + label: el.domain, value: el.id } }) diff --git a/lnbits/extensions/nostrnip5/views_api.py b/lnbits/extensions/nostrnip5/views_api.py index 92731e8a..ef83a14f 100644 --- a/lnbits/extensions/nostrnip5/views_api.py +++ b/lnbits/extensions/nostrnip5/views_api.py @@ -15,6 +15,7 @@ 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, @@ -25,7 +26,6 @@ from .crud import ( get_domain, get_domain_by_name, get_domains, - activate_address, ) from .models import CreateAddressData, CreateDomainData @@ -98,7 +98,11 @@ async def api_address_delete( return True -@nostrnip5_ext.post("/api/v1/domain/{domain_id}/address/{address_id}/activate", status_code=HTTPStatus.OK) + +@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, @@ -108,6 +112,7 @@ async def api_address_activate( return True + @nostrnip5_ext.post( "/api/v1/domain/{domain_id}/address", status_code=HTTPStatus.CREATED ) @@ -161,7 +166,11 @@ async def api_address_create( 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} + return { + "payment_hash": payment_hash, + "payment_request": payment_request, + "address_id": address.id, + } @nostrnip5_ext.get( From e65082b585c7587913595bf6b38b81ad68e31437 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Wed, 21 Dec 2022 14:05:45 -0600 Subject: [PATCH 13/18] add self-service pubkey rotation for nip-05 addresses --- lnbits/extensions/nostrnip5/crud.py | 20 +++++ lnbits/extensions/nostrnip5/models.py | 4 + .../nostrnip5/templates/nostrnip5/index.html | 10 +++ .../nostrnip5/templates/nostrnip5/rotate.html | 88 +++++++++++++++++++ .../nostrnip5/templates/nostrnip5/signup.html | 46 +++++++--- lnbits/extensions/nostrnip5/views.py | 29 +++++- lnbits/extensions/nostrnip5/views_api.py | 30 ++++++- 7 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 lnbits/extensions/nostrnip5/templates/nostrnip5/rotate.html diff --git a/lnbits/extensions/nostrnip5/crud.py b/lnbits/extensions/nostrnip5/crud.py index 27dffa98..b7c019f7 100644 --- a/lnbits/extensions/nostrnip5/crud.py +++ b/lnbits/extensions/nostrnip5/crud.py @@ -101,6 +101,26 @@ async def activate_address(domain_id: str, address_id: str) -> Address: 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( """ diff --git a/lnbits/extensions/nostrnip5/models.py b/lnbits/extensions/nostrnip5/models.py index 604b88b1..e02f2909 100644 --- a/lnbits/extensions/nostrnip5/models.py +++ b/lnbits/extensions/nostrnip5/models.py @@ -6,6 +6,10 @@ from fastapi.param_functions import Query from pydantic import BaseModel +class RotateAddressData(BaseModel): + pubkey: str + + class CreateAddressData(BaseModel): domain_id: str local_part: str diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html index 077c16da..3740e0b6 100644 --- a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html @@ -118,6 +118,16 @@