diff --git a/.github/workflows/on-push.yml b/.github/workflows/on-push.yml new file mode 100644 index 000000000..4d008dec4 --- /dev/null +++ b/.github/workflows/on-push.yml @@ -0,0 +1,58 @@ +name: Docker build on push + +env: + DOCKER_CLI_EXPERIMENTAL: enabled + +on: + push: + branches: + - master + +jobs: + build: + runs-on: ubuntu-20.04 + name: Build and push lnbits image + steps: + - name: Login to Docker Hub + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + - name: Checkout project + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + id: qemu + + - name: Setup Docker buildx action + uses: docker/setup-buildx-action@v1 + id: buildx + + - name: Show available Docker buildx platforms + run: echo ${{ steps.buildx.outputs.platforms }} + + - name: Cache Docker layers + uses: actions/cache@v2 + id: cache + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Run Docker buildx against commit hash + run: | + docker buildx build \ + --cache-from "type=local,src=/tmp/.buildx-cache" \ + --cache-to "type=local,dest=/tmp/.buildx-cache" \ + --platform linux/amd64,linux/arm64,linux/arm/v7 \ + --tag ${{ secrets.DOCKER_USERNAME }}/lnbits:${GITHUB_SHA:0:7} \ + --output "type=registry" ./ + + - name: Run Docker buildx against latest + run: | + docker buildx build \ + --cache-from "type=local,src=/tmp/.buildx-cache" \ + --cache-to "type=local,dest=/tmp/.buildx-cache" \ + --platform linux/amd64,linux/arm64,linux/arm/v7 \ + --tag ${{ secrets.DOCKER_USERNAME }}/lnbits:latest \ + --output "type=registry" ./ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9bd165a7c..960fbf75c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,48 @@ -FROM python:3.7-slim +# Build image +FROM python:3.7-slim as builder +# Setup virtualenv +ENV VIRTUAL_ENV=/opt/venv +RUN python -m venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +# Install build deps +RUN apt-get update +RUN apt-get install -y --no-install-recommends build-essential + +# Install runtime deps +COPY requirements.txt /tmp/requirements.txt +RUN pip install -r /tmp/requirements.txt + +# Install c-lightning specific deps +RUN pip install pylightning + +# Install LND specific deps +RUN pip install lndgrpc purerpc + +# Production image +FROM python:3.7-slim as lnbits + +# Run as non-root +USER 1000:1000 + +# Copy over virtualenv +ENV VIRTUAL_ENV="/opt/venv" +COPY --from=builder --chown=1000:1000 $VIRTUAL_ENV $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +# Setup Quart +ENV QUART_APP="lnbits.app:create_app()" +ENV QUART_ENV="development" +ENV QUART_DEBUG="true" + +# App +ENV LNBITS_BIND="0.0.0.0:5000" + +# Copy in app source WORKDIR /app -COPY requirements.txt /app/ -RUN pip install --no-cache-dir -q -r requirements.txt -COPY . /app +COPY --chown=1000:1000 lnbits /app/lnbits EXPOSE 5000 + +CMD quart assets && quart migrate && hypercorn -k trio --bind $LNBITS_BIND 'lnbits.app:create_app()' diff --git a/README.md b/README.md index a5a1a1ffa..3fce0ab7d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an * Fallback wallet for the LNURL scheme * Instant wallet for LN demonstrations -The wallet can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularily. +LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularily. See [lnbits.org](https://lnbits.org) for more detailed documentation. @@ -68,7 +68,7 @@ Wallets can be easily generated and given out to people at events (one click mul  -## Tip me +## Tip us If you like this project and might even use or extend it, why not [send some tip love](https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)! diff --git a/docs/devs/installation.md b/docs/devs/installation.md index 224aedd8c..81ae2a4c6 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 c9c3b1076..5b0d572c5 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -253,13 +253,14 @@ async def create_payment( preimage: Optional[str] = None, pending: bool = True, extra: Optional[Dict] = None, + webhook: Optional[str] = None, ) -> Payment: await db.execute( """ INSERT INTO apipayments (wallet, checking_id, bolt11, hash, preimage, - amount, pending, memo, fee, extra) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + amount, pending, memo, fee, extra, webhook) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( wallet_id, @@ -272,6 +273,7 @@ async def create_payment( memo, fee, json.dumps(extra) if extra and extra != {} and type(extra) is dict else None, + webhook, ), ) diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index 5ec0c0a57..d04963228 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -120,3 +120,13 @@ async def m002_add_fields_to_apipayments(db): # catching errors like this won't be necessary in anymore now that we # keep track of db versions so no migration ever runs twice. pass + + +async def m003_add_invoice_webhook(db): + """ + Special column for webhook endpoints that can be assigned + to each different invoice. + """ + + await db.execute("ALTER TABLE apipayments ADD COLUMN webhook TEXT") + await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT") diff --git a/lnbits/core/models.py b/lnbits/core/models.py index d26c0aba5..9e37baa17 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -84,6 +84,8 @@ class Payment(NamedTuple): payment_hash: str extra: Dict wallet_id: str + webhook: str + webhook_status: int @classmethod def from_row(cls, row: Row): @@ -99,6 +101,8 @@ class Payment(NamedTuple): memo=row["memo"], time=row["time"], wallet_id=row["wallet"], + webhook=row["webhook"], + webhook_status=row["webhook_status"], ) @property diff --git a/lnbits/core/services.py b/lnbits/core/services.py index df93d6a5a..266e36a84 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -28,6 +28,7 @@ async def create_invoice( memo: str, description_hash: Optional[bytes] = None, extra: Optional[Dict] = None, + webhook: Optional[str] = None, ) -> Tuple[str, str]: await db.begin() invoice_memo = None if description_hash else memo @@ -50,6 +51,7 @@ async def create_invoice( amount=amount_msat, memo=storeable_memo, extra=extra, + webhook=webhook, ) await db.commit() @@ -137,10 +139,12 @@ async def pay_invoice( **payment_kwargs, ) await delete_payment(temp_id) + await db.commit() else: + await delete_payment(temp_id) + await db.commit() raise Exception(payment.error_message or "Failed to pay_invoice on backend.") - await db.commit() return invoice.payment_hash diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index e0e28391f..763ef9988 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -1,7 +1,10 @@ import trio # type: ignore +import httpx from typing import List from lnbits.tasks import register_invoice_listener +from . import db +from .models import Payment sse_listeners: List[trio.MemorySendChannel] = [] @@ -14,9 +17,42 @@ async def register_listeners(): async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): async for payment in invoice_paid_chan: - for send_channel in sse_listeners: - try: - send_channel.send_nowait(payment) - except trio.WouldBlock: - print("removing sse listener", send_channel) - sse_listeners.remove(send_channel) + # send information to sse channel + await dispatch_sse(payment) + + # dispatch webhook + if payment.webhook and not payment.webhook_status: + await dispatch_webhook(payment) + + +async def dispatch_sse(payment: Payment): + for send_channel in sse_listeners: + try: + send_channel.send_nowait(payment) + except trio.WouldBlock: + print("removing sse listener", send_channel) + sse_listeners.remove(send_channel) + + +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, + ) + await mark_webhook_sent(payment, r.status_code) + except (httpx.ConnectError, httpx.RequestError): + await mark_webhook_sent(payment, -1) + + +async def mark_webhook_sent(payment: Payment, status: int) -> None: + await db.execute( + """ + UPDATE apipayments SET webhook_status = ? + WHERE hash = ? + """, + (status, payment.payment_hash), + ) diff --git a/lnbits/core/templates/core/_api_docs.html b/lnbits/core/templates/core/_api_docs.html index 05bc125b6..43a2cc9de 100644 --- a/lnbits/core/templates/core/_api_docs.html +++ b/lnbits/core/templates/core/_api_docs.html @@ -55,8 +55,9 @@
curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": false,
- "amount": <int>, "memo": <string>}' -H "X-Api-Key:
- {{ wallet.inkey }}" -H "Content-type: application/json"
{{ wallet.inkey }}" -H
+ "Content-type: application/json"
diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html
index e641a4ca8..a313ce26c 100644
--- a/lnbits/core/templates/core/wallet.html
+++ b/lnbits/core/templates/core/wallet.html
@@ -219,9 +219,6 @@
This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.
+[<pay_link_object>, ...]
curl -X GET {{ request.url_root }}lnurlp/api/v1/links -H "X-Api-Key:
- {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.url_root }}api/v0/links -H "X-Api-Key: {{
+ g.user.wallets[0].inkey }}"
{"lnurl": <string>}
curl -X GET {{ request.url_root }}lnurlp/api/v1/links/<pay_id>
- -H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.url_root }}api/v1/links/<pay_id> -H
+ "X-Api-Key: {{ g.user.wallets[0].inkey }}"
@@ -63,10 +63,9 @@
{"lnurl": <string>}
curl -X POST {{ request.url_root }}lnurlp/api/v1/links -d
- '{"description": <string>, "amount": <integer>}' -H
- "Content-type: application/json" -H "X-Api-Key: {{
- g.user.wallets[0].adminkey }}"
+ >curl -X POST {{ request.url_root }}api/v1/links -d '{"description":
+ <string>, "amount": <integer>}' -H "Content-type:
+ application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
@@ -93,8 +92,8 @@
{"lnurl": <string>}
curl -X PUT {{ request.url_root }}lnurlp/api/v1/links/<pay_id>
- -d '{"description": <string>, "amount": <integer>}' -H
+ >curl -X PUT {{ request.url_root }}api/v1/links/<pay_id> -d
+ '{"description": <string>, "amount": <integer>}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
@@ -120,9 +119,8 @@
curl -X DELETE {{ request.url_root
- }}lnurlp/api/v1/links/<pay_id> -H "X-Api-Key: {{
- g.user.wallets[0].adminkey }}"
+ >curl -X DELETE {{ request.url_root }}api/v1/links/<pay_id> -H
+ "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html
index 56dbf5645..3884c3b52 100644
--- a/lnbits/extensions/paywall/templates/paywall/_api_docs.html
+++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html
@@ -17,7 +17,7 @@
[<paywall_object>, ...]
curl -X GET {{ request.url_root }}paywall/api/v1/paywalls -H
+ >curl -X GET {{ request.url_root }}api/v1/paywalls -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
@@ -48,7 +48,7 @@
>
curl -X POST {{ request.url_root }}paywall/api/v1/paywalls -d
+ >curl -X POST {{ request.url_root }}api/v1/paywalls -d
'{"url": <string>, "memo": <string>, "description":
<string>, "amount": <integer>, "remembers":
<boolean>}' -H "Content-type: application/json" -H "X-Api-Key:
@@ -81,7 +81,7 @@
Curl example
curl -X POST {{ request.url_root
- }}paywall/api/v1/paywalls/<paywall_id>/invoice -d '{"amount":
+ }}api/v1/paywalls/<paywall_id>/invoice -d '{"amount":
<integer>}' -H "Content-type: application/json"
@@ -112,7 +112,7 @@
Curl example
curl -X POST {{ request.url_root
- }}paywall/api/v1/paywalls/<paywall_id>/check_invoice -d
+ }}api/v1/paywalls/<paywall_id>/check_invoice -d
'{"payment_hash": <string>}' -H "Content-type: application/json"
@@ -138,7 +138,7 @@
Curl example
curl -X DELETE {{ request.url_root
- }}paywall/api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{
+ }}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
diff --git a/lnbits/extensions/subdomains/README.md b/lnbits/extensions/subdomains/README.md
new file mode 100644
index 000000000..49dfc223d
--- /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 000000000..cad9fee8d
--- /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 000000000..7af85f40d
--- /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 000000000..6bf9480cd
--- /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 000000000..696665251
--- /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 000000000..4864377d4
--- /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 000000000..a519311e5
--- /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 000000000..f5f193a62
--- /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 000000000..e78ae4ac4
--- /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 000000000..e46228cdc
--- /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 000000000..d62f8f385
--- /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 %}
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+ Subdomains
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+
+ {% 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 000000000..c7d663073
--- /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 000000000..e7d2d21f1
--- /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
diff --git a/lnbits/extensions/tpos/templates/tpos/_api_docs.html b/lnbits/extensions/tpos/templates/tpos/_api_docs.html
index aac366a29..6ceab7284 100644
--- a/lnbits/extensions/tpos/templates/tpos/_api_docs.html
+++ b/lnbits/extensions/tpos/templates/tpos/_api_docs.html
@@ -17,7 +17,7 @@
[<tpos_object>, ...]
Curl example
curl -X GET {{ request.url_root }}tpos/api/v1/tposs -H "X-Api-Key:
+ >curl -X GET {{ request.url_root }}api/v1/tposs -H "X-Api-Key:
<invoice_key>"
@@ -42,7 +42,7 @@
>
Curl example
curl -X POST {{ request.url_root }}tpos/api/v1/tposs -d '{"name":
+ >curl -X POST {{ request.url_root }}api/v1/tposs -d '{"name":
<string>, "currency": <string>}' -H "Content-type:
application/json" -H "X-Api-Key: <admin_key>"
@@ -69,8 +69,8 @@
Curl example
curl -X DELETE {{ request.url_root
- }}tpos/api/v1/tposs/<tpos_id> -H "X-Api-Key: <admin_key>"
+ >curl -X DELETE {{ request.url_root }}api/v1/tposs/<tpos_id> -H
+ "X-Api-Key: <admin_key>"
diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py
index 717beaf3e..22980fcef 100644
--- a/lnbits/extensions/tpos/views_api.py
+++ b/lnbits/extensions/tpos/views_api.py
@@ -13,9 +13,8 @@ from .crud import create_tpos, get_tpos, get_tposs, delete_tpos
@api_check_wallet_key("invoice")
async def api_tposs():
wallet_ids = [g.wallet.id]
-
if "all_wallets" in request.args:
- wallet_ids = await get_user(g.wallet.user).wallet_ids
+ wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return jsonify([tpos._asdict() for tpos in await get_tposs(wallet_ids)]), HTTPStatus.OK
diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html
index 6a0980c98..7b1925a50 100644
--- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html
+++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html
@@ -42,7 +42,7 @@
JSON list of users
Curl example
curl -X GET {{ request.url_root }}usermanager/api/v1/users -H
+ >curl -X GET {{ request.url_root }}api/v1/users -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
@@ -65,7 +65,7 @@
Curl example
curl -X GET {{ request.url_root
- }}usermanager/api/v1/wallets/<user_id> -H "X-Api-Key: {{
+ }}api/v1/wallets/<user_id> -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
@@ -88,7 +88,7 @@
Curl example
curl -X GET {{ request.url_root
- }}usermanager/api/v1/wallets<wallet_id> -H "X-Api-Key: {{
+ }}api/v1/wallets<wallet_id> -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
@@ -128,7 +128,7 @@
>
Curl example
curl -X POST {{ request.url_root }}usermanager/api/v1/users -d
+ >curl -X POST {{ request.url_root }}api/v1/users -d
'{"admin_id": "{{ g.user.id }}", "wallet_name": <string>,
"user_name": <string>}' -H "X-Api-Key: {{
g.user.wallets[0].inkey }}" -H "Content-type: application/json"
@@ -165,7 +165,7 @@
>
Curl example
curl -X POST {{ request.url_root }}usermanager/api/v1/wallets -d
+ >curl -X POST {{ request.url_root }}api/v1/wallets -d
'{"user_id": <string>, "wallet_name": <string>,
"admin_id": "{{ g.user.id }}"}' -H "X-Api-Key: {{
g.user.wallets[0].inkey }}" -H "Content-type: application/json"
@@ -190,7 +190,7 @@
Curl example
curl -X DELETE {{ request.url_root
- }}usermanager/api/v1/users/<user_id> -H "X-Api-Key: {{
+ }}api/v1/users/<user_id> -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
@@ -208,7 +208,7 @@
Curl example
curl -X DELETE {{ request.url_root
- }}usermanager/api/v1/wallets/<wallet_id> -H "X-Api-Key: {{
+ }}api/v1/wallets/<wallet_id> -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
@@ -230,7 +230,7 @@
{"X-Api-Key": <string>}
Curl example
curl -X POST {{ request.url_root }}usermanager/api/v1/extensions -d
+ >curl -X POST {{ request.url_root }}api/v1/extensions -d
'{"userid": <string>, "extension": <string>, "active":
<integer>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
"Content-type: application/json"
diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
index b87e3e3bd..bc1aac2b2 100644
--- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
+++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html
@@ -22,7 +22,7 @@
[<withdraw_link_object>, ...]
Curl example
curl -X GET {{ request.url_root }}withdraw/api/v1/links -H
+ >curl -X GET {{ request.url_root }}api/v1/links -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
@@ -50,7 +50,7 @@
Curl example
curl -X GET {{ request.url_root
- }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{
+ }}api/v1/links/<withdraw_id> -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
@@ -79,7 +79,7 @@
{"lnurl": <string>}
Curl example
curl -X POST {{ request.url_root }}withdraw/api/v1/links -d
+ >curl -X POST {{ request.url_root }}api/v1/links -d
'{"title": <string>, "min_withdrawable": <integer>,
"max_withdrawable": <integer>, "uses": <integer>,
"wait_time": <integer>, "is_unique": <boolean>}' -H
@@ -116,7 +116,7 @@
Curl example
curl -X PUT {{ request.url_root
- }}withdraw/api/v1/links/<withdraw_id> -d '{"title":
+ }}api/v1/links/<withdraw_id> -d '{"title":
<string>, "min_withdrawable": <integer>,
"max_withdrawable": <integer>, "uses": <integer>,
"wait_time": <integer>, "is_unique": <boolean>}' -H
@@ -146,7 +146,7 @@
Curl example
curl -X DELETE {{ request.url_root
- }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{
+ }}api/v1/links/<withdraw_id> -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js
index 94041e386..ed0583e50 100644
--- a/lnbits/static/js/base.js
+++ b/lnbits/static/js/base.js
@@ -140,7 +140,10 @@ window.LNbits = {
'bolt11',
'preimage',
'payment_hash',
- 'extra'
+ 'extra',
+ 'wallet_id',
+ 'webhook',
+ 'webhook_status'
],
data
)
diff --git a/lnbits/static/js/components.js b/lnbits/static/js/components.js
index 682da275a..e1faf2fec 100644
--- a/lnbits/static/js/components.js
+++ b/lnbits/static/js/components.js
@@ -204,6 +204,15 @@ Vue.component('lnbits-payment-details', {
Payment hash:
{{ payment.payment_hash }}