diff --git a/lnbits/extensions/nostrnip5/README.md b/lnbits/extensions/nostrnip5/README.md new file mode 100644 index 00000000..b8912fa2 --- /dev/null +++ b/lnbits/extensions/nostrnip5/README.md @@ -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; +} +``` \ 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..b445e991 --- /dev/null +++ b/lnbits/extensions/nostrnip5/crud.py @@ -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 diff --git a/lnbits/extensions/nostrnip5/migrations.py b/lnbits/extensions/nostrnip5/migrations.py new file mode 100644 index 00000000..8e81a1a4 --- /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) + ); + """ + ) diff --git a/lnbits/extensions/nostrnip5/models.py b/lnbits/extensions/nostrnip5/models.py new file mode 100644 index 00000000..e02f2909 --- /dev/null +++ b/lnbits/extensions/nostrnip5/models.py @@ -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)) 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..3cedc9f5 --- /dev/null +++ b/lnbits/extensions/nostrnip5/tasks.py @@ -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 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..17197d5a --- /dev/null +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/_api_docs.html @@ -0,0 +1,174 @@ + + + + + GET /nostrnip5/api/v1/domains +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<domain_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}nostrnip5/api/v1/domains -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + GET /nostrnip5/api/v1/addresses +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<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 }}nostrnip5/api/v1/domain/{domain_id} + -H "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + POST /nostrnip5/api/v1/domain +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {domain_object} +
Curl example
+ curl -X POST {{ request.base_url }}nostrnip5/api/v1/domain -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + POST + /nostrnip5/api/v1/domain/{domain_id}/address +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {address_object} +
Curl example
+ curl -X POST {{ request.base_url + }}nostrnip5/api/v1/domain/{domain_id}/address -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 + /nostrnip5/api/v1/domain/{domain_id}/payments/{payment_hash} +
Headers
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+
Curl example
+ curl -X GET {{ request.base_url + }}nostrnip5/api/v1/domain/{domain_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..e6544d62 --- /dev/null +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html @@ -0,0 +1,703 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Domain + New Address + + + + + +
+
+
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 +
+
+
+
+ + + + + + + +
+ Create Address + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/rotate.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/rotate.html new file mode 100644 index 00000000..c9011507 --- /dev/null +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/rotate.html @@ -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 %} + +
+ + +

+ You can use this page to change the public key associated with your + NIP-5 identity. +

+

+ Your current NIP-5 identity is {{ address.local_part }}@{{ domain.domain + }} with nostr public key {{ address.pubkey }}. +

+ +

Input your new pubkey below to update it.

+ + + + +
+ Rotate Keys +
+
+
+
+{% endblock %} {% block scripts %} + +{% 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..78be5db1 --- /dev/null +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/signup.html @@ -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 %} + +
+ + {% raw %} +

+ 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! +

+ +

Important!

+

+ Bookmark this link: + {{ base_url }}nostrnip5/rotate/{{ domain_id }}/{{ + successData.address_id }} +

+

+ 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. +

+ {% endraw %} +
+ + +

+ 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 +
+
+
+ + + + + + + + +
+ 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..8e49a9f5 --- /dev/null +++ b/lnbits/extensions/nostrnip5/views.py @@ -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, + }, + ) diff --git a/lnbits/extensions/nostrnip5/views_api.py b/lnbits/extensions/nostrnip5/views_api.py new file mode 100644 index 00000000..bc4f72c7 --- /dev/null +++ b/lnbits/extensions/nostrnip5/views_api.py @@ -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} diff --git a/pyproject.toml b/pyproject.toml index 96a066e5..573eef1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.