diff --git a/docs/devs/installation.md b/docs/devs/installation.md index 224aedd..81ae2a4 100644 --- a/docs/devs/installation.md +++ b/docs/devs/installation.md @@ -23,6 +23,9 @@ $ pipenv shell $ pipenv install --dev ``` +If any of the modules fails to install, try checking and upgrading your setupTool module. +`pip install -U setuptools` + If you wish to use a version of Python higher than 3.7: ```sh diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index b66ab9b..5b0d572 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -285,7 +285,11 @@ async def create_payment( async def update_payment_status(checking_id: str, pending: bool) -> None: await db.execute( - "UPDATE apipayments SET pending = ? WHERE checking_id = ?", (int(pending), checking_id,), + "UPDATE apipayments SET pending = ? WHERE checking_id = ?", + ( + int(pending), + checking_id, + ), ) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index a79d73f..9e37baa 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -40,7 +40,11 @@ class Wallet(NamedTuple): hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest() linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256") - return SigningKey.from_string(linking_key, curve=SECP256k1, hashfunc=hashlib.sha256,) + return SigningKey.from_string( + linking_key, + curve=SECP256k1, + hashfunc=hashlib.sha256, + ) async def get_payment(self, payment_hash: str) -> Optional["Payment"]: from .crud import get_wallet_payment diff --git a/lnbits/core/services.py b/lnbits/core/services.py index a5f7d99..82409b0 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -133,7 +133,10 @@ async def pay_invoice( payment: PaymentResponse = WALLET.pay_invoice(payment_request) if payment.ok and payment.checking_id: await create_payment( - checking_id=payment.checking_id, fee=payment.fee_msat, preimage=payment.preimage, **payment_kwargs, + checking_id=payment.checking_id, + fee=payment.fee_msat, + preimage=payment.preimage, + **payment_kwargs, ) await delete_payment(temp_id) else: @@ -153,7 +156,8 @@ async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo async with httpx.AsyncClient() as client: await client.get( - res.callback.base, params={**res.callback.query_params, **{"k1": res.k1, "pr": payment_request}}, + res.callback.base, + params={**res.callback.query_params, **{"k1": res.k1, "pr": payment_request}}, ) @@ -210,7 +214,11 @@ async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]: async with httpx.AsyncClient() as client: r = await client.get( callback, - params={"k1": k1.hex(), "key": key.verifying_key.to_string("compressed").hex(), "sig": sig.hex(),}, + params={ + "k1": k1.hex(), + "key": key.verifying_key.to_string("compressed").hex(), + "sig": sig.hex(), + }, ) try: resp = json.loads(r.text) @@ -219,7 +227,9 @@ async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]: return LnurlErrorResponse(reason=resp["reason"]) except (KeyError, json.decoder.JSONDecodeError): - return LnurlErrorResponse(reason=r.text[:200] + "..." if len(r.text) > 200 else r.text,) + return LnurlErrorResponse( + reason=r.text[:200] + "..." if len(r.text) > 200 else r.text, + ) async def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus: diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index 8d1d5a9..763ef99 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -38,7 +38,11 @@ async def dispatch_webhook(payment: Payment): async with httpx.AsyncClient() as client: data = payment._asdict() try: - r = await client.post(payment.webhook, json=data, timeout=40,) + r = await client.post( + payment.webhook, + json=data, + timeout=40, + ) await mark_webhook_sent(payment, r.status_code) except (httpx.ConnectError, httpx.RequestError): await mark_webhook_sent(payment, -1) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 75749de..1225c4a 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -21,7 +21,13 @@ from ..tasks import sse_listeners @api_check_wallet_key("invoice") async def api_wallet(): return ( - jsonify({"id": g.wallet.id, "name": g.wallet.name, "balance": g.wallet.balance_msat,}), + jsonify( + { + "id": g.wallet.id, + "name": g.wallet.name, + "balance": g.wallet.balance_msat, + } + ), HTTPStatus.OK, ) @@ -78,7 +84,11 @@ async def api_payments_create_invoice(): if g.data.get("lnurl_callback"): async with httpx.AsyncClient() as client: try: - r = await client.get(g.data["lnurl_callback"], params={"pr": payment_request}, timeout=10,) + r = await client.get( + g.data["lnurl_callback"], + params={"pr": payment_request}, + timeout=10, + ) if r.is_error: lnurl_response = r.text else: @@ -154,7 +164,9 @@ async def api_payments_pay_lnurl(): async with httpx.AsyncClient() as client: try: r = await client.get( - g.data["callback"], params={"amount": g.data["amount"], "comment": g.data["comment"]}, timeout=40, + g.data["callback"], + params={"amount": g.data["amount"], "comment": g.data["comment"]}, + timeout=40, ) if r.is_error: return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST @@ -194,7 +206,10 @@ async def api_payments_pay_lnurl(): extra["comment"] = g.data["comment"] payment_hash = await pay_invoice( - wallet_id=g.wallet.id, payment_request=params["pr"], description=g.data.get("description", ""), extra=extra, + wallet_id=g.wallet.id, + payment_request=params["pr"], + description=g.data.get("description", ""), + extra=extra, ) except Exception as exc: await db.rollback() @@ -354,7 +369,9 @@ async def api_lnurlscan(code: str): @core_app.route("/api/v1/lnurlauth", methods=["POST"]) @api_check_wallet_key("admin") @api_validate_post_request( - schema={"callback": {"type": "string", "required": True},} + schema={ + "callback": {"type": "string", "required": True}, + } ) async def api_perform_lnurlauth(): err = await perform_lnurlauth(g.data["callback"]) diff --git a/lnbits/extensions/example/migrations.py b/lnbits/extensions/example/migrations.py index 04336b5..aca4a27 100644 --- a/lnbits/extensions/example/migrations.py +++ b/lnbits/extensions/example/migrations.py @@ -1,4 +1,4 @@ -#async def m001_initial(db): +# async def m001_initial(db): # await db.execute( # """ @@ -8,4 +8,4 @@ # time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) # ); # """ -# ) \ No newline at end of file +# ) diff --git a/lnbits/extensions/example/models.py b/lnbits/extensions/example/models.py index 15382e8..be52323 100644 --- a/lnbits/extensions/example/models.py +++ b/lnbits/extensions/example/models.py @@ -1,11 +1,11 @@ -#from sqlite3 import Row -#from typing import NamedTuple +# from sqlite3 import Row +# from typing import NamedTuple -#class Example(NamedTuple): +# class Example(NamedTuple): # id: str # wallet: str # # @classmethod # def from_row(cls, row: Row) -> "Example": -# return cls(**dict(row)) \ No newline at end of file +# return cls(**dict(row)) diff --git a/lnbits/extensions/lnticket/config.json b/lnbits/extensions/lnticket/config.json index dc45ece..99581b8 100644 --- a/lnbits/extensions/lnticket/config.json +++ b/lnbits/extensions/lnticket/config.json @@ -1,6 +1,6 @@ { - "name": "Support Tickets", - "short_description": "LN support ticket system", - "icon": "contact_support", - "contributors": ["benarc"] + "name": "Support Tickets", + "short_description": "LN support ticket system", + "icon": "contact_support", + "contributors": ["benarc"] } diff --git a/lnbits/extensions/lnticket/crud.py b/lnbits/extensions/lnticket/crud.py index b38d352..8a071cb 100644 --- a/lnbits/extensions/lnticket/crud.py +++ b/lnbits/extensions/lnticket/crud.py @@ -6,6 +6,7 @@ from . import db from .models import Tickets, Forms import httpx + async def create_ticket( payment_hash: str, wallet: str, @@ -52,19 +53,14 @@ async def set_ticket_paid(payment_hash: str) -> Tickets: """, (amount, row[1]), ) - + ticket = await get_ticket(payment_hash) if formdata.webhook: async with httpx.AsyncClient() as client: try: r = await client.post( formdata.webhook, - json={ - "form": ticket.form, - "name": ticket.name, - "email": ticket.email, - "content": ticket.ltext - }, + json={"form": ticket.form, "name": ticket.name, "email": ticket.email, "content": ticket.ltext}, timeout=40, ) except AssertionError: @@ -96,7 +92,9 @@ async def delete_ticket(ticket_id: str) -> None: # FORMS -async def create_form(*, wallet: str, name: str, webhook: Optional[str] = None, description: str, costpword: int) -> Forms: +async def create_form( + *, wallet: str, name: str, webhook: Optional[str] = None, description: str, costpword: int +) -> Forms: form_id = urlsafe_short_hash() await db.execute( """ diff --git a/lnbits/extensions/lnticket/migrations.py b/lnbits/extensions/lnticket/migrations.py index f2edb7b..8ced65e 100644 --- a/lnbits/extensions/lnticket/migrations.py +++ b/lnbits/extensions/lnticket/migrations.py @@ -102,7 +102,6 @@ async def m003_changed(db): """ ) - for row in [list(row) for row in await db.fetchall("SELECT * FROM forms")]: usescsv = "" diff --git a/lnbits/extensions/lnticket/templates/lnticket/display.html b/lnbits/extensions/lnticket/templates/lnticket/display.html index e56954b..9a5acca 100644 --- a/lnbits/extensions/lnticket/templates/lnticket/display.html +++ b/lnbits/extensions/lnticket/templates/lnticket/display.html @@ -142,7 +142,7 @@ name: self.formDialog.data.name, email: self.formDialog.data.email, ltext: self.formDialog.data.text, - sats: self.formDialog.data.sats, + sats: self.formDialog.data.sats }) .then(function (response) { self.paymentReq = response.data.payment_request @@ -171,7 +171,6 @@ paymentReq: null } dismissMsg() - self.formDialog.data.name = '' self.formDialog.data.email = '' @@ -179,9 +178,8 @@ self.$q.notify({ type: 'positive', message: 'Sent, thank you!', - icon: 'thumb_up', + icon: 'thumb_up' }) - } }) .catch(function (error) { diff --git a/lnbits/extensions/lnticket/templates/lnticket/index.html b/lnbits/extensions/lnticket/templates/lnticket/index.html index ec6a15a..8486230 100644 --- a/lnbits/extensions/lnticket/templates/lnticket/index.html +++ b/lnbits/extensions/lnticket/templates/lnticket/index.html @@ -252,7 +252,12 @@ {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'}, - {name: 'webhook', align: 'left', label: 'Webhook', field: 'webhook'}, + { + name: 'webhook', + align: 'left', + label: 'Webhook', + field: 'webhook' + }, { name: 'description', align: 'left', diff --git a/lnbits/extensions/subdomains/README.md b/lnbits/extensions/subdomains/README.md new file mode 100644 index 0000000..49dfc22 --- /dev/null +++ b/lnbits/extensions/subdomains/README.md @@ -0,0 +1,54 @@ +

Subdomains Extension

+ +So the goal of the extension is to allow the owner of a domain to sell their subdomain to the anyone who is willing to pay some money for it. + +## Requirements + +- Free cloudflare account +- Cloudflare as a dns server provider +- Cloudflare TOKEN and Cloudflare zone-id where the domain is parked + +## Usage + +1. Register at cloudflare and setup your domain with them. (Just follow instructions they provide...) +2. Change DNS server at your domain registrar to point to cloudflare's +3. Get Cloudflare zoneID for your domain + +4. get Cloudflare API TOKEN + + +5. Open the lnbits subdomains extension and register your domain with lnbits +6. Click on the button in the table to open the public form that was generated for your domain + +- Extension also supports webhooks so you can get notified when someone buys a new domain + + +## API Endpoints + +- **Domains** + - GET /api/v1/domains + - POST /api/v1/domains + - PUT /api/v1/domains/ + - DELETE /api/v1/domains/ +- **Subdomains** + - GET /api/v1/subdomains + - POST /api/v1/subdomains/ + - GET /api/v1/subdomains/ + - DELETE /api/v1/subdomains/ + +## Useful + +### Cloudflare + +- Cloudflare offers programmatic subdomain registration... (create new A record) +- you can keep your existing domain's registrar, you just have to transfer dns records to the cloudflare (free service) +- more information: + - https://api.cloudflare.com/#getting-started-requests + - API endpoints needed for our project: + - https://api.cloudflare.com/#dns-records-for-a-zone-list-dns-records + - https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record + - https://api.cloudflare.com/#dns-records-for-a-zone-delete-dns-record + - https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record +- api can be used by providing authorization token OR authorization key + - check API Tokens and API Keys : https://api.cloudflare.com/#getting-started-requests +- Cloudflare API postman collection: https://support.cloudflare.com/hc/en-us/articles/115002323852-Using-Cloudflare-API-with-Postman-Collections diff --git a/lnbits/extensions/subdomains/__init__.py b/lnbits/extensions/subdomains/__init__.py new file mode 100644 index 0000000..cad9fee --- /dev/null +++ b/lnbits/extensions/subdomains/__init__.py @@ -0,0 +1,15 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_subdomains") + +subdomains_ext: Blueprint = Blueprint("subdomains", __name__, static_folder="static", template_folder="templates") + + +from .views_api import * # noqa +from .views import * # noqa + +from .tasks import register_listeners +from lnbits.tasks import record_async + +subdomains_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/subdomains/cloudflare.py b/lnbits/extensions/subdomains/cloudflare.py new file mode 100644 index 0000000..7af85f4 --- /dev/null +++ b/lnbits/extensions/subdomains/cloudflare.py @@ -0,0 +1,44 @@ +from lnbits.extensions.subdomains.models import Domains +import httpx, json + + +async def cloudflare_create_subdomain(domain: Domains, subdomain: str, record_type: str, ip: str): + # Call to cloudflare sort of a dry-run - if success delete the domain and wait for payment + ### SEND REQUEST TO CLOUDFLARE + url = "https://api.cloudflare.com/client/v4/zones/" + domain.cf_zone_id + "/dns_records" + header = {"Authorization": "Bearer " + domain.cf_token, "Content-Type": "application/json"} + aRecord = subdomain + "." + domain.domain + cf_response = "" + async with httpx.AsyncClient() as client: + try: + r = await client.post( + url, + headers=header, + json={ + "type": record_type, + "name": aRecord, + "content": ip, + "ttl": 0, + "proxed": False, + }, + timeout=40, + ) + cf_response = json.loads(r.text) + except AssertionError: + cf_response = "Error occured" + return cf_response + + +async def cloudflare_deletesubdomain(domain: Domains, domain_id: str): + url = "https://api.cloudflare.com/client/v4/zones/" + domain.cf_zone_id + "/dns_records" + header = {"Authorization": "Bearer " + domain.cf_token, "Content-Type": "application/json"} + async with httpx.AsyncClient() as client: + try: + r = await client.delete( + url + "/" + domain_id, + headers=header, + timeout=40, + ) + cf_response = r.text + except AssertionError: + cf_response = "Error occured" diff --git a/lnbits/extensions/subdomains/config.json b/lnbits/extensions/subdomains/config.json new file mode 100644 index 0000000..6bf9480 --- /dev/null +++ b/lnbits/extensions/subdomains/config.json @@ -0,0 +1,6 @@ +{ + "name": "Subdomains", + "short_description": "Sell subdomains of your domain", + "icon": "domain", + "contributors": ["grmkris"] +} diff --git a/lnbits/extensions/subdomains/crud.py b/lnbits/extensions/subdomains/crud.py new file mode 100644 index 0000000..6966652 --- /dev/null +++ b/lnbits/extensions/subdomains/crud.py @@ -0,0 +1,153 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Domains, Subdomains +from lnbits.extensions import subdomains + + +async def create_subdomain( + payment_hash: str, + wallet: str, + domain: str, + subdomain: str, + email: str, + ip: str, + sats: int, + duration: int, + record_type: str, +) -> Subdomains: + await db.execute( + """ + INSERT INTO subdomain (id, domain, email, subdomain, ip, wallet, sats, duration, paid, record_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (payment_hash, domain, email, subdomain, ip, wallet, sats, duration, False, record_type), + ) + + subdomain = await get_subdomain(payment_hash) + assert subdomain, "Newly created subdomain couldn't be retrieved" + return subdomain + + +async def set_subdomain_paid(payment_hash: str) -> Subdomains: + row = await db.fetchone( + "SELECT s.*, d.domain as domain_name FROM subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?", + (payment_hash,), + ) + if row[8] == False: + await db.execute( + """ + UPDATE subdomain + SET paid = true + WHERE id = ? + """, + (payment_hash,), + ) + + domaindata = await get_domain(row[1]) + assert domaindata, "Couldn't get domain from paid subdomain" + + amount = domaindata.amountmade + row[8] + await db.execute( + """ + UPDATE domain + SET amountmade = ? + WHERE id = ? + """, + (amount, row[1]), + ) + + subdomain = await get_subdomain(payment_hash) + return subdomain + + +async def get_subdomain(subdomain_id: str) -> Optional[Subdomains]: + row = await db.fetchone( + "SELECT s.*, d.domain as domain_name FROM subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?", + (subdomain_id,), + ) + print(row) + return Subdomains(**row) if row else None + + +async def get_subdomainBySubdomain(subdomain: str) -> Optional[Subdomains]: + row = await db.fetchone( + "SELECT s.*, d.domain as domain_name FROM subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.subdomain = ?", + (subdomain,), + ) + print(row) + return Subdomains(**row) if row else None + + +async def get_subdomains(wallet_ids: Union[str, List[str]]) -> List[Subdomains]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT s.*, d.domain as domain_name FROM subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.wallet IN ({q})", + (*wallet_ids,), + ) + + return [Subdomains(**row) for row in rows] + + +async def delete_subdomain(subdomain_id: str) -> None: + await db.execute("DELETE FROM subdomain WHERE id = ?", (subdomain_id,)) + + +# Domains + + +async def create_domain( + *, + wallet: str, + domain: str, + cf_token: str, + cf_zone_id: str, + webhook: Optional[str] = None, + description: str, + cost: int, + allowed_record_types: str, +) -> Domains: + domain_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO domain (id, wallet, domain, webhook, cf_token, cf_zone_id, description, cost, amountmade, allowed_record_types) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (domain_id, wallet, domain, webhook, cf_token, cf_zone_id, description, cost, 0, allowed_record_types), + ) + + domain = await get_domain(domain_id) + assert domain, "Newly created domain couldn't be retrieved" + return domain + + +async def update_domain(domain_id: str, **kwargs) -> Domains: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute(f"UPDATE domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id)) + row = await db.fetchone("SELECT * FROM domain WHERE id = ?", (domain_id,)) + assert row, "Newly updated domain couldn't be retrieved" + return Domains(**row) + + +async def get_domain(domain_id: str) -> Optional[Domains]: + row = await db.fetchone("SELECT * FROM domain WHERE id = ?", (domain_id,)) + return Domains(**row) if row else None + + +async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domains]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall(f"SELECT * FROM domain WHERE wallet IN ({q})", (*wallet_ids,)) + + return [Domains(**row) for row in rows] + + +async def delete_domain(domain_id: str) -> None: + await db.execute("DELETE FROM domain WHERE id = ?", (domain_id,)) diff --git a/lnbits/extensions/subdomains/migrations.py b/lnbits/extensions/subdomains/migrations.py new file mode 100644 index 0000000..4864377 --- /dev/null +++ b/lnbits/extensions/subdomains/migrations.py @@ -0,0 +1,37 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS domain ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + domain TEXT NOT NULL, + webhook TEXT, + cf_token TEXT NOT NULL, + cf_zone_id TEXT NOT NULL, + description TEXT NOT NULL, + cost INTEGER NOT NULL, + amountmade INTEGER NOT NULL, + allowed_record_types TEXT NOT NULL, + time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) + ); + """ + ) + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS subdomain ( + id TEXT PRIMARY KEY, + domain TEXT NOT NULL, + email TEXT NOT NULL, + subdomain TEXT NOT NULL, + ip TEXT NOT NULL, + wallet TEXT NOT NULL, + sats INTEGER NOT NULL, + duration INTEGER NOT NULL, + paid BOOLEAN NOT NULL, + record_type TEXT NOT NULL, + time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) + ); + """ + ) diff --git a/lnbits/extensions/subdomains/models.py b/lnbits/extensions/subdomains/models.py new file mode 100644 index 0000000..a519311 --- /dev/null +++ b/lnbits/extensions/subdomains/models.py @@ -0,0 +1,30 @@ +from typing import NamedTuple + + +class Domains(NamedTuple): + id: str + wallet: str + domain: str + cf_token: str + cf_zone_id: str + webhook: str + description: str + cost: int + amountmade: int + time: int + allowed_record_types: str + + +class Subdomains(NamedTuple): + id: str + wallet: str + domain: str + domain_name: str + subdomain: str + email: str + ip: str + sats: int + duration: int + paid: bool + time: int + record_type: str diff --git a/lnbits/extensions/subdomains/tasks.py b/lnbits/extensions/subdomains/tasks.py new file mode 100644 index 0000000..f5f193a --- /dev/null +++ b/lnbits/extensions/subdomains/tasks.py @@ -0,0 +1,58 @@ +from http import HTTPStatus +from quart.json import jsonify +import trio # type: ignore +import httpx + +from .crud import get_domain, set_subdomain_paid +from lnbits.core.crud import get_user, get_wallet +from lnbits.core import db as core_db +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener +from .cloudflare import cloudflare_create_subdomain + + +async def register_listeners(): + invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2) + register_invoice_listener(invoice_paid_chan_send) + await wait_for_paid_invoices(invoice_paid_chan_recv) + + +async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): + async for payment in invoice_paid_chan: + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + if "lnsubdomain" != payment.extra.get("tag"): + # not an lnurlp invoice + return + + await payment.set_pending(False) + subdomain = await set_subdomain_paid(payment_hash=payment.payment_hash) + domain = await get_domain(subdomain.domain) + + ### Create subdomain + cf_response = cloudflare_create_subdomain( + domain=domain, subdomain=subdomain.subdomain, record_type=subdomain.record_type, ip=subdomain.ip + ) + + ### Use webhook to notify about cloudflare registration + if domain.webhook: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + domain.webhook, + json={ + "domain": subdomain.domain_name, + "subdomain": subdomain.subdomain, + "record_type": subdomain.record_type, + "email": subdomain.email, + "ip": subdomain.ip, + "cost:": str(subdomain.sats) + " sats", + "duration": str(subdomain.duration) + " days", + "cf_response": cf_response, + }, + timeout=40, + ) + except AssertionError: + webhook = None diff --git a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html new file mode 100644 index 0000000..e78ae4a --- /dev/null +++ b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html @@ -0,0 +1,23 @@ + + + +
+ lnSubdomains: Get paid sats to sell your subdomains +
+

+ Charge people for using your subdomain name...
+ Are you the owner of cool-domain.com and want to sell + cool-subdomain.cool-domain.com +
+ + Created by, Kris +

+
+
+
diff --git a/lnbits/extensions/subdomains/templates/subdomains/display.html b/lnbits/extensions/subdomains/templates/subdomains/display.html new file mode 100644 index 0000000..e46228c --- /dev/null +++ b/lnbits/extensions/subdomains/templates/subdomains/display.html @@ -0,0 +1,221 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +

{{ domain_domain }}

+
+
{{ domain_desc }}
+
+ + + + + + + + + +

+ Cost per day: {{ domain_cost }} sats
+ {% raw %} Total cost: {{amountSats}} sats {% endraw %} +

+
+ Submit + Cancel +
+
+
+
+
+ + + + + + +
+ Copy invoice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/subdomains/templates/subdomains/index.html b/lnbits/extensions/subdomains/templates/subdomains/index.html new file mode 100644 index 0000000..d62f8f3 --- /dev/null +++ b/lnbits/extensions/subdomains/templates/subdomains/index.html @@ -0,0 +1,545 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + + New Domain + + + + + +
+
+
Domains
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Subdomains
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ + +
LNbits Subdomain extension
+
+ + + {% include "subdomains/_api_docs.html" %} + +
+
+
+ + + + + + + + + + + + + + + + +
+ Update Form + Create Domain + Cancel +
+
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/subdomains/util.py b/lnbits/extensions/subdomains/util.py new file mode 100644 index 0000000..c7d6630 --- /dev/null +++ b/lnbits/extensions/subdomains/util.py @@ -0,0 +1,36 @@ +from lnbits.extensions.subdomains.models import Subdomains + +# Python3 program to validate +# domain name +# using regular expression +import re +import socket + +# Function to validate domain name. +def isValidDomain(str): + # Regex to check valid + # domain name. + regex = "^((?!-)[A-Za-z0-9-]{1,63}(?") +async def display(domain_id): + domain = await get_domain(domain_id) + if not domain: + abort(HTTPStatus.NOT_FOUND, "Domain does not exist.") + allowed_records = domain.allowed_record_types.replace('"', "").replace(" ", "").split(",") + print(allowed_records) + return await render_template( + "subdomains/display.html", + domain_id=domain.id, + domain_domain=domain.domain, + domain_desc=domain.description, + domain_cost=domain.cost, + domain_allowed_record_types=allowed_records, + ) diff --git a/lnbits/extensions/subdomains/views_api.py b/lnbits/extensions/subdomains/views_api.py new file mode 100644 index 0000000..e7d2d21 --- /dev/null +++ b/lnbits/extensions/subdomains/views_api.py @@ -0,0 +1,191 @@ +import re +from quart import g, jsonify, request +from http import HTTPStatus +from lnbits.core import crud +import json + +import httpx +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from .util import isValidDomain, isvalidIPAddress +from . import subdomains_ext +from .crud import ( + create_subdomain, + get_subdomain, + get_subdomains, + delete_subdomain, + create_domain, + update_domain, + get_domain, + get_domains, + delete_domain, + get_subdomainBySubdomain, +) +from .cloudflare import cloudflare_create_subdomain, cloudflare_deletesubdomain + + +# domainS + + +@subdomains_ext.route("/api/v1/domains", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_domains(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return jsonify([domain._asdict() for domain in await get_domains(wallet_ids)]), HTTPStatus.OK + + +@subdomains_ext.route("/api/v1/domains", methods=["POST"]) +@subdomains_ext.route("/api/v1/domains/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "wallet": {"type": "string", "empty": False, "required": True}, + "domain": {"type": "string", "empty": False, "required": True}, + "cf_token": {"type": "string", "empty": False, "required": True}, + "cf_zone_id": {"type": "string", "empty": False, "required": True}, + "webhook": {"type": "string", "empty": False, "required": False}, + "description": {"type": "string", "min": 0, "required": True}, + "cost": {"type": "integer", "min": 0, "required": True}, + "allowed_record_types": {"type": "string", "required": True}, + } +) +async def api_domain_create(domain_id=None): + if domain_id: + domain = await get_domain(domain_id) + + if not domain: + return jsonify({"message": "domain does not exist."}), HTTPStatus.NOT_FOUND + + if domain.wallet != g.wallet.id: + return jsonify({"message": "Not your domain."}), HTTPStatus.FORBIDDEN + + domain = await update_domain(domain_id, **g.data) + else: + domain = await create_domain(**g.data) + return jsonify(domain._asdict()), HTTPStatus.CREATED + + +@subdomains_ext.route("/api/v1/domains/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_domain_delete(domain_id): + domain = await get_domain(domain_id) + + if not domain: + return jsonify({"message": "domain does not exist."}), HTTPStatus.NOT_FOUND + + if domain.wallet != g.wallet.id: + return jsonify({"message": "Not your domain."}), HTTPStatus.FORBIDDEN + + await delete_domain(domain_id) + + return "", HTTPStatus.NO_CONTENT + + +#########subdomains########## + + +@subdomains_ext.route("/api/v1/subdomains", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_subdomains(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return jsonify([domain._asdict() for domain in await get_subdomains(wallet_ids)]), HTTPStatus.OK + + +@subdomains_ext.route("/api/v1/subdomains/", methods=["POST"]) +@api_validate_post_request( + schema={ + "domain": {"type": "string", "empty": False, "required": True}, + "subdomain": {"type": "string", "empty": False, "required": True}, + "email": {"type": "string", "empty": True, "required": True}, + "ip": {"type": "string", "empty": False, "required": True}, + "sats": {"type": "integer", "min": 0, "required": True}, + "duration": {"type": "integer", "empty": False, "required": True}, + "record_type": {"type": "string", "empty": False, "required": True}, + } +) +async def api_subdomain_make_subdomain(domain_id): + domain = await get_domain(domain_id) + + # If the request is coming for the non-existant domain + if not domain: + return jsonify({"message": "LNsubdomain does not exist."}), HTTPStatus.NOT_FOUND + + ## If record_type is not one of the allowed ones reject the request + if g.data["record_type"] not in domain.allowed_record_types: + return jsonify({"message": g.data["record_type"] + "Not a valid record"}), HTTPStatus.BAD_REQUEST + + ## If domain already exist in our database reject it + if await get_subdomainBySubdomain(g.data["subdomain"]) is not None: + return ( + jsonify({"message": g.data["subdomain"] + "." + domain.domain + " domain already taken"}), + HTTPStatus.BAD_REQUEST, + ) + + ## Dry run cloudflare... (create and if create is sucessful delete it) + cf_response = await cloudflare_create_subdomain( + domain=domain, subdomain=g.data["subdomain"], record_type=g.data["record_type"], ip=g.data["ip"] + ) + if cf_response["success"] == True: + cloudflare_deletesubdomain(domain=domain, domain_id=cf_response["result"]["id"]) + else: + return ( + jsonify({"message": "Problem with cloudflare: " + cf_response["errors"][0]["message"]}), + HTTPStatus.BAD_REQUEST, + ) + + ## ALL OK - create an invoice and return it to the user + sats = g.data["sats"] + payment_hash, payment_request = await create_invoice( + wallet_id=domain.wallet, + amount=sats, + memo=f"subdomain {g.data['subdomain']}.{domain.domain} for {sats} sats for {g.data['duration']} days", + extra={"tag": "lnsubdomain"}, + ) + + subdomain = await create_subdomain(payment_hash=payment_hash, wallet=domain.wallet, **g.data) + + if not subdomain: + return jsonify({"message": "LNsubdomain could not be fetched."}), HTTPStatus.NOT_FOUND + + return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK + + +@subdomains_ext.route("/api/v1/subdomains/", methods=["GET"]) +async def api_subdomain_send_subdomain(payment_hash): + subdomain = await get_subdomain(payment_hash) + try: + status = await check_invoice_status(subdomain.wallet, payment_hash) + is_paid = not status.pending + except Exception: + return jsonify({"paid": False}), HTTPStatus.OK + + if is_paid: + return jsonify({"paid": True}), HTTPStatus.OK + + return jsonify({"paid": False}), HTTPStatus.OK + + +@subdomains_ext.route("/api/v1/subdomains/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_subdomain_delete(subdomain_id): + subdomain = await get_subdomain(subdomain_id) + + if not subdomain: + return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + + if subdomain.wallet != g.wallet.id: + return jsonify({"message": "Not your subdomain."}), HTTPStatus.FORBIDDEN + + await delete_subdomain(subdomain_id) + + return "", HTTPStatus.NO_CONTENT