mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-24 06:48:02 +01:00
Merge remote-tracking branch 'arcbtc/lnaddress' into FastAPI
This commit is contained in:
commit
b122debd8c
15 changed files with 2030 additions and 0 deletions
|
@ -1,7 +1,10 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
|
|
||||||
from .. import core_app
|
from .. import core_app
|
||||||
|
@ -9,6 +12,13 @@ from ..crud import get_standalone_payment
|
||||||
from ..tasks import api_invoice_listeners
|
from ..tasks import api_invoice_listeners
|
||||||
|
|
||||||
|
|
||||||
|
@core_app.get("/.well-known/lnurlp/{username}")
|
||||||
|
async def lnaddress(username: str, request: Request):
|
||||||
|
from lnbits.extensions.lnaddress.lnurl import lnurl_response
|
||||||
|
domain = request.client.host
|
||||||
|
return await lnurl_response(username, domain)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/public/v1/payment/{payment_hash}")
|
@core_app.get("/public/v1/payment/{payment_hash}")
|
||||||
async def api_public_payment_longpolling(payment_hash):
|
async def api_public_payment_longpolling(payment_hash):
|
||||||
payment = await get_standalone_payment(payment_hash)
|
payment = await get_standalone_payment(payment_hash)
|
||||||
|
|
68
lnbits/extensions/lnaddress/README.md
Normal file
68
lnbits/extensions/lnaddress/README.md
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
<h1>Lightning Address</h1>
|
||||||
|
<h2>Rent Lightning Addresses on your domain</h2>
|
||||||
|
LNAddress extension allows for someone to rent users lightning addresses on their domain.
|
||||||
|
|
||||||
|
The extension is muted by default on the .env file and needs the admin of the LNbits instance to meet a few requirements on the server.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Free Cloudflare account
|
||||||
|
- Cloudflare as a DNS server provider
|
||||||
|
- Cloudflare TOKEN and Cloudflare zone-ID where the domain is parked
|
||||||
|
|
||||||
|
The server must provide SSL/TLS certificates to domain owners. If using caddy, this can be easily achieved with the Caddyfife snippet:
|
||||||
|
|
||||||
|
```
|
||||||
|
:443 {
|
||||||
|
reverse_proxy localhost:5000
|
||||||
|
|
||||||
|
tls <your email>@example.com {
|
||||||
|
on_demand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
fill in with your email.
|
||||||
|
|
||||||
|
Certbot is also a possibity.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Before adding a domain, you need to add the domain to Cloudflare and get an API key and Secret key\
|
||||||
|
\
|
||||||
|
You can use the _Edit zone DNS_ template Cloudflare provides.\
|
||||||
|
\
|
||||||
|
Edit the template as you like, if only using one domain you can narrow the scope of the template\
|
||||||
|

|
||||||
|
|
||||||
|
2. Back on LNbits, click "ADD DOMAIN"\
|
||||||
|

|
||||||
|
|
||||||
|
3. Fill the form with the domain information\
|
||||||
|

|
||||||
|
|
||||||
|
- select your wallet - add your domain
|
||||||
|
- cloudflare keys
|
||||||
|
- an optional webhook to get notified
|
||||||
|
- the amount, in sats, you'll rent the addresses, per day
|
||||||
|
|
||||||
|
4. Your domains will show up on the _Domains_ section\
|
||||||
|
\
|
||||||
|
On the left side, is the link to share with users so they can rent an address on your domain. When someone creates an address, after pay, they will be shown on the _Addresses_ section\
|
||||||
|

|
||||||
|
|
||||||
|
5. Addresses get automatically purged if expired or unpaid, after 24 hours. After expiration date, users will be granted a 24 hours period to renew their address!
|
||||||
|
|
||||||
|
6. On the user/buyer side, the webpage will present the _Create_ or _Renew_ address tabs. On the Create tab:\
|
||||||
|

|
||||||
|
- optional email
|
||||||
|
- the alias or username they want on your domain
|
||||||
|
- the LNbits URL, if not the same instance (for example the user has an LNbits wallet on https://s.lnbits.com and is renting an address from https://lnbits.com)
|
||||||
|
- the _Admin key_ for the wallet
|
||||||
|
- how many days to rent a username for - bellow shows the per day cost and total cost the user will have to pay
|
||||||
|
7. On the Renew tab:\
|
||||||
|

|
||||||
|
- enter the Alias/username
|
||||||
|
- enter the wallet key
|
||||||
|
- press the _GET INFO_ button to retrieve your address data
|
||||||
|
- an expiration date will appear and the option to extend the duration of your address
|
28
lnbits/extensions/lnaddress/__init__.py
Normal file
28
lnbits/extensions/lnaddress/__init__.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
from lnbits.tasks import catch_everything_and_restart
|
||||||
|
|
||||||
|
db = Database("ext_lnaddress")
|
||||||
|
|
||||||
|
lnaddress_ext: APIRouter = APIRouter(
|
||||||
|
prefix="/lnaddress",
|
||||||
|
tags=["lnaddress"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def lnaddress_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/lnaddress/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .lnurl import * # noqa
|
||||||
|
from .tasks import wait_for_paid_invoices
|
||||||
|
from .views import * # noqa
|
||||||
|
from .views_api import * # noqa
|
||||||
|
|
||||||
|
|
||||||
|
def lnurlp_start():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
58
lnbits/extensions/lnaddress/cloudflare.py
Normal file
58
lnbits/extensions/lnaddress/cloudflare.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
from lnbits.extensions.lnaddress.models import Domains
|
||||||
|
import httpx, json
|
||||||
|
|
||||||
|
|
||||||
|
async def cloudflare_create_record(
|
||||||
|
domain: Domains, ip: 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",
|
||||||
|
}
|
||||||
|
|
||||||
|
cf_response = ""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
r = await client.post(
|
||||||
|
url,
|
||||||
|
headers=header,
|
||||||
|
json={
|
||||||
|
"type": "CNAME",
|
||||||
|
"name": domain.domain,
|
||||||
|
"content": ip,
|
||||||
|
"ttl": 0,
|
||||||
|
"proxied": False,
|
||||||
|
},
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
cf_response = json.loads(r.text)
|
||||||
|
except AssertionError:
|
||||||
|
cf_response = "Error occured"
|
||||||
|
return cf_response
|
||||||
|
|
||||||
|
|
||||||
|
async def cloudflare_deleterecord(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"
|
6
lnbits/extensions/lnaddress/config.json
Normal file
6
lnbits/extensions/lnaddress/config.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Lightning Address",
|
||||||
|
"short_description": "Sell LN addresses for your domain",
|
||||||
|
"icon": "alternate_email",
|
||||||
|
"contributors": ["talvasconcelos"]
|
||||||
|
}
|
193
lnbits/extensions/lnaddress/crud.py
Normal file
193
lnbits/extensions/lnaddress/crud.py
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import Addresses, CreateAddress, CreateDomain, Domains
|
||||||
|
|
||||||
|
|
||||||
|
async def create_domain(
|
||||||
|
data: CreateDomain
|
||||||
|
) -> Domains:
|
||||||
|
domain_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO lnaddress.domain (id, wallet, domain, webhook, cf_token, cf_zone_id, cost)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
domain_id,
|
||||||
|
data.wallet,
|
||||||
|
data.domain,
|
||||||
|
data.webhook,
|
||||||
|
data.cf_token,
|
||||||
|
data.cf_zone_id,
|
||||||
|
data.cost,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
new_domain = await get_domain(domain_id)
|
||||||
|
assert new_domain, "Newly created domain couldn't be retrieved"
|
||||||
|
return new_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 lnaddress.domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id)
|
||||||
|
)
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM lnaddress.domain WHERE id = ?", (domain_id,)
|
||||||
|
)
|
||||||
|
assert row, "Newly updated domain couldn't be retrieved"
|
||||||
|
return Domains(**row)
|
||||||
|
|
||||||
|
async def delete_domain(domain_id: str) -> None:
|
||||||
|
await db.execute("DELETE FROM lnaddress.domain WHERE id = ?", (domain_id,))
|
||||||
|
|
||||||
|
async def get_domain(domain_id: str) -> Optional[Domains]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM lnaddress.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 lnaddress.domain WHERE wallet IN ({q})", (*wallet_ids,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [Domains(**row) for row in rows]
|
||||||
|
|
||||||
|
## ADRESSES
|
||||||
|
|
||||||
|
async def create_address(
|
||||||
|
payment_hash: str,
|
||||||
|
wallet: str,
|
||||||
|
data: CreateAddress
|
||||||
|
) -> Addresses:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO lnaddress.address (id, wallet, domain, email, username, wallet_key, wallet_endpoint, sats, duration, paid)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
payment_hash,
|
||||||
|
wallet,
|
||||||
|
data.domain,
|
||||||
|
data.email,
|
||||||
|
data.username,
|
||||||
|
data.wallet_key,
|
||||||
|
data.wallet_endpoint,
|
||||||
|
data.sats,
|
||||||
|
data.duration,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
new_address = await get_address(payment_hash)
|
||||||
|
assert new_address, "Newly created address couldn't be retrieved"
|
||||||
|
return new_address
|
||||||
|
|
||||||
|
async def get_address(address_id: str) -> Optional[Addresses]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT a.* FROM lnaddress.address AS a INNER JOIN lnaddress.domain AS d ON a.id = ? AND a.domain = d.id",
|
||||||
|
(address_id,),
|
||||||
|
)
|
||||||
|
return Addresses(**row) if row else None
|
||||||
|
|
||||||
|
async def get_address_by_username(username: str, domain: str) -> Optional[Addresses]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT a.* FROM lnaddress.address AS a INNER JOIN lnaddress.domain AS d ON a.username = ? AND d.domain = ?",
|
||||||
|
# "SELECT * FROM lnaddress.address WHERE username = ? AND domain = ?",
|
||||||
|
(username, domain,),
|
||||||
|
)
|
||||||
|
print("ADD", row)
|
||||||
|
return Addresses(**row) if row else None
|
||||||
|
|
||||||
|
async def delete_address(address_id: str) -> None:
|
||||||
|
await db.execute("DELETE FROM lnaddress.address WHERE id = ?", (address_id,))
|
||||||
|
|
||||||
|
async def get_addresses(wallet_ids: Union[str, List[str]]) -> List[Addresses]:
|
||||||
|
if isinstance(wallet_ids, str):
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
|
q = ",".join(["?"] * len(wallet_ids))
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"SELECT * FROM lnaddress.address WHERE wallet IN ({q})",
|
||||||
|
(*wallet_ids,),
|
||||||
|
)
|
||||||
|
print([Addresses(**row) for row in rows])
|
||||||
|
return [Addresses(**row) for row in rows]
|
||||||
|
|
||||||
|
async def set_address_paid(payment_hash: str) -> Addresses:
|
||||||
|
_address = await get_address(payment_hash)
|
||||||
|
address = _address._asdict()
|
||||||
|
|
||||||
|
if address["paid"] == False:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE lnaddress.address
|
||||||
|
SET paid = true
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(payment_hash,),
|
||||||
|
)
|
||||||
|
|
||||||
|
new_address = await get_address(payment_hash)
|
||||||
|
assert new_address, "Newly paid address couldn't be retrieved"
|
||||||
|
return new_address
|
||||||
|
|
||||||
|
async def set_address_renewed(address_id: str, duration: int):
|
||||||
|
_address = await get_address(address_id)
|
||||||
|
address = _address._asdict()
|
||||||
|
|
||||||
|
extend_duration = int(address["duration"]) + duration
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE lnaddress.address
|
||||||
|
SET duration = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(extend_duration, address_id,),
|
||||||
|
)
|
||||||
|
updated_address = await get_address(address_id)
|
||||||
|
assert updated_address, "Renewed address couldn't be retrieved"
|
||||||
|
return updated_address
|
||||||
|
|
||||||
|
|
||||||
|
async def check_address_available(username: str, domain: str):
|
||||||
|
row, = await db.fetchone(
|
||||||
|
"SELECT COUNT(username) FROM lnaddress.address WHERE username = ? AND domain = ?",
|
||||||
|
(username, domain,),
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def purge_addresses(domain_id: str):
|
||||||
|
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM lnaddress.address WHERE domain = ?",
|
||||||
|
(domain_id, ),
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
r = Addresses(**row)._asdict()
|
||||||
|
|
||||||
|
start = datetime.fromtimestamp(r["time"])
|
||||||
|
paid = r["paid"]
|
||||||
|
pay_expire = now > start.timestamp() + 86400 #if payment wasn't made in 1 day
|
||||||
|
expired = now > (start + timedelta(days = r["duration"] + 1)).timestamp() #give user 1 day to topup is address
|
||||||
|
|
||||||
|
if not paid and pay_expire:
|
||||||
|
print("DELETE UNP_PAY_EXP", r["username"])
|
||||||
|
await delete_address(r["id"])
|
||||||
|
|
||||||
|
if paid and expired:
|
||||||
|
print("DELETE PAID_EXP", r["username"])
|
||||||
|
await delete_address(r["id"])
|
92
lnbits/extensions/lnaddress/lnurl.py
Normal file
92
lnbits/extensions/lnaddress/lnurl.py
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi.params import Query
|
||||||
|
from lnurl import ( # type: ignore
|
||||||
|
LnurlErrorResponse,
|
||||||
|
LnurlPayActionResponse,
|
||||||
|
LnurlPayResponse,
|
||||||
|
)
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from . import lnaddress_ext
|
||||||
|
from .crud import get_address, get_address_by_username, get_domain
|
||||||
|
|
||||||
|
|
||||||
|
async def lnurl_response(username: str, domain: str, request: Request):
|
||||||
|
address = await get_address_by_username(username, domain)
|
||||||
|
|
||||||
|
if not address:
|
||||||
|
return {"status": "ERROR", "reason": "Address not found."}
|
||||||
|
|
||||||
|
## CHECK IF USER IS STILL VALID/PAYING
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
start = datetime.fromtimestamp(address.time)
|
||||||
|
expiration = (start + timedelta(days = address.duration)).timestamp()
|
||||||
|
|
||||||
|
if now > expiration:
|
||||||
|
return LnurlErrorResponse(reason="Address has expired.").dict()
|
||||||
|
|
||||||
|
resp = LnurlPayResponse(
|
||||||
|
callback=request.url_for("lnaddress.lnurl_callback", address_id=address.id, _external=True),
|
||||||
|
min_sendable=1000,
|
||||||
|
max_sendable=1000000000,
|
||||||
|
metadata=await address.lnurlpay_metadata(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return resp.dict()
|
||||||
|
|
||||||
|
@lnaddress_ext.get("/lnurl/cb/{address_id}", name="lnaddress.lnurl_callback")
|
||||||
|
async def lnurl_callback(address_id, amount: int = Query(...)):
|
||||||
|
address = await get_address(address_id)
|
||||||
|
|
||||||
|
if not address:
|
||||||
|
return LnurlErrorResponse(
|
||||||
|
reason=f"Address not found"
|
||||||
|
).dict()
|
||||||
|
|
||||||
|
amount_received = amount
|
||||||
|
# min = 1000
|
||||||
|
# max = 1000000000
|
||||||
|
|
||||||
|
# if amount_received < min:
|
||||||
|
# return LnurlErrorResponse(
|
||||||
|
# reason=f"Amount {amount_received} is smaller than minimum."
|
||||||
|
# ).dict()
|
||||||
|
|
||||||
|
# elif amount_received > max:
|
||||||
|
# return jsonify(
|
||||||
|
# LnurlErrorResponse(
|
||||||
|
# reason=f"Amount {amount_received} is greater than maximum."
|
||||||
|
# ).dict()
|
||||||
|
# )
|
||||||
|
|
||||||
|
domain = await get_domain(address.domain)
|
||||||
|
|
||||||
|
base_url = address.wallet_endpoint[:-1] if address.wallet_endpoint.endswith('/') else address.wallet_endpoint
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
call = await client.post(
|
||||||
|
base_url + "/api/v1/payments",
|
||||||
|
headers={"X-Api-Key": address.wallet_key, "Content-Type": "application/json"},
|
||||||
|
json={
|
||||||
|
"out": False,
|
||||||
|
"amount": int(amount_received / 1000),
|
||||||
|
"description_hash": hashlib.sha256((await address.lnurlpay_metadata()).encode("utf-8")).hexdigest(),
|
||||||
|
"extra": {"tag": f"Payment to {address.username}@{domain.domain}"},
|
||||||
|
},
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
|
||||||
|
r = call.json()
|
||||||
|
except AssertionError as e:
|
||||||
|
return LnurlErrorResponse(reason="ERROR")
|
||||||
|
|
||||||
|
resp = LnurlPayActionResponse(
|
||||||
|
pr=r["payment_request"],
|
||||||
|
routes=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return resp.dict()
|
41
lnbits/extensions/lnaddress/migrations.py
Normal file
41
lnbits/extensions/lnaddress/migrations.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE lnaddress.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,
|
||||||
|
cost INTEGER NOT NULL,
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT """
|
||||||
|
+ db.timestamp_now
|
||||||
|
+ """
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
async def m002_addresses(db):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE lnaddress.address (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
wallet_key TEXT NOT NULL,
|
||||||
|
wallet_endpoint TEXT NOT NULL,
|
||||||
|
sats INTEGER NOT NULL,
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
paid BOOLEAN NOT NULL,
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT """
|
||||||
|
+ db.timestamp_now
|
||||||
|
+ """
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# async def m003_create_unique_indexes(db):
|
||||||
|
# await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS address_at_domain ON lnaddress.address (domain, username);")
|
53
lnbits/extensions/lnaddress/models.py
Normal file
53
lnbits/extensions/lnaddress/models.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi.params import Query
|
||||||
|
from lnurl.types import LnurlPayMetadata
|
||||||
|
from pydantic.main import BaseModel # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class CreateDomain(BaseModel):
|
||||||
|
wallet: str = Query(...)
|
||||||
|
domain: str = Query(...)
|
||||||
|
cf_token: str = Query(...)
|
||||||
|
cf_zone_id: str = Query(...)
|
||||||
|
webhook: str = Query(None)
|
||||||
|
cost: int = Query(..., ge=0)
|
||||||
|
|
||||||
|
class Domains(BaseModel):
|
||||||
|
id: str
|
||||||
|
wallet: str
|
||||||
|
domain: str
|
||||||
|
cf_token: str
|
||||||
|
cf_zone_id: str
|
||||||
|
webhook: str
|
||||||
|
cost: int
|
||||||
|
time: int
|
||||||
|
|
||||||
|
class CreateAddress(BaseModel):
|
||||||
|
domain: str = Query(...)
|
||||||
|
username: str = Query(...)
|
||||||
|
email: str = Query(None)
|
||||||
|
wallet_endpoint: str = Query(...)
|
||||||
|
wallet_key: str = Query(...)
|
||||||
|
sats: int = Query(..., ge=0)
|
||||||
|
duration: int = Query(..., ge=1)
|
||||||
|
|
||||||
|
class Addresses(BaseModel):
|
||||||
|
id: str
|
||||||
|
wallet: str
|
||||||
|
domain: str
|
||||||
|
email: str
|
||||||
|
username: str
|
||||||
|
wallet_key: str
|
||||||
|
wallet_endpoint: str
|
||||||
|
sats: int
|
||||||
|
duration: int
|
||||||
|
paid: bool
|
||||||
|
time: int
|
||||||
|
|
||||||
|
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||||
|
text = f"Payment to {self.username}"
|
||||||
|
metadata = [["text/plain", text]]
|
||||||
|
|
||||||
|
return LnurlPayMetadata(json.dumps(metadata))
|
59
lnbits/extensions/lnaddress/tasks.py
Normal file
59
lnbits/extensions/lnaddress/tasks.py
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
|
||||||
|
from .crud import get_address, get_domain, set_address_paid, set_address_renewed
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_paid_invoices():
|
||||||
|
invoice_queue = asyncio.Queue()
|
||||||
|
register_invoice_listener(invoice_queue)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
payment = await invoice_queue.get()
|
||||||
|
await on_invoice_paid(payment)
|
||||||
|
|
||||||
|
|
||||||
|
async def call_webhook_on_paid(payment_hash):
|
||||||
|
### Use webhook to notify about cloudflare registration
|
||||||
|
address = await get_address(payment_hash)
|
||||||
|
domain = await get_domain(address.domain)
|
||||||
|
|
||||||
|
if not domain.webhook:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
r = await client.post(
|
||||||
|
domain.webhook,
|
||||||
|
json={
|
||||||
|
"domain": domain.domain,
|
||||||
|
"address": address.username,
|
||||||
|
"email": address.email,
|
||||||
|
"cost": str(address.sats) + " sats",
|
||||||
|
"duration": str(address.duration) + " days",
|
||||||
|
},
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
except AssertionError:
|
||||||
|
webhook = None
|
||||||
|
|
||||||
|
|
||||||
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
|
if "lnaddress" == payment.extra.get("tag"):
|
||||||
|
|
||||||
|
await payment.set_pending(False)
|
||||||
|
await set_address_paid(payment_hash=payment.payment_hash)
|
||||||
|
await call_webhook_on_paid(payment.payment_hash)
|
||||||
|
|
||||||
|
elif "renew lnaddress" == payment.extra.get("tag"):
|
||||||
|
|
||||||
|
await payment.set_pending(False)
|
||||||
|
await set_address_renewed(address_id=payment.extra["id"], duration=payment.extra["duration"])
|
||||||
|
await call_webhook_on_paid(payment.payment_hash)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return
|
174
lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html
Normal file
174
lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="About lnAddress"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
|
lnAddress: Get paid sats to sell lightning addresses on your domains
|
||||||
|
</h5>
|
||||||
|
<p>
|
||||||
|
Charge people for using your domain name...<br />
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/lnaddress"
|
||||||
|
>More details</a
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
<small>
|
||||||
|
Created by,
|
||||||
|
<a href="https://twitter.com/talvasconcelos">talvasconcelos</a></small
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="GET domains">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-light-blue">GET</span>
|
||||||
|
lnaddress/api/v1/domains</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<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 }}lnaddress/api/v1/domains -H
|
||||||
|
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="POST domain">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-light-green">POST</span>
|
||||||
|
/lnAddress/api/v1/domains</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code
|
||||||
|
>{"X-Api-Key": <string>, "Content-type":
|
||||||
|
"application/json"}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Body (application/json) - "wallet" is a YOUR wallet ID
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"wallet": <string>, "domain": <string>, "cf_token":
|
||||||
|
<string>,"cf_zone_id": <string>,"webhook": <Optional
|
||||||
|
string> ,"cost": <integer>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 201 CREATED (application/json)
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"id": <string>, "wallet": <string>, "domain":
|
||||||
|
<string>, "webhook": <string>, "cf_token": <string>,
|
||||||
|
"cf_zone_id": <string>, "cost": <integer>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root }}lnaddress/api/v1/domains -d
|
||||||
|
'{"wallet": "{{ user.wallets[0].id }}", "domain": <string>,
|
||||||
|
"cf_token": <string>,"cf_zone_id": <string>,"webhook":
|
||||||
|
<Optional string> ,"cost": <integer>}' -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}" -H "Content-type: application/json"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="DELETE domain">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-red">DELETE</span>
|
||||||
|
/lnaddress/api/v1/domains/<domain_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <string>}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X DELETE {{ request.url_root
|
||||||
|
}}lnaddress/api/v1/domains/<domain_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="GET addresses">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-light-blue">GET</span>
|
||||||
|
lnaddress/api/v1/addresses</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>JSON list of addresses</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}lnaddress/api/v1/addresses -H
|
||||||
|
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="GET address info">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-light-blue">GET</span>
|
||||||
|
lnaddress/api/v1/address/<domain>/<username>/<wallet_key></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>JSON list of addresses</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root
|
||||||
|
}}lnaddress/api/v1/address/<domain>/<username>/<wallet_key>
|
||||||
|
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="POST address">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-green">POST</span>
|
||||||
|
/lnaddress/api/v1/address/<domain_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <string>}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.url_root
|
||||||
|
}}lnaddress/api/v1/address/<domain_id> -d '{"domain":
|
||||||
|
<string>, "username": <string>,"email": <Optional
|
||||||
|
string>, "wallet_endpoint": <string>, "wallet_key":
|
||||||
|
<string>, "sats": <integer> "duration": <integer>,}'
|
||||||
|
-H "X-Api-Key: {{ user.wallets[0].inkey }}" -H "Content-type:
|
||||||
|
application/json"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
436
lnbits/extensions/lnaddress/templates/lnaddress/display.html
Normal file
436
lnbits/extensions/lnaddress/templates/lnaddress/display.html
Normal file
|
@ -0,0 +1,436 @@
|
||||||
|
{% 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">
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<div class="q-gutter-y-md">
|
||||||
|
<q-tabs v-model="tab" active-color="primary">
|
||||||
|
<q-tab
|
||||||
|
name="create"
|
||||||
|
label="Create"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
<q-tab
|
||||||
|
name="renew"
|
||||||
|
label="Renew"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-tab-panels v-model="tab" animated>
|
||||||
|
<q-tab-panel name="create">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<h3 class="q-my-none">{{ domain_domain }}</h3>
|
||||||
|
<br />
|
||||||
|
<h6 class="q-my-none">
|
||||||
|
Your Lightning Address: {% raw
|
||||||
|
%}{{this.formDialog.data.username}}{% endraw %}@{{domain_domain}}
|
||||||
|
</h6>
|
||||||
|
<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-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.username"
|
||||||
|
type="text"
|
||||||
|
label="Alias/username"
|
||||||
|
:rules="[
|
||||||
|
val => checkUsername || 'Sorry, alias already taken',
|
||||||
|
val => isValidUsername || 'Alias is not valid'
|
||||||
|
]"
|
||||||
|
lazy-rules
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.wallet_endpoint"
|
||||||
|
type="text"
|
||||||
|
label="Endpoint of LNbits instance, defaults to this instance"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.wallet_key"
|
||||||
|
type="text"
|
||||||
|
label="Admin key for your wallet"
|
||||||
|
>
|
||||||
|
</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="primary"
|
||||||
|
:disable="!formDialog.data.username || !formDialog.data.wallet_key || !formDialog.data.duration || !checkUsername"
|
||||||
|
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-tab-panel>
|
||||||
|
<q-tab-panel name="renew">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<h3 class="q-my-none">{{ domain_domain }}</h3>
|
||||||
|
<br />
|
||||||
|
<h6 class="q-my-none">
|
||||||
|
Renew your Lightning Address: {% raw
|
||||||
|
%}{{this.formDialog.data.username}}{% endraw %}@{{domain_domain}}
|
||||||
|
</h6>
|
||||||
|
<br />
|
||||||
|
<q-form @submit="renewAddress()" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="renewDialog.data.username"
|
||||||
|
type="text"
|
||||||
|
label="Alias/username"
|
||||||
|
:rules="[
|
||||||
|
val => isValidUsername || 'Alias is not valid'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="renewDialog.data.wallet_key"
|
||||||
|
type="text"
|
||||||
|
label="Admin key for your wallet"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<div>
|
||||||
|
<div v-if="renewDialog.info">
|
||||||
|
{% raw %}
|
||||||
|
<p>
|
||||||
|
<strong>LN Address:</strong>
|
||||||
|
<span
|
||||||
|
>{{renewDialog.data.username}}@{{renewDialog.data.domain}}</span
|
||||||
|
>
|
||||||
|
<br />
|
||||||
|
<span>Expires at: {{renewDialog.data.expiration}}</span>
|
||||||
|
</p>
|
||||||
|
{% endraw %}
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
v-if="!renewDialog.info"
|
||||||
|
:disable="!renewDialog.data.username || !renewDialog.data.wallet_key"
|
||||||
|
@click="getUserInfo()"
|
||||||
|
>Get Info</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-if="renewDialog.info"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="renewDialog.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="primary"
|
||||||
|
:disable="!renewDialog.data.username || !renewDialog.data.wallet_key || !renewDialog.data.duration || !isValidUsername"
|
||||||
|
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-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
</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>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
paymentReq: null,
|
||||||
|
redirectUrl: null,
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
renewDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {},
|
||||||
|
info: false
|
||||||
|
},
|
||||||
|
receive: {
|
||||||
|
show: false,
|
||||||
|
status: 'pending',
|
||||||
|
paymentReq: null
|
||||||
|
},
|
||||||
|
tab: 'create',
|
||||||
|
wallet: {
|
||||||
|
inkey: ''
|
||||||
|
},
|
||||||
|
cancelListener: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
amountSats() {
|
||||||
|
let dialog = this.renewDialog.info ? this.renewDialog : this.formDialog
|
||||||
|
if (!dialog.data.duration) return 0
|
||||||
|
let sats = dialog.data.duration * parseInt('{{ domain_cost }}')
|
||||||
|
dialog.data.sats = parseInt(sats)
|
||||||
|
return sats
|
||||||
|
},
|
||||||
|
checkUsername: async function () {
|
||||||
|
let username = this.formDialog.data.username
|
||||||
|
if (!this.isValidUsername) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let available = await axios
|
||||||
|
.get(
|
||||||
|
`/lnaddress/api/v1/address/availabity/${'{{domain_id}}'}/${username}`
|
||||||
|
)
|
||||||
|
.then(res => {
|
||||||
|
return res.data < 1
|
||||||
|
})
|
||||||
|
console.log(available)
|
||||||
|
return available
|
||||||
|
},
|
||||||
|
isValidUsername: function () {
|
||||||
|
let username = this.formDialog.data.username
|
||||||
|
return /^[a-z0-9_\.]+$/.test(username)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
resetForm: function (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.formDialog.data = {}
|
||||||
|
this.renewDialog.data = {}
|
||||||
|
this.renewDialog.info = false
|
||||||
|
},
|
||||||
|
|
||||||
|
closeReceiveDialog: function () {
|
||||||
|
let checker = this.startPaymentNotifier
|
||||||
|
dismissMsg()
|
||||||
|
if (this.tab == 'create') {
|
||||||
|
clearInterval(paymentChecker)
|
||||||
|
}
|
||||||
|
setTimeout(function () {}, 10000)
|
||||||
|
},
|
||||||
|
getUserInfo() {
|
||||||
|
let {username, wallet_key} = this.renewDialog.data
|
||||||
|
axios
|
||||||
|
.get(
|
||||||
|
`/lnaddress/api/v1/address/{{ domain_domain }}/${username}/${wallet_key}`
|
||||||
|
)
|
||||||
|
.then(res => {
|
||||||
|
if (res) {
|
||||||
|
let dt = {}
|
||||||
|
let result = new Date(res.data.time * 1000)
|
||||||
|
dt.start = new Date(res.data.time * 1000)
|
||||||
|
dt.expiration = moment(
|
||||||
|
result.setDate(result.getDate() + res.data.duration)
|
||||||
|
).format('dddd, MMMM Do YYYY, h:mm:ss a')
|
||||||
|
dt.domain = '{{domain_domain}}'
|
||||||
|
dt.wallet_endpoint = res.data.wallet_endpoint
|
||||||
|
this.renewDialog.data = {
|
||||||
|
...this.renewDialog.data,
|
||||||
|
...dt
|
||||||
|
}
|
||||||
|
this.renewDialog.info = true
|
||||||
|
console.log(this.renewDialog)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
renewAddress() {
|
||||||
|
let {data} = this.renewDialog
|
||||||
|
data.duration = parseInt(data.duration)
|
||||||
|
|
||||||
|
axios
|
||||||
|
.put(
|
||||||
|
'/lnaddress/api/v1/address/{{ domain_id }}/' +
|
||||||
|
data.username +
|
||||||
|
'/' +
|
||||||
|
data.wallet_key,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.paymentReq = response.data.payment_request
|
||||||
|
this.paymentCheck = response.data.payment_hash
|
||||||
|
|
||||||
|
dismissMsg = this.$q.notify({
|
||||||
|
timeout: 0,
|
||||||
|
message: 'Waiting for payment...'
|
||||||
|
})
|
||||||
|
|
||||||
|
this.receive = {
|
||||||
|
show: true,
|
||||||
|
status: 'pending',
|
||||||
|
paymentReq: this.paymentReq
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
},
|
||||||
|
startPaymentNotifier() {
|
||||||
|
this.cancelListener()
|
||||||
|
|
||||||
|
this.cancelListener = LNbits.events.onInvoicePaid(
|
||||||
|
this.wallet,
|
||||||
|
payment => {
|
||||||
|
this.receive = {
|
||||||
|
show: false,
|
||||||
|
status: 'complete',
|
||||||
|
paymentReq: null
|
||||||
|
}
|
||||||
|
dismissMsg()
|
||||||
|
|
||||||
|
this.renewDialog.data = {}
|
||||||
|
this.renewDialog.info = false
|
||||||
|
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Sent, thank you!',
|
||||||
|
icon: 'thumb_up'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Invoice: function () {
|
||||||
|
let {data} = this.formDialog
|
||||||
|
data.domain = '{{ domain_id }}'
|
||||||
|
if (data.wallet_endpoint == '') {
|
||||||
|
data.wallet_endpoint = null
|
||||||
|
}
|
||||||
|
data.wallet_endpoint = data.wallet_endpoint ?? '{{ request.url_root }}'
|
||||||
|
data.duration = parseInt(data.duration)
|
||||||
|
console.log('data', data)
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post('/lnaddress/api/v1/address/{{ domain_id }}', data)
|
||||||
|
.then(response => {
|
||||||
|
this.paymentReq = response.data.payment_request
|
||||||
|
this.paymentCheck = response.data.payment_hash
|
||||||
|
|
||||||
|
dismissMsg = this.$q.notify({
|
||||||
|
timeout: 0,
|
||||||
|
message: 'Waiting for payment...'
|
||||||
|
})
|
||||||
|
|
||||||
|
this.receive = {
|
||||||
|
show: true,
|
||||||
|
status: 'pending',
|
||||||
|
paymentReq: this.paymentReq
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentChecker = setInterval(() => {
|
||||||
|
axios
|
||||||
|
.get(`/lnaddress/api/v1/addresses/${this.paymentCheck}`)
|
||||||
|
.then(res => {
|
||||||
|
console.log('pay_check', res.data)
|
||||||
|
if (res.data.paid) {
|
||||||
|
clearInterval(paymentChecker)
|
||||||
|
this.receive = {
|
||||||
|
show: false,
|
||||||
|
status: 'complete',
|
||||||
|
paymentReq: null
|
||||||
|
}
|
||||||
|
dismissMsg()
|
||||||
|
|
||||||
|
console.log(this.formDialog)
|
||||||
|
this.formDialog.data = {}
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Sent, thank you!',
|
||||||
|
icon: 'thumb_up'
|
||||||
|
})
|
||||||
|
console.log('END')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
console.log(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
}, 5000)
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
console.log(error)
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.wallet.inkey = '{{domain_wallet_inkey}}'
|
||||||
|
this.startPaymentNotifier()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
507
lnbits/extensions/lnaddress/templates/lnaddress/index.html
Normal file
507
lnbits/extensions/lnaddress/templates/lnaddress/index.html
Normal file
|
@ -0,0 +1,507 @@
|
||||||
|
{% 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="primary" @click="domainDialog.show = true"
|
||||||
|
>Add 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">Addresses</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exportAddressesCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="addresses"
|
||||||
|
row-key="id"
|
||||||
|
:columns="addressesTable.columns"
|
||||||
|
:pagination.sync="addressesTable.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="deleteAddress(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{{SITE_TITLE}} LN Address extension
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "lnaddress/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</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-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model.trim="domainDialog.data.domain"
|
||||||
|
type="text"
|
||||||
|
label="Domain name "
|
||||||
|
><q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
|
||||||
|
>The domain to use ex: "example.com"</q-tooltip
|
||||||
|
></q-input
|
||||||
|
>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="domainDialog.data.cf_token"
|
||||||
|
type="text"
|
||||||
|
label="Cloudflare API token"
|
||||||
|
>
|
||||||
|
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
|
||||||
|
>Your API key in cloudflare</q-tooltip
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="domainDialog.data.cf_zone_id"
|
||||||
|
type="text"
|
||||||
|
label="Cloudflare Zone Id"
|
||||||
|
>
|
||||||
|
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
|
||||||
|
>Create a "Edit zone DNS" API token in cloudflare</q-tooltip
|
||||||
|
>
|
||||||
|
</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.number="domainDialog.data.cost"
|
||||||
|
type="number"
|
||||||
|
label="Amount per day in satoshis"
|
||||||
|
><q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
|
||||||
|
>How much to charge per day</q-tooltip
|
||||||
|
></q-input
|
||||||
|
>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
v-if="domainDialog.data.id"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
>Update Form</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
: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>
|
||||||
|
const mapLNDomain = obj => {
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
|
||||||
|
obj.displayUrl = ['/lnaddress/', obj.id].join('')
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
domains: [],
|
||||||
|
addresses: [],
|
||||||
|
domainDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
domainsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{
|
||||||
|
name: 'domain',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Domain name',
|
||||||
|
field: 'domain'
|
||||||
|
},
|
||||||
|
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
|
||||||
|
{
|
||||||
|
name: 'webhook',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Webhook',
|
||||||
|
field: 'webhook'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cost',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Cost Per Day',
|
||||||
|
field: 'cost'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addressesTable: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'username',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Alias/username',
|
||||||
|
field: 'username'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'domain',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Domain name',
|
||||||
|
field: 'domain'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'email',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Email',
|
||||||
|
field: 'email'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
//DOMAINS
|
||||||
|
|
||||||
|
getDomains: function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/lnaddress/api/v1/domains?all_wallets=true',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.domains = response.data.map(function (obj) {
|
||||||
|
return mapLNDomain(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sendFormData: function () {
|
||||||
|
let wallet = _.findWhere(this.g.user.wallets, {
|
||||||
|
id: this.domainDialog.data.wallet
|
||||||
|
})
|
||||||
|
let data = this.domainDialog.data
|
||||||
|
if (data.id) {
|
||||||
|
this.updateDomain(wallet, data)
|
||||||
|
} else {
|
||||||
|
this.createDomain(wallet, data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createDomain: function (wallet, data) {
|
||||||
|
var self = this
|
||||||
|
console.log(data)
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/lnaddress/api/v1/domains', wallet.inkey, data)
|
||||||
|
.then(response => {
|
||||||
|
this.domains.push(mapLNDomain(response.data))
|
||||||
|
this.domainDialog.show = false
|
||||||
|
this.domainDialog.data = {}
|
||||||
|
})
|
||||||
|
.catch(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.show = true
|
||||||
|
},
|
||||||
|
updateDomain: function (wallet, data) {
|
||||||
|
console.log(data)
|
||||||
|
if (!data.webhook) {
|
||||||
|
delete data.webhook
|
||||||
|
}
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/lnaddress/api/v1/domains/' + data.id,
|
||||||
|
wallet.inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.domains = _.reject(this.domains, function (obj) {
|
||||||
|
return obj.id == data.id
|
||||||
|
})
|
||||||
|
this.domains.push(mapLNDomain(response.data))
|
||||||
|
this.domainDialog.show = false
|
||||||
|
this.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',
|
||||||
|
'/lnaddress/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)
|
||||||
|
},
|
||||||
|
|
||||||
|
//ADDRESSES
|
||||||
|
|
||||||
|
getAddresses: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/lnaddress/api/v1/addresses?all_wallets=true',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.addresses = response.data
|
||||||
|
.filter(d => d.paid)
|
||||||
|
.map(function (obj) {
|
||||||
|
// obj.domain_name = this.domains.find(d => d.id == obj.domain)
|
||||||
|
return mapLNDomain(obj)
|
||||||
|
})
|
||||||
|
console.log(self.addresses)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteAddress: function (addressId) {
|
||||||
|
let self = this
|
||||||
|
let addresses = _.findWhere(this.addresses, {id: addressId})
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this LN address')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/lnaddress/api/v1/addresses/' + addressId,
|
||||||
|
_.findWhere(self.g.user.wallets, {id: addresses.wallet}).inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.addresses = _.reject(self.addresses, function (obj) {
|
||||||
|
return obj.id == addressId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportAddressesCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(this.addressesTable.columns, this.addresses)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
if (this.g.user.wallets.length) {
|
||||||
|
this.getDomains()
|
||||||
|
this.getAddresses()
|
||||||
|
}
|
||||||
|
// var self = this
|
||||||
|
//
|
||||||
|
// // axios is available for making requests
|
||||||
|
// axios({
|
||||||
|
// method: 'GET',
|
||||||
|
// url: '/example/api/v1/tools',
|
||||||
|
// headers: {
|
||||||
|
// 'X-example-header': 'not-used'
|
||||||
|
// }
|
||||||
|
// }).then(function (response) {
|
||||||
|
// self.tools = response.data
|
||||||
|
// })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
43
lnbits/extensions/lnaddress/views.py
Normal file
43
lnbits/extensions/lnaddress/views.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_wallet
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
||||||
|
from . import lnaddress_ext, lnaddress_renderer
|
||||||
|
from .crud import get_domain, purge_addresses
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@lnaddress_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return lnaddress_renderer().TemplateResponse("lnaddress/index.html", {"request": request, "user": user.dict()})
|
||||||
|
|
||||||
|
@lnaddress_ext.get("/{domain_id}")
|
||||||
|
async def display(domain_id, request: Request):
|
||||||
|
domain = await get_domain(domain_id)
|
||||||
|
if not domain:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
await purge_addresses(domain_id, response_class=HTMLResponse)
|
||||||
|
|
||||||
|
wallet = await get_wallet(domain.wallet)
|
||||||
|
|
||||||
|
return lnaddress_renderer().TemplateResponse(
|
||||||
|
"lnaddress/display.html",{
|
||||||
|
"request": request,
|
||||||
|
"domain_id":domain.id,
|
||||||
|
"domain_domain": domain.domain,
|
||||||
|
"domain_cost": domain.cost,
|
||||||
|
"domain_wallet_inkey": wallet.inkey
|
||||||
|
}
|
||||||
|
)
|
262
lnbits/extensions/lnaddress/views_api.py
Normal file
262
lnbits/extensions/lnaddress/views_api.py
Normal file
|
@ -0,0 +1,262 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.params import Depends, Query
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.core.services import check_invoice_status, create_invoice
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
|
from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain
|
||||||
|
|
||||||
|
from . import lnaddress_ext
|
||||||
|
from .cloudflare import cloudflare_create_record, cloudflare_deleterecord
|
||||||
|
from .crud import (
|
||||||
|
check_address_available,
|
||||||
|
create_address,
|
||||||
|
create_domain,
|
||||||
|
delete_address,
|
||||||
|
delete_domain,
|
||||||
|
get_address,
|
||||||
|
get_address_by_username,
|
||||||
|
get_addresses,
|
||||||
|
get_domain,
|
||||||
|
get_domains,
|
||||||
|
update_domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# DOMAINS
|
||||||
|
@lnaddress_ext.get("/api/v1/domains")
|
||||||
|
async def api_domains(
|
||||||
|
g: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
all_wallets: bool = Query(False),
|
||||||
|
):
|
||||||
|
wallet_ids = [g.wallet.id]
|
||||||
|
|
||||||
|
if all_wallets:
|
||||||
|
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||||
|
|
||||||
|
return [domain.dict() for domain in await get_domains(wallet_ids)]
|
||||||
|
|
||||||
|
|
||||||
|
@lnaddress_ext.post("/api/v1/domains")
|
||||||
|
@lnaddress_ext.put("/api/v1/domains/{domain_id}")
|
||||||
|
async def api_domain_create(request: Request,data: CreateDomain, domain_id=None, g: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
if domain_id:
|
||||||
|
domain = await get_domain(domain_id)
|
||||||
|
|
||||||
|
if not domain:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Domain does not exist.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if domain.wallet != g.wallet.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Not your domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
domain = await update_domain(domain_id, **data.dict())
|
||||||
|
else:
|
||||||
|
|
||||||
|
domain = await create_domain(data=data)
|
||||||
|
root_url = urlparse(request.url.path).netloc
|
||||||
|
#root_url = request.url_root
|
||||||
|
|
||||||
|
cf_response = await cloudflare_create_record(
|
||||||
|
domain=domain,
|
||||||
|
ip=root_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not cf_response or cf_response["success"] != True:
|
||||||
|
await delete_domain(domain.id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Problem with cloudflare: " + cf_response["errors"][0]["message"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return domain.dict()
|
||||||
|
|
||||||
|
@lnaddress_ext.delete("/api/v1/domains/{domain_id}")
|
||||||
|
async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
domain = await get_domain(domain_id)
|
||||||
|
|
||||||
|
if not domain:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Domain does not exist.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if domain.wallet != g.wallet.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Not your domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_domain(domain_id)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
||||||
|
# ADDRESSES
|
||||||
|
|
||||||
|
@lnaddress_ext.get("/api/v1/addresses")
|
||||||
|
async def api_addresses(
|
||||||
|
g: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
all_wallets: bool = Query(False),
|
||||||
|
):
|
||||||
|
wallet_ids = [g.wallet.id]
|
||||||
|
|
||||||
|
if all_wallets:
|
||||||
|
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||||
|
|
||||||
|
return [address.dict() for address in await get_addresses(wallet_ids)]
|
||||||
|
|
||||||
|
@lnaddress_ext.get("/api/v1/address/{domain}/{username}/{wallet_key}")
|
||||||
|
async def api_get_user_info(username, wallet_key, domain):
|
||||||
|
address = await get_address_by_username(username, domain)
|
||||||
|
|
||||||
|
if not address:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Address does not exist.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if address.wallet_key != wallet_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Incorrect user/wallet information.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return address.dict()
|
||||||
|
|
||||||
|
@lnaddress_ext.get("/api/v1/address/availabity/{domain_id}/{username}")
|
||||||
|
async def api_check_available_username(domain_id, username):
|
||||||
|
used_username = await check_address_available(username, domain_id)
|
||||||
|
|
||||||
|
return used_username
|
||||||
|
|
||||||
|
@lnaddress_ext.post("/api/v1/address/{domain_id}")
|
||||||
|
@lnaddress_ext.put("/api/v1/address/{domain_id}/{user}/{wallet_key}")
|
||||||
|
async def api_lnaddress_make_address(domain_id, data: CreateAddress, user=None, wallet_key=None):
|
||||||
|
domain = await get_domain(domain_id)
|
||||||
|
|
||||||
|
# If the request is coming for the non-existant domain
|
||||||
|
if not domain:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="The domain does not exist.",
|
||||||
|
)
|
||||||
|
|
||||||
|
domain_cost = domain[6]
|
||||||
|
sats = data.sats
|
||||||
|
|
||||||
|
## FAILSAFE FOR CREATING ADDRESSES BY API
|
||||||
|
if(domain_cost * data.duration != data.sats):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="The amount is not correct. Either 'duration', or 'sats' are wrong.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
print("USER", user, domain.domain)
|
||||||
|
address = await get_address_by_username(user, domain.domain)
|
||||||
|
|
||||||
|
if not address:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="The address does not exist.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if address.wallet_key != wallet_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Not your address.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=domain.wallet,
|
||||||
|
amount=data.sats,
|
||||||
|
memo=f"Renew {data.username}@{domain.domain} for {sats} sats for {data.duration} more days",
|
||||||
|
extra={
|
||||||
|
"tag": "renew lnaddress",
|
||||||
|
"id": address.id,
|
||||||
|
"duration": data.duration
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
used_username = await check_address_available(data.username, data.domain)
|
||||||
|
# If username is already taken
|
||||||
|
if used_username:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Alias/username already taken.",
|
||||||
|
)
|
||||||
|
|
||||||
|
## ALL OK - create an invoice and return it to the user
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=domain.wallet,
|
||||||
|
amount=sats,
|
||||||
|
memo=f"LNAddress {data.username}@{domain.domain} for {sats} sats for {data.duration} days",
|
||||||
|
extra={"tag": "lnaddress"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
address = await create_address(
|
||||||
|
payment_hash=payment_hash, wallet=domain.wallet, data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
if not address:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="LNAddress could not be fetched.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||||
|
|
||||||
|
@lnaddress_ext.get("/api/v1/addresses/{payment_hash}")
|
||||||
|
async def api_address_send_address(payment_hash):
|
||||||
|
address = await get_address(payment_hash)
|
||||||
|
domain = await get_domain(address.domain)
|
||||||
|
try:
|
||||||
|
status = await check_invoice_status(domain.wallet, payment_hash)
|
||||||
|
is_paid = not status.pending
|
||||||
|
except Exception as e:
|
||||||
|
return {"paid": False, 'error': str(e)}
|
||||||
|
|
||||||
|
if is_paid:
|
||||||
|
return {"paid": True}
|
||||||
|
|
||||||
|
return {"paid": False}
|
||||||
|
|
||||||
|
@lnaddress_ext.delete("/api/v1/addresses/{address_id}")
|
||||||
|
async def api_address_delete(address_id, g: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
address = await get_address(address_id)
|
||||||
|
if not address:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Address does not exist.",
|
||||||
|
)
|
||||||
|
if address.wallet != g.wallet.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Not your address.",
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_address(address_id)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
Loading…
Add table
Reference in a new issue