Merge pull request #151 from lnbits/master

Bring branch up to date
This commit is contained in:
Arc 2021-02-21 13:14:42 +00:00 committed by GitHub
commit ca60893701
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1756 additions and 89 deletions

58
.github/workflows/on-push.yml vendored Normal file
View File

@ -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" ./

View File

@ -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()'

View File

@ -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)!

View File

@ -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

View File

@ -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,
),
)

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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),
)

View File

@ -55,8 +55,9 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
"amount": &lt;int&gt;, "memo": &lt;string&gt;, "webhook":
&lt;url:string&gt;}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>

View File

@ -219,9 +219,6 @@
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn flat color="grey" @click="exportCSV" class="float-right"
>Renew keys</q-btn
>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">LNbits wallet</h6>
<strong>Wallet name: </strong><em>{{ wallet.name }}</em><br />
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
@ -233,6 +230,22 @@
<q-list>
{% include "core/_api_docs.html" %}
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="settings_cell"
label="Export to Phone with QR Code"
>
<q-card>
<q-card-section>
<p>This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.</p>
<qrcode
:value="'{{request.url_root}}'+'wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}"
></qrcode>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="remove_circle"

View File

@ -51,6 +51,8 @@ async def api_payments():
"memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
"lnurl_callback": {"type": "string", "nullable": True, "required": False},
"extra": {"type": "dict", "nullable": True, "required": False},
"webhook": {"type": "string", "empty": False, "required": False},
}
)
async def api_payments_create_invoice():
@ -63,7 +65,12 @@ async def api_payments_create_invoice():
try:
payment_hash, payment_request = await create_invoice(
wallet_id=g.wallet.id, amount=g.data["amount"], memo=memo, description_hash=description_hash
wallet_id=g.wallet.id,
amount=g.data["amount"],
memo=memo,
description_hash=description_hash,
extra=g.data.get("extra"),
webhook=g.data.get("webhook"),
)
except Exception as exc:
await db.rollback()

View File

@ -0,0 +1,11 @@
# async def m001_initial(db):
# await db.execute(
# """
# CREATE TABLE IF NOT EXISTS example (
# id TEXT PRIMARY KEY,
# wallet TEXT NOT NULL,
# time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
# );
# """
# )

View File

@ -0,0 +1,11 @@
# from sqlite3 import Row
# from typing import NamedTuple
# class Example(NamedTuple):
# id: str
# wallet: str
#
# @classmethod
# def from_row(cls, row: Row) -> "Example":
# return cls(**dict(row))

View File

@ -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",
},
{

View File

@ -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"]
}

View File

@ -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(
"""

View File

@ -102,7 +102,6 @@ async def m003_changed(db):
"""
)
for row in [list(row) for row in await db.fetchall("SELECT * FROM forms")]:
usescsv = ""

View File

@ -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) {

View File

@ -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',

View File

@ -17,8 +17,8 @@
<code>[&lt;pay_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>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 }}"
</code>
</q-card-section>
</q-card>
@ -38,8 +38,8 @@
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}lnurlp/api/v1/links/&lt;pay_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
>curl -X GET {{ request.url_root }}api/v1/links/&lt;pay_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
@ -63,10 +63,9 @@
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}lnurlp/api/v1/links -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -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":
&lt;string&gt;, "amount": &lt;integer&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
@ -93,8 +92,8 @@
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root }}lnurlp/api/v1/links/&lt;pay_id&gt;
-d '{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
>curl -X PUT {{ request.url_root }}api/v1/links/&lt;pay_id&gt; -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
@ -120,9 +119,8 @@
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}lnurlp/api/v1/links/&lt;pay_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
>curl -X DELETE {{ request.url_root }}api/v1/links/&lt;pay_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>

View File

@ -17,7 +17,7 @@
<code>[&lt;paywall_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>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 }}"
</code>
</q-card-section>
@ -48,7 +48,7 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}paywall/api/v1/paywalls -d
>curl -X POST {{ request.url_root }}api/v1/paywalls -d
'{"url": &lt;string&gt;, "memo": &lt;string&gt;, "description":
&lt;string&gt;, "amount": &lt;integer&gt;, "remembers":
&lt;boolean&gt;}' -H "Content-type: application/json" -H "X-Api-Key:
@ -81,7 +81,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root
}}paywall/api/v1/paywalls/&lt;paywall_id&gt;/invoice -d '{"amount":
}}api/v1/paywalls/&lt;paywall_id&gt;/invoice -d '{"amount":
&lt;integer&gt;}' -H "Content-type: application/json"
</code>
</q-card-section>
@ -112,7 +112,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root
}}paywall/api/v1/paywalls/&lt;paywall_id&gt;/check_invoice -d
}}api/v1/paywalls/&lt;paywall_id&gt;/check_invoice -d
'{"payment_hash": &lt;string&gt;}' -H "Content-type: application/json"
</code>
</q-card-section>
@ -138,7 +138,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}paywall/api/v1/paywalls/&lt;paywall_id&gt; -H "X-Api-Key: {{
}}api/v1/paywalls/&lt;paywall_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>

View File

@ -0,0 +1,54 @@
<h1>Subdomains Extension</h1>
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
<img src="https://i.imgur.com/xOgapHr.png">
4. get Cloudflare API TOKEN
<img src="https://i.imgur.com/BZbktTy.png">
<img src="https://i.imgur.com/YDZpW7D.png">
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
<img src="https://i.imgur.com/hiauxeR.png">
## API Endpoints
- **Domains**
- GET /api/v1/domains
- POST /api/v1/domains
- PUT /api/v1/domains/<domain_id>
- DELETE /api/v1/domains/<domain_id>
- **Subdomains**
- GET /api/v1/subdomains
- POST /api/v1/subdomains/<domain_id>
- GET /api/v1/subdomains/<payment_hash>
- DELETE /api/v1/subdomains/<subdomain_id>
## 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

View File

@ -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))

View File

@ -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"

View File

@ -0,0 +1,6 @@
{
"name": "Subdomains",
"short_description": "Sell subdomains of your domain",
"icon": "domain",
"contributors": ["grmkris"]
}

View File

@ -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,))

View File

@ -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'))
);
"""
)

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,23 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="About lnSubdomains"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
lnSubdomains: Get paid sats to sell your subdomains
</h5>
<p>
Charge people for using your subdomain name...<br />
Are you the owner of <b>cool-domain.com</b> and want to sell
<i>cool-subdomain</i>.<b>cool-domain.com</b>
<br />
<small>
Created by, <a href="https://github.com/grmkris">Kris</a></small
>
</p>
</q-card-section>
</q-card>
</q-expansion-item>

View File

@ -0,0 +1,221 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h3 class="q-my-none">{{ domain_domain }}</h3>
<br />
<h5 class="q-my-none">{{ domain_desc }}</h5>
<br />
<q-form @submit="Invoice()" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.email"
type="email"
label="Your email (optional, if you want a reply)"
></q-input>
<q-select
dense
filled
v-model="formDialog.data.record_type"
:options="{{domain_allowed_record_types}}"
label="Record type"
></q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.subdomain"
type="text"
label="Subdomain you want"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.ip"
type="text"
label="Ip of your server"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.duration"
type="number"
label="Number of days"
>
</q-input>
<p>
Cost per day: {{ domain_cost }} sats<br />
{% raw %} Total cost: {{amountSats}} sats {% endraw %}
</p>
<div class="row q-mt-lg">
<q-btn
unelevated
color="deep-purple"
:disable="formDialog.data.subdomain == '' || formDialog.data.ip == '' || formDialog.data.duration == ''"
type="submit"
>Submit</q-btn
>
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="paymentReq"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
console.log('{{ domain_cost }}')
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
paymentReq: null,
redirectUrl: null,
formDialog: {
show: false,
data: {
ip: '',
subdomain: '',
duration: '',
email: '',
record_type: ''
}
},
receive: {
show: false,
status: 'pending',
paymentReq: null
}
}
},
computed: {
amountSats() {
var sats = this.formDialog.data.duration * parseInt('{{ domain_cost }}')
this.formDialog.data.sats = sats
return sats
}
},
methods: {
resetForm: function (e) {
e.preventDefault()
this.formDialog.data.subdomain = ''
this.formDialog.data.email = ''
this.formDialog.data.ip = ''
this.formDialog.data.duration = ''
this.formDialog.data.record_type = ''
},
closeReceiveDialog: function () {
var checker = this.receive.paymentChecker
dismissMsg()
clearInterval(paymentChecker)
setTimeout(function () {}, 10000)
},
Invoice: function () {
var self = this
axios
.post('/subdomains/api/v1/subdomains/{{ domain_id }}', {
domain: '{{ domain_id }}',
subdomain: self.formDialog.data.subdomain,
ip: self.formDialog.data.ip,
email: self.formDialog.data.email,
sats: self.formDialog.data.sats,
duration: parseInt(self.formDialog.data.duration),
record_type: self.formDialog.data.record_type
})
.then(function (response) {
self.paymentReq = response.data.payment_request
self.paymentCheck = response.data.payment_hash
dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
self.receive = {
show: true,
status: 'pending',
paymentReq: self.paymentReq
}
paymentChecker = setInterval(function () {
axios
.get('/subdomains/api/v1/subdomains/' + self.paymentCheck)
.then(function (res) {
console.log(res.data)
if (res.data.paid) {
clearInterval(paymentChecker)
self.receive = {
show: false,
status: 'complete',
paymentReq: null
}
dismissMsg()
console.log(self.formDialog)
self.formDialog.data.subdomain = ''
self.formDialog.data.email = ''
self.formDialog.data.ip = ''
self.formDialog.data.duration = ''
self.formDialog.data.record_type = ''
self.$q.notify({
type: 'positive',
message: 'Sent, thank you!',
icon: 'thumb_up'
})
console.log('END')
}
})
.catch(function (error) {
console.log(error)
LNbits.utils.notifyApiError(error)
})
}, 2000)
})
.catch(function (error) {
console.log(error)
LNbits.utils.notifyApiError(error)
})
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,545 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="deep-purple" @click="domainDialog.show = true"
>New Domain</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Domains</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportDomainsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="domains"
row-key="id"
:columns="domainsTable.columns"
:pagination.sync="domainsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.displayUrl"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateDomainDialog(props.row.id)"
icon="edit"
color="light-blue"
>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteDomain(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Subdomains</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportSubdomainsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="subdomains"
row-key="id"
:columns="subdomainsTable.columns"
:pagination.sync="subdomainsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props" v-if="props.row.paid">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="email"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'mailto:' + props.row.email"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteSubdomain(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits Subdomain extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "subdomains/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
</div>
<q-dialog v-model="domainDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="domainDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-select
dense
filled
v-model="domainDialog.data.allowed_record_types"
multiple
:options="dnsRecordTypes"
label="Allowed record types"
></q-select>
<q-input
filled
dense
emit-value
v-model.trim="domainDialog.data.domain"
type="text"
label="Domain name "
></q-input>
<q-input
filled
dense
v-model.trim="domainDialog.data.cf_token"
type="text"
label="Cloudflare API token"
>
</q-input>
<q-input
filled
dense
v-model.trim="domainDialog.data.cf_zone_id"
type="text"
label="Cloudflare Zone Id"
>
</q-input>
<q-input
filled
dense
v-model.trim="domainDialog.data.webhook"
type="text"
label="Webhook (optional)"
hint="A URL to be called whenever this link receives a payment."
></q-input>
<q-input
filled
dense
v-model.trim="domainDialog.data.description"
type="textarea"
label="Description "
>
</q-input>
<q-input
filled
dense
v-model.number="domainDialog.data.cost"
type="number"
label="Amount per day in satoshis"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="domainDialog.data.id"
unelevated
color="deep-purple"
type="submit"
>Update Form</q-btn
>
<q-btn
v-else
unelevated
color="deep-purple"
:disable="domainDialog.data.cost == null || domainDialog.data.cost < 0 || domainDialog.data.domain == null"
type="submit"
>Create Domain</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapLNDomain = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.displayUrl = ['/subdomains/', obj.id].join('')
console.log(obj)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
domains: [],
subdomains: [],
dnsRecordTypes: [
'A',
'AAAA',
'CNAME',
'HTTPS',
'TXT',
'SRV',
'LOC',
'MX',
'NS',
'SPF',
'CERT',
'DNSKEY',
'DS',
'NAPTR',
'SMIMEA',
'SSHFP',
'SVCB',
'TLSA',
'URI'
],
domainsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{
name: 'domain',
align: 'left',
label: 'Domain name',
field: 'domain'
},
{
name: 'allowed_record_types',
align: 'left',
label: 'Allowed record types',
field: 'allowed_record_types'
},
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
{
name: 'webhook',
align: 'left',
label: 'Webhook',
field: 'webhook'
},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'cost',
align: 'left',
label: 'Cost Per Day',
field: 'cost'
}
],
pagination: {
rowsPerPage: 10
}
},
subdomainsTable: {
columns: [
{
name: 'subdomain',
align: 'left',
label: 'Subdomain name',
field: 'subdomain'
},
{
name: 'domain',
align: 'left',
label: 'Domain name',
field: 'domain_name'
},
{
name: 'record_type',
align: 'left',
label: 'Record type',
field: 'record_type'
},
{
name: 'email',
align: 'left',
label: 'Email',
field: 'email'
},
{
name: 'ip',
align: 'left',
label: 'IP address',
field: 'ip'
},
{
name: 'sats',
align: 'left',
label: 'Sats paid',
field: 'sats'
},
{
name: 'duration',
align: 'left',
label: 'Duration in days',
field: 'duration'
},
{name: 'id', align: 'left', label: 'ID', field: 'id'}
],
pagination: {
rowsPerPage: 10
}
},
domainDialog: {
show: false,
data: {}
}
}
},
methods: {
getSubdomains: function () {
var self = this
LNbits.api
.request(
'GET',
'/subdomains/api/v1/subdomains?all_wallets',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.subdomains = response.data.map(function (obj) {
return mapLNDomain(obj)
})
})
},
deleteSubdomain: function (subdomainId) {
var self = this
var subdomains = _.findWhere(this.subdomains, {id: subdomainId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this subdomain')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/subdomain/api/v1/subdomains/' + subdomainId,
_.findWhere(self.g.user.wallets, {id: subdomains.wallet}).inkey
)
.then(function (response) {
self.subdomains = _.reject(self.subdomains, function (obj) {
return obj.id == subdomainId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportSubdomainsCSV: function () {
LNbits.utils.exportCSV(this.subdomainsTable.columns, this.subdomains)
},
getDomains: function () {
var self = this
LNbits.api
.request(
'GET',
'/subdomains/api/v1/domains?all_wallets',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.domains = response.data.map(function (obj) {
return mapLNDomain(obj)
})
})
},
sendFormData: function () {
var wallet = _.findWhere(this.g.user.wallets, {
id: this.domainDialog.data.wallet
})
var data = this.domainDialog.data
data.allowed_record_types = data.allowed_record_types.join(', ')
console.log(this.domainDialog)
if (data.id) {
this.updateDomain(wallet, data)
} else {
this.createDomain(wallet, data)
}
},
createDomain: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/subdomains/api/v1/domains', wallet.inkey, data)
.then(function (response) {
self.domains.push(mapLNDomain(response.data))
self.domainDialog.show = false
self.domainDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
updateDomainDialog: function (formId) {
var link = _.findWhere(this.domains, {id: formId})
console.log(link.id)
this.domainDialog.data = _.clone(link)
this.domainDialog.data.allowed_record_types = link.allowed_record_types.split(
', '
)
this.domainDialog.show = true
},
updateDomain: function (wallet, data) {
var self = this
console.log(data)
LNbits.api
.request(
'PUT',
'/subdomains/api/v1/domains/' + data.id,
wallet.inkey,
data
)
.then(function (response) {
self.domains = _.reject(self.domains, function (obj) {
return obj.id == data.id
})
self.domains.push(mapLNDomain(response.data))
self.domainDialog.show = false
self.domainDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteDomain: function (domainId) {
var self = this
var domains = _.findWhere(this.domains, {id: domainId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this domain link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/subdomains/api/v1/domains/' + domainId,
_.findWhere(self.g.user.wallets, {id: domains.wallet}).inkey
)
.then(function (response) {
self.domains = _.reject(self.domains, function (obj) {
return obj.id == domainId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportDomainsCSV: function () {
LNbits.utils.exportCSV(this.domainsTable.columns, this.domains)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getDomains()
this.getSubdomains()
}
}
})
</script>
{% endblock %}

View File

@ -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}(?<!-)\\.)+[A-Za-z]{2,6}"
# Compile the ReGex
p = re.compile(regex)
# If the string is empty
# return false
if str == None:
return False
# Return if the string
# matched the ReGex
if re.search(p, str):
return True
else:
return False
# Function to validate IP address
def isvalidIPAddress(str):
try:
socket.inet_aton(str)
return True
except socket.error:
return False

View File

@ -0,0 +1,31 @@
from quart import g, abort, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from http import HTTPStatus
from . import subdomains_ext
from .crud import get_domain
@subdomains_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("subdomains/index.html", user=g.user)
@subdomains_ext.route("/<domain_id>")
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,
)

View File

@ -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/<domain_id>", 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/<domain_id>", 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/<domain_id>", 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/<payment_hash>", 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/<subdomain_id>", 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

View File

@ -17,7 +17,7 @@
<code>[&lt;tpos_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>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:
&lt;invoice_key&gt;"
</code>
</q-card-section>
@ -42,7 +42,7 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}tpos/api/v1/tposs -d '{"name":
>curl -X POST {{ request.url_root }}api/v1/tposs -d '{"name":
&lt;string&gt;, "currency": &lt;string&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: &lt;admin_key&gt;"
</code>
@ -69,8 +69,8 @@
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}tpos/api/v1/tposs/&lt;tpos_id&gt; -H "X-Api-Key: &lt;admin_key&gt;"
>curl -X DELETE {{ request.url_root }}api/v1/tposs/&lt;tpos_id&gt; -H
"X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>

View File

@ -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

View File

@ -42,7 +42,7 @@
<code>JSON list of users</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>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 }}"
</code>
</q-card-section>
@ -65,7 +65,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}usermanager/api/v1/wallets/&lt;user_id&gt; -H "X-Api-Key: {{
}}api/v1/wallets/&lt;user_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
@ -88,7 +88,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}usermanager/api/v1/wallets&lt;wallet_id&gt; -H "X-Api-Key: {{
}}api/v1/wallets&lt;wallet_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
@ -128,7 +128,7 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>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": &lt;string&gt;,
"user_name": &lt;string&gt;}' -H "X-Api-Key: {{
g.user.wallets[0].inkey }}" -H "Content-type: application/json"
@ -165,7 +165,7 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}usermanager/api/v1/wallets -d
>curl -X POST {{ request.url_root }}api/v1/wallets -d
'{"user_id": &lt;string&gt;, "wallet_name": &lt;string&gt;,
"admin_id": "{{ g.user.id }}"}' -H "X-Api-Key: {{
g.user.wallets[0].inkey }}" -H "Content-type: application/json"
@ -190,7 +190,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}usermanager/api/v1/users/&lt;user_id&gt; -H "X-Api-Key: {{
}}api/v1/users/&lt;user_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
@ -208,7 +208,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}usermanager/api/v1/wallets/&lt;wallet_id&gt; -H "X-Api-Key: {{
}}api/v1/wallets/&lt;wallet_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
@ -230,7 +230,7 @@
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}usermanager/api/v1/extensions -d
>curl -X POST {{ request.url_root }}api/v1/extensions -d
'{"userid": &lt;string&gt;, "extension": &lt;string&gt;, "active":
&lt;integer&gt;}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
"Content-type: application/json"

View File

@ -22,7 +22,7 @@
<code>[&lt;withdraw_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>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 }}"
</code>
</q-card-section>
@ -50,7 +50,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}withdraw/api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key: {{
}}api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
@ -79,7 +79,7 @@
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}withdraw/api/v1/links -d
>curl -X POST {{ request.url_root }}api/v1/links -d
'{"title": &lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -H
@ -116,7 +116,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root
}}withdraw/api/v1/links/&lt;withdraw_id&gt; -d '{"title":
}}api/v1/links/&lt;withdraw_id&gt; -d '{"title":
&lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -H
@ -146,7 +146,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}withdraw/api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key: {{
}}api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>

View File

@ -140,7 +140,10 @@ window.LNbits = {
'bolt11',
'preimage',
'payment_hash',
'extra'
'extra',
'wallet_id',
'webhook',
'webhook_status'
],
data
)

View File

@ -204,6 +204,15 @@ Vue.component('lnbits-payment-details', {
<div class="col-3"><b>Payment hash</b>:</div>
<div class="col-9 text-wrap mono">{{ payment.payment_hash }}</div>
</div>
<div class="row" v-if="payment.webhook">
<div class="col-3"><b>Webhook</b>:</div>
<div class="col-9 text-wrap mono">
{{ payment.webhook }}
<q-badge :color="webhookStatusColor" text-color="white">
{{ webhookStatusText }}
</q-badge>
</div>
</div>
<div class="row" v-if="hasPreimage">
<div class="col-3"><b>Payment proof</b>:</div>
<div class="col-9 text-wrap mono">{{ payment.preimage }}</div>
@ -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
},

View File

@ -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)