Merge pull request #369 from arcbtc/FastAPI

latest fastapi chnages
This commit is contained in:
Arc 2021-10-29 18:23:51 +01:00 committed by GitHub
commit 1fb2fee99b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 7011 additions and 154 deletions

View File

@ -5,16 +5,17 @@ from binascii import unhexlify
from http import HTTPStatus from http import HTTPStatus
from typing import Dict, Optional, Union from typing import Dict, Optional, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
from lnbits.bolt11 import Invoice
import httpx import httpx
from fastapi import Query, Request from fastapi import Query, Request
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.param_functions import Depends from fastapi.param_functions import Depends
from fastapi.params import Body from fastapi.params import Body
from sse_starlette.sse import EventSourceResponse
from pydantic import BaseModel from pydantic import BaseModel
from sse_starlette.sse import EventSourceResponse
from lnbits import bolt11, lnurl from lnbits import bolt11, lnurl
from lnbits.bolt11 import Invoice
from lnbits.core.models import Payment, Wallet from lnbits.core.models import Payment, Wallet
from lnbits.decorators import ( from lnbits.decorators import (
WalletAdminKeyChecker, WalletAdminKeyChecker,
@ -29,17 +30,17 @@ from lnbits.utils.exchange_rates import currencies, fiat_amount_as_satoshis
from .. import core_app, db from .. import core_app, db
from ..crud import ( from ..crud import (
get_payments, get_payments,
get_standalone_payment,
save_balance_check, save_balance_check,
update_wallet, update_wallet,
get_standalone_payment,
) )
from ..services import ( from ..services import (
InvoiceFailure, InvoiceFailure,
PaymentFailure, PaymentFailure,
check_invoice_status,
create_invoice, create_invoice,
pay_invoice, pay_invoice,
perform_lnurlauth, perform_lnurlauth,
check_invoice_status,
) )
from ..tasks import api_invoice_listeners from ..tasks import api_invoice_listeners
@ -100,8 +101,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
else: else:
description_hash = b"" description_hash = b""
memo = data.memo memo = data.memo
if data.unit == "sat":
if data.unit or "sat" == "sat":
amount = data.amount amount = data.amount
else: else:
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit) price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)

View File

@ -1,5 +1,4 @@
import asyncio import asyncio
from http import HTTPStatus from http import HTTPStatus
from typing import Optional from typing import Optional
@ -13,10 +12,10 @@ from pydantic.types import UUID4
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from lnbits.core import db from lnbits.core import db
from lnbits.helpers import template_renderer, url_for
from lnbits.requestvars import g
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer, url_for
from lnbits.requestvars import g
from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_SITE_TITLE, SERVICE_FEE from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_SITE_TITLE, SERVICE_FEE
from ..crud import ( from ..crud import (
@ -189,21 +188,20 @@ async def lnurl_full_withdraw_callback(request: Request):
@core_html_routes.get("/deletewallet") @core_html_routes.get("/deletewallet")
# @validate_uuids(["usr", "wal"], required=True) async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)):
# @check_user_exists() user = await get_user(usr)
async def deletewallet(request: Request): user_wallet_ids = [u.id for u in user.wallets]
wallet_id = request.path_params.get("wal", type=str) print("USR", user_wallet_ids)
user_wallet_ids = g().user.wallet_ids
if wallet_id not in user_wallet_ids: if wal not in user_wallet_ids:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.") raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
else: else:
await delete_wallet(user_id=g().user.id, wallet_id=wallet_id) await delete_wallet(user_id=user.id, wallet_id=wal)
user_wallet_ids.remove(wallet_id) user_wallet_ids.remove(wal)
if user_wallet_ids: if user_wallet_ids:
return RedirectResponse( return RedirectResponse(
url_for("/wallet", usr=g().user.id, wal=user_wallet_ids[0]), url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
status_code=status.HTTP_307_TEMPORARY_REDIRECT, status_code=status.HTTP_307_TEMPORARY_REDIRECT,
) )

View File

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

View File

@ -0,0 +1,27 @@
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_bleskomat")
bleskomat_static_files = [
{
"path": "/bleskomat/static",
"app": StaticFiles(directory="lnbits/extensions/bleskomat/static"),
"name": "bleskomat_static",
}
]
bleskomat_ext: APIRouter = APIRouter(
prefix="/bleskomat",
tags=["Bleskomat"]
)
def bleskomat_renderer():
return template_renderer(["lnbits/extensions/bleskomat/templates"])
from .lnurl_api import * # noqa
from .views import * # noqa
from .views_api import * # noqa

View File

@ -0,0 +1,6 @@
{
"name": "Bleskomat",
"short_description": "Connect a Bleskomat ATM to an lnbits",
"icon": "money",
"contributors": ["chill117"]
}

View File

@ -0,0 +1,116 @@
import secrets
import time
from typing import List, Optional, Union
from uuid import uuid4
from . import db
from .helpers import generate_bleskomat_lnurl_hash
from .models import Bleskomat, BleskomatLnurl, CreateBleskomat
async def create_bleskomat(
data: CreateBleskomat,
wallet_id: 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,
data.name,
data.fiat_currency,
data.exchange_rate_provider,
data.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

View File

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

View File

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

View File

@ -0,0 +1,154 @@
import base64
import hashlib
import hmac
import urllib
from binascii import unhexlify
from http import HTTPStatus
from typing import Dict
from starlette.requests import Request
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(request: Request):
return request.url_for("bleskomat.api_bleskomat_lnurl")
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

View File

@ -0,0 +1,128 @@
import json
import math
import traceback
from http import HTTPStatus
from starlette.requests import Request
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 (
LnurlHttpError,
LnurlValidationError,
generate_bleskomat_lnurl_secret,
generate_bleskomat_lnurl_signature,
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.get("/u", name="bleskomat.api_bleskomat_lnurl")
async def api_bleskomat_lnurl(request: Request):
try:
query = request.query_params
# 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 lnurl.get_info_response_object(secret)
# 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 {"status": "ERROR", "reason": str(e)}
except Exception:
traceback.print_exc()
return {"status": "ERROR", "reason": "Unexpected error"}
return {"status": "OK"}

View File

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

View File

@ -0,0 +1,141 @@
import json
import time
from typing import Dict
from fastapi.params import Query
from pydantic import BaseModel, validator
from starlette.requests import Request
from lnbits import bolt11
from lnbits.core.services import pay_invoice
from . import db
from .exchange_rates import exchange_rate_providers, fiat_currencies
from .helpers import LnurlValidationError, get_callback_url
class CreateBleskomat(BaseModel):
name: str = Query(...)
fiat_currency: str = Query(...)
exchange_rate_provider: str = Query(...)
fee: str = Query(...)
@validator('fiat_currency')
def allowed_fiat_currencies(cls, v):
if(v not in fiat_currencies.keys()):
raise ValueError('Not allowed currency')
return v
@validator('exchange_rate_provider')
def allowed_providers(cls, v):
if(v not in exchange_rate_providers.keys()):
raise ValueError('Not allowed provider')
return v
@validator('fee')
def fee_type(cls, v):
if(not isinstance(v, (str, float, int))):
raise ValueError('Fee type not allowed')
return v
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, req: Request) -> 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(req)
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

View File

@ -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=true',
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)
}
}
})

View File

@ -0,0 +1,65 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Setup guide"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<p>
This extension allows you to connect a Bleskomat ATM to an lnbits
wallet. It will work with both the
<a href="https://github.com/samotari/bleskomat"
>open-source DIY Bleskomat ATM project</a
>
as well as the
<a href="https://www.bleskomat.com/">commercial Bleskomat ATM</a>.
</p>
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
<div>
<ol>
<li>Click the "Add Bleskomat" button on this page to begin.</li>
<li>
Choose a wallet. This will be the wallet that is used to pay
satoshis to your ATM customers.
</li>
<li>
Choose the fiat currency. This should match the fiat currency that
your ATM accepts.
</li>
<li>
Pick an exchange rate provider. This is the API that will be used to
query the fiat to satoshi exchange rate at the time your customer
attempts to withdraw their funds.
</li>
<li>Set your ATM's fee percentage.</li>
<li>Click the "Done" button.</li>
<li>
Find the new Bleskomat in the list and then click the export icon to
download a new configuration file for your ATM.
</li>
<li>
Copy the configuration file ("bleskomat.conf") to your ATM's SD
card.
</li>
<li>
Restart Your Bleskomat ATM. It should automatically reload the
configurations from the SD card.
</li>
</ol>
</div>
<h5 class="text-subtitle1 q-my-none">How does it work?</h5>
<p>
Since the Bleskomat ATMs are designed to be offline, a cryptographic
signing scheme is used to verify that the URL was generated by an
authorized device. When one of your customers inserts fiat money into
the device, a signed URL (lnurl-withdraw) is created and displayed as a
QR code. Your customer scans the QR code with their lnurl-supporting
mobile app, their mobile app communicates with the web API of lnbits to
verify the signature, the fiat currency amount is converted to sats, the
customer accepts the withdrawal, and finally lnbits will pay the
customer from your lnbits wallet.
</p>
</q-card-section>
</q-card>
</q-expansion-item>

View File

@ -0,0 +1,180 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
<script>
{% if bleskomat_vars %}
window.bleskomat_vars = {{ bleskomat_vars | tojson | safe }}
{% endif %}
</script>
<script src="/bleskomat/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>Add Bleskomat</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Bleskomats</h5>
</div>
</div>
<q-table
dense
flat
:data="sortedBleskomats"
row-key="id"
:columns="bleskomatsTable.columns"
:pagination.sync="bleskomatsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
flat
dense
size="xs"
icon="file_download"
color="orange"
@click="exportConfigFile(props.row.id)"
>
<q-tooltip content-class="bg-accent"
>Export Configuration</q-tooltip
>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip content-class="bg-accent">Edit</q-tooltip>
</q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteBleskomat(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip content-class="bg-accent">Delete</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Bleskomat extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "bleskomat/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.name"
type="text"
label="Name *"
></q-input>
<q-select
filled
dense
v-model="formDialog.data.fiat_currency"
:options="formDialog.fiatCurrencies"
label="Fiat Currency *"
>
</q-select>
<q-select
filled
dense
v-model="formDialog.data.exchange_rate_provider"
:options="formDialog.exchangeRateProviders"
label="Exchange Rate Provider *"
>
</q-select>
<q-input
filled
dense
v-model.number="formDialog.data.fee"
type="string"
:default="0.00"
label="Fee (%) *"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Bleskomat</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialog.data.wallet == null ||
formDialog.data.name == null ||
formDialog.data.fiat_currency == null ||
formDialog.data.exchange_rate_provider == null ||
formDialog.data.fee == null"
type="submit"
>Add Bleskomat</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %}

View File

@ -0,0 +1,26 @@
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import bleskomat_ext, bleskomat_renderer
from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies
from .helpers import get_callback_url
templates = Jinja2Templates(directory="templates")
@bleskomat_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
bleskomat_vars = {
"callback_url": get_callback_url(request=request),
"exchange_rate_providers": exchange_rate_providers_serializable,
"fiat_currencies": fiat_currencies,
}
return bleskomat_renderer().TemplateResponse(
"bleskomat/index.html", {"request": request, "user": user.dict(), "bleskomat_vars": bleskomat_vars}
)

View File

@ -0,0 +1,86 @@
from http import HTTPStatus
from fastapi import Depends, Query
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, require_admin_key
from lnbits.extensions.bleskomat.models import CreateBleskomat
from . import bleskomat_ext
from .crud import (
create_bleskomat,
delete_bleskomat,
get_bleskomat,
get_bleskomats,
update_bleskomat,
)
from .exchange_rates import fetch_fiat_exchange_rate
@bleskomat_ext.get("/api/v1/bleskomats")
async def api_bleskomats(wallet: WalletTypeInfo = Depends(require_admin_key), all_wallets: bool = Query(False)):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [bleskomat.dict() for bleskomat in await get_bleskomats(wallet_ids)]
@bleskomat_ext.get("/api/v1/bleskomat/{bleskomat_id}")
async def api_bleskomat_retrieve(bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Bleskomat configuration not found."
)
return bleskomat.dict()
@bleskomat_ext.post("/api/v1/bleskomat")
@bleskomat_ext.put("/api/v1/bleskomat/{bleskomat_id}",)
async def api_bleskomat_create_or_update(data: CreateBleskomat, wallet: WalletTypeInfo = Depends(require_admin_key), bleskomat_id=None):
try:
fiat_currency = data.fiat_currency
exchange_rate_provider = data.exchange_rate_provider
await fetch_fiat_exchange_rate(
currency=fiat_currency, provider=exchange_rate_provider
)
except Exception as e:
print(e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"'
)
if bleskomat_id:
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Bleskomat configuration not found."
)
bleskomat = await update_bleskomat(bleskomat_id, **data.dict())
else:
bleskomat = await create_bleskomat(wallet_id=wallet.wallet.id, data=data)
return bleskomat.dict()
@bleskomat_ext.delete("/api/v1/bleskomat/{bleskomat_id}")
async def api_bleskomat_delete(bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Bleskomat configuration not found."
)
await delete_bleskomat(bleskomat_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)

View File

@ -1,7 +1,8 @@
import asyncio import asyncio
from fastapi import APIRouter, FastAPI
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.routing import Mount
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart from lnbits.tasks import catch_everything_and_restart
@ -22,10 +23,10 @@ def copilot_renderer():
return template_renderer(["lnbits/extensions/copilot/templates"]) return template_renderer(["lnbits/extensions/copilot/templates"])
from .views_api import * # noqa
from .views import * # noqa
from .tasks import wait_for_paid_invoices
from .lnurl import * # noqa from .lnurl import * # noqa
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def copilot_start(): def copilot_start():

View File

@ -0,0 +1,3 @@
<h1>Hivemind</h1>
Placeholder for a future <a href="https://bitcoinhivemind.com/">Bitcoin Hivemind</a> extension.

View File

@ -0,0 +1,17 @@
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_hivemind")
hivemind_ext: APIRouter = APIRouter(
prefix="/hivemind",
tags=["hivemind"]
)
def hivemind_renderer():
return template_renderer(["lnbits/extensions/hivemind/templates"])
from .views import * # noqa

View File

@ -0,0 +1,6 @@
{
"name": "Hivemind",
"short_description": "Make cheap talk expensive!",
"icon": "batch_prediction",
"contributors": ["fiatjaf"]
}

View File

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

View File

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

View File

@ -0,0 +1,35 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-mt-none q-mb-md">
This extension is just a placeholder for now.
</h5>
<p>
<a href="https://bitcoinhivemind.com/">Hivemind</a> 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.
</p>
<p>
These markets have the potential to revolutionize the emergence of
diffusion of knowledge in society and fix all sorts of problems in the
world.
</p>
<p>
This extension will become fully operative when the
<a href="https://drivechain.xyz/">BIP300</a> soft-fork gets activated and
Bitcoin Hivemind is launched.
</p>
</q-card-section>
</q-card>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,13 @@
from fastapi.param_functions import Depends
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import hivemind_ext, hivemind_renderer
@hivemind_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return hivemind_renderer().TemplateResponse("hivemind/index.html", {"request": request, "user": user.dict()})

View File

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

View File

@ -0,0 +1,36 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_livestream")
livestream_static_files = [
{
"path": "/livestream/static",
"app": StaticFiles(directory="lnbits/extensions/livestream/static"),
"name": "livestream_static",
}
]
livestream_ext: APIRouter = APIRouter(
prefix="/livestream",
tags=["livestream"]
)
def livestream_renderer():
return template_renderer(["lnbits/extensions/livestream/templates"])
from .lnurl import * # noqa
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def lnticket_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -0,0 +1,10 @@
{
"name": "DJ Livestream",
"short_description": "Sell tracks and split revenue (lnurl-pay)",
"icon": "speaker",
"contributors": [
"fiatjaf",
"cryptograffiti"
],
"hidden": false
}

View File

@ -0,0 +1,200 @@
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, Producer, Track
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]

View File

@ -0,0 +1,120 @@
import hashlib
import math
from http import HTTPStatus
from os import name
from fastapi.exceptions import HTTPException
from fastapi.params import Query
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
from starlette.requests import Request # 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.get("/lnurl/{ls_id}", name="livestream.lnurl_livestream")
async def lnurl_livestream(ls_id, request: Request):
ls = await get_livestream(ls_id)
if not ls:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Livestream not found."
)
track = await get_track(ls.current_track)
if not track:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="This livestream is offline."
)
resp = LnurlPayResponse(
callback=request.url_for(
"livestream.lnurl_callback", track_id=track.id
),
min_sendable=track.min_sendable,
max_sendable=track.max_sendable,
metadata=await track.lnurlpay_metadata(),
)
params = resp.dict()
params["commentAllowed"] = 300
return params
@livestream_ext.get("/lnurl/t/{track_id}", name="livestream.lnurl_track")
async def lnurl_track(track_id, request: Request):
track = await get_track(track_id)
if not track:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Track not found."
)
resp = LnurlPayResponse(
callback=request.url_for(
"livestream.lnurl_callback", track_id=track.id
),
min_sendable=track.min_sendable,
max_sendable=track.max_sendable,
metadata=await track.lnurlpay_metadata(),
)
params = resp.dict()
params["commentAllowed"] = 300
return params
@livestream_ext.get("/lnurl/cb/{track_id}", name="livestream.lnurl_callback")
async def lnurl_callback(track_id, request: Request, amount: int = Query(...), comment: str = Query("")):
track = await get_track(track_id)
if not track:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Track not found."
)
amount_received = int(amount or 0)
if amount_received < track.min_sendable:
return 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 LnurlErrorResponse(
reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}."
).dict()
if len(comment or "") > 300:
return 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, request=request)
resp = LnurlPayActionResponse(
pr=payment_request,
success_action=success_action,
routes=[],
)
return resp.dict()

View File

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

View File

@ -0,0 +1,89 @@
import json
from typing import Optional
from fastapi.params import Query
from lnurl import Lnurl
from lnurl import encode as lnurl_encode # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from pydantic.main import BaseModel
from starlette.requests import Request
class CreateTrack(BaseModel):
name: str = Query(...)
download_url: str = Query(None)
price_msat: int = Query(None, ge=0)
producer_id: str = Query(None)
producer_name: str = Query(None)
class Livestream(BaseModel):
id: int
wallet: str
fee_pct: int
current_track: Optional[int]
def lnurl(self, request: Request) -> Lnurl:
url = request.url_for("livestream.lnurl_livestream", ls_id=self.id)
return lnurl_encode(url)
class Track(BaseModel):
id: int
download_url: Optional[str]
price_msat: Optional[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)
def lnurl(self, request: Request) -> Lnurl:
url = request.url_for("livestream.lnurl_track", track_id=self.id)
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, request: Request) -> Optional[LnurlPaySuccessAction]:
if not self.download_url:
return None
return UrlAction(
url=request.url_for(
"livestream.track_redirect_download",
track_id=self.id,
p=payment_hash
),
description=f"Download the track {self.name}!",
)
class Producer(BaseModel):
id: int
user: str
wallet: str
name: str

View File

@ -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 <b>${satoshiAmount} sat</b> for the track <em>${trackName}</em>.`,
caption: payment.extra.comment
? `<em>"${payment.extra.comment}"</em>`
: 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()
}
})

View File

@ -0,0 +1,93 @@
import asyncio
import json
from lnbits.core import db as core_db
from lnbits.core.crud import create_payment
from lnbits.core.models import Payment
from lnbits.helpers import urlsafe_short_hash
from lnbits.tasks import internal_invoice_listener, register_invoice_listener
from .crud import get_livestream_by_track, get_producer, get_track
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
# async def 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 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)
await internal_invoice_listener.put(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

View File

@ -0,0 +1,146 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="How to use"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<p>Add tracks, profit.</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item
group="api"
dense
expand-separator
label="List livestream links"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/livestream/api/v1/livestream</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;livestream_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update track">
<q-card>
<q-card-section>
<code
><span class="text-blue">PUT</span>
/livestream/api/v1/livestream/track/&lt;track_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root
}}api/v1/livestream/track/&lt;track_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update fee">
<q-card>
<q-card-section>
<code
><span class="text-blue">PUT</span>
/livestream/api/v1/livestream/fee/&lt;fee_pct&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root
}}api/v1/livestream/fee/&lt;fee_pct&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Add track">
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/livestream/api/v1/livestream/tracks</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"name": &lt;string&gt;, "download_url": &lt;string&gt;,
"price_msat": &lt;integer&gt;, "producer_id": &lt;integer&gt;,
"producer_name": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/livestream/tracks -d
'{"name": &lt;string&gt;, "download_url": &lt;string&gt;,
"price_msat": &lt;integer&gt;, "producer_id": &lt;integer&gt;,
"producer_name": &lt;string&gt;}' -H "Content-type: application/json"
-H "X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a withdraw link"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/livestream/api/v1/livestream/tracks/&lt;track_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}api/v1/livestream/tracks/&lt;track_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,322 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card class="q-pa-lg q-pt-xl">
<q-form
@submit="updateCurrentTrack(nextCurrentTrack)"
class="q-gutter-md"
>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
dense
filled
v-model="nextCurrentTrack"
use-input
hide-selected
fill-input
input-debounce="0"
:options="sortedTracks.map(track => track.id)"
option-value="id"
:option-label="getTrackLabel"
options-dense
label="Current track"
/>
</div>
<div class="col">
{% raw %}
<q-btn unelevated color="primary" type="submit">
{{ nextCurrentTrack && nextCurrentTrack ===
livestream.current_track ? 'Stop' : 'Set' }} current track
</q-btn>
{% endraw %}
</div>
</div>
</q-form>
<q-form @submit="updateFeePct" class="q-gutter-md">
<div class="row q-col-gutter-sm">
<div class="col">
<q-input
filled
dense
v-model.number="livestream.fee_pct"
type="number"
label="Revenue to keep (%)"
></q-input>
</div>
<div class="col">
<q-btn unelevated color="primary" type="submit"
>Set percent rate</q-btn
>
</div>
</div>
</q-form>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Tracks</h5>
</div>
<div class="col q-ml-lg">
<q-btn unelevated color="primary" @click="openAddTrackDialog"
>Add new track</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="sortedTracks"
row-key="id"
no-data-label="No tracks added yet"
:pagination="{rowsPerPage: 0}"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width>Name</q-th>
<q-th auto-width>Producer</q-th>
<q-th auto-width>Price</q-th>
<q-th auto-width>Download URL</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
:icon="livestream.current_track !== props.row.id ? 'play_circle_outline' : 'play_arrow'"
:color="livestream.current_track !== props.row.id ? ($q.dark.isActive ? 'grey-7' : 'grey-5') : 'green'"
type="a"
@click="updateCurrentTrack(props.row.id)"
target="_blank"
></q-btn>
</q-td>
<q-td auto-width>{{ props.row.name }}</q-td>
<q-td auto-width>
{{ producersMap[props.row.producer].name }}
</q-td>
<q-td class="text-right" auto-width
>{{ Math.round(props.row.price_msat / 1000) }}</q-td
>
<q-td class="text-center" auto-width
>{{ props.row.download_url }}</q-td
>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="delete"
color="negative"
type="a"
@click="deleteTrack(props.row.id)"
target="_blank"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Producers</h5>
</div>
</div>
<q-table
dense
flat
:data="livestream.producers"
row-key="id"
no-data-label="To include a producer, add a track"
:pagination="{rowsPerPage: 0}"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width>Name</q-th>
<q-th auto-width>Wallet</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>{{ props.row.name }}</q-td>
<q-td class="text-center" auto-width>
<a
target="_blank"
:href="'/wallet?usr=' + props.row.user + '&wal=' + props.row.wallet"
>
{{ props.row.wallet }}
</a>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<q-form class="q-gutter-md">
<q-select
filled
dense
:options="g.user.wallets"
:value="selectedWallet"
label="Using wallet:"
option-label="name"
@input="changedWallet"
>
</q-select>
</q-form>
<a :href="'lightning:' + livestream.lnurl">
<q-responsive :ratio="1" class="q-mx-sm">
<qrcode
:value="livestream.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<q-btn
outline
color="grey"
@click="copyText(livestream.lnurl)"
class="text-center q-mb-md"
>Copy LNURL-pay code</q-btn
>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Livestream extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "livestream/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="trackDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-card-section
v-if="trackDialog.data.lnurl"
class="q-pa-none text-center"
>
<p class="text-subtitle1 q-my-none">
Standalone QR Code for this track
</p>
<a :href="'lightning:' + trackDialog.data.lnurl">
<q-responsive :ratio="1" class="q-mx-sm">
<qrcode
:value="trackDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<q-btn
outline
color="grey"
@click="copyText(trackDialog.data.lnurl)"
class="text-center q-mb-md"
>Copy LNURL-pay code</q-btn
>
</q-card-section>
<q-card-section>
<q-form @submit="addTrack" class="q-gutter-md">
<q-select
filled
dense
v-model="trackDialog.data.producer"
use-input
hide-selected
fill-input
option-label="name"
input-debounce="0"
@new-value="producerAdded"
:options="livestream.producers"
options-dense
label="Producer"
hint="Select an existing producer or add a new one by name (press Enter to add)."
></q-select>
<q-input
filled
dense
v-model.trim="trackDialog.data.name"
type="text"
label="Track name"
></q-input>
<q-input
filled
dense
v-model.number="trackDialog.data.price_sat"
type="number"
min="1"
label="Track price (sat)"
hint="This is the minimum price for buying the track download link. It does nothing for tracks without a download URL."
></q-input>
<q-input
filled
dense
v-model="trackDialog.data.download_url"
type="text"
label="Download URL"
></q-input>
<div class="row q-mt-lg">
<div class="col q-ml-lg">
<q-btn
unelevated
color="primary"
:disable="disabledAddTrackButton()"
type="submit"
>
<span v-if="trackDialog.data.id">Update track</span>
<span v-else>Add track</span>
</q-btn>
</div>
<div class="col q-ml-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/livestream/static/js/index.js"></script>
{% endblock %}

View File

@ -0,0 +1,41 @@
from http import HTTPStatus
from mmap import MAP_DENYWRITE
from fastapi.param_functions import Depends
from fastapi.params import Query
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse
from lnbits.core.crud import get_wallet_payment
from lnbits.core.models import Payment, User
from lnbits.decorators import check_user_exists
from . import livestream_ext, livestream_renderer
from .crud import get_livestream_by_track, get_track
@livestream_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return livestream_renderer().TemplateResponse("livestream/index.html", {"request": request, "user": user.dict()})
@livestream_ext.get("/track/{track_id}", name="livestream.track_redirect_download")
async def track_redirect_download(track_id, request: Request):
payment_hash = request.path_params["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:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Couldn't find the payment {payment_hash} or track {track.id}."
)
if payment.pending:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute."
)
return RedirectResponse(url=track.download_url)

View File

@ -0,0 +1,107 @@
from http import HTTPStatus
from fastapi.param_functions import Depends
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from starlette.exceptions import HTTPException
from starlette.requests import Request # type: ignore
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.livestream.models import CreateTrack
from . import livestream_ext
from .crud import (
add_producer,
add_track,
delete_track_from_livestream,
get_or_create_livestream_by_wallet,
get_producers,
get_tracks,
update_current_track,
update_livestream_fee,
update_track,
)
@livestream_ext.get("/api/v1/livestream")
async def api_livestream_from_wallet(req: Request, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
tracks = await get_tracks(ls.id)
producers = await get_producers(ls.id)
print("INIT", ls, tracks, producers)
try:
return {
**ls.dict(),
**{
"lnurl": ls.lnurl(request=req),
"tracks": [
dict(lnurl=track.lnurl(request=req), **track.dict())
for track in tracks
],
"producers": [producer.dict() for producer in producers],
},
}
except LnurlInvalidUrl:
raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED,
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor."
)
@livestream_ext.put("/api/v1/livestream/track/{track_id}")
async def api_update_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
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)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@livestream_ext.put("/api/v1/livestream/fee/{fee_pct}")
async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await update_livestream_fee(ls.id, int(fee_pct))
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@livestream_ext.post("/api/v1/livestream/tracks")
@livestream_ext.put("/api/v1/livestream/tracks/{id}")
async def api_add_track(data: CreateTrack, id=None, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
if data.producer_id:
p_id = data.producer_id
elif data.producer_name:
p_id = await add_producer(ls.id, data.producer_name)
else:
raise TypeError("need either producer_id or producer_name arguments")
if id:
await update_track(
ls.id,
id,
data.name,
data.download_url,
data.price_msat or 0,
p_id,
)
else:
await add_track(
ls.id,
data.name,
data.download_url,
data.price_msat or 0,
p_id,
)
return
@livestream_ext.route("/api/v1/livestream/tracks/{track_id}")
async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await delete_track_from_livestream(ls.id, track_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)

View File

@ -27,7 +27,6 @@ async def api_list_currencies_available():
@lnurlp_ext.get("/api/v1/links", status_code=HTTPStatus.OK) @lnurlp_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
# @api_check_wallet_key("invoice")
async def api_links( async def api_links(
req: Request, req: Request,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type),
@ -43,26 +42,15 @@ async def api_links(
{**link.dict(), "lnurl": link.lnurl(req)} {**link.dict(), "lnurl": link.lnurl(req)}
for link in await get_pay_links(wallet_ids) for link in await get_pay_links(wallet_ids)
] ]
# return [
# {**link.dict(), "lnurl": link.lnurl}
# for link in await get_pay_links(wallet_ids)
# ]
except LnurlInvalidUrl: except LnurlInvalidUrl:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED, status_code=HTTPStatus.UPGRADE_REQUIRED,
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.", detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
) )
# return (
# {
# "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
# },
# HTTPStatus.UPGRADE_REQUIRED,
# )
@lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
# @api_check_wallet_key("invoice")
async def api_link_retrieve( async def api_link_retrieve(
r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type) r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type)
): ):
@ -72,20 +60,17 @@ async def api_link_retrieve(
raise HTTPException( raise HTTPException(
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
# return {"message": "Pay link does not exist."}, HTTPStatus.NOT_FOUND
if link.wallet != wallet.wallet.id: if link.wallet != wallet.wallet.id:
raise HTTPException( raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
) )
# return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN
return {**link._asdict(), **{"lnurl": link.lnurl(r)}} return {**link._asdict(), **{"lnurl": link.lnurl(r)}}
@lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) @lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
@lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
# @api_check_wallet_key("invoice")
async def api_link_create_or_update( async def api_link_create_or_update(
data: CreatePayLinkData, data: CreatePayLinkData,
link_id=None, link_id=None,
@ -95,7 +80,6 @@ async def api_link_create_or_update(
raise HTTPException( raise HTTPException(
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
) )
# return {"message": "Min is greater than max."}, HTTPStatus.BAD_REQUEST
if data.currency == None and ( if data.currency == None and (
round(data.min) != data.min or round(data.max) != data.max round(data.min) != data.min or round(data.max) != data.max
@ -103,17 +87,12 @@ async def api_link_create_or_update(
raise HTTPException( raise HTTPException(
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
) )
# return {"message": "Must use full satoshis."}, HTTPStatus.BAD_REQUEST
if "success_url" in data and data.success_url[:8] != "https://": if "success_url" in data and data.success_url[:8] != "https://":
raise HTTPException( raise HTTPException(
detail="Success URL must be secure https://...", detail="Success URL must be secure https://...",
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
) )
# return (
# {"message": "Success URL must be secure https://..."},
# HTTPStatus.BAD_REQUEST,
# )
if link_id: if link_id:
link = await get_pay_link(link_id) link = await get_pay_link(link_id)
@ -122,18 +101,13 @@ async def api_link_create_or_update(
raise HTTPException( raise HTTPException(
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
# return (
# {"message": "Pay link does not exist."},
# HTTPStatus.NOT_FOUND,
# )
if link.wallet != wallet.wallet.id: if link.wallet != wallet.wallet.id:
raise HTTPException( raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
) )
# return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN
link = await update_pay_link(data, link_id=link_id) link = await update_pay_link(**data.dict(), link_id=link_id)
else: else:
link = await create_pay_link(data, wallet_id=wallet.wallet.id) link = await create_pay_link(data, wallet_id=wallet.wallet.id)
print("LINK", link) print("LINK", link)
@ -141,7 +115,6 @@ async def api_link_create_or_update(
@lnurlp_ext.delete("/api/v1/links/{link_id}") @lnurlp_ext.delete("/api/v1/links/{link_id}")
# @api_check_wallet_key("invoice")
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)): async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
link = await get_pay_link(link_id) link = await get_pay_link(link_id)
@ -149,17 +122,14 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type
raise HTTPException( raise HTTPException(
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
# return {"message": "Pay link does not exist."}, HTTPStatus.NOT_FOUND
if link.wallet != wallet.wallet.id: if link.wallet != wallet.wallet.id:
raise HTTPException( raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
) )
# return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN
await delete_pay_link(link_id) await delete_pay_link(link_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
# return "", HTTPStatus.NO_CONTENT
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK) @lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)

View File

@ -64,6 +64,7 @@ async def lnurl_response(
pin=decryptedPin, pin=decryptedPin,
payhash="payment_hash", payhash="payment_hash",
) )
print(price_msat)
if not lnurlpospayment: if not lnurlpospayment:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Could not create payment" status_code=HTTPStatus.FORBIDDEN, detail="Could not create payment"

View File

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

View File

@ -0,0 +1,18 @@
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_paywall")
paywall_ext: APIRouter = APIRouter(
prefix="/paywall",
tags=["Paywall"]
)
def paywall_renderer():
return template_renderer(["lnbits/extensions/paywall/templates"])
from .views import * # noqa
from .views_api import * # noqa

View File

@ -0,0 +1,6 @@
{
"name": "Paywall",
"short_description": "Create paywalls for content",
"icon": "policy",
"contributors": ["eillarra"]
}

View File

@ -0,0 +1,47 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import CreatePaywall, Paywall
async def create_paywall(
wallet_id: str,
data: CreatePaywall
) -> 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, data.url, data.memo, data.description, data.amount, int(data.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,))

View File

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

View File

@ -0,0 +1,38 @@
import json
from sqlite3 import Row
from typing import Optional
from fastapi import Query
from pydantic import BaseModel
class CreatePaywall(BaseModel):
url: str = Query(...)
memo: str = Query(...)
description: str = Query(None)
amount: int = Query(..., ge=0)
remembers: bool = Query(...)
class CreatePaywallInvoice(BaseModel):
amount: int = Query(..., ge=1)
class CheckPaywallInvoice(BaseModel):
payment_hash: str = Query(...)
class Paywall(BaseModel):
id: str
wallet: str
url: str
memo: str
description: Optional[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)

View File

@ -0,0 +1,147 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List paywalls">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /paywall/api/v1/paywalls</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;paywall_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a paywall">
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span> /paywall/api/v1/paywalls</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"amount": &lt;integer&gt;, "description": &lt;string&gt;, "memo":
&lt;string&gt;, "remembers": &lt;boolean&gt;, "url":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"amount": &lt;integer&gt;, "description": &lt;string&gt;, "id":
&lt;string&gt;, "memo": &lt;string&gt;, "remembers": &lt;boolean&gt;,
"time": &lt;int&gt;, "url": &lt;string&gt;, "wallet":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url":
&lt;string&gt;, "memo": &lt;string&gt;, "description": &lt;string&gt;,
"amount": &lt;integer&gt;, "remembers": &lt;boolean&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create an invoice (public)"
>
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/paywall/api/v1/paywalls/&lt;paywall_id&gt;/invoice</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"amount": &lt;integer&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"payment_hash": &lt;string&gt;, "payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root
}}api/v1/paywalls/&lt;paywall_id&gt;/invoice -d '{"amount":
&lt;integer&gt;}' -H "Content-type: application/json"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check invoice status (public)"
>
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/paywall/api/v1/paywalls/&lt;paywall_id&gt;/check_invoice</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": false}</code><br />
<code
>{"paid": true, "url": &lt;string&gt;, "remembers":
&lt;boolean&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root
}}api/v1/paywalls/&lt;paywall_id&gt;/check_invoice -d
'{"payment_hash": &lt;string&gt;}' -H "Content-type: application/json"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a paywall"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/paywall/api/v1/paywalls/&lt;paywall_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}api/v1/paywalls/&lt;paywall_id&gt; -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,162 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-sm-8 col-md-5 col-lg-4">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h5 class="text-subtitle1 q-mt-none q-mb-sm">{{ paywall.memo }}</h5>
{% if paywall.description %}
<p>{{ paywall.description }}</p>
{% endif %}
<div v-if="!this.redirectUrl" class="q-mt-lg">
<q-form v-if="">
<q-input
filled
v-model.number="userAmount"
type="number"
:min="paywallAmount"
suffix="sat"
label="Choose an amount *"
:hint="'Minimum ' + paywallAmount + ' sat'"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="check"
color="primary"
type="submit"
@click="createInvoice"
:disabled="userAmount < paywallAmount || paymentReq"
></q-btn>
</template>
</q-input>
</q-form>
<div v-if="paymentReq" class="q-mt-lg">
<a :href="'lightning:' + paymentReq">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="paymentReq"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(paymentReq)"
>Copy invoice</q-btn
>
<q-btn @click="cancelPayment" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</div>
<div v-else>
<q-separator class="q-my-lg"></q-separator>
<p>
You can access the URL behind this paywall:<br />
<strong>{% raw %}{{ redirectUrl }}{% endraw %}</strong>
</p>
<div class="row q-mt-lg">
<q-btn outline color="grey" type="a" :href="redirectUrl"
>Open URL</q-btn
>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
userAmount: {{ paywall.amount }},
paywallAmount: {{ paywall.amount }},
paymentReq: null,
redirectUrl: null,
paymentDialog: {
dismissMsg: null,
checker: null
}
}
},
computed: {
amount: function () {
return (this.paywallAmount > this.userAmount) ? this.paywallAmount : this.userAmount
}
},
methods: {
cancelPayment: function () {
this.paymentReq = null
clearInterval(this.paymentDialog.checker)
if (this.paymentDialog.dismissMsg) {
this.paymentDialog.dismissMsg()
}
},
createInvoice: function () {
var self = this
console.log(this.amount)
axios
.post(
'/paywall/api/v1/paywalls/{{ paywall.id }}/invoice',
{amount: self.amount}
)
.then(function (response) {
self.paymentReq = response.data.payment_request.toUpperCase()
self.paymentDialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
self.paymentDialog.checker = setInterval(function () {
axios
.post(
'/paywall/api/v1/paywalls/{{ paywall.id }}/check_invoice',
{payment_hash: response.data.payment_hash}
)
.then(function (res) {
if (res.data.paid) {
self.cancelPayment()
self.redirectUrl = res.data.url
if (res.data.remembers) {
self.$q.localStorage.set(
'lnbits.paywall.{{ paywall.id }}',
res.data.url
)
}
self.$q.notify({
type: 'positive',
message: 'Payment received!',
icon: null
})
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}, 2000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}
},
created: function () {
var url = this.$q.localStorage.getItem('lnbits.paywall.{{ paywall.id }}')
if (url) {
this.redirectUrl = url
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,312 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New paywall</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Paywalls</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="paywalls"
row-key="id"
:columns="paywallsTable.columns"
:pagination.sync="paywallsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.displayUrl"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.url"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deletePaywall(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} paywall extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "paywall/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createPaywall" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.url"
type="url"
label="Redirect URL *"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.memo"
label="Title *"
placeholder="LNbits paywall"
></q-input>
<q-input
filled
dense
autogrow
v-model.trim="formDialog.data.description"
label="Description"
></q-input>
<q-input
filled
dense
v-model.number="formDialog.data.amount"
type="number"
label="Amount (sat) *"
hint="This is the minimum amount users can pay/donate."
></q-input>
<q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
<q-checkbox
v-model="formDialog.data.remembers"
color="primary"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label>Remember payments</q-item-label>
<q-item-label caption
>A succesful payment will be registered in the browser's
storage, so the user doesn't need to pay again to access the
URL.</q-item-label
>
</q-item-section>
</q-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.url == null || formDialog.data.memo == null"
type="submit"
>Create paywall</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapPaywall = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.displayUrl = ['/paywall/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
paywalls: [],
paywallsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'memo', align: 'left', label: 'Memo', field: 'memo'},
{
name: 'amount',
align: 'right',
label: 'Amount (sat)',
field: 'fsat',
sortable: true,
sort: function (a, b, rowA, rowB) {
return rowA.amount - rowB.amount
}
},
{
name: 'remembers',
align: 'left',
label: 'Remember',
field: 'remembers'
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {
remembers: false
}
}
}
},
methods: {
getPaywalls: function () {
var self = this
LNbits.api
.request(
'GET',
'/paywall/api/v1/paywalls?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.paywalls = response.data.map(function (obj) {
return mapPaywall(obj)
})
})
},
createPaywall: function () {
var data = {
url: this.formDialog.data.url,
memo: this.formDialog.data.memo,
amount: this.formDialog.data.amount,
description: this.formDialog.data.description,
remembers: this.formDialog.data.remembers
}
var self = this
LNbits.api
.request(
'POST',
'/paywall/api/v1/paywalls',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.paywalls.push(mapPaywall(response.data))
self.formDialog.show = false
self.formDialog.data = {
remembers: false
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deletePaywall: function (paywallId) {
var self = this
var paywall = _.findWhere(this.paywalls, {id: paywallId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this paywall link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/paywall/api/v1/paywalls/' + paywallId,
_.findWhere(self.g.user.wallets, {id: paywall.wallet}).inkey
)
.then(function (response) {
self.paywalls = _.reject(self.paywalls, function (obj) {
return obj.id == paywallId
})
})
.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) {
this.getPaywalls()
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,26 @@
from http import HTTPStatus
from fastapi import Depends
from starlette.exceptions import HTTPException
from starlette.requests import Request
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import paywall_ext, paywall_renderer
from .crud import get_paywall
@paywall_ext.get("/")
async def index(request: Request, user: User = Depends(check_user_exists)):
return paywall_renderer().TemplateResponse("paywall/index.html", {"request": request, "user": user.dict()})
@paywall_ext.get("/{paywall_id}")
async def display(request: Request, paywall_id):
paywall = await get_paywall(paywall_id)
if not paywall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
)
return paywall_renderer().TemplateResponse("paywall/display.html", {"request": request, "paywall": paywall})

View File

@ -0,0 +1,105 @@
from http import HTTPStatus
from fastapi import Depends, Query
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from . import paywall_ext
from .crud import create_paywall, delete_paywall, get_paywall, get_paywalls
from .models import CheckPaywallInvoice, CreatePaywall, CreatePaywallInvoice
@paywall_ext.get("/api/v1/paywalls")
async def api_paywalls(wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [paywall.dict() for paywall in await get_paywalls(wallet_ids)]
@paywall_ext.post("/api/v1/paywalls")
async def api_paywall_create(data: CreatePaywall, wallet: WalletTypeInfo = Depends(get_key_type)):
paywall = await create_paywall(wallet_id=wallet.wallet.id, data=data)
return paywall.dict()
@paywall_ext.delete("/api/v1/paywalls/{paywall_id}")
async def api_paywall_delete(paywall_id, wallet: WalletTypeInfo = Depends(get_key_type)):
paywall = await get_paywall(paywall_id)
if not paywall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Paywall does not exist."
)
if paywall.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your paywall."
)
await delete_paywall(paywall_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@paywall_ext.post("/api/v1/paywalls/{paywall_id}/invoice")
async def api_paywall_create_invoice(paywall_id, data: CreatePaywallInvoice, wallet: WalletTypeInfo = Depends(get_key_type)):
paywall = await get_paywall(paywall_id)
print("PAYW", paywall)
print("DATA", data)
if data.amount < paywall.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Minimum amount is {paywall.amount} sat."
)
try:
amount = (
data.amount if 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:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(e)
)
return {"payment_hash": payment_hash, "payment_request": payment_request}
@paywall_ext.post("/api/v1/paywalls/{paywall_id}/check_invoice")
async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id):
paywall = await get_paywall(paywall_id)
payment_hash = data.payment_hash
if not paywall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Paywall does not exist."
)
try:
status = await check_invoice_status(paywall.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return {"paid": False}
if is_paid:
wallet = await get_wallet(paywall.wallet)
payment = await wallet.get_payment(payment_hash)
await payment.set_pending(False)
return {"paid": True, "url": paywall.url, "remembers": paywall.remembers}
return {"paid": False}

View File

@ -1,21 +1,22 @@
from datetime import datetime from datetime import datetime
from typing import List, Optional, Union from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from typing import List, Optional
from . import db from . import db
from .models import ( from .models import (
satsdiceWithdraw,
HashCheck,
satsdiceLink,
satsdicePayment,
CreateSatsDiceLink, CreateSatsDiceLink,
CreateSatsDicePayment, CreateSatsDicePayment,
CreateSatsDiceWithdraw, CreateSatsDiceWithdraw,
HashCheck,
satsdiceLink,
satsdicePayment,
satsdiceWithdraw,
) )
from lnbits.helpers import urlsafe_short_hash
async def create_satsdice_pay( async def create_satsdice_pay(
wallet_id: str,
data: CreateSatsDiceLink, data: CreateSatsDiceLink,
) -> satsdiceLink: ) -> satsdiceLink:
satsdice_id = urlsafe_short_hash() satsdice_id = urlsafe_short_hash()
@ -40,7 +41,7 @@ async def create_satsdice_pay(
""", """,
( (
satsdice_id, satsdice_id,
data.wallet_id, wallet_id,
data.title, data.title,
data.base_url, data.base_url,
data.min_bet, data.min_bet,

View File

@ -1,33 +1,24 @@
import shortuuid # type: ignore
import hashlib import hashlib
import math
import json import json
import math
from http import HTTPStatus from http import HTTPStatus
from datetime import datetime
from lnbits.core.services import pay_invoice, create_invoice from fastapi import Request
from http import HTTPStatus
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse, JSONResponse # type: ignore
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
from fastapi import FastAPI, Request
from fastapi.params import Depends
from typing import Optional
from fastapi.param_functions import Query from fastapi.param_functions import Query
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse # type: ignore
from lnbits.core.services import create_invoice, pay_invoice
from . import satsdice_ext from . import satsdice_ext
from .crud import ( from .crud import (
create_satsdice_payment,
get_satsdice_pay,
get_satsdice_withdraw_by_hash, get_satsdice_withdraw_by_hash,
update_satsdice_withdraw, update_satsdice_withdraw,
get_satsdice_pay,
create_satsdice_payment,
)
from lnurl import (
LnurlPayResponse,
LnurlPayActionResponse,
LnurlErrorResponse,
) )
from .models import CreateSatsDicePayment from .models import CreateSatsDicePayment
##############LNURLP STUFF ##############LNURLP STUFF

View File

@ -342,7 +342,7 @@
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/satsdice/api/v1/links?all_wallets', '/satsdice/api/v1/links?all_wallets=true',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(response => { .then(response => {
@ -446,7 +446,7 @@
key === 'success_url') && key === 'success_url') &&
(value === null || value === '') (value === null || value === '')
) )
LNbits.api LNbits.api
.request( .request(
'PUT', 'PUT',
@ -516,9 +516,9 @@
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
var getPayLinks = this.getPayLinks var getPayLinks = this.getPayLinks
getPayLinks() getPayLinks()
this.checker = setInterval(() => { // this.checker = setInterval(() => {
getPayLinks() // getPayLinks()
}, 20000) // }, 20000)
} }
} }
}) })

View File

@ -1,48 +1,53 @@
import random
from datetime import datetime from datetime import datetime
from http import HTTPStatus from http import HTTPStatus
from lnbits.decorators import check_user_exists, WalletTypeInfo, get_key_type
from . import satsdice_ext, satsdice_renderer
from .crud import (
get_satsdice_pay,
update_satsdice_payment,
get_satsdice_payment,
create_satsdice_withdraw,
get_satsdice_withdraw,
)
from lnbits.core.crud import (
get_payments,
get_standalone_payment,
delete_expired_invoices,
get_balance_checks,
)
from lnbits.core.views.api import api_payment
from lnbits.core.services import check_invoice_status
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from lnbits.core.models import User, Payment
from fastapi.params import Depends from lnbits.core.crud import (
from fastapi.param_functions import Query delete_expired_invoices,
import random get_balance_checks,
from .models import CreateSatsDiceWithdraw get_payments,
get_standalone_payment,
)
from lnbits.core.models import Payment, User
from lnbits.core.services import check_invoice_status
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, check_user_exists, get_key_type
from . import satsdice_ext, satsdice_renderer
from .crud import (
create_satsdice_withdraw,
get_satsdice_pay,
get_satsdice_payment,
get_satsdice_withdraw,
update_satsdice_payment,
)
from .models import CreateSatsDiceWithdraw, satsdiceLink
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@satsdice_ext.get("/") @satsdice_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(request: Request, user: User = Depends(check_user_exists)):
return satsdice_renderer().TemplateResponse( return satsdice_renderer().TemplateResponse(
"satsdice/index.html", {"request": request, "user": user.dict()} "satsdice/index.html", {"request": request, "user": user.dict()}
) )
@satsdice_ext.get("/{link_id}") @satsdice_ext.get("/{link_id}", response_class=HTMLResponse)
async def display(request: Request, link_id: str = Query(None)): async def display(request: Request, link_id: str = Query(None)):
link = await get_satsdice_pay(link_id) or abort( link = await get_satsdice_pay(link_id)
HTTPStatus.NOT_FOUND, "satsdice link does not exist." if not link:
) raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="satsdice link does not exist."
)
return satsdice_renderer().TemplateResponse( return satsdice_renderer().TemplateResponse(
"satsdice/display.html", "satsdice/display.html",
{ {
@ -55,13 +60,15 @@ async def display(request: Request, link_id: str = Query(None)):
) )
@satsdice_ext.get("/win/{link_id}/{payment_hash}", name="satsdice.displaywin") @satsdice_ext.get("/win/{link_id}/{payment_hash}", name="satsdice.displaywin", response_class=HTMLResponse)
async def displaywin( async def displaywin(
request: Request, link_id: str = Query(None), payment_hash: str = Query(None) request: Request, link_id: str = Query(None), payment_hash: str = Query(None)
): ):
satsdicelink = await get_satsdice_pay(link_id) or abort( satsdicelink = await get_satsdice_pay(link_id)
HTTPStatus.NOT_FOUND, "satsdice link does not exist." if not satsdiceLink:
) raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="satsdice link does not exist."
)
withdrawLink = await get_satsdice_withdraw(payment_hash) withdrawLink = await get_satsdice_withdraw(payment_hash)
if withdrawLink: if withdrawLink:
@ -118,7 +125,7 @@ async def displaywin(
) )
@satsdice_ext.get("/img/{link_id}") @satsdice_ext.get("/img/{link_id}", response_class=HTMLResponse)
async def img(link_id): async def img(link_id):
link = await get_satsdice_pay(link_id) or abort( link = await get_satsdice_pay(link_id) or abort(
HTTPStatus.NOT_FOUND, "satsdice link does not exist." HTTPStatus.NOT_FOUND, "satsdice link does not exist."

View File

@ -31,7 +31,7 @@ from .models import CreateSatsDiceLink, CreateSatsDiceWithdraws
async def api_links( async def api_links(
request: Request, request: Request,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type),
all_wallets: str = Query(None), all_wallets: bool = Query(False),
): ):
wallet_ids = [wallet.wallet.id] wallet_ids = [wallet.wallet.id]
@ -81,7 +81,6 @@ async def api_link_create_or_update(
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Bad request") raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Bad request")
if link_id: if link_id:
link = await get_satsdice_pay(link_id) link = await get_satsdice_pay(link_id)
if not link: if not link:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Satsdice does not exist" status_code=HTTPStatus.NOT_FOUND, detail="Satsdice does not exist"
@ -92,11 +91,11 @@ async def api_link_create_or_update(
status_code=HTTPStatus.FORBIDDEN, status_code=HTTPStatus.FORBIDDEN,
detail="Come on, seriously, this isn't your satsdice!", detail="Come on, seriously, this isn't your satsdice!",
) )
data.link_id = link_id
link = await update_satsdice_pay(data)
else:
data.wallet_id = wallet.wallet.id data.wallet_id = wallet.wallet.id
link = await create_satsdice_pay(data) link = await update_satsdice_pay(link_id, **data.dict())
else:
link = await create_satsdice_pay(wallet_id=wallet.wallet.id, data=data)
return {**link.dict(), **{"lnurl": link.lnurl}} return {**link.dict(), **{"lnurl": link.lnurl}}

View File

@ -0,0 +1,39 @@
<h1>Stream Alerts</h1>
<h2>Integrate Bitcoin Donations into your livestream alerts</h2>
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)
<h2>How to set it up</h2>
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).
<h3>CONGRATS! Let the sats flow!</h3>

View File

@ -0,0 +1,17 @@
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_streamalerts")
streamalerts_ext: APIRouter = APIRouter(
prefix="/streamalerts",
tags=["streamalerts"]
)
def streamalerts_renderer():
return template_renderer(["lnbits/extensions/streamalerts/templates"])
from .views import * # noqa
from .views_api import * # noqa

View File

@ -0,0 +1,6 @@
{
"name": "Stream Alerts",
"short_description": "Bitcoin donations in stream alerts",
"icon": "notifications_active",
"contributors": ["Fittiboy"]
}

View File

@ -0,0 +1,283 @@
from http import HTTPStatus
from typing import Optional
import httpx
from lnbits.core.crud import get_wallet
from lnbits.db import SQLITE
from lnbits.helpers import urlsafe_short_hash
from ..satspay.crud import delete_charge # type: ignore
from . import db
from .models import CreateService, Donation, Service
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.url.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 {"message": "Donation not found!"}
if donation.posted:
return {"message": "Donation has already been posted!"}
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 {"message": "StreamElements not yet supported!"}
else:
return {"message": "Unsopported servicename"}
await db.execute(
"UPDATE streamalerts.Donations SET posted = 1 WHERE id = ?", (donation_id,)
)
return response.json()
async def create_service(
data: CreateService
) -> 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}
""",
(
data.twitchuser,
data.client_id,
data.client_secret,
data.wallet,
data.servicename,
False,
urlsafe_short_hash(),
data.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)

View File

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

View File

@ -0,0 +1,65 @@
from sqlite3 import Row
from typing import Optional
from fastapi.params import Query
from pydantic.main import BaseModel
class CreateService(BaseModel):
twitchuser: str = Query(...)
client_id: str = Query(...)
client_secret: str = Query(...)
wallet: str = Query(...)
servicename: str = Query(...)
onchain: str = Query(None)
class CreateDonation(BaseModel):
name: str = Query("Anonymous")
sats: int = Query(..., ge=1)
service: int = Query(...)
message: str = Query("")
class ValidateDonation(BaseModel):
id: str = Query(...)
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: Optional[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))

View File

@ -0,0 +1,18 @@
<q-card>
<q-card-section>
<h4 class="text-subtitle1 q-my-none">
Stream Alerts: Integrate Bitcoin into your stream alerts!
</h4>
<p>
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!<br />
For detailed setup instructions, check out
<a href="https://github.com/Fittiboy/bitcoin-on-twitch"> this guide!</a
><br />
<small>
Created by, <a href="https://github.com/Fittiboy">Fitti</a></small
>
</p>
</q-card-section>
</q-card>

View File

@ -0,0 +1,97 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h5 class="q-my-none">Donate Bitcoin to {{ twitchuser }}</h5>
<br />
<q-form @submit="Invoice()" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="donationDialog.data.name"
maxlength="25"
type="name"
label="Your Name (leave blank for Anonymous donation)"
></q-input>
<q-input
filled
dense
v-model.number="donationDialog.data.sats"
type="number"
min="1"
max="2100000000000000"
suffix="sats"
:rules="[val => val > 0 || 'Choose a positive number of sats!']"
label="Amount of sats"
></q-input>
<q-input
filled
dense
v-model.trim="donationDialog.data.message"
maxlength="255"
type="textarea"
label="Donation Message (you can leave this blank too)"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="donationDialog.data.sats < 1 || !donationDialog.data.sats"
type="submit"
>Submit</q-btn
>
</div>
</q-form>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
paymentReq: null,
redirectUrl: null,
donationDialog: {
show: false,
data: {
name: '',
sats: '',
message: ''
}
},
receive: {
show: false,
status: 'pending',
paymentReq: null
}
}
},
methods: {
Invoice: function () {
var self = this
axios
.post('/streamalerts/api/v1/donations', {
service: {{ service }},
name: self.donationDialog.data.name,
sats: self.donationDialog.data.sats,
message: self.donationDialog.data.message
})
.then(function (response) {
self.redirect_url = response.data.redirect_url
console.log(self.redirect_url)
window.location.href = self.redirect_url
})
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,502 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="serviceDialog.show = true"
>New Service</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Services</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportservicesCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="services"
row-key="id"
:columns="servicesTable.columns"
:pagination.sync="servicesTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.authUrl"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="send"
:color="($q.dark.isActive) ? 'grey-8' : 'grey-6'"
type="a"
:href="props.row.displayUrl"
target="_blank"
></q-btn>
<a :href="props.row.redirectURI">Redirect URI for Streamlabs</a>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteService(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Donations</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportdonationsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="donations"
:columns="donationsTable.columns"
:pagination.sync="donationsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteDonation(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Stream Alerts extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "streamalerts/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="serviceDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendServiceData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="serviceDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<div class="row">
<div class="col">
<div v-if="walletLinks.length > 0">
<q-checkbox v-model="serviceDialog.data.chain" label="Chain" />
</div>
<div v-else>
<q-checkbox :value="false" label="Chain" disabled>
<q-tooltip>
Watch-Only extension MUST be activated and have a wallet
</q-tooltip>
</q-checkbox>
</div>
</div>
</div>
<div v-if="serviceDialog.data.chain">
<q-select
filled
dense
emit-value
v-model="serviceDialog.data.onchain"
:options="walletLinks"
label="Chain Wallet"
/>
</div>
<q-input
filled
dense
v-model.trim="serviceDialog.data.twitchuser"
type="name"
label="Twitch Username *"
></q-input>
<q-select
filled
dense
emit-value
v-model="serviceDialog.data.servicename"
:options="servicenames"
label="Streamlabs"
hint="The service you use for alerts. (Currently only Streamlabs)"
></q-select>
<q-input
filled
dense
v-model.trim="serviceDialog.data.client_id"
type="name"
label="Client ID *"
></q-input>
<q-input
filled
dense
v-model.trim="serviceDialog.data.client_secret"
type="name"
label="Client Secret *"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="serviceDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Service</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="serviceDialog.data.client_id == null || serviceDialog.data.client_secret == 0 || serviceDialog.data.twitchuser == null"
type="submit"
>Create Service</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapStreamAlerts = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.redirectURI = ['/streamalerts/api/v1/authenticate/', obj.id].join('')
obj.authUrl = ['/streamalerts/api/v1/getaccess/', obj.id].join('')
obj.displayUrl = ['/streamalerts/', obj.state].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
servicenames: ['Streamlabs'],
services: [],
donations: [],
walletLinks: [],
servicesTable: {
columns: [
{
name: 'id',
align: 'left',
label: 'ID',
field: 'id'
},
{
name: 'twitchuser',
align: 'left',
label: 'Twitch Username',
field: 'twitchuser'
},
{
name: 'wallet',
align: 'left',
label: 'Wallet',
field: 'wallet'
},
{
name: 'onchain address',
align: 'left',
label: 'Onchain Address',
field: 'onchain'
},
{
name: 'servicename',
align: 'left',
label: 'Service',
field: 'servicename'
},
{
name: 'client_id',
align: 'left',
label: 'Client ID',
field: 'client_id'
},
{
name: 'client_secret',
align: 'left',
label: 'Client Secret',
field: 'client_secret'
},
{
name: 'authenticated',
align: 'left',
label: 'Authenticated',
field: 'authenticated'
}
],
pagination: {
rowsPerPage: 10
}
},
donationsTable: {
columns: [
{
name: 'service',
align: 'left',
label: 'Service',
field: 'service'
},
{name: 'id', align: 'left', label: 'Charge ID', field: 'id'},
{name: 'name', align: 'left', label: 'Donor', field: 'name'},
{
name: 'message',
align: 'left',
label: 'Message',
field: 'message'
},
{name: 'sats', align: 'left', label: 'Sats', field: 'sats'},
{
name: 'posted',
align: 'left',
label: 'Posted to API',
field: 'posted'
}
],
pagination: {
rowsPerPage: 10
}
},
serviceDialog: {
show: false,
chain: false,
data: {}
}
}
},
methods: {
getWalletLinks: function () {
var self = this
LNbits.api
.request(
'GET',
'/watchonly/api/v1/wallet',
this.g.user.wallets[0].inkey
)
.then(function (response) {
for (i = 0; i < response.data.length; i++) {
self.walletLinks.push(response.data[i].id)
}
return
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getDonations: function () {
var self = this
LNbits.api
.request(
'GET',
'/streamalerts/api/v1/donations',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.donations = response.data.map(function (obj) {
return mapStreamAlerts(obj)
})
})
},
deleteDonation: function (donationId) {
var self = this
var donations = _.findWhere(this.donations, {id: donationId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this donation?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/streamalerts/api/v1/donations/' + donationId,
_.findWhere(self.g.user.wallets, {id: donations.wallet}).inkey
)
.then(function (response) {
self.donations = _.reject(self.donations, function (obj) {
return obj.id == ticketId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportdonationsCSV: function () {
LNbits.utils.exportCSV(this.donationsTable.columns, this.donations)
},
getServices: function () {
var self = this
LNbits.api
.request(
'GET',
'/streamalerts/api/v1/services',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.services = response.data.map(function (obj) {
return mapStreamAlerts(obj)
})
})
},
sendServiceData: function () {
var wallet = _.findWhere(this.g.user.wallets, {
id: this.serviceDialog.data.wallet
})
var data = this.serviceDialog.data
this.createService(wallet, data)
},
createService: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/streamalerts/api/v1/services', wallet.inkey, data)
.then(function (response) {
self.services.push(mapStreamAlerts(response.data))
self.serviceDialog.show = false
self.serviceDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
updateserviceDialog: function (serviceId) {
var link = _.findWhere(this.services, {id: serviceId})
console.log(link.id)
this.serviceDialog.data.id = link.id
this.serviceDialog.data.wallet = link.wallet
this.serviceDialog.data.twitchuser = link.twitchuser
this.serviceDialog.data.servicename = link.servicename
this.serviceDialog.data.client_id = link.client_id
this.serviceDialog.data.client_secret = link.client_secret
this.serviceDialog.show = true
},
deleteService: function (servicesId) {
var self = this
var services = _.findWhere(this.services, {id: servicesId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this service link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/streamalerts/api/v1/services/' + servicesId,
_.findWhere(self.g.user.wallets, {id: services.wallet}).inkey
)
.then(function (response) {
self.services = _.reject(self.services, function (obj) {
return obj.id == servicesId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportservicesCSV: function () {
LNbits.utils.exportCSV(this.servicesTable.columns, this.services)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getWalletLinks()
this.getDonations()
this.getServices()
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,39 @@
from http import HTTPStatus
from fastapi.param_functions import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import streamalerts_ext, streamalerts_renderer
from .crud import get_service
templates = Jinja2Templates(directory="templates")
@streamalerts_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
"""Return the extension's settings page"""
return streamalerts_renderer().TemplateResponse("streamalerts/index.html", {"request": request, "user": user.dict()})
@streamalerts_ext.get("/{state}")
async def donation(state, request: Request):
"""Return the donation form for the Service corresponding to state"""
service = await get_service(0, by_state=state)
if not service:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Service does not exist."
)
return streamalerts_renderer().TemplateResponse(
"streamalerts/display.html",
{
"request": request,
"twitchuser": service.twitchuser,
"service":service.id
}
)

View File

@ -0,0 +1,269 @@
from http import HTTPStatus
from fastapi.params import Depends, Query
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import RedirectResponse
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.streamalerts.models import (
CreateDonation,
CreateService,
ValidateDonation,
)
from lnbits.utils.exchange_rates import btc_price
from ..satspay.crud import create_charge, get_charge
from . import streamalerts_ext
from .crud import (
authenticate_service,
create_donation,
create_service,
delete_donation,
delete_service,
get_charge_details,
get_donation,
get_donations,
get_service,
get_service_redirect_uri,
get_services,
post_donation,
update_donation,
update_service,
)
@streamalerts_ext.post("/api/v1/services")
async def api_create_service(data : CreateService, wallet: WalletTypeInfo = Depends(get_key_type)):
"""Create a service, which holds data about how/where to post donations"""
try:
service = await create_service(data=data)
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)
)
return service.dict()
@streamalerts_ext.get("/api/v1/getaccess/{service_id}")
async def api_get_access(service_id, request: Request):
"""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 RedirectResponse(redirect_url)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Service does not exist!"
)
@streamalerts_ext.get("/api/v1/authenticate/{service_id}")
async def api_authenticate_service(service_id, request: Request, code: str = Query(...), state: str = Query(...)):
"""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.
"""
service = await get_service(service_id)
if service.state != state:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="State doesn't match!"
)
redirect_uri = request.url.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 RedirectResponse(url)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Service already authenticated!"
)
@streamalerts_ext.post("/api/v1/donations")
async def api_create_donation(data: CreateDonation, request: Request):
"""Take data from donation form and return satspay charge"""
# Currency is hardcoded while frotnend is limited
cur_code = "USD"
sats = data.sats
message = data.message
# Fiat amount is calculated here while frontend is limited
price = await btc_price(cur_code)
amount = sats * (10 ** (-8)) * price
webhook_base = request.url.scheme + "://" + request.headers["Host"]
service_id = data.service
service = await get_service(service_id)
charge_details = await get_charge_details(service.id)
name = data.name
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=data.sats,
amount=amount,
service=data.service
)
return {"redirect_url": f"/satspay/{charge.id}"}
@streamalerts_ext.post("/api/v1/postdonation")
async def api_post_donation(request: Request, data: ValidateDonation):
"""Post a paid donation to Stremalabs/StreamElements.
This endpoint acts as a webhook for the SatsPayServer extension."""
donation_id = data.id
charge = await get_charge(donation_id)
if charge and charge.paid:
return await post_donation(donation_id)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Not a paid charge!"
)
@streamalerts_ext.get("/api/v1/services")
async def api_get_services(g: WalletTypeInfo = Depends(get_key_type)):
"""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 [service.dict() for service in services] if services else []
@streamalerts_ext.get("/api/v1/donations")
async def api_get_donations(g: WalletTypeInfo = Depends(get_key_type)):
"""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 [donation._asdict() for donation in donations] if donations else []
@streamalerts_ext.put("/api/v1/donations/{donation_id}")
async def api_update_donation(data: CreateDonation, donation_id=None, g: WalletTypeInfo = Depends(get_key_type)):
"""Update a donation with the data given in the request"""
if donation_id:
donation = await get_donation(donation_id)
if not donation:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Donation does not exist."
)
if donation.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your donation."
)
donation = await update_donation(donation_id, **data.dict())
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="No donation ID specified"
)
return donation.dict()
@streamalerts_ext.put("/api/v1/services/{service_id}")
async def api_update_service(data: CreateService, service_id=None, g: WalletTypeInfo = Depends(get_key_type)):
"""Update a service with the data given in the request"""
if service_id:
service = await get_service(service_id)
if not service:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Service does not exist."
)
if service.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your service."
)
service = await update_service(service_id, **data.dict())
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="No service ID specified"
)
return service.dict()
@streamalerts_ext.delete("/api/v1/donations/{donation_id}")
async def api_delete_donation(donation_id, g: WalletTypeInfo = Depends(get_key_type)):
"""Delete the donation with the given donation_id"""
donation = await get_donation(donation_id)
if not donation:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="No donation with this ID!"
)
if donation.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not authorized to delete this donation!"
)
await delete_donation(donation_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@streamalerts_ext.delete("/api/v1/services/{service_id}")
async def api_delete_service(service_id, g: WalletTypeInfo = Depends(get_key_type)):
"""Delete the service with the given service_id"""
service = await get_service(service_id)
if not service:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="No service with this ID!"
)
if service.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not authorized to delete this service!"
)
await delete_service(service_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)

View File

@ -0,0 +1,54 @@
<h1>Subdomains Extension</h1>
So the goal of the extension is to allow the owner of a domain to sell 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
<img src="https://i.imgur.com/xOgapHr.png">
4. Get Cloudflare API TOKEN
<img src="https://i.imgur.com/BZbktTy.png">
<img src="https://i.imgur.com/YDZpW7D.png">
5. Open the LNBits subdomains extension and register your domain
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\
<img src="https://i.imgur.com/hiauxeR.png">
## API Endpoints
- **Domains**
- GET /api/v1/domains
- POST /api/v1/domains
- PUT /api/v1/domains/<domain_id>
- DELETE /api/v1/domains/<domain_id>
- **Subdomains**
- GET /api/v1/subdomains
- POST /api/v1/subdomains/<domain_id>
- GET /api/v1/subdomains/<payment_hash>
- DELETE /api/v1/subdomains/<subdomain_id>
### Cloudflare
- Cloudflare offers programmatic subdomain registration... (create new A record)
- you can keep your existing domain's registrar, you just have to transfer dns records to the cloudflare (free service)
- more information:
- https://api.cloudflare.com/#getting-started-requests
- API endpoints needed for our project:
- https://api.cloudflare.com/#dns-records-for-a-zone-list-dns-records
- https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record
- https://api.cloudflare.com/#dns-records-for-a-zone-delete-dns-record
- https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record
- api can be used by providing authorization token OR authorization key
- check API Tokens and API Keys : https://api.cloudflare.com/#getting-started-requests
- Cloudflare API postman collection: https://support.cloudflare.com/hc/en-us/articles/115002323852-Using-Cloudflare-API-with-Postman-Collections

View File

@ -0,0 +1,28 @@
import asyncio
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_subdomains")
subdomains_ext: APIRouter = APIRouter(
prefix="/subdomains",
tags=["subdomains"]
)
def subdomains_renderer():
return template_renderer(["lnbits/extensions/subdomains/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def subdomains_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -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,
"proxied": False,
},
timeout=40,
)
cf_response = json.loads(r.text)
except AssertionError:
cf_response = "Error occured"
return cf_response
async def cloudflare_deletesubdomain(domain: Domains, domain_id: str):
url = (
"https://api.cloudflare.com/client/v4/zones/"
+ domain.cf_zone_id
+ "/dns_records"
)
header = {
"Authorization": "Bearer " + domain.cf_token,
"Content-Type": "application/json",
}
async with httpx.AsyncClient() as client:
try:
r = await client.delete(
url + "/" + domain_id,
headers=header,
timeout=40,
)
cf_response = r.text
except AssertionError:
cf_response = "Error occured"

View File

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

View File

@ -0,0 +1,168 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import CreateDomain, Domains, Subdomains
async def create_subdomain(
payment_hash,
wallet,
data: CreateDomain
) -> Subdomains:
await db.execute(
"""
INSERT INTO subdomains.subdomain (id, domain, email, subdomain, ip, wallet, sats, duration, paid, record_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payment_hash,
data.domain,
data.email,
data.subdomain,
data.ip,
wallet,
data.sats,
data.duration,
False,
data.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(
data: CreateDomain
) -> 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,
data.wallet,
data.domain,
data.webhook,
data.cf_token,
data.cf_zone_id,
data.description,
data.cost,
0,
data.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,))

View File

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

View File

@ -0,0 +1,49 @@
from fastapi.params import Query
from pydantic.main import BaseModel
class CreateDomain(BaseModel):
wallet: str = Query(...)
domain: str = Query(...)
cf_token: str = Query(...)
cf_zone_id: str = Query(...)
webhook: str = Query("")
description: str = Query(..., min_length=0)
cost: int = Query(..., ge=0)
allowed_record_types: str = Query(...)
class CreateSubdomain(BaseModel):
domain: str = Query(...)
subdomain: str = Query(...)
email: str = Query(...)
ip: str = Query(...)
sats: int = Query(..., ge=0)
duration: int = Query(...)
record_type: str = Query(...)
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

View File

@ -0,0 +1,67 @@
import asyncio
import httpx
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from .cloudflare import cloudflare_create_subdomain
from .crud import get_domain, set_subdomain_paid
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
# async def register_listeners():
# invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
# register_invoice_listener(invoice_paid_chan_send)
# await wait_for_paid_invoices(invoice_paid_chan_recv)
# async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
# async for payment in invoice_paid_chan:
# await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if "lnsubdomain" != payment.extra.get("tag"):
# not an lnurlp invoice
return
await payment.set_pending(False)
subdomain = await set_subdomain_paid(payment_hash=payment.payment_hash)
domain = await get_domain(subdomain.domain)
### Create subdomain
cf_response = cloudflare_create_subdomain(
domain=domain,
subdomain=subdomain.subdomain,
record_type=subdomain.record_type,
ip=subdomain.ip,
)
### Use webhook to notify about cloudflare registration
if domain.webhook:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
domain.webhook,
json={
"domain": subdomain.domain_name,
"subdomain": subdomain.subdomain,
"record_type": subdomain.record_type,
"email": subdomain.email,
"ip": subdomain.ip,
"cost:": str(subdomain.sats) + " sats",
"duration": str(subdomain.duration) + " days",
"cf_response": cf_response,
},
timeout=40,
)
except AssertionError:
webhook = None

View File

@ -0,0 +1,26 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="About lnSubdomains"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
lnSubdomains: Get paid sats to sell your subdomains
</h5>
<p>
Charge people for using your subdomain name...<br />
<a
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/subdomains"
>More details</a
>
<br />
<small>
Created by, <a href="https://github.com/grmkris">Kris</a></small
>
</p>
</q-card-section>
</q-card>
</q-expansion-item>

View File

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

View File

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

View File

@ -0,0 +1,36 @@
from lnbits.extensions.subdomains.models import Subdomains
# Python3 program to validate
# domain name
# using regular expression
import re
import socket
# Function to validate domain name.
def isValidDomain(str):
# Regex to check valid
# domain name.
regex = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,6}"
# Compile the ReGex
p = re.compile(regex)
# If the string is empty
# return false
if str == None:
return False
# Return if the string
# matched the ReGex
if re.search(p, str):
return True
else:
return False
# Function to validate IP address
def isvalidIPAddress(str):
try:
socket.inet_aton(str)
return True
except socket.error:
return False

View File

@ -0,0 +1,43 @@
from http import HTTPStatus
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import subdomains_ext, subdomains_renderer
from .crud import get_domain
templates = Jinja2Templates(directory="templates")
@subdomains_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return subdomains_renderer().TemplateResponse("subdomains/index.html", {"request": request, "user": user.dict()})
@subdomains_ext.get("/{domain_id}")
async def display(request: Request, domain_id):
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Domain does not exist."
)
allowed_records = (
domain.allowed_record_types.replace('"', "").replace(" ", "").split(",")
)
return subdomains_renderer().TemplateResponse(
"subdomains/display.html",{
"request": request,
"domain_id": domain.id,
"domain_domain": domain.domain,
"domain_desc": domain.description,
"domain_cost": domain.cost,
"domain_allowed_record_types": allowed_records,
}
)

View File

@ -0,0 +1,195 @@
from http import HTTPStatus
from fastapi import Query
from fastapi.params import Depends
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.subdomains.models import CreateDomain, CreateSubdomain
from . import subdomains_ext
from .cloudflare import cloudflare_create_subdomain, cloudflare_deletesubdomain
from .crud import (
create_domain,
create_subdomain,
delete_domain,
delete_subdomain,
get_domain,
get_domains,
get_subdomain,
get_subdomainBySubdomain,
get_subdomains,
update_domain,
)
# domainS
@subdomains_ext.get("/api/v1/domains")
async def api_domains(g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return [domain.dict() for domain in await get_domains(wallet_ids)]
@subdomains_ext.post("/api/v1/domains")
@subdomains_ext.put("/api/v1/domains/{domain_id}")
async def api_domain_create(data: CreateDomain, domain_id=None, g: WalletTypeInfo = Depends(get_key_type)):
if domain_id:
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Domain does not exist."
)
if domain.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your domain."
)
domain = await update_domain(domain_id, **data.dict())
else:
domain = await create_domain(data=data)
return domain.dict()
@subdomains_ext.delete("/api/v1/domains/{domain_id}")
async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)):
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Domain does not exist."
)
if domain.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your domain."
)
await delete_domain(domain_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
#########subdomains##########
@subdomains_ext.get("/api/v1/subdomains")
async def api_subdomains(all_wallets: bool = Query(False), g: WalletTypeInfo = Depends(get_key_type)):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return [domain.dict() for domain in await get_subdomains(wallet_ids)]
@subdomains_ext.post("/api/v1/subdomains/{domain_id}")
async def api_subdomain_make_subdomain(domain_id, data: CreateSubdomain):
domain = await get_domain(domain_id)
# If the request is coming for the non-existant domain
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="LNsubdomain does not exist."
)
## If record_type is not one of the allowed ones reject the request
if data.record_type not in domain.allowed_record_types:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{data.record_type} not a valid record."
)
## If domain already exist in our database reject it
if await get_subdomainBySubdomain(data.subdomain) is not None:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{data.subdomain}.{domain.domain} domain already taken."
)
## Dry run cloudflare... (create and if create is sucessful delete it)
cf_response = await cloudflare_create_subdomain(
domain=domain,
subdomain=data.subdomain,
record_type=data.record_type,
ip=data.ip,
)
if cf_response["success"] == True:
cloudflare_deletesubdomain(domain=domain, domain_id=cf_response["result"]["id"])
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f'Problem with cloudflare: {cf_response["errors"][0]["message"]}'
)
## ALL OK - create an invoice and return it to the user
sats = data.sats
try:
payment_hash, payment_request = await create_invoice(
wallet_id=domain.wallet,
amount=sats,
memo=f"subdomain {data.subdomain}.{domain.domain} for {sats} sats for {data.duration} days",
extra={"tag": "lnsubdomain"},
)
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(e)
)
subdomain = await create_subdomain(
payment_hash=payment_hash, wallet=domain.wallet, data=data
)
if not subdomain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="LNsubdomain could not be fetched."
)
return {"payment_hash": payment_hash, "payment_request": payment_request}
@subdomains_ext.get("/api/v1/subdomains/{payment_hash}")
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 {"paid": False}
if is_paid:
return {"paid": True}
return {"paid": False}
@subdomains_ext.delete("/api/v1/subdomains/{subdomain_id}")
async def api_subdomain_delete(subdomain_id, g: WalletTypeInfo = Depends(get_key_type)):
subdomain = await get_subdomain(subdomain_id)
if not subdomain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="LNsubdomain does not exist."
)
if subdomain.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your subdomain."
)
await delete_subdomain(subdomain_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)

View File

@ -439,7 +439,7 @@
this.getWalletLinks() this.getWalletLinks()
this.getTipJars() this.getTipJars()
this.getTips() this.getTips()
this.getServices() // this.getServices()
} }
} }
}) })

View File

@ -1,7 +1,12 @@
from typing import List, Optional from typing import List, Optional
from lnbits.core.crud import (create_account, create_wallet, delete_wallet, from lnbits.core.crud import (
get_payments, get_user) create_account,
create_wallet,
delete_wallet,
get_payments,
get_user,
)
from lnbits.core.models import Payment from lnbits.core.models import Payment
from . import db from . import db

View File

@ -11,6 +11,11 @@ class CreateUserData(BaseModel):
email: str = Query("") email: str = Query("")
password: str = Query("") password: str = Query("")
class CreateUserWallet(BaseModel):
user_id: str = Query(...)
wallet_name: str = Query(...)
admin_id: str = Query(...)
class Users(BaseModel): class Users(BaseModel):
id: str id: str

View File

@ -21,7 +21,7 @@ from .crud import (
get_usermanager_wallet_transactions, get_usermanager_wallet_transactions,
get_usermanager_wallets, get_usermanager_wallets,
) )
from .models import CreateUserData from .models import CreateUserData, CreateUserWallet
### Users ### Users
@ -93,12 +93,10 @@ async def api_usermanager_activate_extension(
@usermanager_ext.post("/api/v1/wallets") @usermanager_ext.post("/api/v1/wallets")
async def api_usermanager_wallets_create( async def api_usermanager_wallets_create(
wallet: WalletTypeInfo = Depends(get_key_type), data: CreateUserWallet,
user_id: str = Query(...), wallet: WalletTypeInfo = Depends(get_key_type)
wallet_name: str = Query(...),
admin_id: str = Query(...),
): ):
user = await create_usermanager_wallet(user_id, wallet_name, admin_id) user = await create_usermanager_wallet(user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id)
return user.dict() return user.dict()

View File

@ -59,24 +59,16 @@ async def api_lnurl_callback(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
) )
# return (
# {"status": "ERROR", "reason": "LNURL-withdraw not found."},
# HTTPStatus.OK,
# )
if link.is_spent: if link.is_spent:
raise HTTPException( raise HTTPException(
# WHAT STATUS_CODE TO USE?? # WHAT STATUS_CODE TO USE??
detail="Withdraw is spent." detail="Withdraw is spent."
) )
# return (
# {"status": "ERROR", "reason": "Withdraw is spent."},
# HTTPStatus.OK,
# )
if link.k1 != k1: if link.k1 != k1:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Bad request.") raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Bad request.")
# return {"status": "ERROR", "reason": "Bad request."}, HTTPStatus.OK
if now < link.open_time: if now < link.open_time:
return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."} return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}

View File

@ -1,7 +1,8 @@
import asyncio import asyncio
import httpx
from typing import Callable, NamedTuple from typing import Callable, NamedTuple
import httpx
currencies = { currencies = {
"AED": "United Arab Emirates Dirham", "AED": "United Arab Emirates Dirham",
"AFN": "Afghan Afghani", "AFN": "Afghan Afghani",
@ -219,9 +220,11 @@ async def btc_price(currency: str) -> float:
"to": currency.lower(), "to": currency.lower(),
} }
rates = [] rates = []
send_channel = asyncio.Queue(0) tasks = []
async def controller(nursery): send_channel = asyncio.Queue()
async def controller():
failures = 0 failures = 0
while True: while True:
rate = await send_channel.get() rate = await send_channel.get()
@ -229,31 +232,47 @@ async def btc_price(currency: str) -> float:
rates.append(rate) rates.append(rate)
else: else:
failures += 1 failures += 1
if len(rates) >= 2 or len(rates) == 1 and failures >= 2: if len(rates) >= 2 or len(rates) == 1 and failures >= 2:
nursery.cancel_scope.cancel() for t in tasks: t.cancel()
break break
if failures == len(exchange_rate_providers): if failures == len(exchange_rate_providers):
nursery.cancel_scope.cancel() for t in tasks: t.cancel()
break break
async def fetch_price(key: str, provider: Provider):
async def fetch_price(provider: Provider):
url = provider.api_url.format(**replacements)
try: try:
url = provider.api_url.format(**replacements)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.get(url, timeout=0.5) r = await client.get(url, timeout=0.5)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
rate = float(provider.getter(data, replacements)) rate = float(provider.getter(data, replacements))
await send_channel.send(rate) await send_channel.put(rate)
except Exception: except (
await send_channel.send(None) TypeError, # CoinMate returns HTTPStatus 200 but no data when a currency pair is not found
httpx.ConnectTimeout,
httpx.ConnectError,
httpx.ReadTimeout,
httpx.HTTPStatusError, # Some providers throw a 404 when a currency pair is not found
):
await send_channel.put(None)
# asyncio.create_task(controller, nursery)
for key, provider in exchange_rate_providers.items(): asyncio.create_task(controller())
asyncio.create_task(fetch_price(key, provider)) for _, provider in exchange_rate_providers.items():
tasks.append(asyncio.create_task(fetch_price(provider)))
try:
await asyncio.gather(*tasks)
except asyncio.CancelledError:
pass
if not rates: if not rates:
return 9999999999 return 9999999999
elif len(rates) == 1:
print("Warning could only fetch one Bitcoin price.")
return sum([rate for rate in rates]) / len(rates) return sum([rate for rate in rates]) / len(rates)