Merge branch 'main' into eclair-backend

This commit is contained in:
Tiago vasconcelos 2022-04-28 15:30:26 +01:00
commit 96c7decae7
399 changed files with 48752 additions and 10219 deletions

View file

@ -5,16 +5,38 @@ QUART_DEBUG=true
HOST=127.0.0.1
PORT=5000
LNBITS_SITE_TITLE=LNbits
LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok"
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
LNBITS_DATA_FOLDER="./data"
LNBITS_AD_SPACE="" # csv ad image filepaths or urls, extensions can choose to honor
LNBITS_HIDE_API=false # Hides wallet api, extensions can choose to honor
# Disable extensions for all users, use "all" to disable all extensions
LNBITS_DISABLED_EXTENSIONS="amilk"
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data"
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
LNBITS_FORCE_HTTPS=true
LNBITS_SERVICE_FEE="0.0"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC),
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, EclairWallet
# Change theme
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet,
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file.
@ -28,19 +50,15 @@ SPARK_TOKEN=myaccesstoken
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
# LnbitsWallet
LNBITS_ENDPOINT=http://127.0.0.1:5000
LNBITS_ENDPOINT=https://legend.lnbits.com
LNBITS_KEY=LNBITS_ADMIN_KEY
# LndWallet
LND_GRPC_ENDPOINT=127.0.0.1
LND_GRPC_PORT=11009
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon"
# LndRestWallet
LND_REST_ENDPOINT=https://127.0.0.1:8080/
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_REST_MACAROON="HEXSTRING"
LND_REST_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
# To use an AES-encrypted macaroon, set
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
# LNPayWallet
LNPAY_API_ENDPOINT=https://api.lnpay.co/v1/
@ -50,13 +68,13 @@ LNPAY_API_KEY=LNPAY_API_KEY
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
# LntxbotWallet
LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/
LNTXBOT_API_ENDPOINT=https://lntxbot.com/
LNTXBOT_KEY=LNTXBOT_ADMIN_KEY
# OpenNodeWallet
OPENNODE_API_ENDPOINT=https://api.opennode.com/
OPENNODE_KEY=OPENNODE_ADMIN_KEY
# EclairWallet
ECLAIR_URL=http://127.0.0.1:8080
ECLAIR_PASS=eclair_password
# FakeWallet
FAKE_WALLET_SECRET="ToTheMoon1"
LNBITS_DENOMINATION=sats

View file

@ -9,4 +9,5 @@ jobs:
- uses: actions/checkout@v1
- uses: jpetrucciani/mypy-check@master
with:
mypy_flags: '--install-types --non-interactive'
path: lnbits

View file

@ -1,58 +0,0 @@
name: Docker build on push
env:
DOCKER_CLI_EXPERIMENTAL: enabled
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-20.04
name: Build and push lnbits image
steps:
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Checkout project
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
id: qemu
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v1
id: buildx
- name: Show available Docker buildx platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Cache Docker layers
uses: actions/cache@v2
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Run Docker buildx against commit hash
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits:${GITHUB_SHA:0:7} \
--output "type=registry" ./
- name: Run Docker buildx against latest
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits:latest \
--output "type=registry" ./

68
.github/workflows/on-tag.yml vendored Normal file
View file

@ -0,0 +1,68 @@
name: Build and push Docker image on tag
env:
DOCKER_CLI_EXPERIMENTAL: enabled
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+-*"
jobs:
build:
runs-on: ubuntu-20.04
name: Build and push lnbits image
steps:
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Checkout project
uses: actions/checkout@v2
- name: Import environment variables
id: import-env
shell: bash
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
- name: Show set environment variables
run: |
printf " TAG: %s\n" "$TAG"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
id: qemu
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v1
id: buildx
- name: Show available Docker buildx platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Cache Docker layers
uses: actions/cache@v2
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Run Docker buildx against tag
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:${TAG} \
--output "type=registry" ./
- name: Run Docker buildx against latest
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:latest \
--output "type=registry" ./

View file

@ -3,11 +3,11 @@ name: tests
on: [push, pull_request]
jobs:
build:
unit:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8]
python-version: [3.8]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
@ -15,22 +15,44 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Test with pytest
env:
LNBITS_BACKEND_WALLET_CLASS: LNPayWallet
LNBITS_FORCE_HTTPS: 0
LNPAY_API_ENDPOINT: https://api.lnpay.co/v1/
LNPAY_API_KEY: sak_gG5pSFZhFgOLHm26a8hcWvXKt98yd
LNPAY_ADMIN_KEY: waka_HqWfOoNE0TPqmQHSYErbF4n9
LNPAY_INVOICE_KEY: waki_ZqFEbhrTyopuPlOZButZUw
LNPAY_READ_KEY: wakr_6IyTaNrvSeu3jbojSWt4ou6h
run: |
pip install pytest pytest-cov
pytest --cov=lnbits --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
with:
file: ./coverage.xml
python -m venv ${{ env.VIRTUAL_ENV }}
./venv/bin/python -m pip install --upgrade pip
./venv/bin/pip install -r requirements.txt
./venv/bin/pip install pytest pytest-asyncio requests trio mock
- name: Run tests
run: make test
# build:
# runs-on: ubuntu-latest
# strategy:
# matrix:
# python-version: [3.7, 3.8]
# steps:
# - uses: actions/checkout@v2
# - name: Set up Python ${{ matrix.python-version }}
# uses: actions/setup-python@v1
# with:
# python-version: ${{ matrix.python-version }}
# - name: Install dependencies
# run: |
# python -m pip install --upgrade pip
# pip install -r requirements.txt
# - name: Test with pytest
# env:
# LNBITS_BACKEND_WALLET_CLASS: LNPayWallet
# LNBITS_FORCE_HTTPS: 0
# LNPAY_API_ENDPOINT: https://api.lnpay.co/v1/
# LNPAY_API_KEY: sak_gG5pSFZhFgOLHm26a8hcWvXKt98yd
# LNPAY_ADMIN_KEY: waka_HqWfOoNE0TPqmQHSYErbF4n9
# LNPAY_INVOICE_KEY: waki_ZqFEbhrTyopuPlOZButZUw
# LNPAY_READ_KEY: wakr_6IyTaNrvSeu3jbojSWt4ou6h
# run: |
# pip install pytest pytest-cov
# pytest --cov=lnbits --cov-report=xml
# - name: Upload coverage to Codecov
# uses: codecov/codecov-action@v1
# with:
# file: ./coverage.xml

2
.gitignore vendored
View file

@ -6,6 +6,7 @@ __pycache__
*$py.class
.mypy_cache
.vscode
*-lock.json
*.egg
*.egg-info
@ -14,6 +15,7 @@ __pycache__
.webassets-cache
htmlcov
test-reports
tests/data
*.swo
*.swp

View file

@ -8,7 +8,9 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install build deps
RUN apt-get update
RUN apt-get install -y --no-install-recommends build-essential
RUN apt-get install -y --no-install-recommends build-essential pkg-config
RUN python -m pip install --upgrade pip
RUN pip install wheel
# Install runtime deps
COPY requirements.txt /tmp/requirements.txt
@ -18,7 +20,7 @@ RUN pip install -r /tmp/requirements.txt
RUN pip install pylightning
# Install LND specific deps
RUN pip install lndgrpc purerpc
RUN pip install lndgrpc
# Production image
FROM python:3.7-slim as lnbits
@ -31,18 +33,13 @@ ENV VIRTUAL_ENV="/opt/venv"
COPY --from=builder --chown=1000:1000 $VIRTUAL_ENV $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Setup Quart
ENV QUART_APP="lnbits.app:create_app()"
ENV QUART_ENV="development"
ENV QUART_DEBUG="true"
# App
ENV LNBITS_BIND="0.0.0.0:5000"
# Copy in app source
WORKDIR /app
COPY --chown=1000:1000 lnbits /app/lnbits
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD quart assets && quart migrate && hypercorn -k trio --bind $LNBITS_BIND 'lnbits.app:create_app()'
CMD ["sh", "-c", "uvicorn lnbits.__main__:app --port $LNBITS_PORT --host $LNBITS_HOST"]

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019 Arc
Copyright (c) 2022 Arc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -1,3 +1,5 @@
.PHONY: test
all: format check requirements.txt
format: prettier black
@ -26,3 +28,10 @@ Pipfile.lock: Pipfile
requirements.txt: Pipfile.lock
cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt
test:
rm -rf ./tests/data
mkdir -p ./tests/data
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
./venv/bin/pytest -s

19
Pipfile
View file

@ -14,25 +14,26 @@ environs = "*"
lnurl = "==0.3.6"
pyscss = "*"
shortuuid = "*"
quart = "*"
quart-cors = "*"
quart-compress = "*"
secure = "*"
typing-extensions = "*"
httpx = "*"
quart-trio = "*"
trio = "==0.16.0"
hypercorn = {extras = ["trio"], version = "*"}
sqlalchemy-aio = "*"
embit = "*"
pyqrcode = "*"
pypng = "*"
sqlalchemy = "==1.3.23"
psycopg2-binary = "*"
aiofiles = "*"
asyncio = "*"
fastapi = "*"
uvicorn = {extras = ["standard"], version = "*"}
sse-starlette = "*"
jinja2 = "3.0.1"
pyngrok = "*"
secp256k1 = "*"
pycryptodomex = "*"
[dev-packages]
black = "==20.8b1"
pytest = "*"
pytest-cov = "*"
mypy = "latest"
pytest-trio = "*"
trio-typing = "*"

1220
Pipfile.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,6 @@
LNbits
======
[![github-tests-badge]][github-tests]
[![github-mypy-badge]][github-mypy]
[![codecov-badge]][codecov]
[![license-badge]](LICENSE)
[![docs-badge]][docs]
@ -12,6 +9,10 @@ LNbits
# LNbits v0.3 BETA, free and open-source lightning-network wallet/accounts system
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
(LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me)
Use [lnbits.com](https://lnbits.com), or run your own LNbits server!
LNbits is a very simple Python server that sits on top of any funding source, and can be used as:
@ -32,11 +33,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
## Running LNbits
See the [install guide](docs/guide/installation.md) for details on installation and setup.
### Contributing to LNbits
There's a [slightly different setup](docs/devs/installation.md) if you want to contribute to LNbits, but if your changes don't require adding or removing any package dependencies you don't have to bother with that, just follow the [normal installation](docs/guide/installation.md) steps.
See the [install guide](docs/devs/installation.md) for details on installation and setup.
## LNbits as an account system

657
conv.py Normal file
View file

@ -0,0 +1,657 @@
import psycopg2
import sqlite3
import os
# Python script to migrate an LNbits SQLite DB to Postgres
# All credits to @Fritz446 for the awesome work
# pip install psycopg2 OR psycopg2-binary
# Change these values as needed
sqfolder = "data/"
pgdb = "lnbits"
pguser = "postgres"
pgpswd = "yourpassword"
pghost = "localhost"
pgport = "5432"
pgschema = ""
def get_sqlite_cursor(sqdb) -> sqlite3:
consq = sqlite3.connect(sqdb)
return consq.cursor()
def get_postgres_cursor():
conpg = psycopg2.connect(
database=pgdb, user=pguser, password=pgpswd, host=pghost, port=pgport
)
return conpg.cursor()
def check_db_versions(sqdb):
sqlite = get_sqlite_cursor(sqdb)
dblite = dict(sqlite.execute("SELECT * FROM dbversions;").fetchall())
if "lnurlpos" in dblite:
del dblite["lnurlpos"]
sqlite.close()
postgres = get_postgres_cursor()
postgres.execute("SELECT * FROM public.dbversions;")
dbpost = dict(postgres.fetchall())
for key in dblite.keys():
if key in dblite and key in dbpost and dblite[key] != dbpost[key]:
raise Exception(
f"sqlite database version ({dblite[key]}) of {key} doesn't match postgres database version {dbpost[key]}"
)
connection = postgres.connection
postgres.close()
connection.close()
print("Database versions OK, converting")
def fix_id(seq, values):
if not values or len(values) == 0:
return
postgres = get_postgres_cursor()
max_id = values[len(values) - 1][0]
postgres.execute(f"SELECT setval('{seq}', {max_id});")
connection = postgres.connection
postgres.close()
connection.close()
def insert_to_pg(query, data):
if len(data) == 0:
return
cursor = get_postgres_cursor()
connection = cursor.connection
for d in data:
try:
cursor.execute(query, d)
except:
raise ValueError(f"Failed to insert {d}")
connection.commit()
cursor.close()
connection.close()
def migrate_core(sqlite_db_file):
sq = get_sqlite_cursor(sqlite_db_file)
# ACCOUNTS
res = sq.execute("SELECT * FROM accounts;")
q = f"INSERT INTO public.accounts (id, email, pass) VALUES (%s, %s, %s);"
insert_to_pg(q, res.fetchall())
# WALLETS
res = sq.execute("SELECT * FROM wallets;")
q = f'INSERT INTO public.wallets (id, name, "user", adminkey, inkey) VALUES (%s, %s, %s, %s, %s);'
insert_to_pg(q, res.fetchall())
# API PAYMENTS
res = sq.execute("SELECT * FROM apipayments;")
q = f"""
INSERT INTO public.apipayments(
checking_id, amount, fee, wallet, pending, memo, "time", hash, preimage, bolt11, extra, webhook, webhook_status)
VALUES (%s, %s, %s, %s, %s::boolean, %s, to_timestamp(%s), %s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# BALANCE CHECK
res = sq.execute("SELECT * FROM balance_check;")
q = f"INSERT INTO public.balance_check(wallet, service, url) VALUES (%s, %s, %s);"
insert_to_pg(q, res.fetchall())
# BALANCE NOTIFY
res = sq.execute("SELECT * FROM balance_notify;")
q = f"INSERT INTO public.balance_notify(wallet, url) VALUES (%s, %s);"
insert_to_pg(q, res.fetchall())
# EXTENSIONS
res = sq.execute("SELECT * FROM extensions;")
q = f'INSERT INTO public.extensions("user", extension, active) VALUES (%s, %s, %s::boolean);'
insert_to_pg(q, res.fetchall())
print("Migrated: core")
def migrate_ext(sqlite_db_file, schema):
sq = get_sqlite_cursor(sqlite_db_file)
if schema == "bleskomat":
# BLESKOMAT LNURLS
res = sq.execute("SELECT * FROM bleskomat_lnurls;")
q = f"""
INSERT INTO bleskomat.bleskomat_lnurls(
id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# BLESKOMATS
res = sq.execute("SELECT * FROM bleskomats;")
q = f"""
INSERT INTO bleskomat.bleskomats(
id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "captcha":
# CAPTCHA
res = sq.execute("SELECT * FROM captchas;")
q = f"""
INSERT INTO captcha.captchas(
id, wallet, url, memo, description, amount, "time", remembers, extras)
VALUES (%s, %s, %s, %s, %s, %s, to_timestamp(%s), %s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "copilot":
# OLD COPILOTS
res = sq.execute("SELECT * FROM copilots;")
q = f"""
INSERT INTO copilot.copilots(
id, "user", title, lnurl_toggle, wallet, animation1, animation2, animation3, animation1threshold, animation2threshold, animation3threshold, animation1webhook, animation2webhook, animation3webhook, lnurl_title, show_message, show_ack, show_price, amount_made, fullscreen_cam, iframe_url, "timestamp")
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
# NEW COPILOTS
q = f"""
INSERT INTO copilot.newer_copilots(
id, "user", title, lnurl_toggle, wallet, animation1, animation2, animation3, animation1threshold, animation2threshold, animation3threshold, animation1webhook, animation2webhook, animation3webhook, lnurl_title, show_message, show_ack, show_price, amount_made, fullscreen_cam, iframe_url, "timestamp")
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
elif schema == "events":
# EVENTS
res = sq.execute("SELECT * FROM events;")
q = f"""
INSERT INTO events.events(
id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold, "time")
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
# EVENT TICKETS
res = sq.execute("SELECT * FROM ticket;")
q = f"""
INSERT INTO events.ticket(
id, wallet, event, name, email, registered, paid, "time")
VALUES (%s, %s, %s, %s, %s, %s::boolean, %s::boolean, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
elif schema == "example":
# Example doesn't have a database at the moment
pass
elif schema == "hivemind":
# Hivemind doesn't have a database at the moment
pass
elif schema == "jukebox":
# JUKEBOXES
res = sq.execute("SELECT * FROM jukebox;")
q = f"""
INSERT INTO jukebox.jukebox(
id, "user", title, wallet, inkey, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# JUKEBOX PAYMENTS
res = sq.execute("SELECT * FROM jukebox_payment;")
q = f"""
INSERT INTO jukebox.jukebox_payment(
payment_hash, juke_id, song_id, paid)
VALUES (%s, %s, %s, %s::boolean);
"""
insert_to_pg(q, res.fetchall())
elif schema == "withdraw":
# WITHDRAW LINK
res = sq.execute("SELECT * FROM withdraw_link;")
q = f"""
INSERT INTO withdraw.withdraw_link (
id,
wallet,
title,
min_withdrawable,
max_withdrawable,
uses,
wait_time,
is_unique,
unique_hash,
k1,
open_time,
used,
usescsv
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# WITHDRAW HASH CHECK
res = sq.execute("SELECT * FROM hash_check;")
q = f"""
INSERT INTO withdraw.hash_check (id, lnurl_id)
VALUES (%s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "watchonly":
# WALLETS
res = sq.execute("SELECT * FROM wallets;")
q = f"""
INSERT INTO watchonly.wallets (
id,
"user",
masterpub,
title,
address_no,
balance
)
VALUES (%s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# ADDRESSES
res = sq.execute("SELECT * FROM addresses;")
q = f"""
INSERT INTO watchonly.addresses (id, address, wallet, amount)
VALUES (%s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# MEMPOOL
res = sq.execute("SELECT * FROM mempool;")
q = f"""
INSERT INTO watchonly.mempool ("user", endpoint)
VALUES (%s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "usermanager":
# USERS
res = sq.execute("SELECT * FROM users;")
q = f"""
INSERT INTO usermanager.users (id, name, admin, email, password)
VALUES (%s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# WALLETS
res = sq.execute("SELECT * FROM wallets;")
q = f"""
INSERT INTO usermanager.wallets (id, admin, name, "user", adminkey, inkey)
VALUES (%s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "tpos":
# TPOSS
res = sq.execute("SELECT * FROM tposs;")
q = f"""
INSERT INTO tpos.tposs (id, wallet, name, currency)
VALUES (%s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "tipjar":
# TIPJARS
res = sq.execute("SELECT * FROM TipJars;")
q = f"""
INSERT INTO tipjar.TipJars (id, name, wallet, onchain, webhook)
VALUES (%s, %s, %s, %s, %s);
"""
pay_links = res.fetchall()
insert_to_pg(q, pay_links)
fix_id("tipjar.tipjars_id_seq", pay_links)
# TIPS
res = sq.execute("SELECT * FROM Tips;")
q = f"""
INSERT INTO tipjar.Tips (id, wallet, name, message, sats, tipjar)
VALUES (%s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "subdomains":
# DOMAIN
res = sq.execute("SELECT * FROM domain;")
q = f"""
INSERT INTO subdomains.domain (
id,
wallet,
domain,
webhook,
cf_token,
cf_zone_id,
description,
cost,
amountmade,
allowed_record_types,
time
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
# SUBDOMAIN
res = sq.execute("SELECT * FROM subdomain;")
q = f"""
INSERT INTO subdomains.subdomain (
id,
domain,
email,
subdomain,
ip,
wallet,
sats,
duration,
paid,
record_type,
time
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::boolean, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
elif schema == "streamalerts":
# SERVICES
res = sq.execute("SELECT * FROM Services;")
q = f"""
INSERT INTO streamalerts.Services (
id,
state,
twitchuser,
client_id,
client_secret,
wallet,
onchain,
servicename,
authenticated,
token
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::boolean, %s);
"""
services = res.fetchall()
insert_to_pg(q, services)
fix_id("streamalerts.services_id_seq", services)
# DONATIONS
res = sq.execute("SELECT * FROM Donations;")
q = f"""
INSERT INTO streamalerts.Donations (
id,
wallet,
name,
message,
cur_code,
sats,
amount,
service,
posted,
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::boolean);
"""
insert_to_pg(q, res.fetchall())
elif schema == "splitpayments":
# TARGETS
res = sq.execute("SELECT * FROM targets;")
q = f"""
INSERT INTO splitpayments.targets (wallet, source, percent, alias)
VALUES (%s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "satspay":
# CHARGES
res = sq.execute("SELECT * FROM charges;")
q = f"""
INSERT INTO satspay.charges (
id,
"user",
description,
onchainwallet,
onchainaddress,
lnbitswallet,
payment_request,
payment_hash,
webhook,
completelink,
completelinktext,
time,
amount,
balance,
timestamp
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
elif schema == "satsdice":
# SATSDICE PAY
res = sq.execute("SELECT * FROM satsdice_pay;")
q = f"""
INSERT INTO satsdice.satsdice_pay (
id,
wallet,
title,
min_bet,
max_bet,
amount,
served_meta,
served_pr,
multiplier,
haircut,
chance,
base_url,
open_time
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# SATSDICE WITHDRAW
res = sq.execute("SELECT * FROM satsdice_withdraw;")
q = f"""
INSERT INTO satsdice.satsdice_withdraw (
id,
satsdice_pay,
value,
unique_hash,
k1,
open_time,
used
)
VALUES (%s, %s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# SATSDICE PAYMENT
res = sq.execute("SELECT * FROM satsdice_payment;")
q = f"""
INSERT INTO satsdice.satsdice_payment (
payment_hash,
satsdice_pay,
value,
paid,
lost
)
VALUES (%s, %s, %s, %s::boolean, %s::boolean);
"""
insert_to_pg(q, res.fetchall())
# SATSDICE HASH CHECK
res = sq.execute("SELECT * FROM hash_checkw;")
q = f"""
INSERT INTO satsdice.hash_checkw (id, lnurl_id)
VALUES (%s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "paywall":
# PAYWALLS
res = sq.execute("SELECT * FROM paywalls;")
q = f"""
INSERT INTO paywall.paywalls(
id,
wallet,
url,
memo,
amount,
time,
remembers,
extra
)
VALUES (%s, %s, %s, %s, %s, to_timestamp(%s), %s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "offlineshop":
# SHOPS
res = sq.execute("SELECT * FROM shops;")
q = f"""
INSERT INTO offlineshop.shops (id, wallet, method, wordlist)
VALUES (%s, %s, %s, %s);
"""
shops = res.fetchall()
insert_to_pg(q, shops)
fix_id("offlineshop.shops_id_seq", shops)
# ITEMS
res = sq.execute("SELECT * FROM items;")
q = f"""
INSERT INTO offlineshop.items (shop, id, name, description, image, enabled, price, unit)
VALUES (%s, %s, %s, %s, %s, %s::boolean, %s, %s);
"""
items = res.fetchall()
insert_to_pg(q, items)
fix_id("offlineshop.items_id_seq", items)
elif schema == "lnurlpos":
# LNURLPOSS
res = sq.execute("SELECT * FROM lnurlposs;")
q = f"""
INSERT INTO lnurlpos.lnurlposs (id, key, title, wallet, currency, timestamp)
VALUES (%s, %s, %s, %s, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
# LNURLPOS PAYMENT
res = sq.execute("SELECT * FROM lnurlpospayment;")
q = f"""
INSERT INTO lnurlpos.lnurlpospayment (id, posid, payhash, payload, pin, sats, timestamp)
VALUES (%s, %s, %s, %s, %s, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
elif schema == "lnurlp":
# PAY LINKS
res = sq.execute("SELECT * FROM pay_links;")
q = f"""
INSERT INTO lnurlp.pay_links (
id,
wallet,
description,
min,
served_meta,
served_pr,
webhook_url,
success_text,
success_url,
currency,
comment_chars,
max
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
"""
pay_links = res.fetchall()
insert_to_pg(q, pay_links)
fix_id("lnurlp.pay_links_id_seq", pay_links)
elif schema == "lndhub":
# LndHub doesn't have a database at the moment
pass
elif schema == "lnticket":
# TICKET
res = sq.execute("SELECT * FROM ticket;")
q = f"""
INSERT INTO lnticket.ticket (
id,
form,
email,
ltext,
name,
wallet,
sats,
paid,
time
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::boolean, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
# FORM
res = sq.execute("SELECT * FROM form2;")
q = f"""
INSERT INTO lnticket.form2 (
id,
wallet,
name,
webhook,
description,
flatrate,
amount,
amountmade,
time
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
"""
insert_to_pg(q, res.fetchall())
elif schema == "livestream":
# LIVESTREAMS
res = sq.execute("SELECT * FROM livestreams;")
q = f"""
INSERT INTO livestream.livestreams (
id,
wallet,
fee_pct,
current_track
)
VALUES (%s, %s, %s, %s);
"""
livestreams = res.fetchall()
insert_to_pg(q, livestreams)
fix_id("livestream.livestreams_id_seq", livestreams)
# PRODUCERS
res = sq.execute("SELECT * FROM producers;")
q = f"""
INSERT INTO livestream.producers (
livestream,
id,
"user",
wallet,
name
)
VALUES (%s, %s, %s, %s, %s);
"""
producers = res.fetchall()
insert_to_pg(q, producers)
fix_id("livestream.producers_id_seq", producers)
# TRACKS
res = sq.execute("SELECT * FROM tracks;")
q = f"""
INSERT INTO livestream.tracks (
livestream,
id,
download_url,
price_msat,
name,
producer
)
VALUES (%s, %s, %s, %s, %s, %s);
"""
tracks = res.fetchall()
insert_to_pg(q, tracks)
fix_id("livestream.tracks_id_seq", tracks)
else:
print(f"Not implemented: {schema}")
sq.close()
return
print(f"Migrated: {schema}")
sq.close()
check_db_versions("data/database.sqlite3")
migrate_core("data/database.sqlite3")
files = os.listdir(sqfolder)
for file in files:
path = f"data/{file}"
if file.startswith("ext_"):
schema = file.replace("ext_", "").split(".")[0]
print(f"Migrating: {schema}")
migrate_ext(path, schema)

View file

@ -10,3 +10,17 @@ For developers
==============
Thanks for contributing :)
Tests
=====
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
```bash
./venv/bin/pip install pytest pytest-asyncio requests trio mock
```
Then to run the tests:
```bash
make test
```

View file

@ -7,64 +7,46 @@ nav_order: 1
# Installation
Download the latest stable release https://github.com/lnbits/lnbits/releases
## Application dependencies
The application uses [Pipenv][pipenv] to manage Python packages.
While in development, you will need to install all dependencies:
LNbits uses [Pipenv][pipenv] to manage Python packages.
```sh
$ pipenv shell
$ pipenv install --dev
```
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
If any of the modules fails to install, try checking and upgrading your setupTool module.
`pip install -U setuptools`
sudo apt-get install pipenv
pipenv shell
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
pipenv install --dev
# pipenv --python 3.9 install --dev (if you wish to use a version of Python higher than 3.7)
If you wish to use a version of Python higher than 3.7:
# If any of the modules fails to install, try checking and upgrading your setupTool module
# pip install -U setuptools
```sh
$ pipenv --python 3.8 install --dev
```
# install libffi/libpq in case "pipenv install" fails
# sudo apt-get install -y libffi-dev libpq-dev
```
## Running the server
You will need to copy `.env.example` to `.env`, then set variables there.
Create the data folder and edit the .env file:
![Files](https://i.imgur.com/ri2zOe8.png)
mkdir data
cp .env.example .env
sudo nano .env
To then run the server for development purposes (includes hot-reload), use:
pipenv run python -m uvicorn lnbits.__main__:app --host 0.0.0.0 --reload
For production, use:
pipenv run python -m uvicorn lnbits.__main__:app --host 0.0.0.0
You might also need to install additional packages, depending on the [backend wallet](../guide/wallets.md) you use.
E.g. when you want to use LND you have to `pipenv run pip install lndgrpc` and `pipenv run pip install purerpc`.
Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment.
## Running the server
**Notes**:
LNbits uses [Quart][quart] as an application server.
Before running the server for the first time, make sure to create the data folder:
```sh
$ mkdir data
```
To then run the server, use:
```sh
$ pipenv run python -m lnbits
```
**Note**: You'll need to use _https_ for some endpoints and/or extensions. You can use [ngrok](https://ngrok.com/) for that. Follow the installation instructions on the website and when it's all set you can run:
```sh
$ ./nrok http 5000
```
this will give you an _https_ tunnel for the _localhost_, use that URL for navigating to LNBits.
## Frontend
The frontend uses [Vue.js and Quasar][quasar].
[quart]: https://pgjones.gitlab.io/
[pipenv]: https://pipenv.pypa.io/
[polar]: https://lightningpolar.com/
[quasar]: https://quasar.dev/start/how-to-use-vue
* We reccomend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
* <a href="https://linuxize.com/post/how-to-use-linux-screen/#starting-linux-screen">Screen</a> works well if you want LNbits to continue running when you close your terminal session.

View file

@ -0,0 +1,122 @@
## Defining a route with path parameters
**old:**
```python
# with <>
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
```
**new:**
```python
# with curly braces: {}
@offlineshop_ext.get("/lnurl/{item_id}")
```
## Check if a user exists and access user object
**old:**
```python
# decorators
@check_user_exists()
async def do_routing_stuff():
pass
```
**new:**
If user doesn't exist, `Depends(check_user_exists)` will raise an exception.
If user exists, `user` will be the user object
```python
# depends calls
@core_html_routes.get("/my_route")
async def extensions(user: User = Depends(check_user_exists)):
pass
```
## Returning data from API calls
**old:**
```python
return (
{
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat
},
HTTPStatus.OK,
)
```
FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
**new:**
```python
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat
}
```
To change the default HTTPStatus, add it to the path decorator
```python
@core_app.post("/api/v1/payments", status_code=HTTPStatus.CREATED)
async def payments():
pass
```
## Raise exceptions
**old:**
```python
return (
{"message": f"Failed to connect to {domain}."},
HTTPStatus.BAD_REQUEST,
)
# or the Quart way via abort function
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
```
**new:**
Raise an exception to return a status code other than the default status code.
```python
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}."
)
```
## Extensions
**old:**
```python
from quart import Blueprint
amilk_ext: Blueprint = Blueprint(
"amilk", __name__, static_folder="static", template_folder="templates"
)
```
**new:**
```python
from fastapi import APIRouter
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.helpers import template_renderer
from fastapi.staticfiles import StaticFiles
offlineshop_ext: APIRouter = APIRouter(
prefix="/Extension",
tags=["Offlineshop"]
)
offlineshop_ext.mount(
"lnbits/extensions/offlineshop/static",
StaticFiles("lnbits/extensions/offlineshop/static")
)
offlineshop_rndr = template_renderer([
"lnbits/extensions/offlineshop/templates",
])
```
## Possible optimizations
### Use Redis as a cache server
Instead of hitting the database over and over again, we can store a short lived object in [Redis](https://redis.io) for an arbitrary key.
Example:
* Get transactions for a wallet ID
* User data for a user id
* Wallet data for a Admin / Invoice key

View file

@ -4,57 +4,142 @@ title: Basic installation
nav_order: 2
---
# Basic installation
Install Postgres and setup a database for LNbits:
Basic installation
==================
```sh
# on debian/ubuntu 'sudo apt-get -y install postgresql'
# or follow instructions at https://www.postgresql.org/download/linux/
# Postgres doesn't have a default password, so we'll create one.
sudo -i -u postgres
psql
# on psql
ALTER USER postgres PASSWORD 'myPassword'; # choose whatever password you want
\q
# on postgres user
createdb lnbits
exit
```
Download this repo and install the dependencies:
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits/
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv' should work
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
cp .env.example .env
mkdir data
./venv/bin/quart assets
./venv/bin/quart migrate
./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
# postgres://<user>:<myPassword>@<host>/<lnbits> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# save and exit
./venv/bin/uvicorn lnbits.__main__:app --port 5000
```
No you can visit your LNbits at http://localhost:5000/.
Now you can visit your LNbits at http://localhost:5000/.
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
Then you can run restart it and it will be using the new settings.
Then you can restart it and it will be using the new settings.
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
Docker installation
===================
## Important note
If you already have LNbits installed and running, on an SQLite database, we **HIGHLY** recommend you migrate to postgres!
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user, check the guide above.
```sh
# STOP LNbits
# on the LNBits folder, locate and edit 'conv.py' with the relevant credentials
python3 conv.py
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# save and exit
```
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
# Additional guides
### LNbits as a systemd service
Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
```
# Systemd unit for lnbits
# /etc/systemd/system/lnbits.service
[Unit]
Description=LNbits
#Wants=lnd.service # you can uncomment these lines if you know what you're doing
#After=lnd.service # it will make sure that lnbits starts after lnd (replace with your own backend service)
[Service]
WorkingDirectory=/home/bitcoin/lnbits # replace with the absolute path of your lnbits installation
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 # same here
User=bitcoin # replace with the user that you're running lnbits on
Restart=always
TimeoutSec=120
RestartSec=30
Environment=PYTHONUNBUFFERED=1 # this makes sure that you receive logs in real time
[Install]
WantedBy=multi-user.target
```
Save the file and run the following commands:
```sh
sudo systemctl enable lnbits.service
sudo systemctl start lnbits.service
```
### LNbits running on Umbrel behind Tor
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
### Docker installation
To install using docker you first need to build the docker image as:
```
git clone https://github.com/lnbits/lnbits.git
cd lnbits/ # ${PWD} refered as <lnbits_repo>
cd lnbits/ # ${PWD} referred as <lnbits_repo>
docker build -t lnbits .
```
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
```
cp <lnbits_repo>/.env.example .env
```
and change the configuration in `.env` as required.
Then create the data directory for the user ID 1000, which is the user that runs the lnbits within the docker container.
```
mkdir data
sudo chown 1000:1000 ./data/
```
Then the image can be run as:
```
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
```
Finally you can access the lnbits on your machine port 5000.
Finally you can access your lnbits on your machine at port 5000.
# Additional guides
## LNbits running on Umbrel behind Tor
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.

View file

@ -17,6 +17,7 @@ A backend wallet can be configured using the following LNbits environment variab
### CLightning
Using this wallet requires the installation of the `pylightning` Python package.
If you want to use LNURLp you should use SparkWallet because of an issue with description_hash and CLightning.
- `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet**
- `CLIGHTNING_RPC`: /file/path/lightning-rpc
@ -29,22 +30,30 @@ Using this wallet requires the installation of the `pylightning` Python package.
### LND (gRPC)
Using this wallet requires the installation of the `lndgrpc` and `purerpc` Python packages.
Using this wallet requires the installation of the `grpcio` and `protobuf` Python packages.
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
- `LND_GRPC_ENDPOINT`: ip_address
- `LND_GRPC_PORT`: port
- `LND_GRPC_CERT`: /file/path/tls.cert
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
You can also use an AES-encrypted macaroon (more info) instead by using
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroon.py`.
### LND (REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: ip_address
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
or
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
### LNbits
@ -65,7 +74,7 @@ For the invoice listener to work you have a publicly accessible URL in your LNbi
### lntxbot
- `LNBITS_BACKEND_WALLET_CLASS`: **LntxbotWallet**
- `LNTXBOT_API_ENDPOINT`: https://lntxbot.bigsun.xyz/
- `LNTXBOT_API_ENDPOINT`: https://lntxbot.com/
- `LNTXBOT_KEY`: lntxbotAdminApiKey

View file

@ -1,8 +1,22 @@
import trio
import asyncio
from .commands import migrate_databases, transpile_scss, bundle_vendored
import uvloop
from starlette.requests import Request
trio.run(migrate_databases)
from .commands import bundle_vendored, migrate_databases, transpile_scss
from .settings import (
DEBUG,
LNBITS_COMMIT,
LNBITS_DATA_FOLDER,
LNBITS_SITE_TITLE,
PORT,
SERVICE_FEE,
WALLET,
)
uvloop.install()
asyncio.create_task(migrate_databases())
transpile_scss()
bundle_vendored()
@ -10,15 +24,6 @@ from .app import create_app
app = create_app()
from .settings import (
LNBITS_SITE_TITLE,
SERVICE_FEE,
DEBUG,
LNBITS_DATA_FOLDER,
WALLET,
LNBITS_COMMIT,
)
print(
f"""Starting LNbits with
- git version: {LNBITS_COMMIT}
@ -29,5 +34,3 @@ print(
- service fee: {SERVICE_FEE}
"""
)
app.run(host=app.config["HOST"], port=app.config["PORT"])

View file

@ -1,156 +1,176 @@
import sys
import warnings
import asyncio
import importlib
import sys
import traceback
import warnings
from quart import g
from quart_trio import QuartTrio
from quart_cors import cors # type: ignore
from quart_compress import Compress # type: ignore
from secure import SecureHeaders # type: ignore
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
import lnbits.settings
from lnbits.core.tasks import register_task_listeners
from .commands import db_migrate, handle_assets
from .core import core_app
from .core.views.generic import core_html_routes
from .helpers import (
get_valid_extensions,
get_js_vendored,
get_css_vendored,
get_js_vendored,
get_valid_extensions,
template_renderer,
url_for_vendored,
)
from .proxy_fix import ASGIProxyFix
from .requestvars import g
from .settings import WALLET
from .tasks import (
run_deferred_async,
catch_everything_and_restart,
check_pending_payments,
invoice_listener,
internal_invoice_listener,
invoice_listener,
run_deferred_async,
webhook_handler,
)
from .settings import WALLET
secure_headers = SecureHeaders(hsts=False, xfo=False)
def create_app(config_object="lnbits.settings") -> QuartTrio:
def create_app(config_object="lnbits.settings") -> FastAPI:
"""Create application factory.
:param config_object: The configuration object to use.
"""
app = QuartTrio(__name__, static_folder="static")
app.config.from_object(config_object)
app.asgi_http_class = ASGIProxyFix
app = FastAPI()
app.mount("/static", StaticFiles(directory="lnbits/static"), name="static")
app.mount(
"/core/static", StaticFiles(directory="lnbits/core/static"), name="core_static"
)
cors(app)
Compress(app)
origins = ["*"]
app.add_middleware(
CORSMiddleware, allow_origins=origins, allow_methods=["*"], allow_headers=["*"]
)
g().config = lnbits.settings
g().base_url = f"http://{lnbits.settings.HOST}:{lnbits.settings.PORT}"
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
return template_renderer().TemplateResponse(
"error.html",
{"request": request, "err": f"`{exc.errors()}` is not a valid UUID."},
)
# return HTMLResponse(
# status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
# content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
# )
app.add_middleware(GZipMiddleware, minimum_size=1000)
# app.add_middleware(ASGIProxyFix)
check_funding_source(app)
register_assets(app)
register_blueprints(app)
register_filters(app)
register_commands(app)
register_request_hooks(app)
register_routes(app)
# register_commands(app)
register_async_tasks(app)
register_exception_handlers(app)
return app
def check_funding_source(app: QuartTrio) -> None:
@app.before_serving
def check_funding_source(app: FastAPI) -> None:
@app.on_event("startup")
async def check_wallet_status():
error_message, balance = await WALLET.status()
if error_message:
while True:
error_message, balance = await WALLET.status()
if not error_message:
break
warnings.warn(
f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning,
)
sys.exit(4)
else:
print(
f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat."
)
print("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5)
print(
f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat."
)
def register_blueprints(app: QuartTrio) -> None:
"""Register Flask blueprints / LNbits extensions."""
app.register_blueprint(core_app)
def register_routes(app: FastAPI) -> None:
"""Register FastAPI routes / LNbits extensions."""
app.include_router(core_app)
app.include_router(core_html_routes)
for ext in get_valid_extensions():
try:
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
bp = getattr(ext_module, f"{ext.code}_ext")
ext_route = getattr(ext_module, f"{ext.code}_ext")
app.register_blueprint(bp, url_prefix=f"/{ext.code}")
except Exception:
if hasattr(ext_module, f"{ext.code}_start"):
ext_start_func = getattr(ext_module, f"{ext.code}_start")
ext_start_func()
if hasattr(ext_module, f"{ext.code}_static_files"):
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics:
app.mount(s["path"], s["app"], s["name"])
app.include_router(ext_route)
except Exception as e:
print(str(e))
raise ImportError(
f"Please make sure that the extension `{ext.code}` follows conventions."
)
def register_commands(app: QuartTrio):
def register_commands(app: FastAPI):
"""Register Click commands."""
app.cli.add_command(db_migrate)
app.cli.add_command(handle_assets)
def register_assets(app: QuartTrio):
def register_assets(app: FastAPI):
"""Serve each vendored asset separately or a bundle."""
@app.before_request
@app.on_event("startup")
async def vendored_assets_variable():
if app.config["DEBUG"]:
g.VENDORED_JS = map(url_for_vendored, get_js_vendored())
g.VENDORED_CSS = map(url_for_vendored, get_css_vendored())
if g().config.DEBUG:
g().VENDORED_JS = map(url_for_vendored, get_js_vendored())
g().VENDORED_CSS = map(url_for_vendored, get_css_vendored())
else:
g.VENDORED_JS = ["/static/bundle.js"]
g.VENDORED_CSS = ["/static/bundle.css"]
def register_filters(app: QuartTrio):
"""Jinja filters."""
app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"]
app.jinja_env.globals["LNBITS_VERSION"] = app.config["LNBITS_COMMIT"]
app.jinja_env.globals["EXTENSIONS"] = get_valid_extensions()
def register_request_hooks(app: QuartTrio):
"""Open the core db for each request so everything happens in a big transaction"""
@app.after_request
async def set_secure_headers(response):
secure_headers.quart(response)
return response
g().VENDORED_JS = ["/static/bundle.js"]
g().VENDORED_CSS = ["/static/bundle.css"]
def register_async_tasks(app):
@app.route("/wallet/webhook", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
@app.route("/wallet/webhook")
async def webhook_listener():
return await webhook_handler()
@app.before_serving
@app.on_event("startup")
async def listeners():
run_deferred_async(app.nursery)
app.nursery.start_soon(check_pending_payments)
app.nursery.start_soon(invoice_listener, app.nursery)
app.nursery.start_soon(internal_invoice_listener, app.nursery)
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(check_pending_payments))
loop.create_task(catch_everything_and_restart(invoice_listener))
loop.create_task(catch_everything_and_restart(internal_invoice_listener))
await register_task_listeners()
await run_deferred_async()
@app.after_serving
@app.on_event("shutdown")
async def stop_listeners():
pass
def register_exception_handlers(app):
@app.errorhandler(Exception)
async def basic_error(err):
etype, value, tb = sys.exc_info()
def register_exception_handlers(app: FastAPI):
@app.exception_handler(Exception)
async def basic_error(request: Request, err):
print("handled error", traceback.format_exc())
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, err, tb)
exc = traceback.format_exc()
return (
"\n\n".join(
[
"LNbits internal error!",
exc,
"If you believe this shouldn't be an error please bring it up on https://t.me/lnbits",
]
),
500,
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": err}
)

View file

@ -2,10 +2,14 @@ import bitstring # type: ignore
import re
import hashlib
from typing import List, NamedTuple, Optional
from bech32 import bech32_decode, CHARSET # type: ignore
from bech32 import bech32_encode, bech32_decode, CHARSET
from ecdsa import SECP256k1, VerifyingKey # type: ignore
from ecdsa.util import sigdecode_string # type: ignore
from binascii import unhexlify
import time
from decimal import Decimal
import embit
import secp256k1
class Route(NamedTuple):
@ -116,6 +120,166 @@ def decode(pr: str) -> Invoice:
return invoice
def encode(options):
"""Convert options into LnAddr and pass it to the encoder"""
addr = LnAddr()
addr.currency = options["currency"]
addr.fallback = options["fallback"] if options["fallback"] else None
if options["amount"]:
addr.amount = options["amount"]
if options["timestamp"]:
addr.date = int(options["timestamp"])
addr.paymenthash = unhexlify(options["paymenthash"])
if options["description"]:
addr.tags.append(("d", options["description"]))
if options["description_hash"]:
addr.tags.append(("h", options["description_hash"]))
if options["expires"]:
addr.tags.append(("x", options["expires"]))
if options["fallback"]:
addr.tags.append(("f", options["fallback"]))
if options["route"]:
for r in options["route"]:
splits = r.split("/")
route = []
while len(splits) >= 5:
route.append(
(
unhexlify(splits[0]),
unhexlify(splits[1]),
int(splits[2]),
int(splits[3]),
int(splits[4]),
)
)
splits = splits[5:]
assert len(splits) == 0
addr.tags.append(("r", route))
return lnencode(addr, options["privkey"])
def lnencode(addr, privkey):
if addr.amount:
amount = Decimal(str(addr.amount))
# We can only send down to millisatoshi.
if amount * 10 ** 12 % 10:
raise ValueError(
"Cannot encode {}: too many decimal places".format(addr.amount)
)
amount = addr.currency + shorten_amount(amount)
else:
amount = addr.currency if addr.currency else ""
hrp = "ln" + amount + "0n"
# Start with the timestamp
data = bitstring.pack("uint:35", addr.date)
# Payment hash
data += tagged_bytes("p", addr.paymenthash)
tags_set = set()
for k, v in addr.tags:
# BOLT #11:
#
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
if k in ("d", "h", "n", "x"):
if k in tags_set:
raise ValueError("Duplicate '{}' tag".format(k))
if k == "r":
route = bitstring.BitArray()
for step in v:
pubkey, channel, feebase, feerate, cltv = step
route.append(
bitstring.BitArray(pubkey)
+ bitstring.BitArray(channel)
+ bitstring.pack("intbe:32", feebase)
+ bitstring.pack("intbe:32", feerate)
+ bitstring.pack("intbe:16", cltv)
)
data += tagged("r", route)
elif k == "f":
data += encode_fallback(v, addr.currency)
elif k == "d":
data += tagged_bytes("d", v.encode())
elif k == "x":
# Get minimal length by trimming leading 5 bits at a time.
expirybits = bitstring.pack("intbe:64", v)[4:64]
while expirybits.startswith("0b00000"):
expirybits = expirybits[5:]
data += tagged("x", expirybits)
elif k == "h":
data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest())
elif k == "n":
data += tagged_bytes("n", v)
else:
# FIXME: Support unknown tags?
raise ValueError("Unknown tag {}".format(k))
tags_set.add(k)
# BOLT #11:
#
# A writer MUST include either a `d` or `h` field, and MUST NOT include
# both.
if "d" in tags_set and "h" in tags_set:
raise ValueError("Cannot include both 'd' and 'h'")
if not "d" in tags_set and not "h" in tags_set:
raise ValueError("Must include either 'd' or 'h'")
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey)))
sig = privkey.ecdsa_sign_recoverable(
bytearray([ord(c) for c in hrp]) + data.tobytes()
)
# This doesn't actually serialize, but returns a pair of values :(
sig, recid = privkey.ecdsa_recoverable_serialize(sig)
data += bytes(sig) + bytes([recid])
return bech32_encode(hrp, bitarray_to_u5(data))
class LnAddr(object):
def __init__(
self, paymenthash=None, amount=None, currency="bc", tags=None, date=None
):
self.date = int(time.time()) if not date else int(date)
self.tags = [] if not tags else tags
self.unknown_tags = []
self.paymenthash = paymenthash
self.signature = None
self.pubkey = None
self.currency = currency
self.amount = amount
def __str__(self):
return "LnAddr[{}, amount={}{} tags=[{}]]".format(
hexlify(self.pubkey.serialize()).decode("utf-8"),
self.amount,
self.currency,
", ".join([k + "=" + str(v) for k, v in self.tags]),
)
def shorten_amount(amount):
"""Given an amount in bitcoin, shorten it"""
# Convert to pico initially
amount = int(amount * 10 ** 12)
units = ["p", "n", "u", "m", ""]
for unit in units:
if amount % 1000 == 0:
amount //= 1000
else:
break
return str(amount) + unit
def _unshorten_amount(amount: str) -> int:
"""Given a shortened amount, return millisatoshis"""
# BOLT #11:
@ -125,12 +289,7 @@ def _unshorten_amount(amount: str) -> int:
# * `u` (micro): multiply by 0.000001
# * `n` (nano): multiply by 0.000000001
# * `p` (pico): multiply by 0.000000000001
units = {
"p": 10 ** 12,
"n": 10 ** 9,
"u": 10 ** 6,
"m": 10 ** 3,
}
units = {"p": 10 ** 12, "n": 10 ** 9, "u": 10 ** 6, "m": 10 ** 3}
unit = str(amount)[-1]
# BOLT #11:
@ -151,6 +310,34 @@ def _pull_tagged(stream):
return (CHARSET[tag], stream.read(length * 5), stream)
def is_p2pkh(currency, prefix):
return prefix == base58_prefix_map[currency][0]
def is_p2sh(currency, prefix):
return prefix == base58_prefix_map[currency][1]
# Tagged field containing BitArray
def tagged(char, l):
# Tagged fields need to be zero-padded to 5 bits.
while l.len % 5 != 0:
l.append("0b0")
return (
bitstring.pack(
"uint:5, uint:5, uint:5",
CHARSET.find(char),
(l.len / 5) / 32,
(l.len / 5) % 32,
)
+ l
)
def tagged_bytes(char, l):
return tagged(char, bitstring.BitArray(l))
def _trim_to_bytes(barr):
# Adds a byte if necessary.
b = barr.tobytes()
@ -161,9 +348,9 @@ def _trim_to_bytes(barr):
def _readable_scid(short_channel_id: int) -> str:
return "{blockheight}x{transactionindex}x{outputindex}".format(
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
outputindex=(short_channel_id & 0xFFFF),
blockheight=((short_channel_id >> 40) & 0xffffff),
transactionindex=((short_channel_id >> 16) & 0xffffff),
outputindex=(short_channel_id & 0xffff),
)
@ -172,3 +359,12 @@ def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
for a in arr:
ret += bitstring.pack("uint:5", a)
return ret
def bitarray_to_u5(barr):
assert barr.len % 5 == 0
ret = []
s = bitstring.ConstBitStream(barr)
while s.pos != s.len:
ret.append(s.read(5).uint)
return ret

View file

@ -1,11 +1,11 @@
import trio
import asyncio
import warnings
import click
import importlib
import re
import os
from sqlalchemy.exc import OperationalError # type: ignore
from .db import SQLITE, POSTGRES, COCKROACH
from .core import db as core_db, migrations as core_migrations
from .helpers import (
get_valid_extensions,
@ -18,7 +18,7 @@ from .settings import LNBITS_PATH
@click.command("migrate")
def db_migrate():
trio.run(migrate_databases)
asyncio.create_task(migrate_databases())
@click.command("assets")
@ -53,41 +53,61 @@ def bundle_vendored():
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
try:
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
except OperationalError:
# migration 3 wasn't ran
await core_migrations.m000_create_migrations_table(conn)
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
async def set_migration_version(conn, db_name, version):
await conn.execute(
"""
INSERT INTO dbversions (db, version) VALUES (?, ?)
ON CONFLICT (db) DO UPDATE SET version = ?
""",
(db_name, version, version),
)
async def run_migration(db, migrations_module):
db_name = migrations_module.__name__.split(".")[-2]
for key, migrate in migrations_module.__dict__.items():
match = match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_versions.get(db_name, 0):
print(f"running migration {db_name}.{version}")
await migrate(db)
if db.schema == None:
await set_migration_version(db, db_name, version)
else:
async with core_db.connect() as conn:
await set_migration_version(conn, db_name, version)
async with core_db.connect() as conn:
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
current_versions = {row["db"]: row["version"] for row in rows}
matcher = re.compile(r"^m(\d\d\d)_")
async def run_migration(db, migrations_module):
db_name = migrations_module.__name__.split(".")[-2]
for key, migrate in migrations_module.__dict__.items():
match = match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_versions.get(db_name, 0):
print(f"running migration {db_name}.{version}")
await migrate(db)
await conn.execute(
"INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)",
(db_name, version),
)
await run_migration(conn, core_migrations)
for ext in get_valid_extensions():
try:
ext_migrations = importlib.import_module(
f"lnbits.extensions.{ext.code}.migrations"
)
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
await run_migration(ext_db, ext_migrations)
except ImportError:
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
)
for ext in get_valid_extensions():
try:
ext_migrations = importlib.import_module(
f"lnbits.extensions.{ext.code}.migrations"
)
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
except ImportError:
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
)
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations)
print(" ✔️ All migrations done.")

View file

@ -1,22 +1,11 @@
from quart import Blueprint
from fastapi.routing import APIRouter
from lnbits.db import Database
db = Database("database")
core_app: Blueprint = Blueprint(
"core",
__name__,
template_folder="templates",
static_folder="static",
static_url_path="/core/static",
)
core_app: APIRouter = APIRouter()
from .views.api import * # noqa
from .views.generic import * # noqa
from .views.public_api import * # noqa
from .tasks import register_listeners
from lnbits.tasks import record_async
core_app.record(record_async(register_listeners))

View file

@ -5,13 +5,12 @@ from typing import List, Optional, Dict, Any
from urllib.parse import urlparse
from lnbits import bolt11
from lnbits.db import Connection
from lnbits.settings import DEFAULT_WALLET_NAME
from lnbits.db import Connection, POSTGRES, COCKROACH
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
from . import db
from .models import User, Wallet, Payment, BalanceCheck
# accounts
# --------
@ -43,41 +42,40 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
if user:
extensions = await (conn or db).fetchall(
"SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,)
"""SELECT extension FROM extensions WHERE "user" = ? AND active""",
(user_id,),
)
wallets = await (conn or db).fetchall(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE user = ?
WHERE "user" = ?
""",
(user_id,),
)
else:
return None
return (
User(
**{
**user,
**{
"extensions": [e[0] for e in extensions],
"wallets": [Wallet(**w) for w in wallets],
},
}
)
if user
else None
return User(
id=user["id"],
email=user["email"],
extensions=[e[0] for e in extensions],
wallets=[Wallet(**w) for w in wallets],
admin=user["id"] in [x.strip() for x in LNBITS_ADMIN_USERS]
if LNBITS_ADMIN_USERS
else False,
)
async def update_user_extension(
*, user_id: str, extension: str, active: int, conn: Optional[Connection] = None
*, user_id: str, extension: str, active: bool, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
INSERT OR REPLACE INTO extensions (user, extension, active)
VALUES (?, ?, ?)
INSERT INTO extensions ("user", extension, active) VALUES (?, ?, ?)
ON CONFLICT ("user", extension) DO UPDATE SET active = ?
""",
(user_id, extension, active),
(user_id, extension, active, active),
)
@ -94,7 +92,7 @@ async def create_wallet(
wallet_id = uuid4().hex
await (conn or db).execute(
"""
INSERT INTO wallets (id, name, user, adminkey, inkey)
INSERT INTO wallets (id, name, "user", adminkey, inkey)
VALUES (?, ?, ?, ?, ?)
""",
(
@ -112,6 +110,19 @@ async def create_wallet(
return new_wallet
async def update_wallet(
wallet_id: str, new_name: str, conn: Optional[Connection] = None
) -> Optional[Wallet]:
await (conn or db).execute(
"""
UPDATE wallets SET
name = ?
WHERE id = ?
""",
(new_name, wallet_id),
)
async def delete_wallet(
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
@ -119,10 +130,10 @@ async def delete_wallet(
"""
UPDATE wallets AS w
SET
user = 'del:' || w.user,
"user" = 'del:' || w."user",
adminkey = 'del:' || w.adminkey,
inkey = 'del:' || w.inkey
WHERE id = ? AND user = ?
WHERE id = ? AND "user" = ?
""",
(wallet_id, user_id),
)
@ -208,6 +219,8 @@ async def get_payments(
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
limit: Optional[int] = None,
offset: Optional[int] = None,
conn: Optional[Connection] = None,
) -> List[Payment]:
"""
@ -218,7 +231,12 @@ async def get_payments(
clause: List[str] = []
if since != None:
clause.append("time > ?")
if db.type == POSTGRES:
clause.append("time > to_timestamp(?)")
elif db.type == COCKROACH:
clause.append("time > cast(? AS timestamp)")
else:
clause.append("time > ?")
args.append(since)
if wallet_id:
@ -228,9 +246,9 @@ async def get_payments(
if complete and pending:
pass
elif complete:
clause.append("((amount > 0 AND pending = 0) OR amount < 0)")
clause.append("((amount > 0 AND pending = false) OR amount < 0)")
elif pending:
clause.append("pending = 1")
clause.append("pending = true")
else:
pass
@ -247,6 +265,15 @@ async def get_payments(
clause.append("checking_id NOT LIKE 'temp_%'")
clause.append("checking_id NOT LIKE 'internal_%'")
limit_clause = f"LIMIT {limit}" if type(limit) == int and limit > 0 else ""
offset_clause = f"OFFSET {offset}" if type(offset) == int and offset > 0 else ""
# combine limit and offset clauses
limit_offset_clause = (
f"{limit_clause} {offset_clause}"
if limit_clause and offset_clause
else limit_clause or offset_clause
)
where = ""
if clause:
where = f"WHERE {' AND '.join(clause)}"
@ -257,10 +284,10 @@ async def get_payments(
FROM apipayments
{where}
ORDER BY time DESC
{limit_offset_clause}
""",
tuple(args),
)
return [Payment.from_row(row) for row in rows]
@ -269,20 +296,21 @@ async def delete_expired_invoices(
) -> None:
# first we delete all invoices older than one month
await (conn or db).execute(
"""
f"""
DELETE FROM apipayments
WHERE pending = 1 AND amount > 0 AND time < strftime('%s', 'now') - 2592000
WHERE pending = true AND amount > 0
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
"""
)
# then we delete all expired invoices, checking one by one
rows = await (conn or db).fetchall(
"""
f"""
SELECT bolt11
FROM apipayments
WHERE pending = 1
WHERE pending = true
AND bolt11 IS NOT NULL
AND amount > 0 AND time < strftime('%s', 'now') - 86400
AND amount > 0 AND time < {db.timestamp_now} - {db.interval_seconds(86400)}
"""
)
for (payment_request,) in rows:
@ -298,7 +326,7 @@ async def delete_expired_invoices(
await (conn or db).execute(
"""
DELETE FROM apipayments
WHERE pending = 1 AND hash = ?
WHERE pending = true AND hash = ?
""",
(invoice.payment_hash,),
)
@ -337,7 +365,7 @@ async def create_payment(
payment_hash,
preimage,
amount,
int(pending),
pending,
memo,
fee,
json.dumps(extra)
@ -354,36 +382,27 @@ async def create_payment(
async def update_payment_status(
checking_id: str,
pending: bool,
conn: Optional[Connection] = None,
checking_id: str, pending: bool, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
(
int(pending),
checking_id,
),
(pending, checking_id),
)
async def delete_payment(
checking_id: str,
conn: Optional[Connection] = None,
) -> None:
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
)
async def check_internal(
payment_hash: str,
conn: Optional[Connection] = None,
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[str]:
row = await (conn or db).fetchone(
"""
SELECT checking_id FROM apipayments
WHERE hash = ? AND pending AND amount > 0
WHERE hash = ? AND pending AND amount > 0
""",
(payment_hash,),
)
@ -398,25 +417,21 @@ async def check_internal(
async def save_balance_check(
wallet_id: str,
url: str,
conn: Optional[Connection] = None,
wallet_id: str, url: str, conn: Optional[Connection] = None
):
domain = urlparse(url).netloc
await (conn or db).execute(
"""
INSERT OR REPLACE INTO balance_check (wallet, service, url)
VALUES (?, ?, ?)
INSERT INTO balance_check (wallet, service, url) VALUES (?, ?, ?)
ON CONFLICT (wallet, service) DO UPDATE SET url = ?
""",
(wallet_id, domain, url),
(wallet_id, domain, url, url),
)
async def get_balance_check(
wallet_id: str,
domain: str,
conn: Optional[Connection] = None,
wallet_id: str, domain: str, conn: Optional[Connection] = None
) -> Optional[BalanceCheck]:
row = await (conn or db).fetchone(
"""
@ -439,22 +454,19 @@ async def get_balance_checks(conn: Optional[Connection] = None) -> List[BalanceC
async def save_balance_notify(
wallet_id: str,
url: str,
conn: Optional[Connection] = None,
wallet_id: str, url: str, conn: Optional[Connection] = None
):
await (conn or db).execute(
"""
INSERT OR REPLACE INTO balance_notify (wallet, url)
VALUES (?, ?)
INSERT INTO balance_notify (wallet, url) VALUES (?, ?)
ON CONFLICT (wallet) DO UPDATE SET url = ?
""",
(wallet_id, url),
(wallet_id, url, url),
)
async def get_balance_notify(
wallet_id: str,
conn: Optional[Connection] = None,
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[str]:
row = await (conn or db).fetchone(
"""

View file

@ -4,7 +4,7 @@ from sqlalchemy.exc import OperationalError # type: ignore
async def m000_create_migrations_table(db):
await db.execute(
"""
CREATE TABLE dbversions (
CREATE TABLE IF NOT EXISTS dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
@ -28,11 +28,11 @@ async def m001_initial(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS extensions (
user TEXT NOT NULL,
"user" TEXT NOT NULL,
extension TEXT NOT NULL,
active BOOLEAN DEFAULT 0,
active BOOLEAN DEFAULT false,
UNIQUE (user, extension)
UNIQUE ("user", extension)
);
"""
)
@ -41,14 +41,14 @@ async def m001_initial(db):
CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
user TEXT NOT NULL,
"user" TEXT NOT NULL,
adminkey TEXT NOT NULL,
inkey TEXT
);
"""
)
await db.execute(
"""
f"""
CREATE TABLE IF NOT EXISTS apipayments (
payhash TEXT NOT NULL,
amount INTEGER NOT NULL,
@ -56,8 +56,7 @@ async def m001_initial(db):
wallet TEXT NOT NULL,
pending BOOLEAN NOT NULL,
memo TEXT,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')),
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
UNIQUE (wallet, payhash)
);
"""
@ -65,18 +64,18 @@ async def m001_initial(db):
await db.execute(
"""
CREATE VIEW IF NOT EXISTS balances AS
CREATE VIEW balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming
FROM apipayments
WHERE amount > 0 AND pending = 0 -- don't sum pending
WHERE amount > 0 AND pending = false -- don't sum pending
GROUP BY wallet
UNION ALL
SELECT wallet, SUM(amount + fee) AS s -- outgoing, sum fees
FROM apipayments
WHERE amount < 0 -- do sum pending
GROUP BY wallet
)
)x
GROUP BY wallet;
"""
)
@ -143,21 +142,20 @@ async def m004_ensure_fees_are_always_negative(db):
"""
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW IF NOT EXISTS balances AS
CREATE VIEW balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming
FROM apipayments
WHERE amount > 0 AND pending = 0 -- don't sum pending
WHERE amount > 0 AND pending = false -- don't sum pending
GROUP BY wallet
UNION ALL
SELECT wallet, SUM(amount - abs(fee)) AS s -- outgoing, sum fees
FROM apipayments
WHERE amount < 0 -- do sum pending
GROUP BY wallet
)
)x
GROUP BY wallet;
"""
)
@ -170,8 +168,8 @@ async def m005_balance_check_balance_notify(db):
await db.execute(
"""
CREATE TABLE balance_check (
wallet INTEGER NOT NULL REFERENCES wallets (id),
CREATE TABLE IF NOT EXISTS balance_check (
wallet TEXT NOT NULL REFERENCES wallets (id),
service TEXT NOT NULL,
url TEXT NOT NULL,
@ -182,8 +180,8 @@ async def m005_balance_check_balance_notify(db):
await db.execute(
"""
CREATE TABLE balance_notify (
wallet INTEGER NOT NULL REFERENCES wallets (id),
CREATE TABLE IF NOT EXISTS balance_notify (
wallet TEXT NOT NULL REFERENCES wallets (id),
url TEXT NOT NULL,
UNIQUE(wallet, url)

View file

@ -1,32 +1,16 @@
import json
import hmac
import hashlib
from quart import url_for
from lnbits.helpers import url_for
from ecdsa import SECP256k1, SigningKey # type: ignore
from lnurl import encode as lnurl_encode # type: ignore
from typing import List, NamedTuple, Optional, Dict
from sqlite3 import Row
from pydantic import BaseModel
from lnbits.settings import WALLET
class User(NamedTuple):
id: str
email: str
extensions: List[str] = []
wallets: List["Wallet"] = []
password: Optional[str] = None
@property
def wallet_ids(self) -> List[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
class Wallet(NamedTuple):
class Wallet(BaseModel):
id: str
name: str
user: str
@ -46,12 +30,8 @@ class Wallet(NamedTuple):
@property
def lnurlwithdraw_full(self) -> str:
url = url_for(
"core.lnurl_full_withdraw",
usr=self.user,
wal=self.id,
_external=True,
)
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except:
@ -62,51 +42,46 @@ class Wallet(NamedTuple):
linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256")
return SigningKey.from_string(
linking_key,
curve=SECP256k1,
hashfunc=hashlib.sha256,
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
from .crud import get_wallet_payment
from .crud import get_standalone_payment
return await get_wallet_payment(self.id, payment_hash)
async def get_payments(
self,
*,
complete: bool = True,
pending: bool = False,
outgoing: bool = True,
incoming: bool = True,
exclude_uncheckable: bool = False,
) -> List["Payment"]:
from .crud import get_payments
return await get_payments(
wallet_id=self.id,
complete=complete,
pending=pending,
outgoing=outgoing,
incoming=incoming,
exclude_uncheckable=exclude_uncheckable,
)
return await get_standalone_payment(payment_hash)
class Payment(NamedTuple):
class User(BaseModel):
id: str
email: Optional[str] = None
extensions: List[str] = []
wallets: List[Wallet] = []
password: Optional[str] = None
admin: bool = False
@property
def wallet_ids(self) -> List[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
class Payment(BaseModel):
checking_id: str
pending: bool
amount: int
fee: int
memo: str
memo: Optional[str]
time: int
bolt11: str
preimage: str
payment_hash: str
extra: Dict
extra: Optional[Dict] = {}
wallet_id: str
webhook: str
webhook_status: int
webhook: Optional[str]
webhook_status: Optional[int]
@classmethod
def from_row(cls, row: Row):
@ -181,7 +156,7 @@ class Payment(NamedTuple):
await delete_payment(self.checking_id)
class BalanceCheck(NamedTuple):
class BalanceCheck(BaseModel):
wallet: str
service: str
url: str

View file

@ -1,34 +1,36 @@
import trio
import asyncio
import json
import httpx
from io import BytesIO
from binascii import unhexlify
from typing import Optional, Tuple, Dict
from urllib.parse import urlparse, parse_qs
from quart import g, url_for
from lnurl import LnurlErrorResponse, decode as decode_lnurl # type: ignore
from io import BytesIO
from typing import Dict, Optional, Tuple
from urllib.parse import parse_qs, urlparse
import httpx
from lnurl import LnurlErrorResponse
from lnurl import decode as decode_lnurl # type: ignore
from lnbits import bolt11
from lnbits.db import Connection
from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.requestvars import g
from lnbits.settings import WALLET
from lnbits.wallets.base import PaymentResponse, PaymentStatus
from . import db
from .crud import (
check_internal,
create_payment,
delete_payment,
get_wallet,
get_wallet_payment,
update_payment_status,
)
try:
from typing import TypedDict # type: ignore
except ImportError: # pragma: nocover
from typing_extensions import TypedDict
from lnbits import bolt11
from lnbits.db import Connection
from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import WALLET
from lnbits.wallets.base import PaymentStatus, PaymentResponse
from . import db
from .crud import (
get_wallet,
create_payment,
delete_payment,
check_internal,
update_payment_status,
get_wallet_payment,
)
class PaymentFailure(Exception):
pass
@ -49,7 +51,6 @@ async def create_invoice(
conn: Optional[Connection] = None,
) -> Tuple[str, str]:
invoice_memo = None if description_hash else memo
storeable_memo = memo
ok, checking_id, payment_request, error_message = await WALLET.create_invoice(
amount=amount, memo=invoice_memo, description_hash=description_hash
@ -66,7 +67,7 @@ async def create_invoice(
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_msat,
memo=storeable_memo,
memo=memo,
extra=extra,
webhook=webhook,
conn=conn,
@ -84,11 +85,12 @@ async def pay_invoice(
description: str = "",
conn: Optional[Connection] = None,
) -> str:
invoice = bolt11.decode(payment_request)
fee_reserve_msat = fee_reserve(invoice.amount_msat)
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
temp_id = f"temp_{urlsafe_short_hash()}"
internal_id = f"internal_{urlsafe_short_hash()}"
invoice = bolt11.decode(payment_request)
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000:
@ -131,7 +133,7 @@ async def pay_invoice(
# the balance is enough in the next step
await create_payment(
checking_id=temp_id,
fee=-fee_reserve(invoice.amount_msat),
fee=-fee_reserve_msat,
conn=conn,
**payment_kwargs,
)
@ -140,26 +142,33 @@ async def pay_invoice(
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet
if wallet.balance_msat < 0:
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
raise PaymentFailure(
f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
)
raise PermissionError("Insufficient balance.")
if internal_checking_id:
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
if internal_checking_id:
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id,
pending=False,
conn=conn,
checking_id=internal_checking_id, pending=False, conn=conn
)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_paid
# notify receiver asynchronously
await internal_invoice_paid.send(internal_checking_id)
else:
# actually pay the external invoice
payment: PaymentResponse = await WALLET.pay_invoice(payment_request)
if payment.checking_id:
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(internal_checking_id)
else:
# actually pay the external invoice
payment: PaymentResponse = await WALLET.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id:
async with db.connect() as conn:
await create_payment(
checking_id=payment.checking_id,
fee=payment.fee_msat,
@ -169,13 +178,15 @@ async def pay_invoice(
**payment_kwargs,
)
await delete_payment(temp_id, conn=conn)
else:
raise PaymentFailure(
payment.error_message
or "Payment failed, but backend didn't give us an error message."
)
else:
async with db.connect() as conn:
await delete_payment(temp_id, conn=conn)
raise PaymentFailure(
payment.error_message
or "Payment failed, but backend didn't give us an error message."
)
return invoice.payment_hash
return invoice.payment_hash
async def redeem_lnurl_withdraw(
@ -211,19 +222,15 @@ async def redeem_lnurl_withdraw(
return None
if wait_seconds:
await trio.sleep(wait_seconds)
await asyncio.sleep(wait_seconds)
params = {
"k1": res["k1"],
"pr": payment_request,
}
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
"core.lnurl_balance_notify",
service=urlparse(lnurl_request).netloc,
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
_external=True,
)
except Exception:
pass
@ -236,13 +243,12 @@ async def redeem_lnurl_withdraw(
async def perform_lnurlauth(
callback: str,
conn: Optional[Connection] = None,
callback: str, conn: Optional[Connection] = None
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
key = g.wallet.lnurlauth_key(cb.netloc)
key = g().wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
@ -275,12 +281,12 @@ async def perform_lnurlauth(
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30 .to_bytes(1, "big", signed=False))
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02 .to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r)
signature.write(0x02 .to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s)
@ -305,21 +311,30 @@ async def perform_lnurlauth(
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text,
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
async def check_invoice_status(
wallet_id: str,
payment_hash: str,
conn: Optional[Connection] = None,
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
if not payment:
return PaymentStatus(None)
return await WALLET.get_invoice_status(payment.checking_id)
status = await WALLET.get_invoice_status(payment.checking_id)
if not payment.pending:
return status
if payment.is_out and status.failed:
print(f" - deleting outgoing failed payment {payment.checking_id}: {status}")
await payment.delete()
elif not status.pending:
print(
f" - marking '{'in' if payment.is_in else 'out'}' {payment.checking_id} as not pending anymore: {status}"
)
await payment.set_pending(status.pending)
return status
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int) -> int:
return max(1000, int(amount_msat * 0.01))
return max(2000, int(amount_msat * 0.01))

View file

@ -161,14 +161,14 @@ new Vue({
{
name: 'sat',
align: 'right',
label: 'Amount (sat)',
label: 'Amount (' + LNBITS_DENOMINATION + ')',
field: 'sat',
sortable: true
},
{
name: 'fee',
align: 'right',
label: 'Fee (msat)',
label: 'Fee (m' + LNBITS_DENOMINATION + ')',
field: 'fee'
}
],
@ -184,12 +184,18 @@ new Vue({
show: false,
location: window.location
},
balance: 0
balance: 0,
credit: 0,
newName: ''
}
},
computed: {
formattedBalance: function () {
return LNbits.utils.formatSat(this.balance || this.g.wallet.sat)
if (LNBITS_DENOMINATION != 'sats') {
return this.balance / 100
} else {
return LNbits.utils.formatSat(this.balance || this.g.wallet.sat)
}
},
filteredPayments: function () {
var q = this.paymentsTable.filter
@ -202,9 +208,7 @@ new Vue({
return this.parse.invoice.sat <= this.balance
},
pendingPaymentsExist: function () {
return this.payments
? _.where(this.payments, {pending: 1}).length > 0
: false
return this.payments.findIndex(payment => payment.pending) !== -1
}
},
filters: {
@ -250,6 +254,28 @@ new Vue({
this.parse.data.paymentChecker = null
this.parse.camera.show = false
},
updateBalance: function (credit) {
if (LNBITS_DENOMINATION != 'sats') {
credit = credit * 100
}
LNbits.api
.request('PUT', '/api/v1/wallet/balance/' + credit, this.g.wallet.inkey)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `Failed to update.`
})
return
}
this.balance = this.balance + data.balance
})
},
closeReceiveDialog: function () {
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
@ -272,7 +298,9 @@ new Vue({
},
createInvoice: function () {
this.receive.status = 'loading'
if (LNBITS_DENOMINATION != 'sats') {
this.receive.data.amount = this.receive.data.amount * 100
}
LNbits.api
.createInvoice(
this.g.wallet,
@ -336,18 +364,21 @@ new Vue({
},
decodeRequest: function () {
this.parse.show = true
let req = this.parse.data.request.toLowerCase()
if (this.parse.data.request.startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10)
} else if (this.parse.data.request.startsWith('lnurl:')) {
this.parse.data.request = this.parse.data.request.slice(6)
} else if (this.parse.data.request.indexOf('lightning=lnurl1') !== -1) {
} else if (req.indexOf('lightning=lnurl1') !== -1) {
this.parse.data.request = this.parse.data.request
.split('lightning=')[1]
.split('&')[0]
}
if (this.parse.data.request.toLowerCase().startsWith('lnurl1')) {
if (
this.parse.data.request.toLowerCase().startsWith('lnurl1') ||
this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
) {
LNbits.api
.request(
'GET',
@ -585,6 +616,30 @@ new Vue({
}
})
},
updateWalletName: function () {
let newName = this.newName
if (!newName || !newName.length) return
// let data = {name: newName}
LNbits.api
.request('PUT', '/api/v1/wallet/' + newName, this.g.wallet.adminkey, {})
.then(res => {
this.newName = ''
this.$q.notify({
message: `Wallet named updated.`,
type: 'positive',
timeout: 3500
})
LNbits.href.updateWallet(
res.data.name,
this.user.id,
this.g.wallet.id
)
})
.catch(err => {
this.newName = ''
LNbits.utils.notifyApiError(err)
})
},
deleteWallet: function (walletId, user) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?')

View file

@ -1,4 +1,4 @@
import trio
import asyncio
import httpx
from typing import List
@ -8,17 +8,19 @@ from . import db
from .crud import get_balance_notify
from .models import Payment
api_invoice_listeners: List[trio.MemorySendChannel] = []
api_invoice_listeners: List[asyncio.Queue] = []
async def register_listeners():
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(5)
register_invoice_listener(invoice_paid_chan_send)
await wait_for_paid_invoices(invoice_paid_chan_recv)
async def register_task_listeners():
invoice_paid_queue = asyncio.Queue(5)
register_invoice_listener(invoice_paid_queue)
asyncio.create_task(wait_for_paid_invoices(invoice_paid_queue))
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
while True:
payment = await invoice_paid_queue.get()
# send information to sse channel
await dispatch_invoice_listener(payment)
@ -31,10 +33,7 @@ async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
if url:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
url,
timeout=4,
)
r = await client.post(url, timeout=4)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
pass
@ -43,21 +42,17 @@ async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async def dispatch_invoice_listener(payment: Payment):
for send_channel in api_invoice_listeners:
try:
send_channel.send_nowait(payment)
except trio.WouldBlock:
send_channel.put_nowait(payment)
except asyncio.QueueFull:
print("removing sse listener", send_channel)
api_invoice_listeners.remove(send_channel)
async def dispatch_webhook(payment: Payment):
async with httpx.AsyncClient() as client:
data = payment._asdict()
data = payment.dict()
try:
r = await client.post(
payment.webhook,
json=data,
timeout=40,
)
r = await client.post(payment.webhook, json=data, timeout=40)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)

View file

@ -19,7 +19,7 @@
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.adminkey }}</i>"}</code><br />
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@ -29,7 +29,7 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl {{ request.url_root }}api/v1/wallet -H "X-Api-Key:
>curl {{ request.base_url }}api/v1/wallet -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>"</code
>
</q-card-section>
@ -59,9 +59,9 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": false,
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;, "webhook":
&lt;url:string&gt;}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
&lt;url:string&gt;, "unit": &lt;string&gt;}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
"Content-type: application/json"</code
>
</q-card-section>
@ -86,7 +86,7 @@
<code>{"payment_hash": &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/payments -d '{"out": true,
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": true,
"bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
application/json"</code
@ -94,6 +94,35 @@
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Decode an invoice"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"invoice": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
@ -115,7 +144,7 @@
<code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
>curl -X GET {{ request.base_url
}}api/v1/payments/&lt;payment_hash&gt; -H "X-Api-Key:
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
>

View file

@ -17,14 +17,14 @@
></q-icon>
{% raw %}
<h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5>
{{ extension.shortDescription }} {% endraw %}
<small>{{ extension.shortDescription }} </small>{% endraw %}
</q-card-section>
<q-separator></q-separator>
<q-card-actions>
<div v-if="extension.isEnabled">
<q-btn
flat
color="deep-purple"
color="primary"
type="a"
:href="[extension.url, '?usr=', g.user.id].join('')"
>Open</q-btn
@ -41,7 +41,7 @@
<q-btn
v-else
flat
color="deep-purple"
color="primary"
type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')"
>

View file

@ -8,10 +8,10 @@
{% if lnurl %}
<q-btn
unelevated
color="deep-purple"
color="primary"
@click="processing"
type="a"
href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}"
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
>
Press to claim bitcoin
</q-btn>
@ -21,11 +21,11 @@
filled
dense
v-model="walletName"
label="Name your LNbits wallet *"
label="Name your {{SITE_TITLE}} wallet *"
></q-input>
<q-btn
unelevated
color="deep-purple"
color="primary"
:disable="walletName == ''"
type="submit"
>Add a new wallet</q-btn
@ -37,58 +37,66 @@
<q-card>
<q-card-section>
<h3 class="q-my-none"><strong>LN</strong>bits</h3>
<h5 class="q-my-md">Free and open-source lightning wallet</h5>
<p>
Easy to set up and lightweight, LNbits can run on any
lightning-network funding source, currently supporting LND,
c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself!
</p>
<p>
You can run LNbits for yourself, or easily offer a custodian solution
for others.
</p>
<p>
Each wallet has its own API keys and there is no limit to the number
of wallets you can make. Being able to partition funds makes LNbits a
useful tool for money management and as a development tool.
</p>
<p>
Extensions add extra functionality to LNbits so you can experiment
with a range of cutting-edge technologies on the lightning network. We
have made developing extensions as easy as possible, and as a free and
open-source project, we encourage people to develop and submit their
own.
</p>
<div class="row q-mt-md q-gutter-sm">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener"
>View project in GitHub</q-btn
>
<q-btn
outline
color="grey"
type="a"
href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
target="_blank"
rel="noopener"
>Donate</q-btn
>
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
<div v-if="'{{SITE_TITLE}}' == 'LNbits'">
<p>
Easy to set up and lightweight, LNbits can run on any
lightning-network funding source, currently supporting LND,
c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself!
</p>
<p>
You can run LNbits for yourself, or easily offer a custodian
solution for others.
</p>
<p>
Each wallet has its own API keys and there is no limit to the number
of wallets you can make. Being able to partition funds makes LNbits
a useful tool for money management and as a development tool.
</p>
<p>
Extensions add extra functionality to LNbits so you can experiment
with a range of cutting-edge technologies on the lightning network.
We have made developing extensions as easy as possible, and as a
free and open-source project, we encourage people to develop and
submit their own.
</p>
<div class="row q-mt-md q-gutter-sm">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener"
>View project in GitHub</q-btn
>
<q-btn
outline
color="grey"
type="a"
href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
target="_blank"
rel="noopener"
>Donate</q-btn
>
</div>
</div>
<p v-else>{{SITE_DESCRIPTION}}</p>
</q-card-section>
</q-card>
</div>
<!-- Ads -->
<div class="col-12 col-md-3 col-lg-3">
<div class="col-12 col-md-3 col-lg-3" v-if="'{{SITE_TITLE}}' == 'LNbits'">
<div class="row q-col-gutter-lg justify-center">
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn flat color="purple" label="Runs on" class="full-width"></q-btn>
<q-btn
flat
color="secondary"
label="Runs on"
class="full-width"
></q-btn>
<div class="row">
<div class="col">
<a href="https://github.com/ElementsProject/lightning">

View file

@ -15,14 +15,58 @@
<q-card>
<q-card-section>
<h3 class="q-my-none">
<strong>{% raw %}{{ formattedBalance }}{% endraw %}</strong> sat
<strong>{% raw %}{{ formattedBalance }} {% endraw %}</strong>
{{LNBITS_DENOMINATION}}
<q-btn
v-if="'{{user.admin}}' == 'True'"
flat
round
color="primary"
icon="add"
size="md"
>
<q-popup-edit
class="bg-accent text-white"
v-slot="scope"
v-model="credit"
>
<q-input
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
label="Amount to credit account"
v-model="scope.value"
dense
autofocus
mask="#.##"
fill-mask="0"
reverse-fill-mask
@keyup.enter="updateBalance(scope.value)"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
<q-input
v-else
type="number"
label="Amount to credit account"
v-model="scope.value"
dense
autofocus
@keyup.enter="updateBalance(scope.value)"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
</q-btn>
</h3>
</q-card-section>
<div class="row q-pb-md q-px-md q-col-gutter-md">
<div class="row q-pb-md q-px-md q-col-gutter-md gt-sm">
<div class="col">
<q-btn
unelevated
color="deep-purple"
color="primary"
class="full-width"
@click="showParseDialog"
>Paste Request</q-btn
@ -31,7 +75,7 @@
<div class="col">
<q-btn
unelevated
color="deep-purple"
color="primary"
class="full-width"
@click="showReceiveDialog"
>Create Invoice</q-btn
@ -40,7 +84,7 @@
<div class="col">
<q-btn
unelevated
color="purple"
color="secondary"
icon="photo_camera"
@click="showCamera"
>scan
@ -92,6 +136,7 @@
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
no-data-label="No transactions made yet"
:filter="paymentsTable.filter"
>
{% raw %}
<template v-slot:header="props">
@ -140,7 +185,17 @@
<q-tooltip>{{ props.row.date }}</q-tooltip>
{{ props.row.dateFrom }}
</q-td>
<q-td auto-width key="sat" :props="props">
{% endraw %}
<q-td
auto-width
key="sat"
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
:props="props"
>{% raw %} {{ parseFloat(String(props.row.fsat).replaceAll(",",
"")) / 100 }}
</q-td>
<q-td auto-width key="sat" v-else :props="props">
{{ props.row.fsat }}
</q-td>
<q-td auto-width key="fee" :props="props">
@ -218,378 +273,470 @@
</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-mt-none q-mb-sm">
LNbits wallet: <strong><em>{{ wallet.name }}</em></strong>
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "core/_api_docs.html" %}
{% if HIDE_API %}
<div class="col-12 col-md-4 q-gutter-y-md">
{% else %}
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
{{ SITE_TITLE }} Wallet: <strong><em>{{ wallet.name }}</em></strong>
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
{% if wallet.lnurlwithdraw_full %}
<q-expansion-item group="extras" icon="crop_free" label="Drain Funds">
<q-card>
<q-card-section class="text-center">
<p>
This is an LNURL-withdraw QR code for slurping everything from
this wallet. Do not share with anyone.
</p>
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
<q-list>
{% include "core/_api_docs.html" %}
<q-separator></q-separator>
{% if wallet.lnurlwithdraw_full %}
<q-expansion-item
group="extras"
icon="crop_free"
label="Drain Funds"
>
<q-card>
<q-card-section class="text-center">
<p>
This is an LNURL-withdraw QR code for slurping everything
from this wallet. Do not share with anyone.
</p>
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
<qrcode
value="{{wallet.lnurlwithdraw_full}}"
:options="{width:240}"
></qrcode>
</a>
<p>
It is compatible with <code>balanceCheck</code> and
<code>balanceNotify</code> so your wallet may keep pulling
the funds continuously from here after the first withdraw.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
{% endif %}
<q-expansion-item
group="extras"
icon="settings_cell"
label="Export to Phone with QR Code"
>
<q-card>
<q-card-section class="text-center">
<p>
This QR code contains your wallet URL with full access. You
can scan it from your phone to open your wallet from there.
</p>
<qrcode
value="{{wallet.lnurlwithdraw_full}}"
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}"
></qrcode>
</a>
<p>
It is compatible with <code>balanceCheck</code> and
<code>balanceNotify</code> so your wallet may keep pulling the
funds continuously from here after the first withdraw.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
{% endif %}
<q-expansion-item
group="extras"
icon="settings_cell"
label="Export to Phone with QR Code"
>
<q-card>
<q-card-section class="text-center">
<p>
This QR code contains your wallet URL with full access. You
can scan it from your phone to open your wallet from there.
</p>
<qrcode
:value="'{{request.url_root}}'+'wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}"
></qrcode>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="remove_circle"
label="Delete wallet"
>
<q-card>
<q-card-section>
<p>
This whole wallet will be deleted, the funds will be
<strong>UNRECOVERABLE</strong>.
</p>
<q-btn
unelevated
color="red-10"
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
>Delete wallet</q-btn
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item group="extras" icon="edit" label="Rename wallet">
<q-card>
<q-card-section>
<div class="" style="max-width: 320px">
<q-input
filled
v-model.trim="newName"
label="Label"
dense="dense"
@update:model-value="(e) => console.log(e)"
/>
</div>
<q-btn
:disable="!newName.length"
unelevated
class="q-mt-sm"
color="primary"
@click="updateWalletName()"
>Update name</q-btn
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="remove_circle"
label="Delete wallet"
>
<q-card>
<q-card-section>
<p>
This whole wallet will be deleted, the funds will be
<strong>UNRECOVERABLE</strong>.
</p>
<q-btn
unelevated
color="red-10"
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
>Delete wallet</q-btn
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
ADS.split(';') %}
<q-card>
<a href="{{ AD[0] }}"
><img width="100%" src="{{ AD[1] }}"
/></a> </q-card
>{% endfor %} {% endif %}
</div>
</div>
</div>
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
{% raw %}
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
</p>
<q-select
filled
dense
v-model="receive.unit"
type="text"
label="Unit"
:options="receive.units"
></q-select>
<q-input
filled
dense
v-model.number="receive.data.amount"
type="number"
:label="`Amount (${receive.unit}) *`"
:step="receive.unit != 'sat' ? '0.001' : '1'"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
<q-input
filled
dense
v-model.trim="receive.data.memo"
label="Memo"
placeholder="LNbits invoice"
></q-input>
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn
unelevated
color="deep-purple"
:disable="receive.data.memo == null || receive.data.amount == null || receive.data.amount <= 0"
type="submit"
>
<span v-if="receive.lnurl">
Withdraw from {{receive.lnurl.domain}}
</span>
<span v-else> Create invoice </span>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<q-spinner
v-if="receive.status == 'loading'"
color="deep-purple"
size="2.55em"
></q-spinner>
</q-form>
</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="receive.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>
{% endraw %}
</q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="parse.invoice">
{% raw %}
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator>
<p class="text-wrap">
<strong>Description:</strong> {{ parse.invoice.description }}<br />
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ parse.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="deep-purple" @click="payInvoice">Pay</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black"
>Not enough funds!</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
<div v-else-if="parse.lnurlauth">
{% raw %}
<q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6">
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
{% raw %}
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
</p>
<q-separator class="q-my-sm"></q-separator>
<p>
For every website and for every LNbits wallet, a new keypair will be
deterministically generated so your identity can't be tied to your
LNbits wallet or linked across websites. No other data will be shared
with {{ parse.lnurlauth.domain }}.
</p>
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
<p class="q-mx-xl">
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
</p>
<div class="row q-mt-lg">
<q-btn unelevated color="deep-purple" type="submit">Login</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else-if="parse.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
parse.lnurlpay.maxSendable | msatoshiFormat }} sat
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.domain }}</b> is requesting <br />
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b> sat
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<q-separator class="q-my-sm"></q-separator>
<div class="row">
<p class="col text-justify text-italic">
{{ parse.lnurlpay.description }}
</p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" />
</p>
</div>
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.number="parse.data.amount"
type="number"
label="Amount (sat) *"
:min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay.fixed"
></q-input>
</div>
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
<q-input
filled
dense
v-model="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="deep-purple" type="submit"
>Send satoshis</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else>
<q-form
v-if="!parse.camera.show"
@submit="decodeRequest"
class="q-gutter-md"
>
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %}
<q-input
filled
dense
v-model.trim="parse.data.request"
type="textarea"
label="Paste an invoice, payment request or lnurl code *"
>
</q-input>
<div class="row q-mt-lg">
v-model.number="receive.data.amount"
label="Amount ({{LNBITS_DENOMINATION}}) *"
mask="#.##"
fill-mask="0"
reverse-fill-mask
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% else %}
<q-select
filled
dense
v-model="receive.unit"
type="text"
label="Unit"
:options="receive.units"
></q-select>
<q-input
filled
dense
v-model.number="receive.data.amount"
:label="'Amount (' + receive.unit + ') *'"
:mask="receive.unit != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:step="receive.unit != 'sat' ? '0.01' : '1'"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% endif %}
<q-input
filled
dense
v-model.trim="receive.data.memo"
label="Memo"
></q-input>
{% raw %}
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn
unelevated
color="deep-purple"
:disable="parse.data.request == ''"
color="primary"
:disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit"
>Read</q-btn
>
<span v-if="receive.lnurl">
Withdraw from {{receive.lnurl.domain}}
</span>
<span v-else> Create invoice </span>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
<q-spinner
v-if="receive.status == 'loading'"
color="primary"
size="2.55em"
></q-spinner>
</q-form>
</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="receive.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>
{% endraw %}
</q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="parse.invoice">
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",",
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
</h6>
<h6 v-else class="q-my-none">
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
</h6>
<q-separator class="q-my-sm"></q-separator>
<p class="text-wrap">
<strong>Description:</strong> {{ parse.invoice.description }}<br />
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ parse.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black"
>Not enough funds!</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</div>
<div v-else-if="parse.lnurlauth">
{% raw %}
<q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6">
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
</p>
<q-separator class="q-my-sm"></q-separator>
<p>
For every website and for every LNbits wallet, a new keypair will be
deterministically generated so your identity can't be tied to your
LNbits wallet or linked across websites. No other data will be
shared with {{ parse.lnurlauth.domain }}.
</p>
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
<p class="q-mx-xl">
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
</p>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Login</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else-if="parse.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
parse.lnurlpay.maxSendable | msatoshiFormat }}
{{LNBITS_DENOMINATION}}
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b> is
requesting <br />
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<q-separator class="q-my-sm"></q-separator>
<div class="row">
<p class="col text-justify text-italic">
{{ parse.lnurlpay.description }}
</p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" />
</p>
</div>
<div class="row">
<div class="col">
{% endraw %}
<q-input
filled
dense
v-model.number="parse.data.amount"
type="number"
label="Amount ({{LNBITS_DENOMINATION}}) *"
:min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay.fixed"
></q-input>
{% raw %}
</div>
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
<q-input
filled
dense
v-model="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
>Send {{LNBITS_DENOMINATION}}</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
Cancel
</q-btn>
<q-form
v-if="!parse.camera.show"
@submit="decodeRequest"
class="q-gutter-md"
>
<q-input
filled
dense
v-model.trim="parse.data.request"
type="textarea"
label="Paste an invoice, payment request or lnurl code *"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="parse.data.request == ''"
type="submit"
>Read</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
Cancel
</q-btn>
</div>
</div>
</div>
</div>
</q-card>
</q-dialog>
</q-card>
</q-dialog>
<q-dialog v-model="parse.camera.show">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="parse.camera.show">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@decode="decodeQR"
class="rounded-borders"
></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="paymentsChart.show">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="paymentsChart.show">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
<q-tabs
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-max"
active-class="px-0"
indicator-color="transparent"
>
<q-tab
icon="account_balance_wallet"
label="Wallets"
@click="g.visibleDrawer = !g.visibleDrawer"
>
</q-tab>
<q-tab icon="content_paste" label="Paste" @click="showParseDialog"> </q-tab>
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog">
</q-tab>
{% if service_fee > 0 %}
<div ref="disclaimer"></div>
<q-dialog v-model="disclaimerDialog.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-deep-purple">Warning</h6>
<p>
Login functionality to be released in v0.2, for now,
<strong
>make sure you bookmark this page for future access to your
wallet</strong
>!
</p>
<p>
This service is in BETA, and we hold no responsibility for people losing
access to funds. To encourage you to run your own LNbits installation, any
balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will
incur a charge of <strong>{{ service_fee }}% service fee</strong> per
week.
</p>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(disclaimerDialog.location.href)"
>Copy wallet URL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>I understand</q-btn
>
</div>
</q-card>
</q-dialog>
{% endif %} {% endblock %}
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
</q-tabs>
{% if service_fee > 0 %}
<div ref="disclaimer"></div>
<q-dialog v-model="disclaimerDialog.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-deep-purple">Warning</h6>
<p>
Login functionality to be released in v0.2, for now,
<strong
>make sure you bookmark this page for future access to your
wallet</strong
>!
</p>
<p>
This service is in BETA, and we hold no responsibility for people losing
access to funds. To encourage you to run your own LNbits installation,
any balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %}
will incur a charge of
<strong>{{ service_fee }}% service fee</strong> per week.
</p>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(disclaimerDialog.location.href)"
>Copy wallet URL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>I understand</q-btn
>
</div>
</q-card>
</q-dialog>
{% endif %} {% endblock %}
</div>

View file

@ -1,22 +1,54 @@
import trio
import asyncio
import hashlib
import json
import lnurl # type: ignore
import httpx
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
from quart import g, current_app, jsonify, make_response, url_for
from http import HTTPStatus
from binascii import unhexlify
from typing import Dict, Union
from http import HTTPStatus
from typing import Dict, List, Optional, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
from lnbits import bolt11
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.utils.exchange_rates import currencies, fiat_amount_as_satoshis
import httpx
from fastapi import Query, Request, Header
from fastapi.exceptions import HTTPException
from fastapi.param_functions import Depends
from fastapi.params import Body
from pydantic import BaseModel
from pydantic.fields import Field
from sse_starlette.sse import EventSourceResponse
from lnbits import bolt11, lnurl
from lnbits.bolt11 import Invoice
from lnbits.core.models import Payment, Wallet
from lnbits.decorators import (
WalletAdminKeyChecker,
WalletInvoiceKeyChecker,
WalletTypeInfo,
get_key_type,
require_admin_key
)
from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.requestvars import g
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE
from lnbits.utils.exchange_rates import (
currencies,
fiat_amount_as_satoshis,
satoshis_amount_as_fiat,
)
from .. import core_app, db
from ..crud import save_balance_check
from ..crud import (
create_payment,
get_payments,
get_standalone_payment,
get_wallet,
get_wallet_for_key,
save_balance_check,
update_payment_status,
update_wallet,
)
from ..services import (
PaymentFailure,
InvoiceFailure,
PaymentFailure,
check_invoice_status,
create_invoice,
pay_invoice,
perform_lnurlauth,
@ -24,98 +56,131 @@ from ..services import (
from ..tasks import api_invoice_listeners
@core_app.route("/api/v1/wallet", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_wallet():
return (
jsonify(
{
"id": g.wallet.id,
"name": g.wallet.name,
"balance": g.wallet.balance_msat,
}
),
HTTPStatus.OK,
@core_app.get("/api/v1/wallet")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
if wallet.wallet_type == 0:
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
else:
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
@core_app.put("/api/v1/wallet/balance/{amount}")
async def api_update_balance(
amount: int, wallet: WalletTypeInfo = Depends(get_key_type)
):
if wallet.wallet.user not in LNBITS_ADMIN_USERS:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not an admin user"
)
payHash = urlsafe_short_hash()
await create_payment(
wallet_id=wallet.wallet.id,
checking_id=payHash,
payment_request="selfPay",
payment_hash=payHash,
amount=amount * 1000,
memo="selfPay",
fee=0,
)
await update_payment_status(checking_id=payHash, pending=False)
updatedWallet = await get_wallet(wallet.wallet.id)
@core_app.route("/api/v1/payments", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_payments():
return jsonify(await g.wallet.get_payments(pending=True)), HTTPStatus.OK
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"amount": {"type": "number", "min": 0.001, "required": True},
"memo": {
"type": "string",
"empty": False,
"required": True,
"excludes": "description_hash",
},
"unit": {"type": "string", "empty": False, "required": False},
"description_hash": {
"type": "string",
"empty": False,
"required": True,
"excludes": "memo",
},
"lnurl_callback": {"type": "string", "nullable": True, "required": False},
"lnurl_balance_check": {"type": "string", "required": False},
"extra": {"type": "dict", "nullable": True, "required": False},
"webhook": {"type": "string", "empty": False, "required": False},
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": amount,
}
)
async def api_payments_create_invoice():
if "description_hash" in g.data:
description_hash = unhexlify(g.data["description_hash"])
@core_app.put("/api/v1/wallet/{new_name}")
async def api_update_wallet(
new_name: str, wallet: WalletTypeInfo = Depends(WalletAdminKeyChecker())
):
await update_wallet(wallet.wallet.id, new_name)
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
@core_app.get("/api/v1/payments")
async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
await get_payments(wallet_id=wallet.wallet.id, pending=True, complete=True)
pendingPayments = await get_payments(
wallet_id=wallet.wallet.id, pending=True, exclude_uncheckable=True
)
for payment in pendingPayments:
await check_invoice_status(
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
)
return await get_payments(wallet_id=wallet.wallet.id, pending=True, complete=True)
class CreateInvoiceData(BaseModel):
out: Optional[bool] = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
unit: Optional[str] = "sat"
description_hash: Optional[str] = None
lnurl_callback: Optional[str] = None
lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.description_hash:
description_hash = unhexlify(data.description_hash)
memo = ""
else:
description_hash = b""
memo = g.data["memo"]
if g.data.get("unit") or "sat" == "sat":
amount = g.data["amount"]
memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat":
amount = int(data.amount)
else:
price_in_sats = await fiat_amount_as_satoshis(g.data["amount"], g.data["unit"])
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
amount = price_in_sats
async with db.connect() as conn:
try:
payment_hash, payment_request = await create_invoice(
wallet_id=g.wallet.id,
wallet_id=wallet.id,
amount=amount,
memo=memo,
description_hash=description_hash,
extra=g.data.get("extra"),
webhook=g.data.get("webhook"),
extra=data.extra,
webhook=data.webhook,
conn=conn,
)
except InvoiceFailure as e:
return jsonify({"message": str(e)}), 520
raise HTTPException(status_code=520, detail=str(e))
except Exception as exc:
raise exc
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
if g.data.get("lnurl_callback"):
if "lnurl_balance_check" in g.data:
save_balance_check(g.wallet.id, g.data["lnurl_balance_check"])
if data.lnurl_callback:
if "lnurl_balance_check" in data:
save_balance_check(wallet.id, data.lnurl_balance_check)
async with httpx.AsyncClient() as client:
try:
r = await client.get(
g.data["lnurl_callback"],
data.lnurl_callback,
params={
"pr": payment_request,
"balanceNotify": url_for(
"core.lnurl_balance_notify",
service=urlparse(g.data["lnurl_callback"]).netloc,
wal=g.wallet.id,
_external=True,
f"/withdraw/notify/{urlparse(data.lnurl_callback).netloc}",
external=True,
wal=wallet.id,
),
},
timeout=10,
@ -131,320 +196,387 @@ async def api_payments_create_invoice():
except (httpx.ConnectError, httpx.RequestError):
lnurl_response = False
return (
jsonify(
{
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
# maintain backwards compatibility with API clients:
"checking_id": invoice.payment_hash,
"lnurl_response": lnurl_response,
}
),
HTTPStatus.CREATED,
)
return {
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
# maintain backwards compatibility with API clients:
"checking_id": invoice.payment_hash,
"lnurl_response": lnurl_response,
}
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={"bolt11": {"type": "string", "empty": False, "required": True}}
)
async def api_payments_pay_invoice():
async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
try:
payment_hash = await pay_invoice(
wallet_id=g.wallet.id,
payment_request=g.data["bolt11"],
)
payment_hash = await pay_invoice(wallet_id=wallet.id, payment_request=bolt11)
except ValueError as e:
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
except PermissionError as e:
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e))
except PaymentFailure as e:
return jsonify({"message": str(e)}), 520
raise HTTPException(status_code=520, detail=str(e))
except Exception as exc:
raise exc
return (
jsonify(
{
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
),
HTTPStatus.CREATED,
)
@core_app.route("/api/v1/payments", methods=["POST"])
@api_validate_post_request(schema={"out": {"type": "boolean", "required": True}})
async def api_payments_create():
if g.data["out"] is True:
return await api_payments_pay_invoice()
return await api_payments_create_invoice()
@core_app.route("/api/v1/payments/lnurl", methods=["POST"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"description_hash": {"type": "string", "empty": False, "required": True},
"callback": {"type": "string", "empty": False, "required": True},
"amount": {"type": "number", "empty": False, "required": True},
"comment": {
"type": "string",
"nullable": True,
"empty": True,
"required": False,
},
"description": {
"type": "string",
"nullable": True,
"empty": True,
"required": False,
},
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
@core_app.post(
"/api/v1/payments",
# deprecated=True,
# description="DEPRECATED. Use /api/v2/TBD and /api/v2/TBD instead",
status_code=HTTPStatus.CREATED,
)
async def api_payments_pay_lnurl():
domain = urlparse(g.data["callback"]).netloc
async def api_payments_create(
wallet: WalletTypeInfo = Depends(get_key_type),
invoiceData: CreateInvoiceData = Body(...),
):
if wallet.wallet_type < 0 or wallet.wallet_type > 2:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Key is invalid")
if invoiceData.out is True and wallet.wallet_type == 0:
if not invoiceData.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="BOLT11 string is invalid or not given",
)
return await api_payments_pay_invoice(
invoiceData.bolt11, wallet.wallet
) # admin key
# invoice key
return await api_payments_create_invoice(invoiceData, wallet.wallet)
class CreateLNURLData(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
@core_app.post("/api/v1/payments/lnurl")
async def api_payments_pay_lnurl(
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(get_key_type)
):
domain = urlparse(data.callback).netloc
async with httpx.AsyncClient() as client:
try:
r = await client.get(
g.data["callback"],
params={"amount": g.data["amount"], "comment": g.data["comment"]},
data.callback,
params={"amount": data.amount, "comment": data.comment},
timeout=40,
)
if r.is_error:
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
raise httpx.ConnectError
except (httpx.ConnectError, httpx.RequestError):
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
)
params = json.loads(r.text)
if params.get("status") == "ERROR":
return (
jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}),
HTTPStatus.BAD_REQUEST,
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != g.data["amount"]:
return (
jsonify(
{
"message": f"{domain} returned an invalid invoice. Expected {g.data['amount']} msat, got {invoice.amount_msat}."
}
),
HTTPStatus.BAD_REQUEST,
)
if invoice.description_hash != g.data["description_hash"]:
return (
jsonify(
{
"message": f"{domain} returned an invalid invoice. Expected description_hash == {g.data['description_hash']}, got {invoice.description_hash}."
}
),
HTTPStatus.BAD_REQUEST,
if invoice.amount_msat != data.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
)
# if invoice.description_hash != data.description_hash:
# raise HTTPException(
# status_code=HTTPStatus.BAD_REQUEST,
# detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
# )
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if g.data["comment"]:
extra["comment"] = g.data["comment"]
if data.comment:
extra["comment"] = data.comment
payment_hash = await pay_invoice(
wallet_id=g.wallet.id,
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=g.data.get("description", ""),
description=data.description,
extra=extra,
)
return (
jsonify(
{
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
),
HTTPStatus.CREATED,
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
async def subscribe(request: Request, wallet: Wallet):
this_wallet_id = wallet.wallet.id
payment_queue = asyncio.Queue(0)
print("adding sse listener", payment_queue)
api_invoice_listeners.append(payment_queue)
send_queue = asyncio.Queue(0)
async def payment_received() -> None:
while True:
payment: Payment = await payment_queue.get()
if payment.wallet_id == this_wallet_id:
await send_queue.put(("payment-received", payment))
asyncio.create_task(payment_received())
try:
while True:
typ, data = await send_queue.get()
if data:
jdata = json.dumps(dict(data.dict(), pending=False))
# yield dict(id=1, event="this", data="1234")
# await asyncio.sleep(2)
yield dict(data=jdata, event=typ)
# yield dict(data=jdata.encode("utf-8"), event=typ.encode("utf-8"))
except asyncio.CancelledError:
return
@core_app.get("/api/v1/payments/sse")
async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
):
return EventSourceResponse(
subscribe(request, wallet), ping=20, media_type="text/event-stream"
)
@core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_payment(payment_hash):
payment = await g.wallet.get_payment(payment_hash)
@core_app.get("/api/v1/payments/{payment_hash}")
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
wallet = None
try:
if X_Api_Key.extra:
print("No key")
except:
wallet = await get_wallet_for_key(X_Api_Key)
payment = await get_standalone_payment(payment_hash)
await check_invoice_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(payment_hash)
if not payment:
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
return jsonify({"paid": True, "preimage": payment.preimage}), HTTPStatus.OK
if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage}
try:
await payment.check_pending()
except Exception:
return jsonify({"paid": False}), HTTPStatus.OK
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment}
return {"paid": False}
return (
jsonify({"paid": not payment.pending, "preimage": payment.preimage}),
HTTPStatus.OK,
)
if wallet and wallet.id == payment.wallet_id:
return {"paid": not payment.pending, "preimage": payment.preimage, "details": payment}
return {"paid": not payment.pending, "preimage": payment.preimage}
@core_app.route("/api/v1/payments/sse", methods=["GET"])
@api_check_wallet_key("invoice", accept_querystring=True)
async def api_payments_sse():
this_wallet_id = g.wallet.id
send_payment, receive_payment = trio.open_memory_channel(0)
print("adding sse listener", send_payment)
api_invoice_listeners.append(send_payment)
send_event, event_to_send = trio.open_memory_channel(0)
async def payment_received() -> None:
async for payment in receive_payment:
if payment.wallet_id == this_wallet_id:
await send_event.send(("payment-received", payment))
async def repeat_keepalive():
await trio.sleep(1)
while True:
await send_event.send(("keepalive", ""))
await trio.sleep(25)
current_app.nursery.start_soon(payment_received)
current_app.nursery.start_soon(repeat_keepalive)
async def send_events():
try:
async for typ, data in event_to_send:
message = [f"event: {typ}".encode("utf-8")]
if data:
jdata = json.dumps(dict(data._asdict(), pending=False))
message.append(f"data: {jdata}".encode("utf-8"))
yield b"\n".join(message) + b"\r\n\r\n"
except trio.Cancelled:
return
response = await make_response(
send_events(),
{
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
"Transfer-Encoding": "chunked",
},
)
response.timeout = None
return response
@core_app.route("/api/v1/lnurlscan/<code>", methods=["GET"])
@api_check_wallet_key("invoice")
@core_app.get(
"/api/v1/lnurlscan/{code}", dependencies=[Depends(WalletInvoiceKeyChecker())]
)
async def api_lnurlscan(code: str):
try:
url = lnurl.Lnurl(code)
except ValueError:
return jsonify({"message": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
domain = urlparse(url.url).netloc
url = lnurl.decode(code)
domain = urlparse(url).netloc
except:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) == 2:
name, domain = name_domain
url = (
("http://" if domain.endswith(".onion") else "https://")
+ domain
+ "/.well-known/lnurlp/"
+ name
)
# will proceed with these values
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
)
# params is what will be returned to the client
params: Dict = {"domain": domain}
if url.is_login:
if "tag=login" in url:
params.update(kind="auth")
params.update(callback=url.url) # with k1 already in it
params.update(callback=url) # with k1 already in it
lnurlauth_key = g.wallet.lnurlauth_key(domain)
lnurlauth_key = g().wallet.lnurlauth_key(domain)
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
async with httpx.AsyncClient() as client:
r = await client.get(url.url, timeout=40)
r = await client.get(url, timeout=5)
if r.is_error:
return (
jsonify({"domain": domain, "message": "failed to get parameters"}),
HTTPStatus.SERVICE_UNAVAILABLE,
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={"domain": domain, "message": "failed to get parameters"},
)
try:
jdata = json.loads(r.text)
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
return (
jsonify(
{
data = json.loads(r.text)
except json.decoder.JSONDecodeError:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
},
)
try:
tag = data["tag"]
if tag == "channelRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
}
),
HTTPStatus.SERVICE_UNAVAILABLE,
"kind": "channel",
"message": "unsupported",
},
)
params.update(**data)
if tag == "withdrawRequest":
params.update(kind="withdraw")
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: Dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
if "balanceCheck" in data:
params.update(balanceCheck=data["balanceCheck"])
# format callback url and send to client
parsed_callback = parsed_callback._replace(
query=urlencode(qs, doseq=True)
)
params.update(callback=urlunparse(parsed_callback))
if tag == "payRequest":
params.update(kind="pay")
params.update(fixed=data["minSendable"] == data["maxSendable"])
params.update(
description_hash=hashlib.sha256(
data["metadata"].encode("utf-8")
).hexdigest()
)
metadata = json.loads(data["metadata"])
for [k, v] in metadata:
if k == "text/plain":
params.update(description=v)
if k == "image/jpeg;base64" or k == "image/png;base64":
data_uri = "data:" + k + "," + v
params.update(image=data_uri)
if k == "text/email" or k == "text/identifier":
params.update(targetUser=v)
params.update(commentAllowed=data.get("commentAllowed", 0))
except KeyError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"lnurl JSON response invalid: {exc}",
},
)
if type(data) is lnurl.LnurlChannelResponse:
return (
jsonify(
{"domain": domain, "kind": "channel", "message": "unsupported"}
),
HTTPStatus.BAD_REQUEST,
)
params.update(**data.dict())
if type(data) is lnurl.LnurlWithdrawResponse:
params.update(kind="withdraw")
params.update(fixed=data.min_withdrawable == data.max_withdrawable)
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data.callback)
qs: Dict = parse_qs(parsed_callback.query)
qs["k1"] = data.k1
# balanceCheck/balanceNotify
if "balanceCheck" in jdata:
params.update(balanceCheck=jdata["balanceCheck"])
# format callback url and send to client
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
params.update(callback=urlunparse(parsed_callback))
if type(data) is lnurl.LnurlPayResponse:
params.update(kind="pay")
params.update(fixed=data.min_sendable == data.max_sendable)
params.update(description_hash=data.metadata.h)
params.update(description=data.metadata.text)
if data.metadata.images:
image = min(data.metadata.images, key=lambda image: len(image[1]))
data_uri = "data:" + image[0] + "," + image[1]
params.update(image=data_uri)
params.update(commentAllowed=jdata.get("commentAllowed", 0))
return jsonify(params)
return params
@core_app.route("/api/v1/lnurlauth", methods=["POST"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"callback": {"type": "string", "required": True},
}
)
async def api_perform_lnurlauth():
err = await perform_lnurlauth(g.data["callback"])
class DecodePayment(BaseModel):
data: str
@core_app.post("/api/v1/payments/decode")
async def api_payments_decode(data: DecodePayment):
payment_str = data.data
try:
if payment_str[:5] == "LNURL":
url = lnurl.decode(payment_str)
return {"domain": url}
else:
invoice = bolt11.decode(payment_str)
return {
"payment_hash": invoice.payment_hash,
"amount_msat": invoice.amount_msat,
"description": invoice.description,
"description_hash": invoice.description_hash,
"payee": invoice.payee,
"date": invoice.date,
"expiry": invoice.expiry,
"secret": invoice.secret,
"route_hints": invoice.route_hints,
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
}
except:
return {"message": "Failed to decode"}
@core_app.post("/api/v1/lnurlauth", dependencies=[Depends(WalletAdminKeyChecker())])
async def api_perform_lnurlauth(callback: str):
err = await perform_lnurlauth(callback)
if err:
return jsonify({"reason": err.reason}), HTTPStatus.SERVICE_UNAVAILABLE
return "", HTTPStatus.OK
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
)
return ""
@core_app.route("/api/v1/currencies", methods=["GET"])
@core_app.get("/api/v1/currencies")
async def api_list_currencies_available():
return jsonify(list(currencies.keys()))
return list(currencies.keys())
class ConversionData(BaseModel):
from_: str = Field("sat", alias="from")
amount: float
to: str = Query("usd")
@core_app.post("/api/v1/conversion")
async def api_fiat_as_sats(data: ConversionData):
output = {}
if data.from_ == "sat":
output["sats"] = int(data.amount)
output["BTC"] = data.amount / 100000000
for currency in data.to.split(","):
output[currency.strip().upper()] = await satoshis_amount_as_fiat(
data.amount, currency.strip()
)
return output
else:
output[data.from_.upper()] = data.amount
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
output["BTC"] = output["sats"] / 100000000
return output

View file

@ -1,156 +1,185 @@
from os import path
import asyncio
from http import HTTPStatus
from quart import (
g,
current_app,
abort,
jsonify,
request,
redirect,
render_template,
send_from_directory,
url_for,
)
from typing import Optional
from lnbits.core import core_app, db
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE, LNBITS_SITE_TITLE
from fastapi import Request, status
from fastapi.exceptions import HTTPException
from fastapi.params import Depends, Query
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.routing import APIRouter
from pydantic.types import UUID4
from starlette.responses import HTMLResponse, JSONResponse
from lnbits.core import db
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer, url_for
from lnbits.settings import (
LNBITS_ADMIN_USERS,
LNBITS_ALLOWED_USERS,
LNBITS_SITE_TITLE,
SERVICE_FEE,
)
from ..crud import (
create_account,
get_user,
update_user_extension,
create_wallet,
delete_wallet,
get_balance_check,
get_user,
save_balance_notify,
update_user_extension,
)
from ..services import redeem_lnurl_withdraw, pay_invoice
from ..services import pay_invoice, redeem_lnurl_withdraw
core_html_routes: APIRouter = APIRouter(tags=["Core NON-API Website Routes"])
@core_app.route("/favicon.ico")
@core_html_routes.get("/favicon.ico", response_class=FileResponse)
async def favicon():
return await send_from_directory(
path.join(core_app.root_path, "static"), "favicon.ico"
return FileResponse("lnbits/core/static/favicon.ico")
@core_html_routes.get("/", response_class=HTMLResponse)
async def home(request: Request, lightning: str = None):
return template_renderer().TemplateResponse(
"core/index.html", {"request": request, "lnurl": lightning}
)
@core_app.route("/")
async def home():
return await render_template(
"core/index.html", lnurl=request.args.get("lightning", None)
)
@core_app.route("/extensions")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def extensions():
extension_to_enable = request.args.get("enable", type=str)
extension_to_disable = request.args.get("disable", type=str)
@core_html_routes.get(
"/extensions", name="core.extensions", response_class=HTMLResponse
)
async def extensions(
request: Request,
user: User = Depends(check_user_exists),
enable: str = Query(None),
disable: str = Query(None),
):
extension_to_enable = enable
extension_to_disable = disable
if extension_to_enable and extension_to_disable:
abort(
raise HTTPException(
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
)
if extension_to_enable:
await update_user_extension(
user_id=g.user.id, extension=extension_to_enable, active=1
user_id=user.id, extension=extension_to_enable, active=True
)
elif extension_to_disable:
await update_user_extension(
user_id=g.user.id, extension=extension_to_disable, active=0
user_id=user.id, extension=extension_to_disable, active=False
)
return await render_template("core/extensions.html", user=await get_user(g.user.id))
# Update user as his extensions have been updated
if extension_to_enable or extension_to_disable:
user = await get_user(user.id)
return template_renderer().TemplateResponse(
"core/extensions.html", {"request": request, "user": user.dict()}
)
@core_app.route("/wallet")
@validate_uuids(["usr", "wal"])
async def wallet():
user_id = request.args.get("usr", type=str)
wallet_id = request.args.get("wal", type=str)
wallet_name = request.args.get("nme", type=str)
@core_html_routes.get(
"/wallet",
response_class=HTMLResponse,
description="""
Args:
just **wallet_name**: create a new user, then create a new wallet for user with wallet_name<br>
just **user_id**: return the first user wallet or create one if none found (with default wallet_name)<br>
**user_id** and **wallet_name**: create a new wallet for user with wallet_name<br>
**user_id** and **wallet_id**: return that wallet if user is the owner<br>
nothing: create everything<br>
""",
)
async def wallet(
request: Request = Query(None),
nme: Optional[str] = Query(None),
usr: Optional[UUID4] = Query(None),
wal: Optional[UUID4] = Query(None),
):
user_id = usr.hex if usr else None
wallet_id = wal.hex if wal else None
wallet_name = nme
service_fee = int(SERVICE_FEE) if int(SERVICE_FEE) == SERVICE_FEE else SERVICE_FEE
# just wallet_name: create a new user, then create a new wallet for user with wallet_name
# just user_id: return the first user wallet or create one if none found (with default wallet_name)
# user_id and wallet_name: create a new wallet for user with wallet_name
# user_id and wallet_id: return that wallet if user is the owner
# nothing: create everything
if not user_id:
user = await get_user((await create_account()).id)
else:
user = await get_user(user_id)
if not user:
abort(HTTPStatus.NOT_FOUND, "User does not exist.")
return
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User does not exist."}
)
if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS:
abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User not authorized."}
)
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
user.admin = True
if not wallet_id:
if user.wallets and not wallet_name:
wallet = user.wallets[0]
else:
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
return RedirectResponse(
f"/wallet?usr={user.id}&wal={wallet.id}",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
wallet = user.get_wallet(wallet_id)
if not wallet:
abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "Wallet not found"}
)
return await render_template(
"core/wallet.html", user=user, wallet=wallet, service_fee=service_fee
)
@core_app.route("/withdraw")
@validate_uuids(["usr", "wal"], required=True)
async def lnurl_full_withdraw():
user = await get_user(request.args.get("usr"))
if not user:
return jsonify({"status": "ERROR", "reason": "User does not exist."})
wallet = user.get_wallet(request.args.get("wal"))
if not wallet:
return jsonify({"status": "ERROR", "reason": "Wallet does not exist."})
return jsonify(
return template_renderer().TemplateResponse(
"core/wallet.html",
{
"tag": "withdrawRequest",
"callback": url_for(
"core.lnurl_full_withdraw_callback",
usr=user.id,
wal=wallet.id,
_external=True,
),
"k1": "0",
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
"maxWithdrawable": wallet.withdrawable_balance,
"defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
"balanceCheck": url_for(
"core.lnurl_full_withdraw", usr=user.id, wal=wallet.id, _external=True
),
}
"request": request,
"user": user.dict(),
"wallet": wallet.dict(),
"service_fee": service_fee,
},
)
@core_app.route("/withdraw/cb")
@validate_uuids(["usr", "wal"], required=True)
async def lnurl_full_withdraw_callback():
user = await get_user(request.args.get("usr"))
@core_html_routes.get("/withdraw", response_class=JSONResponse)
async def lnurl_full_withdraw(request: Request):
user = await get_user(request.query_params.get("usr"))
if not user:
return jsonify({"status": "ERROR", "reason": "User does not exist."})
return {"status": "ERROR", "reason": "User does not exist."}
wallet = user.get_wallet(request.args.get("wal"))
wallet = user.get_wallet(request.query_params.get("wal"))
if not wallet:
return jsonify({"status": "ERROR", "reason": "Wallet does not exist."})
return {"status": "ERROR", "reason": "Wallet does not exist."}
pr = request.args.get("pr")
return {
"tag": "withdrawRequest",
"callback": url_for("/withdraw/cb", external=True, usr=user.id, wal=wallet.id),
"k1": "0",
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
"maxWithdrawable": wallet.withdrawable_balance,
"defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
"balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id),
}
@core_html_routes.get("/withdraw/cb", response_class=JSONResponse)
async def lnurl_full_withdraw_callback(request: Request):
user = await get_user(request.query_params.get("usr"))
if not user:
return {"status": "ERROR", "reason": "User does not exist."}
wallet = user.get_wallet(request.query_params.get("wal"))
if not wallet:
return {"status": "ERROR", "reason": "Wallet does not exist."}
pr = request.query_params.get("pr")
async def pay():
try:
@ -158,92 +187,97 @@ async def lnurl_full_withdraw_callback():
except:
pass
current_app.nursery.start_soon(pay)
asyncio.create_task(pay())
balance_notify = request.args.get("balanceNotify")
balance_notify = request.query_params.get("balanceNotify")
if balance_notify:
await save_balance_notify(wallet.id, balance_notify)
return jsonify({"status": "OK"})
return {"status": "OK"}
@core_app.route("/deletewallet")
@validate_uuids(["usr", "wal"], required=True)
@check_user_exists()
async def deletewallet():
wallet_id = request.args.get("wal", type=str)
user_wallet_ids = g.user.wallet_ids
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)):
user = await get_user(usr)
user_wallet_ids = [u.id for u in user.wallets]
print("USR", user_wallet_ids)
if wallet_id not in user_wallet_ids:
abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
if wal not in user_wallet_ids:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
else:
await delete_wallet(user_id=g.user.id, wallet_id=wallet_id)
user_wallet_ids.remove(wallet_id)
await delete_wallet(user_id=user.id, wallet_id=wal)
user_wallet_ids.remove(wal)
if user_wallet_ids:
return redirect(url_for("core.wallet", usr=g.user.id, wal=user_wallet_ids[0]))
return RedirectResponse(
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
return redirect(url_for("core.home"))
return RedirectResponse(
url_for("/"), status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
@core_app.route("/withdraw/notify/<service>")
@validate_uuids(["wal"], required=True)
async def lnurl_balance_notify(service: str):
bc = await get_balance_check(request.args.get("wal"), service)
@core_html_routes.get("/withdraw/notify/{service}")
async def lnurl_balance_notify(request: Request, service: str):
bc = await get_balance_check(request.query_params.get("wal"), service)
if bc:
redeem_lnurl_withdraw(bc.wallet, bc.url)
@core_app.route("/lnurlwallet")
async def lnurlwallet():
@core_html_routes.get("/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet")
async def lnurlwallet(request: Request):
async with db.connect() as conn:
account = await create_account(conn=conn)
user = await get_user(account.id, conn=conn)
wallet = await create_wallet(user_id=user.id, conn=conn)
current_app.nursery.start_soon(
redeem_lnurl_withdraw,
wallet.id,
request.args.get("lightning"),
"LNbits initial funding: voucher redeem.",
{"tag": "lnurlwallet"},
5, # wait 5 seconds before sending the invoice to the service
asyncio.create_task(
redeem_lnurl_withdraw(
wallet.id,
request.query_params.get("lightning"),
"LNbits initial funding: voucher redeem.",
{"tag": "lnurlwallet"},
5, # wait 5 seconds before sending the invoice to the service
)
)
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
return RedirectResponse(
f"/wallet?usr={user.id}&wal={wallet.id}",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
@core_app.route("/manifest/<usr>.webmanifest")
@core_html_routes.get("/manifest/{usr}.webmanifest")
async def manifest(usr: str):
user = await get_user(usr)
if not user:
return "", HTTPStatus.NOT_FOUND
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
return jsonify(
{
"short_name": "LNbits",
"name": "LNbits Wallet",
"icons": [
{
"src": "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
"type": "image/png",
"sizes": "900x900",
}
],
"start_url": "/wallet?usr=" + usr,
"background_color": "#3367D6",
"description": "Weather forecast information",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": wallet.name,
"short_name": wallet.name,
"description": wallet.name,
"url": "/wallet?usr=" + usr + "&wal=" + wallet.id,
}
for wallet in user.wallets
],
}
)
return {
"short_name": "LNbits",
"name": "LNbits Wallet",
"icons": [
{
"src": "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
"type": "image/png",
"sizes": "900x900",
}
],
"start_url": "/wallet?usr=" + usr,
"background_color": "#3367D6",
"description": "Weather forecast information",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": wallet.name,
"short_name": wallet.name,
"description": wallet.name,
"url": "/wallet?usr=" + usr + "&wal=" + wallet.id,
}
for wallet in user.wallets
],
}

View file

@ -1,7 +1,11 @@
import trio
import asyncio
import datetime
from http import HTTPStatus
from quart import jsonify
from urllib.parse import urlparse
from fastapi import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits import bolt11
@ -10,28 +14,57 @@ from ..crud import get_standalone_payment
from ..tasks import api_invoice_listeners
@core_app.route("/public/v1/payment/<payment_hash>", methods=["GET"])
@core_app.get("/.well-known/lnurlp/{username}")
async def lnaddress(username: str, request: Request):
from lnbits.extensions.lnaddress.lnurl import lnurl_response
domain = urlparse(str(request.url)).netloc
return await lnurl_response(username, domain, request)
@core_app.get("/public/v1/payment/{payment_hash}")
async def api_public_payment_longpolling(payment_hash):
payment = await get_standalone_payment(payment_hash)
if not payment:
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
return jsonify({"status": "paid"}), HTTPStatus.OK
return {"status": "paid"}
try:
invoice = bolt11.decode(payment.bolt11)
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration < datetime.datetime.now():
return jsonify({"status": "expired"}), HTTPStatus.OK
return {"status": "expired"}
except:
return jsonify({"message": "Invalid bolt11 invoice."}), HTTPStatus.BAD_REQUEST
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
)
send_payment, receive_payment = trio.open_memory_channel(0)
payment_queue = asyncio.Queue(0)
print("adding standalone invoice listener", payment_hash, send_payment)
api_invoice_listeners.append(send_payment)
print("adding standalone invoice listener", payment_hash, payment_queue)
api_invoice_listeners.append(payment_queue)
async for payment in receive_payment:
if payment.payment_hash == payment_hash:
return jsonify({"status": "paid"}), HTTPStatus.OK
response = None
async def payment_info_receiver(cancel_scope):
async for payment in payment_queue.get():
if payment.payment_hash == payment_hash:
nonlocal response
response = {"status": "paid"}
cancel_scope.cancel()
async def timeouter(cancel_scope):
await asyncio.sleep(45)
cancel_scope.cancel()
asyncio.create_task(payment_info_receiver())
asyncio.create_task(timeouter())
if response:
return response
else:
raise HTTPException(status_code=HTTPStatus.REQUEST_TIMEOUT, detail="timeout")

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,45 +1,173 @@
import asyncio
import datetime
import os
import trio
import time
from contextlib import asynccontextmanager
from sqlalchemy import create_engine # type: ignore
from sqlalchemy_aio import TRIO_STRATEGY # type: ignore
from sqlalchemy_aio.base import AsyncConnection # type: ignore
from typing import Optional
from .settings import LNBITS_DATA_FOLDER
from sqlalchemy import create_engine
from sqlalchemy_aio.base import AsyncConnection
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore
from .settings import LNBITS_DATA_FOLDER, LNBITS_DATABASE_URL
POSTGRES = "POSTGRES"
COCKROACH = "COCKROACH"
SQLITE = "SQLITE"
class Connection:
def __init__(self, conn: AsyncConnection):
class Compat:
type: Optional[str] = "<inherited>"
schema: Optional[str] = "<inherited>"
def interval_seconds(self, seconds: int) -> str:
if self.type in {POSTGRES, COCKROACH}:
return f"interval '{seconds} seconds'"
elif self.type == SQLITE:
return f"{seconds}"
return "<nothing>"
@property
def timestamp_now(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
return "now()"
elif self.type == SQLITE:
return "(strftime('%s', 'now'))"
return "<nothing>"
@property
def serial_primary_key(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
return "SERIAL PRIMARY KEY"
elif self.type == SQLITE:
return "INTEGER PRIMARY KEY AUTOINCREMENT"
return "<nothing>"
@property
def references_schema(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
return f"{self.schema}."
elif self.type == SQLITE:
return ""
return "<nothing>"
class Connection(Compat):
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
self.conn = conn
self.txn = txn
self.type = typ
self.name = name
self.schema = schema
def rewrite_query(self, query) -> str:
if self.type in {POSTGRES, COCKROACH}:
query = query.replace("%", "%%")
query = query.replace("?", "%s")
return query
async def fetchall(self, query: str, values: tuple = ()) -> list:
result = await self.conn.execute(query, values)
result = await self.conn.execute(self.rewrite_query(query), values)
return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()):
result = await self.conn.execute(query, values)
result = await self.conn.execute(self.rewrite_query(query), values)
row = await result.fetchone()
await result.close()
return row
async def execute(self, query: str, values: tuple = ()):
return await self.conn.execute(query, values)
return await self.conn.execute(self.rewrite_query(query), values)
class Database:
class Database(Compat):
def __init__(self, db_name: str):
self.db_name = db_name
db_path = os.path.join(LNBITS_DATA_FOLDER, f"{db_name}.sqlite3")
self.engine = create_engine(f"sqlite:///{db_path}", strategy=TRIO_STRATEGY)
self.lock = trio.StrictFIFOLock()
self.name = db_name
if LNBITS_DATABASE_URL:
database_uri = LNBITS_DATABASE_URL
if database_uri.startswith("cockroachdb://"):
self.type = COCKROACH
else:
self.type = POSTGRES
import psycopg2 # type: ignore
def _parse_timestamp(value, _):
f = "%Y-%m-%d %H:%M:%S.%f"
if not "." in value:
f = "%Y-%m-%d %H:%M:%S"
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
psycopg2.extensions.register_type(
psycopg2.extensions.new_type(
psycopg2.extensions.DECIMAL.values,
"DEC2FLOAT",
lambda value, curs: float(value) if value is not None else None,
)
)
psycopg2.extensions.register_type(
psycopg2.extensions.new_type(
(1082, 1083, 1266),
"DATE2INT",
lambda value, curs: time.mktime(value.timetuple())
if value is not None
else None,
)
)
psycopg2.extensions.register_type(
psycopg2.extensions.new_type(
(1184, 1114),
"TIMESTAMP2INT",
_parse_timestamp
# lambda value, curs: time.mktime(
# datetime.datetime.strptime(
# value, "%Y-%m-%d %H:%M:%S.%f"
# ).timetuple()
# ),
)
)
else:
if os.path.isdir(LNBITS_DATA_FOLDER):
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
database_uri = f"sqlite:///{self.path}"
self.type = SQLITE
else:
raise NotADirectoryError(
f"LNBITS_DATA_FOLDER named {LNBITS_DATA_FOLDER} was not created"
f" - please 'mkdir {LNBITS_DATA_FOLDER}' and try again"
)
self.schema = self.name
if self.name.startswith("ext_"):
self.schema = self.name[4:]
else:
self.schema = None
self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY)
self.lock = asyncio.Lock()
@asynccontextmanager
async def connect(self):
await self.lock.acquire()
try:
async with self.engine.connect() as conn:
async with conn.begin():
yield Connection(conn)
async with conn.begin() as txn:
wconn = Connection(conn, txn, self.type, self.name, self.schema)
if self.schema:
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(
f"ATTACH '{self.path}' AS {self.schema}"
)
yield wconn
finally:
self.lock.release()

View file

@ -1,104 +1,217 @@
from cerberus import Validator # type: ignore
from quart import g, abort, jsonify, request
from functools import wraps
from http import HTTPStatus
from typing import List, Union
from uuid import UUID
from cerberus import Validator # type: ignore
from fastapi import status
from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn
from fastapi.params import Security
from fastapi.security.api_key import APIKeyHeader, APIKeyQuery
from fastapi.security.base import SecurityBase
from pydantic.types import UUID4
from starlette.requests import Request
from lnbits.core.crud import get_user, get_wallet_for_key
from lnbits.settings import LNBITS_ALLOWED_USERS
from lnbits.core.models import User, Wallet
from lnbits.requestvars import g
from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS
def api_check_wallet_key(key_type: str = "invoice", accept_querystring=False):
def wrap(view):
@wraps(view)
async def wrapped_view(**kwargs):
try:
key_value = request.headers.get("X-Api-Key") or request.args["api-key"]
g.wallet = await get_wallet_for_key(key_value, key_type)
except KeyError:
return (
jsonify({"message": "`X-Api-Key` header missing."}),
HTTPStatus.BAD_REQUEST,
class KeyChecker(SecurityBase):
def __init__(
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
):
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
self._key_type = "invoice"
self._api_key = api_key
if api_key:
self.model: APIKey = APIKey(
**{"in": APIKeyIn.query},
name="X-API-KEY",
description="Wallet API Key - QUERY",
)
else:
self.model: APIKey = APIKey(
**{"in": APIKeyIn.header},
name="X-API-KEY",
description="Wallet API Key - HEADER",
)
self.wallet = None
async def __call__(self, request: Request) -> Wallet:
try:
key_value = (
self._api_key
if self._api_key
else request.headers.get("X-API-KEY") or request.query_params["api-key"]
)
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
# Also, we should not return the wallet here - thats silly.
# Possibly store it in a Redis DB
self.wallet = await get_wallet_for_key(key_value, self._key_type)
if not self.wallet:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invalid key or expired key.",
)
if not g.wallet:
return jsonify({"message": "Wrong keys."}), HTTPStatus.UNAUTHORIZED
return await view(**kwargs)
return wrapped_view
return wrap
def api_validate_post_request(*, schema: dict):
def wrap(view):
@wraps(view)
async def wrapped_view(**kwargs):
if "application/json" not in request.headers["Content-Type"]:
return (
jsonify({"message": "Content-Type must be `application/json`."}),
HTTPStatus.BAD_REQUEST,
)
v = Validator(schema)
data = await request.get_json()
g.data = {key: data[key] for key in schema.keys() if key in data}
if not v.validate(g.data):
return (
jsonify({"message": f"Errors in request data: {v.errors}"}),
HTTPStatus.BAD_REQUEST,
)
return await view(**kwargs)
return wrapped_view
return wrap
def check_user_exists(param: str = "usr"):
def wrap(view):
@wraps(view)
async def wrapped_view(**kwargs):
g.user = await get_user(request.args.get(param, type=str)) or abort(
HTTPStatus.NOT_FOUND, "User does not exist."
except KeyError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
)
if LNBITS_ALLOWED_USERS and g.user.id not in LNBITS_ALLOWED_USERS:
abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
return await view(**kwargs)
class WalletInvoiceKeyChecker(KeyChecker):
"""
WalletInvoiceKeyChecker will ensure that the provided invoice
wallet key is correct and populate g().wallet with the wallet
for the key in `X-API-key`.
return wrapped_view
The checker will raise an HTTPException when the key is wrong in some ways.
"""
return wrap
def __init__(
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
):
super().__init__(scheme_name, auto_error, api_key)
self._key_type = "invoice"
def validate_uuids(
params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4
class WalletAdminKeyChecker(KeyChecker):
"""
WalletAdminKeyChecker will ensure that the provided admin
wallet key is correct and populate g().wallet with the wallet
for the key in `X-API-key`.
The checker will raise an HTTPException when the key is wrong in some ways.
"""
def __init__(
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
):
super().__init__(scheme_name, auto_error, api_key)
self._key_type = "admin"
class WalletTypeInfo:
wallet_type: int
wallet: Wallet
def __init__(self, wallet_type: int, wallet: Wallet) -> None:
self.wallet_type = wallet_type
self.wallet = wallet
api_key_header = APIKeyHeader(
name="X-API-KEY",
auto_error=False,
description="Admin or Invoice key for wallet API's",
)
api_key_query = APIKeyQuery(
name="api-key",
auto_error=False,
description="Admin or Invoice key for wallet API's",
)
async def get_key_type(
r: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
) -> WalletTypeInfo:
# 0: admin
# 1: invoice
# 2: invalid
pathname = r['path'].split('/')[1]
if not api_key_header and not api_key_query:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
token = api_key_header if api_key_header else api_key_query
try:
checker = WalletAdminKeyChecker(api_key=token)
await checker.__call__(r)
wallet = WalletTypeInfo(0, checker.wallet)
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
return wallet
except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST:
raise
if e.status_code == HTTPStatus.UNAUTHORIZED:
pass
except:
raise
try:
checker = WalletInvoiceKeyChecker(api_key=token)
await checker.__call__(r)
wallet = WalletTypeInfo(1, checker.wallet)
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
return wallet
except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST:
raise
if e.status_code == HTTPStatus.UNAUTHORIZED:
return WalletTypeInfo(2, None)
except:
raise
async def require_admin_key(
r: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
):
def wrap(view):
@wraps(view)
async def wrapped_view(**kwargs):
query_params = {
param: request.args.get(param, type=str) for param in params
}
token = api_key_header if api_key_header else api_key_query
for param, value in query_params.items():
if not value and (required is True or (required and param in required)):
abort(HTTPStatus.BAD_REQUEST, f"`{param}` is required.")
wallet = await get_key_type(r, token)
if value:
try:
UUID(value, version=version)
except ValueError:
abort(HTTPStatus.BAD_REQUEST, f"`{param}` is not a valid UUID.")
if wallet.wallet_type != 0:
# If wallet type is not admin then return the unauthorized status
# This also covers when the user passes an invalid key type
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin key required."
)
else:
return wallet
return await view(**kwargs)
return wrapped_view
async def require_invoice_key(
r: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
):
token = api_key_header if api_key_header else api_key_query
return wrap
wallet = await get_key_type(r, token)
if wallet.wallet_type > 1:
# If wallet type is not invoice then return the unauthorized status
# This also covers when the user passes an invalid key type
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
else:
return wallet
async def check_user_exists(usr: UUID4) -> User:
g().user = await get_user(usr.hex)
if not g().user:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
)
if LNBITS_ALLOWED_USERS and g().user.id not in LNBITS_ALLOWED_USERS:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
if LNBITS_ADMIN_USERS and g().user.id in LNBITS_ADMIN_USERS:
g().user.admin = True
return g().user

View file

@ -1,11 +0,0 @@
<h1>Example Extension</h1>
<h2>*tagline*</h2>
This is an example extension to help you organise and build you own.
Try to include an image
<img src="https://i.imgur.com/9i4xcQB.png">
<h2>If your extension has API endpoints, include useful ones here</h2>
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>

View file

@ -1,12 +0,0 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_amilk")
amilk_ext: Blueprint = Blueprint(
"amilk", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa

View file

@ -1,6 +0,0 @@
{
"name": "AMilk",
"short_description": "Assistant Faucet Milker",
"icon": "room_service",
"contributors": ["arcbtc"]
}

View file

@ -1,42 +0,0 @@
from base64 import urlsafe_b64encode
from uuid import uuid4
from typing import List, Optional, Union
from . import db
from .models import AMilk
async def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk:
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
await db.execute(
"""
INSERT INTO amilks (id, wallet, lnurl, atime, amount)
VALUES (?, ?, ?, ?, ?)
""",
(amilk_id, wallet_id, lnurl, atime, amount),
)
amilk = await get_amilk(amilk_id)
assert amilk, "Newly created amilk_id couldn't be retrieved"
return amilk
async def get_amilk(amilk_id: str) -> Optional[AMilk]:
row = await db.fetchone("SELECT * FROM amilks WHERE id = ?", (amilk_id,))
return AMilk(**row) if row else None
async def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM amilks WHERE wallet IN ({q})", (*wallet_ids,)
)
return [AMilk(**row) for row in rows]
async def delete_amilk(amilk_id: str) -> None:
await db.execute("DELETE FROM amilks WHERE id = ?", (amilk_id,))

View file

@ -1,15 +0,0 @@
async def m001_initial(db):
"""
Initial amilks table.
"""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS amilks (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
lnurl TEXT NOT NULL,
atime INTEGER NOT NULL,
amount INTEGER NOT NULL
);
"""
)

View file

@ -1,9 +0,0 @@
from typing import NamedTuple
class AMilk(NamedTuple):
id: str
wallet: str
lnurl: str
atime: int
amount: int

View file

@ -1,24 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Info"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">Assistant Faucet Milker</h5>
<p>
Milking faucets with software, known as "assmilking", seems at first to
be black-hat, although in fact there might be some unexplored use cases.
An LNURL withdraw gives someone the right to pull funds, which can be
done over time. An LNURL withdraw could be used outside of just faucets,
to provide money streaming and repeat payments.<br />Paste or scan an
LNURL withdraw, enter the amount for the AMilk to pull and the frequency
for it to be pulled.<br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
>
</p>
</q-card-section>
</q-card>
</q-expansion-item>

View file

@ -1,250 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="deep-purple" @click="amilkDialog.show = true"
>New AMilk</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">AMilks</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="amilks"
row-key="id"
:columns="amilksTable.columns"
:pagination.sync="amilksTable.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="deleteAMilk(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">
LNbits Assistant Faucet Milker Extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "amilk/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="amilkDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createAMilk" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="amilkDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="amilkDialog.data.lnurl"
type="url"
label="LNURL Withdraw"
></q-input>
<q-input
filled
dense
v-model.number="amilkDialog.data.amount"
type="number"
label="Amount *"
></q-input>
<q-input
filled
dense
v-model.trim="amilkDialog.data.atime"
type="number"
label="Hit frequency (secs)"
placeholder="Frequency to be hit"
></q-input>
<q-btn
unelevated
color="deep-purple"
:disable="amilkDialog.data.amount == null || amilkDialog.data.amount < 0 || amilkDialog.data.lnurl == null"
type="submit"
>Create amilk</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapAMilk = 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.wall = ['/amilk/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
amilks: [],
amilksTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'lnurl', align: 'left', label: 'LNURL', field: 'lnurl'},
{name: 'atime', align: 'left', label: 'Freq', field: 'atime'},
{name: 'amount', align: 'left', label: 'Amount', field: 'amount'}
],
pagination: {
rowsPerPage: 10
}
},
amilkDialog: {
show: false,
data: {}
}
}
},
methods: {
getAMilks: function () {
var self = this
LNbits.api
.request(
'GET',
'/amilk/api/v1/amilk?all_wallets',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.amilks = response.data.map(function (obj) {
response.data.forEach(MILK)
function MILK(item) {
window.setInterval(function () {
LNbits.api
.request(
'GET',
'/amilk/api/v1/amilk/milk/' + item.id,
'Lorem'
)
.then(function (response) {
self.amilks = response.data.map(function (obj) {
return mapAMilk(obj)
})
})
}, item.atime * 1000)
}
return mapAMilk(obj)
})
})
},
createAMilk: function () {
var data = {
lnurl: this.amilkDialog.data.lnurl,
atime: parseInt(this.amilkDialog.data.atime),
amount: this.amilkDialog.data.amount
}
var self = this
console.log(this.amilkDialog.data.wallet)
LNbits.api
.request(
'POST',
'/amilk/api/v1/amilk',
_.findWhere(this.g.user.wallets, {id: this.amilkDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.amilks.push(mapAMilk(response.data))
self.amilkDialog.show = false
self.amilkDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteAMilk: function (amilkId) {
var self = this
var amilk = _.findWhere(this.amilks, {id: amilkId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this AMilk link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/amilk/api/v1/amilks/' + amilkId,
_.findWhere(self.g.user.wallets, {id: amilk.wallet}).inkey
)
.then(function (response) {
self.amilks = _.reject(self.amilks, function (obj) {
return obj.id == amilkId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.amilksTable.columns, this.amilks)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getAMilks()
}
}
})
</script>
{% endblock %}

View file

@ -1,23 +0,0 @@
from quart import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from . import amilk_ext
from .crud import get_amilk
@amilk_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("amilk/index.html", user=g.user)
@amilk_ext.route("/<amilk_id>")
async def wall(amilk_id):
amilk = await get_amilk(amilk_id)
if not amilk:
abort(HTTPStatus.NOT_FOUND, "AMilk does not exist.")
return await render_template("amilk/wall.html", amilk=amilk)

View file

@ -1,105 +0,0 @@
import httpx
from quart import g, jsonify, request, abort
from http import HTTPStatus
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore
from lnurl.exceptions import LnurlException # type: ignore
from time import sleep
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.core.services import create_invoice, check_invoice_status
from . import amilk_ext
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
@amilk_ext.route("/api/v1/amilk", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_amilks():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return (
jsonify([amilk._asdict() for amilk in await get_amilks(wallet_ids)]),
HTTPStatus.OK,
)
@amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"])
async def api_amilkit(amilk_id):
milk = await get_amilk(amilk_id)
memo = milk.id
try:
withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse)
except LnurlException:
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
try:
payment_hash, payment_request = await create_invoice(
wallet_id=milk.wallet,
amount=withdraw_res.max_sats,
memo=memo,
extra={"tag": "amilk"},
)
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
r = httpx.get(
withdraw_res.callback.base,
params={
**withdraw_res.callback.query_params,
**{"k1": withdraw_res.k1, "pr": payment_request},
},
)
if r.is_error:
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
for i in range(10):
sleep(i)
invoice_status = await check_invoice_status(milk.wallet, payment_hash)
if invoice_status.paid:
return jsonify({"paid": True}), HTTPStatus.OK
else:
continue
return jsonify({"paid": False}), HTTPStatus.OK
@amilk_ext.route("/api/v1/amilk", methods=["POST"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"lnurl": {"type": "string", "empty": False, "required": True},
"atime": {"type": "integer", "min": 0, "required": True},
"amount": {"type": "integer", "min": 0, "required": True},
}
)
async def api_amilk_create():
amilk = await create_amilk(
wallet_id=g.wallet.id,
lnurl=g.data["lnurl"],
atime=g.data["atime"],
amount=g.data["amount"],
)
return jsonify(amilk._asdict()), HTTPStatus.CREATED
@amilk_ext.route("/api/v1/amilk/<amilk_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_amilk_delete(amilk_id):
amilk = await get_amilk(amilk_id)
if not amilk:
return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
if amilk.wallet != g.wallet.id:
return jsonify({"message": "Not your amilk."}), HTTPStatus.FORBIDDEN
await delete_amilk(amilk_id)
return "", HTTPStatus.NO_CONTENT

View file

@ -1,12 +1,26 @@
from quart import Blueprint
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_ext: Blueprint = Blueprint(
"bleskomat", __name__, static_folder="static", template_folder="templates"
)
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_api import * # noqa
from .views import * # noqa
from .views_api import * # noqa

View file

@ -1,27 +1,21 @@
import secrets
import time
from uuid import uuid4
from typing import List, Optional, Union
from uuid import uuid4
from . import db
from .models import Bleskomat, BleskomatLnurl
from .helpers import generate_bleskomat_lnurl_hash
from .models import Bleskomat, BleskomatLnurl, CreateBleskomat
async def create_bleskomat(
*,
wallet_id: str,
name: str,
fiat_currency: str,
exchange_rate_provider: str,
fee: str,
) -> Bleskomat:
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 bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
INSERT INTO bleskomat.bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
@ -30,10 +24,10 @@ async def create_bleskomat(
api_key_id,
api_key_secret,
api_key_encoding,
name,
fiat_currency,
exchange_rate_provider,
fee,
data.name,
data.fiat_currency,
data.exchange_rate_provider,
data.fee,
),
)
bleskomat = await get_bleskomat(bleskomat_id)
@ -42,13 +36,15 @@ async def create_bleskomat(
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
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 bleskomats WHERE api_key_id = ?", (api_key_id,)
"SELECT * FROM bleskomat.bleskomats WHERE api_key_id = ?", (api_key_id,)
)
return Bleskomat(**row) if row else None
@ -58,7 +54,7 @@ async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
f"SELECT * FROM bleskomat.bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Bleskomat(**row) for row in rows]
@ -66,14 +62,17 @@ async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE bleskomats SET {q} WHERE id = ?", (*kwargs.values(), bleskomat_id)
f"UPDATE bleskomat.bleskomats SET {q} WHERE id = ?",
(*kwargs.values(), bleskomat_id),
)
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
)
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
return Bleskomat(**row) if row else None
async def delete_bleskomat(bleskomat_id: str) -> None:
await db.execute("DELETE FROM bleskomats WHERE id = ?", (bleskomat_id,))
await db.execute("DELETE FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,))
async def create_bleskomat_lnurl(
@ -84,7 +83,7 @@ async def create_bleskomat_lnurl(
now = int(time.time())
await db.execute(
"""
INSERT INTO bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
INSERT INTO bleskomat.bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
@ -108,5 +107,7 @@ async def create_bleskomat_lnurl(
async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]:
hash = generate_bleskomat_lnurl_hash(secret)
row = await db.fetchone("SELECT * FROM bleskomat_lnurls WHERE hash = ?", (hash,))
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomat_lnurls WHERE hash = ?", (hash,)
)
return BleskomatLnurl(**row) if row else None

View file

@ -65,15 +65,16 @@ async def fetch_fiat_exchange_rate(currency: str, provider: str):
}
url = exchange_rate_providers[provider]["api_url"]
for key in replacements.keys():
url = url.replace("{" + key + "}", replacements[key])
if url:
for key in replacements.keys():
url = url.replace("{" + key + "}", replacements[key])
async with httpx.AsyncClient() as client:
r = await client.get(url)
r.raise_for_status()
data = r.json()
else:
data = {}
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))
rate = float(getter(data, replacements))
return rate

View file

@ -1,11 +1,12 @@
import base64
import hashlib
import hmac
from http import HTTPStatus
from binascii import unhexlify
from typing import Dict
from quart import url_for
import urllib
from binascii import unhexlify
from http import HTTPStatus
from typing import Dict
from starlette.requests import Request
def generate_bleskomat_lnurl_hash(secret: str):
@ -34,8 +35,8 @@ def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
return m.hexdigest()
def get_callback_url():
return url_for("bleskomat.api_bleskomat_lnurl", _external=True)
def get_callback_url(req: Request):
return req.url_for("bleskomat.api_bleskomat_lnurl")
def is_supported_lnurl_subprotocol(tag: str) -> bool:

View file

@ -1,8 +1,9 @@
import json
import math
from quart import jsonify, request
from http import HTTPStatus
import traceback
from http import HTTPStatus
from starlette.requests import Request
from . import bleskomat_ext
from .crud import (
@ -10,16 +11,12 @@ from .crud import (
get_bleskomat_by_api_key_id,
get_bleskomat_lnurl,
)
from .exchange_rates import (
fetch_fiat_exchange_rate,
)
from .exchange_rates import fetch_fiat_exchange_rate
from .helpers import (
generate_bleskomat_lnurl_signature,
generate_bleskomat_lnurl_secret,
LnurlHttpError,
LnurlValidationError,
generate_bleskomat_lnurl_secret,
generate_bleskomat_lnurl_signature,
prepare_lnurl_params,
query_to_signing_payload,
unshorten_lnurl_query,
@ -27,10 +24,10 @@ from .helpers import (
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
@bleskomat_ext.route("/u", methods=["GET"])
async def api_bleskomat_lnurl():
@bleskomat_ext.get("/u", name="bleskomat.api_bleskomat_lnurl")
async def api_bleskomat_lnurl(req: Request):
try:
query = request.args.to_dict()
query = req.query_params
# Unshorten query if "s" is used instead of "signature".
if "s" in query:
@ -99,7 +96,7 @@ async def api_bleskomat_lnurl():
)
# Reply with LNURL response object.
return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK
return lnurl.get_info_response_object(secret, req)
# No signature provided.
# Treat as "action" callback.
@ -123,12 +120,9 @@ async def api_bleskomat_lnurl():
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
except LnurlHttpError as e:
return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status
except Exception:
traceback.print_exc()
return (
jsonify({"status": "ERROR", "reason": "Unexpected error"}),
HTTPStatus.INTERNAL_SERVER_ERROR,
)
return {"status": "ERROR", "reason": str(e)}
except Exception as e:
print(str(e))
return {"status": "ERROR", "reason": "Unexpected error"}
return jsonify({"status": "OK"}), HTTPStatus.OK
return {"status": "OK"}

View file

@ -2,7 +2,7 @@ async def m001_initial(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS bleskomats (
CREATE TABLE bleskomat.bleskomats (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
api_key_id TEXT NOT NULL,
@ -19,7 +19,7 @@ async def m001_initial(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS bleskomat_lnurls (
CREATE TABLE bleskomat.bleskomat_lnurls (
id TEXT PRIMARY KEY,
bleskomat TEXT NOT NULL,
wallet TEXT NOT NULL,

View file

@ -1,13 +1,45 @@
import json
import time
from typing import NamedTuple, Dict
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 lnbits.core.services import pay_invoice, PaymentFailure
from . import db
from .helpers import get_callback_url, LnurlValidationError
from .exchange_rates import exchange_rate_providers, fiat_currencies
from .helpers import LnurlValidationError, get_callback_url
class Bleskomat(NamedTuple):
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
@ -19,7 +51,7 @@ class Bleskomat(NamedTuple):
fee: str
class BleskomatLnurl(NamedTuple):
class BleskomatLnurl(BaseModel):
id: str
bleskomat: str
wallet: str
@ -36,14 +68,14 @@ class BleskomatLnurl(NamedTuple):
# When initial uses is 0 then the LNURL has unlimited uses.
return self.initial_uses == 0 or self.remaining_uses > 0
def get_info_response_object(self, secret: str) -> Dict[str, str]:
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()
response["callback"] = get_callback_url(req)
response["k1"] = secret
return response
@ -87,20 +119,20 @@ class BleskomatLnurl(NamedTuple):
tag = self.tag
if tag == "withdrawRequest":
try:
payment_hash = await pay_invoice(
wallet_id=self.wallet,
payment_request=query["pr"],
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")
except (ValueError, PermissionError, PaymentFailure) as e:
raise LnurlValidationError("Failed to pay invoice: " + str(e))
except Exception as e:
print(str(e))
raise LnurlValidationError("Unexpected error")
async def use(self, conn) -> bool:
now = int(time.time())
result = await conn.execute(
"""
UPDATE bleskomat_lnurls
UPDATE bleskomat.bleskomat_lnurls
SET remaining_uses = remaining_uses - 1, updated_time = ?
WHERE id = ?
AND remaining_uses > 0

View file

@ -84,7 +84,7 @@ new Vue({
LNbits.api
.request(
'GET',
'/bleskomat/api/v1/bleskomats?all_wallets',
'/bleskomat/api/v1/bleskomats?all_wallets=true',
this.g.user.wallets[0].adminkey
)
.then(function (response) {

View file

@ -1,7 +1,7 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Bleskomat Extension for lnbits"
label="Setup guide"
:content-inset-level="0.5"
>
<q-card>

View file

@ -11,7 +11,7 @@
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
<q-btn unelevated color="primary" @click="formDialog.show = true"
>Add Bleskomat</q-btn
>
</q-card-section>
@ -94,7 +94,9 @@
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits Bleskomat extension</h6>
<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>
@ -150,14 +152,14 @@
<q-btn
v-if="formDialog.data.id"
unelevated
color="deep-purple"
color="primary"
type="submit"
>Update Bleskomat</q-btn
>
<q-btn
v-else
unelevated
color="deep-purple"
color="primary"
:disable="
formDialog.data.wallet == null ||
formDialog.data.name == null ||

View file

@ -1,22 +1,26 @@
from quart import g, render_template
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.decorators import check_user_exists, validate_uuids
from . import bleskomat_ext
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.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
@bleskomat_ext.get("/", response_class=HTMLResponse)
async def index(req: Request, user: User = Depends(check_user_exists)):
bleskomat_vars = {
"callback_url": get_callback_url(),
"callback_url": get_callback_url(req),
"exchange_rate_providers": exchange_rate_providers_serializable,
"fiat_currencies": fiat_currencies,
}
return await render_template(
"bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars
return bleskomat_renderer().TemplateResponse(
"bleskomat/index.html",
{"request": req, "user": user.dict(), "bleskomat_vars": bleskomat_vars},
)

View file

@ -1,120 +1,97 @@
from quart import g, jsonify, request
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 api_check_wallet_key, api_validate_post_request
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,
delete_bleskomat,
)
from .exchange_rates import (
exchange_rate_providers,
fetch_fiat_exchange_rate,
fiat_currencies,
)
from .exchange_rates import fetch_fiat_exchange_rate
@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"])
@api_check_wallet_key("admin")
async def api_bleskomats():
wallet_ids = [g.wallet.id]
@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" in request.args:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return (
jsonify(
[bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)]
),
HTTPStatus.OK,
)
return [bleskomat.dict() for bleskomat in await get_bleskomats(wallet_ids)]
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["GET"])
@api_check_wallet_key("admin")
async def api_bleskomat_retrieve(bleskomat_id):
@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 != g.wallet.id:
return (
jsonify({"message": "Bleskomat configuration not found."}),
HTTPStatus.NOT_FOUND,
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Bleskomat configuration not found.",
)
return jsonify(bleskomat._asdict()), HTTPStatus.OK
return bleskomat.dict()
@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"])
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"name": {"type": "string", "empty": False, "required": True},
"fiat_currency": {
"type": "string",
"allowed": fiat_currencies.keys(),
"required": True,
},
"exchange_rate_provider": {
"type": "string",
"allowed": exchange_rate_providers.keys(),
"required": True,
},
"fee": {"type": ["string", "float", "number", "integer"], "required": True},
}
)
async def api_bleskomat_create_or_update(bleskomat_id=None):
@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 = g.data["fiat_currency"]
exchange_rate_provider = g.data["exchange_rate_provider"]
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)
return (
jsonify(
{
"message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"'
}
),
HTTPStatus.INTERNAL_SERVER_ERROR,
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 != g.wallet.id:
return (
jsonify({"message": "Bleskomat configuration not found."}),
HTTPStatus.NOT_FOUND,
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, **g.data)
bleskomat = await update_bleskomat(bleskomat_id, **data.dict())
else:
bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data)
bleskomat = await create_bleskomat(wallet_id=wallet.wallet.id, data=data)
return (
jsonify(bleskomat._asdict()),
HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED,
)
return bleskomat.dict()
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["DELETE"])
@api_check_wallet_key("admin")
async def api_bleskomat_delete(bleskomat_id):
@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 != g.wallet.id:
return (
jsonify({"message": "Bleskomat configuration not found."}),
HTTPStatus.NOT_FOUND,
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)
return "", HTTPStatus.NO_CONTENT
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)

View file

@ -1,11 +0,0 @@
<h1>Example Extension</h1>
<h2>*tagline*</h2>
This is an example extension to help you organise and build you own.
Try to include an image
<img src="https://i.imgur.com/9i4xcQB.png">
<h2>If your extension has API endpoints, include useful ones here</h2>
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>

View file

@ -1,12 +0,0 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_captcha")
captcha_ext: Blueprint = Blueprint(
"captcha", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa

View file

@ -1,6 +0,0 @@
{
"name": "Captcha",
"short_description": "Create captcha to stop spam",
"icon": "block",
"contributors": ["pseudozach"]
}

View file

@ -1,51 +0,0 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Captcha
async def create_captcha(
*,
wallet_id: str,
url: str,
memo: str,
description: Optional[str] = None,
amount: int = 0,
remembers: bool = True,
) -> Captcha:
captcha_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO captchas (id, wallet, url, memo, description, amount, remembers)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(captcha_id, wallet_id, url, memo, description, amount, int(remembers)),
)
captcha = await get_captcha(captcha_id)
assert captcha, "Newly created captcha couldn't be retrieved"
return captcha
async def get_captcha(captcha_id: str) -> Optional[Captcha]:
row = await db.fetchone("SELECT * FROM captchas WHERE id = ?", (captcha_id,))
return Captcha.from_row(row) if row else None
async def get_captchas(wallet_ids: Union[str, List[str]]) -> List[Captcha]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM captchas WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Captcha.from_row(row) for row in rows]
async def delete_captcha(captcha_id: str) -> None:
await db.execute("DELETE FROM captchas WHERE id = ?", (captcha_id,))

View file

@ -1,67 +0,0 @@
from sqlalchemy.exc import OperationalError # type: ignore
async def m001_initial(db):
"""
Initial captchas table.
"""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS captchas (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
secret TEXT NOT NULL,
url TEXT NOT NULL,
memo TEXT NOT NULL,
amount INTEGER NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
);
"""
)
async def m002_redux(db):
"""
Creates an improved captchas table and migrates the existing data.
"""
try:
await db.execute("SELECT remembers FROM captchas")
except OperationalError:
await db.execute("ALTER TABLE captchas RENAME TO captchas_old")
await db.execute(
"""
CREATE TABLE IF NOT EXISTS captchas (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
url TEXT NOT NULL,
memo TEXT NOT NULL,
description TEXT NULL,
amount INTEGER DEFAULT 0,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')),
remembers INTEGER DEFAULT 0,
extras TEXT NULL
);
"""
)
await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON captchas (wallet)")
for row in [
list(row) for row in await db.fetchall("SELECT * FROM captchas_old")
]:
await db.execute(
"""
INSERT INTO captchas (
id,
wallet,
url,
memo,
amount,
time
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(row[0], row[1], row[3], row[4], row[5], row[6]),
)
await db.execute("DROP TABLE captchas_old")

View file

@ -1,23 +0,0 @@
import json
from sqlite3 import Row
from typing import NamedTuple, Optional
class Captcha(NamedTuple):
id: str
wallet: str
url: str
memo: str
description: str
amount: int
time: int
remembers: bool
extras: Optional[dict]
@classmethod
def from_row(cls, row: Row) -> "Captcha":
data = dict(row)
data["remembers"] = bool(data["remembers"])
data["extras"] = json.loads(data["extras"]) if data["extras"] else None
return cls(**data)

View file

@ -1,82 +0,0 @@
var ciframeLoaded = !1,
captchaStyleAdded = !1
function ccreateIframeElement(t = {}) {
const e = document.createElement('iframe')
// e.style.marginLeft = "25px",
;(e.style.border = 'none'),
(e.style.width = '100%'),
(e.style.height = '100%'),
(e.scrolling = 'no'),
(e.id = 'captcha-iframe')
t.dest, t.amount, t.currency, t.label, t.opReturn
var captchaid = document
.getElementById('captchascript')
.getAttribute('data-captchaid')
var lnbhostsrc = document.getElementById('captchascript').getAttribute('src')
var lnbhost = lnbhostsrc.split('/captcha/static/js/captcha.js')[0]
return (e.src = lnbhost + '/captcha/' + captchaid), e
}
document.addEventListener('DOMContentLoaded', function () {
if (captchaStyleAdded) console.log('Captcha already added!')
else {
console.log('Adding captcha'), (captchaStyleAdded = !0)
var t = document.createElement('style')
t.innerHTML =
"\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}"
var e = document.querySelector('script')
e.parentNode.insertBefore(t, e)
var i = document.getElementById('captchacheckbox'),
n = i.dataset,
o = 'true' === n.dark
var a = document.createElement('div')
;(a.className += ' modal-captcha-container'),
(a.innerHTML =
'\t\t<div class="modal-captcha-content"> \t<span class="close-button-captcha" style="display: none;">&times;</span>\t\t</div>\t'),
document.getElementsByTagName('body')[0].appendChild(a)
var r = document.getElementsByClassName('modal-captcha-content').item(0)
document
.getElementsByClassName('close-button-captcha')
.item(0)
.addEventListener('click', d),
window.addEventListener('click', function (t) {
t.target === a && d()
}),
i.addEventListener('change', function () {
if (this.checked) {
// console.log("checkbox checked");
if (0 == ciframeLoaded) {
// console.log("n: ", n);
var t = ccreateIframeElement(n)
r.appendChild(t), (ciframeLoaded = !0)
}
d()
}
})
}
function d() {
a.classList.toggle('show-modal-captcha')
}
})
function receiveMessage(event) {
if (event.data.includes('paymenthash')) {
// console.log("paymenthash received: ", event.data);
document.getElementById('captchapayhash').value = event.data.split('_')[1]
}
if (event.data.includes('removetheiframe')) {
if (event.data.includes('nok')) {
//invoice was NOT paid
// console.log("receiveMessage not paid")
document.getElementById('captchacheckbox').checked = false
}
ciframeLoaded = !1
var element = document.getElementById('captcha-iframe')
document
.getElementsByClassName('modal-captcha-container')[0]
.classList.toggle('show-modal-captcha')
element.parentNode.removeChild(element)
}
}
window.addEventListener('message', receiveMessage, false)

View file

@ -1,147 +0,0 @@
<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 captchas">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /captcha/api/v1/captchas</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;captcha_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}captcha/api/v1/captchas -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a captcha">
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span> /captcha/api/v1/captchas</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 }}captcha/api/v1/captchas -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:
{{ g.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>
/captcha/api/v1/captchas/&lt;captcha_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
}}captcha/api/v1/captchas/&lt;captcha_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>
/captcha/api/v1/captchas/&lt;captcha_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
}}captcha/api/v1/captchas/&lt;captcha_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 captcha"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/captcha/api/v1/captchas/&lt;captcha_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
}}captcha/api/v1/captchas/&lt;captcha_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View file

@ -1,178 +0,0 @@
{% 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">{{ captcha.memo }}</h5>
{% if captcha.description %}
<p>{{ captcha.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="captchaAmount"
suffix="sat"
label="Choose an amount *"
:hint="'Minimum ' + captchaAmount + ' sat'"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="check"
color="deep-purple"
type="submit"
@click="createInvoice"
:disabled="userAmount < captchaAmount || 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(false)"
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>
Captcha accepted. You are probably human.<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: {{ captcha.amount }},
captchaAmount: {{ captcha.amount }},
paymentReq: null,
redirectUrl: null,
paymentDialog: {
dismissMsg: null,
checker: null
}
}
},
computed: {
amount: function () {
return (this.captchaAmount > this.userAmount) ? this.captchaAmount : this.userAmount
}
},
methods: {
cancelPayment: function (paid) {
this.paymentReq = null
clearInterval(this.paymentDialog.checker)
if (this.paymentDialog.dismissMsg) {
this.paymentDialog.dismissMsg()
}
var removeiframestring = "removetheiframe_nok";
var timeout = 500;
if(paid){
console.log("paid, dismissing iframe");
removeiframestring = "removetheiframe_ok";
timeout = 2000;
}
setTimeout(function () {
// parent.closeIFrame()
parent.window.postMessage(removeiframestring, "*");
}, timeout)
},
createInvoice: function () {
var self = this
axios
.post(
'/captcha/api/v1/captchas/{{ captcha.id }}/invoice',
{amount: this.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(
'/captcha/api/v1/captchas/{{ captcha.id }}/check_invoice',
{payment_hash: response.data.payment_hash}
)
.then(function (res) {
if (res.data.paid) {
self.cancelPayment(true)
self.redirectUrl = res.data.url
if (res.data.remembers) {
self.$q.localStorage.set(
'lnbits.captcha.{{ captcha.id }}',
res.data.url
)
}
parent.window.postMessage("paymenthash_"+response.data.payment_hash, "*");
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.captcha.{{ captcha.id }}')
if (url) {
this.redirectUrl = url
}
}
})
</script>
{% endblock %}

View file

@ -1,425 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
>New captcha</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">Captchas</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="captchas"
row-key="id"
:columns="captchasTable.columns"
:pagination.sync="captchasTable.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="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
:href="buildCaptchaSnippet(props.row.id)"
@click="openQrCodeDialog(props.row.id)"
></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="deleteCaptcha(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">LNbits captcha extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "captcha/_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="createCaptcha" 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="hidden"
label="Redirect URL *"
:value="https://dummy.com"
></q-input> -->
<q-input
filled
dense
v-model.trim="formDialog.data.memo"
label="Title *"
placeholder="LNbits captcha"
></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="deep-purple"
></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 prove they are
human.</q-item-label
>
</q-item-section>
</q-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
unelevated
color="deep-purple"
:disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.memo == null"
type="submit"
>Create captcha</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
{% raw %}
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<!-- <qrcode
:value="qrCodeDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode> -->
<code style="word-break: break-all">
{{ qrCodeDialog.data.snippet }}
</code>
<p style="margin-top: 20px">
Copy the snippet above and paste into your website/form. The checkbox
can be in checked state only after user pays.
</p>
</q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
<!-- <span v-if="qrCodeDialog.data.currency"
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
fiatRates[qrCodeDialog.data.currency] ?
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
/></span>
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
}}<br />
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br /> -->
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.snippet, 'Snippet copied to clipboard!')"
class="q-ml-sm"
>Copy Snippet</q-btn
>
<!-- <q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
>Shareable link</q-btn
>
<q-btn
outline
color="grey"
icon="print"
type="a"
:href="qrCodeDialog.data.print_url"
target="_blank"
></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 %} {{ window_vars(user) }}
<script>
var mapCaptcha = 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 = ['/captcha/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
captchas: [],
captchasTable: {
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
}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
getCaptchas: function () {
var self = this
LNbits.api
.request(
'GET',
'/captcha/api/v1/captchas?all_wallets',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.captchas = response.data.map(function (obj) {
return mapCaptcha(obj)
})
})
},
createCaptcha: function () {
var data = {
// url: this.formDialog.data.url,
url: 'http://dummy.com',
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',
'/captcha/api/v1/captchas',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.captchas.push(mapCaptcha(response.data))
self.formDialog.show = false
self.formDialog.data = {
remembers: false
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteCaptcha: function (captchaId) {
var self = this
var captcha = _.findWhere(this.captchas, {id: captchaId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this captcha link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/captcha/api/v1/captchas/' + captchaId,
_.findWhere(self.g.user.wallets, {id: captcha.wallet}).inkey
)
.then(function (response) {
self.captchas = _.reject(self.captchas, function (obj) {
return obj.id == captchaId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
buildCaptchaSnippet: function (captchaId) {
var locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
var captchasnippet =
'<!-- Captcha Checkbox Start -->\n' +
'<input type="checkbox" id="captchacheckbox">\n' +
'<label for="captchacheckbox">I\'m not a robot</label><br/>\n' +
'<input type="text" id="captchapayhash" style="display: none;"/>\n' +
'<script type="text/javascript" src="' +
locationPath +
'static/js/captcha.js" id="captchascript" data-captchaid="' +
captchaId +
'">\n' +
'<\/script>\n' +
'<!-- Captcha Checkbox End -->'
return captchasnippet
},
openQrCodeDialog(captchaId) {
// var link = _.findWhere(this.payLinks, {id: linkId})
var captcha = _.findWhere(this.captchas, {id: captchaId})
// if (link.currency) this.updateFiatRate(link.currency)
this.qrCodeDialog.data = {
id: captcha.id,
amount: captcha.amount,
// (link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
// ' ' +
// (link.currency || 'sat'),
snippet: this.buildCaptchaSnippet(captcha.id)
// currency: link.currency,
// comments: link.comment_chars
// ? `${link.comment_chars} characters`
// : 'no',
// webhook: link.webhook_url || 'nowhere',
// success:
// link.success_text || link.success_url
// ? 'Display message "' +
// link.success_text +
// '"' +
// (link.success_url ? ' and URL "' + link.success_url + '"' : '')
// : 'do nothing',
// lnurl: link.lnurl,
// pay_url: link.pay_url,
// print_url: link.print_url
}
this.qrCodeDialog.show = true
},
exportCSV: function () {
LNbits.utils.exportCSV(this.captchasTable.columns, this.captchas)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getCaptchas()
}
}
})
</script>
{% endblock %}

View file

@ -1,22 +0,0 @@
from quart import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from . import captcha_ext
from .crud import get_captcha
@captcha_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("captcha/index.html", user=g.user)
@captcha_ext.route("/<captcha_id>")
async def display(captcha_id):
captcha = await get_captcha(captcha_id) or abort(
HTTPStatus.NOT_FOUND, "captcha does not exist."
)
return await render_template("captcha/display.html", captcha=captcha)

View file

@ -1,121 +0,0 @@
from quart import g, jsonify, request
from http import HTTPStatus
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import create_invoice, check_invoice_status
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from . import captcha_ext
from .crud import create_captcha, get_captcha, get_captchas, delete_captcha
@captcha_ext.route("/api/v1/captchas", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_captchas():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return (
jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]),
HTTPStatus.OK,
)
@captcha_ext.route("/api/v1/captchas", methods=["POST"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"url": {"type": "string", "empty": False, "required": True},
"memo": {"type": "string", "empty": False, "required": True},
"description": {
"type": "string",
"empty": True,
"nullable": True,
"required": False,
},
"amount": {"type": "integer", "min": 0, "required": True},
"remembers": {"type": "boolean", "required": True},
}
)
async def api_captcha_create():
captcha = await create_captcha(wallet_id=g.wallet.id, **g.data)
return jsonify(captcha._asdict()), HTTPStatus.CREATED
@captcha_ext.route("/api/v1/captchas/<captcha_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_captcha_delete(captcha_id):
captcha = await get_captcha(captcha_id)
if not captcha:
return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND
if captcha.wallet != g.wallet.id:
return jsonify({"message": "Not your captcha."}), HTTPStatus.FORBIDDEN
await delete_captcha(captcha_id)
return "", HTTPStatus.NO_CONTENT
@captcha_ext.route("/api/v1/captchas/<captcha_id>/invoice", methods=["POST"])
@api_validate_post_request(
schema={"amount": {"type": "integer", "min": 1, "required": True}}
)
async def api_captcha_create_invoice(captcha_id):
captcha = await get_captcha(captcha_id)
if g.data["amount"] < captcha.amount:
return (
jsonify({"message": f"Minimum amount is {captcha.amount} sat."}),
HTTPStatus.BAD_REQUEST,
)
try:
amount = (
g.data["amount"] if g.data["amount"] > captcha.amount else captcha.amount
)
payment_hash, payment_request = await create_invoice(
wallet_id=captcha.wallet,
amount=amount,
memo=f"{captcha.memo}",
extra={"tag": "captcha"},
)
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
return (
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
HTTPStatus.CREATED,
)
@captcha_ext.route("/api/v1/captchas/<captcha_id>/check_invoice", methods=["POST"])
@api_validate_post_request(
schema={"payment_hash": {"type": "string", "empty": False, "required": True}}
)
async def api_paywal_check_invoice(captcha_id):
captcha = await get_captcha(captcha_id)
if not captcha:
return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND
try:
status = await check_invoice_status(captcha.wallet, g.data["payment_hash"])
is_paid = not status.pending
except Exception:
return jsonify({"paid": False}), HTTPStatus.OK
if is_paid:
wallet = await get_wallet(captcha.wallet)
payment = await wallet.get_payment(g.data["payment_hash"])
await payment.set_pending(False)
return (
jsonify({"paid": True, "url": captcha.url, "remembers": captcha.remembers}),
HTTPStatus.OK,
)
return jsonify({"paid": False}), HTTPStatus.OK

View file

@ -0,0 +1,3 @@
# StreamerCopilot
Tool to help streamers accept sats for tips

View file

@ -0,0 +1,34 @@
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_copilot")
copilot_static_files = [
{
"path": "/copilot/static",
"app": StaticFiles(directory="lnbits/extensions/copilot/static"),
"name": "copilot_static",
}
]
copilot_ext: APIRouter = APIRouter(prefix="/copilot", tags=["copilot"])
def copilot_renderer():
return template_renderer(["lnbits/extensions/copilot/templates"])
from .lnurl import * # noqa
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def copilot_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View file

@ -0,0 +1,8 @@
{
"name": "Streamer Copilot",
"short_description": "Video tips/animations/webhooks",
"icon": "face",
"contributors": [
"arcbtc"
]
}

View file

@ -0,0 +1,97 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Copilots, CreateCopilotData
###############COPILOTS##########################
async def create_copilot(
data: CreateCopilotData, inkey: Optional[str] = ""
) -> Copilots:
copilot_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO copilot.newer_copilots (
id,
"user",
lnurl_toggle,
wallet,
title,
animation1,
animation2,
animation3,
animation1threshold,
animation2threshold,
animation3threshold,
animation1webhook,
animation2webhook,
animation3webhook,
lnurl_title,
show_message,
show_ack,
show_price,
fullscreen_cam,
iframe_url,
amount_made
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
copilot_id,
data.user,
int(data.lnurl_toggle),
data.wallet,
data.title,
data.animation1,
data.animation2,
data.animation3,
data.animation1threshold,
data.animation2threshold,
data.animation3threshold,
data.animation1webhook,
data.animation2webhook,
data.animation3webhook,
data.lnurl_title,
int(data.show_message),
int(data.show_ack),
data.show_price,
0,
None,
0,
),
)
return await get_copilot(copilot_id)
async def update_copilot(
data: CreateCopilotData, copilot_id: Optional[str] = ""
) -> Optional[Copilots]:
q = ", ".join([f"{field[0]} = ?" for field in data])
items = [f"{field[1]}" for field in data]
items.append(copilot_id)
await db.execute(f"UPDATE copilot.newer_copilots SET {q} WHERE id = ?", (items))
row = await db.fetchone(
"SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
)
return Copilots(**row) if row else None
async def get_copilot(copilot_id: str) -> Copilots:
row = await db.fetchone(
"SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
)
return Copilots(**row) if row else None
async def get_copilots(user: str) -> List[Copilots]:
rows = await db.fetchall(
'SELECT * FROM copilot.newer_copilots WHERE "user" = ?', (user,)
)
return [Copilots(**row) for row in rows]
async def delete_copilot(copilot_id: str) -> None:
await db.execute("DELETE FROM copilot.newer_copilots WHERE id = ?", (copilot_id,))

View file

@ -0,0 +1,84 @@
import hashlib
import json
from http import HTTPStatus
from fastapi import Request
from fastapi.param_functions import Query
from lnurl.types import LnurlPayMetadata
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse # type: ignore
from lnbits.core.services import create_invoice
from . import copilot_ext
from .crud import get_copilot
@copilot_ext.get(
"/lnurl/{cp_id}", response_class=HTMLResponse, name="copilot.lnurl_response"
)
async def lnurl_response(req: Request, cp_id: str = Query(None)):
cp = await get_copilot(cp_id)
if not cp:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
)
payResponse = {
"tag": "payRequest",
"callback": req.url_for("copilot.lnurl_callback", cp_id=cp_id),
"metadata": LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])),
"maxSendable": 50000000,
"minSendable": 10000,
}
if cp.show_message:
payResponse["commentAllowed"] = 300
return json.dumps(payResponse)
@copilot_ext.get(
"/lnurl/cb/{cp_id}", response_class=HTMLResponse, name="copilot.lnurl_callback"
)
async def lnurl_callback(
cp_id: str = Query(None), amount: str = Query(None), comment: str = Query(None)
):
cp = await get_copilot(cp_id)
if not cp:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
)
amount_received = int(amount)
if amount_received < 10000:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Amount {round(amount_received / 1000)} is smaller than minimum 10 sats.",
)
elif amount_received / 1000 > 10000000:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Amount {round(amount_received / 1000)} is greater than maximum 50000.",
)
comment = ""
if comment:
if len(comment or "") > 300:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Got a comment with {len(comment)} characters, but can only accept 300",
)
if len(comment) < 1:
comment = "none"
_, payment_request = await create_invoice(
wallet_id=cp.wallet,
amount=int(amount_received / 1000),
memo=cp.lnurl_title,
description_hash=hashlib.sha256(
(
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
).encode("utf-8")
).digest(),
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
)
payResponse = {"pr": payment_request, "routes": []}
return json.dumps(payResponse)

View file

@ -0,0 +1,79 @@
async def m001_initial(db):
"""
Initial copilot table.
"""
await db.execute(
f"""
CREATE TABLE copilot.copilots (
id TEXT NOT NULL PRIMARY KEY,
"user" TEXT,
title TEXT,
lnurl_toggle INTEGER,
wallet TEXT,
animation1 TEXT,
animation2 TEXT,
animation3 TEXT,
animation1threshold INTEGER,
animation2threshold INTEGER,
animation3threshold INTEGER,
animation1webhook TEXT,
animation2webhook TEXT,
animation3webhook TEXT,
lnurl_title TEXT,
show_message INTEGER,
show_ack INTEGER,
show_price INTEGER,
amount_made INTEGER,
fullscreen_cam INTEGER,
iframe_url TEXT,
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m002_fix_data_types(db):
"""
Fix data types.
"""
if db.type != "SQLITE":
await db.execute(
"ALTER TABLE copilot.copilots ALTER COLUMN show_price TYPE TEXT;"
)
async def m003_fix_data_types(db):
await db.execute(
f"""
CREATE TABLE copilot.newer_copilots (
id TEXT NOT NULL PRIMARY KEY,
"user" TEXT,
title TEXT,
lnurl_toggle INTEGER,
wallet TEXT,
animation1 TEXT,
animation2 TEXT,
animation3 TEXT,
animation1threshold INTEGER,
animation2threshold INTEGER,
animation3threshold INTEGER,
animation1webhook TEXT,
animation2webhook TEXT,
animation3webhook TEXT,
lnurl_title TEXT,
show_message INTEGER,
show_ack INTEGER,
show_price TEXT,
amount_made INTEGER,
fullscreen_cam INTEGER,
iframe_url TEXT,
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
"INSERT INTO copilot.newer_copilots SELECT * FROM copilot.copilots"
)

View file

@ -0,0 +1,64 @@
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
from starlette.requests import Request
from fastapi.param_functions import Query
from typing import Optional, Dict
from lnbits.lnurl import encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from pydantic import BaseModel
import json
from sqlite3 import Row
class CreateCopilotData(BaseModel):
user: str = Query(None)
title: str = Query(None)
lnurl_toggle: int = Query(0)
wallet: str = Query(None)
animation1: str = Query(None)
animation2: str = Query(None)
animation3: str = Query(None)
animation1threshold: int = Query(None)
animation2threshold: int = Query(None)
animation3threshold: int = Query(None)
animation1webhook: str = Query(None)
animation2webhook: str = Query(None)
animation3webhook: str = Query(None)
lnurl_title: str = Query(None)
show_message: int = Query(0)
show_ack: int = Query(0)
show_price: str = Query(None)
amount_made: int = Query(0)
timestamp: int = Query(0)
fullscreen_cam: int = Query(0)
iframe_url: str = Query(None)
success_url: str = Query(None)
class Copilots(BaseModel):
id: str
user: str = Query(None)
title: str = Query(None)
lnurl_toggle: int = Query(0)
wallet: str = Query(None)
animation1: str = Query(None)
animation2: str = Query(None)
animation3: str = Query(None)
animation1threshold: int = Query(None)
animation2threshold: int = Query(None)
animation3threshold: int = Query(None)
animation1webhook: str = Query(None)
animation2webhook: str = Query(None)
animation3webhook: str = Query(None)
lnurl_title: str = Query(None)
show_message: int = Query(0)
show_ack: int = Query(0)
show_price: str = Query(None)
amount_made: int = Query(0)
timestamp: int = Query(0)
fullscreen_cam: int = Query(0)
iframe_url: str = Query(None)
success_url: str = Query(None)
def lnurl(self, req: Request) -> str:
url = req.url_for("copilot.lnurl_response", cp_id=self.id)
return lnurl_encode(url)

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

View file

@ -0,0 +1,81 @@
import asyncio
import json
from http import HTTPStatus
import httpx
from starlette.exceptions import HTTPException
from lnbits.core import db as core_db
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from .crud import get_copilot
from .views import updater
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 on_invoice_paid(payment: Payment) -> None:
webhook = None
data = None
if "copilot" != payment.extra.get("tag"):
# not an copilot invoice
return
copilot = await get_copilot(payment.extra.get("copilotid", -1))
if not copilot:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
)
if copilot.animation1threshold:
if int(payment.amount / 1000) >= copilot.animation1threshold:
data = copilot.animation1
webhook = copilot.animation1webhook
if copilot.animation2threshold:
if int(payment.amount / 1000) >= copilot.animation2threshold:
data = copilot.animation2
webhook = copilot.animation1webhook
if copilot.animation3threshold:
if int(payment.amount / 1000) >= copilot.animation3threshold:
data = copilot.animation3
webhook = copilot.animation1webhook
if webhook:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
webhook,
json={
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": payment.amount,
"comment": payment.extra.get("comment"),
},
timeout=40,
)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)
if payment.extra.get("comment"):
await updater(copilot.id, data, payment.extra.get("comment"))
await updater(copilot.id, data, "none")
async def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status
await core_db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)

View file

@ -0,0 +1,172 @@
<q-card>
<q-card-section>
<p>
StreamerCopilot: get tips via static QR (lnurl-pay) and show an
animation<br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
>
</p>
</q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="Create copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /copilot/api/v1/copilot</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>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/copilot -d '{"title":
&lt;string&gt;, "animation": &lt;string&gt;,
"show_message":&lt;string&gt;, "amount": &lt;integer&gt;,
"lnurl_title": &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="Update copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">PUT</span>
/copilot/api/v1/copilot/&lt;copilot_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">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}api/v1/copilot/&lt;copilot_id&gt; -d '{"title": &lt;string&gt;,
"animation": &lt;string&gt;, "show_message":&lt;string&gt;,
"amount": &lt;integer&gt;, "lnurl_title": &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="Get copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/copilot/api/v1/copilot/&lt;copilot_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 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}api/v1/copilot/&lt;copilot_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="Get copilots">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /copilot/api/v1/copilots</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;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}api/v1/copilots -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="Delete a pay link"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/copilot/api/v1/copilot/&lt;copilot_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.base_url
}}api/v1/copilot/&lt;copilot_id&gt; -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="Trigger an animation"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/api/v1/copilot/ws/&lt;copilot_id&gt;/&lt;comment&gt;/&lt;data&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 200</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}/api/v1/copilot/ws/&lt;string,
copilot_id&gt;/&lt;string, comment&gt;/&lt;string, gif name&gt; -H
"X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card>

View file

@ -0,0 +1,287 @@
{% extends "public.html" %} {% block page %}<q-page>
<video
autoplay="true"
id="videoScreen"
style="width: 100%"
class="fixed-bottom-right"
></video>
<video
autoplay="true"
id="videoCamera"
style="width: 100%"
class="fixed-bottom-right"
></video>
<img src="" style="width: 100%" id="animations" class="fixed-bottom-left" />
<div
v-if="copilot.lnurl_toggle == 1"
class="rounded-borders column fixed-right"
style="
width: 250px;
background-color: white;
height: 300px;
margin-top: 10%;
"
>
<div class="col">
<qrcode
:value="copilot.lnurl"
:options="{width:250}"
class="rounded-borders"
></qrcode>
<center class="absolute-bottom" style="color: black; font-size: 20px">
{% raw %}{{ copilot.lnurl_title }}{% endraw %}
</center>
</div>
</div>
<h2
v-if="copilot.show_price != 0"
class="text-bold fixed-bottom-left"
style="
margin: 60px 60px;
font-size: 110px;
text-shadow: 4px 8px 4px black;
color: white;
"
>
{% raw %}{{ price }}{% endraw %}
</h2>
<p
v-if="copilot.show_ack != 0"
class="fixed-top"
style="
font-size: 22px;
text-shadow: 2px 4px 1px black;
color: white;
padding-left: 40%;
"
>
Powered by LNbits/StreamerCopilot
</p>
</q-page>
{% endblock %} {% block scripts %}
<style>
body.body--dark .q-drawer,
body.body--dark .q-footer,
body.body--dark .q-header,
.q-drawer,
.q-footer,
.q-header {
display: none;
}
.q-page {
padding: 0px;
}
</style>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
price: '',
counter: 1,
colours: ['teal', 'purple', 'indigo', 'pink', 'green'],
copilot: {},
animQueue: [],
queue: false,
lnurl: ''
}
},
methods: {
showNotif: function (userMessage) {
var colour = this.colours[
Math.floor(Math.random() * this.colours.length)
]
this.$q.notify({
color: colour,
icon: 'chat_bubble_outline',
html: true,
message: '<h4 style="color: white;">' + userMessage + '</h4>',
position: 'top-left',
timeout: 5000
})
},
openURL: function (url) {
return Quasar.utils.openURL(url)
},
initCamera() {
var video = document.querySelector('#videoCamera')
if (navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices
.getUserMedia({video: true})
.then(function (stream) {
video.srcObject = stream
})
.catch(function (err0r) {
console.log('Something went wrong!')
})
}
},
initScreenShare() {
var video = document.querySelector('#videoScreen')
navigator.mediaDevices
.getDisplayMedia({video: true})
.then(function (stream) {
video.srcObject = stream
})
.catch(function (err0r) {
console.log('Something went wrong!')
})
},
pushAnim(content) {
document.getElementById('animations').style.width = content[0]
document.getElementById('animations').src = content[1]
if (content[2] != 'none') {
self.showNotif(content[2])
}
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
},
launch() {
self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/ws/' +
self.copilot.id +
'/launching/rocket'
)
.then(function (response1) {
self.$q.notify({
color: 'green',
message: 'Sent!'
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
mounted() {
this.initCamera()
},
created: function () {
self = this
self.copilot = JSON.parse(localStorage.getItem('copilot'))
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/' + self.copilot.id,
localStorage.getItem('inkey')
)
.then(function (response) {
self.copilot = response.data
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
this.connectionBitStamp = new WebSocket('wss://ws.bitstamp.net')
const obj = JSON.stringify({
event: 'bts:subscribe',
data: {channel: 'live_trades_' + self.copilot.show_price}
})
this.connectionBitStamp.onmessage = function (e) {
if (self.copilot.show_price) {
if (self.copilot.show_price == 'btcusd') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(JSON.parse(e.data).data.price)
)
} else if (self.copilot.show_price == 'btceur') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(JSON.parse(e.data).data.price)
)
} else if (self.copilot.show_price == 'btcgbp') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'GBP'
}).format(JSON.parse(e.data).data.price)
)
}
}
}
this.connectionBitStamp.onopen = () => this.connectionBitStamp.send(obj)
const fetch = data =>
new Promise(resolve => setTimeout(resolve, 5000, this.pushAnim(data)))
const addTask = (() => {
let pending = Promise.resolve()
const run = async data => {
try {
await pending
} finally {
return fetch(data)
}
}
return data => (pending = run(data))
})()
if (location.protocol !== 'http:') {
localUrl =
'wss://' +
document.domain +
':' +
location.port +
'/copilot/ws/' +
self.copilot.id
} else {
localUrl =
'ws://' +
document.domain +
':' +
location.port +
'/copilot/ws/' +
self.copilot.id
}
this.connection = new WebSocket(localUrl)
this.connection.onmessage = function (e) {
console.log(e)
res = e.data.split('-')
if (res[0] == 'rocket') {
addTask(['40%', '/copilot/static/rocket.gif', res[1]])
}
if (res[0] == 'face') {
addTask(['35%', '/copilot/static/face.gif', res[1]])
}
if (res[0] == 'bitcoin') {
addTask(['30%', '/copilot/static/bitcoin.gif', res[1]])
}
if (res[0] == 'confetti') {
addTask(['100%', '/copilot/static/confetti.gif', res[1]])
}
if (res[0] == 'martijn') {
addTask(['40%', '/copilot/static/martijn.gif', res[1]])
}
if (res[0] == 'rick') {
addTask(['40%', '/copilot/static/rick.gif', res[1]])
}
if (res[0] == 'true') {
document.getElementById('videoCamera').style.width = '20%'
self.initScreenShare()
}
if (res[0] == 'false') {
document.getElementById('videoCamera').style.width = '100%'
document.getElementById('videoScreen').src = null
}
}
this.connection.onopen = () => this.launch
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,660 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
{% raw %}
<q-btn unelevated color="primary" @click="formDialogCopilot.show = true"
>New copilot instance
</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">Copilots</h5>
</div>
<div class="col-auto">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
<q-btn flat color="grey" @click="exportcopilotCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
flat
dense
:data="CopilotLinks"
row-key="id"
:columns="CopilotsTable.columns"
:pagination.sync="CopilotsTable.pagination"
:filter="filter"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div>
</q-th>
<!-- <q-th auto-width></q-th> -->
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<q-btn
unelevated
dense
size="xs"
icon="apps"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openCopilotPanel(props.row.id)"
>
<q-tooltip> Panel </q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
unelevated
dense
size="xs"
icon="face"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openCopilotCompose(props.row.id)"
>
<q-tooltip> Compose window </q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
flat
dense
size="xs"
@click="deleteCopilotLink(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip> Delete copilot </q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
flat
dense
size="xs"
@click="openUpdateCopilotLink(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip> Edit copilot </q-tooltip>
</q-btn>
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.value }}</div>
</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}} StreamCopilot Extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "copilot/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog
v-model="formDialogCopilot.show"
position="top"
@hide="closeFormDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataCopilot" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.title"
type="text"
label="Title"
></q-input>
<div class="row">
<q-checkbox
v-model="formDialogCopilot.data.lnurl_toggle"
label="Include lnurl payment QR? (requires https)"
left-label
></q-checkbox>
</div>
<div v-if="formDialogCopilot.data.lnurl_toggle">
<q-checkbox
v-model="formDialogCopilot.data.show_message"
left-label
label="Show lnurl-pay messages? (supported by few wallets)"
></q-checkbox>
<q-select
filled
dense
emit-value
v-model="formDialogCopilot.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 1"
>
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation1"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation1threshold"
type="number"
step="1"
label="From *sats (min. 10)"
:rules="[ val => val >= 10 || 'Please use minimum 10' ]"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation1webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 2 (Must be higher than last)"
>
<q-card>
<q-card-section>
<div
class="row"
v-if="formDialogCopilot.data.animation1threshold > 0"
>
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation2"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model="formDialogCopilot.data.animation2threshold"
type="number"
step="1"
label="From *sats"
:min="formDialogCopilot.data.animation1threshold"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation2webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 3 (Must be higher than last)"
>
<q-card>
<q-card-section>
<div
class="row"
v-if="formDialogCopilot.data.animation2threshold > formDialogCopilot.data.animation1threshold"
>
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation3"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model="formDialogCopilot.data.animation3threshold"
type="number"
step="1"
label="From *sats"
:min="formDialogCopilot.data.animation2threshold"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation3webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.lnurl_title"
type="text"
max="1440"
label="Lnurl title (message with QR code)"
>
</q-input>
</div>
<div class="q-gutter-sm">
<q-select
filled
dense
style="width: 50%"
v-model.trim="formDialogCopilot.data.show_price"
:options="currencyOptions"
label="Show price"
/>
</div>
<div class="q-gutter-sm">
<div class="row">
<q-checkbox
v-model="formDialogCopilot.data.show_ack"
left-label
label="Show 'powered by LNbits'"
></q-checkbox>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="formDialogCopilot.data.id"
unelevated
color="primary"
:disable="
formDialogCopilot.data.title == ''"
type="submit"
>Update Copilot</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialogCopilot.data.title == ''"
type="submit"
>Create Copilot</q-btn
>
<q-btn @click="cancelCopilot" 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>
Vue.component(VueQrcode.name, VueQrcode)
var mapCopilot = obj => {
obj._data = _.clone(obj)
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
obj.time = obj.time + 'mins'
if (obj.time_elapsed) {
obj.date = 'Time elapsed'
} else {
obj.date = Quasar.utils.date.formatDate(
new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss'
)
}
obj.displayComposeUrl = ['/copilot/cp/', obj.id].join('')
obj.displayPanelUrl = ['/copilot/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
filter: '',
CopilotLinks: [],
CopilotLinksObj: [],
CopilotsTable: {
columns: [
{
name: 'theId',
align: 'left',
label: 'id',
field: 'id'
},
{
name: 'lnurl_toggle',
align: 'left',
label: 'Show lnurl pay link',
field: 'lnurl_toggle'
},
{
name: 'title',
align: 'left',
label: 'title',
field: 'title'
},
{
name: 'amount_made',
align: 'left',
label: 'amount made',
field: 'amount_made'
}
],
pagination: {
rowsPerPage: 10
}
},
passedCopilot: {},
formDialog: {
show: false,
data: {}
},
formDialogCopilot: {
show: false,
data: {
lnurl_toggle: false,
show_message: false,
show_ack: false,
show_price: 'None',
title: ''
}
},
qrCodeDialog: {
show: false,
data: null
},
options: ['bitcoin', 'confetti', 'rocket', 'face', 'martijn', 'rick'],
currencyOptions: ['None', 'btcusd', 'btceur', 'btcgbp']
}
},
methods: {
cancelCopilot: function (data) {
var self = this
self.formDialogCopilot.show = false
self.clearFormDialogCopilot()
},
closeFormDialog: function () {
this.clearFormDialogCopilot()
this.formDialog.data = {
is_unique: false
}
},
sendFormDataCopilot: function () {
var self = this
if (self.formDialogCopilot.data.id) {
this.updateCopilot(
self.g.user.wallets[0].adminkey,
self.formDialogCopilot.data
)
} else {
console.log(self.g.user.wallets[0].adminkey)
console.log(self.formDialogCopilot.data)
this.createCopilot(
self.g.user.wallets[0].adminkey,
self.formDialogCopilot.data
)
}
},
createCopilot: function (wallet, data) {
var self = this
var updatedData = {}
for (const property in data) {
if (data[property]) {
updatedData[property] = data[property]
}
if (property == 'animation1threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation2threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation3threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
}
LNbits.api
.request('POST', '/copilot/api/v1/copilot', wallet, updatedData)
.then(function (response) {
self.CopilotLinks.push(mapCopilot(response.data))
self.formDialogCopilot.show = false
self.clearFormDialogCopilot()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getCopilots: function () {
var self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot',
this.g.user.wallets[0].inkey
)
.then(function (response) {
if (response.data) {
self.CopilotLinks = response.data.map(mapCopilot)
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getCopilot: function (copilot_id) {
var self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/' + copilot_id,
this.g.user.wallets[0].inkey
)
.then(function (response) {
localStorage.setItem('copilot', JSON.stringify(response.data))
localStorage.setItem('inkey', self.g.user.wallets[0].inkey)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
openCopilotCompose: function (copilot_id) {
this.getCopilot(copilot_id)
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
open('../copilot/cp/', '_blank', params)
},
openCopilotPanel: function (copilot_id) {
this.getCopilot(copilot_id)
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=300,height=450,left=10,top=400'
open('../copilot/pn/', '_blank', params)
},
deleteCopilotLink: function (copilotId) {
var self = this
var link = _.findWhere(this.CopilotLinks, {id: copilotId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/copilot/api/v1/copilot/' + copilotId,
self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
return obj.id === copilotId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
openUpdateCopilotLink: function (copilotId) {
var self = this
var copilot = _.findWhere(this.CopilotLinks, {id: copilotId})
self.formDialogCopilot.data = _.clone(copilot._data)
self.formDialogCopilot.show = true
},
updateCopilot: function (wallet, data) {
var self = this
var updatedData = {}
for (const property in data) {
if (data[property]) {
updatedData[property] = data[property]
}
if (property == 'animation1threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation2threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation3threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
}
LNbits.api
.request(
'PUT',
'/copilot/api/v1/copilot/' + updatedData.id,
wallet,
updatedData
)
.then(function (response) {
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
return obj.id === updatedData.id
})
self.CopilotLinks.push(mapCopilot(response.data))
self.formDialogCopilot.show = false
self.clearFormDialogCopilot()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
clearFormDialogCopilot() {
this.formDialogCopilot.data = {
lnurl_toggle: false,
show_message: false,
show_ack: false,
show_price: 'None',
title: ''
}
},
exportcopilotCSV: function () {
var self = this
LNbits.utils.exportCSV(self.CopilotsTable.columns, this.CopilotLinks)
}
},
created: function () {
var self = this
var getCopilots = this.getCopilots
getCopilots()
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,156 @@
{% extends "public.html" %} {% block page %}
<div class="q-pa-sm" style="width: 240px; margin: 10px auto">
<q-card class="my-card">
<div class="column">
<div class="col">
<center>
<q-btn
flat
round
dense
@click="openCompose"
icon="face"
style="font-size: 60px"
></q-btn>
</center>
</div>
<center>
<div class="col" style="margin: 15px; font-size: 22px">
Title: {% raw %} {{ copilot.title }} {% endraw %}
</div>
</center>
<q-separator></q-separator>
<div class="col">
<div class="row">
<div class="col">
<q-btn
class="q-mt-sm q-ml-sm"
color="primary"
@click="fullscreenToggle"
label="Screen share"
size="sm"
>
</q-btn>
</div>
</div>
<div class="row q-pa-sm">
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('rocket')"
label="rocket"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('confetti')"
label="confetti"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('face')"
label="face"
size="sm"
/>
</div>
</div>
<div class="row q-pa-sm">
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('rick')"
label="rick"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('martijn')"
label="martijn"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('bitcoin')"
label="bitcoin"
size="sm"
/>
</div>
</div>
</div>
</div>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
fullscreen_cam: true,
textareaModel: '',
iframe: '',
copilot: {}
}
},
methods: {
iframeChange: function (url) {
this.connection.send(String(url))
},
fullscreenToggle: function () {
self = this
self.animationBTN(String(this.fullscreen_cam))
if (this.fullscreen_cam) {
this.fullscreen_cam = false
} else {
this.fullscreen_cam = true
}
},
openCompose: function () {
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
open('../cp/', 'test', params)
},
animationBTN: function (name) {
self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/ws/' + self.copilot.id + '/none/' + name
)
.then(function (response1) {
self.$q.notify({
color: 'green',
message: 'Sent!'
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
created: function () {
self = this
self.copilot = JSON.parse(localStorage.getItem('copilot'))
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,80 @@
from typing import List
from fastapi import Request, WebSocket, WebSocketDisconnect
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse # type: ignore
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import copilot_ext, copilot_renderer
from .crud import get_copilot
templates = Jinja2Templates(directory="templates")
@copilot_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return copilot_renderer().TemplateResponse(
"copilot/index.html", {"request": request, "user": user.dict()}
)
@copilot_ext.get("/cp/", response_class=HTMLResponse)
async def compose(request: Request):
return copilot_renderer().TemplateResponse(
"copilot/compose.html", {"request": request}
)
@copilot_ext.get("/pn/", response_class=HTMLResponse)
async def panel(request: Request):
return copilot_renderer().TemplateResponse(
"copilot/panel.html", {"request": request}
)
##################WEBSOCKET ROUTES########################
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, copilot_id: str):
await websocket.accept()
websocket.id = copilot_id
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, copilot_id: str):
for connection in self.active_connections:
if connection.id == copilot_id:
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@copilot_ext.websocket("/copilot/ws/{copilot_id}", name="copilot.websocket_by_id")
async def websocket_endpoint(websocket: WebSocket, copilot_id: str):
await manager.connect(websocket, copilot_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(copilot_id, data, comment):
copilot = await get_copilot(copilot_id)
if not copilot:
return
await manager.send_personal_message(f"{data + '-' + comment}", copilot_id)

View file

@ -0,0 +1,97 @@
from http import HTTPStatus
from fastapi import Request
from fastapi.param_functions import Query
from fastapi.params import Depends
from starlette.exceptions import HTTPException
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import copilot_ext
from .crud import (
create_copilot,
delete_copilot,
get_copilot,
get_copilots,
update_copilot,
)
from .models import CreateCopilotData
from .views import updater
#######################COPILOT##########################
@copilot_ext.get("/api/v1/copilot")
async def api_copilots_retrieve(
req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_user = wallet.wallet.user
copilots = [copilot.dict() for copilot in await get_copilots(wallet_user)]
try:
return copilots
except:
raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No copilots")
@copilot_ext.get("/api/v1/copilot/{copilot_id}")
async def api_copilot_retrieve(
req: Request,
copilot_id: str = Query(None),
wallet: WalletTypeInfo = Depends(get_key_type),
):
copilot = await get_copilot(copilot_id)
if not copilot:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
)
if not copilot.lnurl_toggle:
return copilot.dict()
return {**copilot.dict(), **{"lnurl": copilot.lnurl(req)}}
@copilot_ext.post("/api/v1/copilot")
@copilot_ext.put("/api/v1/copilot/{juke_id}")
async def api_copilot_create_or_update(
data: CreateCopilotData,
copilot_id: str = Query(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
data.user = wallet.wallet.user
data.wallet = wallet.wallet.id
if copilot_id:
copilot = await update_copilot(data, copilot_id=copilot_id)
else:
copilot = await create_copilot(data, inkey=wallet.wallet.inkey)
return copilot
@copilot_ext.delete("/api/v1/copilot/{copilot_id}")
async def api_copilot_delete(
copilot_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
):
copilot = await get_copilot(copilot_id)
if not copilot:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
)
await delete_copilot(copilot_id)
return "", HTTPStatus.NO_CONTENT
@copilot_ext.get("/api/v1/copilot/ws/{copilot_id}/{comment}/{data}")
async def api_copilot_ws_relay(
copilot_id: str = Query(None), comment: str = Query(None), data: str = Query(None)
):
copilot = await get_copilot(copilot_id)
if not copilot:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
)
try:
await updater(copilot_id, data, comment)
except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
return ""

View file

@ -1,10 +0,0 @@
<h1>Diagon Alley</h1>
<h2>A movable market stand</h2>
Make a list of products to sell, point the list to an indexer (or many), stack sats.
Diagon Alley is a movable market stand, for anon transactions. You then give permission for an indexer to list those products. Delivery addresses are sent through the Lightning Network.
<img src="https://i.imgur.com/P1tvBSG.png">
<h2>API endpoints</h2>
<code>curl -X GET http://YOUR-TOR-ADDRESS</code>

View file

@ -1,10 +0,0 @@
from quart import Blueprint
diagonalley_ext: Blueprint = Blueprint(
"diagonalley", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa

View file

@ -1,6 +0,0 @@
{
"name": "Diagon Alley",
"short_description": "Movable anonymous market stand",
"icon": "add_shopping_cart",
"contributors": ["benarc"]
}

View file

@ -1,295 +0,0 @@
from base64 import urlsafe_b64encode
from uuid import uuid4
from typing import List, Optional, Union
import httpx
from lnbits.db import open_ext_db
from lnbits.settings import WALLET
from .models import Products, Orders, Indexers
import re
regex = re.compile(
r"^(?:http|ftp)s?://" # http:// or https://
r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|"
r"localhost|"
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
r"(?::\d+)?"
r"(?:/?|[/?]\S+)$",
re.IGNORECASE,
)
###Products
def create_diagonalleys_product(
*,
wallet_id: str,
product: str,
categories: str,
description: str,
image: str,
price: int,
quantity: int,
) -> Products:
with open_ext_db("diagonalley") as db:
product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute(
"""
INSERT INTO products (id, wallet, product, categories, description, image, price, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
product_id,
wallet_id,
product,
categories,
description,
image,
price,
quantity,
),
)
return get_diagonalleys_product(product_id)
def update_diagonalleys_product(product_id: str, **kwargs) -> Optional[Indexers]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("diagonalley") as db:
db.execute(
f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id)
)
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
return get_diagonalleys_indexer(product_id)
def get_diagonalleys_product(product_id: str) -> Optional[Products]:
with open_ext_db("diagonalley") as db:
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
return Products(**row) if row else None
def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Products]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(
f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Products(**row) for row in rows]
def delete_diagonalleys_product(product_id: str) -> None:
with open_ext_db("diagonalley") as db:
db.execute("DELETE FROM products WHERE id = ?", (product_id,))
###Indexers
def create_diagonalleys_indexer(
wallet_id: str,
shopname: str,
indexeraddress: str,
shippingzone1: str,
shippingzone2: str,
zone1cost: int,
zone2cost: int,
email: str,
) -> Indexers:
with open_ext_db("diagonalley") as db:
indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute(
"""
INSERT INTO indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
indexer_id,
wallet_id,
shopname,
indexeraddress,
False,
0,
shippingzone1,
shippingzone2,
zone1cost,
zone2cost,
email,
),
)
return get_diagonalleys_indexer(indexer_id)
def update_diagonalleys_indexer(indexer_id: str, **kwargs) -> Optional[Indexers]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("diagonalley") as db:
db.execute(
f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id)
)
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
return get_diagonalleys_indexer(indexer_id)
def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
with open_ext_db("diagonalley") as db:
roww = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
try:
x = httpx.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
if x.status_code == 200:
print(x)
print("poo")
with open_ext_db("diagonalley") as db:
db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
True,
indexer_id,
),
)
else:
with open_ext_db("diagonalley") as db:
db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
False,
indexer_id,
),
)
except:
print("An exception occurred")
with open_ext_db("diagonalley") as db:
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
return Indexers(**row) if row else None
def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexers]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
)
for r in rows:
try:
x = httpx.get(r["indexeraddress"] + "/" + r["ratingkey"])
if x.status_code == 200:
with open_ext_db("diagonalley") as db:
db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
True,
r["id"],
),
)
else:
with open_ext_db("diagonalley") as db:
db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
False,
r["id"],
),
)
except:
print("An exception occurred")
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Indexers(**row) for row in rows]
def delete_diagonalleys_indexer(indexer_id: str) -> None:
with open_ext_db("diagonalley") as db:
db.execute("DELETE FROM indexers WHERE id = ?", (indexer_id,))
###Orders
def create_diagonalleys_order(
*,
productid: str,
wallet: str,
product: str,
quantity: int,
shippingzone: str,
address: str,
email: str,
invoiceid: str,
paid: bool,
shipped: bool,
) -> Indexers:
with open_ext_db("diagonalley") as db:
order_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute(
"""
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
order_id,
productid,
wallet,
product,
quantity,
shippingzone,
address,
email,
invoiceid,
False,
False,
),
)
return get_diagonalleys_order(order_id)
def get_diagonalleys_order(order_id: str) -> Optional[Orders]:
with open_ext_db("diagonalley") as db:
row = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
return Orders(**row) if row else None
def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
)
for r in rows:
PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid
if PAID:
with open_ext_db("diagonalley") as db:
db.execute(
"UPDATE orders SET paid = ? WHERE id = ?",
(
True,
r["id"],
),
)
rows = db.fetchall(
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Orders(**row) for row in rows]
def delete_diagonalleys_order(order_id: str) -> None:
with open_ext_db("diagonalley") as db:
db.execute("DELETE FROM orders WHERE id = ?", (order_id,))

View file

@ -1,60 +0,0 @@
async def m001_initial(db):
"""
Initial products table.
"""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
product TEXT NOT NULL,
categories TEXT NOT NULL,
description TEXT NOT NULL,
image TEXT NOT NULL,
price INTEGER NOT NULL,
quantity INTEGER NOT NULL
);
"""
)
"""
Initial indexers table.
"""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS indexers (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
shopname TEXT NOT NULL,
indexeraddress TEXT NOT NULL,
online BOOLEAN NOT NULL,
rating INTEGER NOT NULL,
shippingzone1 TEXT NOT NULL,
shippingzone2 TEXT NOT NULL,
zone1cost INTEGER NOT NULL,
zone2cost INTEGER NOT NULL,
email TEXT NOT NULL
);
"""
)
"""
Initial orders table.
"""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
productid TEXT NOT NULL,
wallet TEXT NOT NULL,
product TEXT NOT NULL,
quantity INTEGER NOT NULL,
shippingzone INTEGER NOT NULL,
address TEXT NOT NULL,
email TEXT NOT NULL,
invoiceid TEXT NOT NULL,
paid BOOLEAN NOT NULL,
shipped BOOLEAN NOT NULL
);
"""
)

View file

@ -1,40 +0,0 @@
from typing import NamedTuple
class Indexers(NamedTuple):
id: str
wallet: str
shopname: str
indexeraddress: str
online: bool
rating: str
shippingzone1: str
shippingzone2: str
zone1cost: int
zone2cost: int
email: str
class Products(NamedTuple):
id: str
wallet: str
product: str
categories: str
description: str
image: str
price: int
quantity: int
class Orders(NamedTuple):
id: str
productid: str
wallet: str
product: str
quantity: int
shippingzone: int
address: str
email: str
invoiceid: str
paid: bool
shipped: bool

Some files were not shown because too many files have changed in this diff Show more