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