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 ![lnurl ATM](https://i.imgur.com/xFWDnwy.png) -## 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 example
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 @@
- Renew keys
LNbits wallet
Wallet name: {{ wallet.name }}
Wallet ID: {{ wallet.id }}
@@ -233,6 +230,22 @@ {% include "core/_api_docs.html" %} + + + +

This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.

+ +
+
+
+ "Example": +# return cls(**dict(row)) diff --git a/lnbits/extensions/example/views_api.py b/lnbits/extensions/example/views_api.py index c04f8c77e..e59c1072a 100644 --- a/lnbits/extensions/example/views_api.py +++ b/lnbits/extensions/example/views_api.py @@ -21,8 +21,8 @@ async def api_example(): """Try to add descriptions for others.""" tools = [ { - "name": "Flask", - "url": "https://flask.palletsprojects.com/", + "name": "Quart", + "url": "https://pgjones.gitlab.io/quart/", "language": "Python", }, { diff --git a/lnbits/extensions/lnticket/config.json b/lnbits/extensions/lnticket/config.json index dc45eced3..99581b8f7 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 5e987d216..8a071cbc1 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,23 +53,19 @@ async def set_ticket_paid(payment_hash: str) -> Tickets: """, (amount, row[1]), ) - + ticket = await get_ticket(payment_hash) - 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 - }, - timeout=40, - ) - except AssertionError: - webhook = None - return ticket + 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}, + timeout=40, + ) + except AssertionError: + webhook = None + return ticket ticket = await get_ticket(payment_hash) return @@ -95,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 f2edb7b11..8ced65ef7 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 e56954b15..9a5accaf2 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 ec6a15ac9..848623076 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/lnurlp/templates/lnurlp/_api_docs.html b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html index d23242cd3..8aff693f6 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html @@ -17,8 +17,8 @@ [<pay_link_object>, ...]
Curl example
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 }}"
@@ -38,8 +38,8 @@ {"lnurl": <string>}
Curl example
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 example
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 example
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 example
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 example
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 example
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 %} + + + {% 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 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 }}
+
+
Webhook:
+
+ {{ payment.webhook }} + + {{ webhookStatusText }} + +
+
Payment proof:
{{ payment.preimage }}
@@ -243,6 +252,19 @@ Vue.component('lnbits-payment-details', { this.payment.extra.success_action ) }, + webhookStatusColor() { + return this.payment.webhook_status >= 300 || + this.payment.webhook_status < 0 + ? 'red-10' + : !this.payment.webhook_status + ? 'cyan-7' + : 'green-10' + }, + webhookStatusText() { + return this.payment.webhook_status + ? this.payment.webhook_status + : 'not sent yet' + }, hasTag() { return this.payment.extra && !!this.payment.extra.tag }, diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py index dab12a3f2..bd3be4755 100644 --- a/lnbits/wallets/lntxbot.py +++ b/lnbits/wallets/lntxbot.py @@ -82,7 +82,7 @@ class LntxbotWallet(Wallet): data = r.json() checking_id = data["payment_hash"] - fee_msat = data["fee_msat"] + fee_msat = -data["fee_msat"] preimage = data["payment_preimage"] return PaymentResponse(True, checking_id, fee_msat, preimage, None)