diff --git a/lnbits/extensions/amilk/README.md b/lnbits/extensions/amilk/README.md new file mode 100644 index 000000000..277294592 --- /dev/null +++ b/lnbits/extensions/amilk/README.md @@ -0,0 +1,11 @@ +

Example Extension

+

*tagline*

+This is an example extension to help you organise and build you own. + +Try to include an image + + + +

If your extension has API endpoints, include useful ones here

+ +curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY" diff --git a/lnbits/extensions/amilk/__init__.py b/lnbits/extensions/amilk/__init__.py new file mode 100644 index 000000000..0cdd8727f --- /dev/null +++ b/lnbits/extensions/amilk/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_amilk") + +amilk_ext: Blueprint = Blueprint( + "amilk", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/amilk/config.json b/lnbits/extensions/amilk/config.json new file mode 100644 index 000000000..09faf8af8 --- /dev/null +++ b/lnbits/extensions/amilk/config.json @@ -0,0 +1,6 @@ +{ + "name": "AMilk", + "short_description": "Assistant Faucet Milker", + "icon": "room_service", + "contributors": ["arcbtc"] +} diff --git a/lnbits/extensions/amilk/crud.py b/lnbits/extensions/amilk/crud.py new file mode 100644 index 000000000..859d2fa84 --- /dev/null +++ b/lnbits/extensions/amilk/crud.py @@ -0,0 +1,42 @@ +from base64 import urlsafe_b64encode +from uuid import uuid4 +from typing import List, Optional, Union + +from . import db +from .models import AMilk + + +async def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk: + amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") + await db.execute( + """ + INSERT INTO amilk.amilks (id, wallet, lnurl, atime, amount) + VALUES (?, ?, ?, ?, ?) + """, + (amilk_id, wallet_id, lnurl, atime, amount), + ) + + amilk = await get_amilk(amilk_id) + assert amilk, "Newly created amilk_id couldn't be retrieved" + return amilk + + +async def get_amilk(amilk_id: str) -> Optional[AMilk]: + row = await db.fetchone("SELECT * FROM amilk.amilks WHERE id = ?", (amilk_id,)) + return AMilk(**row) if row else None + + +async def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM amilk.amilks WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [AMilk(**row) for row in rows] + + +async def delete_amilk(amilk_id: str) -> None: + await db.execute("DELETE FROM amilk.amilks WHERE id = ?", (amilk_id,)) diff --git a/lnbits/extensions/amilk/migrations.py b/lnbits/extensions/amilk/migrations.py new file mode 100644 index 000000000..596a86335 --- /dev/null +++ b/lnbits/extensions/amilk/migrations.py @@ -0,0 +1,15 @@ +async def m001_initial(db): + """ + Initial amilks table. + """ + await db.execute( + """ + CREATE TABLE amilk.amilks ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + lnurl TEXT NOT NULL, + atime INTEGER NOT NULL, + amount INTEGER NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/amilk/models.py b/lnbits/extensions/amilk/models.py new file mode 100644 index 000000000..647cc530e --- /dev/null +++ b/lnbits/extensions/amilk/models.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class AMilk(BaseModel): + id: str + wallet: str + lnurl: str + atime: int + amount: int diff --git a/lnbits/extensions/amilk/templates/amilk/_api_docs.html b/lnbits/extensions/amilk/templates/amilk/_api_docs.html new file mode 100644 index 000000000..f1c27a1ba --- /dev/null +++ b/lnbits/extensions/amilk/templates/amilk/_api_docs.html @@ -0,0 +1,24 @@ + + + +
Assistant Faucet Milker
+

+ Milking faucets with software, known as "assmilking", seems at first to + be black-hat, although in fact there might be some unexplored use cases. + An LNURL withdraw gives someone the right to pull funds, which can be + done over time. An LNURL withdraw could be used outside of just faucets, + to provide money streaming and repeat payments.
Paste or scan an + LNURL withdraw, enter the amount for the AMilk to pull and the frequency + for it to be pulled.
+ + Created by, Ben Arc +

+
+
+
diff --git a/lnbits/extensions/amilk/templates/amilk/index.html b/lnbits/extensions/amilk/templates/amilk/index.html new file mode 100644 index 000000000..bb332e276 --- /dev/null +++ b/lnbits/extensions/amilk/templates/amilk/index.html @@ -0,0 +1,250 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New AMilk + + + + + +
+
+
AMilks
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ LNbits Assistant Faucet Milker Extension +
+
+ + + {% include "amilk/_api_docs.html" %} + +
+
+ + + + + + + + + + Create amilk + Cancel + + + +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/amilk/views.py b/lnbits/extensions/amilk/views.py new file mode 100644 index 000000000..2f61df77b --- /dev/null +++ b/lnbits/extensions/amilk/views.py @@ -0,0 +1,23 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import amilk_ext +from .crud import get_amilk + + +@amilk_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("amilk/index.html", user=g.user) + + +@amilk_ext.route("/") +async def wall(amilk_id): + amilk = await get_amilk(amilk_id) + if not amilk: + abort(HTTPStatus.NOT_FOUND, "AMilk does not exist.") + + return await render_template("amilk/wall.html", amilk=amilk) diff --git a/lnbits/extensions/amilk/views_api.py b/lnbits/extensions/amilk/views_api.py new file mode 100644 index 000000000..4b8cad182 --- /dev/null +++ b/lnbits/extensions/amilk/views_api.py @@ -0,0 +1,105 @@ +import httpx +from quart import g, jsonify, request, abort +from http import HTTPStatus +from lnurl import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore +from lnurl.exceptions import LnurlException # type: ignore +from time import sleep + +from lnbits.core.crud import get_user +from lnbits.decorators import api_check_wallet_key, api_validate_post_request +from lnbits.core.services import create_invoice, check_invoice_status + +from . import amilk_ext +from .crud import create_amilk, get_amilk, get_amilks, delete_amilk + + +@amilk_ext.route("/api/v1/amilk", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_amilks(): + wallet_ids = [g.wallet.id] + + 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, + ) + + +@amilk_ext.route("/api/v1/amilk/milk/", methods=["GET"]) +async def api_amilkit(amilk_id): + milk = await get_amilk(amilk_id) + memo = milk.id + + try: + withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse) + except LnurlException: + abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.") + + 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}, + }, + ) + + if r.is_error: + abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.") + + for i in range(10): + sleep(i) + invoice_status = await check_invoice_status(milk.wallet, payment_hash) + if invoice_status.paid: + return jsonify({"paid": True}), HTTPStatus.OK + else: + continue + + return jsonify({"paid": False}), HTTPStatus.OK + + +@amilk_ext.route("/api/v1/amilk", methods=["POST"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "lnurl": {"type": "string", "empty": False, "required": True}, + "atime": {"type": "integer", "min": 0, "required": True}, + "amount": {"type": "integer", "min": 0, "required": True}, + } +) +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"], + ) + + return jsonify(amilk._asdict()), HTTPStatus.CREATED + + +@amilk_ext.route("/api/v1/amilk/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_amilk_delete(amilk_id): + amilk = await get_amilk(amilk_id) + + if not amilk: + return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + + if amilk.wallet != g.wallet.id: + return jsonify({"message": "Not your amilk."}), HTTPStatus.FORBIDDEN + + await delete_amilk(amilk_id) + + return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/bleskomat/README.md b/lnbits/extensions/bleskomat/README.md new file mode 100644 index 000000000..97c70700a --- /dev/null +++ b/lnbits/extensions/bleskomat/README.md @@ -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. diff --git a/lnbits/extensions/bleskomat/__init__.py b/lnbits/extensions/bleskomat/__init__.py new file mode 100644 index 000000000..42f9bb460 --- /dev/null +++ b/lnbits/extensions/bleskomat/__init__.py @@ -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 diff --git a/lnbits/extensions/bleskomat/config.json b/lnbits/extensions/bleskomat/config.json new file mode 100644 index 000000000..99244df14 --- /dev/null +++ b/lnbits/extensions/bleskomat/config.json @@ -0,0 +1,6 @@ +{ + "name": "Bleskomat", + "short_description": "Connect a Bleskomat ATM to an lnbits", + "icon": "money", + "contributors": ["chill117"] +} diff --git a/lnbits/extensions/bleskomat/crud.py b/lnbits/extensions/bleskomat/crud.py new file mode 100644 index 000000000..1cc445769 --- /dev/null +++ b/lnbits/extensions/bleskomat/crud.py @@ -0,0 +1,119 @@ +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 bleskomat.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 bleskomat.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 bleskomat.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 bleskomat.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 bleskomat.bleskomats SET {q} WHERE id = ?", + (*kwargs.values(), bleskomat_id), + ) + row = await db.fetchone( + "SELECT * FROM bleskomat.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 bleskomat.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.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.bleskomat_lnurls WHERE hash = ?", (hash,) + ) + return BleskomatLnurl(**row) if row else None diff --git a/lnbits/extensions/bleskomat/exchange_rates.py b/lnbits/extensions/bleskomat/exchange_rates.py new file mode 100644 index 000000000..928a28231 --- /dev/null +++ b/lnbits/extensions/bleskomat/exchange_rates.py @@ -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 diff --git a/lnbits/extensions/bleskomat/fiat_currencies.json b/lnbits/extensions/bleskomat/fiat_currencies.json new file mode 100644 index 000000000..ff831f3ec --- /dev/null +++ b/lnbits/extensions/bleskomat/fiat_currencies.json @@ -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" +} \ No newline at end of file diff --git a/lnbits/extensions/bleskomat/helpers.py b/lnbits/extensions/bleskomat/helpers.py new file mode 100644 index 000000000..a3857b773 --- /dev/null +++ b/lnbits/extensions/bleskomat/helpers.py @@ -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 diff --git a/lnbits/extensions/bleskomat/lnurl_api.py b/lnbits/extensions/bleskomat/lnurl_api.py new file mode 100644 index 000000000..086562d1c --- /dev/null +++ b/lnbits/extensions/bleskomat/lnurl_api.py @@ -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 diff --git a/lnbits/extensions/bleskomat/migrations.py b/lnbits/extensions/bleskomat/migrations.py new file mode 100644 index 000000000..84e886e56 --- /dev/null +++ b/lnbits/extensions/bleskomat/migrations.py @@ -0,0 +1,37 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE bleskomat.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 bleskomat.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) + ); + """ + ) diff --git a/lnbits/extensions/bleskomat/models.py b/lnbits/extensions/bleskomat/models.py new file mode 100644 index 000000000..9b5e43223 --- /dev/null +++ b/lnbits/extensions/bleskomat/models.py @@ -0,0 +1,112 @@ +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 +from sqlite3 import Row +from pydantic import BaseModel + + +class Bleskomat(BaseModel): + 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(BaseModel): + 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.bleskomat_lnurls + SET remaining_uses = remaining_uses - 1, updated_time = ? + WHERE id = ? + AND remaining_uses > 0 + """, + (now, self.id), + ) + return result.rowcount > 0 diff --git a/lnbits/extensions/bleskomat/static/js/index.js b/lnbits/extensions/bleskomat/static/js/index.js new file mode 100644 index 000000000..fd166ff39 --- /dev/null +++ b/lnbits/extensions/bleskomat/static/js/index.js @@ -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) + } + } +}) diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html new file mode 100644 index 000000000..210d534c2 --- /dev/null +++ b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html @@ -0,0 +1,65 @@ + + + +

+ 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 + as well as the + commercial Bleskomat ATM. +

+
Connect Your Bleskomat ATM
+
+
    +
  1. Click the "Add Bleskomat" button on this page to begin.
  2. +
  3. + Choose a wallet. This will be the wallet that is used to pay + satoshis to your ATM customers. +
  4. +
  5. + Choose the fiat currency. This should match the fiat currency that + your ATM accepts. +
  6. +
  7. + 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. +
  8. +
  9. Set your ATM's fee percentage.
  10. +
  11. Click the "Done" button.
  12. +
  13. + Find the new Bleskomat in the list and then click the export icon to + download a new configuration file for your ATM. +
  14. +
  15. + Copy the configuration file ("bleskomat.conf") to your ATM's SD + card. +
  16. +
  17. + Restart Your Bleskomat ATM. It should automatically reload the + configurations from the SD card. +
  18. +
+
+
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. +

+
+
+
diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/index.html b/lnbits/extensions/bleskomat/templates/bleskomat/index.html new file mode 100644 index 000000000..0cc512378 --- /dev/null +++ b/lnbits/extensions/bleskomat/templates/bleskomat/index.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} {% block page %} +
+
+ + + Add Bleskomat + + + + + +
+
+
Bleskomats
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} Bleskomat extension +
+
+ + + {% include "bleskomat/_api_docs.html" %} + +
+
+ + + + + + + + + + + + +
+ Update Bleskomat + Add Bleskomat + Cancel +
+
+
+
+
+{% endblock %} diff --git a/lnbits/extensions/bleskomat/views.py b/lnbits/extensions/bleskomat/views.py new file mode 100644 index 000000000..3a7f72637 --- /dev/null +++ b/lnbits/extensions/bleskomat/views.py @@ -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 + ) diff --git a/lnbits/extensions/bleskomat/views_api.py b/lnbits/extensions/bleskomat/views_api.py new file mode 100644 index 000000000..2971b0669 --- /dev/null +++ b/lnbits/extensions/bleskomat/views_api.py @@ -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/", 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/", 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/", 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 diff --git a/lnbits/extensions/captcha/README.md b/lnbits/extensions/captcha/README.md new file mode 100644 index 000000000..277294592 --- /dev/null +++ b/lnbits/extensions/captcha/README.md @@ -0,0 +1,11 @@ +

Example Extension

+

*tagline*

+This is an example extension to help you organise and build you own. + +Try to include an image + + + +

If your extension has API endpoints, include useful ones here

+ +curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY" diff --git a/lnbits/extensions/captcha/__init__.py b/lnbits/extensions/captcha/__init__.py new file mode 100644 index 000000000..f25dccce2 --- /dev/null +++ b/lnbits/extensions/captcha/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_captcha") + +captcha_ext: Blueprint = Blueprint( + "captcha", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/captcha/config.json b/lnbits/extensions/captcha/config.json new file mode 100644 index 000000000..4ef7c43fb --- /dev/null +++ b/lnbits/extensions/captcha/config.json @@ -0,0 +1,6 @@ +{ + "name": "Captcha", + "short_description": "Create captcha to stop spam", + "icon": "block", + "contributors": ["pseudozach"] +} diff --git a/lnbits/extensions/captcha/crud.py b/lnbits/extensions/captcha/crud.py new file mode 100644 index 000000000..43a0374e1 --- /dev/null +++ b/lnbits/extensions/captcha/crud.py @@ -0,0 +1,53 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +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, +) -> Captcha: + captcha_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO captcha.captchas (id, wallet, url, memo, description, amount, remembers) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (captcha_id, wallet_id, url, memo, description, amount, int(remembers)), + ) + + captcha = await get_captcha(captcha_id) + assert captcha, "Newly created captcha couldn't be retrieved" + return captcha + + +async def get_captcha(captcha_id: str) -> Optional[Captcha]: + row = await db.fetchone( + "SELECT * FROM captcha.captchas WHERE id = ?", (captcha_id,) + ) + + return Captcha.from_row(row) if row else None + + +async def get_captchas(wallet_ids: Union[str, List[str]]) -> List[Captcha]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM captcha.captchas WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Captcha.from_row(row) for row in rows] + + +async def delete_captcha(captcha_id: str) -> None: + await db.execute("DELETE FROM captcha.captchas WHERE id = ?", (captcha_id,)) diff --git a/lnbits/extensions/captcha/migrations.py b/lnbits/extensions/captcha/migrations.py new file mode 100644 index 000000000..744fc5067 --- /dev/null +++ b/lnbits/extensions/captcha/migrations.py @@ -0,0 +1,63 @@ +async def m001_initial(db): + """ + Initial captchas table. + """ + await db.execute( + """ + CREATE TABLE captcha.captchas ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + secret TEXT NOT NULL, + url TEXT NOT NULL, + memo TEXT NOT NULL, + amount INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + +async def m002_redux(db): + """ + Creates an improved captchas table and migrates the existing data. + """ + await db.execute("ALTER TABLE captcha.captchas RENAME TO captchas_old") + await db.execute( + """ + CREATE TABLE captcha.captchas ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + url TEXT NOT NULL, + memo TEXT NOT NULL, + description TEXT NULL, + amount INTEGER DEFAULT 0, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """, + remembers INTEGER DEFAULT 0, + extras TEXT NULL + ); + """ + ) + + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM captcha.captchas_old") + ]: + await db.execute( + """ + INSERT INTO captcha.captchas ( + id, + wallet, + url, + memo, + amount, + time + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (row[0], row[1], row[3], row[4], row[5], row[6]), + ) + + await db.execute("DROP TABLE captcha.captchas_old") diff --git a/lnbits/extensions/captcha/models.py b/lnbits/extensions/captcha/models.py new file mode 100644 index 000000000..2b98a91e4 --- /dev/null +++ b/lnbits/extensions/captcha/models.py @@ -0,0 +1,24 @@ +import json + +from sqlite3 import Row +from pydantic import BaseModel +from typing import Optional + + +class Captcha(BaseModel): + id: str + wallet: str + url: str + memo: str + description: str + amount: int + time: int + remembers: bool + extras: Optional[dict] + + @classmethod + def from_row(cls, row: Row) -> "Captcha": + data = dict(row) + data["remembers"] = bool(data["remembers"]) + data["extras"] = json.loads(data["extras"]) if data["extras"] else None + return cls(**data) diff --git a/lnbits/extensions/captcha/static/js/captcha.js b/lnbits/extensions/captcha/static/js/captcha.js new file mode 100644 index 000000000..1da24f572 --- /dev/null +++ b/lnbits/extensions/captcha/static/js/captcha.js @@ -0,0 +1,82 @@ +var ciframeLoaded = !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') + var lnbhostsrc = document.getElementById('captchascript').getAttribute('src') + var lnbhost = lnbhostsrc.split('/captcha/static/js/captcha.js')[0] + return (e.src = lnbhost + '/captcha/' + captchaid), e +} +document.addEventListener('DOMContentLoaded', function () { + if (captchaStyleAdded) console.log('Captcha already added!') + else { + console.log('Adding captcha'), (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\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 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) diff --git a/lnbits/extensions/captcha/templates/captcha/_api_docs.html b/lnbits/extensions/captcha/templates/captcha/_api_docs.html new file mode 100644 index 000000000..dfe2f32f8 --- /dev/null +++ b/lnbits/extensions/captcha/templates/captcha/_api_docs.html @@ -0,0 +1,147 @@ + + + + + GET /captcha/api/v1/captchas +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<captcha_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}captcha/api/v1/captchas -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /captcha/api/v1/captchas +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"amount": <integer>, "description": <string>, "memo": + <string>, "remembers": <boolean>, "url": + <string>} +
+ Returns 201 CREATED (application/json) +
+ {"amount": <integer>, "description": <string>, "id": + <string>, "memo": <string>, "remembers": <boolean>, + "time": <int>, "url": <string>, "wallet": + <string>} +
Curl example
+ curl -X POST {{ request.url_root }}captcha/api/v1/captchas -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 }}" + +
+
+
+ + + + POST + /captcha/api/v1/captchas/<captcha_id>/invoice +
Body (application/json)
+ {"amount": <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"payment_hash": <string>, "payment_request": + <string>} +
Curl example
+ curl -X POST {{ request.url_root + }}captcha/api/v1/captchas/<captcha_id>/invoice -d '{"amount": + <integer>}' -H "Content-type: application/json" + +
+
+
+ + + + POST + /captcha/api/v1/captchas/<captcha_id>/check_invoice +
Body (application/json)
+ {"payment_hash": <string>} +
+ Returns 200 OK (application/json) +
+ {"paid": false}
+ {"paid": true, "url": <string>, "remembers": + <boolean>} +
Curl example
+ curl -X POST {{ request.url_root + }}captcha/api/v1/captchas/<captcha_id>/check_invoice -d + '{"payment_hash": <string>}' -H "Content-type: application/json" + +
+
+
+ + + + DELETE + /captcha/api/v1/captchas/<captcha_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}captcha/api/v1/captchas/<captcha_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/captcha/templates/captcha/display.html b/lnbits/extensions/captcha/templates/captcha/display.html new file mode 100644 index 000000000..a96cae058 --- /dev/null +++ b/lnbits/extensions/captcha/templates/captcha/display.html @@ -0,0 +1,178 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
{{ captcha.memo }}
+ {% if captcha.description %} +

{{ captcha.description }}

+ {% endif %} +
+ + + + + +
+ + + + + +
+ Copy invoice + Cancel +
+
+
+
+ +

+ Captcha accepted. You are probably human.
+ +

+ +
+
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/captcha/templates/captcha/index.html b/lnbits/extensions/captcha/templates/captcha/index.html new file mode 100644 index 000000000..45318f080 --- /dev/null +++ b/lnbits/extensions/captcha/templates/captcha/index.html @@ -0,0 +1,427 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New captcha + + + + + +
+
+
Captchas
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} captcha extension +
+
+ + + {% include "captcha/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + Remember payments + 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. + + + +
+ Create captcha + Cancel +
+
+
+
+ + + + {% raw %} + + + + {{ qrCodeDialog.data.snippet }} + +

+ Copy the snippet above and paste into your website/form. The checkbox + can be in checked state only after user pays. +

+
+

+ ID: {{ qrCodeDialog.data.id }}
+ Amount: {{ qrCodeDialog.data.amount }}
+ +

+ {% endraw %} +
+ Copy Snippet + + Close +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/captcha/views.py b/lnbits/extensions/captcha/views.py new file mode 100644 index 000000000..2b3643fa2 --- /dev/null +++ b/lnbits/extensions/captcha/views.py @@ -0,0 +1,22 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import captcha_ext +from .crud import get_captcha + + +@captcha_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("captcha/index.html", user=g.user) + + +@captcha_ext.route("/") +async def display(captcha_id): + captcha = await get_captcha(captcha_id) or abort( + HTTPStatus.NOT_FOUND, "captcha does not exist." + ) + return await render_template("captcha/display.html", captcha=captcha) diff --git a/lnbits/extensions/captcha/views_api.py b/lnbits/extensions/captcha/views_api.py new file mode 100644 index 000000000..c1b5ade8e --- /dev/null +++ b/lnbits/extensions/captcha/views_api.py @@ -0,0 +1,121 @@ +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import captcha_ext +from .crud import create_captcha, get_captcha, get_captchas, delete_captcha + + +@captcha_ext.route("/api/v1/captchas", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_captchas(): + wallet_ids = [g.wallet.id] + + 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, + ) + + +@captcha_ext.route("/api/v1/captchas", methods=["POST"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "url": {"type": "string", "empty": False, "required": True}, + "memo": {"type": "string", "empty": False, "required": True}, + "description": { + "type": "string", + "empty": True, + "nullable": True, + "required": False, + }, + "amount": {"type": "integer", "min": 0, "required": True}, + "remembers": {"type": "boolean", "required": True}, + } +) +async def api_captcha_create(): + captcha = await create_captcha(wallet_id=g.wallet.id, **g.data) + return jsonify(captcha._asdict()), HTTPStatus.CREATED + + +@captcha_ext.route("/api/v1/captchas/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_captcha_delete(captcha_id): + captcha = await get_captcha(captcha_id) + + if not captcha: + return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND + + if captcha.wallet != g.wallet.id: + return jsonify({"message": "Not your captcha."}), HTTPStatus.FORBIDDEN + + await delete_captcha(captcha_id) + + return "", HTTPStatus.NO_CONTENT + + +@captcha_ext.route("/api/v1/captchas//invoice", methods=["POST"]) +@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, + ) + + try: + 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"}, + ) + 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, + ) + + +@captcha_ext.route("/api/v1/captchas//check_invoice", methods=["POST"]) +@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) + + if not captcha: + return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND + + try: + status = await check_invoice_status(captcha.wallet, g.data["payment_hash"]) + is_paid = not status.pending + except Exception: + return jsonify({"paid": False}), HTTPStatus.OK + + if is_paid: + wallet = await get_wallet(captcha.wallet) + 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": False}), HTTPStatus.OK diff --git a/lnbits/extensions/copilot/models.py b/lnbits/extensions/copilot/models.py index 70d70cf5f..7eabaf9b4 100644 --- a/lnbits/extensions/copilot/models.py +++ b/lnbits/extensions/copilot/models.py @@ -5,9 +5,10 @@ from quart import url_for from lnurl import Lnurl, encode as lnurl_encode # type: ignore from lnurl.types import LnurlPayMetadata # type: ignore from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore +from sqlite3 import Row +from pydantic import BaseModel - -class Copilots(NamedTuple): +class Copilots(BaseModel): id: str user: str title: str diff --git a/lnbits/extensions/diagonalley/README.md b/lnbits/extensions/diagonalley/README.md new file mode 100644 index 000000000..6ba653e79 --- /dev/null +++ b/lnbits/extensions/diagonalley/README.md @@ -0,0 +1,10 @@ +

Diagon Alley

+

A movable market stand

+Make a list of products to sell, point the list to an indexer (or many), stack sats. +Diagon Alley is a movable market stand, for anon transactions. You then give permission for an indexer to list those products. Delivery addresses are sent through the Lightning Network. + + + +

API endpoints

+ +curl -X GET http://YOUR-TOR-ADDRESS diff --git a/lnbits/extensions/diagonalley/__init__.py b/lnbits/extensions/diagonalley/__init__.py new file mode 100644 index 000000000..ac907f5c7 --- /dev/null +++ b/lnbits/extensions/diagonalley/__init__.py @@ -0,0 +1,10 @@ +from quart import Blueprint + + +diagonalley_ext: Blueprint = Blueprint( + "diagonalley", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/diagonalley/config.json.example b/lnbits/extensions/diagonalley/config.json.example new file mode 100644 index 000000000..057d0f234 --- /dev/null +++ b/lnbits/extensions/diagonalley/config.json.example @@ -0,0 +1,6 @@ +{ + "name": "Diagon Alley", + "short_description": "Movable anonymous market stand", + "icon": "add_shopping_cart", + "contributors": ["benarc"] +} diff --git a/lnbits/extensions/diagonalley/crud.py b/lnbits/extensions/diagonalley/crud.py new file mode 100644 index 000000000..971cd449d --- /dev/null +++ b/lnbits/extensions/diagonalley/crud.py @@ -0,0 +1,308 @@ +from base64 import urlsafe_b64encode +from uuid import uuid4 +from typing import List, Optional, Union +import httpx +from lnbits.db import open_ext_db +from lnbits.settings import WALLET +from .models import Products, Orders, Indexers +import re + +regex = re.compile( + r"^(?:http|ftp)s?://" # http:// or https:// + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|" + r"localhost|" + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" + r"(?::\d+)?" + r"(?:/?|[/?]\S+)$", + re.IGNORECASE, +) + +###Products + + +def create_diagonalleys_product( + *, + 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") + db.execute( + """ + INSERT INTO diagonalley.products (id, wallet, product, categories, description, image, price, quantity) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + product_id, + wallet_id, + product, + categories, + description, + image, + price, + quantity, + ), + ) + + return get_diagonalleys_product(product_id) + + +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 diagonalley.products SET {q} WHERE id = ?", + (*kwargs.values(), product_id), + ) + row = db.fetchone( + "SELECT * FROM diagonalley.products WHERE id = ?", (product_id,) + ) + + return get_diagonalleys_indexer(product_id) + + +def get_diagonalleys_product(product_id: str) -> Optional[Products]: + with open_ext_db("diagonalley") as db: + row = db.fetchone( + "SELECT * FROM diagonalley.products WHERE id = ?", (product_id,) + ) + + return Products(**row) if row else None + + +def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Products]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + with open_ext_db("diagonalley") as db: + q = ",".join(["?"] * len(wallet_ids)) + rows = db.fetchall( + f"SELECT * FROM diagonalley.products WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Products(**row) for row in rows] + + +def delete_diagonalleys_product(product_id: str) -> None: + with open_ext_db("diagonalley") as db: + db.execute("DELETE FROM diagonalley.products WHERE id = ?", (product_id,)) + + +###Indexers + + +def create_diagonalleys_indexer( + wallet_id: str, + shopname: str, + indexeraddress: str, + shippingzone1: str, + shippingzone2: str, + zone1cost: int, + zone2cost: int, + email: str, +) -> Indexers: + with open_ext_db("diagonalley") as db: + indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") + db.execute( + """ + INSERT INTO diagonalley.indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + indexer_id, + wallet_id, + shopname, + indexeraddress, + False, + 0, + shippingzone1, + shippingzone2, + zone1cost, + zone2cost, + email, + ), + ) + return get_diagonalleys_indexer(indexer_id) + + +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 diagonalley.indexers SET {q} WHERE id = ?", + (*kwargs.values(), indexer_id), + ) + row = db.fetchone( + "SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,) + ) + + return get_diagonalleys_indexer(indexer_id) + + +def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]: + with open_ext_db("diagonalley") as db: + roww = db.fetchone( + "SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,) + ) + try: + x = httpx.get(roww["indexeraddress"] + "/" + roww["ratingkey"]) + if x.status_code == 200: + print(x) + print("poo") + with open_ext_db("diagonalley") as db: + db.execute( + "UPDATE diagonalley.indexers SET online = ? WHERE id = ?", + ( + True, + indexer_id, + ), + ) + else: + with open_ext_db("diagonalley") as db: + db.execute( + "UPDATE diagonalley.indexers SET online = ? WHERE id = ?", + ( + False, + indexer_id, + ), + ) + except: + print("An exception occurred") + with open_ext_db("diagonalley") as db: + row = db.fetchone( + "SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,) + ) + return Indexers(**row) if row else None + + +def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexers]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + with open_ext_db("diagonalley") as db: + q = ",".join(["?"] * len(wallet_ids)) + rows = db.fetchall( + f"SELECT * FROM diagonalley.indexers WHERE wallet IN ({q})", (*wallet_ids,) + ) + + for r in rows: + try: + x = httpx.get(r["indexeraddress"] + "/" + r["ratingkey"]) + if x.status_code == 200: + with open_ext_db("diagonalley") as db: + db.execute( + "UPDATE diagonalley.indexers SET online = ? WHERE id = ?", + ( + True, + r["id"], + ), + ) + else: + with open_ext_db("diagonalley") as db: + db.execute( + "UPDATE diagonalley.indexers SET online = ? WHERE id = ?", + ( + False, + r["id"], + ), + ) + except: + print("An exception occurred") + with open_ext_db("diagonalley") as db: + q = ",".join(["?"] * len(wallet_ids)) + rows = db.fetchall( + f"SELECT * FROM diagonalley.indexers WHERE wallet IN ({q})", (*wallet_ids,) + ) + return [Indexers(**row) for row in rows] + + +def delete_diagonalleys_indexer(indexer_id: str) -> None: + with open_ext_db("diagonalley") as db: + db.execute("DELETE FROM diagonalley.indexers WHERE id = ?", (indexer_id,)) + + +###Orders + + +def create_diagonalleys_order( + *, + productid: str, + wallet: str, + product: str, + quantity: int, + shippingzone: str, + address: str, + email: str, + invoiceid: str, + paid: bool, + shipped: bool, +) -> Indexers: + with open_ext_db("diagonalley") as db: + order_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") + db.execute( + """ + INSERT INTO diagonalley.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, + ), + ) + + return get_diagonalleys_order(order_id) + + +def get_diagonalleys_order(order_id: str) -> Optional[Orders]: + with open_ext_db("diagonalley") as db: + row = db.fetchone("SELECT * FROM diagonalley.orders WHERE id = ?", (order_id,)) + + return Orders(**row) if row else None + + +def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + with open_ext_db("diagonalley") as db: + q = ",".join(["?"] * len(wallet_ids)) + rows = db.fetchall( + f"SELECT * FROM diagonalley.orders WHERE wallet IN ({q})", (*wallet_ids,) + ) + for r in rows: + PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid + if PAID: + with open_ext_db("diagonalley") as db: + db.execute( + "UPDATE diagonalley.orders SET paid = ? WHERE id = ?", + ( + True, + r["id"], + ), + ) + rows = db.fetchall( + f"SELECT * FROM diagonalley.orders WHERE wallet IN ({q})", + (*wallet_ids,), + ) + return [Orders(**row) for row in rows] + + +def delete_diagonalleys_order(order_id: str) -> None: + with open_ext_db("diagonalley") as db: + db.execute("DELETE FROM diagonalley.orders WHERE id = ?", (order_id,)) diff --git a/lnbits/extensions/diagonalley/migrations.py b/lnbits/extensions/diagonalley/migrations.py new file mode 100644 index 000000000..9f2b787f9 --- /dev/null +++ b/lnbits/extensions/diagonalley/migrations.py @@ -0,0 +1,60 @@ +async def m001_initial(db): + """ + Initial products table. + """ + await db.execute( + """ + CREATE TABLE diagonalley.products ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + product TEXT NOT NULL, + categories TEXT NOT NULL, + description TEXT NOT NULL, + image TEXT NOT NULL, + price INTEGER NOT NULL, + quantity INTEGER NOT NULL + ); + """ + ) + + """ + Initial indexers table. + """ + await db.execute( + """ + CREATE TABLE diagonalley.indexers ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + shopname TEXT NOT NULL, + indexeraddress TEXT NOT NULL, + online BOOLEAN NOT NULL, + rating INTEGER NOT NULL, + shippingzone1 TEXT NOT NULL, + shippingzone2 TEXT NOT NULL, + zone1cost INTEGER NOT NULL, + zone2cost INTEGER NOT NULL, + email TEXT NOT NULL + ); + """ + ) + + """ + Initial orders table. + """ + await db.execute( + """ + CREATE TABLE diagonalley.orders ( + id TEXT PRIMARY KEY, + productid TEXT NOT NULL, + wallet TEXT NOT NULL, + product TEXT NOT NULL, + quantity INTEGER NOT NULL, + shippingzone INTEGER NOT NULL, + address TEXT NOT NULL, + email TEXT NOT NULL, + invoiceid TEXT NOT NULL, + paid BOOLEAN NOT NULL, + shipped BOOLEAN NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/diagonalley/models.py b/lnbits/extensions/diagonalley/models.py new file mode 100644 index 000000000..ab1c592de --- /dev/null +++ b/lnbits/extensions/diagonalley/models.py @@ -0,0 +1,41 @@ +from typing import NamedTuple +from sqlite3 import Row +from pydantic import BaseModel + +class Indexers(BaseModel): + id: str + wallet: str + shopname: str + indexeraddress: str + online: bool + rating: str + shippingzone1: str + shippingzone2: str + zone1cost: int + zone2cost: int + email: str + + +class Products(BaseModel): + id: str + wallet: str + product: str + categories: str + description: str + image: str + price: int + quantity: int + + +class Orders(BaseModel): + id: str + productid: str + wallet: str + product: str + quantity: int + shippingzone: int + address: str + email: str + invoiceid: str + paid: bool + shipped: bool diff --git a/lnbits/extensions/diagonalley/templates/diagonalley/_api_docs.html b/lnbits/extensions/diagonalley/templates/diagonalley/_api_docs.html new file mode 100644 index 000000000..585e8d7c8 --- /dev/null +++ b/lnbits/extensions/diagonalley/templates/diagonalley/_api_docs.html @@ -0,0 +1,122 @@ + + + +
+ Diagon Alley: Decentralised Market-Stalls +
+

+ Make a list of products to sell, point your list of products at a public + indexer. Buyers browse your products on the indexer, and pay you + directly. Ratings are managed by the indexer. Your stall can be listed + in multiple indexers, even over TOR, if you wish to be anonymous.
+ More information on the + Diagon Alley Protocol
+ + Created by, Ben Arc +

+
+
+
+ + + + + GET + /api/v1/diagonalley/stall/products/<indexer_id> +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ Product JSON list +
Curl example
+ curl -X GET {{ request.url_root + }}diagonalley/api/v1/diagonalley/stall/products/<indexer_id> +
+
+
+ + + + POST + /api/v1/diagonalley/stall/order/<indexer_id> +
Body (application/json)
+ {"id": <string>, "address": <string>, "shippingzone": + <integer>, "email": <string>, "quantity": + <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"checking_id": <string>,"payment_request": + <string>} +
Curl example
+ curl -X POST {{ request.url_root + }}diagonalley/api/v1/diagonalley/stall/order/<indexer_id> -d + '{"id": <product_id&>, "email": <customer_email>, + "address": <customer_address>, "quantity": 2, "shippingzone": + 1}' -H "Content-type: application/json" + +
+
+
+ + + + GET + /diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id> +
Headers
+
+ Returns 200 OK (application/json) +
+ {"shipped": <boolean>} +
Curl example
+ curl -X GET {{ request.url_root + }}diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id> + -H "Content-type: application/json" +
+
+
+
diff --git a/lnbits/extensions/diagonalley/templates/diagonalley/index.html b/lnbits/extensions/diagonalley/templates/diagonalley/index.html new file mode 100644 index 000000000..c041239f6 --- /dev/null +++ b/lnbits/extensions/diagonalley/templates/diagonalley/index.html @@ -0,0 +1,906 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Product + New Indexer + + Frontend shop your stall will list its products in + + + + + + +
+
+
Products
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Indexers
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Orders
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
LNbits Diagon Alley Extension
+
+ + + {% include "diagonalley/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + +
+ Update Product + + Create Product + + Cancel +
+
+
+
+ + + + + + + + + + + + + + + + +
+ Update Indexer + + Create Indexer + + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/diagonalley/templates/diagonalley/stall.html b/lnbits/extensions/diagonalley/templates/diagonalley/stall.html new file mode 100644 index 000000000..a45d254de --- /dev/null +++ b/lnbits/extensions/diagonalley/templates/diagonalley/stall.html @@ -0,0 +1,3 @@ + diff --git a/lnbits/extensions/diagonalley/views.py b/lnbits/extensions/diagonalley/views.py new file mode 100644 index 000000000..6781a99e0 --- /dev/null +++ b/lnbits/extensions/diagonalley/views.py @@ -0,0 +1,11 @@ +from quart import g, render_template + +from lnbits.decorators import check_user_exists, validate_uuids +from lnbits.extensions.diagonalley import diagonalley_ext + + +@diagonalley_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("diagonalley/index.html", user=g.user) diff --git a/lnbits/extensions/diagonalley/views_api.py b/lnbits/extensions/diagonalley/views_api.py new file mode 100644 index 000000000..71a2eca6d --- /dev/null +++ b/lnbits/extensions/diagonalley/views_api.py @@ -0,0 +1,360 @@ +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 lnbits.extensions.diagonalley import diagonalley_ext +from .crud import ( + create_diagonalleys_product, + get_diagonalleys_product, + get_diagonalleys_products, + delete_diagonalleys_product, + create_diagonalleys_indexer, + update_diagonalleys_indexer, + get_diagonalleys_indexer, + get_diagonalleys_indexers, + delete_diagonalleys_indexer, + create_diagonalleys_order, + get_diagonalleys_order, + get_diagonalleys_orders, + update_diagonalleys_product, +) +from lnbits.core.services import create_invoice +from base64 import urlsafe_b64encode +from uuid import uuid4 +from lnbits.db import open_ext_db + +### Products + + +@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["GET"]) +@api_check_wallet_key(key_type="invoice") +async def api_diagonalley_products(): + wallet_ids = [g.wallet.id] + + 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, + ) + + +@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"]) +@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["PUT"]) +@api_check_wallet_key(key_type="invoice") +@api_validate_post_request( + schema={ + "product": {"type": "string", "empty": False, "required": True}, + "categories": {"type": "string", "empty": False, "required": True}, + "description": {"type": "string", "empty": False, "required": True}, + "image": {"type": "string", "empty": False, "required": True}, + "price": {"type": "integer", "min": 0, "required": True}, + "quantity": {"type": "integer", "min": 0, "required": True}, + } +) +async def api_diagonalley_product_create(product_id=None): + + if product_id: + product = get_diagonalleys_indexer(product_id) + + if not product: + 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, + ) + + 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, + ) + + +@diagonalley_ext.route("/api/v1/diagonalley/products/", methods=["DELETE"]) +@api_check_wallet_key(key_type="invoice") +async def api_diagonalley_products_delete(product_id): + product = get_diagonalleys_product(product_id) + + if not product: + return jsonify({"message": "Product does not exist."}), HTTPStatus.NOT_FOUND + + if product.wallet != g.wallet.id: + return jsonify({"message": "Not your Diagon Alley."}), HTTPStatus.FORBIDDEN + + delete_diagonalleys_product(product_id) + + return "", HTTPStatus.NO_CONTENT + + +###Indexers + + +@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["GET"]) +@api_check_wallet_key(key_type="invoice") +async def api_diagonalley_indexers(): + wallet_ids = [g.wallet.id] + + 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, + ) + + +@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"]) +@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["PUT"]) +@api_check_wallet_key(key_type="invoice") +@api_validate_post_request( + schema={ + "shopname": {"type": "string", "empty": False, "required": True}, + "indexeraddress": {"type": "string", "empty": False, "required": True}, + "shippingzone1": {"type": "string", "empty": False, "required": True}, + "shippingzone2": {"type": "string", "empty": False, "required": True}, + "email": {"type": "string", "empty": False, "required": True}, + "zone1cost": {"type": "integer", "min": 0, "required": True}, + "zone2cost": {"type": "integer", "min": 0, "required": True}, + } +) +async def api_diagonalley_indexer_create(indexer_id=None): + + if indexer_id: + indexer = get_diagonalleys_indexer(indexer_id) + + if not indexer: + 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, + ) + + 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, + ) + + +@diagonalley_ext.route("/api/v1/diagonalley/indexers/", methods=["DELETE"]) +@api_check_wallet_key(key_type="invoice") +async def api_diagonalley_indexer_delete(indexer_id): + indexer = get_diagonalleys_indexer(indexer_id) + + if not indexer: + return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND + + if indexer.wallet != g.wallet.id: + return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN + + delete_diagonalleys_indexer(indexer_id) + + return "", HTTPStatus.NO_CONTENT + + +###Orders + + +@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["GET"]) +@api_check_wallet_key(key_type="invoice") +async def api_diagonalley_orders(): + wallet_ids = [g.wallet.id] + + 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, + ) + + +@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"]) +@api_check_wallet_key(key_type="invoice") +@api_validate_post_request( + schema={ + "id": {"type": "string", "empty": False, "required": True}, + "address": {"type": "string", "empty": False, "required": True}, + "email": {"type": "string", "empty": False, "required": True}, + "quantity": {"type": "integer", "empty": False, "required": True}, + "shippingzone": {"type": "integer", "empty": False, "required": True}, + } +) +async def api_diagonalley_order_create(): + order = create_diagonalleys_order(wallet_id=g.wallet.id, **g.data) + return jsonify(order._asdict()), HTTPStatus.CREATED + + +@diagonalley_ext.route("/api/v1/diagonalley/orders/", methods=["DELETE"]) +@api_check_wallet_key(key_type="invoice") +async def api_diagonalley_order_delete(order_id): + order = get_diagonalleys_order(order_id) + + if not order: + return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND + + if order.wallet != g.wallet.id: + return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN + + delete_diagonalleys_indexer(order_id) + + return "", HTTPStatus.NO_CONTENT + + +@diagonalley_ext.route("/api/v1/diagonalley/orders/paid/", methods=["GET"]) +@api_check_wallet_key(key_type="invoice") +async def api_diagonalleys_order_paid(order_id): + with open_ext_db("diagonalley") as db: + db.execute( + "UPDATE diagonalley.orders SET paid = ? WHERE id = ?", + ( + True, + order_id, + ), + ) + return "", HTTPStatus.OK + + +@diagonalley_ext.route("/api/v1/diagonalley/orders/shipped/", methods=["GET"]) +@api_check_wallet_key(key_type="invoice") +async def api_diagonalleys_order_shipped(order_id): + with open_ext_db("diagonalley") as db: + db.execute( + "UPDATE diagonalley.orders SET shipped = ? WHERE id = ?", + ( + True, + order_id, + ), + ) + order = db.fetchone( + "SELECT * FROM diagonalley.orders WHERE id = ?", (order_id,) + ) + + 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/", methods=["GET"] +) +async def api_diagonalleys_stall_products(indexer_id): + with open_ext_db("diagonalley") as db: + rows = db.fetchone( + "SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,) + ) + print(rows[1]) + if not rows: + return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND + + products = db.fetchone( + "SELECT * FROM diagonalley.products WHERE wallet = ?", (rows[1],) + ) + 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, + ) + + +###Check a product has been shipped + + +@diagonalley_ext.route( + "/api/v1/diagonalley/stall/checkshipped/", methods=["GET"] +) +async def api_diagonalleys_stall_checkshipped(checking_id): + with open_ext_db("diagonalley") as db: + rows = db.fetchone( + "SELECT * FROM diagonalley.orders WHERE invoiceid = ?", (checking_id,) + ) + + return jsonify({"shipped": rows["shipped"]}), HTTPStatus.OK + + +###Place order + + +@diagonalley_ext.route("/api/v1/diagonalley/stall/order/", methods=["POST"]) +@api_validate_post_request( + schema={ + "id": {"type": "string", "empty": False, "required": True}, + "email": {"type": "string", "empty": False, "required": True}, + "address": {"type": "string", "empty": False, "required": True}, + "quantity": {"type": "integer", "empty": False, "required": True}, + "shippingzone": {"type": "integer", "empty": False, "required": True}, + } +) +async def api_diagonalley_stall_order(indexer_id): + product = get_diagonalleys_product(g.data["id"]) + shipping = get_diagonalleys_indexer(indexer_id) + + if g.data["shippingzone"] == 1: + shippingcost = shipping.zone1cost + else: + shippingcost = shipping.zone2cost + + checking_id, payment_request = create_invoice( + 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: + db.execute( + """ + INSERT INTO diagonalley.orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + selling_id, + g.data["id"], + product.wallet, + product.product, + g.data["quantity"], + g.data["shippingzone"], + g.data["address"], + g.data["email"], + checking_id, + False, + False, + ), + ) + return ( + jsonify({"checking_id": checking_id, "payment_request": payment_request}), + HTTPStatus.OK, + ) diff --git a/lnbits/extensions/events/README.md b/lnbits/extensions/events/README.md new file mode 100644 index 000000000..11b62fecb --- /dev/null +++ b/lnbits/extensions/events/README.md @@ -0,0 +1,33 @@ +# Events + +## Sell tickets for events and use the built-in scanner for registering attendants + +Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance. + +Events includes a shareable ticket scanner, which can be used to register attendees. + +## Usage + +1. Create an event\ + ![create event](https://i.imgur.com/dadK1dp.jpg) +2. Fill out the event information: + + - event name + - wallet (normally there's only one) + - event information + - closing date for event registration + - begin and end date of the event + + ![event info](https://imgur.com/KAv68Yr.jpg) + +3. Share the event registration link\ + ![event ticket](https://imgur.com/AQWUOBY.jpg) + + - ticket example\ + ![ticket example](https://i.imgur.com/trAVSLd.jpg) + + - QR code ticket, presented after invoice paid, to present at registration\ + ![event ticket](https://i.imgur.com/M0ROM82.jpg) + +4. Use the built-in ticket scanner to validate registered, and paid, attendees\ + ![ticket scanner](https://i.imgur.com/zrm9202.jpg) diff --git a/lnbits/extensions/events/__init__.py b/lnbits/extensions/events/__init__.py new file mode 100644 index 000000000..b8f4deb55 --- /dev/null +++ b/lnbits/extensions/events/__init__.py @@ -0,0 +1,13 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_events") + + +events_ext: Blueprint = Blueprint( + "events", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/events/config.json b/lnbits/extensions/events/config.json new file mode 100644 index 000000000..6bc144ab0 --- /dev/null +++ b/lnbits/extensions/events/config.json @@ -0,0 +1,6 @@ +{ + "name": "Events", + "short_description": "Sell and register event tickets", + "icon": "local_activity", + "contributors": ["benarc"] +} diff --git a/lnbits/extensions/events/crud.py b/lnbits/extensions/events/crud.py new file mode 100644 index 000000000..dece8e6d6 --- /dev/null +++ b/lnbits/extensions/events/crud.py @@ -0,0 +1,168 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Tickets, Events + + +# TICKETS + + +async def create_ticket( + payment_hash: str, wallet: str, event: str, name: str, email: str +) -> Tickets: + await db.execute( + """ + INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (payment_hash, wallet, event, name, email, False, False), + ) + + ticket = await get_ticket(payment_hash) + assert ticket, "Newly created ticket couldn't be retrieved" + return ticket + + +async def set_ticket_paid(payment_hash: str) -> Tickets: + row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,)) + if row[6] != True: + await db.execute( + """ + UPDATE events.ticket + SET paid = true + WHERE id = ? + """, + (payment_hash,), + ) + + eventdata = await get_event(row[2]) + assert eventdata, "Couldn't get event from ticket being paid" + + sold = eventdata.sold + 1 + amount_tickets = eventdata.amount_tickets - 1 + await db.execute( + """ + UPDATE events.events + SET sold = ?, amount_tickets = ? + WHERE id = ? + """, + (sold, amount_tickets, row[2]), + ) + + ticket = await get_ticket(payment_hash) + assert ticket, "Newly updated ticket couldn't be retrieved" + return ticket + + +async def get_ticket(payment_hash: str) -> Optional[Tickets]: + row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,)) + return Tickets(**row) if row else None + + +async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM events.ticket WHERE wallet IN ({q})", (*wallet_ids,) + ) + return [Tickets(**row) for row in rows] + + +async def delete_ticket(payment_hash: str) -> None: + await db.execute("DELETE FROM events.ticket WHERE id = ?", (payment_hash,)) + + +# EVENTS + + +async def create_event( + *, + wallet: str, + name: str, + info: str, + closing_date: str, + event_start_date: str, + event_end_date: str, + amount_tickets: int, + price_per_ticket: int, +) -> Events: + event_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO events.events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, + wallet, + name, + info, + closing_date, + event_start_date, + event_end_date, + amount_tickets, + price_per_ticket, + 0, + ), + ) + + event = await get_event(event_id) + assert event, "Newly created event couldn't be retrieved" + return 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.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 + + +async def get_event(event_id: str) -> Optional[Events]: + row = await db.fetchone("SELECT * FROM events.events WHERE id = ?", (event_id,)) + return Events(**row) if row else None + + +async def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM events.events WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Events(**row) for row in rows] + + +async def delete_event(event_id: str) -> None: + await db.execute("DELETE FROM events.events WHERE id = ?", (event_id,)) + + +# EVENTTICKETS + + +async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]: + rows = await db.fetchall( + "SELECT * FROM events.ticket WHERE wallet = ? AND event = ?", + (wallet_id, event_id), + ) + return [Tickets(**row) for row in rows] + + +async def reg_ticket(ticket_id: str) -> List[Tickets]: + await db.execute( + "UPDATE events.ticket SET registered = ? WHERE id = ?", (True, ticket_id) + ) + ticket = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (ticket_id,)) + rows = await db.fetchall( + "SELECT * FROM events.ticket WHERE event = ?", (ticket[1],) + ) + return [Tickets(**row) for row in rows] diff --git a/lnbits/extensions/events/migrations.py b/lnbits/extensions/events/migrations.py new file mode 100644 index 000000000..d8f3d94e8 --- /dev/null +++ b/lnbits/extensions/events/migrations.py @@ -0,0 +1,91 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE events.events ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + info TEXT NOT NULL, + closing_date TEXT NOT NULL, + event_start_date TEXT NOT NULL, + event_end_date TEXT NOT NULL, + amount_tickets INTEGER NOT NULL, + price_per_ticket INTEGER NOT NULL, + sold INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute( + """ + CREATE TABLE events.tickets ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + event TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + registered BOOLEAN NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + +async def m002_changed(db): + + await db.execute( + """ + CREATE TABLE events.ticket ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + event TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + registered BOOLEAN NOT NULL, + paid BOOLEAN NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + for row in [list(row) for row in await db.fetchall("SELECT * FROM events.tickets")]: + usescsv = "" + + for i in range(row[5]): + if row[7]: + usescsv += "," + str(i + 1) + else: + usescsv += "," + str(1) + usescsv = usescsv[1:] + await db.execute( + """ + INSERT INTO events.ticket ( + id, + wallet, + event, + name, + email, + registered, + paid + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + row[0], + row[1], + row[2], + row[3], + row[4], + row[5], + True, + ), + ) + await db.execute("DROP TABLE events.tickets") diff --git a/lnbits/extensions/events/models.py b/lnbits/extensions/events/models.py new file mode 100644 index 000000000..0f79fa413 --- /dev/null +++ b/lnbits/extensions/events/models.py @@ -0,0 +1,26 @@ +from typing import NamedTuple + + +class Events(NamedTuple): + id: str + wallet: str + name: str + info: str + closing_date: str + event_start_date: str + event_end_date: str + amount_tickets: int + price_per_ticket: int + sold: int + time: int + + +class Tickets(NamedTuple): + id: str + wallet: str + event: str + name: str + email: str + registered: bool + paid: bool + time: int diff --git a/lnbits/extensions/events/templates/events/_api_docs.html b/lnbits/extensions/events/templates/events/_api_docs.html new file mode 100644 index 000000000..a5c821747 --- /dev/null +++ b/lnbits/extensions/events/templates/events/_api_docs.html @@ -0,0 +1,23 @@ + + + +
+ Events: Sell and register ticket waves for an event +
+

+ Events alows you to make a wave of tickets for an event, each ticket is + in the form of a unqiue QRcode, which the user presents at registration. + Events comes with a shareable ticket scanner, which can be used to + register attendees.
+ + Created by, Ben Arc + +

+
+
+
diff --git a/lnbits/extensions/events/templates/events/display.html b/lnbits/extensions/events/templates/events/display.html new file mode 100644 index 000000000..4c1f557f1 --- /dev/null +++ b/lnbits/extensions/events/templates/events/display.html @@ -0,0 +1,207 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +

{{ event_name }}

+
+
{{ event_info }}
+
+ + + + +
+ Submit + Cancel +
+
+
+
+ + +
+ Link to your ticket! +

+

You'll be redirected in a few moments...

+
+
+
+ + + + + + +
+ Copy invoice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/events/templates/events/error.html b/lnbits/extensions/events/templates/events/error.html new file mode 100644 index 000000000..f231177b4 --- /dev/null +++ b/lnbits/extensions/events/templates/events/error.html @@ -0,0 +1,35 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

{{ event_name }} error

+
+ + +
{{ event_error }}
+
+
+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/events/templates/events/index.html b/lnbits/extensions/events/templates/events/index.html new file mode 100644 index 000000000..1ad3d885f --- /dev/null +++ b/lnbits/extensions/events/templates/events/index.html @@ -0,0 +1,538 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Event + + + + + +
+
+
Events
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Tickets
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Events extension +
+
+ + + {% include "events/_api_docs.html" %} + +
+
+ + + + +
+
+ +
+
+ + +
+
+ + +
+
Ticket closing date
+
+ +
+
+ +
+
Event begins
+
+ +
+
+ +
+
Event ends
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ Update Event + Create Event + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/events/templates/events/register.html b/lnbits/extensions/events/templates/events/register.html new file mode 100644 index 000000000..4dff9afbb --- /dev/null +++ b/lnbits/extensions/events/templates/events/register.html @@ -0,0 +1,173 @@ +{% extends "public.html" %} {% block page %} + +
+
+ + +
+

{{ event_name }} Registration

+
+ +
+ + Scan ticket +
+
+
+ + + + + {% raw %} + + + {% endraw %} + + + +
+ + + +
+ +
+
+ Cancel +
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/events/templates/events/ticket.html b/lnbits/extensions/events/templates/events/ticket.html new file mode 100644 index 000000000..a53f834f9 --- /dev/null +++ b/lnbits/extensions/events/templates/events/ticket.html @@ -0,0 +1,45 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

{{ ticket_name }} Ticket

+
+
+ Bookmark, print or screenshot this page,
+ and present it for registration! +
+
+ + +
+ + Print +
+
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/events/views.py b/lnbits/extensions/events/views.py new file mode 100644 index 000000000..e15513208 --- /dev/null +++ b/lnbits/extensions/events/views.py @@ -0,0 +1,76 @@ +from quart import g, abort, render_template +from datetime import date, datetime +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import events_ext +from .crud import get_ticket, get_event + + +@events_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("events/index.html", user=g.user) + + +@events_ext.route("/") +async def display(event_id): + event = await get_event(event_id) + if not event: + abort(HTTPStatus.NOT_FOUND, "Event does not exist.") + + if event.amount_tickets < 1: + return await render_template( + "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 :(", + ) + + return await render_template( + "events/display.html", + event_id=event_id, + event_name=event.name, + event_info=event.info, + event_price=event.price_per_ticket, + ) + + +@events_ext.route("/ticket/") +async def ticket(ticket_id): + ticket = await get_ticket(ticket_id) + if not ticket: + abort(HTTPStatus.NOT_FOUND, "Ticket does not exist.") + + event = await get_event(ticket.event) + if not event: + 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_ext.route("/register/") +async def register(event_id): + event = await get_event(event_id) + if not event: + 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, + ) diff --git a/lnbits/extensions/events/views_api.py b/lnbits/extensions/events/views_api.py new file mode 100644 index 000000000..e6aea102a --- /dev/null +++ b/lnbits/extensions/events/views_api.py @@ -0,0 +1,207 @@ +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import events_ext +from .crud import ( + create_ticket, + set_ticket_paid, + get_ticket, + get_tickets, + delete_ticket, + create_event, + update_event, + get_event, + get_events, + delete_event, + get_event_tickets, + reg_ticket, +) + + +# Events + + +@events_ext.route("/api/v1/events", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_events(): + wallet_ids = [g.wallet.id] + + 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, + ) + + +@events_ext.route("/api/v1/events", methods=["POST"]) +@events_ext.route("/api/v1/events/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "wallet": {"type": "string", "empty": False, "required": True}, + "name": {"type": "string", "empty": False, "required": True}, + "info": {"type": "string", "min": 0, "required": True}, + "closing_date": {"type": "string", "empty": False, "required": True}, + "event_start_date": {"type": "string", "empty": False, "required": True}, + "event_end_date": {"type": "string", "empty": False, "required": True}, + "amount_tickets": {"type": "integer", "min": 0, "required": True}, + "price_per_ticket": {"type": "integer", "min": 0, "required": True}, + } +) +async def api_event_create(event_id=None): + if event_id: + event = await get_event(event_id) + if not event: + return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND + + if event.wallet != g.wallet.id: + return jsonify({"message": "Not your event."}), HTTPStatus.FORBIDDEN + + event = await update_event(event_id, **g.data) + else: + event = await create_event(**g.data) + + return jsonify(event._asdict()), HTTPStatus.CREATED + + +@events_ext.route("/api/v1/events/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_form_delete(event_id): + event = await get_event(event_id) + if not event: + return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND + + if event.wallet != g.wallet.id: + return jsonify({"message": "Not your event."}), HTTPStatus.FORBIDDEN + + await delete_event(event_id) + return "", HTTPStatus.NO_CONTENT + + +#########Tickets########## + + +@events_ext.route("/api/v1/tickets", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_tickets(): + wallet_ids = [g.wallet.id] + + 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, + ) + + +@events_ext.route("/api/v1/tickets//", methods=["POST"]) +@api_validate_post_request( + schema={ + "name": {"type": "string", "empty": False, "required": True}, + "email": {"type": "string", "empty": False, "required": True}, + } +) +async def api_ticket_make_ticket(event_id, sats): + event = await get_event(event_id) + if not event: + 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"}, + ) + 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 + ) + + 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, + ) + + +@events_ext.route("/api/v1/tickets/", methods=["GET"]) +async def api_ticket_send_ticket(payment_hash): + ticket = await get_ticket(payment_hash) + + try: + status = await check_invoice_status(ticket.wallet, payment_hash) + is_paid = not status.pending + except Exception: + return jsonify({"message": "Not paid."}), HTTPStatus.NOT_FOUND + + if is_paid: + wallet = await get_wallet(ticket.wallet) + payment = await wallet.get_payment(payment_hash) + await payment.set_pending(False) + ticket = await set_ticket_paid(payment_hash=payment_hash) + + return jsonify({"paid": True, "ticket_id": ticket.id}), HTTPStatus.OK + + return jsonify({"paid": False}), HTTPStatus.OK + + +@events_ext.route("/api/v1/tickets/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_ticket_delete(ticket_id): + ticket = await get_ticket(ticket_id) + + if not ticket: + return jsonify({"message": "Ticket does not exist."}), HTTPStatus.NOT_FOUND + + if ticket.wallet != g.wallet.id: + return jsonify({"message": "Not your ticket."}), HTTPStatus.FORBIDDEN + + await delete_ticket(ticket_id) + return "", HTTPStatus.NO_CONTENT + + +# Event Tickets + + +@events_ext.route("/api/v1/eventtickets//", 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 + ) + ] + ), + HTTPStatus.OK, + ) + + +@events_ext.route("/api/v1/register/ticket/", methods=["GET"]) +async def api_event_register_ticket(ticket_id): + ticket = await get_ticket(ticket_id) + if not ticket: + return jsonify({"message": "Ticket does not exist."}), HTTPStatus.FORBIDDEN + + if not ticket.paid: + return jsonify({"message": "Ticket not paid for."}), HTTPStatus.FORBIDDEN + + 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, + ) diff --git a/lnbits/extensions/example/README.md b/lnbits/extensions/example/README.md new file mode 100644 index 000000000..277294592 --- /dev/null +++ b/lnbits/extensions/example/README.md @@ -0,0 +1,11 @@ +

Example Extension

+

*tagline*

+This is an example extension to help you organise and build you own. + +Try to include an image + + + +

If your extension has API endpoints, include useful ones here

+ +curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY" diff --git a/lnbits/extensions/example/__init__.py b/lnbits/extensions/example/__init__.py new file mode 100644 index 000000000..e16e0372f --- /dev/null +++ b/lnbits/extensions/example/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_example") + +example_ext: Blueprint = Blueprint( + "example", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/example/config.json b/lnbits/extensions/example/config.json new file mode 100644 index 000000000..55389373b --- /dev/null +++ b/lnbits/extensions/example/config.json @@ -0,0 +1,6 @@ +{ + "name": "Build your own!", + "short_description": "Join us, make an extension", + "icon": "info", + "contributors": ["github_username"] +} diff --git a/lnbits/extensions/example/migrations.py b/lnbits/extensions/example/migrations.py new file mode 100644 index 000000000..99d7c362d --- /dev/null +++ b/lnbits/extensions/example/migrations.py @@ -0,0 +1,10 @@ +# async def m001_initial(db): +# await db.execute( +# f""" +# CREATE TABLE example.example ( +# id TEXT PRIMARY KEY, +# wallet TEXT NOT NULL, +# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} +# ); +# """ +# ) diff --git a/lnbits/extensions/example/models.py b/lnbits/extensions/example/models.py new file mode 100644 index 000000000..be5232339 --- /dev/null +++ b/lnbits/extensions/example/models.py @@ -0,0 +1,11 @@ +# from sqlite3 import Row +# from typing import NamedTuple + + +# class Example(NamedTuple): +# id: str +# wallet: str +# +# @classmethod +# def from_row(cls, row: Row) -> "Example": +# return cls(**dict(row)) diff --git a/lnbits/extensions/example/templates/example/index.html b/lnbits/extensions/example/templates/example/index.html new file mode 100644 index 000000000..d732ef376 --- /dev/null +++ b/lnbits/extensions/example/templates/example/index.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + + +
+ Frameworks used by {{SITE_TITLE}} +
+ + + {% raw %} + + + {{ tool.name }} + {{ tool.language }} + + {% endraw %} + + + +

+ A magical "g" is always available, with info about the user, wallets and + extensions: +

+ {% raw %}{{ g }}{% endraw %} +
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/example/views.py b/lnbits/extensions/example/views.py new file mode 100644 index 000000000..99e58f626 --- /dev/null +++ b/lnbits/extensions/example/views.py @@ -0,0 +1,12 @@ +from quart import g, render_template + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import example_ext + + +@example_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("example/index.html", user=g.user) diff --git a/lnbits/extensions/example/views_api.py b/lnbits/extensions/example/views_api.py new file mode 100644 index 000000000..e59c1072a --- /dev/null +++ b/lnbits/extensions/example/views_api.py @@ -0,0 +1,40 @@ +# views_api.py is for you API endpoints that could be hit by another service + +# add your dependencies here + +# import json +# import httpx +# (use httpx just like requests, except instead of response.ok there's only the +# response.is_error that is its inverse) + +from quart import jsonify +from http import HTTPStatus + +from . import example_ext + + +# add your endpoints here + + +@example_ext.route("/api/v1/tools", methods=["GET"]) +async def api_example(): + """Try to add descriptions for others.""" + tools = [ + { + "name": "Quart", + "url": "https://pgjones.gitlab.io/quart/", + "language": "Python", + }, + { + "name": "Vue.js", + "url": "https://vuejs.org/", + "language": "JavaScript", + }, + { + "name": "Quasar Framework", + "url": "https://quasar.dev/", + "language": "JavaScript", + }, + ] + + return jsonify(tools), HTTPStatus.OK diff --git a/lnbits/extensions/hivemind/README.md b/lnbits/extensions/hivemind/README.md new file mode 100644 index 000000000..1e9667ec9 --- /dev/null +++ b/lnbits/extensions/hivemind/README.md @@ -0,0 +1,3 @@ +

Hivemind

+ +Placeholder for a future Bitcoin Hivemind extension. diff --git a/lnbits/extensions/hivemind/__init__.py b/lnbits/extensions/hivemind/__init__.py new file mode 100644 index 000000000..cc2420d83 --- /dev/null +++ b/lnbits/extensions/hivemind/__init__.py @@ -0,0 +1,11 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_hivemind") + +hivemind_ext: Blueprint = Blueprint( + "hivemind", __name__, static_folder="static", template_folder="templates" +) + + +from .views import * # noqa diff --git a/lnbits/extensions/hivemind/config.json b/lnbits/extensions/hivemind/config.json new file mode 100644 index 000000000..a5469b15f --- /dev/null +++ b/lnbits/extensions/hivemind/config.json @@ -0,0 +1,6 @@ +{ + "name": "Hivemind", + "short_description": "Make cheap talk expensive!", + "icon": "batch_prediction", + "contributors": ["fiatjaf"] +} diff --git a/lnbits/extensions/hivemind/migrations.py b/lnbits/extensions/hivemind/migrations.py new file mode 100644 index 000000000..775a94548 --- /dev/null +++ b/lnbits/extensions/hivemind/migrations.py @@ -0,0 +1,10 @@ +# async def m001_initial(db): +# await db.execute( +# f""" +# CREATE TABLE hivemind.hivemind ( +# id TEXT PRIMARY KEY, +# wallet TEXT NOT NULL, +# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} +# ); +# """ +# ) diff --git a/lnbits/extensions/hivemind/models.py b/lnbits/extensions/hivemind/models.py new file mode 100644 index 000000000..be5232339 --- /dev/null +++ b/lnbits/extensions/hivemind/models.py @@ -0,0 +1,11 @@ +# from sqlite3 import Row +# from typing import NamedTuple + + +# class Example(NamedTuple): +# id: str +# wallet: str +# +# @classmethod +# def from_row(cls, row: Row) -> "Example": +# return cls(**dict(row)) diff --git a/lnbits/extensions/hivemind/templates/hivemind/index.html b/lnbits/extensions/hivemind/templates/hivemind/index.html new file mode 100644 index 000000000..40a320f0b --- /dev/null +++ b/lnbits/extensions/hivemind/templates/hivemind/index.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + + +
+ This extension is just a placeholder for now. +
+

+ Hivemind is a Bitcoin sidechain + project for a peer-to-peer oracle protocol that absorbs accurate data into + a blockchain so that Bitcoin users can speculate in prediction markets. +

+

+ These markets have the potential to revolutionize the emergence of + diffusion of knowledge in society and fix all sorts of problems in the + world. +

+

+ This extension will become fully operative when the + BIP300 soft-fork gets activated and + Bitcoin Hivemind is launched. +

+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/hivemind/views.py b/lnbits/extensions/hivemind/views.py new file mode 100644 index 000000000..21c4c2878 --- /dev/null +++ b/lnbits/extensions/hivemind/views.py @@ -0,0 +1,12 @@ +from quart import g, render_template + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import hivemind_ext + + +@hivemind_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("hivemind/index.html", user=g.user) diff --git a/lnbits/extensions/jukebox/README.md b/lnbits/extensions/jukebox/README.md new file mode 100644 index 000000000..c761db448 --- /dev/null +++ b/lnbits/extensions/jukebox/README.md @@ -0,0 +1,36 @@ +# Jukebox + +## An actual Jukebox where users pay sats to play their favourite music from your playlists + +**Note:** To use this extension you need a Premium Spotify subscription. + +## Usage + +1. Click on "ADD SPOTIFY JUKEBOX"\ + ![add jukebox](https://i.imgur.com/NdVoKXd.png) +2. Follow the steps required on the form\ + + - give your jukebox a name + - select a wallet to receive payment + - define the price a user must pay to select a song\ + ![pick wallet price](https://i.imgur.com/4bJ8mb9.png) + - follow the steps to get your Spotify App and get the client ID and secret key\ + ![spotify keys](https://i.imgur.com/w2EzFtB.png) + - paste the codes in the form\ + ![api keys](https://i.imgur.com/6b9xauo.png) + - copy the _Redirect URL_ presented on the form\ + ![redirect url](https://i.imgur.com/GMzl0lG.png) + - on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt + ![spotify app setting](https://i.imgur.com/vb0x4Tl.png) + - back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open + - choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...) + - and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\ + ![select playlists](https://i.imgur.com/g4dbtED.png) + +3. After Jukebox is created, click the icon to open the dialog with the shareable QR, open the Jukebox page, etc...\ + ![shareable jukebox](https://i.imgur.com/EAh9PI0.png) +4. The users will see the Jukebox page and choose a song from the selected playlist\ + ![select song](https://i.imgur.com/YYjeQAs.png) +5. After selecting a song they'd like to hear next a dialog will show presenting the music\ + ![play for sats](https://i.imgur.com/eEHl3o8.png) +6. After payment, the song will automatically start playing on the device selected or enter the queue if some other music is already playing diff --git a/lnbits/extensions/jukebox/__init__.py b/lnbits/extensions/jukebox/__init__.py new file mode 100644 index 000000000..076ae4d9d --- /dev/null +++ b/lnbits/extensions/jukebox/__init__.py @@ -0,0 +1,17 @@ +from quart import Blueprint + +from lnbits.db import Database + +db = Database("ext_jukebox") + +jukebox_ext: Blueprint = Blueprint( + "jukebox", __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 + +jukebox_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/jukebox/config.json b/lnbits/extensions/jukebox/config.json new file mode 100644 index 000000000..91134bc28 --- /dev/null +++ b/lnbits/extensions/jukebox/config.json @@ -0,0 +1,6 @@ +{ + "name": "SpotifyJukebox", + "short_description": "Spotify jukebox middleware", + "icon": "radio", + "contributors": ["benarc"] +} diff --git a/lnbits/extensions/jukebox/crud.py b/lnbits/extensions/jukebox/crud.py new file mode 100644 index 000000000..4e3ba2f15 --- /dev/null +++ b/lnbits/extensions/jukebox/crud.py @@ -0,0 +1,122 @@ +from typing import List, Optional + +from . import db +from .models import Jukebox, JukeboxPayment +from lnbits.helpers import urlsafe_short_hash + + +async def create_jukebox( + inkey: str, + user: str, + wallet: str, + title: str, + price: int, + sp_user: str, + sp_secret: str, + sp_access_token: Optional[str] = "", + sp_refresh_token: Optional[str] = "", + sp_device: Optional[str] = "", + sp_playlists: Optional[str] = "", +) -> Jukebox: + juke_id = urlsafe_short_hash() + result = await db.execute( + """ + INSERT INTO jukebox.jukebox (id, user, title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + juke_id, + user, + title, + wallet, + sp_user, + sp_secret, + sp_access_token, + sp_refresh_token, + sp_device, + sp_playlists, + int(price), + 0, + ), + ) + jukebox = await get_jukebox(juke_id) + assert jukebox, "Newly created Jukebox couldn't be retrieved" + return jukebox + + +async def update_jukebox(juke_id: str, **kwargs) -> Optional[Jukebox]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (*kwargs.values(), juke_id) + ) + row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,)) + return Jukebox(**row) if row else None + + +async def get_jukebox(juke_id: str) -> Optional[Jukebox]: + row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,)) + return Jukebox(**row) if row else None + + +async def get_jukebox_by_user(user: str) -> Optional[Jukebox]: + row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE sp_user = ?", (user,)) + return Jukebox(**row) if row else None + + +async def get_jukeboxs(user: str) -> List[Jukebox]: + rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,)) + for row in rows: + if row.sp_playlists == "": + await delete_jukebox(row.id) + rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,)) + return [Jukebox.from_row(row) for row in rows] + + +async def delete_jukebox(juke_id: str): + await db.execute( + """ + DELETE FROM jukebox.jukebox WHERE id = ? + """, + (juke_id), + ) + + +#####################################PAYMENTS + + +async def create_jukebox_payment( + song_id: str, payment_hash: str, juke_id: str +) -> JukeboxPayment: + result = await db.execute( + """ + INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid) + VALUES (?, ?, ?, ?) + """, + ( + payment_hash, + juke_id, + song_id, + False, + ), + ) + jukebox_payment = await get_jukebox_payment(payment_hash) + assert jukebox_payment, "Newly created Jukebox Payment couldn't be retrieved" + return jukebox_payment + + +async def update_jukebox_payment( + payment_hash: str, **kwargs +) -> Optional[JukeboxPayment]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE jukebox.jukebox_payment SET {q} WHERE payment_hash = ?", + (*kwargs.values(), payment_hash), + ) + return await get_jukebox_payment(payment_hash) + + +async def get_jukebox_payment(payment_hash: str) -> Optional[JukeboxPayment]: + row = await db.fetchone( + "SELECT * FROM jukebox.jukebox_payment WHERE payment_hash = ?", (payment_hash,) + ) + return JukeboxPayment(**row) if row else None diff --git a/lnbits/extensions/jukebox/migrations.py b/lnbits/extensions/jukebox/migrations.py new file mode 100644 index 000000000..a0a3bd285 --- /dev/null +++ b/lnbits/extensions/jukebox/migrations.py @@ -0,0 +1,39 @@ +async def m001_initial(db): + """ + Initial jukebox table. + """ + await db.execute( + """ + CREATE TABLE jukebox.jukebox ( + id TEXT PRIMARY KEY, + "user" TEXT, + title TEXT, + wallet TEXT, + inkey TEXT, + sp_user TEXT NOT NULL, + sp_secret TEXT NOT NULL, + sp_access_token TEXT, + sp_refresh_token TEXT, + sp_device TEXT, + sp_playlists TEXT, + price INTEGER, + profit INTEGER + ); + """ + ) + + +async def m002_initial(db): + """ + Initial jukebox_payment table. + """ + await db.execute( + """ + CREATE TABLE jukebox.jukebox_payment ( + payment_hash TEXT PRIMARY KEY, + juke_id TEXT, + song_id TEXT, + paid BOOL + ); + """ + ) diff --git a/lnbits/extensions/jukebox/models.py b/lnbits/extensions/jukebox/models.py new file mode 100644 index 000000000..f09f76555 --- /dev/null +++ b/lnbits/extensions/jukebox/models.py @@ -0,0 +1,33 @@ +from sqlite3 import Row +from pydantic import BaseModel + + +class Jukebox(BaseModel): + id: str + user: str + title: str + wallet: str + inkey: str + sp_user: str + sp_secret: str + sp_access_token: str + sp_refresh_token: str + sp_device: str + sp_playlists: str + price: int + profit: int + + @classmethod + def from_row(cls, row: Row) -> "Jukebox": + return cls(**dict(row)) + + +class JukeboxPayment(BaseModel): + payment_hash: str + juke_id: str + song_id: str + paid: bool + + @classmethod + def from_row(cls, row: Row) -> "JukeboxPayment": + return cls(**dict(row)) diff --git a/lnbits/extensions/jukebox/static/js/index.js b/lnbits/extensions/jukebox/static/js/index.js new file mode 100644 index 000000000..fc382d711 --- /dev/null +++ b/lnbits/extensions/jukebox/static/js/index.js @@ -0,0 +1,420 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +var mapJukebox = obj => { + obj._data = _.clone(obj) + obj.sp_id = obj.id + obj.device = obj.sp_device.split('-')[0] + playlists = obj.sp_playlists.split(',') + var i + playlistsar = [] + for (i = 0; i < playlists.length; i++) { + playlistsar.push(playlists[i].split('-')[0]) + } + obj.playlist = playlistsar.join() + return obj +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + JukeboxTable: { + columns: [ + { + name: 'title', + align: 'left', + label: 'Title', + field: 'title' + }, + { + name: 'device', + align: 'left', + label: 'Device', + field: 'device' + }, + { + name: 'playlist', + align: 'left', + label: 'Playlist', + field: 'playlist' + }, + { + name: 'price', + align: 'left', + label: 'Price', + field: 'price' + } + ], + pagination: { + rowsPerPage: 10 + } + }, + isPwd: true, + tokenFetched: true, + devices: [], + filter: '', + jukebox: {}, + playlists: [], + JukeboxLinks: [], + step: 1, + locationcbPath: '', + locationcb: '', + jukeboxDialog: { + show: false, + data: {} + }, + spotifyDialog: false, + qrCodeDialog: { + show: false, + data: null + } + } + }, + computed: {}, + methods: { + openQrCodeDialog: function (linkId) { + var link = _.findWhere(this.JukeboxLinks, {id: linkId}) + + this.qrCodeDialog.data = _.clone(link) + console.log(this.qrCodeDialog.data) + this.qrCodeDialog.data.url = + window.location.protocol + '//' + window.location.host + this.qrCodeDialog.show = true + }, + getJukeboxes() { + self = this + LNbits.api + .request( + 'GET', + '/jukebox/api/v1/jukebox', + self.g.user.wallets[0].adminkey + ) + .then(function (response) { + self.JukeboxLinks = response.data.map(mapJukebox) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + deleteJukebox(juke_id) { + self = this + LNbits.utils + .confirmDialog('Are you sure you want to delete this Jukebox?') + .onOk(function () { + LNbits.api + .request( + 'DELETE', + '/jukebox/api/v1/jukebox/' + juke_id, + self.g.user.wallets[0].adminkey + ) + .then(function (response) { + self.JukeboxLinks = _.reject(self.JukeboxLinks, function (obj) { + return obj.id === juke_id + }) + }) + + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + }, + updateJukebox: function (linkId) { + self = this + var link = _.findWhere(self.JukeboxLinks, {id: linkId}) + self.jukeboxDialog.data = _.clone(link._data) + console.log(this.jukeboxDialog.data.sp_access_token) + + self.refreshDevices() + self.refreshPlaylists() + + self.step = 4 + self.jukeboxDialog.data.sp_device = [] + self.jukeboxDialog.data.sp_playlists = [] + self.jukeboxDialog.data.sp_id = self.jukeboxDialog.data.id + self.jukeboxDialog.data.price = String(self.jukeboxDialog.data.price) + self.jukeboxDialog.show = true + }, + closeFormDialog() { + this.jukeboxDialog.data = {} + this.jukeboxDialog.show = false + this.step = 1 + }, + submitSpotifyKeys() { + self = this + self.jukeboxDialog.data.user = self.g.user.id + + LNbits.api + .request( + 'POST', + '/jukebox/api/v1/jukebox/', + self.g.user.wallets[0].adminkey, + self.jukeboxDialog.data + ) + .then(response => { + if (response.data) { + self.jukeboxDialog.data.sp_id = response.data.id + self.step = 3 + } + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + authAccess() { + self = this + self.requestAuthorization() + self.getSpotifyTokens() + self.$q.notify({ + spinner: true, + message: 'Processing', + timeout: 10000 + }) + }, + getSpotifyTokens() { + self = this + var counter = 0 + var timerId = setInterval(function () { + counter++ + if (!self.jukeboxDialog.data.sp_user) { + clearInterval(timerId) + } + LNbits.api + .request( + 'GET', + '/jukebox/api/v1/jukebox/' + self.jukeboxDialog.data.sp_id, + self.g.user.wallets[0].adminkey + ) + .then(response => { + if (response.data.sp_access_token) { + self.fetchAccessToken(response.data.sp_access_token) + if (self.jukeboxDialog.data.sp_access_token) { + self.refreshPlaylists() + self.refreshDevices() + console.log('this.devices') + console.log(self.devices) + console.log('this.devices') + setTimeout(function () { + if (self.devices.length < 1 || self.playlists.length < 1) { + self.$q.notify({ + spinner: true, + color: 'red', + message: + 'Error! Make sure Spotify is open on the device you wish to use, has playlists, and is playing something', + timeout: 10000 + }) + LNbits.api + .request( + 'DELETE', + '/jukebox/api/v1/jukebox/' + response.data.id, + self.g.user.wallets[0].adminkey + ) + .then(function (response) { + self.getJukeboxes() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + clearInterval(timerId) + self.closeFormDialog() + } else { + self.step = 4 + clearInterval(timerId) + } + }, 2000) + } + } + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, 3000) + }, + requestAuthorization() { + self = this + var url = 'https://accounts.spotify.com/authorize' + url += '?client_id=' + self.jukeboxDialog.data.sp_user + url += '&response_type=code' + url += + '&redirect_uri=' + + encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id) + url += '&show_dialog=true' + url += + '&scope=user-read-private user-read-email user-modify-playback-state user-read-playback-position user-library-read streaming user-read-playback-state user-read-recently-played playlist-read-private' + + window.open(url) + }, + openNewDialog() { + this.jukeboxDialog.show = true + this.jukeboxDialog.data = {} + }, + createJukebox() { + self = this + self.jukeboxDialog.data.sp_playlists = self.jukeboxDialog.data.sp_playlists.join() + self.updateDB() + self.jukeboxDialog.show = false + self.getJukeboxes() + }, + updateDB() { + self = this + console.log(self.jukeboxDialog.data) + LNbits.api + .request( + 'PUT', + '/jukebox/api/v1/jukebox/' + this.jukeboxDialog.data.sp_id, + self.g.user.wallets[0].adminkey, + self.jukeboxDialog.data + ) + .then(function (response) { + console.log(response.data) + if ( + self.jukeboxDialog.data.sp_playlists && + self.jukeboxDialog.data.sp_devices + ) { + self.getJukeboxes() + // self.JukeboxLinks.push(mapJukebox(response.data)) + } + }) + }, + playlistApi(method, url, body) { + self = this + let xhr = new XMLHttpRequest() + xhr.open(method, url, true) + xhr.setRequestHeader('Content-Type', 'application/json') + xhr.setRequestHeader( + 'Authorization', + 'Bearer ' + this.jukeboxDialog.data.sp_access_token + ) + xhr.send(body) + xhr.onload = function () { + if (xhr.status == 401) { + self.refreshAccessToken() + self.playlistApi( + 'GET', + 'https://api.spotify.com/v1/me/playlists', + null + ) + } + let responseObj = JSON.parse(xhr.response) + self.jukeboxDialog.data.playlists = null + self.playlists = [] + self.jukeboxDialog.data.playlists = [] + var i + for (i = 0; i < responseObj.items.length; i++) { + self.playlists.push( + responseObj.items[i].name + '-' + responseObj.items[i].id + ) + } + console.log(self.playlists) + } + }, + refreshPlaylists() { + self = this + self.playlistApi('GET', 'https://api.spotify.com/v1/me/playlists', null) + }, + deviceApi(method, url, body) { + self = this + let xhr = new XMLHttpRequest() + xhr.open(method, url, true) + xhr.setRequestHeader('Content-Type', 'application/json') + xhr.setRequestHeader( + 'Authorization', + 'Bearer ' + this.jukeboxDialog.data.sp_access_token + ) + xhr.send(body) + xhr.onload = function () { + if (xhr.status == 401) { + self.refreshAccessToken() + self.deviceApi( + 'GET', + 'https://api.spotify.com/v1/me/player/devices', + null + ) + } + let responseObj = JSON.parse(xhr.response) + self.jukeboxDialog.data.devices = [] + + self.devices = [] + var i + for (i = 0; i < responseObj.devices.length; i++) { + self.devices.push( + responseObj.devices[i].name + '-' + responseObj.devices[i].id + ) + } + } + }, + refreshDevices() { + self = this + self.deviceApi( + 'GET', + 'https://api.spotify.com/v1/me/player/devices', + null + ) + }, + fetchAccessToken(code) { + self = this + let body = 'grant_type=authorization_code' + body += '&code=' + code + body += + '&redirect_uri=' + + encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id) + + self.callAuthorizationApi(body) + }, + refreshAccessToken() { + self = this + let body = 'grant_type=refresh_token' + body += '&refresh_token=' + self.jukeboxDialog.data.sp_refresh_token + body += '&client_id=' + self.jukeboxDialog.data.sp_user + self.callAuthorizationApi(body) + }, + callAuthorizationApi(body) { + self = this + console.log( + btoa( + self.jukeboxDialog.data.sp_user + + ':' + + self.jukeboxDialog.data.sp_secret + ) + ) + let xhr = new XMLHttpRequest() + xhr.open('POST', 'https://accounts.spotify.com/api/token', true) + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') + xhr.setRequestHeader( + 'Authorization', + 'Basic ' + + btoa( + self.jukeboxDialog.data.sp_user + + ':' + + self.jukeboxDialog.data.sp_secret + ) + ) + xhr.send(body) + xhr.onload = function () { + let responseObj = JSON.parse(xhr.response) + if (responseObj.access_token) { + self.jukeboxDialog.data.sp_access_token = responseObj.access_token + self.jukeboxDialog.data.sp_refresh_token = responseObj.refresh_token + self.updateDB() + } + } + } + }, + created() { + console.log(this.g.user.wallets[0]) + var getJukeboxes = this.getJukeboxes + getJukeboxes() + this.selectedWallet = this.g.user.wallets[0] + this.locationcbPath = String( + [ + window.location.protocol, + '//', + window.location.host, + '/jukebox/api/v1/jukebox/spotify/cb/' + ].join('') + ) + this.locationcb = this.locationcbPath + } +}) diff --git a/lnbits/extensions/jukebox/static/js/jukebox.js b/lnbits/extensions/jukebox/static/js/jukebox.js new file mode 100644 index 000000000..ddbb27646 --- /dev/null +++ b/lnbits/extensions/jukebox/static/js/jukebox.js @@ -0,0 +1,14 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return {} + }, + computed: {}, + methods: {}, + created() {} +}) diff --git a/lnbits/extensions/jukebox/static/spotapi.gif b/lnbits/extensions/jukebox/static/spotapi.gif new file mode 100644 index 000000000..023efc9a9 Binary files /dev/null and b/lnbits/extensions/jukebox/static/spotapi.gif differ diff --git a/lnbits/extensions/jukebox/static/spotapi1.gif b/lnbits/extensions/jukebox/static/spotapi1.gif new file mode 100644 index 000000000..478032c56 Binary files /dev/null and b/lnbits/extensions/jukebox/static/spotapi1.gif differ diff --git a/lnbits/extensions/jukebox/tasks.py b/lnbits/extensions/jukebox/tasks.py new file mode 100644 index 000000000..65fca93dc --- /dev/null +++ b/lnbits/extensions/jukebox/tasks.py @@ -0,0 +1,28 @@ +import json +import trio # type: ignore + +from lnbits.core.models import Payment +from lnbits.core.crud import create_payment +from lnbits.core import db as core_db +from lnbits.tasks import register_invoice_listener, internal_invoice_paid +from lnbits.helpers import urlsafe_short_hash + +from .crud import get_jukebox, update_jukebox_payment + + +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 "jukebox" != payment.extra.get("tag"): + # not a jukebox invoice + return + await update_jukebox_payment(payment.payment_hash, paid=True) diff --git a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html new file mode 100644 index 000000000..f5a913130 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html @@ -0,0 +1,125 @@ + + To use this extension you need a Spotify client ID and client secret. You get + these by creating an app in the Spotify developers dashboard + here + +

Select the playlists you want people to be able to pay for, share + the frontend page, profit :)

+ Made by, + benarc. + Inspired by, + pirosb3. +
+ + + + + + GET /jukebox/api/v1/jukebox +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<jukebox_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/jukebox -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /jukebox/api/v1/jukebox/<juke_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ <jukebox_object> +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/jukebox/<juke_id> -H + "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
+ + + + POST/PUT + /jukebox/api/v1/jukebox/ +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ <jukbox_object> +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/jukebox/ -d '{"user": + <string, user_id>, "title": <string>, + "wallet":<string>, "sp_user": <string, + spotify_user_account>, "sp_secret": <string, + spotify_user_secret>, "sp_access_token": <string, + not_required>, "sp_refresh_token": <string, not_required>, + "sp_device": <string, spotify_user_secret>, "sp_playlists": + <string, not_required>, "price": <integer, not_required>}' + -H "Content-type: application/json" -H "X-Api-Key: + {{g.user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /jukebox/api/v1/jukebox/<juke_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ <jukebox_object> +
Curl example
+ curl -X DELETE {{ request.url_root }}api/v1/jukebox/<juke_id> + -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
diff --git a/lnbits/extensions/jukebox/templates/jukebox/error.html b/lnbits/extensions/jukebox/templates/jukebox/error.html new file mode 100644 index 000000000..f6f7fd584 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/error.html @@ -0,0 +1,37 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

Jukebox error

+
+ + +
+ Ask the host to turn on the device and launch spotify +
+
+
+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/jukebox/templates/jukebox/index.html b/lnbits/extensions/jukebox/templates/jukebox/index.html new file mode 100644 index 000000000..9b4efbd5c --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/index.html @@ -0,0 +1,368 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + Add Spotify Jukebox + + {% raw %} + + + + + + + {% endraw %} + + +
+ +
+ + +
+ {{SITE_TITLE}} jukebox extension +
+
+ + + {% include "jukebox/_api_docs.html" %} + +
+
+ + + + + + + + + +
+
+ Continue + Continue +
+
+ Cancel +
+
+ +
+
+ + + + To use this extension you need a Spotify client ID and client secret. + You get these by creating an app in the Spotify developers dashboard + here. + + + + + + + +
+
+ Submit keys + Submit keys +
+
+ Cancel +
+
+ +
+
+ + + + In the app go to edit-settings, set the redirect URI to this link +
+ {% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw + %} Click to copy URL + +
+ Settings can be found + here. + +
+
+ Authorise access + Authorise access +
+
+ Cancel +
+
+ +
+
+ + + + +
+
+ Create Jukebox + Create Jukebox +
+
+ Cancel +
+
+
+
+
+
+ + + +
+
Shareable Jukebox QR
+
+ + + +
+ + Copy jukebox link + Open jukebox + Close +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/jukebox/templates/jukebox/jukebox.html b/lnbits/extensions/jukebox/templates/jukebox/jukebox.html new file mode 100644 index 000000000..cb3ab49d8 --- /dev/null +++ b/lnbits/extensions/jukebox/templates/jukebox/jukebox.html @@ -0,0 +1,277 @@ +{% extends "public.html" %} {% block page %} {% raw %} +
+
+ + +

Currently playing

+
+
+ +
+
+ {{ currentPlay.name }}
+ {{ currentPlay.artist }} +
+
+
+
+ + + +

Pick a song

+ + +
+ + + + + + +
+
+ + + + +
+
+ +
+
+ {{ receive.name }}
+ {{ receive.artist }} +
+
+
+
+
+ Play for {% endraw %}{{ price }}{% raw %} sats + +
+
+
+ + + + + +
+ Copy invoice +
+
+
+
+{% endraw %} {% endblock %} {% block scripts %} + + + +{% endblock %} diff --git a/lnbits/extensions/jukebox/views.py b/lnbits/extensions/jukebox/views.py new file mode 100644 index 000000000..f439110a0 --- /dev/null +++ b/lnbits/extensions/jukebox/views.py @@ -0,0 +1,42 @@ +import time +from datetime import datetime +from quart import g, render_template, request, jsonify, websocket +from http import HTTPStatus +import trio +from lnbits.decorators import check_user_exists, validate_uuids +from lnbits.core.models import Payment + +import json +from . import jukebox_ext +from .crud import get_jukebox +from .views_api import api_get_jukebox_device_check + + +@jukebox_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("jukebox/index.html", user=g.user) + + +@jukebox_ext.route("/") +async def connect_to_jukebox(juke_id): + jukebox = await get_jukebox(juke_id) + if not jukebox: + return "error" + deviceCheck = await api_get_jukebox_device_check(juke_id) + devices = json.loads(deviceCheck[0].text) + deviceConnected = False + for device in devices["devices"]: + if device["id"] == jukebox.sp_device.split("-")[1]: + deviceConnected = True + if deviceConnected: + return await render_template( + "jukebox/jukebox.html", + playlists=jukebox.sp_playlists.split(","), + juke_id=juke_id, + price=jukebox.price, + inkey=jukebox.inkey, + ) + else: + return await render_template("jukebox/error.html") diff --git a/lnbits/extensions/jukebox/views_api.py b/lnbits/extensions/jukebox/views_api.py new file mode 100644 index 000000000..1390a019a --- /dev/null +++ b/lnbits/extensions/jukebox/views_api.py @@ -0,0 +1,491 @@ +from quart import g, jsonify, request +from http import HTTPStatus +import base64 +from lnbits.core.crud import get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +import json + +from lnbits.decorators import api_check_wallet_key, api_validate_post_request +import httpx +from . import jukebox_ext +from .crud import ( + create_jukebox, + update_jukebox, + get_jukebox, + get_jukeboxs, + delete_jukebox, + create_jukebox_payment, + get_jukebox_payment, + update_jukebox_payment, +) +from lnbits.core.services import create_invoice, check_invoice_status + + +@jukebox_ext.route("/api/v1/jukebox", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_get_jukeboxs(): + try: + return ( + jsonify( + [{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)] + ), + HTTPStatus.OK, + ) + except: + return "", HTTPStatus.NO_CONTENT + + +##################SPOTIFY AUTH##################### + + +@jukebox_ext.route("/api/v1/jukebox/spotify/cb/", methods=["GET"]) +async def api_check_credentials_callbac(juke_id): + sp_code = "" + sp_access_token = "" + sp_refresh_token = "" + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + if request.args.get("code"): + sp_code = request.args.get("code") + jukebox = await update_jukebox( + juke_id=juke_id, sp_secret=jukebox.sp_secret, sp_access_token=sp_code + ) + if request.args.get("access_token"): + sp_access_token = request.args.get("access_token") + sp_refresh_token = request.args.get("refresh_token") + jukebox = await update_jukebox( + juke_id=juke_id, + sp_secret=jukebox.sp_secret, + sp_access_token=sp_access_token, + sp_refresh_token=sp_refresh_token, + ) + return "

Success!

You can close this window

" + + +@jukebox_ext.route("/api/v1/jukebox/", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_check_credentials_check(juke_id): + jukebox = await get_jukebox(juke_id) + return jsonify(jukebox._asdict()), HTTPStatus.CREATED + + +@jukebox_ext.route("/api/v1/jukebox/", methods=["POST"]) +@jukebox_ext.route("/api/v1/jukebox/", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "user": {"type": "string", "empty": False, "required": True}, + "title": {"type": "string", "empty": False, "required": True}, + "wallet": {"type": "string", "empty": False, "required": True}, + "sp_user": {"type": "string", "empty": False, "required": True}, + "sp_secret": {"type": "string", "required": True}, + "sp_access_token": {"type": "string", "required": False}, + "sp_refresh_token": {"type": "string", "required": False}, + "sp_device": {"type": "string", "required": False}, + "sp_playlists": {"type": "string", "required": False}, + "price": {"type": "string", "required": False}, + } +) +async def api_create_update_jukebox(juke_id=None): + if juke_id: + jukebox = await update_jukebox(juke_id=juke_id, inkey=g.wallet.inkey, **g.data) + else: + jukebox = await create_jukebox(inkey=g.wallet.inkey, **g.data) + + return jsonify(jukebox._asdict()), HTTPStatus.CREATED + + +@jukebox_ext.route("/api/v1/jukebox/", methods=["DELETE"]) +@api_check_wallet_key("admin") +async def api_delete_item(juke_id): + await delete_jukebox(juke_id) + try: + return ( + jsonify( + [{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)] + ), + HTTPStatus.OK, + ) + except: + return "", HTTPStatus.NO_CONTENT + + +################JUKEBOX ENDPOINTS################## + +######GET ACCESS TOKEN###### + + +@jukebox_ext.route( + "/api/v1/jukebox/jb/playlist//", methods=["GET"] +) +async def api_get_jukebox_song(juke_id, sp_playlist, retry=False): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + tracks = [] + async with httpx.AsyncClient() as client: + try: + r = await client.get( + "https://api.spotify.com/v1/playlists/" + sp_playlist + "/tracks", + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + if "items" not in r.json(): + if r.status_code == 401: + token = await api_get_token(juke_id) + if token == False: + return False + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return await api_get_jukebox_song( + juke_id, sp_playlist, retry=True + ) + return r, HTTPStatus.OK + for item in r.json()["items"]: + tracks.append( + { + "id": item["track"]["id"], + "name": item["track"]["name"], + "album": item["track"]["album"]["name"], + "artist": item["track"]["artists"][0]["name"], + "image": item["track"]["album"]["images"][0]["url"], + } + ) + except AssertionError: + something = None + return jsonify([track for track in tracks]) + + +async def api_get_token(juke_id): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + + async with httpx.AsyncClient() as client: + try: + r = await client.post( + "https://accounts.spotify.com/api/token", + timeout=40, + params={ + "grant_type": "refresh_token", + "refresh_token": jukebox.sp_refresh_token, + "client_id": jukebox.sp_user, + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + + base64.b64encode( + str(jukebox.sp_user + ":" + jukebox.sp_secret).encode("ascii") + ).decode("ascii"), + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + if "access_token" not in r.json(): + return False + else: + await update_jukebox( + juke_id=juke_id, sp_access_token=r.json()["access_token"] + ) + except AssertionError: + something = None + return True + + +######CHECK DEVICE + + +@jukebox_ext.route("/api/v1/jukebox/jb/", methods=["GET"]) +async def api_get_jukebox_device_check(juke_id, retry=False): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + async with httpx.AsyncClient() as client: + rDevice = await client.get( + "https://api.spotify.com/v1/me/player/devices", + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + + if rDevice.status_code == 204 or rDevice.status_code == 200: + return ( + rDevice, + HTTPStatus.OK, + ) + elif rDevice.status_code == 401 or rDevice.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + return ( + jsonify({"error": "No device connected"}), + HTTPStatus.FORBIDDEN, + ) + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return api_get_jukebox_device_check(juke_id, retry=True) + else: + return ( + jsonify({"error": "No device connected"}), + HTTPStatus.FORBIDDEN, + ) + + +######GET INVOICE STUFF + + +@jukebox_ext.route("/api/v1/jukebox/jb/invoice//", methods=["GET"]) +async def api_get_jukebox_invoice(juke_id, song_id): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + try: + deviceCheck = await api_get_jukebox_device_check(juke_id) + devices = json.loads(deviceCheck[0].text) + deviceConnected = False + for device in devices["devices"]: + if device["id"] == jukebox.sp_device.split("-")[1]: + deviceConnected = True + if not deviceConnected: + return ( + jsonify({"error": "No device connected"}), + HTTPStatus.NOT_FOUND, + ) + except: + return ( + jsonify({"error": "No device connected"}), + HTTPStatus.NOT_FOUND, + ) + + invoice = await create_invoice( + wallet_id=jukebox.wallet, + amount=jukebox.price, + memo=jukebox.title, + extra={"tag": "jukebox"}, + ) + + jukebox_payment = await create_jukebox_payment(song_id, invoice[0], juke_id) + + return jsonify(invoice, jukebox_payment) + + +@jukebox_ext.route( + "/api/v1/jukebox/jb/checkinvoice//", methods=["GET"] +) +async def api_get_jukebox_invoice_check(pay_hash, juke_id): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + try: + status = await check_invoice_status(jukebox.wallet, pay_hash) + is_paid = not status.pending + except Exception as exc: + return jsonify({"paid": False}), HTTPStatus.OK + if is_paid: + wallet = await get_wallet(jukebox.wallet) + payment = await wallet.get_payment(pay_hash) + await payment.set_pending(False) + await update_jukebox_payment(pay_hash, paid=True) + return jsonify({"paid": True}), HTTPStatus.OK + return jsonify({"paid": False}), HTTPStatus.OK + + +@jukebox_ext.route( + "/api/v1/jukebox/jb/invoicep///", methods=["GET"] +) +async def api_get_jukebox_invoice_paid(song_id, juke_id, pay_hash, retry=False): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + await api_get_jukebox_invoice_check(pay_hash, juke_id) + jukebox_payment = await get_jukebox_payment(pay_hash) + if jukebox_payment.paid: + async with httpx.AsyncClient() as client: + r = await client.get( + "https://api.spotify.com/v1/me/player/currently-playing?market=ES", + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + rDevice = await client.get( + "https://api.spotify.com/v1/me/player", + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + isPlaying = False + if rDevice.status_code == 200: + isPlaying = rDevice.json()["is_playing"] + + if r.status_code == 204 or isPlaying == False: + async with httpx.AsyncClient() as client: + uri = ["spotify:track:" + song_id] + r = await client.put( + "https://api.spotify.com/v1/me/player/play?device_id=" + + jukebox.sp_device.split("-")[1], + json={"uris": uri}, + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + if r.status_code == 204: + return jsonify(jukebox_payment), HTTPStatus.OK + elif r.status_code == 401 or r.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.FORBIDDEN, + ) + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return api_get_jukebox_invoice_paid( + song_id, juke_id, pay_hash, retry=True + ) + else: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.FORBIDDEN, + ) + elif r.status_code == 200: + async with httpx.AsyncClient() as client: + r = await client.post( + "https://api.spotify.com/v1/me/player/queue?uri=spotify%3Atrack%3A" + + song_id + + "&device_id=" + + jukebox.sp_device.split("-")[1], + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + if r.status_code == 204: + return jsonify(jukebox_payment), HTTPStatus.OK + + elif r.status_code == 401 or r.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.OK, + ) + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return await api_get_jukebox_invoice_paid( + song_id, juke_id, pay_hash + ) + else: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.OK, + ) + elif r.status_code == 401 or r.status_code == 403: + token = await api_get_token(juke_id) + if token == False: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.OK, + ) + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return await api_get_jukebox_invoice_paid( + song_id, juke_id, pay_hash + ) + return jsonify({"error": "Invoice not paid"}), HTTPStatus.OK + + +############################GET TRACKS + + +@jukebox_ext.route("/api/v1/jukebox/jb/currently/", methods=["GET"]) +async def api_get_jukebox_currently(juke_id, retry=False): + try: + jukebox = await get_jukebox(juke_id) + except: + return ( + jsonify({"error": "No Jukebox"}), + HTTPStatus.FORBIDDEN, + ) + async with httpx.AsyncClient() as client: + try: + r = await client.get( + "https://api.spotify.com/v1/me/player/currently-playing?market=ES", + timeout=40, + headers={"Authorization": "Bearer " + jukebox.sp_access_token}, + ) + if r.status_code == 204: + return jsonify({"error": "Nothing"}), HTTPStatus.OK + elif r.status_code == 200: + try: + response = r.json() + + track = { + "id": response["item"]["id"], + "name": response["item"]["name"], + "album": response["item"]["album"]["name"], + "artist": response["item"]["artists"][0]["name"], + "image": response["item"]["album"]["images"][0]["url"], + } + return jsonify(track), HTTPStatus.OK + except: + return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND + + elif r.status_code == 401: + token = await api_get_token(juke_id) + if token == False: + return ( + jsonify({"error": "Invoice not paid"}), + HTTPStatus.FORBIDDEN, + ) + elif retry: + return ( + jsonify({"error": "Failed to get auth"}), + HTTPStatus.FORBIDDEN, + ) + else: + return await api_get_jukebox_currently(juke_id, retry=True) + else: + return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND + except AssertionError: + return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND diff --git a/lnbits/extensions/livestream/README.md b/lnbits/extensions/livestream/README.md new file mode 100644 index 000000000..4e88e7bc7 --- /dev/null +++ b/lnbits/extensions/livestream/README.md @@ -0,0 +1,45 @@ +# DJ Livestream + +## Help DJ's and music producers conduct music livestreams + +LNBits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet. + +When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional). + +The revenue will be sent to a wallet created specifically for that producer, with optional revenue splitting between the DJ and the producer. + +[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) + +[![video tutorial livestream](http://img.youtube.com/vi/zDrSWShKz7k/0.jpg)](https://youtu.be/zDrSWShKz7k 'video tutorial offline shop') + +## Usage + +1. Start by adding a track\ + ![add new track](https://i.imgur.com/Cu0eGrW.jpg) + - set the producer, or choose an existing one + - set the track name + - define a minimum price where a user can download the track + - set the download URL, where user will be redirected if he tips the livestream and the tip is equal or above the set price\ + ![track settings](https://i.imgur.com/HTJYwcW.jpg) +2. Adjust the percentage of the pay you want to take from the user's tips. 10%, the default, means that the DJ will keep 10% of all the tips sent by users. The other 90% will go to an auto generated producer wallet\ + ![adjust percentage](https://i.imgur.com/9weHKAB.jpg) +3. For every different producer added, when adding tracks, a wallet is generated for them\ + ![producer wallet](https://i.imgur.com/YFIZ7Tm.jpg) +4. On the bottom of the LNBits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed +5. After all tracks and producers are added, you can start "playing" songs\ + ![play tracks](https://i.imgur.com/7ytiBkq.jpg) +6. You'll see the current track playing and a green icon indicating active track also\ + ![active track](https://i.imgur.com/W1vBz54.jpg) +7. When a user scans the QR code, and sends a tip, you'll receive 10%, in the example case, in your wallet and the producer's wallet will get the rest. For example someone tips 100 sats, you'll get 10 sats and the producer will get 90 sats + - producer's wallet receiving 18 sats from 20 sats tips\ + ![producer wallet](https://i.imgur.com/OM9LawA.jpg) + +## Use cases + +You can print the QR code and display it on a live gig, a street performance, etc... OR you can use the QR as an overlay in an online stream of you playing music, doing a DJ set, making a podcast. + +You can use the extension's API to trigger updates for the current track, update fees, add tracks... + +## Sponsored by + +[![](https://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/) diff --git a/lnbits/extensions/livestream/__init__.py b/lnbits/extensions/livestream/__init__.py new file mode 100644 index 000000000..d8f61fe0a --- /dev/null +++ b/lnbits/extensions/livestream/__init__.py @@ -0,0 +1,19 @@ +from quart import Blueprint + +from lnbits.db import Database + +db = Database("ext_livestream") + +livestream_ext: Blueprint = Blueprint( + "livestream", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa +from .lnurl import * # noqa +from .tasks import register_listeners + +from lnbits.tasks import record_async + +livestream_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/livestream/config.json b/lnbits/extensions/livestream/config.json new file mode 100644 index 000000000..12ba6b797 --- /dev/null +++ b/lnbits/extensions/livestream/config.json @@ -0,0 +1,10 @@ +{ + "name": "DJ Livestream", + "short_description": "Sell tracks and split revenue (lnurl-pay)", + "icon": "speaker", + "contributors": [ + "fiatjaf", + "cryptograffiti" + ], + "hidden": false +} diff --git a/lnbits/extensions/livestream/crud.py b/lnbits/extensions/livestream/crud.py new file mode 100644 index 000000000..47854dbdc --- /dev/null +++ b/lnbits/extensions/livestream/crud.py @@ -0,0 +1,199 @@ +from typing import List, Optional + +from lnbits.core.crud import create_account, create_wallet +from lnbits.db import SQLITE +from . import db +from .models import Livestream, Track, Producer + + +async def create_livestream(*, wallet_id: str) -> int: + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await (method)( + f""" + INSERT INTO livestream.livestreams (wallet) + VALUES (?) + {returning} + """, + (wallet_id,), + ) + + if db.type == SQLITE: + return result._result_proxy.lastrowid + else: + return result[0] + + +async def get_livestream(ls_id: int) -> Optional[Livestream]: + row = await db.fetchone( + "SELECT * FROM livestream.livestreams WHERE id = ?", (ls_id,) + ) + return Livestream(**dict(row)) if row else None + + +async def get_livestream_by_track(track_id: int) -> Optional[Livestream]: + row = await db.fetchone( + """ + SELECT livestreams.* FROM livestream.livestreams + INNER JOIN tracks ON tracks.livestream = livestreams.id + WHERE tracks.id = ? + """, + (track_id,), + ) + return Livestream(**dict(row)) if row else None + + +async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]: + row = await db.fetchone( + "SELECT * FROM livestream.livestreams WHERE wallet = ?", (wallet,) + ) + + if not row: + # create on the fly + ls_id = await create_livestream(wallet_id=wallet) + return await get_livestream(ls_id) + + return Livestream(**dict(row)) if row else None + + +async def update_current_track(ls_id: int, track_id: Optional[int]): + await db.execute( + "UPDATE livestream.livestreams SET current_track = ? WHERE id = ?", + (track_id, ls_id), + ) + + +async def update_livestream_fee(ls_id: int, fee_pct: int): + await db.execute( + "UPDATE livestream.livestreams SET fee_pct = ? WHERE id = ?", + (fee_pct, ls_id), + ) + + +async def add_track( + livestream: int, + name: str, + download_url: Optional[str], + price_msat: int, + producer: Optional[int], +) -> int: + result = await db.execute( + """ + INSERT INTO livestream.tracks (livestream, name, download_url, price_msat, producer) + VALUES (?, ?, ?, ?, ?) + """, + (livestream, name, download_url, price_msat, producer), + ) + return result._result_proxy.lastrowid + + +async def update_track( + livestream: int, + track_id: int, + name: str, + download_url: Optional[str], + price_msat: int, + producer: int, +) -> int: + result = await db.execute( + """ + UPDATE livestream.tracks SET + name = ?, + download_url = ?, + price_msat = ?, + producer = ? + WHERE livestream = ? AND id = ? + """, + (name, download_url, price_msat, producer, livestream, track_id), + ) + return result._result_proxy.lastrowid + + +async def get_track(track_id: Optional[int]) -> Optional[Track]: + if not track_id: + return None + + row = await db.fetchone( + """ + SELECT id, download_url, price_msat, name, producer + FROM livestream.tracks WHERE id = ? + """, + (track_id,), + ) + return Track(**dict(row)) if row else None + + +async def get_tracks(livestream: int) -> List[Track]: + rows = await db.fetchall( + """ + SELECT id, download_url, price_msat, name, producer + FROM livestream.tracks WHERE livestream = ? + """, + (livestream,), + ) + return [Track(**dict(row)) for row in rows] + + +async def delete_track_from_livestream(livestream: int, track_id: int): + await db.execute( + """ + DELETE FROM livestream.tracks WHERE livestream = ? AND id = ? + """, + (livestream, track_id), + ) + + +async def add_producer(livestream: int, name: str) -> int: + name = name.strip() + + existing = await db.fetchall( + """ + SELECT id FROM livestream.producers + WHERE livestream = ? AND lower(name) = ? + """, + (livestream, name.lower()), + ) + if existing: + return existing[0].id + + user = await create_account() + wallet = await create_wallet(user_id=user.id, wallet_name="livestream: " + name) + + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await method( + f""" + INSERT INTO livestream.producers (livestream, name, "user", wallet) + VALUES (?, ?, ?, ?) + {returning} + """, + (livestream, name, user.id, wallet.id), + ) + if db.type == SQLITE: + return result._result_proxy.lastrowid + else: + return result[0] + + +async def get_producer(producer_id: int) -> Optional[Producer]: + row = await db.fetchone( + """ + SELECT id, "user", wallet, name + FROM livestream.producers WHERE id = ? + """, + (producer_id,), + ) + return Producer(**dict(row)) if row else None + + +async def get_producers(livestream: int) -> List[Producer]: + rows = await db.fetchall( + """ + SELECT id, "user", wallet, name + FROM livestream.producers WHERE livestream = ? + """, + (livestream,), + ) + return [Producer(**dict(row)) for row in rows] diff --git a/lnbits/extensions/livestream/lnurl.py b/lnbits/extensions/livestream/lnurl.py new file mode 100644 index 000000000..3b9e7e316 --- /dev/null +++ b/lnbits/extensions/livestream/lnurl.py @@ -0,0 +1,114 @@ +import hashlib +import math +from quart import jsonify, url_for, request +from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore + +from lnbits.core.services import create_invoice + +from . import livestream_ext +from .crud import get_livestream, get_livestream_by_track, get_track + + +@livestream_ext.route("/lnurl/", methods=["GET"]) +async def lnurl_livestream(ls_id): + ls = await get_livestream(ls_id) + if not ls: + return jsonify({"status": "ERROR", "reason": "Livestream not found."}) + + track = await get_track(ls.current_track) + if not track: + return jsonify({"status": "ERROR", "reason": "This livestream is offline."}) + + resp = LnurlPayResponse( + callback=url_for( + "livestream.lnurl_callback", track_id=track.id, _external=True + ), + min_sendable=track.min_sendable, + max_sendable=track.max_sendable, + metadata=await track.lnurlpay_metadata(), + ) + + params = resp.dict() + params["commentAllowed"] = 300 + + return jsonify(params) + + +@livestream_ext.route("/lnurl/t/", methods=["GET"]) +async def lnurl_track(track_id): + track = await get_track(track_id) + if not track: + return jsonify({"status": "ERROR", "reason": "Track not found."}) + + resp = LnurlPayResponse( + callback=url_for( + "livestream.lnurl_callback", track_id=track.id, _external=True + ), + min_sendable=track.min_sendable, + max_sendable=track.max_sendable, + metadata=await track.lnurlpay_metadata(), + ) + + params = resp.dict() + params["commentAllowed"] = 300 + + return jsonify(params) + + +@livestream_ext.route("/lnurl/cb/", methods=["GET"]) +async def lnurl_callback(track_id): + track = await get_track(track_id) + if not track: + return jsonify({"status": "ERROR", "reason": "Couldn't find track."}) + + amount_received = int(request.args.get("amount") or 0) + + if amount_received < track.min_sendable: + return ( + jsonify( + LnurlErrorResponse( + reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}." + ).dict() + ), + ) + elif track.max_sendable < amount_received: + return ( + jsonify( + LnurlErrorResponse( + reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}." + ).dict() + ), + ) + + comment = request.args.get("comment") + if len(comment or "") > 300: + return jsonify( + LnurlErrorResponse( + reason=f"Got a comment with {len(comment)} characters, but can only accept 300" + ).dict() + ) + + ls = await get_livestream_by_track(track_id) + + payment_hash, payment_request = await create_invoice( + wallet_id=ls.wallet, + amount=int(amount_received / 1000), + memo=await track.fullname(), + description_hash=hashlib.sha256( + (await track.lnurlpay_metadata()).encode("utf-8") + ).digest(), + extra={"tag": "livestream", "track": track.id, "comment": comment}, + ) + + if amount_received < track.price_msat: + success_action = None + else: + success_action = track.success_action(payment_hash) + + resp = LnurlPayActionResponse( + pr=payment_request, + success_action=success_action, + routes=[], + ) + + return jsonify(resp.dict()) diff --git a/lnbits/extensions/livestream/migrations.py b/lnbits/extensions/livestream/migrations.py new file mode 100644 index 000000000..fb664ab16 --- /dev/null +++ b/lnbits/extensions/livestream/migrations.py @@ -0,0 +1,39 @@ +async def m001_initial(db): + """ + Initial livestream tables. + """ + await db.execute( + f""" + CREATE TABLE livestream.livestreams ( + id {db.serial_primary_key}, + wallet TEXT NOT NULL, + fee_pct INTEGER NOT NULL DEFAULT 10, + current_track INTEGER + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE livestream.producers ( + livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id), + id {db.serial_primary_key}, + "user" TEXT NOT NULL, + wallet TEXT NOT NULL, + name TEXT NOT NULL + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE livestream.tracks ( + livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id), + id {db.serial_primary_key}, + download_url TEXT, + price_msat INTEGER NOT NULL DEFAULT 0, + name TEXT, + producer INTEGER REFERENCES {db.references_schema}producers (id) NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/livestream/models.py b/lnbits/extensions/livestream/models.py new file mode 100644 index 000000000..7df1f961a --- /dev/null +++ b/lnbits/extensions/livestream/models.py @@ -0,0 +1,82 @@ +import json +from quart import url_for +from typing import Optional +from lnurl import Lnurl, encode as lnurl_encode # type: ignore +from lnurl.types import LnurlPayMetadata # type: ignore +from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore +from sqlite3 import Row +from pydantic import BaseModel + +class Livestream(BaseModel): + id: int + wallet: str + fee_pct: int + current_track: Optional[int] + + @property + def lnurl(self) -> Lnurl: + url = url_for("livestream.lnurl_livestream", ls_id=self.id, _external=True) + return lnurl_encode(url) + + +class Track(BaseModel): + id: int + download_url: str + price_msat: int + name: str + producer: int + + @property + def min_sendable(self) -> int: + return min(100_000, self.price_msat or 100_000) + + @property + def max_sendable(self) -> int: + return max(50_000_000, self.price_msat * 5) + + @property + def lnurl(self) -> Lnurl: + url = url_for("livestream.lnurl_track", track_id=self.id, _external=True) + return lnurl_encode(url) + + async def fullname(self) -> str: + from .crud import get_producer + + producer = await get_producer(self.producer) + if producer: + producer_name = producer.name + else: + producer_name = "unknown author" + + return f"'{self.name}', from {producer_name}." + + async def lnurlpay_metadata(self) -> LnurlPayMetadata: + description = ( + await self.fullname() + ) + " Like this track? Send some sats in appreciation." + + if self.download_url: + description += f" Send {round(self.price_msat/1000)} sats or more and you can download it." + + return LnurlPayMetadata(json.dumps([["text/plain", description]])) + + def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]: + if not self.download_url: + return None + + return UrlAction( + url=url_for( + "livestream.track_redirect_download", + track_id=self.id, + p=payment_hash, + _external=True, + ), + description=f"Download the track {self.name}!", + ) + + +class Producer(BaseModel): + id: int + user: str + wallet: str + name: str diff --git a/lnbits/extensions/livestream/static/js/index.js b/lnbits/extensions/livestream/static/js/index.js new file mode 100644 index 000000000..c49befce2 --- /dev/null +++ b/lnbits/extensions/livestream/static/js/index.js @@ -0,0 +1,216 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + cancelListener: () => {}, + selectedWallet: null, + nextCurrentTrack: null, + livestream: { + tracks: [], + producers: [] + }, + trackDialog: { + show: false, + data: {} + } + } + }, + computed: { + sortedTracks() { + return this.livestream.tracks.sort((a, b) => a.name - b.name) + }, + tracksMap() { + return Object.fromEntries( + this.livestream.tracks.map(track => [track.id, track]) + ) + }, + producersMap() { + return Object.fromEntries( + this.livestream.producers.map(prod => [prod.id, prod]) + ) + } + }, + methods: { + getTrackLabel(trackId) { + if (!trackId) return + let track = this.tracksMap[trackId] + return `${track.name}, ${this.producersMap[track.producer].name}` + }, + disabledAddTrackButton() { + return ( + !this.trackDialog.data.name || + this.trackDialog.data.name.length === 0 || + !this.trackDialog.data.producer || + this.trackDialog.data.producer.length === 0 + ) + }, + changedWallet(wallet) { + this.selectedWallet = wallet + this.loadLivestream() + this.startPaymentNotifier() + }, + loadLivestream() { + LNbits.api + .request( + 'GET', + '/livestream/api/v1/livestream', + this.selectedWallet.inkey + ) + .then(response => { + this.livestream = response.data + this.nextCurrentTrack = this.livestream.current_track + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + startPaymentNotifier() { + this.cancelListener() + + this.cancelListener = LNbits.events.onInvoicePaid( + this.selectedWallet, + payment => { + let satoshiAmount = Math.round(payment.amount / 1000) + let trackName = ( + this.tracksMap[payment.extra.track] || {name: '[unknown]'} + ).name + + this.$q.notify({ + message: `Someone paid ${satoshiAmount} sat for the track ${trackName}.`, + caption: payment.extra.comment + ? `"${payment.extra.comment}"` + : undefined, + color: 'secondary', + html: true, + timeout: 0, + actions: [{label: 'Dismiss', color: 'white', handler: () => {}}] + }) + } + ) + }, + addTrack() { + let {id, name, producer, price_sat, download_url} = this.trackDialog.data + + const [method, path] = id + ? ['PUT', `/livestream/api/v1/livestream/tracks/${id}`] + : ['POST', '/livestream/api/v1/livestream/tracks'] + + LNbits.api + .request(method, path, this.selectedWallet.inkey, { + download_url: + download_url && download_url.length > 0 ? download_url : undefined, + name, + price_msat: price_sat * 1000 || 0, + producer_name: typeof producer === 'string' ? producer : undefined, + producer_id: typeof producer === 'object' ? producer.id : undefined + }) + .then(response => { + this.$q.notify({ + message: `Track '${this.trackDialog.data.name}' added.`, + timeout: 700 + }) + this.loadLivestream() + this.trackDialog.show = false + this.trackDialog.data = {} + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + openAddTrackDialog() { + this.trackDialog.show = true + this.trackDialog.data = {} + }, + openUpdateDialog(itemId) { + this.trackDialog.show = true + let item = this.livestream.tracks.find(item => item.id === itemId) + this.trackDialog.data = { + ...item, + producer: this.livestream.producers.find( + prod => prod.id === item.producer + ), + price_sat: Math.round(item.price_msat / 1000) + } + }, + deleteTrack(trackId) { + LNbits.utils + .confirmDialog('Are you sure you want to delete this track?') + .onOk(() => { + LNbits.api + .request( + 'DELETE', + '/livestream/api/v1/livestream/tracks/' + trackId, + this.selectedWallet.inkey + ) + .then(response => { + this.$q.notify({ + message: `Track deleted`, + timeout: 700 + }) + this.livestream.tracks.splice( + this.livestream.tracks.findIndex(track => track.id === trackId), + 1 + ) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + }, + updateCurrentTrack(track) { + console.log(this.nextCurrentTrack, this.livestream) + if (this.livestream.current_track === track) { + // if clicking the same, stop it + track = 0 + } + + LNbits.api + .request( + 'PUT', + '/livestream/api/v1/livestream/track/' + track, + this.selectedWallet.inkey + ) + .then(() => { + this.livestream.current_track = track + this.nextCurrentTrack = track + this.$q.notify({ + message: `Current track updated.`, + timeout: 700 + }) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + updateFeePct() { + LNbits.api + .request( + 'PUT', + '/livestream/api/v1/livestream/fee/' + this.livestream.fee_pct, + this.selectedWallet.inkey + ) + .then(() => { + this.$q.notify({ + message: `Percentage updated.`, + timeout: 700 + }) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + producerAdded(added, cb) { + cb(added) + } + }, + created() { + this.selectedWallet = this.g.user.wallets[0] + this.loadLivestream() + this.startPaymentNotifier() + } +}) diff --git a/lnbits/extensions/livestream/tasks.py b/lnbits/extensions/livestream/tasks.py new file mode 100644 index 000000000..52f86d155 --- /dev/null +++ b/lnbits/extensions/livestream/tasks.py @@ -0,0 +1,89 @@ +import json +import trio + +from lnbits.core.models import Payment +from lnbits.core.crud import create_payment +from lnbits.core import db as core_db +from lnbits.tasks import register_invoice_listener, internal_invoice_paid +from lnbits.helpers import urlsafe_short_hash + +from .crud import get_track, get_producer, get_livestream_by_track + + +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 "livestream" != payment.extra.get("tag"): + # not a livestream invoice + return + + track = await get_track(payment.extra.get("track", -1)) + if not track: + print("this should never happen", payment) + return + + if payment.extra.get("shared_with"): + print("payment was shared already", payment) + return + + producer = await get_producer(track.producer) + assert producer, f"track {track.id} is not associated with a producer" + + ls = await get_livestream_by_track(track.id) + assert ls, f"track {track.id} is not associated with a livestream" + + # now we make a special kind of internal transfer + amount = int(payment.amount * (100 - ls.fee_pct) / 100) + + # mark the original payment with two extra keys, "shared_with" and "received" + # (this prevents us from doing this process again and it's informative) + # and reduce it by the amount we're going to send to the producer + await core_db.execute( + """ + UPDATE apipayments + SET extra = ?, amount = ? + WHERE hash = ? + AND checking_id NOT LIKE 'internal_%' + """, + ( + json.dumps( + dict( + **payment.extra, + shared_with=[producer.name, producer.id], + received=payment.amount, + ) + ), + payment.amount - amount, + payment.payment_hash, + ), + ) + + # perform an internal transfer using the same payment_hash to the producer wallet + internal_checking_id = f"internal_{urlsafe_short_hash()}" + await create_payment( + wallet_id=producer.wallet, + checking_id=internal_checking_id, + payment_request="", + payment_hash=payment.payment_hash, + amount=amount, + memo=f"Revenue from '{track.name}'.", + pending=False, + ) + + # manually send this for now + await internal_invoice_paid.send(internal_checking_id) + + # so the flow is the following: + # - we receive, say, 1000 satoshis + # - if the fee_pct is, say, 30%, the amount we will send is 700 + # - we change the amount of receiving payment on the database from 1000 to 300 + # - we create a new payment on the producer's wallet with amount 700 diff --git a/lnbits/extensions/livestream/templates/livestream/_api_docs.html b/lnbits/extensions/livestream/templates/livestream/_api_docs.html new file mode 100644 index 000000000..fd92f0f31 --- /dev/null +++ b/lnbits/extensions/livestream/templates/livestream/_api_docs.html @@ -0,0 +1,146 @@ + + + +

Add tracks, profit.

+
+
+
+ + + + + + GET + /livestream/api/v1/livestream +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<livestream_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + PUT + /livestream/api/v1/livestream/track/<track_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+
Curl example
+ curl -X PUT {{ request.url_root + }}api/v1/livestream/track/<track_id> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + PUT + /livestream/api/v1/livestream/fee/<fee_pct> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+
Curl example
+ curl -X PUT {{ request.url_root + }}api/v1/livestream/fee/<fee_pct> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + + POST + /livestream/api/v1/livestream/tracks +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+ {"name": <string>, "download_url": <string>, + "price_msat": <integer>, "producer_id": <integer>, + "producer_name": <string>} +
+ Returns 201 CREATED (application/json) +
+
Curl example
+ curl -X POST {{ request.url_root }}api/v1/livestream/tracks -d + '{"name": <string>, "download_url": <string>, + "price_msat": <integer>, "producer_id": <integer>, + "producer_name": <string>}' -H "Content-type: application/json" + -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /livestream/api/v1/livestream/tracks/<track_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/livestream/tracks/<track_id> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+
diff --git a/lnbits/extensions/livestream/templates/livestream/index.html b/lnbits/extensions/livestream/templates/livestream/index.html new file mode 100644 index 000000000..a93bab71e --- /dev/null +++ b/lnbits/extensions/livestream/templates/livestream/index.html @@ -0,0 +1,322 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+
+ +
+
+ {% raw %} + + {{ nextCurrentTrack && nextCurrentTrack === + livestream.current_track ? 'Stop' : 'Set' }} current track + + {% endraw %} +
+
+
+ +
+
+ +
+
+ Set percent rate +
+
+
+
+ + + +
+
+
Tracks
+
+
+ Add new track +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Producers
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + + + + + + + + + + + + + Copy LNURL-pay code + + +
+ +
+ + +
+ {{SITE_TITLE}} Livestream extension +
+
+ + + {% include "livestream/_api_docs.html" %} + +
+
+ + + + +

+ Standalone QR Code for this track +

+ + + + + + + Copy LNURL-pay code +
+ + + + + + + +
+
+ + Update track + Add track + +
+
+ Cancel +
+
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/livestream/views.py b/lnbits/extensions/livestream/views.py new file mode 100644 index 000000000..8864ac2cd --- /dev/null +++ b/lnbits/extensions/livestream/views.py @@ -0,0 +1,38 @@ +from quart import g, render_template, request, redirect +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_wallet_payment + +from . import livestream_ext +from .crud import get_track, get_livestream_by_track + + +@livestream_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("livestream/index.html", user=g.user) + + +@livestream_ext.route("/track/") +async def track_redirect_download(track_id): + payment_hash = request.args.get("p") + track = await get_track(track_id) + ls = await get_livestream_by_track(track_id) + payment: Payment = await get_wallet_payment(ls.wallet, payment_hash) + + if not payment: + return ( + f"Couldn't find the payment {payment_hash} or track {track.id}.", + HTTPStatus.NOT_FOUND, + ) + + if payment.pending: + return ( + f"Payment {payment_hash} wasn't received yet. Please try again in a minute.", + HTTPStatus.PAYMENT_REQUIRED, + ) + + return redirect(track.download_url) diff --git a/lnbits/extensions/livestream/views_api.py b/lnbits/extensions/livestream/views_api.py new file mode 100644 index 000000000..c8816ac1c --- /dev/null +++ b/lnbits/extensions/livestream/views_api.py @@ -0,0 +1,135 @@ +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 . import livestream_ext +from .crud import ( + get_or_create_livestream_by_wallet, + add_track, + get_tracks, + update_track, + add_producer, + get_producers, + update_current_track, + update_livestream_fee, + delete_track_from_livestream, +) + + +@livestream_ext.route("/api/v1/livestream", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_livestream_from_wallet(): + ls = await get_or_create_livestream_by_wallet(g.wallet.id) + tracks = await get_tracks(ls.id) + producers = await get_producers(ls.id) + + try: + return ( + jsonify( + { + **ls._asdict(), + **{ + "lnurl": ls.lnurl, + "tracks": [ + dict(lnurl=track.lnurl, **track._asdict()) + for track in tracks + ], + "producers": [producer._asdict() for producer in producers], + }, + } + ), + HTTPStatus.OK, + ) + except LnurlInvalidUrl: + return ( + jsonify( + { + "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." + } + ), + HTTPStatus.UPGRADE_REQUIRED, + ) + + +@livestream_ext.route("/api/v1/livestream/track/", methods=["PUT"]) +@api_check_wallet_key("invoice") +async def api_update_track(track_id): + try: + id = int(track_id) + except ValueError: + id = 0 + if id <= 0: + id = None + + ls = await get_or_create_livestream_by_wallet(g.wallet.id) + await update_current_track(ls.id, id) + return "", HTTPStatus.NO_CONTENT + + +@livestream_ext.route("/api/v1/livestream/fee/", methods=["PUT"]) +@api_check_wallet_key("invoice") +async def api_update_fee(fee_pct): + ls = await get_or_create_livestream_by_wallet(g.wallet.id) + await update_livestream_fee(ls.id, int(fee_pct)) + return "", HTTPStatus.NO_CONTENT + + +@livestream_ext.route("/api/v1/livestream/tracks", methods=["POST"]) +@livestream_ext.route("/api/v1/livestream/tracks/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "name": {"type": "string", "empty": False, "required": True}, + "download_url": {"type": "string", "empty": False, "required": False}, + "price_msat": {"type": "number", "min": 0, "required": False}, + "producer_id": { + "type": "number", + "required": True, + "excludes": "producer_name", + }, + "producer_name": { + "type": "string", + "required": True, + "excludes": "producer_id", + }, + } +) +async def api_add_track(id=None): + ls = await get_or_create_livestream_by_wallet(g.wallet.id) + + if "producer_id" in g.data: + p_id = g.data["producer_id"] + elif "producer_name" in g.data: + p_id = await add_producer(ls.id, g.data["producer_name"]) + else: + raise TypeError("need either producer_id or producer_name arguments") + + if id: + await update_track( + ls.id, + id, + g.data["name"], + g.data.get("download_url"), + g.data.get("price_msat", 0), + p_id, + ) + return "", HTTPStatus.OK + else: + await add_track( + ls.id, + g.data["name"], + g.data.get("download_url"), + g.data.get("price_msat", 0), + p_id, + ) + return "", HTTPStatus.CREATED + + +@livestream_ext.route("/api/v1/livestream/tracks/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_delete_track(track_id): + ls = await get_or_create_livestream_by_wallet(g.wallet.id) + await delete_track_from_livestream(ls.id, track_id) + return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/lndhub/README.md b/lnbits/extensions/lndhub/README.md new file mode 100644 index 000000000..f567d5492 --- /dev/null +++ b/lnbits/extensions/lndhub/README.md @@ -0,0 +1,6 @@ +

lndhub Extension

+

*connect to your lnbits wallet from BlueWallet or Zeus*

+ +Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/. + +Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club. diff --git a/lnbits/extensions/lndhub/__init__.py b/lnbits/extensions/lndhub/__init__.py new file mode 100644 index 000000000..7610b0a3e --- /dev/null +++ b/lnbits/extensions/lndhub/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_lndhub") + +lndhub_ext: Blueprint = Blueprint( + "lndhub", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/lndhub/config.json b/lnbits/extensions/lndhub/config.json new file mode 100644 index 000000000..6285ff80d --- /dev/null +++ b/lnbits/extensions/lndhub/config.json @@ -0,0 +1,6 @@ +{ + "name": "LndHub", + "short_description": "Access lnbits from BlueWallet or Zeus", + "icon": "navigation", + "contributors": ["fiatjaf"] +} diff --git a/lnbits/extensions/lndhub/decorators.py b/lnbits/extensions/lndhub/decorators.py new file mode 100644 index 000000000..c9c3bb71e --- /dev/null +++ b/lnbits/extensions/lndhub/decorators.py @@ -0,0 +1,29 @@ +from base64 import b64decode +from quart import jsonify, g, request +from functools import wraps + +from lnbits.core.crud import get_wallet_for_key + + +def check_wallet(requires_admin=False): + def wrap(view): + @wraps(view) + async def wrapped_view(**kwargs): + token = request.headers["Authorization"].split("Bearer ")[1] + 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"} + ) + + g.wallet = await get_wallet_for_key(key, key_type) + if not g.wallet: + return jsonify( + {"error": True, "code": 2, "message": "insufficient permissions"} + ) + return await view(**kwargs) + + return wrapped_view + + return wrap diff --git a/lnbits/extensions/lndhub/migrations.py b/lnbits/extensions/lndhub/migrations.py new file mode 100644 index 000000000..d6ea5fdea --- /dev/null +++ b/lnbits/extensions/lndhub/migrations.py @@ -0,0 +1,2 @@ +async def migrate(): + pass diff --git a/lnbits/extensions/lndhub/templates/lndhub/_instructions.html b/lnbits/extensions/lndhub/templates/lndhub/_instructions.html new file mode 100644 index 000000000..4db79aba8 --- /dev/null +++ b/lnbits/extensions/lndhub/templates/lndhub/_instructions.html @@ -0,0 +1,35 @@ + + + + To access an LNbits wallet from a mobile phone, +
    +
  1. + Install either Zeus or + BlueWallet; +
  2. +
  3. + Go to Add a wallet / Import wallet on BlueWallet or + Settings / Add a new node on Zeus. +
  4. +
  5. Select the desired wallet on this page;
  6. +
  7. Scan one of the two QR codes from the mobile wallet.
  8. +
+
    +
  • + Invoice URLs mean the mobile wallet will only have the + authorization to read your payments and invoices and generate new + invoices. +
  • +
  • + Admin URLs mean the mobile wallet will be able to pay + invoices.. +
  • +
+
+
+
diff --git a/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html b/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html new file mode 100644 index 000000000..a15cab8fa --- /dev/null +++ b/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html @@ -0,0 +1,19 @@ + + + +

+ LndHub is a protocol invented by + BlueWallet that allows mobile + wallets to query payments and balances, generate invoices and make + payments from accounts that exist on a server. The protocol is a + collection of HTTP endpoints exposed through the internet. +

+

+ For a wallet that supports it, reading a QR code that contains the URL + along with secret access credentials should enable access. Currently it + is supported by Zeus and + BlueWallet. +

+
+
+
diff --git a/lnbits/extensions/lndhub/templates/lndhub/index.html b/lnbits/extensions/lndhub/templates/lndhub/index.html new file mode 100644 index 000000000..ad0a3b046 --- /dev/null +++ b/lnbits/extensions/lndhub/templates/lndhub/index.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} {% raw %} +
+
+
+ + + +
+ Copy LndHub {{type}} URL +
+
+
+
+ + + + + + + +
+ + {% endraw %} + +
+ + +
+ {{SITE_TITLE}} LndHub extension +
+
+ + + + {% include "lndhub/_instructions.html" %} + + {% include "lndhub/_lndhub.html" %} + + +
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/lndhub/utils.py b/lnbits/extensions/lndhub/utils.py new file mode 100644 index 000000000..3db6317a7 --- /dev/null +++ b/lnbits/extensions/lndhub/utils.py @@ -0,0 +1,21 @@ +from binascii import unhexlify + +from lnbits.bolt11 import Invoice + + +def to_buffer(payment_hash: str): + return {"type": "Buffer", "data": [b for b in unhexlify(payment_hash)]} + + +def decoded_as_lndhub(invoice: Invoice): + return { + "destination": invoice.payee, + "payment_hash": invoice.payment_hash, + "num_satoshis": invoice.amount_msat / 1000, + "timestamp": str(invoice.date), + "expiry": str(invoice.expiry), + "description": invoice.description, + "fallback_addr": "", + "cltv_expiry": invoice.min_final_cltv_expiry, + "route_hints": "", + } diff --git a/lnbits/extensions/lndhub/views.py b/lnbits/extensions/lndhub/views.py new file mode 100644 index 000000000..2bc01fc17 --- /dev/null +++ b/lnbits/extensions/lndhub/views.py @@ -0,0 +1,11 @@ +from quart import render_template, g + +from lnbits.decorators import check_user_exists, validate_uuids +from . import lndhub_ext + + +@lndhub_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def lndhub_index(): + return await render_template("lndhub/index.html", user=g.user) diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py new file mode 100644 index 000000000..de61820a8 --- /dev/null +++ b/lnbits/extensions/lndhub/views_api.py @@ -0,0 +1,240 @@ +import time +from base64 import urlsafe_b64encode +from quart import jsonify, g, request + +from lnbits.core.services import pay_invoice, create_invoice +from lnbits.core.crud import get_payments, delete_expired_invoices +from lnbits.decorators import api_validate_post_request +from lnbits.settings import WALLET +from lnbits import bolt11 + +from . import lndhub_ext +from .decorators import check_wallet +from .utils import to_buffer, decoded_as_lndhub + + +@lndhub_ext.route("/ext/getinfo", methods=["GET"]) +async def lndhub_getinfo(): + return jsonify({"error": True, "code": 1, "message": "bad auth"}) + + +@lndhub_ext.route("/ext/auth", methods=["POST"]) +@api_validate_post_request( + 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"], + }, + } +) +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") + ) + return jsonify({"refresh_token": token, "access_token": token}) + + +@lndhub_ext.route("/ext/addinvoice", methods=["POST"]) +@check_wallet() +@api_validate_post_request( + schema={ + "amt": {"type": "string", "required": True}, + "memo": {"type": "string", "required": True}, + "preimage": {"type": "string", "required": False}, + } +) +async def lndhub_addinvoice(): + try: + _, pr = await create_invoice( + wallet_id=g.wallet.id, + amount=int(g.data["amt"]), + memo=g.data["memo"], + extra={"tag": "lndhub"}, + ) + except Exception as e: + return jsonify( + { + "error": True, + "code": 7, + "message": "Failed to create invoice: " + str(e), + } + ) + + invoice = bolt11.decode(pr) + return jsonify( + { + "pay_req": pr, + "payment_request": pr, + "add_index": "500", + "r_hash": to_buffer(invoice.payment_hash), + "hash": invoice.payment_hash, + } + ) + + +@lndhub_ext.route("/ext/payinvoice", methods=["POST"]) +@check_wallet(requires_admin=True) +@api_validate_post_request(schema={"invoice": {"type": "string", "required": True}}) +async def lndhub_payinvoice(): + try: + await pay_invoice( + wallet_id=g.wallet.id, + payment_request=g.data["invoice"], + extra={"tag": "lndhub"}, + ) + except Exception as e: + return jsonify( + { + "error": True, + "code": 10, + "message": "Payment failed: " + str(e), + } + ) + + invoice: bolt11.Invoice = bolt11.decode(g.data["invoice"]) + return jsonify( + { + "payment_error": "", + "payment_preimage": "0" * 64, + "route": {}, + "payment_hash": invoice.payment_hash, + "decoded": decoded_as_lndhub(invoice), + "fee_msat": 0, + "type": "paid_invoice", + "fee": 0, + "value": invoice.amount_msat / 1000, + "timestamp": int(time.time()), + "memo": invoice.description, + } + ) + + +@lndhub_ext.route("/ext/balance", methods=["GET"]) +@check_wallet() +async def lndhub_balance(): + return jsonify({"BTC": {"AvailableBalance": g.wallet.balance}}) + + +@lndhub_ext.route("/ext/gettxs", methods=["GET"]) +@check_wallet() +async def lndhub_gettxs(): + for payment in await get_payments( + wallet_id=g.wallet.id, + complete=False, + pending=True, + outgoing=True, + incoming=False, + exclude_uncheckable=True, + ): + await payment.set_pending( + (await WALLET.get_payment_status(payment.checking_id)).pending + ) + + limit = int(request.args.get("limit", 200)) + return jsonify( + [ + { + "payment_preimage": payment.preimage, + "payment_hash": payment.payment_hash, + "fee_msat": payment.fee * 1000, + "type": "paid_invoice", + "fee": payment.fee, + "value": int(payment.amount / 1000), + "timestamp": payment.time, + "memo": payment.memo + if not payment.pending + else "Payment in transition", + } + for payment in reversed( + ( + await get_payments( + wallet_id=g.wallet.id, + pending=True, + complete=True, + outgoing=True, + incoming=False, + ) + )[:limit] + ) + ] + ) + + +@lndhub_ext.route("/ext/getuserinvoices", methods=["GET"]) +@check_wallet() +async def lndhub_getuserinvoices(): + await delete_expired_invoices() + for invoice in await get_payments( + wallet_id=g.wallet.id, + complete=False, + pending=True, + outgoing=False, + incoming=True, + exclude_uncheckable=True, + ): + await invoice.set_pending( + (await WALLET.get_invoice_status(invoice.checking_id)).pending + ) + + limit = int(request.args.get("limit", 200)) + return jsonify( + [ + { + "r_hash": to_buffer(invoice.payment_hash), + "payment_request": invoice.bolt11, + "add_index": "500", + "description": invoice.memo, + "payment_hash": invoice.payment_hash, + "ispaid": not invoice.pending, + "amt": int(invoice.amount / 1000), + "expire_time": int(time.time() + 1800), + "timestamp": invoice.time, + "type": "user_invoice", + } + for invoice in reversed( + ( + await get_payments( + wallet_id=g.wallet.id, + pending=True, + complete=True, + incoming=True, + outgoing=False, + ) + )[:limit] + ) + ] + ) + + +@lndhub_ext.route("/ext/getbtc", methods=["GET"]) +@check_wallet() +async def lndhub_getbtc(): + "load an address for incoming onchain btc" + return jsonify([]) + + +@lndhub_ext.route("/ext/getpending", methods=["GET"]) +@check_wallet() +async def lndhub_getpending(): + "pending onchain transactions" + return jsonify([]) + + +@lndhub_ext.route("/ext/decodeinvoice", methods=["GET"]) +async def lndhub_decodeinvoice(): + invoice = request.args.get("invoice") + inv = bolt11.decode(invoice) + return jsonify(decoded_as_lndhub(inv)) + + +@lndhub_ext.route("/ext/checkrouteinvoice", methods=["GET"]) +async def lndhub_checkrouteinvoice(): + "not implemented on canonical lndhub" + pass diff --git a/lnbits/extensions/lnticket/README.md b/lnbits/extensions/lnticket/README.md new file mode 100644 index 000000000..bd0714506 --- /dev/null +++ b/lnbits/extensions/lnticket/README.md @@ -0,0 +1,29 @@ +# Support Tickets + +## Get paid sats to answer questions + +Charge a per word amount for people to contact you. + +Possible applications include, paid support ticketing, PAYG language services, contact spam protection. + +1. Click "NEW FORM" to create a new contact form\ + ![new contact form](https://i.imgur.com/kZqWGPe.png) +2. Fill out the contact form + - set the wallet to use + - give your form a name + - set an optional webhook that will get called when the form receives a payment + - give it a small description + - set the amount you want to charge, per **word**, for people to contact you\ + ![form settings](https://i.imgur.com/AsXeVet.png) +3. Your new contact form will appear on the _Forms_ section. Note that you can create various forms with different rates per word, for different purposes\ + ![forms section](https://i.imgur.com/gg71HhM.png) +4. When a user wants to reach out to you, they will get to the contact form. They can fill out some information: + - a name + - an optional email if they want you to reply + - and the actual message + - at the bottom, a value in satoshis, will display how much it will cost them to send this message\ + ![user view of form](https://i.imgur.com/DWGJWQz.png) + - after submiting the Lightning Network invoice will pop up and after payment the message will be sent to you\ + ![contact form payment](https://i.imgur.com/7heGsiO.png) +5. Back in "Support ticket" extension you'll get the messages your fans, users, haters, etc, sent you on the _Tickets_ section\ + ![tickets](https://i.imgur.com/dGhJ6Ok.png) diff --git a/lnbits/extensions/lnticket/__init__.py b/lnbits/extensions/lnticket/__init__.py new file mode 100644 index 000000000..cfdadc402 --- /dev/null +++ b/lnbits/extensions/lnticket/__init__.py @@ -0,0 +1,17 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_lnticket") + +lnticket_ext: Blueprint = Blueprint( + "lnticket", __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 + +lnticket_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/lnticket/config.json b/lnbits/extensions/lnticket/config.json new file mode 100644 index 000000000..99581b8f7 --- /dev/null +++ b/lnbits/extensions/lnticket/config.json @@ -0,0 +1,6 @@ +{ + "name": "Support Tickets", + "short_description": "LN support ticket system", + "icon": "contact_support", + "contributors": ["benarc"] +} diff --git a/lnbits/extensions/lnticket/crud.py b/lnbits/extensions/lnticket/crud.py new file mode 100644 index 000000000..5c1f1e021 --- /dev/null +++ b/lnbits/extensions/lnticket/crud.py @@ -0,0 +1,156 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Tickets, Forms +import httpx + + +async def create_ticket( + payment_hash: str, + wallet: str, + form: str, + name: str, + email: str, + ltext: str, + sats: int, +) -> Tickets: + await db.execute( + """ + INSERT INTO lnticket.ticket (id, form, email, ltext, name, wallet, sats, paid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (payment_hash, form, email, ltext, name, wallet, sats, False), + ) + + ticket = await get_ticket(payment_hash) + assert ticket, "Newly created ticket couldn't be retrieved" + return ticket + + +async def set_ticket_paid(payment_hash: str) -> Tickets: + row = await db.fetchone( + "SELECT * FROM lnticket.ticket WHERE id = ?", (payment_hash,) + ) + if row[7] == False: + await db.execute( + """ + UPDATE lnticket.ticket + SET paid = true + WHERE id = ? + """, + (payment_hash,), + ) + + formdata = await get_form(row[1]) + assert formdata, "Couldn't get form from paid ticket" + + amount = formdata.amountmade + row[7] + await db.execute( + """ + UPDATE lnticket.form2 + SET amountmade = ? + WHERE id = ? + """, + (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: + 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) + assert ticket, "Newly paid ticket could not be retrieved" + return ticket + + +async def get_ticket(ticket_id: str) -> Optional[Tickets]: + row = await db.fetchone("SELECT * FROM lnticket.ticket WHERE id = ?", (ticket_id,)) + return Tickets(**row) if row else None + + +async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM lnticket.ticket WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Tickets(**row) for row in rows] + + +async def delete_ticket(ticket_id: str) -> None: + await db.execute("DELETE FROM lnticket.ticket WHERE id = ?", (ticket_id,)) + + +# FORMS + + +async def create_form( + *, + wallet: str, + name: str, + webhook: Optional[str] = None, + description: str, + amount: int, + flatrate: int, +) -> Forms: + form_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO lnticket.form2 (id, wallet, name, webhook, description, flatrate, amount, amountmade) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (form_id, wallet, name, webhook, description, flatrate, amount, 0), + ) + + form = await get_form(form_id) + assert form, "Newly created forms couldn't be retrieved" + return form + + +async def update_form(form_id: str, **kwargs) -> Forms: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE lnticket.form2 SET {q} WHERE id = ?", (*kwargs.values(), form_id) + ) + row = await db.fetchone("SELECT * FROM lnticket.form2 WHERE id = ?", (form_id,)) + assert row, "Newly updated form couldn't be retrieved" + return Forms(**row) + + +async def get_form(form_id: str) -> Optional[Forms]: + row = await db.fetchone("SELECT * FROM lnticket.form2 WHERE id = ?", (form_id,)) + return Forms(**row) if row else None + + +async def get_forms(wallet_ids: Union[str, List[str]]) -> List[Forms]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM lnticket.form2 WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Forms(**row) for row in rows] + + +async def delete_form(form_id: str) -> None: + await db.execute("DELETE FROM lnticket.form2 WHERE id = ?", (form_id,)) diff --git a/lnbits/extensions/lnticket/migrations.py b/lnbits/extensions/lnticket/migrations.py new file mode 100644 index 000000000..abcd5c7f4 --- /dev/null +++ b/lnbits/extensions/lnticket/migrations.py @@ -0,0 +1,202 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE lnticket.forms ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + costpword INTEGER NOT NULL, + amountmade INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute( + """ + CREATE TABLE lnticket.tickets ( + id TEXT PRIMARY KEY, + form TEXT NOT NULL, + email TEXT NOT NULL, + ltext TEXT NOT NULL, + name TEXT NOT NULL, + wallet TEXT NOT NULL, + sats INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + +async def m002_changed(db): + + await db.execute( + """ + CREATE TABLE lnticket.ticket ( + id TEXT PRIMARY KEY, + form TEXT NOT NULL, + email TEXT NOT NULL, + ltext TEXT NOT NULL, + name TEXT NOT NULL, + wallet TEXT NOT NULL, + sats INTEGER NOT NULL, + paid BOOLEAN NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM lnticket.tickets") + ]: + usescsv = "" + + for i in range(row[5]): + if row[7]: + usescsv += "," + str(i + 1) + else: + usescsv += "," + str(1) + usescsv = usescsv[1:] + await db.execute( + """ + INSERT INTO lnticket.ticket ( + id, + form, + email, + ltext, + name, + wallet, + sats, + paid + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row[0], + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + True, + ), + ) + await db.execute("DROP TABLE lnticket.tickets") + + +async def m003_changed(db): + + await db.execute( + """ + CREATE TABLE lnticket.form ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + webhook TEXT, + description TEXT NOT NULL, + costpword INTEGER NOT NULL, + amountmade INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + for row in [list(row) for row in await db.fetchall("SELECT * FROM lnticket.forms")]: + usescsv = "" + + for i in range(row[5]): + if row[7]: + usescsv += "," + str(i + 1) + else: + usescsv += "," + str(1) + usescsv = usescsv[1:] + await db.execute( + """ + INSERT INTO lnticket.form ( + id, + wallet, + name, + webhook, + description, + costpword, + amountmade + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + row[0], + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + ), + ) + await db.execute("DROP TABLE lnticket.forms") + + +async def m004_changed(db): + + await db.execute( + """ + CREATE TABLE lnticket.form2 ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + webhook TEXT, + description TEXT NOT NULL, + flatrate INTEGER DEFAULT 0, + amount INTEGER NOT NULL, + amountmade INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + for row in [list(row) for row in await db.fetchall("SELECT * FROM lnticket.form")]: + usescsv = "" + + for i in range(row[5]): + if row[7]: + usescsv += "," + str(i + 1) + else: + usescsv += "," + str(1) + usescsv = usescsv[1:] + await db.execute( + """ + INSERT INTO lnticket.form2 ( + id, + wallet, + name, + webhook, + description, + amount, + amountmade + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + row[0], + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + ), + ) + await db.execute("DROP TABLE lnticket.form") diff --git a/lnbits/extensions/lnticket/models.py b/lnbits/extensions/lnticket/models.py new file mode 100644 index 000000000..1bc8237c7 --- /dev/null +++ b/lnbits/extensions/lnticket/models.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel + +class Forms(BaseModel): + id: str + wallet: str + name: str + webhook: str + description: str + amount: int + flatrate: int + amountmade: int + time: int + + +class Tickets(BaseModel): + id: str + form: str + email: str + ltext: str + name: str + wallet: str + sats: int + paid: bool + time: int diff --git a/lnbits/extensions/lnticket/tasks.py b/lnbits/extensions/lnticket/tasks.py new file mode 100644 index 000000000..5160de1dd --- /dev/null +++ b/lnbits/extensions/lnticket/tasks.py @@ -0,0 +1,37 @@ +import json +import trio # type: ignore + +from lnbits.core.models import Payment +from lnbits.core.crud import create_payment +from lnbits.core import db as core_db +from lnbits.tasks import register_invoice_listener, internal_invoice_paid +from lnbits.helpers import urlsafe_short_hash + +from .crud import get_ticket, set_ticket_paid + + +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 "lnticket" != payment.extra.get("tag"): + # not a lnticket invoice + return + + ticket = await get_ticket(payment.checking_id) + if not ticket: + print("this should never happen", payment) + return + + await payment.set_pending(False) + await set_ticket_paid(payment.payment_hash) + _ticket = await get_ticket(payment.checking_id) + print("ticket", _ticket) diff --git a/lnbits/extensions/lnticket/templates/lnticket/_api_docs.html b/lnbits/extensions/lnticket/templates/lnticket/_api_docs.html new file mode 100644 index 000000000..69328f384 --- /dev/null +++ b/lnbits/extensions/lnticket/templates/lnticket/_api_docs.html @@ -0,0 +1,22 @@ + + + +
+ Support Tickets: Get paid sats to answer questions +
+

+ Charge people per word for contacting you. Possible applications incude, + paid support ticketing, PAYG language services, contact spam + protection.
+ + Created by, Ben Arc +

+
+
+
diff --git a/lnbits/extensions/lnticket/templates/lnticket/display.html b/lnbits/extensions/lnticket/templates/lnticket/display.html new file mode 100644 index 000000000..3b48766cc --- /dev/null +++ b/lnbits/extensions/lnticket/templates/lnticket/display.html @@ -0,0 +1,202 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +

{{ form_name }}

+
+
{{ form_desc }}
+
+ + + + + +

{% raw %}{{amountWords}}{% endraw %}

+
+ Submit + Cancel +
+
+
+
+
+ + + + + + +
+ Copy invoice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/lnticket/templates/lnticket/index.html b/lnbits/extensions/lnticket/templates/lnticket/index.html new file mode 100644 index 000000000..bc9fe9a43 --- /dev/null +++ b/lnbits/extensions/lnticket/templates/lnticket/index.html @@ -0,0 +1,490 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Form + + + + + +
+
+
Forms
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Tickets
+
+ +
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Support Tickets extension +
+
+ + + {% include "lnticket/_api_docs.html" %} + +
+
+ + + + + + + + + +
+
+ +
+
+ +
+
+ +
+ Update Form + + Create Form + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/lnticket/views.py b/lnbits/extensions/lnticket/views.py new file mode 100644 index 000000000..00ba32391 --- /dev/null +++ b/lnbits/extensions/lnticket/views.py @@ -0,0 +1,34 @@ +from quart import g, abort, render_template + +from lnbits.core.crud import get_wallet +from lnbits.decorators import check_user_exists, validate_uuids +from http import HTTPStatus + +from . import lnticket_ext +from .crud import get_form + + +@lnticket_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("lnticket/index.html", user=g.user) + + +@lnticket_ext.route("/") +async def display(form_id): + form = await get_form(form_id) + if not form: + abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.") + + wallet = await get_wallet(form.wallet) + + return await render_template( + "lnticket/display.html", + form_id=form.id, + form_name=form.name, + form_desc=form.description, + form_amount=form.amount, + form_flatrate=form.flatrate, + form_wallet=wallet.inkey, + ) diff --git a/lnbits/extensions/lnticket/views_api.py b/lnbits/extensions/lnticket/views_api.py new file mode 100644 index 000000000..76fe222e8 --- /dev/null +++ b/lnbits/extensions/lnticket/views_api.py @@ -0,0 +1,179 @@ +import re +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import lnticket_ext +from .crud import ( + create_ticket, + set_ticket_paid, + get_ticket, + get_tickets, + delete_ticket, + create_form, + update_form, + get_form, + get_forms, + delete_form, +) + + +# FORMS + + +@lnticket_ext.route("/api/v1/forms", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_forms(): + wallet_ids = [g.wallet.id] + + 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, + ) + + +@lnticket_ext.route("/api/v1/forms", methods=["POST"]) +@lnticket_ext.route("/api/v1/forms/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "wallet": {"type": "string", "empty": False, "required": True}, + "name": {"type": "string", "empty": False, "required": True}, + "webhook": {"type": "string", "required": False}, + "description": {"type": "string", "min": 0, "required": True}, + "amount": {"type": "integer", "min": 0, "required": True}, + "flatrate": {"type": "integer", "required": True}, + } +) +async def api_form_create(form_id=None): + if form_id: + form = await get_form(form_id) + + if not form: + return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND + + if form.wallet != g.wallet.id: + return jsonify({"message": "Not your form."}), HTTPStatus.FORBIDDEN + + form = await update_form(form_id, **g.data) + else: + form = await create_form(**g.data) + return jsonify(form._asdict()), HTTPStatus.CREATED + + +@lnticket_ext.route("/api/v1/forms/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_form_delete(form_id): + form = await get_form(form_id) + + if not form: + return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND + + if form.wallet != g.wallet.id: + return jsonify({"message": "Not your form."}), HTTPStatus.FORBIDDEN + + await delete_form(form_id) + + return "", HTTPStatus.NO_CONTENT + + +#########tickets########## + + +@lnticket_ext.route("/api/v1/tickets", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_tickets(): + wallet_ids = [g.wallet.id] + + 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, + ) + + +@lnticket_ext.route("/api/v1/tickets/", methods=["POST"]) +@api_validate_post_request( + schema={ + "form": {"type": "string", "empty": False, "required": True}, + "name": {"type": "string", "empty": False, "required": True}, + "email": {"type": "string", "empty": True, "required": True}, + "ltext": {"type": "string", "empty": False, "required": True}, + "sats": {"type": "integer", "min": 0, "required": True}, + } +) +async def api_ticket_make_ticket(form_id): + form = await get_form(form_id) + if not form: + return jsonify({"message": "LNTicket does not exist."}), HTTPStatus.NOT_FOUND + + nwords = len(re.split(r"\s+", g.data["ltext"])) + sats = g.data["sats"] + + 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 + ) + + if not ticket: + return ( + jsonify({"message": "LNTicket could not be fetched."}), + HTTPStatus.NOT_FOUND, + ) + + return ( + jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), + HTTPStatus.OK, + ) + + +@lnticket_ext.route("/api/v1/tickets/", methods=["GET"]) +async def api_ticket_send_ticket(payment_hash): + ticket = await get_ticket(payment_hash) + try: + status = await check_invoice_status(ticket.wallet, payment_hash) + is_paid = not status.pending + except Exception: + return jsonify({"paid": False}), HTTPStatus.OK + + if is_paid: + wallet = await get_wallet(ticket.wallet) + payment = await wallet.get_payment(payment_hash) + await payment.set_pending(False) + ticket = await set_ticket_paid(payment_hash=payment_hash) + return jsonify({"paid": True}), HTTPStatus.OK + + return jsonify({"paid": False}), HTTPStatus.OK + + +@lnticket_ext.route("/api/v1/tickets/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_ticket_delete(ticket_id): + ticket = await get_ticket(ticket_id) + + if not ticket: + return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + + if ticket.wallet != g.wallet.id: + return jsonify({"message": "Not your ticket."}), HTTPStatus.FORBIDDEN + + await delete_ticket(ticket_id) + + return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/lnurlp/README.md b/lnbits/extensions/lnurlp/README.md new file mode 100644 index 000000000..0832bfb7d --- /dev/null +++ b/lnbits/extensions/lnurlp/README.md @@ -0,0 +1,27 @@ +# LNURLp + +## Create a static QR code people can use to pay over Lightning Network + +LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL-pay is a link that wallets use to fetch an invoice from a server on-demand. The link or QR code is fixed, but each time it is read by a compatible wallet a new invoice is issued by the service and sent to the wallet. + +[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) + +## Usage + +1. Create an LNURLp (New Pay link)\ + ![create lnurlp](https://i.imgur.com/rhUBJFy.jpg) + + - select your wallets + - make a small description + - enter amount + - if _Fixed amount_ is unchecked you'll have the option to configure a Max and Min amount + - you can set the currency to something different than sats. For example if you choose EUR, the satoshi amount will be calculated when a user scans the LNURLp + - You can ask the user to send a comment that will be sent along with the payment (for example a comment to a blog post) + - Webhook URL allows to call an URL when the LNURLp is paid + - Success mesage, will send a message back to the user after a successful payment, for example a thank you note + - Success URL, will send back a clickable link to the user. Access to some hidden content, or a download link + +2. Use the shareable link or view the LNURLp you just created\ + ![LNURLp](https://i.imgur.com/C8s1P0Q.jpg) + - you can now open your LNURLp and copy the LNURL, get the shareable link or print it\ + ![view lnurlp](https://i.imgur.com/4n41S7T.jpg) diff --git a/lnbits/extensions/lnurlp/__init__.py b/lnbits/extensions/lnurlp/__init__.py new file mode 100644 index 000000000..d820b1973 --- /dev/null +++ b/lnbits/extensions/lnurlp/__init__.py @@ -0,0 +1,18 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_lnurlp") + +lnurlp_ext: Blueprint = Blueprint( + "lnurlp", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa +from .lnurl import * # noqa +from .tasks import register_listeners + +from lnbits.tasks import record_async + +lnurlp_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/lnurlp/config.json b/lnbits/extensions/lnurlp/config.json new file mode 100644 index 000000000..294afe73b --- /dev/null +++ b/lnbits/extensions/lnurlp/config.json @@ -0,0 +1,10 @@ +{ + "name": "LNURLp", + "short_description": "Make reusable LNURL pay links", + "icon": "receipt", + "contributors": [ + "arcbtc", + "eillarra", + "fiatjaf" + ] +} diff --git a/lnbits/extensions/lnurlp/crud.py b/lnbits/extensions/lnurlp/crud.py new file mode 100644 index 000000000..b1744a64e --- /dev/null +++ b/lnbits/extensions/lnurlp/crud.py @@ -0,0 +1,103 @@ +from typing import List, Optional, Union + +from lnbits.db import SQLITE +from . import db +from .models import PayLink + + +async def create_pay_link( + *, + wallet_id: str, + description: str, + min: int, + max: int, + comment_chars: int = 0, + currency: Optional[str] = None, + webhook_url: Optional[str] = None, + success_text: Optional[str] = None, + success_url: Optional[str] = None, +) -> PayLink: + + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await (method)( + f""" + INSERT INTO lnurlp.pay_links ( + wallet, + description, + min, + max, + served_meta, + served_pr, + webhook_url, + success_text, + success_url, + comment_chars, + currency + ) + VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?) + {returning} + """, + ( + wallet_id, + description, + min, + max, + webhook_url, + success_text, + success_url, + comment_chars, + currency, + ), + ) + if db.type == SQLITE: + link_id = result._result_proxy.lastrowid + else: + link_id = result[0] + + link = await get_pay_link(link_id) + assert link, "Newly created link couldn't be retrieved" + return link + + +async def get_pay_link(link_id: int) -> Optional[PayLink]: + row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) + return PayLink.from_row(row) if row else None + + +async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f""" + SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q}) + ORDER BY Id + """, + (*wallet_ids,), + ) + return [PayLink.from_row(row) for row in rows] + + +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 lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) + ) + row = await db.fetchone("SELECT * FROM lnurlp.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 lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) + ) + row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) + return PayLink.from_row(row) if row else None + + +async def delete_pay_link(link_id: int) -> None: + await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,)) diff --git a/lnbits/extensions/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py new file mode 100644 index 000000000..ac0605b3c --- /dev/null +++ b/lnbits/extensions/lnurlp/lnurl.py @@ -0,0 +1,111 @@ +import hashlib +import math +from http import HTTPStatus +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 + + +@lnurlp_ext.route("/api/v1/lnurl/", 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, + ) + + 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, extra=request.args.get('extra'), _external=True), + min_sendable=math.ceil(link.min * rate) * 1000, + max_sendable=round(link.max * rate) * 1000, + metadata=link.lnurlpay_metadata, + ) + params = resp.dict() + + if link.comment_chars > 0: + params["commentAllowed"] = link.comment_chars + + return jsonify(params), HTTPStatus.OK + + +@lnurlp_ext.route("/api/v1/lnurl/cb/", methods=["GET"]) +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, + ) + + min, max = link.min, link.max + 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 + max = rate * 1010 * link.max + else: + min = link.min * 1000 + max = link.max * 1000 + + amount_received = int(request.args.get("amount") or 0) + if amount_received < min: + return ( + 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() + ), + HTTPStatus.OK, + ) + + comment = request.args.get("comment") + if len(comment or "") > link.comment_chars: + return ( + jsonify( + LnurlErrorResponse( + reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}" + ).dict() + ), + HTTPStatus.OK, + ) + + payment_hash, payment_request = await create_invoice( + wallet_id=link.wallet, + amount=int(amount_received / 1000), + memo=link.description, + description_hash=hashlib.sha256( + link.lnurlpay_metadata.encode("utf-8") + ).digest(), + extra={"tag": "lnurlp", "link": link.id, "comment": comment, 'extra': request.args.get('extra')}, + ) + + success_action = link.success_action(payment_hash) + if success_action: + resp = LnurlPayActionResponse( + pr=payment_request, + success_action=success_action, + routes=[], + ) + else: + resp = LnurlPayActionResponse( + pr=payment_request, + routes=[], + ) + + return jsonify(resp.dict()), HTTPStatus.OK diff --git a/lnbits/extensions/lnurlp/migrations.py b/lnbits/extensions/lnurlp/migrations.py new file mode 100644 index 000000000..428bde2c8 --- /dev/null +++ b/lnbits/extensions/lnurlp/migrations.py @@ -0,0 +1,52 @@ +async def m001_initial(db): + """ + Initial pay table. + """ + await db.execute( + f""" + CREATE TABLE lnurlp.pay_links ( + id {db.serial_primary_key}, + wallet TEXT NOT NULL, + description TEXT NOT NULL, + amount INTEGER NOT NULL, + served_meta INTEGER NOT NULL, + served_pr INTEGER NOT NULL + ); + """ + ) + + +async def m002_webhooks_and_success_actions(db): + """ + Webhooks and success actions. + """ + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_url TEXT;") + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_text TEXT;") + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;") + await db.execute( + f""" + CREATE TABLE lnurlp.invoices ( + pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id), + payment_hash TEXT NOT NULL, + webhook_sent INT, -- null means not sent, otherwise store status + expiry INT + ); + """ + ) + + +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 lnurlp.pay_links ADD COLUMN currency TEXT;" + ) # null = satoshis + await db.execute( + "ALTER TABLE lnurlp.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;" + ) + await db.execute("ALTER TABLE lnurlp.pay_links RENAME COLUMN amount TO min;") + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;") + await db.execute("UPDATE lnurlp.pay_links SET max = min;") + await db.execute("DROP TABLE lnurlp.invoices") diff --git a/lnbits/extensions/lnurlp/models.py b/lnbits/extensions/lnurlp/models.py new file mode 100644 index 000000000..f1d4fff1f --- /dev/null +++ b/lnbits/extensions/lnurlp/models.py @@ -0,0 +1,55 @@ +import json +from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult +from quart import url_for +from typing import Optional, Dict +from lnbits.lnurl import encode as lnurl_encode # type: ignore +from lnurl.types import LnurlPayMetadata # type: ignore +from sqlite3 import Row +from pydantic import BaseModel + +class PayLink(BaseModel): + id: int + wallet: str + description: str + min: int + served_meta: int + served_pr: int + webhook_url: str + success_text: str + success_url: str + currency: str + comment_chars: int + max: int + + @classmethod + def from_row(cls, row: Row) -> "PayLink": + data = dict(row) + return cls(**data) + + @property + def lnurl(self) -> str: + url = url_for("lnurlp.api_lnurl_response", link_id=self.id, _external=True) + return lnurl_encode(url) + + @property + def lnurlpay_metadata(self) -> LnurlPayMetadata: + return LnurlPayMetadata(json.dumps([["text/plain", self.description]])) + + def success_action(self, payment_hash: str) -> Optional[Dict]: + if self.success_url: + url: ParseResult = urlparse(self.success_url) + qs: Dict = parse_qs(url.query) + qs["payment_hash"] = payment_hash + url = url._replace(query=urlencode(qs, doseq=True)) + return { + "tag": "url", + "description": self.success_text or "~", + "url": urlunparse(url), + } + elif self.success_text: + return { + "tag": "message", + "message": self.success_text, + } + else: + return None diff --git a/lnbits/extensions/lnurlp/static/js/index.js b/lnbits/extensions/lnurlp/static/js/index.js new file mode 100644 index 000000000..efd0fbd84 --- /dev/null +++ b/lnbits/extensions/lnurlp/static/js/index.js @@ -0,0 +1,227 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +var locationPath = [ + window.location.protocol, + '//', + window.location.host, + window.location.pathname +].join('') + +var mapPayLink = obj => { + obj._data = _.clone(obj) + obj.date = Quasar.utils.date.formatDate( + new Date(obj.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount) + obj.print_url = [locationPath, 'print/', obj.id].join('') + obj.pay_url = [locationPath, obj.id].join('') + return obj +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + currencies: [], + fiatRates: {}, + checker: null, + payLinks: [], + payLinksTable: { + pagination: { + rowsPerPage: 10 + } + }, + formDialog: { + show: false, + fixedAmount: true, + data: {} + }, + qrCodeDialog: { + show: false, + data: null + } + } + }, + methods: { + getPayLinks() { + LNbits.api + .request( + 'GET', + '/lnurlp/api/v1/links?all_wallets', + this.g.user.wallets[0].inkey + ) + .then(response => { + this.payLinks = response.data.map(mapPayLink) + }) + .catch(err => { + clearInterval(this.checker) + LNbits.utils.notifyApiError(err) + }) + }, + closeFormDialog() { + this.resetFormData() + }, + openQrCodeDialog(linkId) { + var link = _.findWhere(this.payLinks, {id: linkId}) + if (link.currency) this.updateFiatRate(link.currency) + + this.qrCodeDialog.data = { + id: link.id, + amount: + (link.min === link.max ? link.min : `${link.min} - ${link.max}`) + + ' ' + + (link.currency || 'sat'), + currency: link.currency, + comments: link.comment_chars + ? `${link.comment_chars} characters` + : 'no', + webhook: link.webhook_url || 'nowhere', + success: + link.success_text || link.success_url + ? 'Display message "' + + link.success_text + + '"' + + (link.success_url ? ' and URL "' + link.success_url + '"' : '') + : 'do nothing', + lnurl: link.lnurl, + pay_url: link.pay_url, + print_url: link.print_url + } + this.qrCodeDialog.show = true + }, + openUpdateDialog(linkId) { + const link = _.findWhere(this.payLinks, {id: linkId}) + if (link.currency) this.updateFiatRate(link.currency) + + this.formDialog.data = _.clone(link._data) + this.formDialog.show = true + this.formDialog.fixedAmount = + this.formDialog.data.min === this.formDialog.data.max + }, + sendFormData() { + const wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + var data = _.omit(this.formDialog.data, 'wallet') + + if (this.formDialog.fixedAmount) data.max = data.min + if (data.currency === 'satoshis') data.currency = null + if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0 + + if (data.id) { + this.updatePayLink(wallet, data) + } else { + this.createPayLink(wallet, data) + } + }, + resetFormData() { + this.formDialog = { + show: false, + fixedAmount: true, + data: {} + } + }, + updatePayLink(wallet, data) { + let values = _.omit( + _.pick( + data, + 'description', + 'min', + 'max', + 'webhook_url', + 'success_text', + 'success_url', + 'comment_chars', + 'currency' + ), + (value, key) => + (key === 'webhook_url' || + key === 'success_text' || + key === 'success_url') && + (value === null || value === '') + ) + + LNbits.api + .request( + 'PUT', + '/lnurlp/api/v1/links/' + data.id, + wallet.adminkey, + values + ) + .then(response => { + this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id) + this.payLinks.push(mapPayLink(response.data)) + this.formDialog.show = false + this.resetFormData() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + createPayLink(wallet, data) { + LNbits.api + .request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data) + .then(response => { + this.payLinks.push(mapPayLink(response.data)) + this.formDialog.show = false + this.resetFormData() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + deletePayLink(linkId) { + var link = _.findWhere(this.payLinks, {id: linkId}) + + LNbits.utils + .confirmDialog('Are you sure you want to delete this pay link?') + .onOk(() => { + LNbits.api + .request( + 'DELETE', + '/lnurlp/api/v1/links/' + linkId, + _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey + ) + .then(response => { + this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + }, + updateFiatRate(currency) { + LNbits.api + .request('GET', '/lnurlp/api/v1/rate/' + currency, null) + .then(response => { + let rates = _.clone(this.fiatRates) + rates[currency] = response.data.rate + this.fiatRates = rates + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + } + }, + created() { + if (this.g.user.wallets.length) { + var getPayLinks = this.getPayLinks + getPayLinks() + this.checker = setInterval(() => { + getPayLinks() + }, 20000) + } + LNbits.api + .request('GET', '/lnurlp/api/v1/currencies') + .then(response => { + this.currencies = ['satoshis', ...response.data] + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + } +}) diff --git a/lnbits/extensions/lnurlp/tasks.py b/lnbits/extensions/lnurlp/tasks.py new file mode 100644 index 000000000..e8d6a453f --- /dev/null +++ b/lnbits/extensions/lnurlp/tasks.py @@ -0,0 +1,61 @@ +import trio +import json +import httpx + +from lnbits.core import db as core_db +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener + +from .crud import get_pay_link + + +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 "lnurlp" != payment.extra.get("tag"): + # not an lnurlp invoice + return + + if payment.extra.get("wh_status"): + # this webhook has already been sent + return + + pay_link = await get_pay_link(payment.extra.get("link", -1)) + if pay_link and pay_link.webhook_url: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + pay_link.webhook_url, + json={ + "payment_hash": payment.payment_hash, + "payment_request": payment.bolt11, + "amount": payment.amount, + "comment": payment.extra.get("comment"), + "lnurlp": pay_link.id, + }, + timeout=40, + ) + await mark_webhook_sent(payment, r.status_code) + except (httpx.ConnectError, httpx.RequestError): + await mark_webhook_sent(payment, -1) + + +async def mark_webhook_sent(payment: Payment, status: int) -> None: + payment.extra["wh_status"] = status + + await core_db.execute( + """ + UPDATE apipayments SET extra = ? + WHERE hash = ? + """, + (json.dumps(payment.extra), payment.payment_hash), + ) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html new file mode 100644 index 000000000..d47ab1f1c --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html @@ -0,0 +1,128 @@ + + + + + GET /lnurlp/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<pay_link_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET /lnurlp/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/links/<pay_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /lnurlp/api/v1/links +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"description": <string> "amount": <integer> "max": <integer> "min": <integer> "comment_chars": <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/links -d '{"description": + <string>, "amount": <integer>, "max": <integer>, "min": <integer>, "comment_chars": <integer>}' -H "Content-type: + application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /lnurlp/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"description": <string>, "amount": <integer>} +
+ Returns 200 OK (application/json) +
+ {"lnurl": <string>} +
Curl example
+ 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 }}" + +
+
+
+ + + + DELETE + /lnurlp/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root }}api/v1/links/<pay_id> -H + "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html b/lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html new file mode 100644 index 000000000..da46d9c47 --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html @@ -0,0 +1,28 @@ + + + +

+ WARNING: LNURL must be used over https or TOR
+ LNURL is a range of lightning-network standards that allow us to use + lightning-network differently. An LNURL-pay is a link that wallets use + to fetch an invoice from a server on-demand. The link or QR code is + fixed, but each time it is read by a compatible wallet a new QR code is + issued by the service. It can be used to activate machines without them + having to maintain an electronic screen to generate and show invoices + locally, or to sell any predefined good or service automatically. +

+

+ Exploring LNURL and finding use cases, is really helping inform + lightning protocol development, rather than the protocol dictating how + lightning-network should be engaged with. +

+ Check + Awesome LNURL + for further information. +
+
+
diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/display.html b/lnbits/extensions/lnurlp/templates/lnurlp/display.html new file mode 100644 index 000000000..a2e0389ca --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/display.html @@ -0,0 +1,47 @@ +{% extends "public.html" %} {% block page %} +
+
+ + + +
+ Copy LNURL +
+
+
+
+
+ + +
LNbits LNURL-pay link
+

Use an LNURL compatible bitcoin wallet to pay.

+
+ + + {% include "lnurlp/_lnurl.html" %} + +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html new file mode 100644 index 000000000..c535f2fb4 --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -0,0 +1,312 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New pay link + + + + + +
+
+
Pay links
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} LNURL-pay extension +
+
+ + + + {% include "lnurlp/_api_docs.html" %} + + {% include "lnurlp/_lnurl.html" %} + + +
+
+ + + + + + + +
+ + +
+
+
+ +
+
+ +
+
+ + + + +
+ Update pay link + Create pay link + Cancel +
+
+
+
+ + + + {% raw %} + + + +

+ ID: {{ qrCodeDialog.data.id }}
+ Amount: {{ qrCodeDialog.data.amount }}
+ {{ qrCodeDialog.data.currency }} price: {{ + fiatRates[qrCodeDialog.data.currency] ? + fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}
+ Accepts comments: {{ qrCodeDialog.data.comments }}
+ Dispatches webhook to: {{ qrCodeDialog.data.webhook + }}
+ On success: {{ qrCodeDialog.data.success }}
+

+ {% endraw %} +
+ Copy LNURL + Shareable link + + Close +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/print_qr.html b/lnbits/extensions/lnurlp/templates/lnurlp/print_qr.html new file mode 100644 index 000000000..a6a98f4d4 --- /dev/null +++ b/lnbits/extensions/lnurlp/templates/lnurlp/print_qr.html @@ -0,0 +1,27 @@ +{% extends "print.html" %} {% block page %} +
+
+ +
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/lnurlp/views.py b/lnbits/extensions/lnurlp/views.py new file mode 100644 index 000000000..72f30c131 --- /dev/null +++ b/lnbits/extensions/lnurlp/views.py @@ -0,0 +1,32 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import lnurlp_ext +from .crud import get_pay_link + + +@lnurlp_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("lnurlp/index.html", user=g.user) + + +@lnurlp_ext.route("/") +async def display(link_id): + link = await get_pay_link(link_id) + if not link: + abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.") + + return await render_template("lnurlp/display.html", link=link) + + +@lnurlp_ext.route("/print/") +async def print_qr(link_id): + link = await get_pay_link(link_id) + if not link: + abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.") + + return await render_template("lnurlp/print_qr.html", link=link) diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py new file mode 100644 index 000000000..af670c839 --- /dev/null +++ b/lnbits/extensions/lnurlp/views_api.py @@ -0,0 +1,142 @@ +from quart import g, jsonify, request +from http import HTTPStatus +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 ( + create_pay_link, + get_pay_link, + get_pay_links, + update_pay_link, + delete_pay_link, +) + + +@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"]) +@api_check_wallet_key("invoice") +async def api_links(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + try: + return ( + 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." + } + ), + HTTPStatus.UPGRADE_REQUIRED, + ) + + +@lnurlp_ext.route("/api/v1/links/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_link_retrieve(link_id): + link = await get_pay_link(link_id) + + if not link: + 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 + + return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK + + +@lnurlp_ext.route("/api/v1/links", methods=["POST"]) +@lnurlp_ext.route("/api/v1/links/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "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", "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}, + "success_url": {"type": "string", "required": False}, + } +) +async def api_link_create_or_update(link_id=None): + if g.data["min"] > g.data["max"]: + return jsonify({"message": "Min is greater than max."}), HTTPStatus.BAD_REQUEST + + if g.data.get("currency") == None and ( + round(g.data["min"]) != g.data["min"] or round(g.data["max"]) != g.data["max"] + ): + return jsonify({"message": "Must use full satoshis."}), HTTPStatus.BAD_REQUEST + + if "success_url" in g.data and g.data["success_url"][:8] != "https://": + return ( + jsonify({"message": "Success URL must be secure https://..."}), + HTTPStatus.BAD_REQUEST, + ) + + if link_id: + link = await get_pay_link(link_id) + + if not link: + 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 + + link = await update_pay_link(link_id, **g.data) + 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, + ) + + +@lnurlp_ext.route("/api/v1/links/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_link_delete(link_id): + link = await get_pay_link(link_id) + + if not link: + 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 + + await delete_pay_link(link_id) + + return "", HTTPStatus.NO_CONTENT + + +@lnurlp_ext.route("/api/v1/rate/", methods=["GET"]) +async def api_check_fiat_rate(currency): + try: + rate = await get_fiat_rate_satoshis(currency) + except AssertionError: + rate = None + + return jsonify({"rate": rate}), HTTPStatus.OK diff --git a/lnbits/extensions/ngrok/README.md b/lnbits/extensions/ngrok/README.md new file mode 100644 index 000000000..666f95bcd --- /dev/null +++ b/lnbits/extensions/ngrok/README.md @@ -0,0 +1,20 @@ +

Ngrok

+

Serve lnbits over https for free using ngrok

+ + + +

How it works

+ +When enabled, ngrok creates a tunnel to ngrok.io with https support and tells you the https web address where you can access your lnbits instance. If you are not the first user to enable it, it doesn't create a new one, it just tells you the existing one. Useful for creating/managing/using lnurls, which must be served either via https or via tor. Note that if you restart your device, your device will generate a new url. If anyone is using your old one for wallets, lnurls, etc., whatever they are doing will stop working. + +

Installation

+ +Check the Extensions page on your instance of lnbits. If you have copy of lnbits with ngrok as one of the built in extensions, click Enable -- that's the only thing you need to do to install it. + +If your copy of lnbits does not have ngrok as one of the built in extensions, stop lnbits, create go into your lnbits folder, and run this command: ./venv/bin/pip install pyngrok. Then go into the lnbits subdirectory and the extensions subdirectory within that. (So lnbits > lnbits > extensions.) Create a new subdirectory in there called freetunnel, download this repository as a zip file, and unzip it in the freetunnel directory. If your unzipper creates a new "freetunnel" subdirectory, take everything out of there and put it in the freetunnel directory you created. Then go back to the top level lnbits directory and run these commands: + +``` +./venv/bin/quart assets +./venv/bin/quart migrate +./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()' +``` diff --git a/lnbits/extensions/ngrok/__init__.py b/lnbits/extensions/ngrok/__init__.py new file mode 100644 index 000000000..4933aa7fc --- /dev/null +++ b/lnbits/extensions/ngrok/__init__.py @@ -0,0 +1,8 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_ngrok") + +ngrok_ext: Blueprint = Blueprint("ngrok", __name__, template_folder="templates") + +from .views import * # noqa diff --git a/lnbits/extensions/ngrok/config.json.example b/lnbits/extensions/ngrok/config.json.example new file mode 100644 index 000000000..58e9ff8e2 --- /dev/null +++ b/lnbits/extensions/ngrok/config.json.example @@ -0,0 +1,6 @@ +{ + "name": "Ngrok", + "short_description": "Serve lnbits over https for free using ngrok", + "icon": "trip_origin", + "contributors": ["supertestnet"] +} diff --git a/lnbits/extensions/ngrok/migrations.py b/lnbits/extensions/ngrok/migrations.py new file mode 100644 index 000000000..f9b8b37dc --- /dev/null +++ b/lnbits/extensions/ngrok/migrations.py @@ -0,0 +1,11 @@ +# async def m001_initial(db): + +# await db.execute( +# """ +# CREATE TABLE example.example ( +# id TEXT PRIMARY KEY, +# wallet TEXT NOT NULL, +# time TIMESTAMP NOT NULL DEFAULT """ + db.timestamp_now + """ +# ); +# """ +# ) diff --git a/lnbits/extensions/ngrok/templates/ngrok/index.html b/lnbits/extensions/ngrok/templates/ngrok/index.html new file mode 100644 index 000000000..3af4fa44f --- /dev/null +++ b/lnbits/extensions/ngrok/templates/ngrok/index.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + +
+ Access this lnbits instance at the following url +
+ +

{{ ngrok }}

+
+
+
+ +
+ + +
Ngrok extension
+
+ + + +

+ Note that if you restart your device, your device will generate a + new url. If anyone is using your old one for wallets, lnurls, + etc., whatever they are doing will stop working. +

+ Created by + Supertestnet. +
+
+
+
+
+
+ +{% endblock %}{% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/ngrok/views.py b/lnbits/extensions/ngrok/views.py new file mode 100644 index 000000000..732ad7ed1 --- /dev/null +++ b/lnbits/extensions/ngrok/views.py @@ -0,0 +1,30 @@ +from quart import g, render_template + +from lnbits.decorators import check_user_exists, validate_uuids + +from pyngrok import conf, ngrok +from . import ngrok_ext +from os import getenv + + +def log_event_callback(log): + string = str(log) + string2 = string[string.find('url="https') : string.find('url="https') + 40] + if string2: + string3 = string2 + string4 = string3[4:] + global string5 + string5 = string4.replace('"', "") + + +conf.get_default().log_event_callback = log_event_callback + +port = getenv("PORT") +ngrok_tunnel = ngrok.connect(port) + + +@ngrok_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("ngrok/index.html", ngrok=string5, user=g.user) diff --git a/lnbits/extensions/offlineshop/README.md b/lnbits/extensions/offlineshop/README.md new file mode 100644 index 000000000..79bbe41d6 --- /dev/null +++ b/lnbits/extensions/offlineshop/README.md @@ -0,0 +1,36 @@ +# Offline Shop + +## Create QR codes for each product and display them on your store for receiving payments Offline + +[![video tutorial offline shop](http://img.youtube.com/vi/_XAvM_LNsoo/0.jpg)](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop') + +LNBits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device. + +Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a costumer chooses an item, scans the QR code, gets the description and price. After payment, the costumer gets a confirmation code that the merchant can validate to be sure the payment was successful. + +Costumers must use an LNURL pay capable wallet. + +[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) + +## Usage + +1. Entering the Offline shop extension you'll see an Items list, the Shop wallet and a Wordslist\ + ![offline shop back office](https://i.imgur.com/Ei7cxj9.png) +2. Begin by creating an item, click "ADD NEW ITEM" + - set the item name and a small description + - you can set an optional, preferably square image, that will show up on the costumer wallet - _depending on wallet_ + - set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time costumer scans to pay\ + ![add new item](https://i.imgur.com/pkZqRgj.png) +3. After creating some products, click on "PRINT QR CODES"\ + ![print qr codes](https://i.imgur.com/2GAiSTe.png) +4. You'll see a QR code for each product in your LNBits Offline Shop with a title and price ready for printing\ + ![qr codes sheet](https://i.imgur.com/faEqOcd.png) +5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet +6. Choose what type of confirmation do you want costumers to report to merchant after a successful payment\ + ![wordlist](https://i.imgur.com/9aM6NUL.png) + + - Wordlist is the default option: after a successful payment the costumer will receive a word from this list, **sequentially**. Starting in _albatross_ as costumers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\ + ![totp authenticator](https://i.imgur.com/MrJXFxz.png) + - TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\ + ![disable confirmations](https://i.imgur.com/2OFs4yi.png) + - Nothing, disables the need for confirmation of payment, click the "DISABLE CONFIRMATION CODES" diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py new file mode 100644 index 000000000..1f9dd1231 --- /dev/null +++ b/lnbits/extensions/offlineshop/__init__.py @@ -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 diff --git a/lnbits/extensions/offlineshop/config.json b/lnbits/extensions/offlineshop/config.json new file mode 100644 index 000000000..0dcb1d6b0 --- /dev/null +++ b/lnbits/extensions/offlineshop/config.json @@ -0,0 +1,8 @@ +{ + "name": "OfflineShop", + "short_description": "Receive payments for products offline!", + "icon": "nature_people", + "contributors": [ + "fiatjaf" + ] +} diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py new file mode 100644 index 000000000..2ee931cd7 --- /dev/null +++ b/lnbits/extensions/offlineshop/crud.py @@ -0,0 +1,113 @@ +from typing import List, Optional + +from lnbits.db import SQLITE +from . import db +from .wordlists import animals +from .models import Shop, Item + + +async def create_shop(*, wallet_id: str) -> int: + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await (method)( + f""" + INSERT INTO offlineshop.shops (wallet, wordlist, method) + VALUES (?, ?, 'wordlist') + {returning} + """, + (wallet_id, "\n".join(animals)), + ) + if db.type == SQLITE: + return result._result_proxy.lastrowid + else: + return result[0] + + +async def get_shop(id: int) -> Optional[Shop]: + row = await db.fetchone("SELECT * FROM offlineshop.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 offlineshop.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 offlineshop.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 offlineshop.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 offlineshop.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 offlineshop.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 offlineshop.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 offlineshop.items WHERE shop = ? AND id = ? + """, + (shop, item_id), + ) diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py new file mode 100644 index 000000000..db2c19cce --- /dev/null +++ b/lnbits/extensions/offlineshop/helpers.py @@ -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) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py new file mode 100644 index 000000000..d99e4ceaa --- /dev/null +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -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/", 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/", 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") or 0) + 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()) diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py new file mode 100644 index 000000000..f7c2dfec8 --- /dev/null +++ b/lnbits/extensions/offlineshop/migrations.py @@ -0,0 +1,29 @@ +async def m001_initial(db): + """ + Initial offlineshop tables. + """ + await db.execute( + f""" + CREATE TABLE offlineshop.shops ( + id {db.serial_primary_key}, + wallet TEXT NOT NULL, + method TEXT NOT NULL, + wordlist TEXT + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE offlineshop.items ( + shop INTEGER NOT NULL REFERENCES {db.references_schema}shops (id), + id {db.serial_primary_key}, + 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' + ); + """ + ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py new file mode 100644 index 000000000..8839f0ddc --- /dev/null +++ b/lnbits/extensions/offlineshop/models.py @@ -0,0 +1,120 @@ +import json +import base64 +import hashlib +from collections import OrderedDict +from quart import url_for +from typing import 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 pydantic import BaseModel +from .helpers import totp + +shop_counters: Dict = {} + + +class ShopCounter(BaseModel): + 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(BaseModel): + 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(BaseModel): + 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.", + ) diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js new file mode 100644 index 000000000..00e932416 --- /dev/null +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -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) + }) + } +}) diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html new file mode 100644 index 000000000..1e3bf0519 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -0,0 +1,147 @@ + + + +
    +
  1. Register items.
  2. +
  3. + Print QR codes and paste them on your store, your menu, somewhere, + somehow. +
  4. +
  5. + Clients scan the QR codes and get information about the items plus the + price on their phones directly (they must have internet) +
  6. +
  7. + Once they decide to pay, they'll get an invoice on their phones + automatically +
  8. +
  9. + When the payment is confirmed, a confirmation code will be issued for + them. +
  10. +
+

+ 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. +

+

+ For example, if your wordlist is + [apple, banana, coconut] the first purchase will be + apple, the second banana and so on. When it + gets to the end it starts from the beginning again. +

+

Powered by LNURL-pay.

+
+
+
+ + + + + + POST +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 201 OK
+
Curl example
+ 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">}' + +
+
+
+ + + + GET +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {"id": <integer>, "wallet": <string>, "wordlist": + <string>, "items": [{"id": <integer>, "name": + <string>, "description": <string>, "image": + <string>, "enabled": <boolean>, "price": <integer>, + "unit": <string>, "lnurl": <string>}, ...]}< +
Curl example
+ curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + PUT +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 200 OK
+
Curl example
+ 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">}' + +
+
+
+ + + + DELETE +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
Returns 200 OK
+
Curl example
+ curl -X GET {{ request.url_root + }}/offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key: + {{ g.user.wallets[0].inkey }}" + +
+
+
+
diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html new file mode 100644 index 000000000..7a3a51252 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -0,0 +1,335 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+
+
Items
+
+
+ Add new item +
+
+ {% raw %} + + + + + {% endraw %} +
+
+ + + +
+
Wallet Shop
+
+ + + + + + +
+ Print QR Codes +
+
+
+ + + + + + + + + + +
+
+ +
+
+ + Update Wordlist + + Reset +
+
+
+ +
+
+
+ + + +
+
+ + Set TOTP + +
+
+
+ +
+

+ 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. +

+ + + Disable Confirmation Codes + +
+
+
+
+ +
+ + +
+ {{SITE_TITLE}} OfflineShop extension +
+
+ + + {% include "offlineshop/_api_docs.html" %} + +
+
+ + + + +
+
Adding a new item
+ + + + + +
+ Copy LNURL +
+ + + + + + + + + + +
+
+ + {% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %} + Item + +
+
+ Cancel +
+
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/print.html b/lnbits/extensions/offlineshop/templates/offlineshop/print.html new file mode 100644 index 000000000..fff12b4c3 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/print.html @@ -0,0 +1,25 @@ +{% extends "print.html" %} {% block page %} {% raw %} +
+
+
{{ item.name }}
+ +
{{ item.price }}
+
+
+{% endraw %} {% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py new file mode 100644 index 000000000..33702f6ba --- /dev/null +++ b/lnbits/extensions/offlineshop/views.py @@ -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 = "" + + 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)}]
+{item.name}
+{item.price} {item.unit}
+{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')} + """ + + style + ) diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py new file mode 100644 index 000000000..ee3631a77 --- /dev/null +++ b/lnbits/extensions/offlineshop/views_api.py @@ -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/", 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/", 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 diff --git a/lnbits/extensions/offlineshop/wordlists.py b/lnbits/extensions/offlineshop/wordlists.py new file mode 100644 index 000000000..ee3663e34 --- /dev/null +++ b/lnbits/extensions/offlineshop/wordlists.py @@ -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", +] diff --git a/lnbits/extensions/paywall/README.md b/lnbits/extensions/paywall/README.md new file mode 100644 index 000000000..738485e28 --- /dev/null +++ b/lnbits/extensions/paywall/README.md @@ -0,0 +1,22 @@ +# Paywall + +## Hide content behind a paywall, a user has to pay some amount to access your hidden content + +A Paywall is a way of restricting to content via a purchase or paid subscription. For example to read a determined blog post, or to continue reading further, to access a downloads area, etc... + +## Usage + +1. Create a paywall by clicking "NEW PAYWALL"\ + ![create new paywall](https://i.imgur.com/q0ZIekC.png) +2. Fill the options for your PAYWALL + - select the wallet + - set the link that will be unlocked after a successful payment + - give your paywall a _Title_ + - an optional small description + - and set an amount a user must pay to access the hidden content. Note this is the minimum amount, a user can over pay if they wish + - if _Remember payments_ is checked, a returning paying user won't have to pay again for the same content.\ + ![paywall config](https://i.imgur.com/CBW48F6.png) +3. You can then use your paywall link to secure your awesome content\ + ![paywall link](https://i.imgur.com/hDQmCDf.png) +4. When a user wants to access your hidden content, he can use the minimum amount or increase and click the "_Check icon_" to generate an invoice, user will then be redirected to the content page\ + ![user paywall view](https://i.imgur.com/3pLywkZ.png) diff --git a/lnbits/extensions/paywall/__init__.py b/lnbits/extensions/paywall/__init__.py new file mode 100644 index 000000000..cf9570a15 --- /dev/null +++ b/lnbits/extensions/paywall/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_paywall") + +paywall_ext: Blueprint = Blueprint( + "paywall", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/paywall/config.json b/lnbits/extensions/paywall/config.json new file mode 100644 index 000000000..d08ce7bad --- /dev/null +++ b/lnbits/extensions/paywall/config.json @@ -0,0 +1,6 @@ +{ + "name": "Paywall", + "short_description": "Create paywalls for content", + "icon": "policy", + "contributors": ["eillarra"] +} diff --git a/lnbits/extensions/paywall/crud.py b/lnbits/extensions/paywall/crud.py new file mode 100644 index 000000000..c13aba439 --- /dev/null +++ b/lnbits/extensions/paywall/crud.py @@ -0,0 +1,53 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +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, +) -> Paywall: + paywall_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO paywall.paywalls (id, wallet, url, memo, description, amount, remembers) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (paywall_id, wallet_id, url, memo, description, amount, int(remembers)), + ) + + paywall = await get_paywall(paywall_id) + assert paywall, "Newly created paywall couldn't be retrieved" + return paywall + + +async def get_paywall(paywall_id: str) -> Optional[Paywall]: + row = await db.fetchone( + "SELECT * FROM paywall.paywalls WHERE id = ?", (paywall_id,) + ) + + return Paywall.from_row(row) if row else None + + +async def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM paywall.paywalls WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Paywall.from_row(row) for row in rows] + + +async def delete_paywall(paywall_id: str) -> None: + await db.execute("DELETE FROM paywall.paywalls WHERE id = ?", (paywall_id,)) diff --git a/lnbits/extensions/paywall/migrations.py b/lnbits/extensions/paywall/migrations.py new file mode 100644 index 000000000..8afe58b18 --- /dev/null +++ b/lnbits/extensions/paywall/migrations.py @@ -0,0 +1,66 @@ +from sqlalchemy.exc import OperationalError # type: ignore + + +async def m001_initial(db): + """ + Initial paywalls table. + """ + await db.execute( + """ + CREATE TABLE paywall.paywalls ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + secret TEXT NOT NULL, + url TEXT NOT NULL, + memo TEXT NOT NULL, + amount INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + +async def m002_redux(db): + """ + Creates an improved paywalls table and migrates the existing data. + """ + await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old") + await db.execute( + """ + CREATE TABLE paywall.paywalls ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + url TEXT NOT NULL, + memo TEXT NOT NULL, + description TEXT NULL, + amount INTEGER DEFAULT 0, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """, + remembers INTEGER DEFAULT 0, + extras TEXT NULL + ); + """ + ) + + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM paywall.paywalls_old") + ]: + await db.execute( + """ + INSERT INTO paywall.paywalls ( + id, + wallet, + url, + memo, + amount, + time + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (row[0], row[1], row[3], row[4], row[5], row[6]), + ) + + await db.execute("DROP TABLE paywall.paywalls_old") diff --git a/lnbits/extensions/paywall/models.py b/lnbits/extensions/paywall/models.py new file mode 100644 index 000000000..7b64db4c1 --- /dev/null +++ b/lnbits/extensions/paywall/models.py @@ -0,0 +1,24 @@ +import json + +from sqlite3 import Row +from pydantic import BaseModel +from typing import Optional + + +class Paywall(BaseModel): + id: str + wallet: str + url: str + memo: str + description: str + amount: int + time: int + remembers: bool + extras: Optional[dict] + + @classmethod + def from_row(cls, row: Row) -> "Paywall": + data = dict(row) + data["remembers"] = bool(data["remembers"]) + data["extras"] = json.loads(data["extras"]) if data["extras"] else None + return cls(**data) diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html new file mode 100644 index 000000000..1157fa467 --- /dev/null +++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html @@ -0,0 +1,147 @@ + + + + + GET /paywall/api/v1/paywalls +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<paywall_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /paywall/api/v1/paywalls +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"amount": <integer>, "description": <string>, "memo": + <string>, "remembers": <boolean>, "url": + <string>} +
+ Returns 201 CREATED (application/json) +
+ {"amount": <integer>, "description": <string>, "id": + <string>, "memo": <string>, "remembers": <boolean>, + "time": <int>, "url": <string>, "wallet": + <string>} +
Curl example
+ 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 }}" + +
+
+
+ + + + POST + /paywall/api/v1/paywalls/<paywall_id>/invoice +
Body (application/json)
+ {"amount": <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"payment_hash": <string>, "payment_request": + <string>} +
Curl example
+ curl -X POST {{ request.url_root + }}api/v1/paywalls/<paywall_id>/invoice -d '{"amount": + <integer>}' -H "Content-type: application/json" + +
+
+
+ + + + POST + /paywall/api/v1/paywalls/<paywall_id>/check_invoice +
Body (application/json)
+ {"payment_hash": <string>} +
+ Returns 200 OK (application/json) +
+ {"paid": false}
+ {"paid": true, "url": <string>, "remembers": + <boolean>} +
Curl example
+ curl -X POST {{ request.url_root + }}api/v1/paywalls/<paywall_id>/check_invoice -d + '{"payment_hash": <string>}' -H "Content-type: application/json" + +
+
+
+ + + + DELETE + /paywall/api/v1/paywalls/<paywall_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/paywall/templates/paywall/display.html b/lnbits/extensions/paywall/templates/paywall/display.html new file mode 100644 index 000000000..7bc7d9b88 --- /dev/null +++ b/lnbits/extensions/paywall/templates/paywall/display.html @@ -0,0 +1,162 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
{{ paywall.memo }}
+ {% if paywall.description %} +

{{ paywall.description }}

+ {% endif %} +
+ + + + + +
+ + + + + +
+ Copy invoice + Cancel +
+
+
+
+ +

+ You can access the URL behind this paywall:
+ {% raw %}{{ redirectUrl }}{% endraw %} +

+
+ Open URL +
+
+
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/paywall/templates/paywall/index.html b/lnbits/extensions/paywall/templates/paywall/index.html new file mode 100644 index 000000000..8be3b2fab --- /dev/null +++ b/lnbits/extensions/paywall/templates/paywall/index.html @@ -0,0 +1,312 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New paywall + + + + + +
+
+
Paywalls
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} paywall extension +
+
+ + + {% include "paywall/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + Remember payments + A succesful payment will be registered in the browser's + storage, so the user doesn't need to pay again to access the + URL. + + + +
+ Create paywall + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/paywall/views.py b/lnbits/extensions/paywall/views.py new file mode 100644 index 000000000..0dcbad2f5 --- /dev/null +++ b/lnbits/extensions/paywall/views.py @@ -0,0 +1,22 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import paywall_ext +from .crud import get_paywall + + +@paywall_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("paywall/index.html", user=g.user) + + +@paywall_ext.route("/") +async def display(paywall_id): + paywall = await get_paywall(paywall_id) or abort( + HTTPStatus.NOT_FOUND, "Paywall does not exist." + ) + return await render_template("paywall/display.html", paywall=paywall) diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py new file mode 100644 index 000000000..9fbda2f17 --- /dev/null +++ b/lnbits/extensions/paywall/views_api.py @@ -0,0 +1,116 @@ +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import paywall_ext +from .crud import create_paywall, get_paywall, get_paywalls, delete_paywall +from typing import Optional +from pydantic import BaseModel +from fastapi import FastAPI, Query + +@paywall_ext.route("/api/v1/paywalls", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_paywalls(): + wallet_ids = [g.wallet.id] + + 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, + ) + + +class CreateData(BaseModel): + url: Optional[str] = Query(...), + memo: Optional[str] = Query(...), + description: str = Query(None), + amount: int = Query(None), + remembers: bool = Query(None) + +@paywall_ext.route("/api/v1/paywalls", methods=["POST"]) +@api_check_wallet_key("invoice") +async def api_paywall_create(data: CreateData): + paywall = await create_paywall(wallet_id=g.wallet.id, **data) + return paywall, HTTPStatus.CREATED + + +@paywall_ext.route("/api/v1/paywalls/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_paywall_delete(paywall_id): + paywall = await get_paywall(paywall_id) + + if not paywall: + return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + + if paywall.wallet != g.wallet.id: + return jsonify({"message": "Not your paywall."}), HTTPStatus.FORBIDDEN + + await delete_paywall(paywall_id) + + return "", HTTPStatus.NO_CONTENT + + +@paywall_ext.route("/api/v1/paywalls//invoice", methods=["POST"]) +@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, + ) + + try: + 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"}, + ) + 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, + ) + + +@paywall_ext.route("/api/v1/paywalls//check_invoice", methods=["POST"]) +@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) + + if not paywall: + return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + + try: + status = await check_invoice_status(paywall.wallet, g.data["payment_hash"]) + is_paid = not status.pending + except Exception: + return jsonify({"paid": False}), HTTPStatus.OK + + if is_paid: + wallet = await get_wallet(paywall.wallet) + 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": False}), HTTPStatus.OK diff --git a/lnbits/extensions/satspay/README.md b/lnbits/extensions/satspay/README.md new file mode 100644 index 000000000..d52547aea --- /dev/null +++ b/lnbits/extensions/satspay/README.md @@ -0,0 +1,27 @@ +# SatsPay Server + +## Create onchain and LN charges. Includes webhooks! + +Easilly create invoices that support Lightning Network and on-chain BTC payment. + +1. Create a "NEW CHARGE"\ + ![new charge](https://i.imgur.com/fUl6p74.png) +2. Fill out the invoice fields + - set a descprition for the payment + - the amount in sats + - the time, in minutes, the invoice is valid for, after this period the invoice can't be payed + - set a webhook that will get the transaction details after a successful payment + - set to where the user should redirect after payment + - set the text for the button that will show after payment (not setting this, will display "NONE" in the button) + - select if you want onchain payment, LN payment or both + - depending on what you select you'll have to choose the respective wallets where to receive your payment\ + ![charge form](https://i.imgur.com/F10yRiW.png) +3. The charge will appear on the _Charges_ section\ + ![charges](https://i.imgur.com/zqHpVxc.png) +4. Your costumer/payee will get the payment page + - they can choose to pay on LN\ + ![offchain payment](https://i.imgur.com/4191SMV.png) + - or pay on chain\ + ![onchain payment](https://i.imgur.com/wzLRR5N.png) +5. You can check the state of your charges in LNBits\ + ![invoice state](https://i.imgur.com/JnBd22p.png) diff --git a/lnbits/extensions/satspay/__init__.py b/lnbits/extensions/satspay/__init__.py new file mode 100644 index 000000000..4bdaa2b63 --- /dev/null +++ b/lnbits/extensions/satspay/__init__.py @@ -0,0 +1,13 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_satspay") + + +satspay_ext: Blueprint = Blueprint( + "satspay", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/satspay/config.json b/lnbits/extensions/satspay/config.json new file mode 100644 index 000000000..b8cd185ab --- /dev/null +++ b/lnbits/extensions/satspay/config.json @@ -0,0 +1,8 @@ +{ + "name": "SatsPayServer", + "short_description": "Create onchain and LN charges", + "icon": "payment", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py new file mode 100644 index 000000000..56cabdbe3 --- /dev/null +++ b/lnbits/extensions/satspay/crud.py @@ -0,0 +1,130 @@ +from typing import List, Optional, Union + +# from lnbits.db import open_ext_db +from . import db +from .models import Charges + +from lnbits.helpers import urlsafe_short_hash + +from quart import jsonify +import httpx +from lnbits.core.services import create_invoice, check_invoice_status +from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool + + +###############CHARGES########################## + + +async def create_charge( + user: str, + description: str = None, + onchainwallet: Optional[str] = None, + lnbitswallet: Optional[str] = None, + webhook: Optional[str] = None, + completelink: Optional[str] = None, + completelinktext: Optional[str] = "Back to Merchant", + time: Optional[int] = None, + amount: Optional[int] = None, +) -> Charges: + charge_id = urlsafe_short_hash() + if onchainwallet: + wallet = await get_watch_wallet(onchainwallet) + onchain = await get_fresh_address(onchainwallet) + onchainaddress = onchain.address + else: + onchainaddress = None + if lnbitswallet: + payment_hash, payment_request = await create_invoice( + wallet_id=lnbitswallet, amount=amount, memo=charge_id + ) + else: + payment_hash = None + payment_request = None + await db.execute( + """ + INSERT INTO satspay.charges ( + id, + "user", + description, + onchainwallet, + onchainaddress, + lnbitswallet, + payment_request, + payment_hash, + webhook, + completelink, + completelinktext, + time, + amount, + balance + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + charge_id, + user, + description, + onchainwallet, + onchainaddress, + lnbitswallet, + payment_request, + payment_hash, + webhook, + completelink, + completelinktext, + time, + amount, + 0, + ), + ) + return await get_charge(charge_id) + + +async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE satspay.charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id) + ) + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None + + +async def get_charge(charge_id: str) -> Charges: + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None + + +async def get_charges(user: str) -> List[Charges]: + rows = await db.fetchall( + """SELECT * FROM satspay.charges WHERE "user" = ?""", (user,) + ) + return [Charges.from_row(row) for row in rows] + + +async def delete_charge(charge_id: str) -> None: + await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,)) + + +async def check_address_balance(charge_id: str) -> List[Charges]: + charge = await get_charge(charge_id) + if not charge.paid: + if charge.onchainaddress: + mempool = await get_mempool(charge.user) + try: + async with httpx.AsyncClient() as client: + r = await client.get( + mempool.endpoint + "/api/address/" + charge.onchainaddress + ) + respAmount = r.json()["chain_stats"]["funded_txo_sum"] + if respAmount >= charge.balance: + await update_charge(charge_id=charge_id, balance=respAmount) + except Exception: + pass + if charge.lnbitswallet: + invoice_status = await check_invoice_status( + charge.lnbitswallet, charge.payment_hash + ) + if invoice_status.paid: + return await update_charge(charge_id=charge_id, balance=charge.amount) + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py new file mode 100644 index 000000000..87446c800 --- /dev/null +++ b/lnbits/extensions/satspay/migrations.py @@ -0,0 +1,28 @@ +async def m001_initial(db): + """ + Initial wallet table. + """ + + await db.execute( + """ + CREATE TABLE satspay.charges ( + id TEXT NOT NULL PRIMARY KEY, + "user" TEXT, + description TEXT, + onchainwallet TEXT, + onchainaddress TEXT, + lnbitswallet TEXT, + payment_request TEXT, + payment_hash TEXT, + webhook TEXT, + completelink TEXT, + completelinktext TEXT, + time INTEGER, + amount INTEGER, + balance INTEGER DEFAULT 0, + timestamp TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py new file mode 100644 index 000000000..3bac7a2bb --- /dev/null +++ b/lnbits/extensions/satspay/models.py @@ -0,0 +1,39 @@ +from sqlite3 import Row +from pydantic import BaseModel +import time + + +class Charges(BaseModel): + id: str + user: str + description: str + onchainwallet: str + onchainaddress: str + lnbitswallet: str + payment_request: str + payment_hash: str + webhook: str + completelink: str + completelinktext: str + time: int + amount: int + balance: int + timestamp: int + + @classmethod + def from_row(cls, row: Row) -> "Charges": + return cls(**dict(row)) + + @property + def time_elapsed(self): + if (self.timestamp + (self.time * 60)) >= time.time(): + return False + else: + return True + + @property + def paid(self): + if self.balance >= self.amount: + return True + else: + return False diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html new file mode 100644 index 000000000..526af7f37 --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -0,0 +1,171 @@ + + +

+ SatsPayServer, create Onchain/LN charges.
WARNING: If using with the + WatchOnly extension, we highly reccomend using a fresh extended public Key + specifically for SatsPayServer!
+ + Created by, Ben Arc +

+
+ + + + + POST /satspay/api/v1/charge +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/charge -d + '{"onchainwallet": <string, watchonly_wallet_id>, + "description": <string>, "webhook":<string>, "time": + <integer>, "amount": <integer>, "lnbitswallet": + <string, lnbits_wallet_id>}' -H "Content-type: + application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/charge/<charge_id> + -d '{"onchainwallet": <string, watchonly_wallet_id>, + "description": <string>, "webhook":<string>, "time": + <integer>, "amount": <integer>, "lnbitswallet": + <string, lnbits_wallet_id>}' -H "Content-type: + application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}" + +
+
+
+ + + + + GET + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/charge/<charge_id> + -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET /satspay/api/v1/charges +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/charges -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/charge/<charge_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /satspay/api/v1/charges/balance/<charge_id> +
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html new file mode 100644 index 000000000..b3386074e --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/display.html @@ -0,0 +1,318 @@ +{% extends "public.html" %} {% block page %} +
+ +
+
+
{{ charge.description }}
+
+
+
+
Time elapsed
+
+
+
Charge paid
+
+
+ + + + Awaiting payment... + + {% raw %} {{ newTimeLeft }} {% endraw %} + + + +
+
+
+
+ Charge ID: {{ charge.id }} +
+ {% raw %} Total to pay: {{ charge_amount }}sats
+ Amount paid: {{ charge_balance }}

+ Amount due: {{ charge_amount - charge_balance }}sats {% endraw %} +
+
+ +
+
+
+ + + bitcoin onchain payment method not available + + + + pay with lightning + +
+
+ + + bitcoin lightning payment method not available + + + + pay onchain + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+ Pay this
+ lightning-network invoice
+
+ + + + + +
+ Copy invoice +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+ Send {{ charge.amount }}sats
+ to this onchain address
+
+ + + + + +
+ Copy address +
+
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + + + +{% endblock %} diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html new file mode 100644 index 000000000..f3566c7c4 --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -0,0 +1,555 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + {% raw %} + New charge + + + + + + +
+
+
Charges
+
+ +
+ + + + Export to CSV +
+
+ + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} satspay Extension +
+
+ + + {% include "satspay/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + +
+
+
+
+ +
+
+
+ +
+ +
+ + + +
+ Create Charge + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + +{% endblock %} diff --git a/lnbits/extensions/satspay/views.py b/lnbits/extensions/satspay/views.py new file mode 100644 index 000000000..2c99a9258 --- /dev/null +++ b/lnbits/extensions/satspay/views.py @@ -0,0 +1,22 @@ +from quart import g, abort, render_template, jsonify +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import satspay_ext +from .crud import get_charge + + +@satspay_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("satspay/index.html", user=g.user) + + +@satspay_ext.route("/") +async def display(charge_id): + charge = await get_charge(charge_id) or abort( + HTTPStatus.NOT_FOUND, "Charge link does not exist." + ) + return await render_template("satspay/display.html", charge=charge) diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py new file mode 100644 index 000000000..9440312ae --- /dev/null +++ b/lnbits/extensions/satspay/views_api.py @@ -0,0 +1,157 @@ +import hashlib +from quart import g, jsonify, url_for +from http import HTTPStatus +import httpx + + +from lnbits.core.crud import get_user +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from lnbits.extensions.satspay import satspay_ext +from .crud import ( + create_charge, + update_charge, + get_charge, + get_charges, + delete_charge, + check_address_balance, +) + +#############################CHARGES########################## + + +@satspay_ext.route("/api/v1/charge", methods=["POST"]) +@satspay_ext.route("/api/v1/charge/", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "onchainwallet": {"type": "string"}, + "lnbitswallet": {"type": "string"}, + "description": {"type": "string", "empty": False, "required": True}, + "webhook": {"type": "string"}, + "completelink": {"type": "string"}, + "completelinktext": {"type": "string"}, + "time": {"type": "integer", "min": 1, "required": True}, + "amount": {"type": "integer", "min": 1, "required": True}, + } +) +async def api_charge_create_or_update(charge_id=None): + if not charge_id: + charge = await create_charge(user=g.wallet.user, **g.data) + return jsonify(charge._asdict()), HTTPStatus.CREATED + else: + charge = await update_charge(charge_id=charge_id, **g.data) + return jsonify(charge._asdict()), HTTPStatus.OK + + +@satspay_ext.route("/api/v1/charges", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_charges_retrieve(): + try: + return ( + jsonify( + [ + { + **charge._asdict(), + **{"time_elapsed": charge.time_elapsed}, + **{"paid": charge.paid}, + } + for charge in await get_charges(g.wallet.user) + ] + ), + HTTPStatus.OK, + ) + except: + return "" + + +@satspay_ext.route("/api/v1/charge/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_charge_retrieve(charge_id): + charge = await get_charge(charge_id) + + if not charge: + return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND + + return ( + jsonify( + { + **charge._asdict(), + **{"time_elapsed": charge.time_elapsed}, + **{"paid": charge.paid}, + } + ), + HTTPStatus.OK, + ) + + +@satspay_ext.route("/api/v1/charge/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_charge_delete(charge_id): + charge = await get_charge(charge_id) + + if not charge: + return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND + + await delete_charge(charge_id) + + return "", HTTPStatus.NO_CONTENT + + +#############################BALANCE########################## + + +@satspay_ext.route("/api/v1/charges/balance/", methods=["GET"]) +async def api_charges_balance(charge_id): + + charge = await check_address_balance(charge_id) + + if not charge: + return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND + if charge.paid and charge.webhook: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + charge.webhook, + json={ + "id": charge.id, + "description": charge.description, + "onchainaddress": charge.onchainaddress, + "payment_request": charge.payment_request, + "payment_hash": charge.payment_hash, + "time": charge.time, + "amount": charge.amount, + "balance": charge.balance, + "paid": charge.paid, + "timestamp": charge.timestamp, + "completelink": charge.completelink, + }, + timeout=40, + ) + except AssertionError: + charge.webhook = None + return jsonify(charge._asdict()), HTTPStatus.OK + + +#############################MEMPOOL########################## + + +@satspay_ext.route("/api/v1/mempool", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "endpoint": {"type": "string", "empty": False, "required": True}, + } +) +async def api_update_mempool(): + mempool = await update_mempool(user=g.wallet.user, **g.data) + return jsonify(mempool._asdict()), HTTPStatus.OK + + +@satspay_ext.route("/api/v1/mempool", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_get_mempool(): + mempool = await get_mempool(g.wallet.user) + if not mempool: + mempool = await create_mempool(user=g.wallet.user) + return jsonify(mempool._asdict()), HTTPStatus.OK diff --git a/lnbits/extensions/splitpayments/README.md b/lnbits/extensions/splitpayments/README.md new file mode 100644 index 000000000..04576a573 --- /dev/null +++ b/lnbits/extensions/splitpayments/README.md @@ -0,0 +1,34 @@ +# Split Payments + +## Have payments split between multiple wallets + +LNBits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever. + +## Usage + +1. After enabling the extension, choose the source wallet that will receive and distribute the Payments + +![choose wallet](https://i.imgur.com/nPQudqL.png) + +2. Add the wallet or wallets info to split payments to + +![split wallets](https://i.imgur.com/5hCNWpg.png) - get the wallet id, or an invoice key from a different wallet. It can be a completely different user as long as it's under the same LNbits instance/domain. You can get the wallet information on the API Info section on every wallet page\ + ![wallet info](https://i.imgur.com/betqflC.png) - set a wallet _Alias_ for your own identification\ + +- set how much, in percentage, this wallet will receive from every payment sent to the source wallets + +3. When done, click "SAVE TARGETS" to make the splits effective + +4. You can have several wallets to split to, as long as the sum of the percentages is under or equal to 100% + +5. When the source wallet receives a payment, the extension will automatically split the corresponding values to every wallet\ + - on receiving a 20 sats payment\ + ![get 20 sats payment](https://i.imgur.com/BKp0xvy.png) + - source wallet gets 18 sats\ + ![source wallet](https://i.imgur.com/GCxDZ5s.png) + - Ben's wallet (the wallet from the example) instantly, and feeless, gets the corresponding 10%, or 2 sats\ + ![ben wallet](https://i.imgur.com/MfsccNa.png) + +## Sponsored by + +[![](https://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/) diff --git a/lnbits/extensions/splitpayments/__init__.py b/lnbits/extensions/splitpayments/__init__.py new file mode 100644 index 000000000..2cd2d7c64 --- /dev/null +++ b/lnbits/extensions/splitpayments/__init__.py @@ -0,0 +1,18 @@ +from quart import Blueprint + +from lnbits.db import Database + +db = Database("ext_splitpayments") + +splitpayments_ext: Blueprint = Blueprint( + "splitpayments", __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 + +splitpayments_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/splitpayments/config.json b/lnbits/extensions/splitpayments/config.json new file mode 100644 index 000000000..5d084c700 --- /dev/null +++ b/lnbits/extensions/splitpayments/config.json @@ -0,0 +1,9 @@ +{ + "name": "SplitPayments", + "short_description": "Split incoming payments across wallets", + "icon": "call_split", + "contributors": [ + "fiatjaf", + "cryptograffiti" + ] +} diff --git a/lnbits/extensions/splitpayments/crud.py b/lnbits/extensions/splitpayments/crud.py new file mode 100644 index 000000000..ef10add48 --- /dev/null +++ b/lnbits/extensions/splitpayments/crud.py @@ -0,0 +1,27 @@ +from typing import List + +from . import db +from .models import Target + + +async def get_targets(source_wallet: str) -> List[Target]: + rows = await db.fetchall( + "SELECT * FROM splitpayments.targets WHERE source = ?", (source_wallet,) + ) + return [Target(**dict(row)) for row in rows] + + +async def set_targets(source_wallet: str, targets: List[Target]): + async with db.connect() as conn: + await conn.execute( + "DELETE FROM splitpayments.targets WHERE source = ?", (source_wallet,) + ) + for target in targets: + await conn.execute( + """ + INSERT INTO splitpayments.targets + (source, wallet, percent, alias) + VALUES (?, ?, ?, ?) + """, + (source_wallet, target.wallet, target.percent, target.alias), + ) diff --git a/lnbits/extensions/splitpayments/migrations.py b/lnbits/extensions/splitpayments/migrations.py new file mode 100644 index 000000000..735afc6c3 --- /dev/null +++ b/lnbits/extensions/splitpayments/migrations.py @@ -0,0 +1,16 @@ +async def m001_initial(db): + """ + Initial split payment table. + """ + await db.execute( + """ + CREATE TABLE splitpayments.targets ( + wallet TEXT NOT NULL, + source TEXT NOT NULL, + percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100), + alias TEXT, + + UNIQUE (source, wallet) + ); + """ + ) diff --git a/lnbits/extensions/splitpayments/models.py b/lnbits/extensions/splitpayments/models.py new file mode 100644 index 000000000..e17000594 --- /dev/null +++ b/lnbits/extensions/splitpayments/models.py @@ -0,0 +1,9 @@ + +from pydantic import BaseModel + + +class Target(BaseModel): + wallet: str + source: str + percent: int + alias: str diff --git a/lnbits/extensions/splitpayments/static/js/index.js b/lnbits/extensions/splitpayments/static/js/index.js new file mode 100644 index 000000000..d9750bef1 --- /dev/null +++ b/lnbits/extensions/splitpayments/static/js/index.js @@ -0,0 +1,143 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +function hashTargets(targets) { + return targets + .filter(isTargetComplete) + .map(({wallet, percent, alias}) => `${wallet}${percent}${alias}`) + .join('') +} + +function isTargetComplete(target) { + return target.wallet && target.wallet.trim() !== '' && target.percent > 0 +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + selectedWallet: null, + currentHash: '', // a string that must match if the edit data is unchanged + targets: [] + } + }, + computed: { + isDirty() { + return hashTargets(this.targets) !== this.currentHash + } + }, + methods: { + clearTargets() { + this.targets = [{}] + this.$q.notify({ + message: + 'Cleared the form, but not saved. You must click to save manually.', + timeout: 500 + }) + }, + getTargets() { + LNbits.api + .request( + 'GET', + '/splitpayments/api/v1/targets', + this.selectedWallet.adminkey + ) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + .then(response => { + this.currentHash = hashTargets(response.data) + this.targets = response.data.concat({}) + }) + }, + changedWallet(wallet) { + this.selectedWallet = wallet + this.getTargets() + }, + targetChanged(isPercent, index) { + // fix percent min and max range + if (isPercent) { + if (this.targets[index].percent > 100) this.targets[index].percent = 100 + if (this.targets[index].percent < 0) this.targets[index].percent = 0 + } + + // remove empty lines (except last) + if (this.targets.length >= 2) { + for (let i = this.targets.length - 2; i >= 0; i--) { + let target = this.targets[i] + if ( + (!target.wallet || target.wallet.trim() === '') && + (!target.alias || target.alias.trim() === '') && + !target.percent + ) { + this.targets.splice(i, 1) + } + } + } + + // add a line at the end if the last one is filled + let last = this.targets[this.targets.length - 1] + if (last.wallet && last.wallet.trim() !== '' && last.percent > 0) { + this.targets.push({}) + } + + // sum of all percents + let currentTotal = this.targets.reduce( + (acc, target) => acc + (target.percent || 0), + 0 + ) + + // remove last (unfilled) line if the percent is already 100 + if (currentTotal >= 100) { + let last = this.targets[this.targets.length - 1] + if ( + (!last.wallet || last.wallet.trim() === '') && + (!last.alias || last.alias.trim() === '') && + !last.percent + ) { + this.targets = this.targets.slice(0, -1) + } + } + + // adjust percents of other lines (not this one) + if (currentTotal > 100 && isPercent) { + let diff = (currentTotal - 100) / (100 - this.targets[index].percent) + this.targets.forEach((target, t) => { + if (t !== index) target.percent -= Math.round(diff * target.percent) + }) + } + + // overwrite so changes appear + this.targets = this.targets + }, + saveTargets() { + LNbits.api + .request( + 'PUT', + '/splitpayments/api/v1/targets', + this.selectedWallet.adminkey, + { + targets: this.targets + .filter(isTargetComplete) + .map(({wallet, percent, alias}) => ({wallet, percent, alias})) + } + ) + .then(response => { + this.$q.notify({ + message: 'Split payments targets set.', + timeout: 700 + }) + this.getTargets() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + } + }, + created() { + this.selectedWallet = this.g.user.wallets[0] + this.getTargets() + } +}) diff --git a/lnbits/extensions/splitpayments/tasks.py b/lnbits/extensions/splitpayments/tasks.py new file mode 100644 index 000000000..50057d9ff --- /dev/null +++ b/lnbits/extensions/splitpayments/tasks.py @@ -0,0 +1,77 @@ +import json +import trio + +from lnbits.core.models import Payment +from lnbits.core.crud import create_payment +from lnbits.core import db as core_db +from lnbits.tasks import register_invoice_listener, internal_invoice_paid +from lnbits.helpers import urlsafe_short_hash + +from .crud import get_targets + + +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 "splitpayments" == payment.extra.get("tag") or payment.extra.get("splitted"): + # already splitted, ignore + return + + # now we make some special internal transfers (from no one to the receiver) + targets = await get_targets(payment.wallet_id) + transfers = [ + (target.wallet, int(target.percent * payment.amount / 100)) + for target in targets + ] + transfers = [(wallet, amount) for wallet, amount in transfers if amount > 0] + amount_left = payment.amount - sum([amount for _, amount in transfers]) + + if amount_left < 0: + print("splitpayments failure: amount_left is negative.", payment.payment_hash) + return + + if not targets: + return + + # mark the original payment with one extra key, "splitted" + # (this prevents us from doing this process again and it's informative) + # and reduce it by the amount we're going to send to the producer + await core_db.execute( + """ + UPDATE apipayments + SET extra = ?, amount = ? + WHERE hash = ? + AND checking_id NOT LIKE 'internal_%' + """, + ( + json.dumps(dict(**payment.extra, splitted=True)), + amount_left, + payment.payment_hash, + ), + ) + + # perform the internal transfer using the same payment_hash + for wallet, amount in transfers: + internal_checking_id = f"internal_{urlsafe_short_hash()}" + await create_payment( + wallet_id=wallet, + checking_id=internal_checking_id, + payment_request="", + payment_hash=payment.payment_hash, + amount=amount, + memo=payment.memo, + pending=False, + extra={"tag": "splitpayments"}, + ) + + # manually send this for now + await internal_invoice_paid.send(internal_checking_id) diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html new file mode 100644 index 000000000..e92fac96f --- /dev/null +++ b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html @@ -0,0 +1,90 @@ + + + +

+ Add some wallets to the list of "Target Wallets", each with an + associated percent. After saving, every time any payment + arrives at the "Source Wallet" that payment will be split with the + target wallets according to their percent. +

+

This is valid for every payment, doesn't matter how it was created.

+

Target wallets can be any wallet from this same LNbits instance.

+

+ To remove a wallet from the targets list, just erase its fields and + save. To remove all, click "Clear" then save. +

+
+
+
+ + + + + + GET + /splitpayments/api/v1/targets +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [{"wallet": <wallet id>, "alias": <chosen name for this + wallet>, "percent": <number between 1 and 100>}, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + PUT + /splitpayments/api/v1/targets +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+
Curl example
+ curl -X PUT {{ request.url_root }}api/v1/splitpayments/targets -H + "X-Api-Key: {{ g.user.wallets[0].adminkey }}" -H 'Content-Type: + application/json' -d '{"targets": [{"wallet": <wallet id or invoice + key>, "alias": <name to identify this>, "percent": <number + between 1 and 100>}, ...]}' + +
+
+
+
diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/index.html b/lnbits/extensions/splitpayments/templates/splitpayments/index.html new file mode 100644 index 000000000..1aae4e334 --- /dev/null +++ b/lnbits/extensions/splitpayments/templates/splitpayments/index.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + + + + + + + + + +
+
Target Wallets
+
+ + +
+ + + +
+ + + + + Clear + + + + + + Save Targets + + + +
+
+
+
+ +
+ + +
+ {{SITE_TITLE}} SplitPayments extension +
+
+ + + {% include "splitpayments/_api_docs.html" %} + +
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/splitpayments/views.py b/lnbits/extensions/splitpayments/views.py new file mode 100644 index 000000000..acded737e --- /dev/null +++ b/lnbits/extensions/splitpayments/views.py @@ -0,0 +1,12 @@ +from quart import g, render_template + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import splitpayments_ext + + +@splitpayments_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("splitpayments/index.html", user=g.user) diff --git a/lnbits/extensions/splitpayments/views_api.py b/lnbits/extensions/splitpayments/views_api.py new file mode 100644 index 000000000..e0fe475ed --- /dev/null +++ b/lnbits/extensions/splitpayments/views_api.py @@ -0,0 +1,70 @@ +from quart import g, jsonify +from http import HTTPStatus + +from lnbits.decorators import api_check_wallet_key, api_validate_post_request +from lnbits.core.crud import get_wallet, get_wallet_for_key + +from . import splitpayments_ext +from .crud import get_targets, set_targets +from .models import Target + + +@splitpayments_ext.route("/api/v1/targets", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_targets_get(): + targets = await get_targets(g.wallet.id) + return jsonify([target._asdict() for target in targets] or []) + + +@splitpayments_ext.route("/api/v1/targets", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "targets": { + "type": "list", + "schema": { + "type": "dict", + "schema": { + "wallet": {"type": "string"}, + "alias": {"type": "string"}, + "percent": {"type": "integer"}, + }, + }, + } + } +) +async def api_targets_set(): + targets = [] + + for entry in g.data["targets"]: + wallet = await get_wallet(entry["wallet"]) + if not wallet: + wallet = await get_wallet_for_key(entry["wallet"], "invoice") + if not wallet: + return ( + jsonify({"message": f"Invalid wallet '{entry['wallet']}'."}), + HTTPStatus.BAD_REQUEST, + ) + + if wallet.id == g.wallet.id: + return ( + jsonify({"message": "Can't split to itself."}), + HTTPStatus.BAD_REQUEST, + ) + + if entry["percent"] < 0: + return ( + jsonify({"message": f"Invalid percent '{entry['percent']}'."}), + HTTPStatus.BAD_REQUEST, + ) + + targets.append( + Target(wallet.id, g.wallet.id, entry["percent"], entry["alias"] or "") + ) + + percent_sum = sum([target.percent for target in targets]) + if percent_sum > 100: + return jsonify({"message": "Splitting over 100%."}), HTTPStatus.BAD_REQUEST + + await set_targets(g.wallet.id, targets) + return "", HTTPStatus.OK diff --git a/lnbits/extensions/streamalerts/README.md b/lnbits/extensions/streamalerts/README.md new file mode 100644 index 000000000..726ffe767 --- /dev/null +++ b/lnbits/extensions/streamalerts/README.md @@ -0,0 +1,39 @@ +

Stream Alerts

+

Integrate Bitcoin Donations into your livestream alerts

+The StreamAlerts extension allows you to integrate Bitcoin Lightning (and on-chain) paymnents in to your existing Streamlabs alerts! + +![image](https://user-images.githubusercontent.com/28876473/127759038-aceb2503-6cff-4061-8b81-c769438ebcaa.png) + +

How to set it up

+ +At the moment, the only service that has an open API to work with is Streamlabs, so this setup requires linking your Twitch/YouTube/Facebook account to Streamlabs. + +1. Log into [Streamlabs](https://streamlabs.com/login?r=https://streamlabs.com/dashboard). +1. Navigate to the API settings page to register an App: +![image](https://user-images.githubusercontent.com/28876473/127759145-710d53b6-3c19-4815-812a-9a6279d1b8bb.png) +![image](https://user-images.githubusercontent.com/28876473/127759182-da8a27cb-bb59-48fa-868e-c8892080ae98.png) +![image](https://user-images.githubusercontent.com/28876473/127759201-7c28e9f1-6286-42be-a38e-1c377a86976b.png) +1. Fill out the form with anything it will accept as valid. Most fields can be gibberish, as the application is not supposed to ever move past the "testing" stage and is for your personal use only. +In the "Whitelist Users" field, input the username of a Twitch account you control. While this feature is *technically* limited to Twitch, you can use the alerts overlay for donations on YouTube and Facebook as well. +For now, simply set the "Redirect URI" to `http://localhost`, you will change this soon. +Then, hit create: +![image](https://user-images.githubusercontent.com/28876473/127759264-ae91539a-5694-4096-a478-80eb02b7b594.png) +1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Watch Only (to accept on-chain donations) extenions: +![image](https://user-images.githubusercontent.com/28876473/127759486-0e3420c2-c498-4bf9-932e-0abfa17bd478.png) +1. Create a "NEW SERVICE" using the button. Fill in all the information (you get your Client ID and Secret from the Streamlabs App page): +![image](https://user-images.githubusercontent.com/28876473/127759512-8e8b4e90-2a64-422a-bf0a-5508d0630bed.png) +![image](https://user-images.githubusercontent.com/28876473/127759526-7f2a4980-39ea-4e58-8af0-c9fb381e5524.png) +1. Right-click and copy the "Redirect URI for Streamlabs" link (you might have to refresh the page for the text to turn into a link) and input it into the "Redirect URI" field for your Streamelements App, and hit "Save Settings": +![image](https://user-images.githubusercontent.com/28876473/127759570-52d34c07-6857-467b-bcb3-54e10679aedb.png) +![image](https://user-images.githubusercontent.com/28876473/127759604-b3c8270b-bd02-44df-a525-9d85af337d14.png) +1. You can now authenticate your app on LNbits by clicking on this button and following the instructions. Make sure to log in with the Twitch account you entered in the "Whitelist Users" field: +![image](https://user-images.githubusercontent.com/28876473/127759642-a3787a6a-3cab-4c44-a2d4-ab45fbbe3fab.png) +![image](https://user-images.githubusercontent.com/28876473/127759681-7289e7f6-0ff1-4988-944f-484040f6b9c7.png) +If everything worked correctly, you should now be redirected back to LNbits. When scrolling all the way right, you should now see that the service has been authenticated: +![image](https://user-images.githubusercontent.com/28876473/127759715-7e839261-d505-4e07-a0e4-f347f114149f.png) +You can now share the link to your donations page, which you can get here: +![image](https://user-images.githubusercontent.com/28876473/127759730-8dd11e61-0186-4935-b1ed-b66d35b05043.png) +![image](https://user-images.githubusercontent.com/28876473/127759747-67d3033f-6ef1-4033-b9b1-51b87189ff8b.png) +Of course, this has to be available publicly on the internet (or, depending on your viewers' technical ability, over Tor). +When your viewers donate to you, these donations will now show up in your Streamlabs donation feed, as well as your alerts overlay (if it is configured to include donations). +

CONGRATS! Let the sats flow!

diff --git a/lnbits/extensions/streamalerts/__init__.py b/lnbits/extensions/streamalerts/__init__.py new file mode 100644 index 000000000..72f0ae7c9 --- /dev/null +++ b/lnbits/extensions/streamalerts/__init__.py @@ -0,0 +1,11 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_streamalerts") + +streamalerts_ext: Blueprint = Blueprint( + "streamalerts", __name__, static_folder="static", template_folder="templates" +) + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/streamalerts/config.json b/lnbits/extensions/streamalerts/config.json new file mode 100644 index 000000000..2fbcc55e2 --- /dev/null +++ b/lnbits/extensions/streamalerts/config.json @@ -0,0 +1,6 @@ +{ + "name": "Stream Alerts", + "short_description": "Integrate Bitcoin donations into your stream alerts!", + "icon": "notifications_active", + "contributors": ["Fittiboy"] +} diff --git a/lnbits/extensions/streamalerts/crud.py b/lnbits/extensions/streamalerts/crud.py new file mode 100644 index 000000000..cbd9d3b04 --- /dev/null +++ b/lnbits/extensions/streamalerts/crud.py @@ -0,0 +1,297 @@ +from . import db +from .models import Donation, Service + +from ..satspay.crud import delete_charge # type: ignore + +import httpx + +from http import HTTPStatus +from quart import jsonify + +from typing import Optional + +from lnbits.db import SQLITE +from lnbits.helpers import urlsafe_short_hash +from lnbits.core.crud import get_wallet + + +async def get_service_redirect_uri(request, service_id): + """Return the service's redirect URI, to be given to the third party API""" + uri_base = request.scheme + "://" + uri_base += request.headers["Host"] + "/streamalerts/api/v1" + redirect_uri = uri_base + f"/authenticate/{service_id}" + return redirect_uri + + +async def get_charge_details(service_id): + """Return the default details for a satspay charge + + These might be different depending for services implemented in the future. + """ + details = { + "time": 1440, + } + service = await get_service(service_id) + wallet_id = service.wallet + wallet = await get_wallet(wallet_id) + user = wallet.user + details["user"] = user + details["lnbitswallet"] = wallet_id + details["onchainwallet"] = service.onchain + return details + + +async def create_donation( + id: str, + wallet: str, + cur_code: str, + sats: int, + amount: float, + service: int, + name: str = "Anonymous", + message: str = "", + posted: bool = False, +) -> Donation: + """Create a new Donation""" + await db.execute( + """ + INSERT INTO streamalerts.Donations ( + id, + wallet, + name, + message, + cur_code, + sats, + amount, + service, + posted + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (id, wallet, name, message, cur_code, sats, amount, service, posted), + ) + + donation = await get_donation(id) + assert donation, "Newly created donation couldn't be retrieved" + return donation + + +async def post_donation(donation_id: str) -> tuple: + """Post donations to their respective third party APIs + + If the donation has already been posted, it will not be posted again. + """ + donation = await get_donation(donation_id) + if not donation: + return (jsonify({"message": "Donation not found!"}), HTTPStatus.BAD_REQUEST) + if donation.posted: + return ( + jsonify({"message": "Donation has already been posted!"}), + HTTPStatus.BAD_REQUEST, + ) + service = await get_service(donation.service) + assert service, "Couldn't fetch service to donate to" + + if service.servicename == "Streamlabs": + url = "https://streamlabs.com/api/v1.0/donations" + data = { + "name": donation.name[:25], + "message": donation.message[:255], + "identifier": "LNbits", + "amount": donation.amount, + "currency": donation.cur_code.upper(), + "access_token": service.token, + } + async with httpx.AsyncClient() as client: + response = await client.post(url, data=data) + print(response.json()) + status = [s for s in list(HTTPStatus) if s == response.status_code][0] + elif service.servicename == "StreamElements": + return ( + jsonify({"message": "StreamElements not yet supported!"}), + HTTPStatus.BAD_REQUEST, + ) + else: + return (jsonify({"message": "Unsopported servicename"}), HTTPStatus.BAD_REQUEST) + await db.execute( + "UPDATE streamalerts.Donations SET posted = 1 WHERE id = ?", (donation_id,) + ) + return (jsonify(response.json()), status) + + +async def create_service( + twitchuser: str, + client_id: str, + client_secret: str, + wallet: str, + servicename: str, + state: str = None, + onchain: str = None, +) -> Service: + """Create a new Service""" + + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await (method)( + f""" + INSERT INTO streamalerts.Services ( + twitchuser, + client_id, + client_secret, + wallet, + servicename, + authenticated, + state, + onchain + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + {returning} + """, + ( + twitchuser, + client_id, + client_secret, + wallet, + servicename, + False, + urlsafe_short_hash(), + onchain, + ), + ) + if db.type == SQLITE: + service_id = result._result_proxy.lastrowid + else: + service_id = result[0] + + service = await get_service(service_id) + assert service + return service + + +async def get_service(service_id: int, by_state: str = None) -> Optional[Service]: + """Return a service either by ID or, available, by state + + Each Service's donation page is reached through its "state" hash + instead of the ID, preventing accidental payments to the wrong + streamer via typos like 2 -> 3. + """ + if by_state: + row = await db.fetchone( + "SELECT * FROM streamalerts.Services WHERE state = ?", (by_state,) + ) + else: + row = await db.fetchone( + "SELECT * FROM streamalerts.Services WHERE id = ?", (service_id,) + ) + return Service.from_row(row) if row else None + + +async def get_services(wallet_id: str) -> Optional[list]: + """Return all services belonging assigned to the wallet_id""" + rows = await db.fetchall( + "SELECT * FROM streamalerts.Services WHERE wallet = ?", (wallet_id,) + ) + return [Service.from_row(row) for row in rows] if rows else None + + +async def authenticate_service(service_id, code, redirect_uri): + """Use authentication code from third party API to retreive access token""" + # The API token is passed in the querystring as 'code' + service = await get_service(service_id) + wallet = await get_wallet(service.wallet) + user = wallet.user + url = "https://streamlabs.com/api/v1.0/token" + data = { + "grant_type": "authorization_code", + "code": code, + "client_id": service.client_id, + "client_secret": service.client_secret, + "redirect_uri": redirect_uri, + } + print(data) + async with httpx.AsyncClient() as client: + response = (await client.post(url, data=data)).json() + print(response) + token = response["access_token"] + success = await service_add_token(service_id, token) + return f"/streamalerts/?usr={user}", success + + +async def service_add_token(service_id, token): + """Add access token to its corresponding Service + + This also sets authenticated = 1 to make sure the token + is not overwritten. + Tokens for Streamlabs never need to be refreshed. + """ + if (await get_service(service_id)).authenticated: + return False + await db.execute( + "UPDATE streamalerts.Services SET authenticated = 1, token = ? where id = ?", + ( + token, + service_id, + ), + ) + return True + + +async def delete_service(service_id: int) -> None: + """Delete a Service and all corresponding Donations""" + await db.execute("DELETE FROM streamalerts.Services WHERE id = ?", (service_id,)) + rows = await db.fetchall( + "SELECT * FROM streamalerts.Donations WHERE service = ?", (service_id,) + ) + for row in rows: + await delete_donation(row["id"]) + + +async def get_donation(donation_id: str) -> Optional[Donation]: + """Return a Donation""" + row = await db.fetchone( + "SELECT * FROM streamalerts.Donations WHERE id = ?", (donation_id,) + ) + return Donation.from_row(row) if row else None + + +async def get_donations(wallet_id: str) -> Optional[list]: + """Return all streamalerts.Donations assigned to wallet_id""" + rows = await db.fetchall( + "SELECT * FROM streamalerts.Donations WHERE wallet = ?", (wallet_id,) + ) + return [Donation.from_row(row) for row in rows] if rows else None + + +async def delete_donation(donation_id: str) -> None: + """Delete a Donation and its corresponding statspay charge""" + await db.execute("DELETE FROM streamalerts.Donations WHERE id = ?", (donation_id,)) + await delete_charge(donation_id) + + +async def update_donation(donation_id: str, **kwargs) -> Donation: + """Update a Donation""" + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE streamalerts.Donations SET {q} WHERE id = ?", + (*kwargs.values(), donation_id), + ) + row = await db.fetchone( + "SELECT * FROM streamalerts.Donations WHERE id = ?", (donation_id,) + ) + assert row, "Newly updated donation couldn't be retrieved" + return Donation(**row) + + +async def update_service(service_id: str, **kwargs) -> Service: + """Update a service""" + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE streamalerts.Services SET {q} WHERE id = ?", + (*kwargs.values(), service_id), + ) + row = await db.fetchone( + "SELECT * FROM streamalerts.Services WHERE id = ?", (service_id,) + ) + assert row, "Newly updated service couldn't be retrieved" + return Service(**row) diff --git a/lnbits/extensions/streamalerts/migrations.py b/lnbits/extensions/streamalerts/migrations.py new file mode 100644 index 000000000..1b0cea375 --- /dev/null +++ b/lnbits/extensions/streamalerts/migrations.py @@ -0,0 +1,35 @@ +async def m001_initial(db): + + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS streamalerts.Services ( + id {db.serial_primary_key}, + state TEXT NOT NULL, + twitchuser TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL, + wallet TEXT NOT NULL, + onchain TEXT, + servicename TEXT NOT NULL, + authenticated BOOLEAN NOT NULL, + token TEXT + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE IF NOT EXISTS streamalerts.Donations ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + message TEXT NOT NULL, + cur_code TEXT NOT NULL, + sats INT NOT NULL, + amount FLOAT NOT NULL, + service INTEGER NOT NULL, + posted BOOLEAN NOT NULL, + FOREIGN KEY(service) REFERENCES {db.references_schema}Services(id) + ); + """ + ) diff --git a/lnbits/extensions/streamalerts/models.py b/lnbits/extensions/streamalerts/models.py new file mode 100644 index 000000000..81e47386e --- /dev/null +++ b/lnbits/extensions/streamalerts/models.py @@ -0,0 +1,45 @@ +from sqlite3 import Row +from pydantic import BaseModel +from typing import Optional + + +class Donation(BaseModel): + """A Donation simply contains all the necessary information about a + user's donation to a streamer + """ + + id: str # This ID always corresponds to a satspay charge ID + wallet: str + name: str # Name of the donor + message: str # Donation message + cur_code: str # Three letter currency code accepted by Streamlabs + sats: int + amount: float # The donation amount after fiat conversion + service: int # The ID of the corresponding Service + posted: bool # Whether the donation has already been posted to a Service + + @classmethod + def from_row(cls, row: Row) -> "Donation": + return cls(**dict(row)) + + +class Service(BaseModel): + """A Service represents an integration with a third-party API + + Currently, Streamlabs is the only supported Service. + """ + + id: int + state: str # A random hash used during authentication + twitchuser: str # The Twitch streamer's username + client_id: str # Third party service Client ID + client_secret: str # Secret corresponding to the Client ID + wallet: str + onchain: str + servicename: str # Currently, this will just always be "Streamlabs" + authenticated: bool # Whether a token (see below) has been acquired yet + token: Optional[int] # The token with which to authenticate requests + + @classmethod + def from_row(cls, row: Row) -> "Service": + return cls(**dict(row)) diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html b/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html new file mode 100644 index 000000000..33b52f150 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html @@ -0,0 +1,18 @@ + + +

+ Stream Alerts: Integrate Bitcoin into your stream alerts! +

+

+ Accept Bitcoin donations on Twitch, and integrate them into your alerts. + Present your viewers with a simple donation page, and add those donations + to Streamlabs to play alerts on your stream!
+ For detailed setup instructions, check out + this guide!
+ + Created by, Fitti +

+
+
diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/display.html b/lnbits/extensions/streamalerts/templates/streamalerts/display.html new file mode 100644 index 000000000..a10e64d88 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/display.html @@ -0,0 +1,97 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
Donate Bitcoin to {{ twitchuser }}
+
+ + + + +
+ Submit +
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/index.html b/lnbits/extensions/streamalerts/templates/streamalerts/index.html new file mode 100644 index 000000000..46d1bb313 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/index.html @@ -0,0 +1,502 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Service + + + + + +
+
+
Services
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Donations
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Stream Alerts extension +
+
+ + + {% include "streamalerts/_api_docs.html" %} + +
+
+ + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + +
+
+
+
+ +
+ + + + +
+ Update Service + + Create Service + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/streamalerts/views.py b/lnbits/extensions/streamalerts/views.py new file mode 100644 index 000000000..3e9e771d4 --- /dev/null +++ b/lnbits/extensions/streamalerts/views.py @@ -0,0 +1,28 @@ +from quart import g, abort, render_template + +from lnbits.decorators import check_user_exists, validate_uuids +from http import HTTPStatus + +from . import streamalerts_ext +from .crud import get_service + + +@streamalerts_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + """Return the extension's settings page""" + return await render_template("streamalerts/index.html", user=g.user) + + +@streamalerts_ext.route("/") +async def donation(state): + """Return the donation form for the Service corresponding to state""" + service = await get_service(0, by_state=state) + if not service: + abort(HTTPStatus.NOT_FOUND, "Service does not exist.") + return await render_template( + "streamalerts/display.html", + twitchuser=service.twitchuser, + service=service.id + ) diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py new file mode 100644 index 000000000..b914753c0 --- /dev/null +++ b/lnbits/extensions/streamalerts/views_api.py @@ -0,0 +1,273 @@ +from quart import g, redirect, request, jsonify +from http import HTTPStatus + +from lnbits.decorators import api_validate_post_request, api_check_wallet_key +from lnbits.core.crud import get_user +from lnbits.utils.exchange_rates import btc_price + +from . import streamalerts_ext +from .crud import ( + get_charge_details, + get_service_redirect_uri, + create_donation, + post_donation, + get_donation, + get_donations, + delete_donation, + create_service, + get_service, + get_services, + authenticate_service, + update_donation, + update_service, + delete_service, +) +from ..satspay.crud import create_charge, get_charge + + +@streamalerts_ext.route("/api/v1/services", methods=["POST"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "twitchuser": {"type": "string", "required": True}, + "client_id": {"type": "string", "required": True}, + "client_secret": {"type": "string", "required": True}, + "wallet": {"type": "string", "required": True}, + "servicename": {"type": "string", "required": True}, + "onchain": {"type": "string"}, + } +) +async def api_create_service(): + """Create a service, which holds data about how/where to post donations""" + try: + service = await create_service(**g.data) + except Exception as e: + return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + + return jsonify(service._asdict()), HTTPStatus.CREATED + + +@streamalerts_ext.route("/api/v1/getaccess/", methods=["GET"]) +async def api_get_access(service_id): + """Redirect to Streamlabs' Approve/Decline page for API access for Service + with service_id + """ + service = await get_service(service_id) + if service: + redirect_uri = await get_service_redirect_uri(request, service_id) + params = { + "response_type": "code", + "client_id": service.client_id, + "redirect_uri": redirect_uri, + "scope": "donations.create", + "state": service.state, + } + endpoint_url = "https://streamlabs.com/api/v1.0/authorize/?" + querystring = "&".join([f"{key}={value}" for key, value in params.items()]) + redirect_url = endpoint_url + querystring + return redirect(redirect_url) + else: + return (jsonify({"message": "Service does not exist!"}), HTTPStatus.BAD_REQUEST) + + +@streamalerts_ext.route("/api/v1/authenticate/", methods=["GET"]) +async def api_authenticate_service(service_id): + """Endpoint visited via redirect during third party API authentication + + If successful, an API access token will be added to the service, and + the user will be redirected to index.html. + """ + code = request.args.get("code") + state = request.args.get("state") + service = await get_service(service_id) + if service.state != state: + return (jsonify({"message": "State doesn't match!"}), HTTPStatus.BAD_Request) + redirect_uri = request.scheme + "://" + request.headers["Host"] + redirect_uri += f"/streamalerts/api/v1/authenticate/{service_id}" + url, success = await authenticate_service(service_id, code, redirect_uri) + if success: + return redirect(url) + else: + return ( + jsonify({"message": "Service already authenticated!"}), + HTTPStatus.BAD_REQUEST, + ) + + +@streamalerts_ext.route("/api/v1/donations", methods=["POST"]) +@api_validate_post_request( + schema={ + "name": {"type": "string"}, + "sats": {"type": "integer", "required": True}, + "service": {"type": "integer", "required": True}, + "message": {"type": "string"}, + } +) +async def api_create_donation(): + """Take data from donation form and return satspay charge""" + # Currency is hardcoded while frotnend is limited + cur_code = "USD" + sats = g.data["sats"] + message = g.data.get("message", "") + # Fiat amount is calculated here while frontend is limited + price = await btc_price(cur_code) + amount = sats * (10 ** (-8)) * price + webhook_base = request.scheme + "://" + request.headers["Host"] + service_id = g.data["service"] + service = await get_service(service_id) + charge_details = await get_charge_details(service.id) + name = g.data.get("name", "") + if not name: + name = "Anonymous" + description = f"{sats} sats donation from {name} to {service.twitchuser}" + charge = await create_charge( + amount=sats, + completelink=f"https://twitch.tv/{service.twitchuser}", + completelinktext="Back to Stream!", + webhook=webhook_base + "/streamalerts/api/v1/postdonation", + description=description, + **charge_details, + ) + await create_donation( + id=charge.id, + wallet=service.wallet, + message=message, + name=name, + cur_code=cur_code, + sats=g.data["sats"], + amount=amount, + service=g.data["service"], + ) + return (jsonify({"redirect_url": f"/satspay/{charge.id}"}), HTTPStatus.OK) + + +@streamalerts_ext.route("/api/v1/postdonation", methods=["POST"]) +@api_validate_post_request( + schema={ + "id": {"type": "string", "required": True}, + } +) +async def api_post_donation(): + """Post a paid donation to Stremalabs/StreamElements. + + This endpoint acts as a webhook for the SatsPayServer extension.""" + data = await request.get_json(force=True) + donation_id = data.get("id", "No ID") + charge = await get_charge(donation_id) + if charge and charge.paid: + return await post_donation(donation_id) + else: + return (jsonify({"message": "Not a paid charge!"}), HTTPStatus.BAD_REQUEST) + + +@streamalerts_ext.route("/api/v1/services", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_get_services(): + """Return list of all services assigned to wallet with given invoice key""" + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + services = [] + for wallet_id in wallet_ids: + new_services = await get_services(wallet_id) + services += new_services if new_services else [] + return ( + jsonify([service._asdict() for service in services] if services else []), + HTTPStatus.OK, + ) + + +@streamalerts_ext.route("/api/v1/donations", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_get_donations(): + """Return list of all donations assigned to wallet with given invoice + key + """ + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + donations = [] + for wallet_id in wallet_ids: + new_donations = await get_donations(wallet_id) + donations += new_donations if new_donations else [] + return ( + jsonify([donation._asdict() for donation in donations] if donations else []), + HTTPStatus.OK, + ) + + +@streamalerts_ext.route("/api/v1/donations/", methods=["PUT"]) +@api_check_wallet_key("invoice") +async def api_update_donation(donation_id=None): + """Update a donation with the data given in the request""" + if donation_id: + donation = await get_donation(donation_id) + + if not donation: + return ( + jsonify({"message": "Donation does not exist."}), + HTTPStatus.NOT_FOUND, + ) + + if donation.wallet != g.wallet.id: + return (jsonify({"message": "Not your donation."}), HTTPStatus.FORBIDDEN) + + donation = await update_donation(donation_id, **g.data) + else: + return ( + jsonify({"message": "No donation ID specified"}), + HTTPStatus.BAD_REQUEST, + ) + return jsonify(donation._asdict()), HTTPStatus.CREATED + + +@streamalerts_ext.route("/api/v1/services/", methods=["PUT"]) +@api_check_wallet_key("invoice") +async def api_update_service(service_id=None): + """Update a service with the data given in the request""" + if service_id: + service = await get_service(service_id) + + if not service: + return ( + jsonify({"message": "Service does not exist."}), + HTTPStatus.NOT_FOUND, + ) + + if service.wallet != g.wallet.id: + return (jsonify({"message": "Not your service."}), HTTPStatus.FORBIDDEN) + + service = await update_service(service_id, **g.data) + else: + return (jsonify({"message": "No service ID specified"}), HTTPStatus.BAD_REQUEST) + return jsonify(service._asdict()), HTTPStatus.CREATED + + +@streamalerts_ext.route("/api/v1/donations/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_delete_donation(donation_id): + """Delete the donation with the given donation_id""" + donation = await get_donation(donation_id) + if not donation: + return (jsonify({"message": "No donation with this ID!"}), HTTPStatus.NOT_FOUND) + if donation.wallet != g.wallet.id: + return ( + jsonify({"message": "Not authorized to delete this donation!"}), + HTTPStatus.FORBIDDEN, + ) + await delete_donation(donation_id) + + return "", HTTPStatus.NO_CONTENT + + +@streamalerts_ext.route("/api/v1/services/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_delete_service(service_id): + """Delete the service with the given service_id""" + service = await get_service(service_id) + if not service: + return (jsonify({"message": "No service with this ID!"}), HTTPStatus.NOT_FOUND) + if service.wallet != g.wallet.id: + return ( + jsonify({"message": "Not authorized to delete this service!"}), + HTTPStatus.FORBIDDEN, + ) + await delete_service(service_id) + + return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/subdomains/README.md b/lnbits/extensions/subdomains/README.md new file mode 100644 index 000000000..729f40f41 --- /dev/null +++ b/lnbits/extensions/subdomains/README.md @@ -0,0 +1,54 @@ +

Subdomains Extension

+ +So the goal of the extension is to allow the owner of a domain to sell subdomains to anyone who is willing to pay some money for it. + +[![video tutorial livestream](http://img.youtube.com/vi/O1X0fy3uNpw/0.jpg)](https://youtu.be/O1X0fy3uNpw 'video tutorial subdomains') + +## 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 zone-ID for your domain + +4. Get Cloudflare API TOKEN + + +5. Open the LNBits subdomains extension and register your domain +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 subdomain\ + + +## API Endpoints + +- **Domains** + - GET /api/v1/domains + - POST /api/v1/domains + - PUT /api/v1/domains/ + - DELETE /api/v1/domains/ +- **Subdomains** + - GET /api/v1/subdomains + - POST /api/v1/subdomains/ + - GET /api/v1/subdomains/ + - DELETE /api/v1/subdomains/ + +### Cloudflare + +- Cloudflare offers programmatic subdomain registration... (create new A record) +- you can keep your existing domain's registrar, you just have to transfer dns records to the cloudflare (free service) +- more information: + - https://api.cloudflare.com/#getting-started-requests + - API endpoints needed for our project: + - https://api.cloudflare.com/#dns-records-for-a-zone-list-dns-records + - https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record + - https://api.cloudflare.com/#dns-records-for-a-zone-delete-dns-record + - https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record +- api can be used by providing authorization token OR authorization key + - check API Tokens and API Keys : https://api.cloudflare.com/#getting-started-requests +- Cloudflare API postman collection: https://support.cloudflare.com/hc/en-us/articles/115002323852-Using-Cloudflare-API-with-Postman-Collections diff --git a/lnbits/extensions/subdomains/__init__.py b/lnbits/extensions/subdomains/__init__.py new file mode 100644 index 000000000..5013230c9 --- /dev/null +++ b/lnbits/extensions/subdomains/__init__.py @@ -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)) diff --git a/lnbits/extensions/subdomains/cloudflare.py b/lnbits/extensions/subdomains/cloudflare.py new file mode 100644 index 000000000..9a0fc4cfb --- /dev/null +++ b/lnbits/extensions/subdomains/cloudflare.py @@ -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" diff --git a/lnbits/extensions/subdomains/config.json b/lnbits/extensions/subdomains/config.json new file mode 100644 index 000000000..6bf9480cd --- /dev/null +++ b/lnbits/extensions/subdomains/config.json @@ -0,0 +1,6 @@ +{ + "name": "Subdomains", + "short_description": "Sell subdomains of your domain", + "icon": "domain", + "contributors": ["grmkris"] +} diff --git a/lnbits/extensions/subdomains/crud.py b/lnbits/extensions/subdomains/crud.py new file mode 100644 index 000000000..08cb19eb4 --- /dev/null +++ b/lnbits/extensions/subdomains/crud.py @@ -0,0 +1,182 @@ +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 subdomains.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 subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?", + (payment_hash,), + ) + if row[8] == False: + await db.execute( + """ + UPDATE subdomains.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 subdomains.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 subdomains.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 subdomains.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 subdomains.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 subdomains.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 subdomains.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 subdomains.domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id) + ) + row = await db.fetchone( + "SELECT * FROM subdomains.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 subdomains.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 subdomains.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 subdomains.domain WHERE id = ?", (domain_id,)) diff --git a/lnbits/extensions/subdomains/migrations.py b/lnbits/extensions/subdomains/migrations.py new file mode 100644 index 000000000..292d1f180 --- /dev/null +++ b/lnbits/extensions/subdomains/migrations.py @@ -0,0 +1,41 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE subdomains.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 """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute( + """ + CREATE TABLE subdomains.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 """ + + db.timestamp_now + + """ + ); + """ + ) diff --git a/lnbits/extensions/subdomains/models.py b/lnbits/extensions/subdomains/models.py new file mode 100644 index 000000000..0f2648d25 --- /dev/null +++ b/lnbits/extensions/subdomains/models.py @@ -0,0 +1,30 @@ + +from pydantic import BaseModel + +class Domains(BaseModel): + 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(BaseModel): + id: str + wallet: str + domain: str + domain_name: str + subdomain: str + email: str + ip: str + sats: int + duration: int + paid: bool + time: int + record_type: str diff --git a/lnbits/extensions/subdomains/tasks.py b/lnbits/extensions/subdomains/tasks.py new file mode 100644 index 000000000..b15703fb6 --- /dev/null +++ b/lnbits/extensions/subdomains/tasks.py @@ -0,0 +1,61 @@ +from http import HTTPStatus +from quart.json import jsonify +import trio +import httpx + +from .crud import get_domain, set_subdomain_paid +from lnbits.core.crud import get_user, get_wallet +from lnbits.core import db as core_db +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener +from .cloudflare import cloudflare_create_subdomain + + +async def register_listeners(): + invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2) + register_invoice_listener(invoice_paid_chan_send) + await wait_for_paid_invoices(invoice_paid_chan_recv) + + +async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): + async for payment in invoice_paid_chan: + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + if "lnsubdomain" != payment.extra.get("tag"): + # not an lnurlp invoice + return + + await payment.set_pending(False) + subdomain = await set_subdomain_paid(payment_hash=payment.payment_hash) + domain = await get_domain(subdomain.domain) + + ### Create subdomain + cf_response = cloudflare_create_subdomain( + domain=domain, + subdomain=subdomain.subdomain, + record_type=subdomain.record_type, + ip=subdomain.ip, + ) + + ### Use webhook to notify about cloudflare registration + if domain.webhook: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + domain.webhook, + json={ + "domain": subdomain.domain_name, + "subdomain": subdomain.subdomain, + "record_type": subdomain.record_type, + "email": subdomain.email, + "ip": subdomain.ip, + "cost:": str(subdomain.sats) + " sats", + "duration": str(subdomain.duration) + " days", + "cf_response": cf_response, + }, + timeout=40, + ) + except AssertionError: + webhook = None diff --git a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html new file mode 100644 index 000000000..b839c641d --- /dev/null +++ b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html @@ -0,0 +1,26 @@ + + + +
+ lnSubdomains: Get paid sats to sell your subdomains +
+

+ Charge people for using your subdomain name...
+ + More details +
+ + Created by, Kris +

+
+
+
diff --git a/lnbits/extensions/subdomains/templates/subdomains/display.html b/lnbits/extensions/subdomains/templates/subdomains/display.html new file mode 100644 index 000000000..e52ac73cf --- /dev/null +++ b/lnbits/extensions/subdomains/templates/subdomains/display.html @@ -0,0 +1,221 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +

{{ domain_domain }}

+
+
{{ domain_desc }}
+
+ + + + + + + + + +

+ Cost per day: {{ domain_cost }} sats
+ {% raw %} Total cost: {{amountSats}} sats {% endraw %} +

+
+ Submit + Cancel +
+
+
+
+
+ + + + + + +
+ Copy invoice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/subdomains/templates/subdomains/index.html b/lnbits/extensions/subdomains/templates/subdomains/index.html new file mode 100644 index 000000000..26b5d7a68 --- /dev/null +++ b/lnbits/extensions/subdomains/templates/subdomains/index.html @@ -0,0 +1,550 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + + New Domain + + + + + +
+
+
Domains
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Subdomains
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Subdomain extension +
+
+ + + {% include "subdomains/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + +
+ Update Form + Create Domain + Cancel +
+
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/subdomains/util.py b/lnbits/extensions/subdomains/util.py new file mode 100644 index 000000000..c7d663073 --- /dev/null +++ b/lnbits/extensions/subdomains/util.py @@ -0,0 +1,36 @@ +from lnbits.extensions.subdomains.models import Subdomains + +# Python3 program to validate +# domain name +# using regular expression +import re +import socket + +# Function to validate domain name. +def isValidDomain(str): + # Regex to check valid + # domain name. + regex = "^((?!-)[A-Za-z0-9-]{1,63}(?") +async def display(domain_id): + domain = await get_domain(domain_id) + if not domain: + abort(HTTPStatus.NOT_FOUND, "Domain does not exist.") + allowed_records = ( + domain.allowed_record_types.replace('"', "").replace(" ", "").split(",") + ) + print(allowed_records) + return await render_template( + "subdomains/display.html", + domain_id=domain.id, + domain_domain=domain.domain, + domain_desc=domain.description, + domain_cost=domain.cost, + domain_allowed_record_types=allowed_records, + ) diff --git a/lnbits/extensions/subdomains/views_api.py b/lnbits/extensions/subdomains/views_api.py new file mode 100644 index 000000000..c11cd4bea --- /dev/null +++ b/lnbits/extensions/subdomains/views_api.py @@ -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/", methods=["PUT"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "wallet": {"type": "string", "empty": False, "required": True}, + "domain": {"type": "string", "empty": False, "required": True}, + "cf_token": {"type": "string", "empty": False, "required": True}, + "cf_zone_id": {"type": "string", "empty": False, "required": True}, + "webhook": {"type": "string", "empty": False, "required": False}, + "description": {"type": "string", "min": 0, "required": True}, + "cost": {"type": "integer", "min": 0, "required": True}, + "allowed_record_types": {"type": "string", "required": True}, + } +) +async def api_domain_create(domain_id=None): + if domain_id: + domain = await get_domain(domain_id) + + if not domain: + return jsonify({"message": "domain does not exist."}), HTTPStatus.NOT_FOUND + + if domain.wallet != g.wallet.id: + return jsonify({"message": "Not your domain."}), HTTPStatus.FORBIDDEN + + domain = await update_domain(domain_id, **g.data) + else: + domain = await create_domain(**g.data) + return jsonify(domain._asdict()), HTTPStatus.CREATED + + +@subdomains_ext.route("/api/v1/domains/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_domain_delete(domain_id): + domain = await get_domain(domain_id) + + if not domain: + return jsonify({"message": "domain does not exist."}), HTTPStatus.NOT_FOUND + + if domain.wallet != g.wallet.id: + return jsonify({"message": "Not your domain."}), HTTPStatus.FORBIDDEN + + await delete_domain(domain_id) + + return "", HTTPStatus.NO_CONTENT + + +#########subdomains########## + + +@subdomains_ext.route("/api/v1/subdomains", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_subdomains(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return ( + jsonify([domain._asdict() for domain in await get_subdomains(wallet_ids)]), + HTTPStatus.OK, + ) + + +@subdomains_ext.route("/api/v1/subdomains/", methods=["POST"]) +@api_validate_post_request( + schema={ + "domain": {"type": "string", "empty": False, "required": True}, + "subdomain": {"type": "string", "empty": False, "required": True}, + "email": {"type": "string", "empty": True, "required": True}, + "ip": {"type": "string", "empty": False, "required": True}, + "sats": {"type": "integer", "min": 0, "required": True}, + "duration": {"type": "integer", "empty": False, "required": True}, + "record_type": {"type": "string", "empty": False, "required": True}, + } +) +async def api_subdomain_make_subdomain(domain_id): + domain = await get_domain(domain_id) + + # If the request is coming for the non-existant domain + if not domain: + return jsonify({"message": "LNsubdomain does not exist."}), HTTPStatus.NOT_FOUND + + ## If record_type is not one of the allowed ones reject the request + if g.data["record_type"] not in domain.allowed_record_types: + return ( + jsonify({"message": g.data["record_type"] + "Not a valid record"}), + HTTPStatus.BAD_REQUEST, + ) + + ## If domain already exist in our database reject it + if await get_subdomainBySubdomain(g.data["subdomain"]) is not None: + return ( + jsonify( + { + "message": g.data["subdomain"] + + "." + + domain.domain + + " domain already taken" + } + ), + HTTPStatus.BAD_REQUEST, + ) + + ## Dry run cloudflare... (create and if create is sucessful delete it) + cf_response = await cloudflare_create_subdomain( + domain=domain, + subdomain=g.data["subdomain"], + record_type=g.data["record_type"], + ip=g.data["ip"], + ) + if cf_response["success"] == True: + cloudflare_deletesubdomain(domain=domain, domain_id=cf_response["result"]["id"]) + else: + return ( + jsonify( + { + "message": "Problem with cloudflare: " + + cf_response["errors"][0]["message"] + } + ), + HTTPStatus.BAD_REQUEST, + ) + + ## ALL OK - create an invoice and return it to the user + sats = g.data["sats"] + + 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/", methods=["GET"]) +async def api_subdomain_send_subdomain(payment_hash): + subdomain = await get_subdomain(payment_hash) + try: + status = await check_invoice_status(subdomain.wallet, payment_hash) + is_paid = not status.pending + except Exception: + return jsonify({"paid": False}), HTTPStatus.OK + + if is_paid: + return jsonify({"paid": True}), HTTPStatus.OK + + return jsonify({"paid": False}), HTTPStatus.OK + + +@subdomains_ext.route("/api/v1/subdomains/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_subdomain_delete(subdomain_id): + subdomain = await get_subdomain(subdomain_id) + + if not subdomain: + return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + + if subdomain.wallet != g.wallet.id: + return jsonify({"message": "Not your subdomain."}), HTTPStatus.FORBIDDEN + + await delete_subdomain(subdomain_id) + + return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/usermanager/README.md b/lnbits/extensions/usermanager/README.md new file mode 100644 index 000000000..b6f306275 --- /dev/null +++ b/lnbits/extensions/usermanager/README.md @@ -0,0 +1,26 @@ +# User Manager + +## Make and manage users/wallets + +To help developers use LNbits to manage their users, the User Manager extension allows the creation and management of users and wallets. + +For example, a games developer may be developing a game that needs each user to have their own wallet, LNbits can be included in the developers stack as the user and wallet manager. Or someone wanting to manage their family's wallets (wife, children, parents, etc...) or you want to host a community Lightning Network node and want to manage wallets for the users. + +## Usage + +1. Click the button "NEW USER" to create a new user\ + ![new user](https://i.imgur.com/4yZyfJE.png) +2. Fill the user information\ + - username + - the generated wallet name, user can create other wallets later on + - email + - set a password + ![user information](https://i.imgur.com/40du7W5.png) +3. After creating your user, it will appear in the **Users** section, and a user's wallet in the **Wallets** section. +4. Next you can share the wallet with the corresponding user\ + ![user wallet](https://i.imgur.com/gAyajbx.png) +5. If you need to create more wallets for some user, click "NEW WALLET" at the top\ + ![multiple wallets](https://i.imgur.com/wovVnim.png) + - select the existing user you wish to add the wallet + - set a wallet name\ + ![new wallet](https://i.imgur.com/sGwG8dC.png) diff --git a/lnbits/extensions/usermanager/__init__.py b/lnbits/extensions/usermanager/__init__.py new file mode 100644 index 000000000..53154812d --- /dev/null +++ b/lnbits/extensions/usermanager/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_usermanager") + +usermanager_ext: Blueprint = Blueprint( + "usermanager", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/usermanager/config.json b/lnbits/extensions/usermanager/config.json new file mode 100644 index 000000000..7391ec299 --- /dev/null +++ b/lnbits/extensions/usermanager/config.json @@ -0,0 +1,6 @@ +{ + "name": "User Manager", + "short_description": "Generate users and wallets", + "icon": "person_add", + "contributors": ["benarc"] +} diff --git a/lnbits/extensions/usermanager/crud.py b/lnbits/extensions/usermanager/crud.py new file mode 100644 index 000000000..a7854ad8f --- /dev/null +++ b/lnbits/extensions/usermanager/crud.py @@ -0,0 +1,122 @@ +from typing import Optional, List + +from lnbits.core.models import Payment +from lnbits.core.crud import ( + create_account, + get_user, + get_payments, + create_wallet, + delete_wallet, +) + +from . import db +from .models import Users, Wallets + + +### Users + + +async def create_usermanager_user( + user_name: str, + wallet_name: str, + admin_id: str, + email: Optional[str] = None, + password: Optional[str] = None, +) -> Users: + account = await create_account() + user = await get_user(account.id) + assert user, "Newly created user couldn't be retrieved" + + wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) + + await db.execute( + """ + INSERT INTO usermanager.users (id, name, admin, email, password) + VALUES (?, ?, ?, ?, ?) + """, + (user.id, user_name, admin_id, email, password), + ) + + await db.execute( + """ + INSERT INTO usermanager.wallets (id, admin, name, "user", adminkey, inkey) + VALUES (?, ?, ?, ?, ?, ?) + """, + (wallet.id, admin_id, wallet_name, user.id, wallet.adminkey, wallet.inkey), + ) + + user_created = await get_usermanager_user(user.id) + assert user_created, "Newly created user couldn't be retrieved" + return user_created + + +async def get_usermanager_user(user_id: str) -> Optional[Users]: + row = await db.fetchone("SELECT * FROM usermanager.users WHERE id = ?", (user_id,)) + return Users(**row) if row else None + + +async def get_usermanager_users(user_id: str) -> List[Users]: + rows = await db.fetchall( + "SELECT * FROM usermanager.users WHERE admin = ?", (user_id,) + ) + return [Users(**row) for row in rows] + + +async def delete_usermanager_user(user_id: str) -> None: + wallets = await get_usermanager_wallets(user_id) + for wallet in wallets: + await delete_wallet(user_id=user_id, wallet_id=wallet.id) + + await db.execute("DELETE FROM usermanager.users WHERE id = ?", (user_id,)) + await db.execute("""DELETE FROM usermanager.wallets WHERE "user" = ?""", (user_id,)) + + +### 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( + """ + INSERT INTO usermanager.wallets (id, admin, name, "user", adminkey, inkey) + VALUES (?, ?, ?, ?, ?, ?) + """, + (wallet.id, admin_id, wallet_name, user_id, wallet.adminkey, wallet.inkey), + ) + wallet_created = await get_usermanager_wallet(wallet.id) + assert wallet_created, "Newly created wallet couldn't be retrieved" + return wallet_created + + +async def get_usermanager_wallet(wallet_id: str) -> Optional[Wallets]: + row = await db.fetchone( + "SELECT * FROM usermanager.wallets WHERE id = ?", (wallet_id,) + ) + return Wallets(**row) if row else None + + +async def get_usermanager_wallets(admin_id: str) -> Optional[Wallets]: + rows = await db.fetchall( + "SELECT * FROM usermanager.wallets WHERE admin = ?", (admin_id,) + ) + return [Wallets(**row) for row in rows] + + +async def get_usermanager_users_wallets(user_id: str) -> Optional[Wallets]: + rows = await db.fetchall( + """SELECT * FROM usermanager.wallets WHERE "user" = ?""", (user_id,) + ) + return [Wallets(**row) for row in rows] + + +async def get_usermanager_wallet_transactions(wallet_id: str) -> Optional[Payment]: + 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: + await delete_wallet(user_id=user_id, wallet_id=wallet_id) + await db.execute("DELETE FROM usermanager.wallets WHERE id = ?", (wallet_id,)) diff --git a/lnbits/extensions/usermanager/migrations.py b/lnbits/extensions/usermanager/migrations.py new file mode 100644 index 000000000..62a215752 --- /dev/null +++ b/lnbits/extensions/usermanager/migrations.py @@ -0,0 +1,31 @@ +async def m001_initial(db): + """ + Initial users table. + """ + await db.execute( + """ + CREATE TABLE usermanager.users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + admin TEXT NOT NULL, + email TEXT, + password TEXT + ); + """ + ) + + """ + Initial wallets table. + """ + await db.execute( + """ + CREATE TABLE usermanager.wallets ( + id TEXT PRIMARY KEY, + admin TEXT NOT NULL, + name TEXT NOT NULL, + "user" TEXT NOT NULL, + adminkey TEXT NOT NULL, + inkey TEXT NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/usermanager/models.py b/lnbits/extensions/usermanager/models.py new file mode 100644 index 000000000..484721197 --- /dev/null +++ b/lnbits/extensions/usermanager/models.py @@ -0,0 +1,23 @@ +from sqlite3 import Row +from pydantic import BaseModel + + +class Users(BaseModel): + id: str + name: str + admin: str + email: str + password: str + + +class Wallets(BaseModel): + id: str + admin: str + name: str + user: str + adminkey: str + inkey: str + + @classmethod + def from_row(cls, row: Row) -> "Wallets": + return cls(**dict(row)) diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html new file mode 100644 index 000000000..74640bb80 --- /dev/null +++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html @@ -0,0 +1,259 @@ + + + +
+ User Manager: Make and manager users/wallets +
+

+ To help developers use LNbits to manage their users, the User Manager + extension allows the creation and management of users and wallets. +
For example, a games developer may be developing a game that needs + each user to have their own wallet, LNbits can be included in the + develpoers stack as the user and wallet manager.
+ + Created by, Ben Arc +

+
+
+
+ + + + + GET + /usermanager/api/v1/users +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON list of users +
Curl example
+ curl -X GET {{ request.url_root }}usermanager/api/v1/users -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /usermanager/api/v1/users/<user_id> +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON list of users +
Curl example
+ curl -X GET {{ request.url_root }}usermanager/api/v1/users/<user_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /usermanager/api/v1/wallets/<user_id> +
Headers
+ {"X-Api-Key": <string>} +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON wallet data +
Curl example
+ curl -X GET {{ request.url_root }}usermanager/api/v1/wallets/<user_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /usermanager/api/v1/wallets<wallet_id> +
Headers
+ {"X-Api-Key": <string>} +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON a wallets transactions +
Curl example
+ curl -X GET {{ request.url_root }}usermanager/api/v1/wallets<wallet_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST + /usermanager/api/v1/users +
Headers
+ {"X-Api-Key": <string>, "Content-type": + "application/json"} +
+ Body (application/json) - "admin_id" is a YOUR user ID +
+ {"admin_id": <string>, "user_name": <string>, + "wallet_name": <string>,"email": <Optional string> + ,"password": <Optional string>} +
+ Returns 201 CREATED (application/json) +
+ {"id": <string>, "name": <string>, "admin": + <string>, "email": <string>, "password": + <string>} +
Curl example
+ curl -X POST {{ request.url_root }}usermanager/api/v1/users -d '{"admin_id": "{{ + g.user.id }}", "wallet_name": <string>, "user_name": + <string>, "email": <Optional string>, "password": < + Optional string>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H + "Content-type: application/json" + +
+
+
+ + + + POST + /usermanager/api/v1/wallets +
Headers
+ {"X-Api-Key": <string>, "Content-type": + "application/json"} +
+ Body (application/json) - "admin_id" is a YOUR user ID +
+ {"user_id": <string>, "wallet_name": <string>, + "admin_id": <string>} +
+ Returns 201 CREATED (application/json) +
+ {"id": <string>, "admin": <string>, "name": + <string>, "user": <string>, "adminkey": <string>, + "inkey": <string>} +
Curl example
+ 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" + +
+
+
+ + + + DELETE + /usermanager/api/v1/users/<user_id> +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X DELETE {{ request.url_root }}usermanager/api/v1/users/<user_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /usermanager/api/v1/wallets/<wallet_id> +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X DELETE {{ request.url_root }}usermanager/api/v1/wallets/<wallet_id> + -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST + /usermanager/api/v1/extensions +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X POST {{ request.url_root }}usermanager/api/v1/extensions -d '{"userid": + <string>, "extension": <string>, "active": + <integer>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H + "Content-type: application/json" + +
+
+
+
diff --git a/lnbits/extensions/usermanager/templates/usermanager/index.html b/lnbits/extensions/usermanager/templates/usermanager/index.html new file mode 100644 index 000000000..446ee51d5 --- /dev/null +++ b/lnbits/extensions/usermanager/templates/usermanager/index.html @@ -0,0 +1,473 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New User + New Wallet + + + + + + +
+
+
Users
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Wallets
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} User Manager Extension +
+
+ + + {% include "usermanager/_api_docs.html" %} + +
+
+ + + + + + + + + + Create User + Cancel + + + + + + + + + + + Create Wallet + Cancel + + + +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/usermanager/views.py b/lnbits/extensions/usermanager/views.py new file mode 100644 index 000000000..df6949c64 --- /dev/null +++ b/lnbits/extensions/usermanager/views.py @@ -0,0 +1,12 @@ +from quart import g, render_template + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import usermanager_ext + + +@usermanager_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("usermanager/index.html", user=g.user) diff --git a/lnbits/extensions/usermanager/views_api.py b/lnbits/extensions/usermanager/views_api.py new file mode 100644 index 000000000..d3bba6ad3 --- /dev/null +++ b/lnbits/extensions/usermanager/views_api.py @@ -0,0 +1,156 @@ +from quart import g, jsonify +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 usermanager_ext +from .crud import ( + create_usermanager_user, + get_usermanager_user, + get_usermanager_users, + get_usermanager_wallet_transactions, + delete_usermanager_user, + create_usermanager_wallet, + get_usermanager_wallet, + get_usermanager_wallets, + get_usermanager_users_wallets, + delete_usermanager_wallet, +) +from lnbits.core import update_user_extension + + +### Users + + +@usermanager_ext.route("/api/v1/users", methods=["GET"]) +@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, + ) + + +@usermanager_ext.route("/api/v1/users/", methods=["GET"]) +@api_check_wallet_key(key_type="invoice") +async def api_usermanager_user(user_id): + user = await get_usermanager_user(user_id) + return ( + jsonify(user._asdict()), + HTTPStatus.OK, + ) + + +@usermanager_ext.route("/api/v1/users", methods=["POST"]) +@api_check_wallet_key(key_type="invoice") +@api_validate_post_request( + schema={ + "user_name": {"type": "string", "empty": False, "required": True}, + "wallet_name": {"type": "string", "empty": False, "required": True}, + "admin_id": {"type": "string", "empty": False, "required": True}, + "email": {"type": "string", "required": False}, + "password": {"type": "string", "required": False}, + } +) +async def api_usermanager_users_create(): + user = await create_usermanager_user(**g.data) + full = user._asdict() + full["wallets"] = [wallet._asdict() for wallet in await get_usermanager_users_wallets(user.id)] + return jsonify(full), HTTPStatus.CREATED + + +@usermanager_ext.route("/api/v1/users/", methods=["DELETE"]) +@api_check_wallet_key(key_type="invoice") +async def api_usermanager_users_delete(user_id): + user = await get_usermanager_user(user_id) + if not user: + return jsonify({"message": "User does not exist."}), HTTPStatus.NOT_FOUND + await delete_usermanager_user(user_id) + return "", HTTPStatus.NO_CONTENT + + +###Activate Extension + + +@usermanager_ext.route("/api/v1/extensions", methods=["POST"]) +@api_check_wallet_key(key_type="invoice") +@api_validate_post_request( + schema={ + "extension": {"type": "string", "empty": False, "required": True}, + "userid": {"type": "string", "empty": False, "required": True}, + "active": {"type": "boolean", "required": True}, + } +) +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"] + ) + return jsonify({"extension": "updated"}), HTTPStatus.CREATED + + +###Wallets + + +@usermanager_ext.route("/api/v1/wallets", methods=["POST"]) +@api_check_wallet_key(key_type="invoice") +@api_validate_post_request( + schema={ + "user_id": {"type": "string", "empty": False, "required": True}, + "wallet_name": {"type": "string", "empty": False, "required": True}, + "admin_id": {"type": "string", "empty": False, "required": True}, + } +) +async def api_usermanager_wallets_create(): + user = await create_usermanager_wallet( + g.data["user_id"], g.data["wallet_name"], g.data["admin_id"] + ) + return jsonify(user._asdict()), HTTPStatus.CREATED + + +@usermanager_ext.route("/api/v1/wallets", methods=["GET"]) +@api_check_wallet_key(key_type="invoice") +async def api_usermanager_wallets(): + admin_id = g.wallet.user + return ( + jsonify( + [wallet._asdict() for wallet in await get_usermanager_wallets(admin_id)] + ), + HTTPStatus.OK, + ) + + +@usermanager_ext.route("/api/v1/wallets", methods=["GET"]) +@api_check_wallet_key(key_type="invoice") +async def api_usermanager_wallet_transactions(wallet_id): + return jsonify(await get_usermanager_wallet_transactions(wallet_id)), HTTPStatus.OK + + +@usermanager_ext.route("/api/v1/wallets/", methods=["GET"]) +@api_check_wallet_key(key_type="invoice") +async def api_usermanager_users_wallets(user_id): + wallet = await get_usermanager_users_wallets(user_id) + return ( + jsonify( + [ + wallet._asdict() + for wallet in await get_usermanager_users_wallets(user_id) + ] + ), + HTTPStatus.OK, + ) + + +@usermanager_ext.route("/api/v1/wallets/", methods=["DELETE"]) +@api_check_wallet_key(key_type="invoice") +async def api_usermanager_wallets_delete(wallet_id): + wallet = await get_usermanager_wallet(wallet_id) + if not wallet: + return jsonify({"message": "Wallet does not exist."}), HTTPStatus.NOT_FOUND + + await delete_usermanager_wallet(wallet_id, wallet.user) + return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md new file mode 100644 index 000000000..d93f7162d --- /dev/null +++ b/lnbits/extensions/watchonly/README.md @@ -0,0 +1,19 @@ +# Watch Only wallet + +## Monitor an onchain wallet and generate addresses for onchain payments + +Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API. + +1. Start by clicking "NEW WALLET"\ + ![new wallet](https://i.imgur.com/vgbAB7c.png) +2. Fill the requested fields: + - give the wallet a name + - paste an Extended Public Key (xpub, ypub, zpub) + - click "CREATE WATCH-ONLY WALLET"\ + ![fill wallet form](https://i.imgur.com/UVoG7LD.png) +3. You can then access your onchain addresses\ + ![get address](https://i.imgur.com/zkxTQ6l.png) +4. You can then generate bitcoin onchain adresses from LNbits\ + ![onchain address](https://i.imgur.com/4KVSSJn.png) + +You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension diff --git a/lnbits/extensions/watchonly/__init__.py b/lnbits/extensions/watchonly/__init__.py new file mode 100644 index 000000000..b8df31978 --- /dev/null +++ b/lnbits/extensions/watchonly/__init__.py @@ -0,0 +1,13 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_watchonly") + + +watchonly_ext: Blueprint = Blueprint( + "watchonly", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/watchonly/config.json b/lnbits/extensions/watchonly/config.json new file mode 100644 index 000000000..48c19ef07 --- /dev/null +++ b/lnbits/extensions/watchonly/config.json @@ -0,0 +1,8 @@ +{ + "name": "Watch Only", + "short_description": "Onchain watch only wallets", + "icon": "visibility", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/watchonly/crud.py b/lnbits/extensions/watchonly/crud.py new file mode 100644 index 000000000..bd301eb44 --- /dev/null +++ b/lnbits/extensions/watchonly/crud.py @@ -0,0 +1,212 @@ +from typing import List, Optional + +from . import db +from .models import Wallets, Addresses, Mempool + +from lnbits.helpers import urlsafe_short_hash + +from embit.descriptor import Descriptor, Key # type: ignore +from embit.descriptor.arguments import AllowedDerivation # type: ignore +from embit.networks import NETWORKS # type: ignore + + +##########################WALLETS#################### + + +def detect_network(k): + version = k.key.version + for network_name in NETWORKS: + net = NETWORKS[network_name] + # not found in this network + if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]: + return net + + +def parse_key(masterpub: str): + """Parses masterpub or descriptor and returns a tuple: (Descriptor, network) + To create addresses use descriptor.derive(num).address(network=network) + """ + network = None + # probably a single key + if "(" not in masterpub: + k = Key.from_string(masterpub) + if not k.is_extended: + raise ValueError("The key is not a master public key") + if k.is_private: + raise ValueError("Private keys are not allowed") + # check depth + if k.key.depth != 3: + raise ValueError( + "Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors." + ) + # if allowed derivation is not provided use default /{0,1}/* + if k.allowed_derivation is None: + k.allowed_derivation = AllowedDerivation.default() + # get version bytes + version = k.key.version + for network_name in NETWORKS: + net = NETWORKS[network_name] + # not found in this network + if version in [net["xpub"], net["ypub"], net["zpub"]]: + network = net + if version == net["xpub"]: + desc = Descriptor.from_string("pkh(%s)" % str(k)) + elif version == net["ypub"]: + desc = Descriptor.from_string("sh(wpkh(%s))" % str(k)) + elif version == net["zpub"]: + desc = Descriptor.from_string("wpkh(%s)" % str(k)) + break + # we didn't find correct version + if network is None: + raise ValueError("Unknown master public key version") + else: + desc = Descriptor.from_string(masterpub) + if not desc.is_wildcard: + raise ValueError("Descriptor should have wildcards") + for k in desc.keys: + if k.is_extended: + net = detect_network(k) + if net is None: + raise ValueError(f"Unknown version: {k}") + if network is not None and network != net: + raise ValueError("Keys from different networks") + network = net + return desc, network + + +async def create_watch_wallet(*, user: str, masterpub: str, title: str) -> Wallets: + # check the masterpub is fine, it will raise an exception if not + parse_key(masterpub) + wallet_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO watchonly.wallets ( + id, + "user", + masterpub, + title, + address_no, + balance + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + # address_no is -1 so fresh address on empty wallet can get address with index 0 + (wallet_id, user, masterpub, title, -1, 0), + ) + + return await get_watch_wallet(wallet_id) + + +async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]: + row = await db.fetchone( + "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) + ) + return Wallets.from_row(row) if row else None + + +async def get_watch_wallets(user: str) -> List[Wallets]: + rows = await db.fetchall( + """SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,) + ) + return [Wallets(**row) for row in rows] + + +async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"UPDATE watchonly.wallets SET {q} WHERE id = ?", (*kwargs.values(), wallet_id) + ) + row = await db.fetchone( + "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) + ) + return Wallets.from_row(row) if row else None + + +async def delete_watch_wallet(wallet_id: str) -> None: + await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,)) + + ########################ADDRESSES####################### + + +async def get_derive_address(wallet_id: str, num: int): + wallet = await get_watch_wallet(wallet_id) + key = wallet[2] + desc, network = parse_key(key) + return desc.derive(num).address(network=network) + + +async def get_fresh_address(wallet_id: str) -> Optional[Addresses]: + wallet = await get_watch_wallet(wallet_id) + if not wallet: + return None + + address = await get_derive_address(wallet_id, wallet[4] + 1) + + await update_watch_wallet(wallet_id=wallet_id, address_no=wallet[4] + 1) + masterpub_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO watchonly.addresses ( + id, + address, + wallet, + amount + ) + VALUES (?, ?, ?, ?) + """, + (masterpub_id, address, wallet_id, 0), + ) + + return await get_address(address) + + +async def get_address(address: str) -> Optional[Addresses]: + row = await db.fetchone( + "SELECT * FROM watchonly.addresses WHERE address = ?", (address,) + ) + return Addresses.from_row(row) if row else None + + +async def get_addresses(wallet_id: str) -> List[Addresses]: + rows = await db.fetchall( + "SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,) + ) + return [Addresses(**row) for row in rows] + + +######################MEMPOOL####################### + + +async def create_mempool(user: str) -> Optional[Mempool]: + await db.execute( + """ + INSERT INTO watchonly.mempool ("user",endpoint) + VALUES (?, ?) + """, + (user, "https://mempool.space"), + ) + row = await db.fetchone( + """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) + ) + return Mempool.from_row(row) if row else None + + +async def update_mempool(user: str, **kwargs) -> Optional[Mempool]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"""UPDATE watchonly.mempool SET {q} WHERE "user" = ?""", + (*kwargs.values(), user), + ) + row = await db.fetchone( + """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) + ) + return Mempool.from_row(row) if row else None + + +async def get_mempool(user: str) -> Mempool: + row = await db.fetchone( + """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) + ) + return Mempool.from_row(row) if row else None diff --git a/lnbits/extensions/watchonly/migrations.py b/lnbits/extensions/watchonly/migrations.py new file mode 100644 index 000000000..05c229b53 --- /dev/null +++ b/lnbits/extensions/watchonly/migrations.py @@ -0,0 +1,36 @@ +async def m001_initial(db): + """ + Initial wallet table. + """ + await db.execute( + """ + CREATE TABLE watchonly.wallets ( + id TEXT NOT NULL PRIMARY KEY, + "user" TEXT, + masterpub TEXT NOT NULL, + title TEXT NOT NULL, + address_no INTEGER NOT NULL DEFAULT 0, + balance INTEGER NOT NULL + ); + """ + ) + + await db.execute( + """ + CREATE TABLE watchonly.addresses ( + id TEXT NOT NULL PRIMARY KEY, + address TEXT NOT NULL, + wallet TEXT NOT NULL, + amount INTEGER NOT NULL + ); + """ + ) + + await db.execute( + """ + CREATE TABLE watchonly.mempool ( + "user" TEXT NOT NULL, + endpoint TEXT NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py new file mode 100644 index 000000000..cc63283b8 --- /dev/null +++ b/lnbits/extensions/watchonly/models.py @@ -0,0 +1,35 @@ +from sqlite3 import Row +from pydantic import BaseModel + + +class Wallets(BaseModel): + id: str + user: str + masterpub: str + title: str + address_no: int + balance: int + + @classmethod + def from_row(cls, row: Row) -> "Wallets": + return cls(**dict(row)) + + +class Mempool(BaseModel): + user: str + endpoint: str + + @classmethod + def from_row(cls, row: Row) -> "Mempool": + return cls(**dict(row)) + + +class Addresses(BaseModel): + id: str + address: str + wallet: str + amount: int + + @classmethod + def from_row(cls, row: Row) -> "Addresses": + return cls(**dict(row)) diff --git a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html new file mode 100644 index 000000000..97fdb8a90 --- /dev/null +++ b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html @@ -0,0 +1,244 @@ + + +

+ Watch Only extension uses mempool.space
+ For use with "account Extended Public Key" + https://iancoleman.io/bip39/ + +
Created by, + Ben Arc (using, + Embit
) +

+
+ + + + + + GET /watchonly/api/v1/wallet +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<wallets_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/wallet -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /watchonly/api/v1/wallet/<wallet_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 201 CREATED (application/json) +
+ [<wallet_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/wallet/<wallet_id> + -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /watchonly/api/v1/wallet +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 201 CREATED (application/json) +
+ [<wallet_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/wallet -d '{"title": + <string>, "masterpub": <string>}' -H "Content-type: + application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /watchonly/api/v1/wallet/<wallet_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/wallet/<wallet_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + + GET + /watchonly/api/v1/addresses/<wallet_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<address_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/addresses/<wallet_id> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + + GET + /watchonly/api/v1/address/<wallet_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<address_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/address/<wallet_id> + -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + + GET /watchonly/api/v1/mempool +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<mempool_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/mempool -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + + POST + /watchonly/api/v1/mempool +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 201 CREATED (application/json) +
+ [<mempool_object>, ...] +
Curl example
+ curl -X PUT {{ request.url_root }}api/v1/mempool -d '{"endpoint": + <string>}' -H "Content-type: application/json" -H "X-Api-Key: + {{ g.user.wallets[0].adminkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html new file mode 100644 index 000000000..521c99fa8 --- /dev/null +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -0,0 +1,476 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + {% raw %} + New wallet + + +
+ Point to another Mempool + {{ this.mempool.endpoint }} + + +
+ set + cancel +
+
+
+
+
+
+ + + +
+
+
Wallets
+
+
+ + + +
+
+ + + + +
+
+
+ + {% endraw %} + +
+ + +
+ {{SITE_TITLE}} Watch Only Extension +
+
+ + + {% include "watchonly/_api_docs.html" %} + +
+
+ + + + + + + + +
+ Create Watch-only Wallet + Cancel +
+
+
+
+ + + + {% raw %} +
Addresses
+
+

+ Current: + {{ currentaddress }} + +

+ + + +

+ + + + {{ data.address }} + + + + +

+ +
+ Get fresh address + Close +
+
+
+ {% endraw %} +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + +{% endblock %} diff --git a/lnbits/extensions/watchonly/views.py b/lnbits/extensions/watchonly/views.py new file mode 100644 index 000000000..e82469680 --- /dev/null +++ b/lnbits/extensions/watchonly/views.py @@ -0,0 +1,22 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import watchonly_ext + + +@watchonly_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("watchonly/index.html", user=g.user) + + +@watchonly_ext.route("/") +async def display(charge_id): + link = get_payment(charge_id) or abort( + HTTPStatus.NOT_FOUND, "Charge link does not exist." + ) + + return await render_template("watchonly/display.html", link=link) diff --git a/lnbits/extensions/watchonly/views_api.py b/lnbits/extensions/watchonly/views_api.py new file mode 100644 index 000000000..01ae25277 --- /dev/null +++ b/lnbits/extensions/watchonly/views_api.py @@ -0,0 +1,138 @@ +import hashlib +from quart import g, jsonify, url_for, request +from http import HTTPStatus +import httpx +import json + +from lnbits.core.crud import get_user +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from lnbits.extensions.watchonly import watchonly_ext +from .crud import ( + create_watch_wallet, + get_watch_wallet, + get_watch_wallets, + update_watch_wallet, + delete_watch_wallet, + get_fresh_address, + get_addresses, + create_mempool, + update_mempool, + get_mempool, +) + +###################WALLETS############################# + + +@watchonly_ext.route("/api/v1/wallet", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_wallets_retrieve(): + + try: + return ( + jsonify( + [wallet._asdict() for wallet in await get_watch_wallets(g.wallet.user)] + ), + HTTPStatus.OK, + ) + except: + return "" + + +@watchonly_ext.route("/api/v1/wallet/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_wallet_retrieve(wallet_id): + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND + + return jsonify(wallet._asdict()), HTTPStatus.OK + + +@watchonly_ext.route("/api/v1/wallet", methods=["POST"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "masterpub": {"type": "string", "empty": False, "required": True}, + "title": {"type": "string", "empty": False, "required": True}, + } +) +async def api_wallet_create_or_update(wallet_id=None): + try: + wallet = await create_watch_wallet( + user=g.wallet.user, masterpub=g.data["masterpub"], title=g.data["title"] + ) + except Exception as e: + return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST + mempool = await get_mempool(g.wallet.user) + if not mempool: + create_mempool(user=g.wallet.user) + return jsonify(wallet._asdict()), HTTPStatus.CREATED + + +@watchonly_ext.route("/api/v1/wallet/", methods=["DELETE"]) +@api_check_wallet_key("admin") +async def api_wallet_delete(wallet_id): + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND + + await delete_watch_wallet(wallet_id) + + return jsonify({"deleted": "true"}), HTTPStatus.NO_CONTENT + + +#############################ADDRESSES########################## + + +@watchonly_ext.route("/api/v1/address/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_fresh_address(wallet_id): + await get_fresh_address(wallet_id) + + addresses = await get_addresses(wallet_id) + + return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK + + +@watchonly_ext.route("/api/v1/addresses/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_get_addresses(wallet_id): + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND + + addresses = await get_addresses(wallet_id) + + if not addresses: + await get_fresh_address(wallet_id) + addresses = await get_addresses(wallet_id) + + return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK + + +#############################MEMPOOL########################## + + +@watchonly_ext.route("/api/v1/mempool", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "endpoint": {"type": "string", "empty": False, "required": True}, + } +) +async def api_update_mempool(): + mempool = await update_mempool(user=g.wallet.user, **g.data) + return jsonify(mempool._asdict()), HTTPStatus.OK + + +@watchonly_ext.route("/api/v1/mempool", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_get_mempool(): + mempool = await get_mempool(g.wallet.user) + if not mempool: + mempool = await create_mempool(user=g.wallet.user) + return jsonify(mempool._asdict()), HTTPStatus.OK diff --git a/lnbits/extensions/withdraw/README.md b/lnbits/extensions/withdraw/README.md new file mode 100644 index 000000000..0e5939fdb --- /dev/null +++ b/lnbits/extensions/withdraw/README.md @@ -0,0 +1,46 @@ +# LNURLw + +## Create a static QR code people can use to withdraw funds from a Lightning Network wallet + +LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL withdraw is the permission for someone to pull a certain amount of funds from a lightning wallet. + +The most common use case for an LNURL withdraw is a faucet, although it is a very powerful technology, with much further reaching implications. For example, an LNURL withdraw could be minted to pay for a subscription service. Or you can have a LNURLw as an offline Lightning wallet (a pre paid "card"), you use to pay for something without having to even reach your smartphone. + +LNURL withdraw is a **very powerful tool** and should not have his use limited to just faucet applications. With LNURL withdraw, you have the ability to give someone the right to spend a range, once or multiple times. **This functionality has not existed in money before**. + +[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) + +## Usage + +#### Quick Vouchers + +LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc... + +1. Create Quick Vouchers\ + ![quick vouchers](https://i.imgur.com/IUfwdQz.jpg) + - select wallet + - set the amount each voucher will allow someone to withdraw + - set the amount of vouchers you want to create - _have in mind you need to have a balance on the wallet that supports the amount \* number of vouchers_ +2. You can now print, share, display your LNURLw links or QR codes\ + ![lnurlw created](https://i.imgur.com/X00twiX.jpg) + - on details you can print the vouchers\ + ![printable vouchers](https://i.imgur.com/2xLHbob.jpg) + - every printed LNURLw QR code is unique, it can only be used once + +#### Advanced + +1. Create the Advanced LNURLw\ + ![create advanced lnurlw](https://i.imgur.com/OR0f885.jpg) + - set the wallet + - set a title for the LNURLw (it will show up in users wallet) + - define the minimum and maximum a user can withdraw, if you want a fixed amount set them both to an equal value + - set how many times can the LNURLw be scanned, if it's a one time use or it can be scanned 100 times + - LNBits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans + - you can set the time in _seconds, minutes or hours_ + - the "_Use unique withdraw QR..._" reduces the chance of your LNURL withdraw being exploited and depleted by one person, by generating a new QR code every time it's scanned +2. Print, share or display your LNURLw link or it's QR code\ + ![lnurlw created](https://i.imgur.com/X00twiX.jpg) + +**LNBits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNBits wallet! + +![](https://i.imgur.com/2zZ7mi8.jpg) diff --git a/lnbits/extensions/withdraw/__init__.py b/lnbits/extensions/withdraw/__init__.py new file mode 100644 index 000000000..69e45e4d3 --- /dev/null +++ b/lnbits/extensions/withdraw/__init__.py @@ -0,0 +1,14 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_withdraw") + + +withdraw_ext: Blueprint = Blueprint( + "withdraw", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa +from .lnurl import * # noqa diff --git a/lnbits/extensions/withdraw/config.json b/lnbits/extensions/withdraw/config.json new file mode 100644 index 000000000..de82e7f1a --- /dev/null +++ b/lnbits/extensions/withdraw/config.json @@ -0,0 +1,6 @@ +{ + "name": "LNURLw", + "short_description": "Make LNURL withdraw links", + "icon": "crop_free", + "contributors": ["arcbtc", "eillarra"] +} diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py new file mode 100644 index 000000000..14178a0c2 --- /dev/null +++ b/lnbits/extensions/withdraw/crud.py @@ -0,0 +1,159 @@ +from datetime import datetime +from typing import List, Optional, Union +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import WithdrawLink, HashCheck + + +async def create_withdraw_link( + *, + wallet_id: str, + title: str, + min_withdrawable: int, + max_withdrawable: int, + uses: int, + wait_time: int, + is_unique: bool, + usescsv: str, +) -> WithdrawLink: + link_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO withdraw.withdraw_link ( + id, + wallet, + title, + min_withdrawable, + max_withdrawable, + uses, + wait_time, + is_unique, + unique_hash, + k1, + open_time, + usescsv + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + link_id, + wallet_id, + title, + min_withdrawable, + max_withdrawable, + uses, + wait_time, + int(is_unique), + urlsafe_short_hash(), + urlsafe_short_hash(), + int(datetime.now().timestamp()) + wait_time, + usescsv, + ), + ) + link = await get_withdraw_link(link_id, 0) + assert link, "Newly created link couldn't be retrieved" + return link + + +async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]: + row = await db.fetchone( + "SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,) + ) + if not row: + return None + + link = [] + for item in row: + link.append(item) + link.append(num) + return WithdrawLink._make(link) + + +async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]: + row = await db.fetchone( + "SELECT * FROM withdraw.withdraw_link WHERE unique_hash = ?", (unique_hash,) + ) + if not row: + return None + + link = [] + for item in row: + link.append(item) + link.append(num) + return WithdrawLink._make(link) + + +async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[WithdrawLink]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM withdraw.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]: + if "is_unique" in kwargs: + kwargs["is_unique"] = int(kwargs["is_unique"]) + + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE withdraw.withdraw_link SET {q} WHERE id = ?", + (*kwargs.values(), link_id), + ) + row = await db.fetchone( + "SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,) + ) + return WithdrawLink.from_row(row) if row else None + + +async def delete_withdraw_link(link_id: str) -> None: + await db.execute("DELETE FROM withdraw.withdraw_link WHERE id = ?", (link_id,)) + + +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 withdraw.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 withdraw.hash_check WHERE id = ?", (the_hash,) + ) + rowlnurl = await db.fetchone( + "SELECT * FROM withdraw.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} diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py new file mode 100644 index 000000000..1322b6e25 --- /dev/null +++ b/lnbits/extensions/withdraw/lnurl.py @@ -0,0 +1,139 @@ +import shortuuid # type: ignore +from http import HTTPStatus +from datetime import datetime +from quart import jsonify, request + +from lnbits.core.services import pay_invoice + +from . import withdraw_ext +from .crud import get_withdraw_link_by_hash, update_withdraw_link + + +# FOR LNURLs WHICH ARE NOT UNIQUE + + +@withdraw_ext.route("/api/v1/lnurl/", methods=["GET"]) +async def api_lnurl_response(unique_hash): + link = await get_withdraw_link_by_hash(unique_hash) + + if not link: + return ( + jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), + HTTPStatus.OK, + ) + + if link.is_spent: + return ( + jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), + HTTPStatus.OK, + ) + + return jsonify(link.lnurl_response.dict()), HTTPStatus.OK + + +# FOR LNURLs WHICH ARE UNIQUE + + +@withdraw_ext.route("/api/v1/lnurl//", methods=["GET"]) +async def api_lnurl_multi_response(unique_hash, id_unique_hash): + link = await get_withdraw_link_by_hash(unique_hash) + + if not link: + return ( + jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), + HTTPStatus.OK, + ) + + if link.is_spent: + return ( + jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), + HTTPStatus.OK, + ) + + useslist = link.usescsv.split(",") + found = False + for x in useslist: + tohash = link.id + link.unique_hash + str(x) + if id_unique_hash == shortuuid.uuid(name=tohash): + found = True + if not found: + return ( + jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), + HTTPStatus.OK, + ) + + return jsonify(link.lnurl_response.dict()), HTTPStatus.OK + + +# CALLBACK + + +@withdraw_ext.route("/api/v1/lnurl/cb/", methods=["GET"]) +async def api_lnurl_callback(unique_hash): + link = await get_withdraw_link_by_hash(unique_hash) + k1 = request.args.get("k1", type=str) + payment_request = request.args.get("pr", type=str) + now = int(datetime.now().timestamp()) + + if not link: + return ( + jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), + HTTPStatus.OK, + ) + + if link.is_spent: + return ( + jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), + HTTPStatus.OK, + ) + + if link.k1 != k1: + return jsonify({"status": "ERROR", "reason": "Bad request."}), HTTPStatus.OK + + if now < link.open_time: + return ( + jsonify( + {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."} + ), + HTTPStatus.OK, + ) + + try: + usescsv = "" + for x in range(1, link.uses - link.used): + usecv = link.usescsv.split(",") + usescsv += "," + str(usecv[x]) + usecsvback = usescsv + usescsv = usescsv[1:] + + changesback = { + "open_time": link.wait_time, + "used": link.used, + "usescsv": usecsvback, + } + + changes = { + "open_time": link.wait_time + now, + "used": link.used + 1, + "usescsv": usescsv, + } + + await update_withdraw_link(link.id, **changes) + + await pay_invoice( + wallet_id=link.wallet, + payment_request=payment_request, + max_sat=link.max_withdrawable, + extra={"tag": "withdraw"}, + ) + except ValueError as e: + await update_withdraw_link(link.id, **changesback) + return jsonify({"status": "ERROR", "reason": str(e)}) + except PermissionError: + await update_withdraw_link(link.id, **changesback) + return jsonify({"status": "ERROR", "reason": "Withdraw link is empty."}) + except Exception as e: + await update_withdraw_link(link.id, **changesback) + return jsonify({"status": "ERROR", "reason": str(e)}) + + return jsonify({"status": "OK"}), HTTPStatus.OK diff --git a/lnbits/extensions/withdraw/migrations.py b/lnbits/extensions/withdraw/migrations.py new file mode 100644 index 000000000..1a13aa6d5 --- /dev/null +++ b/lnbits/extensions/withdraw/migrations.py @@ -0,0 +1,110 @@ +async def m001_initial(db): + """ + Creates an improved withdraw table and migrates the existing data. + """ + await db.execute( + """ + CREATE TABLE withdraw.withdraw_links ( + id TEXT PRIMARY KEY, + wallet TEXT, + title TEXT, + min_withdrawable INTEGER DEFAULT 1, + max_withdrawable INTEGER DEFAULT 1, + uses INTEGER DEFAULT 1, + wait_time INTEGER, + is_unique INTEGER DEFAULT 0, + unique_hash TEXT UNIQUE, + k1 TEXT, + open_time INTEGER, + used INTEGER DEFAULT 0, + usescsv TEXT + ); + """ + ) + + +async def m002_change_withdraw_table(db): + """ + Creates an improved withdraw table and migrates the existing data. + """ + await db.execute( + """ + CREATE TABLE withdraw.withdraw_link ( + id TEXT PRIMARY KEY, + wallet TEXT, + title TEXT, + min_withdrawable INTEGER DEFAULT 1, + max_withdrawable INTEGER DEFAULT 1, + uses INTEGER DEFAULT 1, + wait_time INTEGER, + is_unique INTEGER DEFAULT 0, + unique_hash TEXT UNIQUE, + k1 TEXT, + open_time INTEGER, + used INTEGER DEFAULT 0, + usescsv TEXT + ); + """ + ) + + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM withdraw.withdraw_links") + ]: + usescsv = "" + + for i in range(row[5]): + if row[7]: + usescsv += "," + str(i + 1) + else: + usescsv += "," + str(1) + usescsv = usescsv[1:] + await db.execute( + """ + INSERT INTO withdraw.withdraw_link ( + id, + wallet, + title, + min_withdrawable, + max_withdrawable, + uses, + wait_time, + is_unique, + unique_hash, + k1, + open_time, + used, + usescsv + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row[0], + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + row[7], + row[8], + row[9], + row[10], + row[11], + usescsv, + ), + ) + await db.execute("DROP TABLE withdraw.withdraw_links") + + +async def m003_make_hash_check(db): + """ + Creates a hash check table. + """ + await db.execute( + """ + CREATE TABLE withdraw.hash_check ( + id TEXT PRIMARY KEY, + lnurl_id TEXT + ); + """ + ) diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py new file mode 100644 index 000000000..da32ee7d0 --- /dev/null +++ b/lnbits/extensions/withdraw/models.py @@ -0,0 +1,76 @@ +from quart import url_for +from lnurl import Lnurl, LnurlWithdrawResponse, encode as lnurl_encode # type: ignore +from sqlite3 import Row +from pydantic import BaseModel +import shortuuid # type: ignore + + +class WithdrawLink(BaseModel): + id: str + wallet: str + title: str + min_withdrawable: int + max_withdrawable: int + uses: int + wait_time: int + is_unique: bool + unique_hash: str + k1: str + open_time: int + used: int + usescsv: str + number: int + + @classmethod + def from_row(cls, row: Row) -> "WithdrawLink": + data = dict(row) + data["is_unique"] = bool(data["is_unique"]) + data["number"] = 0 + return cls(**data) + + @property + def is_spent(self) -> bool: + return self.used >= self.uses + + @property + def lnurl(self) -> Lnurl: + if self.is_unique: + usescssv = self.usescsv.split(",") + tohash = self.id + self.unique_hash + usescssv[self.number] + multihash = shortuuid.uuid(name=tohash) + url = url_for( + "withdraw.api_lnurl_multi_response", + unique_hash=self.unique_hash, + id_unique_hash=multihash, + _external=True, + ) + else: + url = url_for( + "withdraw.api_lnurl_response", + unique_hash=self.unique_hash, + _external=True, + ) + + return lnurl_encode(url) + + @property + def lnurl_response(self) -> LnurlWithdrawResponse: + url = url_for( + "withdraw.api_lnurl_callback", unique_hash=self.unique_hash, _external=True + ) + return LnurlWithdrawResponse( + callback=url, + k1=self.k1, + min_withdrawable=self.min_withdrawable * 1000, + max_withdrawable=self.max_withdrawable * 1000, + default_description=self.title, + ) + + +class HashCheck(BaseModel): + id: str + lnurl_id: str + + @classmethod + def from_row(cls, row: Row) -> "Hash": + return cls(**dict(row)) diff --git a/lnbits/extensions/withdraw/static/js/index.js b/lnbits/extensions/withdraw/static/js/index.js new file mode 100644 index 000000000..2237d52be --- /dev/null +++ b/lnbits/extensions/withdraw/static/js/index.js @@ -0,0 +1,246 @@ +/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */ + +Vue.component(VueQrcode.name, VueQrcode) + +var locationPath = [ + window.location.protocol, + '//', + window.location.host, + window.location.pathname +].join('') + +var mapWithdrawLink = function (obj) { + obj._data = _.clone(obj) + obj.date = Quasar.utils.date.formatDate( + new Date(obj.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + obj.min_fsat = new Intl.NumberFormat(LOCALE).format(obj.min_withdrawable) + obj.max_fsat = new Intl.NumberFormat(LOCALE).format(obj.max_withdrawable) + obj.uses_left = obj.uses - obj.used + obj.print_url = [locationPath, 'print/', obj.id].join('') + obj.withdraw_url = [locationPath, obj.id].join('') + return obj +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + checker: null, + withdrawLinks: [], + withdrawLinksTable: { + columns: [ + {name: 'id', align: 'left', label: 'ID', field: 'id'}, + {name: 'title', align: 'left', label: 'Title', field: 'title'}, + { + name: 'wait_time', + align: 'right', + label: 'Wait', + field: 'wait_time' + }, + { + name: 'uses_left', + align: 'right', + label: 'Uses left', + field: 'uses_left' + }, + {name: 'min', align: 'right', label: 'Min (sat)', field: 'min_fsat'}, + {name: 'max', align: 'right', label: 'Max (sat)', field: 'max_fsat'} + ], + pagination: { + rowsPerPage: 10 + } + }, + formDialog: { + show: false, + secondMultiplier: 'seconds', + secondMultiplierOptions: ['seconds', 'minutes', 'hours'], + data: { + is_unique: false + } + }, + simpleformDialog: { + show: false, + data: { + is_unique: true, + title: 'Vouchers', + min_withdrawable: 0, + wait_time: 1 + } + }, + qrCodeDialog: { + show: false, + data: null + } + } + }, + computed: { + sortedWithdrawLinks: function () { + return this.withdrawLinks.sort(function (a, b) { + return b.uses_left - a.uses_left + }) + } + }, + methods: { + getWithdrawLinks: function () { + var self = this + + LNbits.api + .request( + 'GET', + '/withdraw/api/v1/links?all_wallets', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.withdrawLinks = response.data.map(function (obj) { + return mapWithdrawLink(obj) + }) + }) + .catch(function (error) { + clearInterval(self.checker) + LNbits.utils.notifyApiError(error) + }) + }, + closeFormDialog: function () { + this.formDialog.data = { + is_unique: false + } + }, + simplecloseFormDialog: function () { + this.simpleformDialog.data = { + is_unique: false + } + }, + openQrCodeDialog: function (linkId) { + var link = _.findWhere(this.withdrawLinks, {id: linkId}) + + this.qrCodeDialog.data = _.clone(link) + console.log(this.qrCodeDialog.data) + this.qrCodeDialog.data.url = + window.location.protocol + '//' + window.location.host + this.qrCodeDialog.show = true + }, + openUpdateDialog: function (linkId) { + var link = _.findWhere(this.withdrawLinks, {id: linkId}) + this.formDialog.data = _.clone(link._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') + + data.wait_time = + data.wait_time * + { + seconds: 1, + minutes: 60, + hours: 3600 + }[this.formDialog.secondMultiplier] + + if (data.id) { + this.updateWithdrawLink(wallet, data) + } else { + this.createWithdrawLink(wallet, data) + } + }, + simplesendFormData: function () { + var wallet = _.findWhere(this.g.user.wallets, { + id: this.simpleformDialog.data.wallet + }) + var data = _.omit(this.simpleformDialog.data, 'wallet') + + data.wait_time = 1 + data.min_withdrawable = data.max_withdrawable + data.title = 'vouchers' + data.is_unique = true + + if (data.id) { + this.updateWithdrawLink(wallet, data) + } else { + this.createWithdrawLink(wallet, data) + } + }, + updateWithdrawLink: function (wallet, data) { + var self = this + + LNbits.api + .request( + 'PUT', + '/withdraw/api/v1/links/' + data.id, + wallet.adminkey, + _.pick( + data, + 'title', + 'min_withdrawable', + 'max_withdrawable', + 'uses', + 'wait_time', + 'is_unique' + ) + ) + .then(function (response) { + self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { + return obj.id === data.id + }) + self.withdrawLinks.push(mapWithdrawLink(response.data)) + self.formDialog.show = false + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + createWithdrawLink: function (wallet, data) { + var self = this + + LNbits.api + .request('POST', '/withdraw/api/v1/links', wallet.adminkey, data) + .then(function (response) { + self.withdrawLinks.push(mapWithdrawLink(response.data)) + self.formDialog.show = false + self.simpleformDialog.show = false + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + deleteWithdrawLink: function (linkId) { + var self = this + var link = _.findWhere(this.withdrawLinks, {id: linkId}) + + LNbits.utils + .confirmDialog('Are you sure you want to delete this withdraw link?') + .onOk(function () { + LNbits.api + .request( + 'DELETE', + '/withdraw/api/v1/links/' + linkId, + _.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey + ) + .then(function (response) { + self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { + return obj.id === linkId + }) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }) + }, + exportCSV: function () { + LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls) + } + }, + created: function () { + if (this.g.user.wallets.length) { + var getWithdrawLinks = this.getWithdrawLinks + getWithdrawLinks() + this.checker = setInterval(function () { + getWithdrawLinks() + }, 20000) + } + } +}) diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html new file mode 100644 index 000000000..484464baf --- /dev/null +++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html @@ -0,0 +1,199 @@ + + + + + GET /withdraw/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<withdraw_link_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /withdraw/api/v1/links/<withdraw_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/links/<withdraw_id> -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /withdraw/api/v1/links +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"title": <string>, "min_withdrawable": <integer>, + "max_withdrawable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>} +
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/links -d '{"title": + <string>, "min_withdrawable": <integer>, + "max_withdrawable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /withdraw/api/v1/links/<withdraw_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"title": <string>, "min_withdrawable": <integer>, + "max_withdrawable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>} +
+ Returns 200 OK (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X PUT {{ request.url_root }}api/v1/links/<withdraw_id> -d + '{"title": <string>, "min_withdrawable": <integer>, + "max_withdrawable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /withdraw/api/v1/links/<withdraw_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root }}api/v1/links/<withdraw_id> + -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /withdraw/api/v1/links/<the_hash>/<lnurl_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"status": <bool>} +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /withdraw/img/<lnurl_id> +
Curl example
+ curl -X GET {{ request.url_root }}/withdraw/img/<lnurl_id>" + +
+
+
+
diff --git a/lnbits/extensions/withdraw/templates/withdraw/_lnurl.html b/lnbits/extensions/withdraw/templates/withdraw/_lnurl.html new file mode 100644 index 000000000..efb9a486e --- /dev/null +++ b/lnbits/extensions/withdraw/templates/withdraw/_lnurl.html @@ -0,0 +1,29 @@ + + + +

+ WARNING: LNURL must be used over https or TOR
+ LNURL is a range of lightning-network standards that allow us to use + lightning-network differently. An LNURL withdraw is the permission for + someone to pull a certain amount of funds from a lightning wallet. In + this extension time is also added - an amount can be withdraw over a + period of time. A typical use case for an LNURL withdraw is a faucet, + although it is a very powerful technology, with much further reaching + implications. For example, an LNURL withdraw could be minted to pay for + a subscription service. +

+

+ Exploring LNURL and finding use cases, is really helping inform + lightning protocol development, rather than the protocol dictating how + lightning-network should be engaged with. +

+ Check + Awesome LNURL + for further information. +
+
+
diff --git a/lnbits/extensions/withdraw/templates/withdraw/display.html b/lnbits/extensions/withdraw/templates/withdraw/display.html new file mode 100644 index 000000000..f4d6ef9d2 --- /dev/null +++ b/lnbits/extensions/withdraw/templates/withdraw/display.html @@ -0,0 +1,59 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+ {% if link.is_spent %} + Withdraw is spent. + {% endif %} + + + + + +
+
+ Copy LNURL +
+
+
+
+
+ + +
+ LNbits LNURL-withdraw link +
+

+ Use a LNURL compatible bitcoin wallet to claim the sats. +

+
+ + + {% include "withdraw/_lnurl.html" %} + +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html new file mode 100644 index 000000000..3cdabb3ba --- /dev/null +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -0,0 +1,356 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} {% block page %} +
+
+ + + Quick vouchers + Advanced withdraw link(s) + + + + + +
+
+
Withdraw links
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} LNURL-withdraw extension +
+
+ + + + {% include "withdraw/_api_docs.html" %} + + {% include "withdraw/_lnurl.html" %} + + +
+
+ + + + + + + + + + +
+
+ + +
+
+ + +
+
+ + + + + + + Use unique withdraw QR codes to reduce + `assmilking` + This is recommended if you are sharing the links on social + media or print QR codes. + + + +
+ Update withdraw link + Create withdraw link + Cancel +
+
+
+
+ + + + + + + + + +
+ Create vouchers + Cancel +
+
+
+
+ + + + + + {% raw %} + +

+ ID: {{ qrCodeDialog.data.id }}
+ Unique: {{ qrCodeDialog.data.is_unique }} + (QR code will change after each withdrawal)
+ Max. withdrawable: {{ + qrCodeDialog.data.max_withdrawable }} sat
+ Wait time: {{ qrCodeDialog.data.wait_time }} seconds
+ Withdraws: {{ qrCodeDialog.data.used }} / {{ + qrCodeDialog.data.uses }} + +

+ {% endraw %} +
+ Copy LNURL + Shareable link + + Close +
+
+
+
+{% endblock %} diff --git a/lnbits/extensions/withdraw/templates/withdraw/print_qr.html b/lnbits/extensions/withdraw/templates/withdraw/print_qr.html new file mode 100644 index 000000000..df4ca7d72 --- /dev/null +++ b/lnbits/extensions/withdraw/templates/withdraw/print_qr.html @@ -0,0 +1,71 @@ +{% extends "print.html" %} {% block page %} + +
+
+ {% for page in link %} + + + {% for threes in page %} + + {% for one in threes %} + + {% endfor %} + + {% endfor %} +
+
+ +
+
+
+ {% endfor %} +
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py new file mode 100644 index 000000000..28f25756f --- /dev/null +++ b/lnbits/extensions/withdraw/views.py @@ -0,0 +1,63 @@ +from quart import g, abort, render_template +from http import HTTPStatus +import pyqrcode +from io import BytesIO +from lnbits.decorators import check_user_exists, validate_uuids + +from . import withdraw_ext +from .crud import get_withdraw_link, chunks + + +@withdraw_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("withdraw/index.html", user=g.user) + + +@withdraw_ext.route("/") +async def display(link_id): + link = await get_withdraw_link(link_id, 0) or abort( + HTTPStatus.NOT_FOUND, "Withdraw link does not exist." + ) + return await render_template("withdraw/display.html", link=link, unique=True) + + +@withdraw_ext.route("/img/") +async def img(link_id): + link = await get_withdraw_link(link_id, 0) or abort( + HTTPStatus.NOT_FOUND, "Withdraw link does not exist." + ) + qr = pyqrcode.create(link.lnurl) + stream = BytesIO() + qr.svg(stream, scale=3) + return ( + stream.getvalue(), + 200, + { + "Content-Type": "image/svg+xml", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + +@withdraw_ext.route("/print/") +async def print_qr(link_id): + link = await get_withdraw_link(link_id) or abort( + HTTPStatus.NOT_FOUND, "Withdraw link does not exist." + ) + if link.uses == 0: + return await render_template("withdraw/print_qr.html", link=link, unique=False) + links = [] + count = 0 + for x in link.usescsv.split(","): + linkk = await get_withdraw_link(link_id, count) or abort( + HTTPStatus.NOT_FOUND, "Withdraw link does not exist." + ) + links.append(str(linkk.lnurl)) + count = count + 1 + page_link = list(chunks(links, 2)) + linked = list(chunks(page_link, 5)) + return await render_template("withdraw/print_qr.html", link=linked, unique=True) diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py new file mode 100644 index 000000000..55f3c5e86 --- /dev/null +++ b/lnbits/extensions/withdraw/views_api.py @@ -0,0 +1,144 @@ +from quart import g, jsonify, request +from http import HTTPStatus +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 pydantic import BaseModel +from fastapi import FastAPI, Query +from fastapi.encoders import jsonable_encoder + +from . import withdraw_ext +from .crud import ( + create_withdraw_link, + get_withdraw_link, + get_withdraw_links, + update_withdraw_link, + delete_withdraw_link, + create_hash_check, + get_hash_check, +) + + +@withdraw_ext.route("/api/v1/links", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_links(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + try: + return ( + jsonable_encoder( + [ + { + **link._asdict(), + **{"lnurl": link.lnurl}, + } + for link in await get_withdraw_links(wallet_ids) + ] + ), + HTTPStatus.OK, + ) + except LnurlInvalidUrl: + return ( + jsonable_encoder( + { + "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." + } + ), + HTTPStatus.UPGRADE_REQUIRED, + ) + + +@withdraw_ext.route("/api/v1/links/", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_link_retrieve(link_id): + link = await get_withdraw_link(link_id, 0) + + if not link: + return ( + jsonable_encoder({"message": "Withdraw link does not exist."}), + HTTPStatus.NOT_FOUND, + ) + + if link.wallet != g.wallet.id: + return jsonable_encoder({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN + + return jsonable_encoder({**link, **{"lnurl": link.lnurl}}), HTTPStatus.OK + +class CreateData(BaseModel): + title: str = Query(...), + min_withdrawable: int = Query(..., ge=1), + max_withdrawable: int = Query(..., ge=1), + uses: int = Query(..., ge=1), + wait_time: int = Query(..., ge=1), + is_unique: bool + +@withdraw_ext.route("/api/v1/links", methods=["POST"]) +@withdraw_ext.route("/api/v1/links/", methods=["PUT"]) +@api_check_wallet_key("admin") +async def api_link_create_or_update(data: CreateData, link_id: str = None): + if data.max_withdrawable < data.min_withdrawable: + return ( + jsonable_encoder( + { + "message": "`max_withdrawable` needs to be at least `min_withdrawable`." + } + ), + HTTPStatus.BAD_REQUEST, + ) + + usescsv = "" + for i in range(data.uses): + if data.is_unique: + usescsv += "," + str(i + 1) + else: + usescsv += "," + str(1) + usescsv = usescsv[1:] + + if link_id: + link = await get_withdraw_link(link_id, 0) + if not link: + return ( + jsonify({"message": "Withdraw link does not exist."}), + HTTPStatus.NOT_FOUND, + ) + if link.wallet != g.wallet.id: + return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN + link = await update_withdraw_link(link_id, **data, usescsv=usescsv, used=0) + else: + link = await create_withdraw_link( + wallet_id=g.wallet.id, **data, usescsv=usescsv + ) + + return ( + jsonable_encoder({**link, **{"lnurl": link.lnurl}}), + HTTPStatus.OK if link_id else HTTPStatus.CREATED, + ) + + +@withdraw_ext.route("/api/v1/links/", methods=["DELETE"]) +@api_check_wallet_key("admin") +async def api_link_delete(link_id): + link = await get_withdraw_link(link_id) + + if not link: + return ( + jsonable_encoder({"message": "Withdraw link does not exist."}), + HTTPStatus.NOT_FOUND, + ) + + if link.wallet != g.wallet.id: + return jsonable_encoder({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN + + await delete_withdraw_link(link_id) + + return "", HTTPStatus.NO_CONTENT + + +@withdraw_ext.route("/api/v1/links//", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_hash_retrieve(the_hash, lnurl_id): + hashCheck = await get_hash_check(the_hash, lnurl_id) + return jsonify(hashCheck), HTTPStatus.OK