Merge branch 'master' into master
This commit is contained in:
@@ -3,7 +3,9 @@ from lnbits.db import Database
|
||||
|
||||
db = Database("ext_amilk")
|
||||
|
||||
amilk_ext: Blueprint = Blueprint("amilk", __name__, static_folder="static", template_folder="templates")
|
||||
amilk_ext: Blueprint = Blueprint(
|
||||
"amilk", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -31,7 +31,9 @@ async def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]:
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(f"SELECT * FROM amilks WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM amilks WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [AMilk(**row) for row in rows]
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ async def api_amilks():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return jsonify([amilk._asdict() for amilk in await get_amilks(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([amilk._asdict() for amilk in await get_amilks(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"])
|
||||
@@ -34,13 +37,22 @@ async def api_amilkit(amilk_id):
|
||||
except LnurlException:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo, extra={"tag": "amilk"}
|
||||
)
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=milk.wallet,
|
||||
amount=withdraw_res.max_sats,
|
||||
memo=memo,
|
||||
extra={"tag": "amilk"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
r = httpx.get(
|
||||
withdraw_res.callback.base,
|
||||
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
|
||||
params={
|
||||
**withdraw_res.callback.query_params,
|
||||
**{"k1": withdraw_res.k1, "pr": payment_request},
|
||||
},
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
@@ -68,7 +80,10 @@ async def api_amilkit(amilk_id):
|
||||
)
|
||||
async def api_amilk_create():
|
||||
amilk = await create_amilk(
|
||||
wallet_id=g.wallet.id, lnurl=g.data["lnurl"], atime=g.data["atime"], amount=g.data["amount"]
|
||||
wallet_id=g.wallet.id,
|
||||
lnurl=g.data["lnurl"],
|
||||
atime=g.data["atime"],
|
||||
amount=g.data["amount"],
|
||||
)
|
||||
|
||||
return jsonify(amilk._asdict()), HTTPStatus.CREATED
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Bleskomat Extension for lnbits
|
||||
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/).
|
||||
|
||||
|
||||
## Connect Your Bleskomat ATM
|
||||
|
||||
* Click the "Add Bleskomat" button on this page to begin.
|
||||
* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers.
|
||||
* Choose the fiat currency. This should match the fiat currency that your ATM accepts.
|
||||
* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds.
|
||||
* Set your ATM's fee percentage.
|
||||
* Click the "Done" button.
|
||||
* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM.
|
||||
* Copy the configuration file ("bleskomat.conf") to your ATM's SD card.
|
||||
* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card.
|
||||
|
||||
|
||||
## How Does It Work?
|
||||
|
||||
Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet.
|
||||
@@ -0,0 +1,12 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_bleskomat")
|
||||
|
||||
bleskomat_ext: Blueprint = Blueprint(
|
||||
"bleskomat", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
from .lnurl_api import * # noqa
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Bleskomat",
|
||||
"short_description": "Connect a Bleskomat ATM to an lnbits",
|
||||
"icon": "money",
|
||||
"contributors": ["chill117"]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import secrets
|
||||
import time
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Union
|
||||
from . import db
|
||||
from .models import Bleskomat, BleskomatLnurl
|
||||
from .helpers import generate_bleskomat_lnurl_hash
|
||||
|
||||
|
||||
async def create_bleskomat(
|
||||
*,
|
||||
wallet_id: str,
|
||||
name: str,
|
||||
fiat_currency: str,
|
||||
exchange_rate_provider: str,
|
||||
fee: str,
|
||||
) -> Bleskomat:
|
||||
bleskomat_id = uuid4().hex
|
||||
api_key_id = secrets.token_hex(8)
|
||||
api_key_secret = secrets.token_hex(32)
|
||||
api_key_encoding = "hex"
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
bleskomat_id,
|
||||
wallet_id,
|
||||
api_key_id,
|
||||
api_key_secret,
|
||||
api_key_encoding,
|
||||
name,
|
||||
fiat_currency,
|
||||
exchange_rate_provider,
|
||||
fee,
|
||||
),
|
||||
)
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
assert bleskomat, "Newly created bleskomat couldn't be retrieved"
|
||||
return bleskomat
|
||||
|
||||
|
||||
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
|
||||
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomats WHERE api_key_id = ?", (api_key_id,)
|
||||
)
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Bleskomat(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE bleskomats SET {q} WHERE id = ?", (*kwargs.values(), bleskomat_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def delete_bleskomat(bleskomat_id: str) -> None:
|
||||
await db.execute("DELETE FROM bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
|
||||
|
||||
async def create_bleskomat_lnurl(
|
||||
*, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1
|
||||
) -> BleskomatLnurl:
|
||||
bleskomat_lnurl_id = uuid4().hex
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
now = int(time.time())
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
bleskomat_lnurl_id,
|
||||
bleskomat.id,
|
||||
bleskomat.wallet,
|
||||
hash,
|
||||
tag,
|
||||
params,
|
||||
bleskomat.api_key_id,
|
||||
uses,
|
||||
uses,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
bleskomat_lnurl = await get_bleskomat_lnurl(secret)
|
||||
assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved"
|
||||
return bleskomat_lnurl
|
||||
|
||||
|
||||
async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]:
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
row = await db.fetchone("SELECT * FROM bleskomat_lnurls WHERE hash = ?", (hash,))
|
||||
return BleskomatLnurl(**row) if row else None
|
||||
@@ -0,0 +1,79 @@
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
|
||||
fiat_currencies = json.load(
|
||||
open(
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json"
|
||||
),
|
||||
"r",
|
||||
)
|
||||
)
|
||||
|
||||
exchange_rate_providers = {
|
||||
"bitfinex": {
|
||||
"name": "Bitfinex",
|
||||
"domain": "bitfinex.com",
|
||||
"api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}",
|
||||
"getter": lambda data, replacements: data["last_price"],
|
||||
},
|
||||
"bitstamp": {
|
||||
"name": "Bitstamp",
|
||||
"domain": "bitstamp.net",
|
||||
"api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
|
||||
"getter": lambda data, replacements: data["last"],
|
||||
},
|
||||
"coinbase": {
|
||||
"name": "Coinbase",
|
||||
"domain": "coinbase.com",
|
||||
"api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
|
||||
"getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]],
|
||||
},
|
||||
"coinmate": {
|
||||
"name": "CoinMate",
|
||||
"domain": "coinmate.io",
|
||||
"api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
|
||||
"getter": lambda data, replacements: data["data"]["last"],
|
||||
},
|
||||
"kraken": {
|
||||
"name": "Kraken",
|
||||
"domain": "kraken.com",
|
||||
"api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
|
||||
"getter": lambda data, replacements: data["result"][
|
||||
"XXBTZ" + replacements["TO"]
|
||||
]["c"][0],
|
||||
},
|
||||
}
|
||||
|
||||
exchange_rate_providers_serializable = {}
|
||||
for ref, exchange_rate_provider in exchange_rate_providers.items():
|
||||
exchange_rate_provider_serializable = {}
|
||||
for key, value in exchange_rate_provider.items():
|
||||
if not callable(value):
|
||||
exchange_rate_provider_serializable[key] = value
|
||||
exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable
|
||||
|
||||
|
||||
async def fetch_fiat_exchange_rate(currency: str, provider: str):
|
||||
|
||||
replacements = {
|
||||
"FROM": "BTC",
|
||||
"from": "btc",
|
||||
"TO": currency.upper(),
|
||||
"to": currency.lower(),
|
||||
}
|
||||
|
||||
url = exchange_rate_providers[provider]["api_url"]
|
||||
for key in replacements.keys():
|
||||
url = url.replace("{" + key + "}", replacements[key])
|
||||
|
||||
getter = exchange_rate_providers[provider]["getter"]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
rate = float(getter(data, replacements))
|
||||
|
||||
return rate
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"AED": "United Arab Emirates Dirham",
|
||||
"AFN": "Afghan Afghani",
|
||||
"ALL": "Albanian Lek",
|
||||
"AMD": "Armenian Dram",
|
||||
"ANG": "Netherlands Antillean Gulden",
|
||||
"AOA": "Angolan Kwanza",
|
||||
"ARS": "Argentine Peso",
|
||||
"AUD": "Australian Dollar",
|
||||
"AWG": "Aruban Florin",
|
||||
"AZN": "Azerbaijani Manat",
|
||||
"BAM": "Bosnia and Herzegovina Convertible Mark",
|
||||
"BBD": "Barbadian Dollar",
|
||||
"BDT": "Bangladeshi Taka",
|
||||
"BGN": "Bulgarian Lev",
|
||||
"BHD": "Bahraini Dinar",
|
||||
"BIF": "Burundian Franc",
|
||||
"BMD": "Bermudian Dollar",
|
||||
"BND": "Brunei Dollar",
|
||||
"BOB": "Bolivian Boliviano",
|
||||
"BRL": "Brazilian Real",
|
||||
"BSD": "Bahamian Dollar",
|
||||
"BTN": "Bhutanese Ngultrum",
|
||||
"BWP": "Botswana Pula",
|
||||
"BYN": "Belarusian Ruble",
|
||||
"BYR": "Belarusian Ruble",
|
||||
"BZD": "Belize Dollar",
|
||||
"CAD": "Canadian Dollar",
|
||||
"CDF": "Congolese Franc",
|
||||
"CHF": "Swiss Franc",
|
||||
"CLF": "Unidad de Fomento",
|
||||
"CLP": "Chilean Peso",
|
||||
"CNH": "Chinese Renminbi Yuan Offshore",
|
||||
"CNY": "Chinese Renminbi Yuan",
|
||||
"COP": "Colombian Peso",
|
||||
"CRC": "Costa Rican Colón",
|
||||
"CUC": "Cuban Convertible Peso",
|
||||
"CVE": "Cape Verdean Escudo",
|
||||
"CZK": "Czech Koruna",
|
||||
"DJF": "Djiboutian Franc",
|
||||
"DKK": "Danish Krone",
|
||||
"DOP": "Dominican Peso",
|
||||
"DZD": "Algerian Dinar",
|
||||
"EGP": "Egyptian Pound",
|
||||
"ERN": "Eritrean Nakfa",
|
||||
"ETB": "Ethiopian Birr",
|
||||
"EUR": "Euro",
|
||||
"FJD": "Fijian Dollar",
|
||||
"FKP": "Falkland Pound",
|
||||
"GBP": "British Pound",
|
||||
"GEL": "Georgian Lari",
|
||||
"GGP": "Guernsey Pound",
|
||||
"GHS": "Ghanaian Cedi",
|
||||
"GIP": "Gibraltar Pound",
|
||||
"GMD": "Gambian Dalasi",
|
||||
"GNF": "Guinean Franc",
|
||||
"GTQ": "Guatemalan Quetzal",
|
||||
"GYD": "Guyanese Dollar",
|
||||
"HKD": "Hong Kong Dollar",
|
||||
"HNL": "Honduran Lempira",
|
||||
"HRK": "Croatian Kuna",
|
||||
"HTG": "Haitian Gourde",
|
||||
"HUF": "Hungarian Forint",
|
||||
"IDR": "Indonesian Rupiah",
|
||||
"ILS": "Israeli New Sheqel",
|
||||
"IMP": "Isle of Man Pound",
|
||||
"INR": "Indian Rupee",
|
||||
"IQD": "Iraqi Dinar",
|
||||
"ISK": "Icelandic Króna",
|
||||
"JEP": "Jersey Pound",
|
||||
"JMD": "Jamaican Dollar",
|
||||
"JOD": "Jordanian Dinar",
|
||||
"JPY": "Japanese Yen",
|
||||
"KES": "Kenyan Shilling",
|
||||
"KGS": "Kyrgyzstani Som",
|
||||
"KHR": "Cambodian Riel",
|
||||
"KMF": "Comorian Franc",
|
||||
"KRW": "South Korean Won",
|
||||
"KWD": "Kuwaiti Dinar",
|
||||
"KYD": "Cayman Islands Dollar",
|
||||
"KZT": "Kazakhstani Tenge",
|
||||
"LAK": "Lao Kip",
|
||||
"LBP": "Lebanese Pound",
|
||||
"LKR": "Sri Lankan Rupee",
|
||||
"LRD": "Liberian Dollar",
|
||||
"LSL": "Lesotho Loti",
|
||||
"LYD": "Libyan Dinar",
|
||||
"MAD": "Moroccan Dirham",
|
||||
"MDL": "Moldovan Leu",
|
||||
"MGA": "Malagasy Ariary",
|
||||
"MKD": "Macedonian Denar",
|
||||
"MMK": "Myanmar Kyat",
|
||||
"MNT": "Mongolian Tögrög",
|
||||
"MOP": "Macanese Pataca",
|
||||
"MRO": "Mauritanian Ouguiya",
|
||||
"MUR": "Mauritian Rupee",
|
||||
"MVR": "Maldivian Rufiyaa",
|
||||
"MWK": "Malawian Kwacha",
|
||||
"MXN": "Mexican Peso",
|
||||
"MYR": "Malaysian Ringgit",
|
||||
"MZN": "Mozambican Metical",
|
||||
"NAD": "Namibian Dollar",
|
||||
"NGN": "Nigerian Naira",
|
||||
"NIO": "Nicaraguan Córdoba",
|
||||
"NOK": "Norwegian Krone",
|
||||
"NPR": "Nepalese Rupee",
|
||||
"NZD": "New Zealand Dollar",
|
||||
"OMR": "Omani Rial",
|
||||
"PAB": "Panamanian Balboa",
|
||||
"PEN": "Peruvian Sol",
|
||||
"PGK": "Papua New Guinean Kina",
|
||||
"PHP": "Philippine Peso",
|
||||
"PKR": "Pakistani Rupee",
|
||||
"PLN": "Polish Złoty",
|
||||
"PYG": "Paraguayan Guaraní",
|
||||
"QAR": "Qatari Riyal",
|
||||
"RON": "Romanian Leu",
|
||||
"RSD": "Serbian Dinar",
|
||||
"RUB": "Russian Ruble",
|
||||
"RWF": "Rwandan Franc",
|
||||
"SAR": "Saudi Riyal",
|
||||
"SBD": "Solomon Islands Dollar",
|
||||
"SCR": "Seychellois Rupee",
|
||||
"SEK": "Swedish Krona",
|
||||
"SGD": "Singapore Dollar",
|
||||
"SHP": "Saint Helenian Pound",
|
||||
"SLL": "Sierra Leonean Leone",
|
||||
"SOS": "Somali Shilling",
|
||||
"SRD": "Surinamese Dollar",
|
||||
"SSP": "South Sudanese Pound",
|
||||
"STD": "São Tomé and Príncipe Dobra",
|
||||
"SVC": "Salvadoran Colón",
|
||||
"SZL": "Swazi Lilangeni",
|
||||
"THB": "Thai Baht",
|
||||
"TJS": "Tajikistani Somoni",
|
||||
"TMT": "Turkmenistani Manat",
|
||||
"TND": "Tunisian Dinar",
|
||||
"TOP": "Tongan Paʻanga",
|
||||
"TRY": "Turkish Lira",
|
||||
"TTD": "Trinidad and Tobago Dollar",
|
||||
"TWD": "New Taiwan Dollar",
|
||||
"TZS": "Tanzanian Shilling",
|
||||
"UAH": "Ukrainian Hryvnia",
|
||||
"UGX": "Ugandan Shilling",
|
||||
"USD": "US Dollar",
|
||||
"UYU": "Uruguayan Peso",
|
||||
"UZS": "Uzbekistan Som",
|
||||
"VEF": "Venezuelan Bolívar",
|
||||
"VES": "Venezuelan Bolívar Soberano",
|
||||
"VND": "Vietnamese Đồng",
|
||||
"VUV": "Vanuatu Vatu",
|
||||
"WST": "Samoan Tala",
|
||||
"XAF": "Central African Cfa Franc",
|
||||
"XAG": "Silver (Troy Ounce)",
|
||||
"XAU": "Gold (Troy Ounce)",
|
||||
"XCD": "East Caribbean Dollar",
|
||||
"XDR": "Special Drawing Rights",
|
||||
"XOF": "West African Cfa Franc",
|
||||
"XPD": "Palladium",
|
||||
"XPF": "Cfp Franc",
|
||||
"XPT": "Platinum",
|
||||
"YER": "Yemeni Rial",
|
||||
"ZAR": "South African Rand",
|
||||
"ZMW": "Zambian Kwacha",
|
||||
"ZWL": "Zimbabwean Dollar"
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from http import HTTPStatus
|
||||
from binascii import unhexlify
|
||||
from typing import Dict
|
||||
from quart import url_for
|
||||
import urllib
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_hash(secret: str):
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{secret}".encode())
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_signature(
|
||||
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
|
||||
):
|
||||
if api_key_encoding == "hex":
|
||||
key = unhexlify(api_key_secret)
|
||||
elif api_key_encoding == "base64":
|
||||
key = base64.b64decode(api_key_secret)
|
||||
else:
|
||||
key = bytes(f"{api_key_secret}")
|
||||
return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
|
||||
# The secret is not randomly generated by the server.
|
||||
# Instead it is the hash of the API key ID and signature concatenated together.
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{api_key_id}-{signature}".encode())
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def get_callback_url():
|
||||
return url_for("bleskomat.api_bleskomat_lnurl", _external=True)
|
||||
|
||||
|
||||
def is_supported_lnurl_subprotocol(tag: str) -> bool:
|
||||
return tag == "withdrawRequest"
|
||||
|
||||
|
||||
class LnurlHttpError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "",
|
||||
http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
):
|
||||
self.message = message
|
||||
self.http_status = http_status
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class LnurlValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def prepare_lnurl_params(tag: str, query: Dict[str, str]):
|
||||
params = {}
|
||||
if not is_supported_lnurl_subprotocol(tag):
|
||||
raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"')
|
||||
if tag == "withdrawRequest":
|
||||
params["minWithdrawable"] = float(query["minWithdrawable"])
|
||||
params["maxWithdrawable"] = float(query["maxWithdrawable"])
|
||||
params["defaultDescription"] = query["defaultDescription"]
|
||||
if not params["minWithdrawable"] > 0:
|
||||
raise LnurlValidationError('"minWithdrawable" must be greater than zero')
|
||||
if not params["maxWithdrawable"] >= params["minWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'"maxWithdrawable" must be greater than or equal to "minWithdrawable"'
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
encode_uri_component_safe_chars = (
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()"
|
||||
)
|
||||
|
||||
|
||||
def query_to_signing_payload(query: Dict[str, str]) -> str:
|
||||
# Sort the query by key, then stringify it to create the payload.
|
||||
sorted_keys = sorted(query.keys(), key=str.lower)
|
||||
payload = []
|
||||
for key in sorted_keys:
|
||||
if not key == "signature":
|
||||
encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars)
|
||||
encoded_value = urllib.parse.quote(
|
||||
query[key], safe=encode_uri_component_safe_chars
|
||||
)
|
||||
payload.append(f"{encoded_key}={encoded_value}")
|
||||
return "&".join(payload)
|
||||
|
||||
|
||||
unshorten_rules = {
|
||||
"query": {"n": "nonce", "s": "signature", "t": "tag"},
|
||||
"tags": {
|
||||
"c": "channelRequest",
|
||||
"l": "login",
|
||||
"p": "payRequest",
|
||||
"w": "withdrawRequest",
|
||||
},
|
||||
"params": {
|
||||
"channelRequest": {"pl": "localAmt", "pp": "pushAmt"},
|
||||
"login": {},
|
||||
"payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"},
|
||||
"withdrawRequest": {
|
||||
"pn": "minWithdrawable",
|
||||
"px": "maxWithdrawable",
|
||||
"pd": "defaultDescription",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]:
|
||||
new_query = {}
|
||||
rules = unshorten_rules
|
||||
if "tag" in query:
|
||||
tag = query["tag"]
|
||||
elif "t" in query:
|
||||
tag = query["t"]
|
||||
else:
|
||||
raise LnurlValidationError('Missing required query parameter: "tag"')
|
||||
# Unshorten tag:
|
||||
if tag in rules["tags"]:
|
||||
long_tag = rules["tags"][tag]
|
||||
new_query["tag"] = long_tag
|
||||
tag = long_tag
|
||||
if not tag in rules["params"]:
|
||||
raise LnurlValidationError(f'Unknown tag: "{tag}"')
|
||||
for key in query:
|
||||
if key in rules["params"][tag]:
|
||||
short_param_key = key
|
||||
long_param_key = rules["params"][tag][short_param_key]
|
||||
if short_param_key in query:
|
||||
new_query[long_param_key] = query[short_param_key]
|
||||
else:
|
||||
new_query[long_param_key] = query[long_param_key]
|
||||
elif key in rules["query"]:
|
||||
# Unshorten general keys:
|
||||
short_key = key
|
||||
long_key = rules["query"][short_key]
|
||||
if not long_key in new_query:
|
||||
if short_key in query:
|
||||
new_query[long_key] = query[short_key]
|
||||
else:
|
||||
new_query[long_key] = query[long_key]
|
||||
else:
|
||||
# Keep unknown key/value pairs unchanged:
|
||||
new_query[key] = query[key]
|
||||
return new_query
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import math
|
||||
from quart import jsonify, request
|
||||
from http import HTTPStatus
|
||||
import traceback
|
||||
|
||||
from . import bleskomat_ext
|
||||
from .crud import (
|
||||
create_bleskomat_lnurl,
|
||||
get_bleskomat_by_api_key_id,
|
||||
get_bleskomat_lnurl,
|
||||
)
|
||||
|
||||
from .exchange_rates import (
|
||||
fetch_fiat_exchange_rate,
|
||||
)
|
||||
|
||||
from .helpers import (
|
||||
generate_bleskomat_lnurl_signature,
|
||||
generate_bleskomat_lnurl_secret,
|
||||
LnurlHttpError,
|
||||
LnurlValidationError,
|
||||
prepare_lnurl_params,
|
||||
query_to_signing_payload,
|
||||
unshorten_lnurl_query,
|
||||
)
|
||||
|
||||
|
||||
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
|
||||
@bleskomat_ext.route("/u", methods=["GET"])
|
||||
async def api_bleskomat_lnurl():
|
||||
try:
|
||||
query = request.args.to_dict()
|
||||
|
||||
# Unshorten query if "s" is used instead of "signature".
|
||||
if "s" in query:
|
||||
query = unshorten_lnurl_query(query)
|
||||
|
||||
if "signature" in query:
|
||||
|
||||
# Signature provided.
|
||||
# Use signature to verify that the URL was generated by an authorized device.
|
||||
# Later validate parameters, auto-generate LNURL, reply with LNURL response object.
|
||||
signature = query["signature"]
|
||||
|
||||
# The API key ID, nonce, and tag should be present in the query string.
|
||||
for field in ["id", "nonce", "tag"]:
|
||||
if not field in query:
|
||||
raise LnurlHttpError(
|
||||
f'Failed API key signature check: Missing "{field}"',
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
# URL signing scheme is described here:
|
||||
# https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme
|
||||
payload = query_to_signing_payload(query)
|
||||
api_key_id = query["id"]
|
||||
bleskomat = await get_bleskomat_by_api_key_id(api_key_id)
|
||||
if not bleskomat:
|
||||
raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST)
|
||||
api_key_secret = bleskomat.api_key_secret
|
||||
api_key_encoding = bleskomat.api_key_encoding
|
||||
expected_signature = generate_bleskomat_lnurl_signature(
|
||||
payload, api_key_secret, api_key_encoding
|
||||
)
|
||||
if signature != expected_signature:
|
||||
raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN)
|
||||
|
||||
# Signature is valid.
|
||||
# In the case of signed URLs, the secret is deterministic based on the API key ID and signature.
|
||||
secret = generate_bleskomat_lnurl_secret(api_key_id, signature)
|
||||
lnurl = await get_bleskomat_lnurl(secret)
|
||||
if not lnurl:
|
||||
try:
|
||||
tag = query["tag"]
|
||||
params = prepare_lnurl_params(tag, query)
|
||||
if "f" in query:
|
||||
rate = await fetch_fiat_exchange_rate(
|
||||
currency=query["f"],
|
||||
provider=bleskomat.exchange_rate_provider,
|
||||
)
|
||||
# Convert fee (%) to decimal:
|
||||
fee = float(bleskomat.fee) / 100
|
||||
if tag == "withdrawRequest":
|
||||
for key in ["minWithdrawable", "maxWithdrawable"]:
|
||||
amount_sats = int(
|
||||
math.floor((params[key] / rate) * 1e8)
|
||||
)
|
||||
fee_sats = int(math.floor(amount_sats * fee))
|
||||
amount_sats_less_fee = amount_sats - fee_sats
|
||||
# Convert to msats:
|
||||
params[key] = int(amount_sats_less_fee * 1e3)
|
||||
except LnurlValidationError as e:
|
||||
raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST)
|
||||
# Create a new LNURL using the query parameters provided in the signed URL.
|
||||
params = json.JSONEncoder().encode(params)
|
||||
lnurl = await create_bleskomat_lnurl(
|
||||
bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1
|
||||
)
|
||||
|
||||
# Reply with LNURL response object.
|
||||
return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK
|
||||
|
||||
# No signature provided.
|
||||
# Treat as "action" callback.
|
||||
|
||||
if not "k1" in query:
|
||||
raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
secret = query["k1"]
|
||||
lnurl = await get_bleskomat_lnurl(secret)
|
||||
if not lnurl:
|
||||
raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if not lnurl.has_uses_remaining():
|
||||
raise LnurlHttpError(
|
||||
"Maximum number of uses already reached", HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
await lnurl.execute_action(query)
|
||||
except LnurlValidationError as e:
|
||||
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
|
||||
|
||||
except LnurlHttpError as e:
|
||||
return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return (
|
||||
jsonify({"status": "ERROR", "reason": "Unexpected error"}),
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return jsonify({"status": "OK"}), HTTPStatus.OK
|
||||
@@ -0,0 +1,37 @@
|
||||
async def m001_initial(db):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bleskomats (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
api_key_secret TEXT NOT NULL,
|
||||
api_key_encoding TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
fiat_currency TEXT NOT NULL,
|
||||
exchange_rate_provider TEXT NOT NULL,
|
||||
fee TEXT NOT NULL,
|
||||
UNIQUE(api_key_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bleskomat_lnurls (
|
||||
id TEXT PRIMARY KEY,
|
||||
bleskomat TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
initial_uses INTEGER DEFAULT 1,
|
||||
remaining_uses INTEGER DEFAULT 0,
|
||||
created_time INTEGER,
|
||||
updated_time INTEGER,
|
||||
UNIQUE(hash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
import time
|
||||
from typing import NamedTuple, Dict
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.services import pay_invoice
|
||||
from . import db
|
||||
from .helpers import get_callback_url, LnurlValidationError
|
||||
|
||||
|
||||
class Bleskomat(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
api_key_id: str
|
||||
api_key_secret: str
|
||||
api_key_encoding: str
|
||||
name: str
|
||||
fiat_currency: str
|
||||
exchange_rate_provider: str
|
||||
fee: str
|
||||
|
||||
|
||||
class BleskomatLnurl(NamedTuple):
|
||||
id: str
|
||||
bleskomat: str
|
||||
wallet: str
|
||||
hash: str
|
||||
tag: str
|
||||
params: str
|
||||
api_key_id: str
|
||||
initial_uses: int
|
||||
remaining_uses: int
|
||||
created_time: int
|
||||
updated_time: int
|
||||
|
||||
def has_uses_remaining(self) -> bool:
|
||||
# When initial uses is 0 then the LNURL has unlimited uses.
|
||||
return self.initial_uses == 0 or self.remaining_uses > 0
|
||||
|
||||
def get_info_response_object(self, secret: str) -> Dict[str, str]:
|
||||
tag = self.tag
|
||||
params = json.loads(self.params)
|
||||
response = {"tag": tag}
|
||||
if tag == "withdrawRequest":
|
||||
for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]:
|
||||
response[key] = params[key]
|
||||
response["callback"] = get_callback_url()
|
||||
response["k1"] = secret
|
||||
return response
|
||||
|
||||
def validate_action(self, query: Dict[str, str]) -> None:
|
||||
tag = self.tag
|
||||
params = json.loads(self.params)
|
||||
# Perform tag-specific checks.
|
||||
if tag == "withdrawRequest":
|
||||
for field in ["pr"]:
|
||||
if not field in query:
|
||||
raise LnurlValidationError(f'Missing required parameter: "{field}"')
|
||||
# Check the bolt11 invoice(s) provided.
|
||||
pr = query["pr"]
|
||||
if "," in pr:
|
||||
raise LnurlValidationError("Multiple payment requests not supported")
|
||||
try:
|
||||
invoice = bolt11.decode(pr)
|
||||
except ValueError:
|
||||
raise LnurlValidationError(
|
||||
'Invalid parameter ("pr"): Lightning payment request expected'
|
||||
)
|
||||
if invoice.amount_msat < params["minWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'Amount in invoice must be greater than or equal to "minWithdrawable"'
|
||||
)
|
||||
if invoice.amount_msat > params["maxWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'Amount in invoice must be less than or equal to "maxWithdrawable"'
|
||||
)
|
||||
else:
|
||||
raise LnurlValidationError(f'Unknown subprotocol: "{tag}"')
|
||||
|
||||
async def execute_action(self, query: Dict[str, str]):
|
||||
self.validate_action(query)
|
||||
used = False
|
||||
async with db.connect() as conn:
|
||||
if self.initial_uses > 0:
|
||||
used = await self.use(conn)
|
||||
if not used:
|
||||
raise LnurlValidationError("Maximum number of uses already reached")
|
||||
tag = self.tag
|
||||
if tag == "withdrawRequest":
|
||||
try:
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=self.wallet,
|
||||
payment_request=query["pr"],
|
||||
)
|
||||
except Exception:
|
||||
raise LnurlValidationError("Failed to pay invoice")
|
||||
if not payment_hash:
|
||||
raise LnurlValidationError("Failed to pay invoice")
|
||||
|
||||
async def use(self, conn) -> bool:
|
||||
now = int(time.time())
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE bleskomat_lnurls
|
||||
SET remaining_uses = remaining_uses - 1, updated_time = ?
|
||||
WHERE id = ?
|
||||
AND remaining_uses > 0
|
||||
""",
|
||||
(now, self.id),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,216 @@
|
||||
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapBleskomat = function (obj) {
|
||||
obj._data = _.clone(obj)
|
||||
return obj
|
||||
}
|
||||
|
||||
var defaultValues = {
|
||||
name: 'My Bleskomat',
|
||||
fiat_currency: 'EUR',
|
||||
exchange_rate_provider: 'coinbase',
|
||||
fee: '0.00'
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
checker: null,
|
||||
bleskomats: [],
|
||||
bleskomatsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'api_key_id',
|
||||
align: 'left',
|
||||
label: 'API Key ID',
|
||||
field: 'api_key_id'
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
align: 'left',
|
||||
label: 'Name',
|
||||
field: 'name'
|
||||
},
|
||||
{
|
||||
name: 'fiat_currency',
|
||||
align: 'left',
|
||||
label: 'Fiat Currency',
|
||||
field: 'fiat_currency'
|
||||
},
|
||||
{
|
||||
name: 'exchange_rate_provider',
|
||||
align: 'left',
|
||||
label: 'Exchange Rate Provider',
|
||||
field: 'exchange_rate_provider'
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'left',
|
||||
label: 'Fee (%)',
|
||||
field: 'fee'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies),
|
||||
exchangeRateProviders: _.keys(
|
||||
window.bleskomat_vars.exchange_rate_providers
|
||||
),
|
||||
data: _.clone(defaultValues)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedBleskomats: function () {
|
||||
return this.bleskomats.sort(function (a, b) {
|
||||
// Sort by API Key ID alphabetically.
|
||||
var apiKeyId_A = a.api_key_id.toLowerCase()
|
||||
var apiKeyId_B = b.api_key_id.toLowerCase()
|
||||
return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getBleskomats: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/bleskomat/api/v1/bleskomats?all_wallets',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = response.data.map(function (obj) {
|
||||
return mapBleskomat(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
clearInterval(self.checker)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = _.clone(defaultValues)
|
||||
},
|
||||
exportConfigFile: function (bleskomatId) {
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
var fieldToKey = {
|
||||
api_key_id: 'apiKey.id',
|
||||
api_key_secret: 'apiKey.key',
|
||||
api_key_encoding: 'apiKey.encoding',
|
||||
fiat_currency: 'fiatCurrency'
|
||||
}
|
||||
var lines = _.chain(bleskomat)
|
||||
.map(function (value, field) {
|
||||
var key = fieldToKey[field] || null
|
||||
return key ? [key, value].join('=') : null
|
||||
})
|
||||
.compact()
|
||||
.value()
|
||||
lines.push('callbackUrl=' + window.bleskomat_vars.callback_url)
|
||||
lines.push('shorten=true')
|
||||
var content = lines.join('\n')
|
||||
var status = Quasar.utils.exportFile(
|
||||
'bleskomat.conf',
|
||||
content,
|
||||
'text/plain'
|
||||
)
|
||||
if (status !== true) {
|
||||
Quasar.plugins.Notify.create({
|
||||
message: 'Browser denied file download...',
|
||||
color: 'negative',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
},
|
||||
openUpdateDialog: function (bleskomatId) {
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
this.formDialog.data = _.clone(bleskomat._data)
|
||||
this.formDialog.show = true
|
||||
},
|
||||
sendFormData: function () {
|
||||
var wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
if (data.id) {
|
||||
this.updateBleskomat(wallet, data)
|
||||
} else {
|
||||
this.createBleskomat(wallet, data)
|
||||
}
|
||||
},
|
||||
updateBleskomat: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/bleskomat/api/v1/bleskomat/' + data.id,
|
||||
wallet.adminkey,
|
||||
_.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee')
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = _.reject(self.bleskomats, function (obj) {
|
||||
return obj.id === data.id
|
||||
})
|
||||
self.bleskomats.push(mapBleskomat(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
createBleskomat: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data)
|
||||
.then(function (response) {
|
||||
self.bleskomats.push(mapBleskomat(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteBleskomat: function (bleskomatId) {
|
||||
var self = this
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to delete "' + bleskomat.name + '"?'
|
||||
)
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/bleskomat/api/v1/bleskomat/' + bleskomatId,
|
||||
_.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = _.reject(self.bleskomats, function (obj) {
|
||||
return obj.id === bleskomatId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getBleskomats = this.getBleskomats
|
||||
getBleskomats()
|
||||
this.checker = setInterval(function () {
|
||||
getBleskomats()
|
||||
}, 20000)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Bleskomat Extension for lnbits"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits
|
||||
wallet. It will work with both the
|
||||
<a href="https://github.com/samotari/bleskomat"
|
||||
>open-source DIY Bleskomat ATM project</a
|
||||
>
|
||||
as well as the
|
||||
<a href="https://www.bleskomat.com/">commercial Bleskomat ATM</a>.
|
||||
</p>
|
||||
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
|
||||
<div>
|
||||
<ol>
|
||||
<li>Click the "Add Bleskomat" button on this page to begin.</li>
|
||||
<li>
|
||||
Choose a wallet. This will be the wallet that is used to pay
|
||||
satoshis to your ATM customers.
|
||||
</li>
|
||||
<li>
|
||||
Choose the fiat currency. This should match the fiat currency that
|
||||
your ATM accepts.
|
||||
</li>
|
||||
<li>
|
||||
Pick an exchange rate provider. This is the API that will be used to
|
||||
query the fiat to satoshi exchange rate at the time your customer
|
||||
attempts to withdraw their funds.
|
||||
</li>
|
||||
<li>Set your ATM's fee percentage.</li>
|
||||
<li>Click the "Done" button.</li>
|
||||
<li>
|
||||
Find the new Bleskomat in the list and then click the export icon to
|
||||
download a new configuration file for your ATM.
|
||||
</li>
|
||||
<li>
|
||||
Copy the configuration file ("bleskomat.conf") to your ATM's SD
|
||||
card.
|
||||
</li>
|
||||
<li>
|
||||
Restart Your Bleskomat ATM. It should automatically reload the
|
||||
configurations from the SD card.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<h5 class="text-subtitle1 q-my-none">How does it work?</h5>
|
||||
<p>
|
||||
Since the Bleskomat ATMs are designed to be offline, a cryptographic
|
||||
signing scheme is used to verify that the URL was generated by an
|
||||
authorized device. When one of your customers inserts fiat money into
|
||||
the device, a signed URL (lnurl-withdraw) is created and displayed as a
|
||||
QR code. Your customer scans the QR code with their lnurl-supporting
|
||||
mobile app, their mobile app communicates with the web API of lnbits to
|
||||
verify the signature, the fiat currency amount is converted to sats, the
|
||||
customer accepts the withdrawal, and finally lnbits will pay the
|
||||
customer from your lnbits wallet.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,178 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
{% if bleskomat_vars %}
|
||||
window.bleskomat_vars = {{ bleskomat_vars | tojson | safe }}
|
||||
{% endif %}
|
||||
</script>
|
||||
<script src="/bleskomat/static/js/index.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
|
||||
>Add Bleskomat</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">Bleskomats</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="sortedBleskomats"
|
||||
row-key="id"
|
||||
:columns="bleskomatsTable.columns"
|
||||
:pagination.sync="bleskomatsTable.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
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="file_download"
|
||||
color="orange"
|
||||
@click="exportConfigFile(props.row.id)"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent"
|
||||
>Export Configuration</q-tooltip
|
||||
>
|
||||
</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="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent">Edit</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteBleskomat(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent">Delete</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits Bleskomat extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "bleskomat/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<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="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="text"
|
||||
label="Name *"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.fiat_currency"
|
||||
:options="formDialog.fiatCurrencies"
|
||||
label="Fiat Currency *"
|
||||
>
|
||||
</q-select>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.exchange_rate_provider"
|
||||
:options="formDialog.exchangeRateProviders"
|
||||
label="Exchange Rate Provider *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.fee"
|
||||
type="string"
|
||||
:default="0.00"
|
||||
label="Fee (%) *"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
type="submit"
|
||||
>Update Bleskomat</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.name == null ||
|
||||
formDialog.data.fiat_currency == null ||
|
||||
formDialog.data.exchange_rate_provider == null ||
|
||||
formDialog.data.fee == null"
|
||||
type="submit"
|
||||
>Add Bleskomat</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 %}
|
||||
@@ -0,0 +1,22 @@
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import bleskomat_ext
|
||||
|
||||
from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies
|
||||
from .helpers import get_callback_url
|
||||
|
||||
|
||||
@bleskomat_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
bleskomat_vars = {
|
||||
"callback_url": get_callback_url(),
|
||||
"exchange_rate_providers": exchange_rate_providers_serializable,
|
||||
"fiat_currencies": fiat_currencies,
|
||||
}
|
||||
return await render_template(
|
||||
"bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from . import bleskomat_ext
|
||||
from .crud import (
|
||||
create_bleskomat,
|
||||
get_bleskomat,
|
||||
get_bleskomats,
|
||||
update_bleskomat,
|
||||
delete_bleskomat,
|
||||
)
|
||||
|
||||
from .exchange_rates import (
|
||||
exchange_rate_providers,
|
||||
fetch_fiat_exchange_rate,
|
||||
fiat_currencies,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomats():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
[bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["GET"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomat_retrieve(bleskomat_id):
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
return jsonify(bleskomat._asdict()), HTTPStatus.OK
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"])
|
||||
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["PUT"])
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"fiat_currency": {
|
||||
"type": "string",
|
||||
"allowed": fiat_currencies.keys(),
|
||||
"required": True,
|
||||
},
|
||||
"exchange_rate_provider": {
|
||||
"type": "string",
|
||||
"allowed": exchange_rate_providers.keys(),
|
||||
"required": True,
|
||||
},
|
||||
"fee": {"type": ["string", "float", "number", "integer"], "required": True},
|
||||
}
|
||||
)
|
||||
async def api_bleskomat_create_or_update(bleskomat_id=None):
|
||||
try:
|
||||
fiat_currency = g.data["fiat_currency"]
|
||||
exchange_rate_provider = g.data["exchange_rate_provider"]
|
||||
await fetch_fiat_exchange_rate(
|
||||
currency=fiat_currency, provider=exchange_rate_provider
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"'
|
||||
}
|
||||
),
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
if bleskomat_id:
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
bleskomat = await update_bleskomat(bleskomat_id, **g.data)
|
||||
else:
|
||||
bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data)
|
||||
|
||||
return (
|
||||
jsonify(bleskomat._asdict()),
|
||||
HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomat_delete(bleskomat_id):
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
await delete_bleskomat(bleskomat_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@@ -3,7 +3,9 @@ from lnbits.db import Database
|
||||
|
||||
db = Database("ext_captcha")
|
||||
|
||||
captcha_ext: Blueprint = Blueprint("captcha", __name__, static_folder="static", template_folder="templates")
|
||||
captcha_ext: Blueprint = Blueprint(
|
||||
"captcha", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -7,7 +7,13 @@ from .models import Captcha
|
||||
|
||||
|
||||
async def create_captcha(
|
||||
*, wallet_id: str, url: str, memo: str, description: Optional[str] = None, amount: int = 0, remembers: bool = True
|
||||
*,
|
||||
wallet_id: str,
|
||||
url: str,
|
||||
memo: str,
|
||||
description: Optional[str] = None,
|
||||
amount: int = 0,
|
||||
remembers: bool = True,
|
||||
) -> Captcha:
|
||||
captcha_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
@@ -34,7 +40,9 @@ async def get_captchas(wallet_ids: Union[str, List[str]]) -> List[Captcha]:
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(f"SELECT * FROM captchas WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM captchas WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Captcha.from_row(row) for row in rows]
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ async def m002_redux(db):
|
||||
)
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON captchas (wallet)")
|
||||
|
||||
for row in [list(row) for row in await db.fetchall("SELECT * FROM captchas_old")]:
|
||||
for row in [
|
||||
list(row) for row in await db.fetchall("SELECT * FROM captchas_old")
|
||||
]:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO captchas (
|
||||
|
||||
@@ -1,65 +1,80 @@
|
||||
var ciframeLoaded = !1,
|
||||
captchaStyleAdded = !1;
|
||||
captchaStyleAdded = !1
|
||||
|
||||
function ccreateIframeElement(t = {}) {
|
||||
const e = document.createElement("iframe");
|
||||
// e.style.marginLeft = "25px",
|
||||
e.style.border = "none", e.style.width = "100%", e.style.height = "100%", e.scrolling = "no", e.id = "captcha-iframe";
|
||||
t.dest, t.amount, t.currency, t.label, t.opReturn;
|
||||
var captchaid = document.getElementById("captchascript").getAttribute("data-captchaid");
|
||||
return e.src = "./captcha/" + captchaid, e
|
||||
const e = document.createElement('iframe')
|
||||
// e.style.marginLeft = "25px",
|
||||
;(e.style.border = 'none'),
|
||||
(e.style.width = '100%'),
|
||||
(e.style.height = '100%'),
|
||||
(e.scrolling = 'no'),
|
||||
(e.id = 'captcha-iframe')
|
||||
t.dest, t.amount, t.currency, t.label, t.opReturn
|
||||
var captchaid = document
|
||||
.getElementById('captchascript')
|
||||
.getAttribute('data-captchaid')
|
||||
return (e.src = './captcha/' + captchaid), e
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
if (captchaStyleAdded) console.log("Captcha stuff already added!");
|
||||
else {
|
||||
console.log("Adding captcha stuff"), captchaStyleAdded = !0;
|
||||
var t = document.createElement("style");
|
||||
t.innerHTML = "\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}";
|
||||
var e = document.querySelector("script");
|
||||
e.parentNode.insertBefore(t, e);
|
||||
var i = document.getElementById("captchacheckbox"),
|
||||
n = i.dataset,
|
||||
o = "true" === n.dark;
|
||||
var a = document.createElement("div");
|
||||
a.className += " modal-captcha-container", a.innerHTML = '\t\t<div class="modal-captcha-content"> \t<span class="close-button-captcha" style="display: none;">×</span>\t\t</div>\t', document.getElementsByTagName("body")[0].appendChild(a);
|
||||
var r = document.getElementsByClassName("modal-captcha-content").item(0);
|
||||
document.getElementsByClassName("close-button-captcha").item(0).addEventListener("click", d), window.addEventListener("click", function(t) {
|
||||
t.target === a && d()
|
||||
}), i.addEventListener("change", function() {
|
||||
if(this.checked){
|
||||
// console.log("checkbox checked");
|
||||
if (0 == ciframeLoaded) {
|
||||
// console.log("n: ", n);
|
||||
var t = ccreateIframeElement(n);
|
||||
r.appendChild(t), ciframeLoaded = !0
|
||||
}
|
||||
d()
|
||||
}
|
||||
})
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (captchaStyleAdded) console.log('Captcha stuff already added!')
|
||||
else {
|
||||
console.log('Adding captcha stuff'), (captchaStyleAdded = !0)
|
||||
var t = document.createElement('style')
|
||||
t.innerHTML =
|
||||
"\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}"
|
||||
var e = document.querySelector('script')
|
||||
e.parentNode.insertBefore(t, e)
|
||||
var i = document.getElementById('captchacheckbox'),
|
||||
n = i.dataset,
|
||||
o = 'true' === n.dark
|
||||
var a = document.createElement('div')
|
||||
;(a.className += ' modal-captcha-container'),
|
||||
(a.innerHTML =
|
||||
'\t\t<div class="modal-captcha-content"> \t<span class="close-button-captcha" style="display: none;">×</span>\t\t</div>\t'),
|
||||
document.getElementsByTagName('body')[0].appendChild(a)
|
||||
var r = document.getElementsByClassName('modal-captcha-content').item(0)
|
||||
document
|
||||
.getElementsByClassName('close-button-captcha')
|
||||
.item(0)
|
||||
.addEventListener('click', d),
|
||||
window.addEventListener('click', function (t) {
|
||||
t.target === a && d()
|
||||
}),
|
||||
i.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
// console.log("checkbox checked");
|
||||
if (0 == ciframeLoaded) {
|
||||
// console.log("n: ", n);
|
||||
var t = ccreateIframeElement(n)
|
||||
r.appendChild(t), (ciframeLoaded = !0)
|
||||
}
|
||||
d()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function d() {
|
||||
a.classList.toggle("show-modal-captcha")
|
||||
}
|
||||
});
|
||||
function d() {
|
||||
a.classList.toggle('show-modal-captcha')
|
||||
}
|
||||
})
|
||||
|
||||
function receiveMessage(event){
|
||||
if (event.data.includes("paymenthash")){
|
||||
// console.log("paymenthash received: ", event.data);
|
||||
document.getElementById("captchapayhash").value = event.data.split("_")[1];
|
||||
}
|
||||
if (event.data.includes("removetheiframe")){
|
||||
if (event.data.includes("nok")){
|
||||
//invoice was NOT paid
|
||||
// console.log("receiveMessage not paid")
|
||||
document.getElementById("captchacheckbox").checked = false;
|
||||
}
|
||||
ciframeLoaded = !1;
|
||||
var element = document.getElementById('captcha-iframe');
|
||||
document.getElementsByClassName("modal-captcha-container")[0].classList.toggle("show-modal-captcha");
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
function receiveMessage(event) {
|
||||
if (event.data.includes('paymenthash')) {
|
||||
// console.log("paymenthash received: ", event.data);
|
||||
document.getElementById('captchapayhash').value = event.data.split('_')[1]
|
||||
}
|
||||
if (event.data.includes('removetheiframe')) {
|
||||
if (event.data.includes('nok')) {
|
||||
//invoice was NOT paid
|
||||
// console.log("receiveMessage not paid")
|
||||
document.getElementById('captchacheckbox').checked = false
|
||||
}
|
||||
ciframeLoaded = !1
|
||||
var element = document.getElementById('captcha-iframe')
|
||||
document
|
||||
.getElementsByClassName('modal-captcha-container')[0]
|
||||
.classList.toggle('show-modal-captcha')
|
||||
element.parentNode.removeChild(element)
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", receiveMessage, false);
|
||||
|
||||
|
||||
window.addEventListener('message', receiveMessage, false)
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
<q-btn outline color="grey" @click="copyText(paymentReq)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
<q-btn @click="cancelPayment(false)" flat color="grey" class="q-ml-auto"
|
||||
<q-btn
|
||||
@click="cancelPayment(false)"
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
@@ -58,7 +62,7 @@
|
||||
Captcha accepted. You are probably human.<br />
|
||||
<!-- <strong>{% raw %}{{ redirectUrl }}{% endraw %}</strong> -->
|
||||
</p>
|
||||
<!-- <div class="row q-mt-lg">
|
||||
<!-- <div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" type="a" :href="redirectUrl"
|
||||
>Open URL</q-btn>
|
||||
</div> -->
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<!-- <q-input
|
||||
<!-- <q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.url"
|
||||
@@ -148,7 +148,8 @@
|
||||
<q-item-label>Remember payments</q-item-label>
|
||||
<q-item-label caption
|
||||
>A succesful payment will be registered in the browser's
|
||||
storage, so the user doesn't need to pay again to prove they are human.</q-item-label
|
||||
storage, so the user doesn't need to pay again to prove they are
|
||||
human.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
@@ -173,7 +174,7 @@
|
||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||
{% raw %}
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<!-- <qrcode
|
||||
<!-- <qrcode
|
||||
:value="qrCodeDialog.data.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
@@ -181,12 +182,15 @@
|
||||
<code style="word-break: break-all">
|
||||
{{ qrCodeDialog.data.snippet }}
|
||||
</code>
|
||||
<p style="margin-top: 20px;">Copy the snippet above and paste into your website/form. The checkbox can be in checked state only after user pays.</p>
|
||||
<p style="margin-top: 20px">
|
||||
Copy the snippet above and paste into your website/form. The checkbox
|
||||
can be in checked state only after user pays.
|
||||
</p>
|
||||
</q-responsive>
|
||||
<p style="word-break: break-all">
|
||||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
||||
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
|
||||
<!-- <span v-if="qrCodeDialog.data.currency"
|
||||
<!-- <span v-if="qrCodeDialog.data.currency"
|
||||
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
|
||||
fiatRates[qrCodeDialog.data.currency] ?
|
||||
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
|
||||
@@ -305,7 +309,7 @@
|
||||
createCaptcha: function () {
|
||||
var data = {
|
||||
// url: this.formDialog.data.url,
|
||||
url: "http://dummy.com",
|
||||
url: 'http://dummy.com',
|
||||
memo: this.formDialog.data.memo,
|
||||
amount: this.formDialog.data.amount,
|
||||
description: this.formDialog.data.description,
|
||||
@@ -355,7 +359,7 @@
|
||||
})
|
||||
})
|
||||
},
|
||||
buildCaptchaSnippet: function(captchaId){
|
||||
buildCaptchaSnippet: function (captchaId) {
|
||||
var locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
@@ -363,14 +367,19 @@
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
var captchasnippet = '<!-- Captcha Checkbox Start -->\n'
|
||||
+ '<input type="checkbox" id="captchacheckbox">\n'
|
||||
+ '<label for="captchacheckbox">I\'m not a robot</label><br/>\n'
|
||||
+ '<input type="text" id="captchapayhash" style="display: none;"/>\n'
|
||||
+ '<script type="text/javascript" src="'+ locationPath +'static/js/captcha.js" id="captchascript" data-captchaid="'+ captchaId +'">\n'
|
||||
+ '<\/script>\n'
|
||||
+ '<!-- Captcha Checkbox End -->';
|
||||
return captchasnippet;
|
||||
var captchasnippet =
|
||||
'<!-- Captcha Checkbox Start -->\n' +
|
||||
'<input type="checkbox" id="captchacheckbox">\n' +
|
||||
'<label for="captchacheckbox">I\'m not a robot</label><br/>\n' +
|
||||
'<input type="text" id="captchapayhash" style="display: none;"/>\n' +
|
||||
'<script type="text/javascript" src="' +
|
||||
locationPath +
|
||||
'static/js/captcha.js" id="captchascript" data-captchaid="' +
|
||||
captchaId +
|
||||
'">\n' +
|
||||
'<\/script>\n' +
|
||||
'<!-- Captcha Checkbox End -->'
|
||||
return captchasnippet
|
||||
},
|
||||
openQrCodeDialog(captchaId) {
|
||||
// var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
@@ -380,9 +389,9 @@
|
||||
this.qrCodeDialog.data = {
|
||||
id: captcha.id,
|
||||
amount: captcha.amount,
|
||||
// (link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
|
||||
// ' ' +
|
||||
// (link.currency || 'sat'),
|
||||
// (link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
|
||||
// ' ' +
|
||||
// (link.currency || 'sat'),
|
||||
snippet: this.buildCaptchaSnippet(captcha.id)
|
||||
// currency: link.currency,
|
||||
// comments: link.comment_chars
|
||||
|
||||
@@ -16,5 +16,7 @@ async def index():
|
||||
|
||||
@captcha_ext.route("/<captcha_id>")
|
||||
async def display(captcha_id):
|
||||
captcha = await get_captcha(captcha_id) or abort(HTTPStatus.NOT_FOUND, "captcha does not exist.")
|
||||
captcha = await get_captcha(captcha_id) or abort(
|
||||
HTTPStatus.NOT_FOUND, "captcha does not exist."
|
||||
)
|
||||
return await render_template("captcha/display.html", captcha=captcha)
|
||||
|
||||
@@ -17,7 +17,10 @@ async def api_captchas():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@captcha_ext.route("/api/v1/captchas", methods=["POST"])
|
||||
@@ -26,7 +29,12 @@ async def api_captchas():
|
||||
schema={
|
||||
"url": {"type": "string", "empty": False, "required": True},
|
||||
"memo": {"type": "string", "empty": False, "required": True},
|
||||
"description": {"type": "string", "empty": True, "nullable": True, "required": False},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"empty": True,
|
||||
"nullable": True,
|
||||
"required": False,
|
||||
},
|
||||
"amount": {"type": "integer", "min": 0, "required": True},
|
||||
"remembers": {"type": "boolean", "required": True},
|
||||
}
|
||||
@@ -53,26 +61,41 @@ async def api_captcha_delete(captcha_id):
|
||||
|
||||
|
||||
@captcha_ext.route("/api/v1/captchas/<captcha_id>/invoice", methods=["POST"])
|
||||
@api_validate_post_request(schema={"amount": {"type": "integer", "min": 1, "required": True}})
|
||||
@api_validate_post_request(
|
||||
schema={"amount": {"type": "integer", "min": 1, "required": True}}
|
||||
)
|
||||
async def api_captcha_create_invoice(captcha_id):
|
||||
captcha = await get_captcha(captcha_id)
|
||||
|
||||
if g.data["amount"] < captcha.amount:
|
||||
return jsonify({"message": f"Minimum amount is {captcha.amount} sat."}), HTTPStatus.BAD_REQUEST
|
||||
return (
|
||||
jsonify({"message": f"Minimum amount is {captcha.amount} sat."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
amount = g.data["amount"] if g.data["amount"] > captcha.amount else captcha.amount
|
||||
amount = (
|
||||
g.data["amount"] if g.data["amount"] > captcha.amount else captcha.amount
|
||||
)
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=captcha.wallet, amount=amount, memo=f"{captcha.memo}", extra={"tag": "captcha"}
|
||||
wallet_id=captcha.wallet,
|
||||
amount=amount,
|
||||
memo=f"{captcha.memo}",
|
||||
extra={"tag": "captcha"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.CREATED
|
||||
return (
|
||||
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@captcha_ext.route("/api/v1/captchas/<captcha_id>/check_invoice", methods=["POST"])
|
||||
@api_validate_post_request(schema={"payment_hash": {"type": "string", "empty": False, "required": True}})
|
||||
@api_validate_post_request(
|
||||
schema={"payment_hash": {"type": "string", "empty": False, "required": True}}
|
||||
)
|
||||
async def api_paywal_check_invoice(captcha_id):
|
||||
captcha = await get_captcha(captcha_id)
|
||||
|
||||
@@ -90,6 +113,9 @@ async def api_paywal_check_invoice(captcha_id):
|
||||
payment = await wallet.get_payment(g.data["payment_hash"])
|
||||
await payment.set_pending(False)
|
||||
|
||||
return jsonify({"paid": True, "url": captcha.url, "remembers": captcha.remembers}), HTTPStatus.OK
|
||||
return (
|
||||
jsonify({"paid": True, "url": captcha.url, "remembers": captcha.remembers}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates")
|
||||
diagonalley_ext: Blueprint = Blueprint(
|
||||
"diagonalley", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -21,7 +21,14 @@ regex = re.compile(
|
||||
|
||||
|
||||
def create_diagonalleys_product(
|
||||
*, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int
|
||||
*,
|
||||
wallet_id: str,
|
||||
product: str,
|
||||
categories: str,
|
||||
description: str,
|
||||
image: str,
|
||||
price: int,
|
||||
quantity: int,
|
||||
) -> Products:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
@@ -30,7 +37,16 @@ def create_diagonalleys_product(
|
||||
INSERT INTO products (id, wallet, product, categories, description, image, price, quantity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(product_id, wallet_id, product, categories, description, image, price, quantity),
|
||||
(
|
||||
product_id,
|
||||
wallet_id,
|
||||
product,
|
||||
categories,
|
||||
description,
|
||||
image,
|
||||
price,
|
||||
quantity,
|
||||
),
|
||||
)
|
||||
|
||||
return get_diagonalleys_product(product_id)
|
||||
@@ -40,7 +56,9 @@ def update_diagonalleys_product(product_id: str, **kwargs) -> Optional[Indexers]
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id))
|
||||
db.execute(
|
||||
f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id)
|
||||
)
|
||||
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
|
||||
|
||||
return get_diagonalleys_indexer(product_id)
|
||||
@@ -59,7 +77,9 @@ def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Product
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Products(**row) for row in rows]
|
||||
|
||||
@@ -110,7 +130,9 @@ def update_diagonalleys_indexer(indexer_id: str, **kwargs) -> Optional[Indexers]
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id))
|
||||
db.execute(
|
||||
f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id)
|
||||
)
|
||||
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
|
||||
|
||||
return get_diagonalleys_indexer(indexer_id)
|
||||
@@ -154,7 +176,9 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
for r in rows:
|
||||
try:
|
||||
@@ -181,7 +205,9 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
|
||||
print("An exception occurred")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Indexers(**row) for row in rows]
|
||||
|
||||
|
||||
@@ -213,7 +239,19 @@ def create_diagonalleys_order(
|
||||
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(order_id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, False, False),
|
||||
(
|
||||
order_id,
|
||||
productid,
|
||||
wallet,
|
||||
product,
|
||||
quantity,
|
||||
shippingzone,
|
||||
address,
|
||||
email,
|
||||
invoiceid,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
)
|
||||
|
||||
return get_diagonalleys_order(order_id)
|
||||
@@ -232,9 +270,11 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
for r in rows:
|
||||
PAID = WALLET.get_invoice_status(r["invoiceid"]).paid
|
||||
PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid
|
||||
if PAID:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
@@ -244,7 +284,9 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
|
||||
r["id"],
|
||||
),
|
||||
)
|
||||
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Orders(**row) for row in rows]
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,12 @@ async def api_diagonalley_products():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return jsonify([product._asdict() for product in get_diagonalleys_products(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify(
|
||||
[product._asdict() for product in get_diagonalleys_products(wallet_ids)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"])
|
||||
@@ -58,16 +63,25 @@ async def api_diagonalley_product_create(product_id=None):
|
||||
product = get_diagonalleys_indexer(product_id)
|
||||
|
||||
if not product:
|
||||
return jsonify({"message": "Withdraw product does not exist."}), HTTPStatus.NOT_FOUND
|
||||
return (
|
||||
jsonify({"message": "Withdraw product does not exist."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if product.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your withdraw product."}), HTTPStatus.FORBIDDEN
|
||||
return (
|
||||
jsonify({"message": "Not your withdraw product."}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
product = update_diagonalleys_product(product_id, **g.data)
|
||||
else:
|
||||
product = create_diagonalleys_product(wallet_id=g.wallet.id, **g.data)
|
||||
|
||||
return jsonify(product._asdict()), HTTPStatus.OK if product_id else HTTPStatus.CREATED
|
||||
return (
|
||||
jsonify(product._asdict()),
|
||||
HTTPStatus.OK if product_id else HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/products/<product_id>", methods=["DELETE"])
|
||||
@@ -97,7 +111,12 @@ async def api_diagonalley_indexers():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return jsonify([indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify(
|
||||
[indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"])
|
||||
@@ -120,16 +139,25 @@ async def api_diagonalley_indexer_create(indexer_id=None):
|
||||
indexer = get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
if not indexer:
|
||||
return jsonify({"message": "Withdraw indexer does not exist."}), HTTPStatus.NOT_FOUND
|
||||
return (
|
||||
jsonify({"message": "Withdraw indexer does not exist."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if indexer.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your withdraw indexer."}), HTTPStatus.FORBIDDEN
|
||||
return (
|
||||
jsonify({"message": "Not your withdraw indexer."}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
indexer = update_diagonalleys_indexer(indexer_id, **g.data)
|
||||
else:
|
||||
indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **g.data)
|
||||
|
||||
return jsonify(indexer._asdict()), HTTPStatus.OK if indexer_id else HTTPStatus.CREATED
|
||||
return (
|
||||
jsonify(indexer._asdict()),
|
||||
HTTPStatus.OK if indexer_id else HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/indexers/<indexer_id>", methods=["DELETE"])
|
||||
@@ -159,7 +187,10 @@ async def api_diagonalley_orders():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"])
|
||||
@@ -221,13 +252,20 @@ async def api_diagonalleys_order_shipped(order_id):
|
||||
)
|
||||
order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
|
||||
|
||||
return jsonify([order._asdict() for order in get_diagonalleys_orders(order["wallet"])]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify(
|
||||
[order._asdict() for order in get_diagonalleys_orders(order["wallet"])]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
###List products based on indexer id
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"])
|
||||
@diagonalley_ext.route(
|
||||
"/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"]
|
||||
)
|
||||
async def api_diagonalleys_stall_products(indexer_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
rows = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
|
||||
@@ -239,13 +277,20 @@ async def api_diagonalleys_stall_products(indexer_id):
|
||||
if not products:
|
||||
return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND
|
||||
|
||||
return jsonify([products._asdict() for products in get_diagonalleys_products(rows[1])]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify(
|
||||
[products._asdict() for products in get_diagonalleys_products(rows[1])]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
###Check a product has been shipped
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"])
|
||||
@diagonalley_ext.route(
|
||||
"/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"]
|
||||
)
|
||||
async def api_diagonalleys_stall_checkshipped(checking_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,))
|
||||
@@ -276,7 +321,9 @@ async def api_diagonalley_stall_order(indexer_id):
|
||||
shippingcost = shipping.zone2cost
|
||||
|
||||
checking_id, payment_request = create_invoice(
|
||||
wallet_id=product.wallet, amount=shippingcost + (g.data["quantity"] * product.price), memo=g.data["id"]
|
||||
wallet_id=product.wallet,
|
||||
amount=shippingcost + (g.data["quantity"] * product.price),
|
||||
memo=g.data["id"],
|
||||
)
|
||||
selling_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
@@ -299,4 +346,7 @@ async def api_diagonalley_stall_order(indexer_id):
|
||||
False,
|
||||
),
|
||||
)
|
||||
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK
|
||||
return (
|
||||
jsonify({"checking_id": checking_id, "payment_request": payment_request}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@ from lnbits.db import Database
|
||||
db = Database("ext_events")
|
||||
|
||||
|
||||
events_ext: Blueprint = Blueprint("events", __name__, static_folder="static", template_folder="templates")
|
||||
events_ext: Blueprint = Blueprint(
|
||||
"events", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -9,7 +9,9 @@ from .models import Tickets, Events
|
||||
# TICKETS
|
||||
|
||||
|
||||
async def create_ticket(payment_hash: str, wallet: str, event: str, name: str, email: str) -> Tickets:
|
||||
async def create_ticket(
|
||||
payment_hash: str, wallet: str, event: str, name: str, email: str
|
||||
) -> Tickets:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ticket (id, wallet, event, name, email, registered, paid)
|
||||
@@ -64,7 +66,9 @@ async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Tickets(**row) for row in rows]
|
||||
|
||||
|
||||
@@ -113,7 +117,9 @@ async def create_event(
|
||||
|
||||
async def update_event(event_id: str, **kwargs) -> Events:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(f"UPDATE events SET {q} WHERE id = ?", (*kwargs.values(), event_id))
|
||||
await db.execute(
|
||||
f"UPDATE events SET {q} WHERE id = ?", (*kwargs.values(), event_id)
|
||||
)
|
||||
event = await get_event(event_id)
|
||||
assert event, "Newly updated event couldn't be retrieved"
|
||||
return event
|
||||
@@ -129,7 +135,9 @@ async def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]:
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(f"SELECT * FROM events WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM events WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Events(**row) for row in rows]
|
||||
|
||||
@@ -142,7 +150,9 @@ async def delete_event(event_id: str) -> None:
|
||||
|
||||
|
||||
async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]:
|
||||
rows = await db.fetchall("SELECT * FROM ticket WHERE wallet = ? AND event = ?", (wallet_id, event_id))
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM ticket WHERE wallet = ? AND event = ?", (wallet_id, event_id)
|
||||
)
|
||||
return [Tickets(**row) for row in rows]
|
||||
|
||||
|
||||
|
||||
@@ -23,12 +23,16 @@ async def display(event_id):
|
||||
|
||||
if event.amount_tickets < 1:
|
||||
return await render_template(
|
||||
"events/error.html", event_name=event.name, event_error="Sorry, tickets are sold out :("
|
||||
"events/error.html",
|
||||
event_name=event.name,
|
||||
event_error="Sorry, tickets are sold out :(",
|
||||
)
|
||||
datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date()
|
||||
if date.today() > datetime_object:
|
||||
return await render_template(
|
||||
"events/error.html", event_name=event.name, event_error="Sorry, ticket closing date has passed :("
|
||||
"events/error.html",
|
||||
event_name=event.name,
|
||||
event_error="Sorry, ticket closing date has passed :(",
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
@@ -51,7 +55,10 @@ async def ticket(ticket_id):
|
||||
abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
|
||||
return await render_template(
|
||||
"events/ticket.html", ticket_id=ticket_id, ticket_name=event.name, ticket_info=event.info
|
||||
"events/ticket.html",
|
||||
ticket_id=ticket_id,
|
||||
ticket_name=event.name,
|
||||
ticket_info=event.info,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,5 +69,8 @@ async def register(event_id):
|
||||
abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
|
||||
return await render_template(
|
||||
"events/register.html", event_id=event_id, event_name=event.name, wallet_id=event.wallet
|
||||
"events/register.html",
|
||||
event_id=event_id,
|
||||
event_name=event.name,
|
||||
wallet_id=event.wallet,
|
||||
)
|
||||
|
||||
@@ -33,7 +33,10 @@ async def api_events():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return jsonify([event._asdict() for event in await get_events(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([event._asdict() for event in await get_events(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/events", methods=["POST"])
|
||||
@@ -92,7 +95,10 @@ async def api_tickets():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return jsonify([ticket._asdict() for ticket in await get_tickets(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([ticket._asdict() for ticket in await get_tickets(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/tickets/<event_id>/<sats>", methods=["POST"])
|
||||
@@ -108,17 +114,25 @@ async def api_ticket_make_ticket(event_id, sats):
|
||||
return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=event.wallet, amount=int(sats), memo=f"{event_id}", extra={"tag": "events"}
|
||||
wallet_id=event.wallet,
|
||||
amount=int(sats),
|
||||
memo=f"{event_id}",
|
||||
extra={"tag": "events"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
ticket = await create_ticket(payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data)
|
||||
ticket = await create_ticket(
|
||||
payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data
|
||||
)
|
||||
|
||||
if not ticket:
|
||||
return jsonify({"message": "Event could not be fetched."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK
|
||||
return (
|
||||
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/tickets/<payment_hash>", methods=["GET"])
|
||||
@@ -163,7 +177,14 @@ async def api_ticket_delete(ticket_id):
|
||||
@events_ext.route("/api/v1/eventtickets/<wallet_id>/<event_id>", methods=["GET"])
|
||||
async def api_event_tickets(wallet_id, event_id):
|
||||
return (
|
||||
jsonify([ticket._asdict() for ticket in await get_event_tickets(wallet_id=wallet_id, event_id=event_id)]),
|
||||
jsonify(
|
||||
[
|
||||
ticket._asdict()
|
||||
for ticket in await get_event_tickets(
|
||||
wallet_id=wallet_id, event_id=event_id
|
||||
)
|
||||
]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
@@ -177,4 +198,7 @@ async def api_event_register_ticket(ticket_id):
|
||||
if ticket.registered == True:
|
||||
return jsonify({"message": "Ticket already registered"}), HTTPStatus.FORBIDDEN
|
||||
|
||||
return jsonify([ticket._asdict() for ticket in await reg_ticket(ticket_id)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([ticket._asdict() for ticket in await reg_ticket(ticket_id)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,9 @@ from lnbits.db import Database
|
||||
|
||||
db = Database("ext_example")
|
||||
|
||||
example_ext: Blueprint = Blueprint("example", __name__, static_folder="static", template_folder="templates")
|
||||
example_ext: Blueprint = Blueprint(
|
||||
"example", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#async def m001_initial(db):
|
||||
# async def m001_initial(db):
|
||||
|
||||
# await db.execute(
|
||||
# """
|
||||
@@ -8,4 +8,4 @@
|
||||
# time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
# );
|
||||
# """
|
||||
# )
|
||||
# )
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#from sqlite3 import Row
|
||||
#from typing import NamedTuple
|
||||
# from sqlite3 import Row
|
||||
# from typing import NamedTuple
|
||||
|
||||
|
||||
#class Example(NamedTuple):
|
||||
# class Example(NamedTuple):
|
||||
# id: str
|
||||
# wallet: str
|
||||
#
|
||||
# @classmethod
|
||||
# def from_row(cls, row: Row) -> "Example":
|
||||
# return cls(**dict(row))
|
||||
# return cls(**dict(row))
|
||||
|
||||
@@ -3,7 +3,9 @@ from lnbits.db import Database
|
||||
|
||||
db = Database("ext_lndhub")
|
||||
|
||||
lndhub_ext: Blueprint = Blueprint("lndhub", __name__, static_folder="static", template_folder="templates")
|
||||
lndhub_ext: Blueprint = Blueprint(
|
||||
"lndhub", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -13,11 +13,15 @@ def check_wallet(requires_admin=False):
|
||||
key_type, key = b64decode(token).decode("utf-8").split(":")
|
||||
|
||||
if requires_admin and key_type != "admin":
|
||||
return jsonify({"error": True, "code": 2, "message": "insufficient permissions"})
|
||||
return jsonify(
|
||||
{"error": True, "code": 2, "message": "insufficient permissions"}
|
||||
)
|
||||
|
||||
g.wallet = await get_wallet_for_key(key, key_type)
|
||||
if not g.wallet:
|
||||
return jsonify({"error": True, "code": 2, "message": "insufficient permissions"})
|
||||
return jsonify(
|
||||
{"error": True, "code": 2, "message": "insufficient permissions"}
|
||||
)
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
@@ -23,14 +23,20 @@ async def lndhub_getinfo():
|
||||
schema={
|
||||
"login": {"type": "string", "required": True, "excludes": "refresh_token"},
|
||||
"password": {"type": "string", "required": True, "excludes": "refresh_token"},
|
||||
"refresh_token": {"type": "string", "required": True, "excludes": ["login", "password"]},
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"excludes": ["login", "password"],
|
||||
},
|
||||
}
|
||||
)
|
||||
async def lndhub_auth():
|
||||
token = (
|
||||
g.data["refresh_token"]
|
||||
if "refresh_token" in g.data and g.data["refresh_token"]
|
||||
else urlsafe_b64encode((g.data["login"] + ":" + g.data["password"]).encode("utf-8")).decode("ascii")
|
||||
else urlsafe_b64encode(
|
||||
(g.data["login"] + ":" + g.data["password"]).encode("utf-8")
|
||||
).decode("ascii")
|
||||
)
|
||||
return jsonify({"refresh_token": token, "access_token": token})
|
||||
|
||||
@@ -120,9 +126,15 @@ async def lndhub_balance():
|
||||
@check_wallet()
|
||||
async def lndhub_gettxs():
|
||||
for payment in await g.wallet.get_payments(
|
||||
complete=False, pending=True, outgoing=True, incoming=False, exclude_uncheckable=True
|
||||
complete=False,
|
||||
pending=True,
|
||||
outgoing=True,
|
||||
incoming=False,
|
||||
exclude_uncheckable=True,
|
||||
):
|
||||
await payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
|
||||
await payment.set_pending(
|
||||
(await WALLET.get_payment_status(payment.checking_id)).pending
|
||||
)
|
||||
|
||||
limit = int(request.args.get("limit", 200))
|
||||
return jsonify(
|
||||
@@ -135,10 +147,16 @@ async def lndhub_gettxs():
|
||||
"fee": payment.fee,
|
||||
"value": int(payment.amount / 1000),
|
||||
"timestamp": payment.time,
|
||||
"memo": payment.memo if not payment.pending else "Payment in transition",
|
||||
"memo": payment.memo
|
||||
if not payment.pending
|
||||
else "Payment in transition",
|
||||
}
|
||||
for payment in reversed(
|
||||
(await g.wallet.get_payments(pending=True, complete=True, outgoing=True, incoming=False))[:limit]
|
||||
(
|
||||
await g.wallet.get_payments(
|
||||
pending=True, complete=True, outgoing=True, incoming=False
|
||||
)
|
||||
)[:limit]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -149,9 +167,15 @@ async def lndhub_gettxs():
|
||||
async def lndhub_getuserinvoices():
|
||||
await delete_expired_invoices()
|
||||
for invoice in await g.wallet.get_payments(
|
||||
complete=False, pending=True, outgoing=False, incoming=True, exclude_uncheckable=True
|
||||
complete=False,
|
||||
pending=True,
|
||||
outgoing=False,
|
||||
incoming=True,
|
||||
exclude_uncheckable=True,
|
||||
):
|
||||
await invoice.set_pending(WALLET.get_invoice_status(invoice.checking_id).pending)
|
||||
await invoice.set_pending(
|
||||
(await WALLET.get_invoice_status(invoice.checking_id)).pending
|
||||
)
|
||||
|
||||
limit = int(request.args.get("limit", 200))
|
||||
return jsonify(
|
||||
@@ -169,7 +193,11 @@ async def lndhub_getuserinvoices():
|
||||
"type": "user_invoice",
|
||||
}
|
||||
for invoice in reversed(
|
||||
(await g.wallet.get_payments(pending=True, complete=True, incoming=True, outgoing=False))[:limit]
|
||||
(
|
||||
await g.wallet.get_payments(
|
||||
pending=True, complete=True, incoming=True, outgoing=False
|
||||
)
|
||||
)[:limit]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -3,7 +3,9 @@ from lnbits.db import Database
|
||||
|
||||
db = Database("ext_lnticket")
|
||||
|
||||
lnticket_ext: Blueprint = Blueprint("lnticket", __name__, static_folder="static", template_folder="templates")
|
||||
lnticket_ext: Blueprint = Blueprint(
|
||||
"lnticket", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from . import db
|
||||
from .models import Tickets, Forms
|
||||
import httpx
|
||||
|
||||
|
||||
async def create_ticket(
|
||||
payment_hash: str,
|
||||
wallet: str,
|
||||
@@ -52,26 +53,27 @@ async def set_ticket_paid(payment_hash: str) -> Tickets:
|
||||
""",
|
||||
(amount, row[1]),
|
||||
)
|
||||
|
||||
|
||||
ticket = await get_ticket(payment_hash)
|
||||
assert ticket, "Newly paid ticket could not be retrieved"
|
||||
|
||||
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
|
||||
await client.post(
|
||||
formdata.webhook,
|
||||
json={
|
||||
"form": ticket.form,
|
||||
"name": ticket.name,
|
||||
"email": ticket.email,
|
||||
"content": ticket.ltext,
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
return ticket
|
||||
|
||||
ticket = await get_ticket(payment_hash)
|
||||
return
|
||||
assert ticket, "Newly paid ticket could not be retrieved"
|
||||
return ticket
|
||||
|
||||
|
||||
async def get_ticket(ticket_id: str) -> Optional[Tickets]:
|
||||
@@ -84,7 +86,9 @@ async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Tickets(**row) for row in rows]
|
||||
|
||||
@@ -96,7 +100,14 @@ 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(
|
||||
"""
|
||||
@@ -129,7 +140,9 @@ async def get_forms(wallet_ids: Union[str, List[str]]) -> List[Forms]:
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(f"SELECT * FROM form WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM form WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Forms(**row) for row in rows]
|
||||
|
||||
|
||||
@@ -102,7 +102,6 @@ async def m003_changed(db):
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
for row in [list(row) for row in await db.fetchall("SELECT * FROM forms")]:
|
||||
usescsv = ""
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -32,7 +32,10 @@ async def api_forms():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return jsonify([form._asdict() for form in await get_forms(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([form._asdict() for form in await get_forms(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@lnticket_ext.route("/api/v1/forms", methods=["POST"])
|
||||
@@ -90,7 +93,10 @@ async def api_tickets():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return jsonify([form._asdict() for form in await get_tickets(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([form._asdict() for form in await get_tickets(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@lnticket_ext.route("/api/v1/tickets/<form_id>", methods=["POST"])
|
||||
@@ -110,19 +116,31 @@ async def api_ticket_make_ticket(form_id):
|
||||
|
||||
nwords = len(re.split(r"\s+", g.data["ltext"]))
|
||||
sats = g.data["sats"]
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=form.wallet,
|
||||
amount=sats,
|
||||
memo=f"ticket with {nwords} words on {form_id}",
|
||||
extra={"tag": "lnticket"},
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=form.wallet,
|
||||
amount=sats,
|
||||
memo=f"ticket with {nwords} words on {form_id}",
|
||||
extra={"tag": "lnticket"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
ticket = await create_ticket(
|
||||
payment_hash=payment_hash, wallet=form.wallet, **g.data
|
||||
)
|
||||
|
||||
ticket = await create_ticket(payment_hash=payment_hash, wallet=form.wallet, **g.data)
|
||||
|
||||
if not ticket:
|
||||
return jsonify({"message": "LNTicket could not be fetched."}), HTTPStatus.NOT_FOUND
|
||||
return (
|
||||
jsonify({"message": "LNTicket could not be fetched."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK
|
||||
return (
|
||||
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@lnticket_ext.route("/api/v1/tickets/<payment_hash>", methods=["GET"])
|
||||
|
||||
@@ -3,7 +3,9 @@ from lnbits.db import Database
|
||||
|
||||
db = Database("ext_lnurlp")
|
||||
|
||||
lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", template_folder="templates")
|
||||
lnurlp_ext: Blueprint = Blueprint(
|
||||
"lnurlp", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -74,14 +74,18 @@ async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
|
||||
|
||||
async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
|
||||
await db.execute(
|
||||
f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
||||
async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
|
||||
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
|
||||
await db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
|
||||
await db.execute(
|
||||
f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import trio # type: ignore
|
||||
import httpx
|
||||
|
||||
|
||||
async def get_fiat_rate(currency: str):
|
||||
assert currency == "USD", "Only USD is supported as fiat currency."
|
||||
return await get_usd_rate()
|
||||
|
||||
|
||||
async def get_usd_rate():
|
||||
"""
|
||||
Returns an average satoshi price from multiple sources.
|
||||
"""
|
||||
|
||||
satoshi_prices = [None, None, None]
|
||||
|
||||
async def fetch_price(index, url, getter):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
satoshi_price = int(100_000_000 / float(getter(r.json())))
|
||||
satoshi_prices[index] = satoshi_price
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with trio.open_nursery() as nursery:
|
||||
nursery.start_soon(
|
||||
fetch_price,
|
||||
0,
|
||||
"https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD",
|
||||
lambda d: d["result"]["XXBTCZUSD"]["c"][0],
|
||||
)
|
||||
nursery.start_soon(
|
||||
fetch_price,
|
||||
1,
|
||||
"https://www.bitstamp.net/api/v2/ticker/btcusd",
|
||||
lambda d: d["last"],
|
||||
)
|
||||
nursery.start_soon(
|
||||
fetch_price,
|
||||
2,
|
||||
"https://api.coincap.io/v2/rates/bitcoin",
|
||||
lambda d: d["data"]["rateUsd"],
|
||||
)
|
||||
|
||||
satoshi_prices = [x for x in satoshi_prices if x]
|
||||
return sum(satoshi_prices) / len(satoshi_prices)
|
||||
@@ -5,19 +5,22 @@ from quart import jsonify, url_for, request
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
|
||||
|
||||
from . import lnurlp_ext
|
||||
from .crud import increment_pay_link
|
||||
from .helpers import get_fiat_rate
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
|
||||
async def api_lnurl_response(link_id):
|
||||
link = await increment_pay_link(link_id, served_meta=1)
|
||||
if not link:
|
||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||
return (
|
||||
jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
rate = await get_fiat_rate(link.currency) if link.currency else 1
|
||||
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True),
|
||||
min_sendable=math.ceil(link.min * rate) * 1000,
|
||||
@@ -36,10 +39,13 @@ async def api_lnurl_response(link_id):
|
||||
async def api_lnurl_callback(link_id):
|
||||
link = await increment_pay_link(link_id, served_pr=1)
|
||||
if not link:
|
||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||
return (
|
||||
jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
min, max = link.min, link.max
|
||||
rate = await get_fiat_rate(link.currency) if link.currency else 1
|
||||
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||
if link.currency:
|
||||
# allow some fluctuation (as the fiat price may have changed between the calls)
|
||||
min = rate * 995 * link.min
|
||||
@@ -51,12 +57,20 @@ async def api_lnurl_callback(link_id):
|
||||
amount_received = int(request.args.get("amount"))
|
||||
if amount_received < min:
|
||||
return (
|
||||
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is smaller than minimum {min}.").dict()),
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {amount_received} is smaller than minimum {min}."
|
||||
).dict()
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
elif amount_received > max:
|
||||
return (
|
||||
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict()),
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {amount_received} is greater than maximum {max}."
|
||||
).dict()
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
@@ -75,7 +89,9 @@ async def api_lnurl_callback(link_id):
|
||||
wallet_id=link.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=link.description,
|
||||
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
|
||||
description_hash=hashlib.sha256(
|
||||
link.lnurlpay_metadata.encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "lnurlp", "link": link.id, "comment": comment},
|
||||
)
|
||||
|
||||
|
||||
@@ -40,8 +40,12 @@ async def m003_min_max_comment_fiat(db):
|
||||
Support for min/max amounts, comments and fiat prices that get
|
||||
converted automatically to satoshis based on some API.
|
||||
"""
|
||||
await db.execute("ALTER TABLE pay_links ADD COLUMN currency TEXT;") # null = satoshis
|
||||
await db.execute("ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;")
|
||||
await db.execute(
|
||||
"ALTER TABLE pay_links ADD COLUMN currency TEXT;"
|
||||
) # null = satoshis
|
||||
await db.execute(
|
||||
"ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;"
|
||||
)
|
||||
await db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;")
|
||||
await db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;")
|
||||
await db.execute("UPDATE pay_links SET max = min;")
|
||||
|
||||
@@ -26,6 +26,7 @@ new Vue({
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
currencies: [],
|
||||
fiatRates: {},
|
||||
checker: null,
|
||||
payLinks: [],
|
||||
@@ -203,5 +204,14 @@ new Vue({
|
||||
getPayLinks()
|
||||
}, 20000)
|
||||
}
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/lnurlp/api/v1/currencies')
|
||||
.then(response => {
|
||||
this.currencies = ['satoshis', ...response.data]
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<code>[<pay_link_object>, ...]</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": <string>}</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/<pay_id>
|
||||
-H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
>curl -X GET {{ request.url_root }}api/v1/links/<pay_id> -H
|
||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -63,10 +63,9 @@
|
||||
<code>{"lnurl": <string>}</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": <string>, "amount": <integer>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
>curl -X POST {{ request.url_root }}api/v1/links -d '{"description":
|
||||
<string>, "amount": <integer>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -93,8 +92,8 @@
|
||||
<code>{"lnurl": <string>}</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/<pay_id>
|
||||
-d '{"description": <string>, "amount": <integer>}' -H
|
||||
>curl -X PUT {{ request.url_root }}api/v1/links/<pay_id> -d
|
||||
'{"description": <string>, "amount": <integer>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</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/<pay_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
>curl -X DELETE {{ request.url_root }}api/v1/links/<pay_id> -H
|
||||
"X-Api-Key: {{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
@@ -182,7 +182,7 @@
|
||||
<div class="col">
|
||||
<q-select
|
||||
dense
|
||||
:options='["satoshis", "USD"]'
|
||||
:options="currencies"
|
||||
v-model="formDialog.data.currency"
|
||||
:display-value="formDialog.data.currency || 'satoshis'"
|
||||
label="Currency"
|
||||
|
||||
@@ -4,6 +4,7 @@ from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
|
||||
|
||||
from . import lnurlp_ext
|
||||
from .crud import (
|
||||
@@ -13,7 +14,11 @@ from .crud import (
|
||||
update_pay_link,
|
||||
delete_pay_link,
|
||||
)
|
||||
from .helpers import get_fiat_rate
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/currencies", methods=["GET"])
|
||||
async def api_list_currencies_available():
|
||||
return jsonify(list(currencies.keys()))
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
|
||||
@@ -26,12 +31,21 @@ async def api_links():
|
||||
|
||||
try:
|
||||
return (
|
||||
jsonify([{**link._asdict(), **{"lnurl": link.lnurl}} for link in await get_pay_links(wallet_ids)]),
|
||||
jsonify(
|
||||
[
|
||||
{**link._asdict(), **{"lnurl": link.lnurl}}
|
||||
for link in await get_pay_links(wallet_ids)
|
||||
]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except LnurlInvalidUrl:
|
||||
return (
|
||||
jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}),
|
||||
jsonify(
|
||||
{
|
||||
"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
|
||||
}
|
||||
),
|
||||
HTTPStatus.UPGRADE_REQUIRED,
|
||||
)
|
||||
|
||||
@@ -58,7 +72,7 @@ async def api_link_retrieve(link_id):
|
||||
"description": {"type": "string", "empty": False, "required": True},
|
||||
"min": {"type": "number", "min": 0.01, "required": True},
|
||||
"max": {"type": "number", "min": 0.01, "required": True},
|
||||
"currency": {"type": "string", "allowed": ["USD"], "nullable": True, "required": False},
|
||||
"currency": {"type": "string", "nullable": True, "required": False},
|
||||
"comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800},
|
||||
"webhook_url": {"type": "string", "required": False},
|
||||
"success_text": {"type": "string", "required": False},
|
||||
@@ -78,7 +92,10 @@ async def api_link_create_or_update(link_id=None):
|
||||
link = await get_pay_link(link_id)
|
||||
|
||||
if not link:
|
||||
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND
|
||||
return (
|
||||
jsonify({"message": "Pay link does not exist."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if link.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN
|
||||
@@ -87,7 +104,10 @@ async def api_link_create_or_update(link_id=None):
|
||||
else:
|
||||
link = await create_pay_link(wallet_id=g.wallet.id, **g.data)
|
||||
|
||||
return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK if link_id else HTTPStatus.CREATED
|
||||
return (
|
||||
jsonify({**link._asdict(), **{"lnurl": link.lnurl}}),
|
||||
HTTPStatus.OK if link_id else HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/links/<link_id>", methods=["DELETE"])
|
||||
@@ -109,7 +129,7 @@ async def api_link_delete(link_id):
|
||||
@lnurlp_ext.route("/api/v1/rate/<currency>", methods=["GET"])
|
||||
async def api_check_fiat_rate(currency):
|
||||
try:
|
||||
rate = await get_fiat_rate(currency)
|
||||
rate = await get_fiat_rate_satoshis(currency)
|
||||
except AssertionError:
|
||||
rate = None
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Offline Shop
|
||||
@@ -0,0 +1,14 @@
|
||||
from quart import Blueprint
|
||||
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_offlineshop")
|
||||
|
||||
offlineshop_ext: Blueprint = Blueprint(
|
||||
"offlineshop", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .lnurl import * # noqa
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "OfflineShop",
|
||||
"short_description": "Receive payments for products offline!",
|
||||
"icon": "nature_people",
|
||||
"contributors": [
|
||||
"fiatjaf"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from . import db
|
||||
from .wordlists import animals
|
||||
from .models import Shop, Item
|
||||
|
||||
|
||||
async def create_shop(*, wallet_id: str) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO shops (wallet, wordlist, method)
|
||||
VALUES (?, ?, 'wordlist')
|
||||
""",
|
||||
(wallet_id, "\n".join(animals)),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def get_shop(id: int) -> Optional[Shop]:
|
||||
row = await db.fetchone("SELECT * FROM shops WHERE id = ?", (id,))
|
||||
return Shop(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]:
|
||||
row = await db.fetchone("SELECT * FROM shops WHERE wallet = ?", (wallet,))
|
||||
|
||||
if not row:
|
||||
# create on the fly
|
||||
ls_id = await create_shop(wallet_id=wallet)
|
||||
return await get_shop(ls_id)
|
||||
|
||||
return Shop(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]:
|
||||
await db.execute(
|
||||
"UPDATE shops SET method = ?, wordlist = ? WHERE id = ?",
|
||||
(method, wordlist, shop),
|
||||
)
|
||||
return await get_shop(shop)
|
||||
|
||||
|
||||
async def add_item(
|
||||
shop: int,
|
||||
name: str,
|
||||
description: str,
|
||||
image: Optional[str],
|
||||
price: int,
|
||||
unit: str,
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO items (shop, name, description, image, price, unit)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(shop, name, description, image, price, unit),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def update_item(
|
||||
shop: int,
|
||||
item_id: int,
|
||||
name: str,
|
||||
description: str,
|
||||
image: Optional[str],
|
||||
price: int,
|
||||
unit: str,
|
||||
) -> int:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE items SET
|
||||
name = ?,
|
||||
description = ?,
|
||||
image = ?,
|
||||
price = ?,
|
||||
unit = ?
|
||||
WHERE shop = ? AND id = ?
|
||||
""",
|
||||
(name, description, image, price, unit, shop, item_id),
|
||||
)
|
||||
return item_id
|
||||
|
||||
|
||||
async def get_item(id: int) -> Optional[Item]:
|
||||
row = await db.fetchone("SELECT * FROM items WHERE id = ? LIMIT 1", (id,))
|
||||
return Item(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_items(shop: int) -> List[Item]:
|
||||
rows = await db.fetchall("SELECT * FROM items WHERE shop = ?", (shop,))
|
||||
return [Item(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def delete_item_from_shop(shop: int, item_id: int):
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM items WHERE shop = ? AND id = ?
|
||||
""",
|
||||
(shop, item_id),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import base64
|
||||
import struct
|
||||
import hmac
|
||||
import time
|
||||
|
||||
|
||||
def hotp(key, counter, digits=6, digest="sha1"):
|
||||
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
|
||||
counter = struct.pack(">Q", counter)
|
||||
mac = hmac.new(key, counter, digest).digest()
|
||||
offset = mac[-1] & 0x0F
|
||||
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
return str(binary)[-digits:].zfill(digits)
|
||||
|
||||
|
||||
def totp(key, time_step=30, digits=6, digest="sha1"):
|
||||
return hotp(key, int(time.time() / time_step), digits, digest)
|
||||
@@ -0,0 +1,87 @@
|
||||
import hashlib
|
||||
from quart import jsonify, url_for, request
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
from . import offlineshop_ext
|
||||
from .crud import get_shop, get_item
|
||||
|
||||
|
||||
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
|
||||
async def lnurl_response(item_id):
|
||||
item = await get_item(item_id)
|
||||
if not item:
|
||||
return jsonify({"status": "ERROR", "reason": "Item not found."})
|
||||
|
||||
if not item.enabled:
|
||||
return jsonify({"status": "ERROR", "reason": "Item disabled."})
|
||||
|
||||
price_msat = (
|
||||
await fiat_amount_as_satoshis(item.price, item.unit)
|
||||
if item.unit != "sat"
|
||||
else item.price
|
||||
) * 1000
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True),
|
||||
min_sendable=price_msat,
|
||||
max_sendable=price_msat,
|
||||
metadata=await item.lnurlpay_metadata(),
|
||||
)
|
||||
|
||||
return jsonify(resp.dict())
|
||||
|
||||
|
||||
@offlineshop_ext.route("/lnurl/cb/<item_id>", methods=["GET"])
|
||||
async def lnurl_callback(item_id):
|
||||
item = await get_item(item_id)
|
||||
if not item:
|
||||
return jsonify({"status": "ERROR", "reason": "Couldn't find item."})
|
||||
|
||||
if item.unit == "sat":
|
||||
min = item.price * 1000
|
||||
max = item.price * 1000
|
||||
else:
|
||||
price = await fiat_amount_as_satoshis(item.price, item.unit)
|
||||
# allow some fluctuation (the fiat price may have changed between the calls)
|
||||
min = price * 995
|
||||
max = price * 1010
|
||||
|
||||
amount_received = int(request.args.get("amount"))
|
||||
if amount_received < min:
|
||||
return jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {amount_received} is smaller than minimum {min}."
|
||||
).dict()
|
||||
)
|
||||
elif amount_received > max:
|
||||
return jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {amount_received} is greater than maximum {max}."
|
||||
).dict()
|
||||
)
|
||||
|
||||
shop = await get_shop(item.shop)
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=shop.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=item.name,
|
||||
description_hash=hashlib.sha256(
|
||||
(await item.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "offlineshop", "item": item.id},
|
||||
)
|
||||
except Exception as exc:
|
||||
return jsonify(LnurlErrorResponse(reason=exc.message).dict())
|
||||
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=item.success_action(shop, payment_hash) if shop.method else None,
|
||||
routes=[],
|
||||
)
|
||||
|
||||
return jsonify(resp.dict())
|
||||
@@ -0,0 +1,29 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial offlineshop tables.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE shops (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wallet TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
wordlist TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE items (
|
||||
shop INTEGER NOT NULL REFERENCES shop (id),
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image TEXT, -- image/png;base64,...
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
price INTEGER NOT NULL,
|
||||
unit TEXT NOT NULL DEFAULT 'sat'
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
from quart import url_for
|
||||
from typing import NamedTuple, Optional, List, Dict
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||
|
||||
from .helpers import totp
|
||||
|
||||
shop_counters: Dict = {}
|
||||
|
||||
|
||||
class ShopCounter(object):
|
||||
fulfilled_payments: OrderedDict
|
||||
counter: int
|
||||
|
||||
@classmethod
|
||||
def invoke(cls, shop: "Shop"):
|
||||
shop_counter = shop_counters.get(shop.id)
|
||||
if not shop_counter:
|
||||
shop_counter = cls(wordlist=shop.wordlist.split("\n"))
|
||||
shop_counters[shop.id] = shop_counter
|
||||
return shop_counter
|
||||
|
||||
@classmethod
|
||||
def reset(cls, shop: "Shop"):
|
||||
shop_counter = cls.invoke(shop)
|
||||
shop_counter.counter = -1
|
||||
shop_counter.wordlist = shop.wordlist.split("\n")
|
||||
|
||||
def __init__(self, wordlist: List[str]):
|
||||
self.wordlist = wordlist
|
||||
self.fulfilled_payments = OrderedDict()
|
||||
self.counter = -1
|
||||
|
||||
def get_word(self, payment_hash):
|
||||
if payment_hash in self.fulfilled_payments:
|
||||
return self.fulfilled_payments[payment_hash]
|
||||
|
||||
# get a new word
|
||||
self.counter += 1
|
||||
word = self.wordlist[self.counter % len(self.wordlist)]
|
||||
self.fulfilled_payments[payment_hash] = word
|
||||
|
||||
# cleanup confirmation words cache
|
||||
to_remove = len(self.fulfilled_payments) - 23
|
||||
if to_remove > 0:
|
||||
for i in range(to_remove):
|
||||
self.fulfilled_payments.popitem(False)
|
||||
|
||||
return word
|
||||
|
||||
|
||||
class Shop(NamedTuple):
|
||||
id: int
|
||||
wallet: str
|
||||
method: str
|
||||
wordlist: str
|
||||
|
||||
@property
|
||||
def otp_key(self) -> str:
|
||||
return base64.b32encode(
|
||||
hashlib.sha256(
|
||||
("otpkey" + str(self.id) + self.wallet).encode("ascii"),
|
||||
).digest()
|
||||
).decode("ascii")
|
||||
|
||||
def get_code(self, payment_hash: str) -> str:
|
||||
if self.method == "wordlist":
|
||||
sc = ShopCounter.invoke(self)
|
||||
return sc.get_word(payment_hash)
|
||||
elif self.method == "totp":
|
||||
return totp(self.otp_key)
|
||||
return ""
|
||||
|
||||
|
||||
class Item(NamedTuple):
|
||||
shop: int
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
image: str
|
||||
enabled: bool
|
||||
price: int
|
||||
unit: str
|
||||
|
||||
@property
|
||||
def lnurl(self) -> str:
|
||||
return lnurl_encode(
|
||||
url_for("offlineshop.lnurl_response", item_id=self.id, _external=True)
|
||||
)
|
||||
|
||||
def values(self):
|
||||
values = self._asdict()
|
||||
values["lnurl"] = self.lnurl
|
||||
return values
|
||||
|
||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
metadata = [["text/plain", self.description]]
|
||||
|
||||
if self.image:
|
||||
metadata.append(self.image.split(":")[1].split(","))
|
||||
|
||||
return LnurlPayMetadata(json.dumps(metadata))
|
||||
|
||||
def success_action(
|
||||
self, shop: Shop, payment_hash: str
|
||||
) -> Optional[LnurlPaySuccessAction]:
|
||||
if not shop.wordlist:
|
||||
return None
|
||||
|
||||
return UrlAction(
|
||||
url=url_for(
|
||||
"offlineshop.confirmation_code", p=payment_hash, _external=True
|
||||
),
|
||||
description="Open to get the confirmation code for your purchase.",
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
const pica = window.pica()
|
||||
|
||||
const defaultItemData = {
|
||||
unit: 'sat'
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
selectedWallet: null,
|
||||
confirmationMethod: 'wordlist',
|
||||
wordlistTainted: false,
|
||||
offlineshop: {
|
||||
method: null,
|
||||
wordlist: [],
|
||||
items: []
|
||||
},
|
||||
itemDialog: {
|
||||
show: false,
|
||||
data: {...defaultItemData},
|
||||
units: ['sat']
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
printItems() {
|
||||
return this.offlineshop.items.filter(({enabled}) => enabled)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openNewDialog() {
|
||||
this.itemDialog.show = true
|
||||
this.itemDialog.data = {...defaultItemData}
|
||||
},
|
||||
openUpdateDialog(itemId) {
|
||||
this.itemDialog.show = true
|
||||
let item = this.offlineshop.items.find(item => item.id === itemId)
|
||||
this.itemDialog.data = item
|
||||
},
|
||||
imageAdded(file) {
|
||||
let blobURL = URL.createObjectURL(file)
|
||||
let image = new Image()
|
||||
image.src = blobURL
|
||||
image.onload = async () => {
|
||||
let canvas = document.createElement('canvas')
|
||||
canvas.setAttribute('width', 100)
|
||||
canvas.setAttribute('height', 100)
|
||||
await pica.resize(image, canvas, {
|
||||
quality: 0,
|
||||
alpha: true,
|
||||
unsharpAmount: 95,
|
||||
unsharpRadius: 0.9,
|
||||
unsharpThreshold: 70
|
||||
})
|
||||
this.itemDialog.data.image = canvas.toDataURL()
|
||||
this.itemDialog = {...this.itemDialog}
|
||||
}
|
||||
},
|
||||
imageCleared() {
|
||||
this.itemDialog.data.image = null
|
||||
this.itemDialog = {...this.itemDialog}
|
||||
},
|
||||
disabledAddItemButton() {
|
||||
return (
|
||||
!this.itemDialog.data.name ||
|
||||
this.itemDialog.data.name.length === 0 ||
|
||||
!this.itemDialog.data.price ||
|
||||
!this.itemDialog.data.description ||
|
||||
!this.itemDialog.data.unit ||
|
||||
this.itemDialog.data.unit.length === 0
|
||||
)
|
||||
},
|
||||
changedWallet(wallet) {
|
||||
this.selectedWallet = wallet
|
||||
this.loadShop()
|
||||
},
|
||||
loadShop() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/offlineshop/api/v1/offlineshop',
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.offlineshop = response.data
|
||||
this.confirmationMethod = response.data.method
|
||||
this.wordlistTainted = false
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
async setMethod() {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/offlineshop/api/v1/offlineshop/method',
|
||||
this.selectedWallet.inkey,
|
||||
{method: this.confirmationMethod, wordlist: this.offlineshop.wordlist}
|
||||
)
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
return
|
||||
}
|
||||
|
||||
this.$q.notify({
|
||||
message:
|
||||
`Method set to ${this.confirmationMethod}.` +
|
||||
(this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''),
|
||||
timeout: 700
|
||||
})
|
||||
this.loadShop()
|
||||
},
|
||||
async sendItem() {
|
||||
let {id, name, image, description, price, unit} = this.itemDialog.data
|
||||
const data = {
|
||||
name,
|
||||
description,
|
||||
image,
|
||||
price,
|
||||
unit
|
||||
}
|
||||
|
||||
try {
|
||||
if (id) {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/offlineshop/api/v1/offlineshop/items/' + id,
|
||||
this.selectedWallet.inkey,
|
||||
data
|
||||
)
|
||||
} else {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/offlineshop/api/v1/offlineshop/items',
|
||||
this.selectedWallet.inkey,
|
||||
data
|
||||
)
|
||||
this.$q.notify({
|
||||
message: `Item '${this.itemDialog.data.name}' added.`,
|
||||
timeout: 700
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
return
|
||||
}
|
||||
|
||||
this.loadShop()
|
||||
this.itemDialog.show = false
|
||||
this.itemDialog.data = {...defaultItemData}
|
||||
},
|
||||
toggleItem(itemId) {
|
||||
let item = this.offlineshop.items.find(item => item.id === itemId)
|
||||
item.enabled = !item.enabled
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/offlineshop/api/v1/offlineshop/items/' + itemId,
|
||||
this.selectedWallet.inkey,
|
||||
item
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`,
|
||||
timeout: 700
|
||||
})
|
||||
this.offlineshop.items = this.offlineshop.items
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deleteItem(itemId) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this item?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/offlineshop/api/v1/offlineshop/items/' + itemId,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Item deleted.`,
|
||||
timeout: 700
|
||||
})
|
||||
this.offlineshop.items.splice(
|
||||
this.offlineshop.items.findIndex(item => item.id === itemId),
|
||||
1
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.selectedWallet = this.g.user.wallets[0]
|
||||
this.loadShop()
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/offlineshop/api/v1/currencies')
|
||||
.then(response => {
|
||||
this.itemDialog = {...this.itemDialog, units: ['sat', ...response.data]}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="How to use"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<ol>
|
||||
<li>Register items.</li>
|
||||
<li>
|
||||
Print QR codes and paste them on your store, your menu, somewhere,
|
||||
somehow.
|
||||
</li>
|
||||
<li>
|
||||
Clients scan the QR codes and get information about the items plus the
|
||||
price on their phones directly (they must have internet)
|
||||
</li>
|
||||
<li>
|
||||
Once they decide to pay, they'll get an invoice on their phones
|
||||
automatically
|
||||
</li>
|
||||
<li>
|
||||
When the payment is confirmed, a confirmation code will be issued for
|
||||
them.
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
The confirmation codes are words from a predefined sequential word list.
|
||||
Each new payment bumps the words sequence by 1. So you can check the
|
||||
confirmation codes manually by just looking at them.
|
||||
</p>
|
||||
<p>
|
||||
For example, if your wordlist is
|
||||
<code>[apple, banana, coconut]</code> the first purchase will be
|
||||
<code>apple</code>, the second <code>banana</code> and so on. When it
|
||||
gets to the end it starts from the beginning again.
|
||||
</p>
|
||||
<p>Powered by LNURL-pay.</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="Create item (a shop will be created automatically based on the wallet you use)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">POST</span></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<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 201 OK</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}/offlineshop/api/v1/offlineshop/items -H "Content-Type:
|
||||
application/json" -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -d
|
||||
'{"name": <string>, "description": <string>, "image":
|
||||
<data-uri string>, "price": <integer>, "unit": <"sat"
|
||||
or "USD">}'
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get the shop data along with items"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<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
|
||||
>{"id": <integer>, "wallet": <string>, "wordlist":
|
||||
<string>, "items": [{"id": <integer>, "name":
|
||||
<string>, "description": <string>, "image":
|
||||
<string>, "enabled": <boolean>, "price": <integer>,
|
||||
"unit": <string>, "lnurl": <string>}, ...]}<</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H
|
||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Update item (all fields must be sent again)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">PUT</span></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<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</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}/offlineshop/api/v1/offlineshop/items/<item_id> -H
|
||||
"Content-Type: application/json" -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}" -d '{"name": <string>,
|
||||
"description": <string>, "image": <data-uri string>,
|
||||
"price": <integer>, "unit": <"sat" or "USD">}'
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Delete item">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">DELETE</span></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<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</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}/offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key:
|
||||
{{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,332 @@
|
||||
{% 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-7 q-gutter-y-md">
|
||||
<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">Items</h5>
|
||||
</div>
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn unelevated color="deep-purple" @click="openNewDialog()"
|
||||
>Add new item</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{% raw %}
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
selection="multiple"
|
||||
:data="offlineshop.items"
|
||||
row-key="id"
|
||||
no-data-label="No items for sale yet"
|
||||
:pagination="{rowsPerPage: 0}"
|
||||
:binary-state-sort="true"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width>Name</q-th>
|
||||
<q-th auto-width>Description</q-th>
|
||||
<q-th auto-width>Image</q-th>
|
||||
<q-th auto-width>Price</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="props.row.enabled ? 'done' : 'block'"
|
||||
:color="props.row.enabled ? 'green' : ($q.dark.isActive ? 'grey-7' : 'grey-5')"
|
||||
type="a"
|
||||
@click="toggleItem(props.row.id)"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width class="text-center">{{ props.row.name }}</q-td>
|
||||
<q-td auto-width> {{ props.row.description }} </q-td>
|
||||
<q-td class="text-center" auto-width>
|
||||
<img
|
||||
v-if="props.row.image"
|
||||
:src="props.row.image"
|
||||
style="height: 1.5em"
|
||||
/>
|
||||
</q-td>
|
||||
<q-td class="text-center" auto-width>
|
||||
{{ props.row.price }} {{ props.row.unit }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
type="a"
|
||||
@click="deleteItem(props.row.id)"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm col-5">
|
||||
<q-card-section class="q-pa-none text-center">
|
||||
<div class="row">
|
||||
<h5 class="text-subtitle1 q-my-none">Wallet Shop</h5>
|
||||
</div>
|
||||
|
||||
<q-form class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
:options="g.user.wallets"
|
||||
:value="selectedWallet"
|
||||
label="Using wallet:"
|
||||
option-label="name"
|
||||
@input="changedWallet"
|
||||
>
|
||||
</q-select>
|
||||
</q-form>
|
||||
|
||||
<div v-if="printItems.length > 0" class="row q-gutter-sm q-my-md">
|
||||
<q-btn
|
||||
type="a"
|
||||
outline
|
||||
color="purple"
|
||||
:href="'print?items=' + printItems.map(({id}) => id).join(',')"
|
||||
>Print QR Codes</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm col-5">
|
||||
<q-tabs
|
||||
v-model="confirmationMethod"
|
||||
no-caps
|
||||
class="bg-purple text-white shadow-2"
|
||||
>
|
||||
<q-tab name="wordlist" label="Wordlist"></q-tab>
|
||||
<q-tab name="totp" label="TOTP (Google Authenticator)"></q-tab>
|
||||
<q-tab name="none" label="Nothing"></q-tab>
|
||||
</q-tabs>
|
||||
|
||||
<q-card-section class="q-py-sm text-center">
|
||||
<q-form
|
||||
v-if="confirmationMethod === 'wordlist'"
|
||||
class="q-gutter-md q-y-md"
|
||||
@submit="setMethod"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col q-mx-lg">
|
||||
<q-input
|
||||
v-model="offlineshop.wordlist"
|
||||
@input="wordlistTainted = true"
|
||||
dense
|
||||
filled
|
||||
autogrow
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="col q-mx-lg items-align flex items-center justify-center"
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
type="submit"
|
||||
:disabled="!wordlistTainted"
|
||||
>
|
||||
Update Wordlist
|
||||
</q-btn>
|
||||
<q-btn @click="loadShop" flat color="grey" class="q-ml-auto"
|
||||
>Reset</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
|
||||
<div v-else-if="confirmationMethod === 'totp'">
|
||||
<div class="row">
|
||||
<div class="col q-mx-lg">
|
||||
<q-responsive :ratio="1">
|
||||
<qrcode
|
||||
:value="`otpauth://totp/offlineshop:${selectedWallet.name}?secret=${offlineshop.otp_key}`"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</div>
|
||||
<div
|
||||
class="col q-mx-lg items-align flex items-center justify-center"
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disabled="offlineshop.method === 'totp'"
|
||||
@click="setMethod"
|
||||
>
|
||||
Set TOTP
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="confirmationMethod === 'none'">
|
||||
<p>
|
||||
Setting this option disables the confirmation code message that
|
||||
appears in the consumer wallet after a purchase is paid for. It's ok
|
||||
if the consumer is to be trusted when they claim to have paid.
|
||||
</p>
|
||||
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disabled="offlineshop.method === 'none'"
|
||||
@click="setMethod"
|
||||
>
|
||||
Disable Confirmation Codes
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits OfflineShop extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "offlineshop/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="itemDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<h5
|
||||
class="q-ma-none"
|
||||
v-if="itemDialog.data.id"
|
||||
v-text="itemDialog.data.name"
|
||||
></h5>
|
||||
<h5 class="q-ma-none q-mb-xl" v-else>Adding a new item</h5>
|
||||
|
||||
<q-responsive v-if="itemDialog.data.id" :ratio="1">
|
||||
<qrcode
|
||||
:value="itemDialog.data.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
|
||||
<div v-if="itemDialog.data.id" class="row q-gutter-sm justify-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(itemDialog.data.lnurl, 'LNURL copied to clipboard!')"
|
||||
class="q-mb-lg"
|
||||
>Copy LNURL</q-btn
|
||||
>
|
||||
</div>
|
||||
<q-form @submit="sendItem" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="itemDialog.data.name"
|
||||
type="text"
|
||||
label="Item name"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="itemDialog.data.description"
|
||||
type="text"
|
||||
label="Brief description"
|
||||
></q-input>
|
||||
<q-file
|
||||
filled
|
||||
dense
|
||||
capture="environment"
|
||||
accept="image/jpeg, image/png"
|
||||
:max-file-size="3*1024**2"
|
||||
label="Small image (optional)"
|
||||
clearable
|
||||
@input="imageAdded"
|
||||
@clear="imageCleared"
|
||||
>
|
||||
<template v-if="itemDialog.data.image" v-slot:before>
|
||||
<img style="height: 1em" :src="itemDialog.data.image" />
|
||||
</template>
|
||||
<template v-if="itemDialog.data.image" v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="imageCleared"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-file>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="itemDialog.data.price"
|
||||
type="number"
|
||||
min="1"
|
||||
:label="`Item price (${itemDialog.data.unit})`"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="itemDialog.data.unit"
|
||||
type="text"
|
||||
label="Unit"
|
||||
:options="itemDialog.units"
|
||||
></q-select>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="disabledAddItemButton()"
|
||||
type="submit"
|
||||
>
|
||||
{% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %}
|
||||
Item
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
|
||||
<script src="/offlineshop/static/js/index.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "print.html" %} {% block page %} {% raw %}
|
||||
<div class="row justify-center">
|
||||
<div v-for="item in items" class="q-my-sm q-mx-lg">
|
||||
<div class="text-center q-ma-none q-mb-sm">{{ item.name }}</div>
|
||||
<qrcode :value="item.lnurl" :options="{margin: 0, width: 250}"></qrcode>
|
||||
<div class="text-center q-ma-none q-mt-sm">{{ item.price }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %} {% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
created: function () {
|
||||
window.print()
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
items: JSON.parse('{{items | tojson}}')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
import time
|
||||
from datetime import datetime
|
||||
from quart import g, render_template, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.crud import get_standalone_payment
|
||||
|
||||
from . import offlineshop_ext
|
||||
from .crud import get_item, get_shop
|
||||
|
||||
|
||||
@offlineshop_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("offlineshop/index.html", user=g.user)
|
||||
|
||||
|
||||
@offlineshop_ext.route("/print")
|
||||
async def print_qr_codes():
|
||||
items = []
|
||||
for item_id in request.args.get("items").split(","):
|
||||
item = await get_item(item_id)
|
||||
if item:
|
||||
items.append(
|
||||
{
|
||||
"lnurl": item.lnurl,
|
||||
"name": item.name,
|
||||
"price": f"{item.price} {item.unit}",
|
||||
}
|
||||
)
|
||||
|
||||
return await render_template("offlineshop/print.html", items=items)
|
||||
|
||||
|
||||
@offlineshop_ext.route("/confirmation")
|
||||
async def confirmation_code():
|
||||
style = "<style>* { font-size: 100px}</style>"
|
||||
|
||||
payment_hash = request.args.get("p")
|
||||
payment: Payment = await get_standalone_payment(payment_hash)
|
||||
if not payment:
|
||||
return (
|
||||
f"Couldn't find the payment {payment_hash}." + style,
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
if payment.pending:
|
||||
return (
|
||||
f"Payment {payment_hash} wasn't received yet. Please try again in a minute."
|
||||
+ style,
|
||||
HTTPStatus.PAYMENT_REQUIRED,
|
||||
)
|
||||
|
||||
if payment.time + 60 * 15 < time.time():
|
||||
return "too much time has passed." + style
|
||||
|
||||
item = await get_item(payment.extra.get("item"))
|
||||
shop = await get_shop(item.shop)
|
||||
|
||||
return (
|
||||
f"""
|
||||
[{shop.get_code(payment_hash)}]<br>
|
||||
{item.name}<br>
|
||||
{item.price} {item.unit}<br>
|
||||
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')}
|
||||
"""
|
||||
+ style
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
from quart import g, jsonify
|
||||
from http import HTTPStatus
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.utils.exchange_rates import currencies
|
||||
|
||||
from . import offlineshop_ext
|
||||
from .crud import (
|
||||
get_or_create_shop_by_wallet,
|
||||
set_method,
|
||||
add_item,
|
||||
update_item,
|
||||
get_items,
|
||||
delete_item_from_shop,
|
||||
)
|
||||
from .models import ShopCounter
|
||||
|
||||
|
||||
@offlineshop_ext.route("/api/v1/currencies", methods=["GET"])
|
||||
async def api_list_currencies_available():
|
||||
return jsonify(list(currencies.keys()))
|
||||
|
||||
|
||||
@offlineshop_ext.route("/api/v1/offlineshop", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_shop_from_wallet():
|
||||
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||
items = await get_items(shop.id)
|
||||
|
||||
try:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
**shop._asdict(),
|
||||
**{
|
||||
"otp_key": shop.otp_key,
|
||||
"items": [item.values() for item in items],
|
||||
},
|
||||
}
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except LnurlInvalidUrl:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
|
||||
}
|
||||
),
|
||||
HTTPStatus.UPGRADE_REQUIRED,
|
||||
)
|
||||
|
||||
|
||||
@offlineshop_ext.route("/api/v1/offlineshop/items", methods=["POST"])
|
||||
@offlineshop_ext.route("/api/v1/offlineshop/items/<item_id>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"description": {"type": "string", "empty": False, "required": True},
|
||||
"image": {"type": "string", "required": False, "nullable": True},
|
||||
"price": {"type": "number", "required": True},
|
||||
"unit": {"type": "string", "required": True},
|
||||
}
|
||||
)
|
||||
async def api_add_or_update_item(item_id=None):
|
||||
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||
if item_id == None:
|
||||
await add_item(
|
||||
shop.id,
|
||||
g.data["name"],
|
||||
g.data["description"],
|
||||
g.data.get("image"),
|
||||
g.data["price"],
|
||||
g.data["unit"],
|
||||
)
|
||||
return "", HTTPStatus.CREATED
|
||||
else:
|
||||
await update_item(
|
||||
shop.id,
|
||||
item_id,
|
||||
g.data["name"],
|
||||
g.data["description"],
|
||||
g.data.get("image"),
|
||||
g.data["price"],
|
||||
g.data["unit"],
|
||||
)
|
||||
return "", HTTPStatus.OK
|
||||
|
||||
|
||||
@offlineshop_ext.route("/api/v1/offlineshop/items/<item_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_delete_item(item_id):
|
||||
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||
await delete_item_from_shop(shop.id, item_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@offlineshop_ext.route("/api/v1/offlineshop/method", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"method": {"type": "string", "required": True, "nullable": False},
|
||||
"wordlist": {
|
||||
"type": "string",
|
||||
"empty": True,
|
||||
"nullable": True,
|
||||
"required": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
async def api_set_method():
|
||||
method = g.data["method"]
|
||||
|
||||
wordlist = g.data["wordlist"].split("\n") if g.data["wordlist"] else None
|
||||
wordlist = [word.strip() for word in wordlist if word.strip()]
|
||||
|
||||
shop = await get_or_create_shop_by_wallet(g.wallet.id)
|
||||
if not shop:
|
||||
return "", HTTPStatus.NOT_FOUND
|
||||
|
||||
updated_shop = await set_method(shop.id, method, "\n".join(wordlist))
|
||||
if not updated_shop:
|
||||
return "", HTTPStatus.NOT_FOUND
|
||||
|
||||
ShopCounter.reset(updated_shop)
|
||||
return "", HTTPStatus.OK
|
||||
@@ -0,0 +1,28 @@
|
||||
animals = [
|
||||
"albatross",
|
||||
"bison",
|
||||
"chicken",
|
||||
"duck",
|
||||
"eagle",
|
||||
"flamingo",
|
||||
"gorila",
|
||||
"hamster",
|
||||
"iguana",
|
||||
"jaguar",
|
||||
"koala",
|
||||
"llama",
|
||||
"macaroni penguim",
|
||||
"numbat",
|
||||
"octopus",
|
||||
"platypus",
|
||||
"quetzal",
|
||||
"rabbit",
|
||||
"salmon",
|
||||
"tuna",
|
||||
"unicorn",
|
||||
"vulture",
|
||||
"wolf",
|
||||
"xenops",
|
||||
"yak",
|
||||
"zebra",
|
||||
]
|
||||
@@ -3,7 +3,9 @@ from lnbits.db import Database
|
||||
|
||||
db = Database("ext_paywall")
|
||||
|
||||
paywall_ext: Blueprint = Blueprint("paywall", __name__, static_folder="static", template_folder="templates")
|
||||
paywall_ext: Blueprint = Blueprint(
|
||||
"paywall", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -7,7 +7,13 @@ from .models import Paywall
|
||||
|
||||
|
||||
async def create_paywall(
|
||||
*, wallet_id: str, url: str, memo: str, description: Optional[str] = None, amount: int = 0, remembers: bool = True
|
||||
*,
|
||||
wallet_id: str,
|
||||
url: str,
|
||||
memo: str,
|
||||
description: Optional[str] = None,
|
||||
amount: int = 0,
|
||||
remembers: bool = True,
|
||||
) -> Paywall:
|
||||
paywall_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
@@ -34,7 +40,9 @@ async def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]:
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(f"SELECT * FROM paywalls WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM paywalls WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Paywall.from_row(row) for row in rows]
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ async def m002_redux(db):
|
||||
)
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON paywalls (wallet)")
|
||||
|
||||
for row in [list(row) for row in await db.fetchall("SELECT * FROM paywalls_old")]:
|
||||
for row in [
|
||||
list(row) for row in await db.fetchall("SELECT * FROM paywalls_old")
|
||||
]:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO paywalls (
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<code>[<paywall_object>, ...]</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
|
||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
>curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -48,11 +48,11 @@
|
||||
>
|
||||
<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
|
||||
'{"url": <string>, "memo": <string>, "description":
|
||||
<string>, "amount": <integer>, "remembers":
|
||||
<boolean>}' -H "Content-type: application/json" -H "X-Api-Key:
|
||||
{{ g.user.wallets[0].adminkey }}"
|
||||
>curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url":
|
||||
<string>, "memo": <string>, "description": <string>,
|
||||
"amount": <integer>, "remembers": <boolean>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -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/<paywall_id>/invoice -d '{"amount":
|
||||
}}api/v1/paywalls/<paywall_id>/invoice -d '{"amount":
|
||||
<integer>}' -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/<paywall_id>/check_invoice -d
|
||||
}}api/v1/paywalls/<paywall_id>/check_invoice -d
|
||||
'{"payment_hash": <string>}' -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/<paywall_id> -H "X-Api-Key: {{
|
||||
}}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
|
||||
@@ -16,5 +16,7 @@ async def index():
|
||||
|
||||
@paywall_ext.route("/<paywall_id>")
|
||||
async def display(paywall_id):
|
||||
paywall = await get_paywall(paywall_id) or abort(HTTPStatus.NOT_FOUND, "Paywall does not exist.")
|
||||
paywall = await get_paywall(paywall_id) or abort(
|
||||
HTTPStatus.NOT_FOUND, "Paywall does not exist."
|
||||
)
|
||||
return await render_template("paywall/display.html", paywall=paywall)
|
||||
|
||||
@@ -17,7 +17,10 @@ async def api_paywalls():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return jsonify([paywall._asdict() for paywall in await get_paywalls(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([paywall._asdict() for paywall in await get_paywalls(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@paywall_ext.route("/api/v1/paywalls", methods=["POST"])
|
||||
@@ -26,7 +29,12 @@ async def api_paywalls():
|
||||
schema={
|
||||
"url": {"type": "string", "empty": False, "required": True},
|
||||
"memo": {"type": "string", "empty": False, "required": True},
|
||||
"description": {"type": "string", "empty": True, "nullable": True, "required": False},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"empty": True,
|
||||
"nullable": True,
|
||||
"required": False,
|
||||
},
|
||||
"amount": {"type": "integer", "min": 0, "required": True},
|
||||
"remembers": {"type": "boolean", "required": True},
|
||||
}
|
||||
@@ -53,26 +61,41 @@ async def api_paywall_delete(paywall_id):
|
||||
|
||||
|
||||
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/invoice", methods=["POST"])
|
||||
@api_validate_post_request(schema={"amount": {"type": "integer", "min": 1, "required": True}})
|
||||
@api_validate_post_request(
|
||||
schema={"amount": {"type": "integer", "min": 1, "required": True}}
|
||||
)
|
||||
async def api_paywall_create_invoice(paywall_id):
|
||||
paywall = await get_paywall(paywall_id)
|
||||
|
||||
if g.data["amount"] < paywall.amount:
|
||||
return jsonify({"message": f"Minimum amount is {paywall.amount} sat."}), HTTPStatus.BAD_REQUEST
|
||||
return (
|
||||
jsonify({"message": f"Minimum amount is {paywall.amount} sat."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
amount = g.data["amount"] if g.data["amount"] > paywall.amount else paywall.amount
|
||||
amount = (
|
||||
g.data["amount"] if g.data["amount"] > paywall.amount else paywall.amount
|
||||
)
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=paywall.wallet, amount=amount, memo=f"{paywall.memo}", extra={"tag": "paywall"}
|
||||
wallet_id=paywall.wallet,
|
||||
amount=amount,
|
||||
memo=f"{paywall.memo}",
|
||||
extra={"tag": "paywall"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.CREATED
|
||||
return (
|
||||
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/check_invoice", methods=["POST"])
|
||||
@api_validate_post_request(schema={"payment_hash": {"type": "string", "empty": False, "required": True}})
|
||||
@api_validate_post_request(
|
||||
schema={"payment_hash": {"type": "string", "empty": False, "required": True}}
|
||||
)
|
||||
async def api_paywal_check_invoice(paywall_id):
|
||||
paywall = await get_paywall(paywall_id)
|
||||
|
||||
@@ -90,6 +113,9 @@ async def api_paywal_check_invoice(paywall_id):
|
||||
payment = await wallet.get_payment(g.data["payment_hash"])
|
||||
await payment.set_pending(False)
|
||||
|
||||
return jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}), HTTPStatus.OK
|
||||
return (
|
||||
jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,17 @@
|
||||
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))
|
||||
@@ -0,0 +1,60 @@
|
||||
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"
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Subdomains",
|
||||
"short_description": "Sell subdomains of your domain",
|
||||
"icon": "domain",
|
||||
"contributors": ["grmkris"]
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Domains, 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,
|
||||
),
|
||||
)
|
||||
|
||||
new_subdomain = await get_subdomain(payment_hash)
|
||||
assert new_subdomain, "Newly created subdomain couldn't be retrieved"
|
||||
return new_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]),
|
||||
)
|
||||
|
||||
new_subdomain = await get_subdomain(payment_hash)
|
||||
assert new_subdomain, "Newly paid subdomain couldn't be retrieved"
|
||||
return new_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,),
|
||||
)
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
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 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,))
|
||||
@@ -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'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,61 @@
|
||||
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
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import create_invoice, check_invoice_status
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
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"]
|
||||
|
||||
try:
|
||||
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"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
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
|
||||
@@ -3,7 +3,9 @@ from lnbits.db import Database
|
||||
|
||||
db = Database("ext_tpos")
|
||||
|
||||
tpos_ext: Blueprint = Blueprint("tpos", __name__, static_folder="static", template_folder="templates")
|
||||
tpos_ext: Blueprint = Blueprint(
|
||||
"tpos", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -31,7 +31,9 @@ async def get_tposs(wallet_ids: Union[str, List[str]]) -> List[TPoS]:
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(f"SELECT * FROM tposs WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM tposs WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [TPoS.from_row(row) for row in rows]
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<code>[<tpos_object>, ...]</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:
|
||||
<invoice_key>"
|
||||
</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":
|
||||
<string>, "currency": <string>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: <admin_key>"
|
||||
</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/<tpos_id> -H "X-Api-Key: <admin_key>"
|
||||
>curl -X DELETE {{ request.url_root }}api/v1/tposs/<tpos_id> -H
|
||||
"X-Api-Key: <admin_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -16,7 +16,10 @@ async def api_tposs():
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return jsonify([tpos._asdict() for tpos in await get_tposs(wallet_ids)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([tpos._asdict() for tpos in await get_tposs(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@tpos_ext.route("/api/v1/tposs", methods=["POST"])
|
||||
@@ -49,7 +52,9 @@ async def api_tpos_delete(tpos_id):
|
||||
|
||||
|
||||
@tpos_ext.route("/api/v1/tposs/<tpos_id>/invoices/", methods=["POST"])
|
||||
@api_validate_post_request(schema={"amount": {"type": "integer", "min": 1, "required": True}})
|
||||
@api_validate_post_request(
|
||||
schema={"amount": {"type": "integer", "min": 1, "required": True}}
|
||||
)
|
||||
async def api_tpos_create_invoice(tpos_id):
|
||||
tpos = await get_tpos(tpos_id)
|
||||
|
||||
@@ -58,12 +63,18 @@ async def api_tpos_create_invoice(tpos_id):
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=tpos.wallet, amount=g.data["amount"], memo=f"{tpos.name}", extra={"tag": "tpos"}
|
||||
wallet_id=tpos.wallet,
|
||||
amount=g.data["amount"],
|
||||
memo=f"{tpos.name}",
|
||||
extra={"tag": "tpos"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.CREATED
|
||||
return (
|
||||
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@tpos_ext.route("/api/v1/tposs/<tpos_id>/invoices/<payment_hash>", methods=["GET"])
|
||||
|
||||
@@ -3,7 +3,9 @@ from lnbits.db import Database
|
||||
|
||||
db = Database("ext_usermanager")
|
||||
|
||||
usermanager_ext: Blueprint = Blueprint("usermanager", __name__, static_folder="static", template_folder="templates")
|
||||
usermanager_ext: Blueprint = Blueprint(
|
||||
"usermanager", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -4,7 +4,7 @@ from lnbits.core.models import Payment
|
||||
from lnbits.core.crud import (
|
||||
create_account,
|
||||
get_user,
|
||||
get_wallet_payments,
|
||||
get_payments,
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
)
|
||||
@@ -16,7 +16,9 @@ from .models import Users, Wallets
|
||||
### Users
|
||||
|
||||
|
||||
async def create_usermanager_user(user_name: str, wallet_name: str, admin_id: str) -> Users:
|
||||
async def create_usermanager_user(
|
||||
user_name: str, wallet_name: str, admin_id: str
|
||||
) -> Users:
|
||||
account = await create_account()
|
||||
user = await get_user(account.id)
|
||||
assert user, "Newly created user couldn't be retrieved"
|
||||
@@ -66,7 +68,9 @@ async def delete_usermanager_user(user_id: str) -> None:
|
||||
### Wallets
|
||||
|
||||
|
||||
async def create_usermanager_wallet(user_id: str, wallet_name: str, admin_id: str) -> Wallets:
|
||||
async def create_usermanager_wallet(
|
||||
user_id: str, wallet_name: str, admin_id: str
|
||||
) -> Wallets:
|
||||
wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name)
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -91,7 +95,9 @@ async def get_usermanager_wallets(user_id: str) -> List[Wallets]:
|
||||
|
||||
|
||||
async def get_usermanager_wallet_transactions(wallet_id: str) -> List[Payment]:
|
||||
return await get_wallet_payments(wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True)
|
||||
return await get_payments(
|
||||
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True
|
||||
)
|
||||
|
||||
|
||||
async def delete_usermanager_wallet(wallet_id: str, user_id: str) -> None:
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
<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
|
||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
>curl -X GET {{ request.url_root }}api/v1/users -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -64,9 +64,8 @@
|
||||
<code>JSON wallet data</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}usermanager/api/v1/wallets/<user_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
>curl -X GET {{ request.url_root }}api/v1/wallets/<user_id> -H
|
||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -87,9 +86,8 @@
|
||||
<code>JSON a wallets transactions</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}usermanager/api/v1/wallets<wallet_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
>curl -X GET {{ request.url_root }}api/v1/wallets<wallet_id> -H
|
||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -128,10 +126,10 @@
|
||||
>
|
||||
<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
|
||||
'{"admin_id": "{{ g.user.id }}", "wallet_name": <string>,
|
||||
"user_name": <string>}' -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}" -H "Content-type: application/json"
|
||||
>curl -X POST {{ request.url_root }}api/v1/users -d '{"admin_id": "{{
|
||||
g.user.id }}", "wallet_name": <string>, "user_name":
|
||||
<string>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
|
||||
"Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -165,10 +163,10 @@
|
||||
>
|
||||
<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
|
||||
'{"user_id": <string>, "wallet_name": <string>,
|
||||
"admin_id": "{{ g.user.id }}"}' -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}" -H "Content-type: application/json"
|
||||
>curl -X POST {{ request.url_root }}api/v1/wallets -d '{"user_id":
|
||||
<string>, "wallet_name": <string>, "admin_id": "{{
|
||||
g.user.id }}"}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
|
||||
"Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -189,9 +187,8 @@
|
||||
<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
|
||||
}}usermanager/api/v1/users/<user_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
>curl -X DELETE {{ request.url_root }}api/v1/users/<user_id> -H
|
||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -207,9 +204,8 @@
|
||||
<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
|
||||
}}usermanager/api/v1/wallets/<wallet_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
>curl -X DELETE {{ request.url_root }}api/v1/wallets/<wallet_id>
|
||||
-H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -230,8 +226,8 @@
|
||||
<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 }}usermanager/api/v1/extensions -d
|
||||
'{"userid": <string>, "extension": <string>, "active":
|
||||
>curl -X POST {{ request.url_root }}api/v1/extensions -d '{"userid":
|
||||
<string>, "extension": <string>, "active":
|
||||
<integer>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
|
||||
"Content-type: application/json"
|
||||
</code>
|
||||
|
||||
@@ -26,7 +26,10 @@ from lnbits.core import update_user_extension
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_usermanager_users():
|
||||
user_id = g.wallet.user
|
||||
return jsonify([user._asdict() for user in await get_usermanager_users(user_id)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify([user._asdict() for user in await get_usermanager_users(user_id)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@usermanager_ext.route("/api/v1/users", methods=["POST"])
|
||||
@@ -39,7 +42,9 @@ async def api_usermanager_users():
|
||||
}
|
||||
)
|
||||
async def api_usermanager_users_create():
|
||||
user = await create_usermanager_user(g.data["user_name"], g.data["wallet_name"], g.data["admin_id"])
|
||||
user = await create_usermanager_user(
|
||||
g.data["user_name"], g.data["wallet_name"], g.data["admin_id"]
|
||||
)
|
||||
return jsonify(user._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@@ -69,7 +74,9 @@ async def api_usermanager_activate_extension():
|
||||
user = await get_user(g.data["userid"])
|
||||
if not user:
|
||||
return jsonify({"message": "no such user"}), HTTPStatus.NOT_FOUND
|
||||
update_user_extension(user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"])
|
||||
update_user_extension(
|
||||
user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"]
|
||||
)
|
||||
return jsonify({"extension": "updated"}), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@@ -80,7 +87,12 @@ async def api_usermanager_activate_extension():
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_usermanager_wallets():
|
||||
user_id = g.wallet.user
|
||||
return jsonify([wallet._asdict() for wallet in await get_usermanager_wallets(user_id)]), HTTPStatus.OK
|
||||
return (
|
||||
jsonify(
|
||||
[wallet._asdict() for wallet in await get_usermanager_wallets(user_id)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@usermanager_ext.route("/api/v1/wallets", methods=["POST"])
|
||||
@@ -93,7 +105,9 @@ async def api_usermanager_wallets():
|
||||
}
|
||||
)
|
||||
async def api_usermanager_wallets_create():
|
||||
user = await create_usermanager_wallet(g.data["user_id"], g.data["wallet_name"], g.data["admin_id"])
|
||||
user = await create_usermanager_wallet(
|
||||
g.data["user_id"], g.data["wallet_name"], g.data["admin_id"]
|
||||
)
|
||||
return jsonify(user._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ from lnbits.db import Database
|
||||
db = Database("ext_withdraw")
|
||||
|
||||
|
||||
withdraw_ext: Blueprint = Blueprint("withdraw", __name__, static_folder="static", template_folder="templates")
|
||||
withdraw_ext: Blueprint = Blueprint(
|
||||
"withdraw", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import List, Optional, Union
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import WithdrawLink
|
||||
from .models import WithdrawLink, HashCheck
|
||||
|
||||
|
||||
async def create_withdraw_link(
|
||||
@@ -58,6 +58,9 @@ async def create_withdraw_link(
|
||||
|
||||
async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]:
|
||||
row = await db.fetchone("SELECT * FROM withdraw_link WHERE id = ?", (link_id,))
|
||||
if not row:
|
||||
return None
|
||||
|
||||
link = []
|
||||
for item in row:
|
||||
link.append(item)
|
||||
@@ -66,7 +69,12 @@ async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]:
|
||||
|
||||
|
||||
async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]:
|
||||
row = await db.fetchone("SELECT * FROM withdraw_link WHERE unique_hash = ?", (unique_hash,))
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM withdraw_link WHERE unique_hash = ?", (unique_hash,)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
link = []
|
||||
for item in row:
|
||||
link.append(item)
|
||||
@@ -79,14 +87,18 @@ async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[Withdraw
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(f"SELECT * FROM withdraw_link WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM withdraw_link WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [WithdrawLink.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(f"UPDATE withdraw_link SET {q} WHERE id = ?", (*kwargs.values(), link_id))
|
||||
await db.execute(
|
||||
f"UPDATE withdraw_link SET {q} WHERE id = ?", (*kwargs.values(), link_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM withdraw_link WHERE id = ?", (link_id,))
|
||||
return WithdrawLink.from_row(row) if row else None
|
||||
|
||||
@@ -98,3 +110,40 @@ async def delete_withdraw_link(link_id: str) -> None:
|
||||
def chunks(lst, n):
|
||||
for i in range(0, len(lst), n):
|
||||
yield lst[i : i + n]
|
||||
|
||||
|
||||
async def create_hash_check(
|
||||
the_hash: str,
|
||||
lnurl_id: str,
|
||||
) -> HashCheck:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO hash_check (
|
||||
id,
|
||||
lnurl_id
|
||||
)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(
|
||||
the_hash,
|
||||
lnurl_id,
|
||||
),
|
||||
)
|
||||
hashCheck = await get_hash_check(the_hash, lnurl_id)
|
||||
return hashCheck
|
||||
|
||||
|
||||
async def get_hash_check(the_hash: str, lnurl_id: str) -> Optional[HashCheck]:
|
||||
rowid = await db.fetchone("SELECT * FROM hash_check WHERE id = ?", (the_hash,))
|
||||
rowlnurl = await db.fetchone(
|
||||
"SELECT * FROM hash_check WHERE lnurl_id = ?", (lnurl_id,)
|
||||
)
|
||||
if not rowlnurl:
|
||||
await create_hash_check(the_hash, lnurl_id)
|
||||
return {"lnurl": True, "hash": False}
|
||||
else:
|
||||
if not rowid:
|
||||
await create_hash_check(the_hash, lnurl_id)
|
||||
return {"lnurl": True, "hash": False}
|
||||
else:
|
||||
return {"lnurl": True, "hash": True}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user