Compare commits

...
128 Commits
Author SHA1 Message Date
fiatjaf 510ccbc007 update mypy. 2021-03-28 02:02:21 -03:00
fiatjaf 08d124c62a nicer print checking status. 2021-03-28 01:34:37 -03:00
fiatjaf e112258c39 improve checking routine.
check pending invoices only once on startup,
then check outgoing payments every 30 minutes,
and delete outgoing payments that return False (meaning they have failed).

also fix a bug on sparko.
2021-03-28 00:11:45 -03:00
fiatjaf b2efd71d3c allow extensions to hide themselves. 2021-03-27 22:49:53 -03:00
fiatjaf 61c8d70fe7 fix: account for withdraw link not found on two places. 2021-03-27 21:26:56 -03:00
fiatjaf 3215b5d2bb catch some errors on spark. 2021-03-27 21:24:08 -03:00
fiatjaf 574358a118 fix: lnurlpay comment was being parsed as number. 2021-03-26 21:10:15 -03:00
fiatjaf 85011d23c3 fix sqlite database locked issues by using an async lock on the database and requiring explicit transaction control (or each command will be its own transaction). 2021-03-26 19:10:30 -03:00
fiatjaf 9cc7052920 actually wait 2 hours between each checkpending routine. 2021-03-24 10:15:00 -03:00
fiatjaf 6164b9d27e exit the app when we cannot connect to a backend. 2021-03-24 09:49:43 -03:00
fiatjaf a787d60d93 be extra safe when failing a payment if pay_invoice throws on sparko. 2021-03-24 02:02:54 -03:00
fiatjaf aabe3364f4 only check pending from the last 15 days. 2021-03-24 01:41:19 -03:00
fiatjaf d4d069fc77 make all methods from all wallets async. 2021-03-24 01:01:09 -03:00
fiatjaf 42bd5ea989 remove exception to black line-length and reformat. 2021-03-24 00:40:32 -03:00
Ben Arc 3333f1f3f3 Bug in withdraw hash checker 2021-03-23 11:40:03 +00:00
fiatjaf e6ea77e682 catch exception on invalid hash passed to sparko. 2021-03-21 18:12:26 -03:00
fiatjaf 8e62d9287d actually repeat the check once every 5 minutes just to be sure.
also because the stream doesn't cover outgoing payments.
2021-03-21 17:59:59 -03:00
fiatjaf 7e4a42e7ff stop doing the check_invoices thing on each call. do it once on lnbits starup and then rely on the invoices listener. 2021-03-21 17:57:33 -03:00
fiatjaf f27c2ebc21 marginally improve the checkpending situation. 2021-03-21 17:11:00 -03:00
ArcandGitHub e4fb18a53a Merge pull request #164 from lnbits/lnurlwhashcheck
Changed api to check lnurl exists and checking hash sent
2021-03-20 01:08:59 +00:00
Ben Arc fa383ced1c Changed api to check lnurl exists and checking hash sent 2021-03-20 01:06:08 +00:00
ArcandGitHub ddb277ff92 Merge pull request #162 from lnbits/lnurlwhashcheck
Adds a hash store endpoint, so captchas can prevent amilking
2021-03-17 20:02:24 +00:00
benarc 1e9151cedc ran prettier on captcha 2021-03-17 19:59:00 +00:00
benarc 20ef9f92a1 Ran black on bleskomat 2021-03-17 19:54:17 +00:00
benarc c86bd74506 edited api 2021-03-17 19:32:37 +00:00
benarc ad545e7fe1 prettier/black 2021-03-17 19:27:52 +00:00
benarc 66cde0154b api docs 2021-03-17 18:02:52 +00:00
benarc 422298948c api doc 2021-03-17 17:58:03 +00:00
benarc 09f89d07dd API docs 2021-03-17 17:57:12 +00:00
benarc f42dab5e30 Added api docs 2021-03-17 17:55:00 +00:00
benarc 86744ebced Bug 2021-03-17 16:56:23 +00:00
benarc 514329045f Should work 2021-03-17 16:33:12 +00:00
benarc 7cd3487bc9 Added hash check to db 2021-03-17 15:38:32 +00:00
fiatjaf 1a460a2ce5 fix issue #159 2021-03-17 09:03:07 -03:00
benarc b5c4fe905f Extension summary must be small 2021-03-16 13:38:42 +00:00
fiatjaf d511e92a04 update requirements.txt 2021-03-14 22:14:52 -03:00
fiatjafandGitHub d69c5aa2b1 Merge pull request #152 from lnbits/LNURLimg 2021-03-14 22:02:43 -03:00
fiatjafandGitHub b5ea8e2546 Merge pull request #136 from pseudozach/master 2021-03-14 21:59:33 -03:00
fiatjafandGitHub 8df4dd702d Merge pull request #154 from lnbits/offlineshop 2021-03-14 21:55:52 -03:00
fiatjaf 1bc59974a8 also support all currencies in lnurlp. 2021-03-14 21:50:05 -03:00
fiatjaf 0bc66ea150 support all the currencies. 2021-03-14 20:58:49 -03:00
fiatjaf adc3e62573 abstract exchange rates code into a "util". 2021-03-14 20:43:39 -03:00
fiatjaf 5142f1e29f reduce image quality even more. 2021-03-14 16:47:01 -03:00
fiatjaf d13ca2afdb round USD rate to satoshis. 2021-03-14 14:34:47 -03:00
fiatjaf a653a5327b support totp confirmation method. 2021-03-14 14:30:08 -03:00
fiatjaf d9b9d1e9b2 more info on confirmation code screen. 2021-03-14 13:01:19 -03:00
fiatjaf 4c8ce1bfed prettier changes its rules everyday. 2021-03-07 19:20:39 -03:00
fiatjaf 1630a28da0 prettier and black stuff. 2021-03-07 19:18:02 -03:00
fiatjafandGitHub 80e7c2c50f Merge pull request #146 from chill117/bleskomat-extension
Bleskomat extension for lnbits
2021-03-07 19:10:31 -03:00
fiatjaf c7717a611a UI to change the wordlist. 2021-03-07 17:03:01 -03:00
fiatjaf 773103f893 instructions and API docs. 2021-03-07 16:39:34 -03:00
fiatjaf 63ae553565 QR codes, printing, success-action and other fixes. 2021-03-07 16:13:20 -03:00
fiatjaf cda0819f64 improve and finish basic UI. 2021-03-07 14:41:47 -03:00
fiatjaf 732d06c1e5 basic offlineshop functionality. 2021-03-07 00:08:36 -03:00
benarc f571f5f840 Added some tooltips 2021-03-03 14:22:39 +00:00
benarc d0039ca71c black format 2021-03-03 13:59:31 +00:00
benarc 33a90a8de3 prettier 2021-03-03 13:51:31 +00:00
benarc 1f4218d5c2 Changed "LNURL voucher" to the LNURL title 2021-03-03 13:42:36 +00:00
benarc 4fee785229 Changed "LNURL voucher" to the LNURL title 2021-03-03 13:36:26 +00:00
benarc 97e519d569 Deleted some prints 2021-02-26 18:52:45 +00:00
benarc 3bfca5b7b7 Image loading as SVG 2021-02-26 18:23:17 +00:00
benarc 13f440d517 added pypng 2021-02-26 17:54:50 +00:00
benarc 2d4e9202f1 return img 2021-02-26 17:48:51 +00:00
benarc d913ab1306 link broken 2021-02-26 17:44:32 +00:00
benarc 0ed281e0fb bug 2021-02-26 17:42:26 +00:00
benarc c03c463171 Added LNURLw as image view 2021-02-26 16:54:57 +00:00
Luke Childsandfiatjaf 32a3dbb772 python3 => python 2021-02-19 13:36:41 -03:00
Luke Childsandfiatjaf 02a8b86168 Run Dockerfile as non-root user 2021-02-19 13:36:41 -03:00
benarc 042052e4e5 Small readme change 2021-02-18 23:51:04 +00:00
benarc 8cefb2f8fe Small README update 2021-02-18 23:40:50 +00:00
ArcandGitHub bd46c0be91 Merge pull request #149 from louneskmt/docker-image
Improved Dockerfile and auto build lnbits image on commit
2021-02-18 23:20:43 +00:00
Lounès Ksouri 0ba26975d4 Remove workflow execution on push to docker-image branch 2021-02-18 22:38:05 +01:00
Lounès Ksouri 4500078773 Add new Dockerfile 2021-02-18 22:20:55 +01:00
Lounès Ksouri c6437f3fbc Add GitHub Action to build image on push 2021-02-18 22:20:44 +01:00
Charles Hill dbe028e1eb Bleskomat extension for lnbits 2021-02-15 12:53:02 +01:00
fiatjafandfiatjaf ec193ca697 put export-with-qr-code on a toggleable section. 2021-02-13 21:45:22 -03:00
fiatjafandfiatjaf ea51af90d0 remove "Renew Keys" button.
it wasn't doing anything.
closes https://github.com/lnbits/lnbits/issues/147
2021-02-13 21:45:22 -03:00
Pacandfiatjaf 378e408dca Update wallet.html 2021-02-13 21:45:22 -03:00
Pacandfiatjaf e251291076 Display QRcode for easy wallet export
This adds a QRcode of the "url_root/wallet?usr=#####&wal=####" so that
the user can create a wallet on desktop and easily use it on his phone.
Also, in a use case where the <User Manager> extension is used, the
manager can create a wallet and show the QRcode for the user to take it
(e.g. in a hotel front desk the clerk creates it and the user takes it
in his phone browser, to pay for laundry, massage, soda machine...)
2021-02-13 21:45:22 -03:00
fiatjaf 88eb8e0e78 fix curl examples in extensions, path was duplicating the plugin names. 2021-02-07 08:23:01 -03:00
fiatjaf 9c126696fc delete pending payments after WALLET.pay_invoice fails.
fixes https://github.com/lnbits/lnbits/issues/138
2021-01-05 22:29:02 -03:00
fiatjaf 9dd040186d fix fee on lntxbot wallets. 2021-01-05 19:50:06 -03:00
fiatjaf 45148d285e Revert "Fixed fee deduction "
This reverts commit 14b6e21a02.
2021-01-05 19:49:27 -03:00
ArcandGitHub e96c92e6df Merge pull request #135 from grmkris/extensions/subdomains
subdomains extension
2021-01-05 22:35:06 +00:00
benarc 14b6e21a02 Fixed fee deduction 2021-01-05 21:54:58 +00:00
Kristjan 2a25932a85 refactored docs 2021-01-03 16:37:13 +01:00
Kristjan d811d69dac fixed bug on frontend where user couldn't updated information about a domain 2021-01-03 16:15:03 +01:00
Kristjan b6cb305e5c black formatting 2021-01-03 11:28:23 +01:00
Kristjan babaa4459e decided to remove IP and subdomain validations.. we run dry-run to cloudflare anyway so those 2 checks are reduntant 2021-01-03 11:27:45 +01:00
Kristjan f45aadaafe added checks to the cloudflare if the domain can be created; code refactoring - moved calls to cloudflare to seperate file 2021-01-03 11:22:42 +01:00
Kristjan f6010bc139 black formatting 2021-01-03 00:31:02 +01:00
Kristjan 33b48341f1 checks before creating new subdomain 2021-01-03 00:26:33 +01:00
Kristjan dc4a786dda black formatting 2021-01-02 22:56:36 +01:00
Kristjan 10414be0d2 now payments are finalized and dns registered when the event happens instead when user polls the backend 2021-01-02 22:55:52 +01:00
Kristjan e1abea77f5 moved code for generating new dns name and updating the invoice in database to the invoiceListener 2021-01-02 22:38:28 +01:00
Kristjan 8a7652c06f started working on payment listeners 2021-01-02 20:49:39 +01:00
Kristjan be0f853346 validation for the ip address and record type 2021-01-02 19:21:39 +01:00
Kristjan afa7fce1c4 prettier formatting for lnticket extension 2021-01-02 17:05:24 +01:00
Kristjan 94b3416300 prettier formatting 2021-01-02 16:58:03 +01:00
Kristjan c082c953f3 black formatting 2020-12-31 18:50:16 +01:00
Kristjan 128888be92 refactoring & bug fixes 2020-12-31 18:39:16 +01:00
pseudozach c5c5bdb287 payment hash added to captcha for server-side verification 2020-12-30 17:06:30 -08:00
pseudozach b91271315a captcha extension added 2020-12-30 15:50:08 -08:00
Kristjan bd4a668d23 added instructions and better formatting to the readme file 2020-12-30 12:20:37 +01:00
Kristjan 399a07b98a added basic information 2020-12-30 12:08:53 +01:00
Kristjan 493ab5464e integration with cloudflare, working prototype 2020-12-30 11:50:49 +01:00
Kristjan 6c4b5ea406 working subdomains frontend, table, popup, payments 2020-12-29 20:52:54 +01:00
Kristjan c0d7371137 subdomains public window 2020-12-29 19:16:04 +01:00
Kristjan e96ec08f44 code refactor 2020-12-28 23:24:47 +01:00
Kristjan 2eb44674d0 added public facing api 2020-12-28 22:40:46 +01:00
Kristjan 307a919d17 added CRUD operations 2020-12-28 22:32:04 +01:00
Kristjan 3c398a8276 started working on subdomains extension 2020-12-28 19:51:45 +01:00
fiatjafandfiatjaf 1c922a5ddc finish webhooks for normal invoices with two extra columns. 2020-12-24 09:39:46 -03:00
fiatjafandfiatjaf 4623220316 specify webhooks from invoice creation and call them. 2020-12-24 09:39:46 -03:00
benarc 503c981bc9 Updated example extension 2020-12-02 10:45:12 +00:00
benarc 31b1c0d1d5 Fixed tpos links not being fetched 2020-12-01 22:53:52 +00:00
benarc ae59c74c24 Added if webhook to stop 500 in webhook absence 2020-12-01 22:35:06 +00:00
benarc 52956a62a2 Added unique hash check to lnurlw 2020-11-30 22:27:53 +00:00
benarc ccb9d107d5 fixed unique update lnurlw 2020-11-30 22:19:17 +00:00
benarc edc533e902 Moved amount update to cb in lnurlw 2020-11-30 22:03:20 +00:00
benarc 9db327d5db changed 'comment' top 'content' in webhook 2020-11-30 21:12:00 +00:00
benarc 16fff9899f Added webhook for tickets 2020-11-30 14:59:21 +00:00
benarc 67e8c567a3 Fixed some lnticket bugs 2020-11-30 14:02:17 +00:00
fiatjaf 409f62dee7 dependencies update. 2020-11-24 16:25:32 -03:00
fiatjaf d4e718d29b fix migration. 2020-11-24 16:25:20 -03:00
fiatjaf d84915cb00 oops: formatting. 2020-11-21 23:23:11 -03:00
fiatjaf 03706dcbad withdraw: fix incorrect setting of used+1.
fixes https://github.com/lnbits/lnbits/issues/123
2020-11-21 23:20:27 -03:00
fiatjaf d3fc52cd49 migrate to sqlalchemy-aio.
a big refactor that:

- fixes some issues that might have happened (or not) with asynchronous
    reactions to payments;
- paves the way to https://github.com/lnbits/lnbits/issues/121;
- uses more async/await notation which just looks nice; and
- makes it simple(r?) for one extension to modify stuff from other extensions.
2020-11-21 23:02:14 -03:00
172 changed files with 9166 additions and 1976 deletions
+58
View File
@@ -0,0 +1,58 @@
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" ./
+44 -4
View File
@@ -1,8 +1,48 @@
FROM python:3.7-slim
# Build image
FROM python:3.7-slim as builder
# Setup virtualenv
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install build deps
RUN apt-get update
RUN apt-get install -y --no-install-recommends build-essential
# Install runtime deps
COPY requirements.txt /tmp/requirements.txt
RUN pip install -r /tmp/requirements.txt
# Install c-lightning specific deps
RUN pip install pylightning
# Install LND specific deps
RUN pip install lndgrpc purerpc
# Production image
FROM python:3.7-slim as lnbits
# Run as non-root
USER 1000:1000
# Copy over virtualenv
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 requirements.txt /app/
RUN pip install --no-cache-dir -q -r requirements.txt
COPY . /app
COPY --chown=1000:1000 lnbits /app/lnbits
EXPOSE 5000
CMD quart assets && quart migrate && hypercorn -k trio --bind $LNBITS_BIND 'lnbits.app:create_app()'
+5
View File
@@ -12,6 +12,8 @@ black: $(shell find lnbits -name "*.py")
mypy: $(shell find lnbits -name "*.py")
./venv/bin/mypy lnbits
./venv/bin/mypy lnbits/core
./venv/bin/mypy lnbits/extensions/*
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
@@ -19,5 +21,8 @@ checkprettier: $(shell find lnbits -name "*.js" -name ".html")
checkblack: $(shell find lnbits -name "*.py")
./venv/bin/black --check lnbits
Pipfile.lock: Pipfile
./venv/bin/pipenv lock
requirements.txt: Pipfile.lock
cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt
+5 -2
View File
@@ -21,12 +21,15 @@ secure = "*"
typing-extensions = "*"
httpx = "*"
quart-trio = "*"
trio = "*"
trio = "==0.16.0"
hypercorn = {extras = ["trio"], version = "*"}
sqlalchemy-aio = "*"
pyqrcode = "*"
pypng = "*"
[dev-packages]
black = "==20.8b1"
pytest = "*"
pytest-cov = "*"
mypy = "==0.761"
mypy = "latest"
pytest-trio = "*"
Generated
+431 -239
View File
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "193ef930ac0906127fd292b2fc7ba5b4d786227b6795d182dad9b57448fca75e"
"sha256": "f98f5cc03179f57291aeeca8e0e117ef4f38806176c9d2c0f984f501a5806338"
},
"pipfile-spec": 6,
"requires": {
@@ -18,10 +18,10 @@
"default": {
"aiofiles": {
"hashes": [
"sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb",
"sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af"
"sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27",
"sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092"
],
"version": "==0.5.0"
"version": "==0.6.0"
},
"async-generator": {
"hashes": [
@@ -33,11 +33,11 @@
},
"attrs": {
"hashes": [
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
"sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.2.0"
"version": "==20.3.0"
},
"bech32": {
"hashes": [
@@ -63,6 +63,7 @@
"brotli": {
"hashes": [
"sha256:160c78292e98d21e73a4cc7f76a234390e516afcd982fa17e1422f7c6a9ce9c8",
"sha256:16d528a45c2e1909c2798f27f7bf0a3feec1dc9e50948e738b961618e38b6a7b",
"sha256:1c48472a6ba3b113452355b9af0a60da5c2ae60477f8feda8346f8fd48e3e87c",
"sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70",
"sha256:269a5743a393c65db46a7bb982644c67ecba4b8d91b392403ad8a861ba6f495f",
@@ -71,13 +72,16 @@
"sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4",
"sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438",
"sha256:503fa6af7da9f4b5780bb7e4cbe0c639b010f12be85d02c99452825dd0feef3f",
"sha256:56d027eace784738457437df7331965473f2c0da2c70e1a1f6fdbae5402e0389",
"sha256:5913a1177fc36e30fcf6dc868ce23b0453952c78c04c266d3149b3d39e1410d6",
"sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26",
"sha256:5cb1e18167792d7d21e21365d7650b72d5081ed476123ff7b8cac7f45189c0c7",
"sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14",
"sha256:68715970f16b6e92c574c30747c95cf8cf62804569647386ff032195dc89a430",
"sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296",
"sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12",
"sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452",
"sha256:854c33dad5ba0fbd6ab69185fec8dab89e13cda6b7d191ba111987df74f38761",
"sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea",
"sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a",
"sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5",
@@ -86,6 +90,7 @@
"sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb",
"sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b",
"sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4",
"sha256:cfc391f4429ee0a9370aa93d812a52e1fee0f37a81861f4fdd1f4fb28e8547c3",
"sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7",
"sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1",
"sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1"
@@ -101,10 +106,10 @@
},
"certifi": {
"hashes": [
"sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
"sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
],
"version": "==2020.6.20"
"version": "==2020.12.5"
},
"click": {
"hashes": [
@@ -116,26 +121,76 @@
},
"ecdsa": {
"hashes": [
"sha256:494c6a853e9ed2e9be33d160b41d47afc50a6629b993d2b9c5ad7bb226add892",
"sha256:ca359c971594dceebf334f3d623dae43163ab161c7d09f28cae70a86df26eb7a"
"sha256:881fa5e12bb992972d3d1b3d4dfbe149ab76a89f13da02daa5ea1ec7dea6e747",
"sha256:cfc046a2ddd425adbd1a78b3c46f0d1325c657811c0f45ecc3a0a6236c1e50ff"
],
"index": "pypi",
"version": "==0.16.0"
"version": "==0.16.1"
},
"environs": {
"hashes": [
"sha256:a98005aab7613b6fe7a1af7192a5163f72a52d3348d3918e6c7a2a32e4012779",
"sha256:bf3fd6bc54fcfd7f512ddcb80a7781f0ced2b0c83dd123d619e9468ecdaaf537"
"sha256:2da44b7c30114415aa858577fa6396ee326fc76a0a60f0f15e8260ba554f19dc",
"sha256:3f6def554abb5455141b540e6e0b72fda3853404f2b0d31658aab1bf95410db3"
],
"index": "pypi",
"version": "==8.0.0"
"version": "==9.3.1"
},
"greenlet": {
"hashes": [
"sha256:0a77691f0080c9da8dfc81e23f4e3cffa5accf0f5b56478951016d7cfead9196",
"sha256:0ddd77586553e3daf439aa88b6642c5f252f7ef79a39271c25b1d4bf1b7cbb85",
"sha256:111cfd92d78f2af0bc7317452bd93a477128af6327332ebf3c2be7df99566683",
"sha256:122c63ba795fdba4fc19c744df6277d9cfd913ed53d1a286f12189a0265316dd",
"sha256:181300f826625b7fd1182205b830642926f52bd8cdb08b34574c9d5b2b1813f7",
"sha256:1a1ada42a1fd2607d232ae11a7b3195735edaa49ea787a6d9e6a53afaf6f3476",
"sha256:1bb80c71de788b36cefb0c3bb6bfab306ba75073dbde2829c858dc3ad70f867c",
"sha256:1d1d4473ecb1c1d31ce8fd8d91e4da1b1f64d425c1dc965edc4ed2a63cfa67b2",
"sha256:292e801fcb3a0b3a12d8c603c7cf340659ea27fd73c98683e75800d9fd8f704c",
"sha256:2c65320774a8cd5fdb6e117c13afa91c4707548282464a18cf80243cf976b3e6",
"sha256:4365eccd68e72564c776418c53ce3c5af402bc526fe0653722bc89efd85bf12d",
"sha256:5352c15c1d91d22902582e891f27728d8dac3bd5e0ee565b6a9f575355e6d92f",
"sha256:58ca0f078d1c135ecf1879d50711f925ee238fe773dfe44e206d7d126f5bc664",
"sha256:5d4030b04061fdf4cbc446008e238e44936d77a04b2b32f804688ad64197953c",
"sha256:5d69bbd9547d3bc49f8a545db7a0bd69f407badd2ff0f6e1a163680b5841d2b0",
"sha256:5f297cb343114b33a13755032ecf7109b07b9a0020e841d1c3cedff6602cc139",
"sha256:62afad6e5fd70f34d773ffcbb7c22657e1d46d7fd7c95a43361de979f0a45aef",
"sha256:647ba1df86d025f5a34043451d7c4a9f05f240bee06277a524daad11f997d1e7",
"sha256:719e169c79255816cdcf6dccd9ed2d089a72a9f6c42273aae12d55e8d35bdcf8",
"sha256:7cd5a237f241f2764324396e06298b5dee0df580cf06ef4ada0ff9bff851286c",
"sha256:875d4c60a6299f55df1c3bb870ebe6dcb7db28c165ab9ea6cdc5d5af36bb33ce",
"sha256:90b6a25841488cf2cb1c8623a53e6879573010a669455046df5f029d93db51b7",
"sha256:94620ed996a7632723a424bccb84b07e7b861ab7bb06a5aeb041c111dd723d36",
"sha256:b5f1b333015d53d4b381745f5de842f19fe59728b65f0fbb662dafbe2018c3a5",
"sha256:c5b22b31c947ad8b6964d4ed66776bcae986f73669ba50620162ba7c832a6b6a",
"sha256:c93d1a71c3fe222308939b2e516c07f35a849c5047f0197442a4d6fbcb4128ee",
"sha256:cdb90267650c1edb54459cdb51dab865f6c6594c3a47ebd441bc493360c7af70",
"sha256:cfd06e0f0cc8db2a854137bd79154b61ecd940dce96fad0cba23fe31de0b793c",
"sha256:d3789c1c394944084b5e57c192889985a9f23bd985f6d15728c745d380318128",
"sha256:da7d09ad0f24270b20f77d56934e196e982af0d0a2446120cb772be4e060e1a2",
"sha256:df3e83323268594fa9755480a442cabfe8d82b21aba815a71acf1bb6c1776218",
"sha256:df8053867c831b2643b2c489fe1d62049a98566b1646b194cc815f13e27b90df",
"sha256:e1128e022d8dce375362e063754e129750323b67454cac5600008aad9f54139e",
"sha256:e6e9fdaf6c90d02b95e6b0709aeb1aba5affbbb9ccaea5502f8638e4323206be",
"sha256:eac8803c9ad1817ce3d8d15d1bb82c2da3feda6bee1153eec5c58fa6e5d3f770",
"sha256:eb333b90036358a0e2c57373f72e7648d7207b76ef0bd00a4f7daad1f79f5203",
"sha256:ed1d1351f05e795a527abc04a0d82e9aecd3bdf9f46662c36ff47b0b00ecaf06",
"sha256:f3dc68272990849132d6698f7dc6df2ab62a88b0d36e54702a8fd16c0490e44f",
"sha256:f59eded163d9752fd49978e0bab7a1ff21b1b8d25c05f0995d140cc08ac83379",
"sha256:f5e2d36c86c7b03c94b8459c3bd2c9fe2c7dab4b258b8885617d44a22e453fb7",
"sha256:f6f65bf54215e4ebf6b01e4bb94c49180a589573df643735107056f7a910275b",
"sha256:f8450d5ef759dbe59f84f2c9f77491bb3d3c44bc1a573746daf086e70b14c243",
"sha256:f97d83049715fd9dec7911860ecf0e17b48d8725de01e45de07d8ac0bd5bc378"
],
"markers": "python_version >= '3'",
"version": "==1.0.0"
},
"h11": {
"hashes": [
"sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab",
"sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87"
"sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6",
"sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"
],
"version": "==0.11.0"
"markers": "python_version >= '3.6'",
"version": "==0.12.0"
},
"h2": {
"hashes": [
@@ -155,30 +210,30 @@
},
"httpcore": {
"hashes": [
"sha256:18c4afcbfe884b635e59739105aed1692e132bc5d31597109f3c1c97e4ec1cac",
"sha256:2526a38f31ac5967d38b7f593b5d8c4bd3fa82c21400402f866ba3312946acbf"
"sha256:37ae835fb370049b2030c3290e12ed298bf1473c41bb72ca4aa78681eba9b7c9",
"sha256:93e822cd16c32016b414b789aeff4e855d0ccbfc51df563ee34d4dbadbb3bcdc"
],
"markers": "python_version >= '3.6'",
"version": "==0.12.0"
"version": "==0.12.3"
},
"httpx": {
"hashes": [
"sha256:126424c279c842738805974687e0518a94c7ae8d140cd65b9c4f77ac46ffa537",
"sha256:9cffb8ba31fac6536f2c8cde30df859013f59e4bcc5b8d43901cb3654a8e0a5b"
"sha256:cc2a55188e4b25272d2bcd46379d300f632045de4377682aa98a8a6069d55967",
"sha256:d379653bd457e8257eb0df99cb94557e4aac441b7ba948e333be969298cac272"
],
"index": "pypi",
"version": "==0.16.1"
"version": "==0.17.1"
},
"hypercorn": {
"extras": [
"trio"
],
"hashes": [
"sha256:81c69dd84a87b8e8b3ebf06ef5dd92836a8238f0ac65ded3d86befb8ba9acfeb",
"sha256:e3f317d6d64d15ce589f49e4f5057947259fa35332d169e62cb060e9997189e4"
"sha256:5ba1e719c521080abd698ff5781a2331e34ef50fc1c89a50960538115a896a9a",
"sha256:8007c10f81566920f8ae12c0e26e146f94ca70506da964b5a727ad610aa1d821"
],
"index": "pypi",
"version": "==0.11.1"
"version": "==0.11.2"
},
"hyperframe": {
"hashes": [
@@ -190,10 +245,10 @@
},
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
"sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16",
"sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"
],
"version": "==2.10"
"version": "==3.1"
},
"itsdangerous": {
"hashes": [
@@ -205,11 +260,11 @@
},
"jinja2": {
"hashes": [
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
"sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419",
"sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.11.2"
"version": "==2.11.3"
},
"lnurl": {
"hashes": [
@@ -225,8 +280,12 @@
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
"sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f",
"sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
"sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014",
"sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
@@ -235,43 +294,58 @@
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
"sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85",
"sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
"sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850",
"sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0",
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
"sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb",
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
"sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1",
"sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2",
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7",
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8",
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
"sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5",
"sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c",
"sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032",
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be",
"sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.1"
},
"marshmallow": {
"hashes": [
"sha256:2272273505f1644580fbc66c6b220cc78f893eb31f1ecde2af98ad28011e9811",
"sha256:47911dd7c641a27160f0df5fd0fe94667160ffe97f70a42c3cc18388d86098cc"
"sha256:4ab2fdb7f36eb61c3665da67a7ce281c8900db08d72ba6bf0e695828253581f7",
"sha256:eca81d53aa4aafbc0e20566973d0d2e50ce8bf0ee15165bb799bec0df1e50177"
],
"markers": "python_version >= '3.5'",
"version": "==3.8.0"
"version": "==3.10.0"
},
"outcome": {
"hashes": [
"sha256:ee46c5ce42780cde85d55a61819d0e6b8cb490f1dbd749ba75ff2629771dcd2d",
"sha256:fc7822068ba7dd0fc2532743611e8a73246708d3564e29a39f93d6ab3701b66f"
"sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958",
"sha256:e862f01d4e626e63e8f92c38d1f8d5546d3f9cce989263c521b2e7990d186967"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.0.1"
"markers": "python_version >= '3.6'",
"version": "==1.1.0"
},
"priority": {
"hashes": [
@@ -282,26 +356,46 @@
},
"pydantic": {
"hashes": [
"sha256:1783c1d927f9e1366e0e0609ae324039b2479a1a282a98ed6a6836c9ed02002c",
"sha256:2dc946b07cf24bee4737ced0ae77e2ea6bc97489ba5a035b603bd1b40ad81f7e",
"sha256:2de562a456c4ecdc80cf1a8c3e70c666625f7d02d89a6174ecf63754c734592e",
"sha256:36dbf6f1be212ab37b5fda07667461a9219c956181aa5570a00edfb0acdfe4a1",
"sha256:3fa799f3cfff3e5f536cbd389368fc96a44bb30308f258c94ee76b73bd60531d",
"sha256:40d765fa2d31d5be8e29c1794657ad46f5ee583a565c83cea56630d3ae5878b9",
"sha256:418b84654b60e44c0cdd5384294b0e4bc1ebf42d6e873819424f3b78b8690614",
"sha256:4900b8820b687c9a3ed753684337979574df20e6ebe4227381d04b3c3c628f99",
"sha256:530d7222a2786a97bc59ee0e0ebbe23728f82974b1f1ad9a11cd966143410633",
"sha256:54122a8ed6b75fe1dd80797f8251ad2063ea348a03b77218d73ea9fe19bd4e73",
"sha256:6c3f162ba175678218629f446a947e3356415b6b09122dcb364e58c442c645a7",
"sha256:b49c86aecde15cde33835d5d6360e55f5e0067bb7143a8303bf03b872935c75b",
"sha256:b5b3489cb303d0f41ad4a7390cf606a5f2c7a94dcba20c051cd1c653694cb14d",
"sha256:cf3933c98cb5e808b62fae509f74f209730b180b1e3c3954ee3f7949e083a7df",
"sha256:eb75dc1809875d5738df14b6566ccf9fd9c0bcde4f36b72870f318f16b9f5c20",
"sha256:f769141ab0abfadf3305d4fcf36660e5cf568a666dd3efab7c3d4782f70946b1",
"sha256:f8af9b840a9074e08c0e6dc93101de84ba95df89b267bf7151d74c553d66833b"
"sha256:0c40162796fc8d0aa744875b60e4dc36834db9f2a25dbf9ba9664b1915a23850",
"sha256:20d42f1be7c7acc352b3d09b0cf505a9fab9deb93125061b376fbe1f06a5459f",
"sha256:2287ebff0018eec3cc69b1d09d4b7cebf277726fa1bd96b45806283c1d808683",
"sha256:258576f2d997ee4573469633592e8b99aa13bda182fcc28e875f866016c8e07e",
"sha256:26cf3cb2e68ec6c0cfcb6293e69fb3450c5fd1ace87f46b64f678b0d29eac4c3",
"sha256:2f2736d9a996b976cfdfe52455ad27462308c9d3d0ae21a2aa8b4cd1a78f47b9",
"sha256:3114d74329873af0a0e8004627f5389f3bb27f956b965ddd3e355fe984a1789c",
"sha256:3bbd023c981cbe26e6e21c8d2ce78485f85c2e77f7bab5ec15b7d2a1f491918f",
"sha256:3bcb9d7e1f9849a6bdbd027aabb3a06414abd6068cb3b21c49427956cce5038a",
"sha256:4bbc47cf7925c86a345d03b07086696ed916c7663cb76aa409edaa54546e53e2",
"sha256:6388ef4ef1435364c8cc9a8192238aed030595e873d8462447ccef2e17387125",
"sha256:830ef1a148012b640186bf4d9789a206c56071ff38f2460a32ae67ca21880eb8",
"sha256:8fbb677e4e89c8ab3d450df7b1d9caed23f254072e8597c33279460eeae59b99",
"sha256:c17a0b35c854049e67c68b48d55e026c84f35593c66d69b278b8b49e2484346f",
"sha256:dd4888b300769ecec194ca8f2699415f5f7760365ddbe243d4fd6581485fa5f0",
"sha256:dde4ca368e82791de97c2ec019681ffb437728090c0ff0c3852708cf923e0c7d",
"sha256:e3f8790c47ac42549dc8b045a67b0ca371c7f66e73040d0197ce6172b385e520",
"sha256:e8bc082afef97c5fd3903d05c6f7bb3a6af9fc18631b4cc9fedeb4720efb0c58",
"sha256:eb8ccf12295113ce0de38f80b25f736d62f0a8d87c6b88aca645f168f9c78771",
"sha256:fb77f7a7e111db1832ae3f8f44203691e15b1fa7e5a1cb9691d4e2659aee41c4",
"sha256:fbfb608febde1afd4743c6822c19060a8dbdd3eb30f98e36061ba4973308059e",
"sha256:fff29fe54ec419338c522b908154a2efabeee4f483e48990f87e189661f31ce3"
],
"markers": "python_version >= '3.6'",
"version": "==1.6.1"
"markers": "python_full_version >= '3.6.1'",
"version": "==1.8.1"
},
"pypng": {
"hashes": [
"sha256:1032833440c91bafee38a42c38c02d00431b24c42927feb3e63b104d8550170b"
],
"index": "pypi",
"version": "==0.0.20"
},
"pyqrcode": {
"hashes": [
"sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6",
"sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5"
],
"index": "pypi",
"version": "==1.2.1"
},
"pyscss": {
"hashes": [
@@ -312,18 +406,18 @@
},
"python-dotenv": {
"hashes": [
"sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d",
"sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"
"sha256:31d752f5b748f4e292448c9a0cac6a08ed5e6f4cefab85044462dcad56905cec",
"sha256:9fa413c37d4652d3fa02fea0ff465c384f5db75eab259c4fc5d0c5b8bf20edd4"
],
"version": "==0.14.0"
"version": "==0.16.0"
},
"quart": {
"hashes": [
"sha256:9c634e4c1e4b21b824003c676de1583581258c72b0ac4d2ba747db846e97ff56",
"sha256:d885d782edd9d5dcfd2c4a56e020db3b82493d4c3950f91c221b7d88d239ac93"
"sha256:429c5b4ff27e1d2f9ca0aacc38f6aba0ff49b38b815448bf24b613d3de12ea02",
"sha256:7b13786e07541cc9ce1466fdc6a6ccd5f36eb39118edd25a42d617593cd17707"
],
"index": "pypi",
"version": "==0.13.1"
"version": "==0.14.1"
},
"quart-compress": {
"hashes": [
@@ -335,19 +429,27 @@
},
"quart-cors": {
"hashes": [
"sha256:020a17d504264db86cada3c1335ef174af28b33f57cee321ddc46d69c33d5c8e",
"sha256:c08bdb326219b6c186d19ed6a97a7fd02de8fe36c7856af889494c69b525c53c"
"sha256:0ea23ea8db2c21835f6698b91a09d99ab59f98f8d90a2a739475ef0409591573",
"sha256:e526e9929934ad31301853efe357a3bd2e08c3282aff37184fa8671ed854f052"
],
"index": "pypi",
"version": "==0.3.0"
"version": "==0.4.0"
},
"quart-trio": {
"hashes": [
"sha256:00f3b20f8d82ce7e81ead61db4efba38ed7653c7e28199defded46b663ab2595",
"sha256:dafc8f0440d4b70fa60d24122a161d2373894d2bfa9f713d9f1df1fd508f0834"
"sha256:1e7fce0df41afc3038bf0431b20614f90984de50341b19f9d4d3b9ba1ac7574a",
"sha256:933e3c18e232ece30ccbac7579fdc5f62f2f9c79c3273d6c341f5a1686791eb1"
],
"index": "pypi",
"version": "==0.5.1"
"version": "==0.7.0"
},
"represent": {
"hashes": [
"sha256:026c0de2ee8385d1255b9c2426cd4f03fe9177ac94c09979bc601946c8493aa0",
"sha256:99142650756ef1998ce0661568f54a47dac8c638fb27e3816c02536575dbba8c"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.6.0.post0"
},
"rfc3986": {
"extras": [
@@ -385,33 +487,82 @@
},
"sniffio": {
"hashes": [
"sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5",
"sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"
"sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663",
"sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"
],
"markers": "python_version >= '3.5'",
"version": "==1.1.0"
"version": "==1.2.0"
},
"sortedcontainers": {
"hashes": [
"sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
"sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
"sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f",
"sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"
],
"version": "==2.2.2"
"version": "==2.3.0"
},
"sqlalchemy": {
"hashes": [
"sha256:0096305b3e0912c59d8308f55d17544b3e5c1787f5ad8ef9cd75084136bcba9c",
"sha256:106fd3da313390dffe6ca156e5b7244293d6f4bfd389bcb315771c7addb5f3b3",
"sha256:1b30b71ea7c0f854d1b31549816694d8c435c9b5cce44da140b473544bb48a6b",
"sha256:228fe0cc700748ccc7a9a430896a77dfaa8a1035874e540961589e31f31cabe1",
"sha256:22faab9884c46ea2c00d5457a6a23375e0b4ab5257b72a27c8b979d4f677d4cf",
"sha256:3280c283e85e5c7b95c7be75b2df765d4bb13a01be36552826557bb4177d2bdf",
"sha256:33a2b756bd8f7022c24a16228071dea39cf6f21f62732a5307b6ebcef084bf16",
"sha256:409f3cd35f99592d0ceb1ee2e13c24b3083109e0f80096aae36000e5988aa24a",
"sha256:41a6dc66714c7dddf210dc8652d19bb2a55364c21038ca77312500014271aa67",
"sha256:4aa4de9bd3ae5e46f7395aa769303722e7174795ae83dd78302d849fcbc7513d",
"sha256:52c2512914bdeb3ce7957e2597b6a9d4a3dd3b3177c32ccf481908d6e59384ae",
"sha256:5da7f97893631d060c4590e531784f5eaf64bb3e6002804ee8a96d9c91cd1885",
"sha256:655b35725f1478bb6f797336acc803ddbb4c693816b3663a6fd94ad454eb056a",
"sha256:755aed46915e20c0b317a4124251f31682dcc7a43984d771352f6863ea11cd9c",
"sha256:81920161039cca14dac30378713c472a0ac5e783b2077984d6f8ec6f2d824356",
"sha256:83e65d8826bc649f8af556588555b744d4b9cfc0fcda8f3ddd08fe43c656e459",
"sha256:8761759028eb7754b76ca153e613bdea0fb6f8107557e57c60616a7212e2a297",
"sha256:8cf28097524b7fff3526df9154abcdbed0c4e434d4c4e6787e3d4fc33e7deb6a",
"sha256:92cca0c8757ac9a8a53cc800ea0f04a4f6c346376bc4cb878e4a6aed6f19d18d",
"sha256:96e68231f7115f5acb1bb51ccec26351bc155fedf835d7625fa203a43c8a3762",
"sha256:9852a7b4feee4c7de4b7541fa8a72ab36a5dad7942c58006e76ffe59c0f8efec",
"sha256:a719b80b41a900bbcec3cc248616394ebd134043ce5e62185270d785d8a184d3",
"sha256:a91fa4189f66af9644fde50740c5134689dc01c6c5edf04af6eafa3225ae110c",
"sha256:aa529647a3770293f392dff40466344a5d142fe66a2bbec465247a05d695eced",
"sha256:ad2d9fc0ffba476cf069cea558527bc23e1ced24ec6c8badab8aa63cbde56b07",
"sha256:b445fe8f043288178bc7d4adda49a505de86641864d50493d3fad10e0711cbff",
"sha256:b529f285b04094d458e811147a320019397909265eef1d1aa9dc6ecac0ad240c",
"sha256:bce476fd66aeaeb1a155f97838233d95fccd2c611da4d6b1cb4b6205435e5326",
"sha256:be1df71f9a06730b2a7213b68d6c465130a82305789462e375cf87037b181af3",
"sha256:c063277efd89c7f755480ff80f87828c9a68afb0fdc6d79462b9e474301fded3",
"sha256:d157a87dcd861eae04cd9b19cac535451719397fcae7b6f870688d8fb69d84f9",
"sha256:d34afc46b9fe3025b8db7ade6876bf80668918c5cdcdae067aaec348b5daa821",
"sha256:e337983564e09857a7a687dfa7adfaf85f59ed9e885d30081e13aea792d6abf7",
"sha256:e8b24bb68e981b6a2a8845d6d0f85891564d38562fc338170338ef90a221241e"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==1.4.3"
},
"sqlalchemy-aio": {
"hashes": [
"sha256:7f77366f55d34891c87386dd0962a28b948b684e8ea5edb7daae4187c0b291bf",
"sha256:f767320427c22c66fa5840a1f17f3261110a8ddc8560558f4fbf12d31a66b17b"
],
"index": "pypi",
"version": "==0.16.0"
},
"toml": {
"hashes": [
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
],
"version": "==0.10.1"
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.2"
},
"trio": {
"hashes": [
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
"sha256:451ddb27b4e5215e00646fcbb8028d341fccf284e053dc376506a14bb133dbcf",
"sha256:df067dd0560c321af39d412cd81fc3a7d13f55af9150527daab980683e9fcf3c"
],
"index": "pypi",
"version": "==0.17.0"
"version": "==0.16.0"
},
"typing-extensions": {
"hashes": [
@@ -432,11 +583,11 @@
},
"wsproto": {
"hashes": [
"sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d",
"sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d"
"sha256:868776f8456997ad0d9720f7322b746bbe9193751b5b290b7f924659377c8c38",
"sha256:d8345d1808dd599b5ffb352c25a367adb6157e664e140dbecba3f9bc007edb9f"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==0.15.0"
"version": "==1.0.0"
}
},
"develop": {
@@ -457,11 +608,11 @@
},
"attrs": {
"hashes": [
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
"sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.2.0"
"version": "==20.3.0"
},
"black": {
"hashes": [
@@ -480,77 +631,103 @@
},
"coverage": {
"hashes": [
"sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
"sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
"sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
"sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
"sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
"sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
"sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
"sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
"sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
"sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
"sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
"sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
"sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
"sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
"sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
"sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
"sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
"sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
"sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
"sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
"sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
"sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
"sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
"sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
"sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
"sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
"sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
"sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
"sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
"sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
"sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
"sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
"sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
"sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
"sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c",
"sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6",
"sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45",
"sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a",
"sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03",
"sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529",
"sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a",
"sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a",
"sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2",
"sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6",
"sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759",
"sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53",
"sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a",
"sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4",
"sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff",
"sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502",
"sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793",
"sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb",
"sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905",
"sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821",
"sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b",
"sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81",
"sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0",
"sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b",
"sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3",
"sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184",
"sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701",
"sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a",
"sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82",
"sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638",
"sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5",
"sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083",
"sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6",
"sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90",
"sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465",
"sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a",
"sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3",
"sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e",
"sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066",
"sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf",
"sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b",
"sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae",
"sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669",
"sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873",
"sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b",
"sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6",
"sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb",
"sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160",
"sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c",
"sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079",
"sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d",
"sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==5.3"
"version": "==5.5"
},
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
"sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16",
"sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"
],
"version": "==2.10"
"version": "==3.1"
},
"iniconfig": {
"hashes": [
"sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
"sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"
"sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3",
"sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"
],
"version": "==1.0.1"
"version": "==1.1.1"
},
"mypy": {
"hashes": [
"sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
"sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7",
"sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2",
"sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474",
"sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0",
"sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217",
"sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749",
"sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6",
"sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf",
"sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36",
"sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b",
"sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72",
"sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1",
"sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"
"sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e",
"sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064",
"sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c",
"sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4",
"sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97",
"sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df",
"sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8",
"sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a",
"sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56",
"sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7",
"sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6",
"sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5",
"sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a",
"sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521",
"sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564",
"sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49",
"sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66",
"sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a",
"sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119",
"sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506",
"sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c",
"sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"
],
"index": "pypi",
"version": "==0.761"
"version": "==0.812"
},
"mypy-extensions": {
"hashes": [
@@ -561,26 +738,26 @@
},
"outcome": {
"hashes": [
"sha256:ee46c5ce42780cde85d55a61819d0e6b8cb490f1dbd749ba75ff2629771dcd2d",
"sha256:fc7822068ba7dd0fc2532743611e8a73246708d3564e29a39f93d6ab3701b66f"
"sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958",
"sha256:e862f01d4e626e63e8f92c38d1f8d5546d3f9cce989263c521b2e7990d186967"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.0.1"
"markers": "python_version >= '3.6'",
"version": "==1.1.0"
},
"packaging": {
"hashes": [
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
"sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
"sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
"sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.4"
"version": "==20.9"
},
"pathspec": {
"hashes": [
"sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
"sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
"sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd",
"sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"
],
"version": "==0.8.0"
"version": "==0.8.1"
},
"pluggy": {
"hashes": [
@@ -592,11 +769,11 @@
},
"py": {
"hashes": [
"sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
"sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
"sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3",
"sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.9.0"
"version": "==1.10.0"
},
"pyparsing": {
"hashes": [
@@ -608,123 +785,138 @@
},
"pytest": {
"hashes": [
"sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9",
"sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92"
"sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9",
"sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839"
],
"index": "pypi",
"version": "==6.1.1"
"version": "==6.2.2"
},
"pytest-cov": {
"hashes": [
"sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191",
"sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"
"sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7",
"sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"
],
"index": "pypi",
"version": "==2.10.1"
"version": "==2.11.1"
},
"pytest-trio": {
"hashes": [
"sha256:3f48cc1df66d279d705af38ad38d1639c2e2380ddffcdc3a45bb81758de61f03",
"sha256:9bf0a490fd177a33617e8709242293fae47934de2b51f8209eb2c0545b6ca8fe"
"sha256:c01b741819aec2c419555f28944e132d3c711dae1e673d63260809bf92c30c31"
],
"index": "pypi",
"version": "==0.6.0"
"version": "==0.7.0"
},
"regex": {
"hashes": [
"sha256:088afc8c63e7bd187a3c70a94b9e50ab3f17e1d3f52a32750b5b77dbe99ef5ef",
"sha256:1fe0a41437bbd06063aa184c34804efa886bcc128222e9916310c92cd54c3b4c",
"sha256:3d20024a70b97b4f9546696cbf2fd30bae5f42229fbddf8661261b1eaff0deb7",
"sha256:41bb65f54bba392643557e617316d0d899ed5b4946dccee1cb6696152b29844b",
"sha256:4318d56bccfe7d43e5addb272406ade7a2274da4b70eb15922a071c58ab0108c",
"sha256:4707f3695b34335afdfb09be3802c87fa0bc27030471dbc082f815f23688bc63",
"sha256:49f23ebd5ac073765ecbcf046edc10d63dcab2f4ae2bce160982cb30df0c0302",
"sha256:5533a959a1748a5c042a6da71fe9267a908e21eded7a4f373efd23a2cbdb0ecc",
"sha256:5d892a4f1c999834eaa3c32bc9e8b976c5825116cde553928c4c8e7e48ebda67",
"sha256:5f18875ac23d9aa2f060838e8b79093e8bb2313dbaaa9f54c6d8e52a5df097be",
"sha256:60b0e9e6dc45683e569ec37c55ac20c582973841927a85f2d8a7d20ee80216ab",
"sha256:816064fc915796ea1f26966163f6845de5af78923dfcecf6551e095f00983650",
"sha256:84cada8effefe9a9f53f9b0d2ba9b7b6f5edf8d2155f9fdbe34616e06ececf81",
"sha256:84e9407db1b2eb368b7ecc283121b5e592c9aaedbe8c78b1a2f1102eb2e21d19",
"sha256:8d69cef61fa50c8133382e61fd97439de1ae623fe943578e477e76a9d9471637",
"sha256:9a02d0ae31d35e1ec12a4ea4d4cca990800f66a917d0fb997b20fbc13f5321fc",
"sha256:9bc13e0d20b97ffb07821aa3e113f9998e84994fe4d159ffa3d3a9d1b805043b",
"sha256:a6f32aea4260dfe0e55dc9733ea162ea38f0ea86aa7d0f77b15beac5bf7b369d",
"sha256:ae91972f8ac958039920ef6e8769277c084971a142ce2b660691793ae44aae6b",
"sha256:c570f6fa14b9c4c8a4924aaad354652366577b4f98213cf76305067144f7b100",
"sha256:c9443124c67b1515e4fe0bb0aa18df640965e1030f468a2a5dc2589b26d130ad",
"sha256:d23a18037313714fb3bb5a94434d3151ee4300bae631894b1ac08111abeaa4a3",
"sha256:eaf548d117b6737df379fdd53bdde4f08870e66d7ea653e230477f071f861121",
"sha256:ebbe29186a3d9b0c591e71b7393f1ae08c83cb2d8e517d2a822b8f7ec99dfd8b",
"sha256:eda4771e0ace7f67f58bc5b560e27fb20f32a148cbc993b0c3835970935c2707",
"sha256:f1b3afc574a3db3b25c89161059d857bd4909a1269b0b3cb3c904677c8c4a3f7",
"sha256:f2388013e68e750eaa16ccbea62d4130180c26abb1d8e5d584b9baf69672b30f"
"sha256:07ef35301b4484bce843831e7039a84e19d8d33b3f8b2f9aab86c376813d0139",
"sha256:13f50969028e81765ed2a1c5fcfdc246c245cf8d47986d5172e82ab1a0c42ee5",
"sha256:14de88eda0976020528efc92d0a1f8830e2fb0de2ae6005a6fc4e062553031fa",
"sha256:159fac1a4731409c830d32913f13f68346d6b8e39650ed5d704a9ce2f9ef9cb3",
"sha256:18e25e0afe1cf0f62781a150c1454b2113785401ba285c745acf10c8ca8917df",
"sha256:201e2619a77b21a7780580ab7b5ce43835e242d3e20fef50f66a8df0542e437f",
"sha256:360a01b5fa2ad35b3113ae0c07fb544ad180603fa3b1f074f52d98c1096fa15e",
"sha256:39c44532d0e4f1639a89e52355b949573e1e2c5116106a395642cbbae0ff9bcd",
"sha256:3d9356add82cff75413bec360c1eca3e58db4a9f5dafa1f19650958a81e3249d",
"sha256:3d9a7e215e02bd7646a91fb8bcba30bc55fd42a719d6b35cf80e5bae31d9134e",
"sha256:4651f839dbde0816798e698626af6a2469eee6d9964824bb5386091255a1694f",
"sha256:486a5f8e11e1f5bbfcad87f7c7745eb14796642323e7e1829a331f87a713daaa",
"sha256:4b8a1fb724904139149a43e172850f35aa6ea97fb0545244dc0b805e0154ed68",
"sha256:4c0788010a93ace8a174d73e7c6c9d3e6e3b7ad99a453c8ee8c975ddd9965643",
"sha256:4c2e364491406b7888c2ad4428245fc56c327e34a5dfe58fd40df272b3c3dab3",
"sha256:575a832e09d237ae5fedb825a7a5bc6a116090dd57d6417d4f3b75121c73e3be",
"sha256:5770a51180d85ea468234bc7987f5597803a4c3d7463e7323322fe4a1b181578",
"sha256:633497504e2a485a70a3268d4fc403fe3063a50a50eed1039083e9471ad0101c",
"sha256:63f3ca8451e5ff7133ffbec9eda641aeab2001be1a01878990f6c87e3c44b9d5",
"sha256:709f65bb2fa9825f09892617d01246002097f8f9b6dde8d1bb4083cf554701ba",
"sha256:808404898e9a765e4058bf3d7607d0629000e0a14a6782ccbb089296b76fa8fe",
"sha256:882f53afe31ef0425b405a3f601c0009b44206ea7f55ee1c606aad3cc213a52c",
"sha256:8bd4f91f3fb1c9b1380d6894bd5b4a519409135bec14c0c80151e58394a4e88a",
"sha256:8e65e3e4c6feadf6770e2ad89ad3deb524bcb03d8dc679f381d0568c024e0deb",
"sha256:976a54d44fd043d958a69b18705a910a8376196c6b6ee5f2596ffc11bff4420d",
"sha256:a0d04128e005142260de3733591ddf476e4902c0c23c1af237d9acf3c96e1b38",
"sha256:a0df9a0ad2aad49ea3c7f65edd2ffb3d5c59589b85992a6006354f6fb109bb18",
"sha256:a2ee026f4156789df8644d23ef423e6194fad0bc53575534101bb1de5d67e8ce",
"sha256:a59a2ee329b3de764b21495d78c92ab00b4ea79acef0f7ae8c1067f773570afa",
"sha256:b97ec5d299c10d96617cc851b2e0f81ba5d9d6248413cd374ef7f3a8871ee4a6",
"sha256:b98bc9db003f1079caf07b610377ed1ac2e2c11acc2bea4892e28cc5b509d8d5",
"sha256:b9d8d286c53fe0cbc6d20bf3d583cabcd1499d89034524e3b94c93a5ab85ca90",
"sha256:bcd945175c29a672f13fce13a11893556cd440e37c1b643d6eeab1988c8b209c",
"sha256:c66221e947d7207457f8b6f42b12f613b09efa9669f65a587a2a71f6a0e4d106",
"sha256:c782da0e45aff131f0bed6e66fbcfa589ff2862fc719b83a88640daa01a5aff7",
"sha256:cb4ee827857a5ad9b8ae34d3c8cc51151cb4a3fe082c12ec20ec73e63cc7c6f0",
"sha256:d47d359545b0ccad29d572ecd52c9da945de7cd6cf9c0cfcb0269f76d3555689",
"sha256:dc9963aacb7da5177e40874585d7407c0f93fb9d7518ec58b86e562f633f36cd",
"sha256:ea2f41445852c660ba7c3ebf7d70b3779b20d9ca8ba54485a17740db49f46932",
"sha256:f5d0c921c99297354cecc5a416ee4280bd3f20fd81b9fb671ca6be71499c3fdf",
"sha256:f85d6f41e34f6a2d1607e312820971872944f1661a73d33e1e82d35ea3305e14"
],
"version": "==2020.9.27"
},
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
"version": "==2021.3.17"
},
"sniffio": {
"hashes": [
"sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5",
"sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"
"sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663",
"sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"
],
"markers": "python_version >= '3.5'",
"version": "==1.1.0"
"version": "==1.2.0"
},
"sortedcontainers": {
"hashes": [
"sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
"sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
"sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f",
"sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"
],
"version": "==2.2.2"
"version": "==2.3.0"
},
"toml": {
"hashes": [
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
],
"version": "==0.10.1"
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.2"
},
"trio": {
"hashes": [
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
"sha256:451ddb27b4e5215e00646fcbb8028d341fccf284e053dc376506a14bb133dbcf",
"sha256:df067dd0560c321af39d412cd81fc3a7d13f55af9150527daab980683e9fcf3c"
],
"index": "pypi",
"version": "==0.17.0"
"version": "==0.16.0"
},
"typed-ast": {
"hashes": [
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
"sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1",
"sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d",
"sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6",
"sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd",
"sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37",
"sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151",
"sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07",
"sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440",
"sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70",
"sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496",
"sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea",
"sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400",
"sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc",
"sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606",
"sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc",
"sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581",
"sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412",
"sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a",
"sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2",
"sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787",
"sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f",
"sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937",
"sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64",
"sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487",
"sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b",
"sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41",
"sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a",
"sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3",
"sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166",
"sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"
],
"version": "==1.4.1"
"version": "==1.4.2"
},
"typing-extensions": {
"hashes": [
+2 -2
View File
@@ -22,7 +22,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an
* Fallback wallet for the LNURL scheme
* Instant wallet for LN demonstrations
The wallet can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularily.
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularily.
See [lnbits.org](https://lnbits.org) for more detailed documentation.
@@ -68,7 +68,7 @@ Wallets can be easily generated and given out to people at events (one click mul
![lnurl ATM](https://i.imgur.com/xFWDnwy.png)
## Tip me
## Tip us
If you like this project and might even use or extend it, why not [send some tip love](https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
+3
View File
@@ -23,6 +23,9 @@ $ pipenv shell
$ pipenv install --dev
```
If any of the modules fails to install, try checking and upgrading your setupTool module.
`pip install -U setuptools`
If you wish to use a version of Python higher than 3.7:
```sh
+15 -4
View File
@@ -1,13 +1,24 @@
from .app import create_app
from .commands import migrate_databases, transpile_scss, bundle_vendored
from .settings import LNBITS_SITE_TITLE, SERVICE_FEE, DEBUG, LNBITS_DATA_FOLDER, WALLET, LNBITS_COMMIT
import trio # type: ignore
migrate_databases()
from .commands import migrate_databases, transpile_scss, bundle_vendored
trio.run(migrate_databases)
transpile_scss()
bundle_vendored()
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}
+28 -22
View File
@@ -1,3 +1,4 @@
import sys
import importlib
import warnings
@@ -9,13 +10,24 @@ from secure import SecureHeaders # type: ignore
from .commands import db_migrate, handle_assets
from .core import core_app
from .db import open_db, open_ext_db
from .helpers import get_valid_extensions, get_js_vendored, get_css_vendored, url_for_vendored
from .helpers import (
get_valid_extensions,
get_js_vendored,
get_css_vendored,
url_for_vendored,
)
from .proxy_fix import ASGIProxyFix
from .tasks import run_deferred_async, invoice_listener, internal_invoice_listener, webhook_handler, grab_app_for_later
from .tasks import (
run_deferred_async,
check_pending_payments,
invoice_listener,
internal_invoice_listener,
webhook_handler,
grab_app_for_later,
)
from .settings import WALLET
secure_headers = SecureHeaders(hsts=False)
secure_headers = SecureHeaders(hsts=False, xfo=False)
def create_app(config_object="lnbits.settings") -> QuartTrio:
@@ -44,14 +56,18 @@ def create_app(config_object="lnbits.settings") -> QuartTrio:
def check_funding_source(app: QuartTrio) -> None:
@app.before_serving
async def check_wallet_status():
error_message, balance = WALLET.status()
error_message, balance = await WALLET.status()
if error_message:
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(
f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat."
)
def register_blueprints(app: QuartTrio) -> None:
@@ -63,17 +79,11 @@ def register_blueprints(app: QuartTrio) -> None:
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
bp = getattr(ext_module, f"{ext.code}_ext")
@bp.before_request
async def before_request():
g.ext_db = open_ext_db(ext.code)
@bp.teardown_request
async def after_request(exc):
g.ext_db.close()
app.register_blueprint(bp, url_prefix=f"/{ext.code}")
except Exception:
raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.")
raise ImportError(
f"Please make sure that the extension `{ext.code}` follows conventions."
)
def register_commands(app: QuartTrio):
@@ -106,7 +116,6 @@ def register_request_hooks(app: QuartTrio):
@app.before_request
async def before_request():
g.db = open_db()
g.nursery = app.nursery
@app.after_request
@@ -114,10 +123,6 @@ def register_request_hooks(app: QuartTrio):
secure_headers.quart(response)
return response
@app.teardown_request
async def after_request(exc):
g.db.close()
def register_async_tasks(app):
@app.route("/wallet/webhook", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
@@ -127,8 +132,9 @@ def register_async_tasks(app):
@app.before_serving
async def listeners():
run_deferred_async(app.nursery)
app.nursery.start_soon(invoice_listener)
app.nursery.start_soon(internal_invoice_listener)
app.nursery.start_soon(check_pending_payments)
app.nursery.start_soon(invoice_listener, app.nursery)
app.nursery.start_soon(internal_invoice_listener, app.nursery)
@app.after_serving
async def stop_listeners():
+3 -1
View File
@@ -106,7 +106,9 @@ def decode(pr: str) -> Invoice:
key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1)
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
else:
keys = VerifyingKey.from_public_key_recovery(sig, message, SECP256k1, hashlib.sha256)
keys = VerifyingKey.from_public_key_recovery(
sig, message, SECP256k1, hashlib.sha256
)
signaling_byte = signature[64]
key = keys[int(signaling_byte)]
invoice.payee = key.to_string("compressed").hex()
+31 -21
View File
@@ -1,19 +1,24 @@
import trio # type: ignore
import warnings
import click
import importlib
import re
import os
import sqlite3
from sqlalchemy.exc import OperationalError # type: ignore
from .core import migrations as core_migrations
from .db import open_db, open_ext_db
from .helpers import get_valid_extensions, get_css_vendored, get_js_vendored, url_for_vendored
from .core import db as core_db, migrations as core_migrations
from .helpers import (
get_valid_extensions,
get_css_vendored,
get_js_vendored,
url_for_vendored,
)
from .settings import LNBITS_PATH
@click.command("migrate")
def db_migrate():
migrate_databases()
trio.run(migrate_databases)
@click.command("assets")
@@ -45,39 +50,44 @@ def bundle_vendored():
f.write(output)
def migrate_databases():
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
with open_db() as core_db:
async with core_db.connect() as conn:
try:
rows = core_db.fetchall("SELECT * FROM dbversions")
except sqlite3.OperationalError:
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
except OperationalError:
# migration 3 wasn't ran
core_migrations.m000_create_migrations_table(core_db)
rows = core_db.fetchall("SELECT * FROM dbversions")
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)_")
def run_migration(db, migrations_module):
async def run_migration(db, migrations_module):
db_name = migrations_module.__name__.split(".")[-2]
for key, run_migration in migrations_module.__dict__.items():
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}")
run_migration(db)
core_db.execute(
"INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)", (db_name, version)
await migrate(db)
await conn.execute(
"INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)",
(db_name, version),
)
run_migration(core_db, core_migrations)
await run_migration(conn, core_migrations)
for ext in get_valid_extensions():
try:
ext_migrations = importlib.import_module(f"lnbits.extensions.{ext.code}.migrations")
with open_ext_db(ext.code) as db:
run_migration(db, ext_migrations)
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.")
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
)
+7 -1
View File
@@ -1,8 +1,14 @@
from quart import Blueprint
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",
__name__,
template_folder="templates",
static_folder="static",
static_url_path="/core/static",
)
+140 -68
View File
@@ -1,12 +1,13 @@
import json
import datetime
from uuid import uuid4
from typing import List, Optional, Dict
from quart import g
from typing import List, Optional, Dict, Any
from lnbits import bolt11
from lnbits.db import Connection
from lnbits.settings import DEFAULT_WALLET_NAME
from . import db
from .models import User, Wallet, Payment
@@ -14,28 +15,36 @@ from .models import User, Wallet, Payment
# --------
def create_account() -> User:
async def create_account(conn: Optional[Connection] = None) -> User:
user_id = uuid4().hex
g.db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
await (conn or db).execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
new_account = get_account(user_id=user_id)
new_account = await get_account(user_id=user_id, conn=conn)
assert new_account, "Newly created account couldn't be retrieved"
return new_account
def get_account(user_id: str) -> Optional[User]:
row = g.db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,))
async def get_account(
user_id: str, conn: Optional[Connection] = None
) -> Optional[User]:
row = await (conn or db).fetchone(
"SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,)
)
return User(**row) if row else None
def get_user(user_id: str) -> Optional[User]:
user = g.db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,))
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
user = await (conn or db).fetchone(
"SELECT id, email FROM accounts WHERE id = ?", (user_id,)
)
if user:
extensions = g.db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,))
wallets = g.db.fetchall(
extensions = await (conn or db).fetchall(
"SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,)
)
wallets = await (conn or db).fetchall(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
@@ -45,14 +54,24 @@ def get_user(user_id: str) -> Optional[User]:
)
return (
User(**{**user, **{"extensions": [e[0] for e in extensions], "wallets": [Wallet(**w) for w in wallets]}})
User(
**{
**user,
**{
"extensions": [e[0] for e in extensions],
"wallets": [Wallet(**w) for w in wallets],
},
}
)
if user
else None
)
def update_user_extension(*, user_id: str, extension: str, active: int) -> None:
g.db.execute(
async def update_user_extension(
*, user_id: str, extension: str, active: int, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
INSERT OR REPLACE INTO extensions (user, extension, active)
VALUES (?, ?, ?)
@@ -65,24 +84,37 @@ def update_user_extension(*, user_id: str, extension: str, active: int) -> None:
# -------
def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet:
async def create_wallet(
*,
user_id: str,
wallet_name: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Wallet:
wallet_id = uuid4().hex
g.db.execute(
await (conn or db).execute(
"""
INSERT INTO wallets (id, name, user, adminkey, inkey)
VALUES (?, ?, ?, ?, ?)
""",
(wallet_id, wallet_name or DEFAULT_WALLET_NAME, user_id, uuid4().hex, uuid4().hex),
(
wallet_id,
wallet_name or DEFAULT_WALLET_NAME,
user_id,
uuid4().hex,
uuid4().hex,
),
)
new_wallet = get_wallet(wallet_id=wallet_id)
new_wallet = await get_wallet(wallet_id=wallet_id, conn=conn)
assert new_wallet, "Newly created wallet couldn't be retrieved"
return new_wallet
def delete_wallet(*, user_id: str, wallet_id: str) -> None:
g.db.execute(
async def delete_wallet(
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
UPDATE wallets AS w
SET
@@ -95,8 +127,10 @@ def delete_wallet(*, user_id: str, wallet_id: str) -> None:
)
def get_wallet(wallet_id: str) -> Optional[Wallet]:
row = g.db.fetchone(
async def get_wallet(
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[Wallet]:
row = await (conn or db).fetchone(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
@@ -108,8 +142,10 @@ def get_wallet(wallet_id: str) -> Optional[Wallet]:
return Wallet(**row) if row else None
def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
row = g.db.fetchone(
async def get_wallet_for_key(
key: str, key_type: str = "invoice", conn: Optional[Connection] = None
) -> Optional[Wallet]:
row = await (conn or db).fetchone(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
@@ -131,21 +167,26 @@ def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
# ---------------
def get_standalone_payment(checking_id: str) -> Optional[Payment]:
row = g.db.fetchone(
async def get_standalone_payment(
checking_id_or_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
row = await (conn or db).fetchone(
"""
SELECT *
FROM apipayments
WHERE checking_id = ?
WHERE checking_id = ? OR hash = ?
LIMIT 1
""",
(checking_id,),
(checking_id_or_hash, checking_id_or_hash),
)
return Payment.from_row(row) if row else None
def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]:
row = g.db.fetchone(
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
row = await (conn or db).fetchone(
"""
SELECT *
FROM apipayments
@@ -157,61 +198,75 @@ def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]:
return Payment.from_row(row) if row else None
def get_wallet_payments(
wallet_id: str,
async def get_payments(
*,
wallet_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
conn: Optional[Connection] = None,
) -> List[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
clause = ""
if complete and pending:
clause += ""
elif complete:
clause += "AND ((amount > 0 AND pending = 0) OR amount < 0)"
elif pending:
clause += "AND pending = 1"
else:
raise TypeError("at least one of [complete, pending] must be True.")
args: List[Any] = []
clause: List[str] = []
clause += " "
if since != None:
clause.append("time > ?")
args.append(since)
if wallet_id:
clause.append("wallet = ?")
args.append(wallet_id)
if complete and pending:
pass
elif complete:
clause.append("((amount > 0 AND pending = 0) OR amount < 0)")
elif pending:
clause.append("pending = 1")
else:
pass
if outgoing and incoming:
clause += ""
pass
elif outgoing:
clause += "AND amount < 0"
clause.append("amount < 0")
elif incoming:
clause += "AND amount > 0"
clause.append("amount > 0")
else:
raise TypeError("at least one of [outgoing, incoming] must be True.")
clause += " "
pass
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
clause += "AND checking_id NOT LIKE 'temp_%' "
clause += "AND checking_id NOT LIKE 'internal_%' "
clause.append("checking_id NOT LIKE 'temp_%'")
clause.append("checking_id NOT LIKE 'internal_%'")
rows = g.db.fetchall(
where = ""
if clause:
where = f"WHERE {' AND '.join(clause)}"
rows = await (conn or db).fetchall(
f"""
SELECT *
FROM apipayments
WHERE wallet = ? {clause}
{where}
ORDER BY time DESC
""",
(wallet_id,),
tuple(args),
)
return [Payment.from_row(row) for row in rows]
def delete_expired_invoices() -> None:
rows = g.db.fetchall(
async def delete_expired_invoices(
conn: Optional[Connection] = None,
) -> None:
rows = await (conn or db).fetchall(
"""
SELECT bolt11
FROM apipayments
@@ -228,7 +283,7 @@ def delete_expired_invoices() -> None:
if expiration_date > datetime.datetime.utcnow():
continue
g.db.execute(
await (conn or db).execute(
"""
DELETE FROM apipayments
WHERE pending = 1 AND hash = ?
@@ -241,7 +296,7 @@ def delete_expired_invoices() -> None:
# --------
def create_payment(
async def create_payment(
*,
wallet_id: str,
checking_id: str,
@@ -253,13 +308,15 @@ def create_payment(
preimage: Optional[str] = None,
pending: bool = True,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Payment:
g.db.execute(
await (conn or db).execute(
"""
INSERT INTO apipayments
(wallet, checking_id, bolt11, hash, preimage,
amount, pending, memo, fee, extra)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
amount, pending, memo, fee, extra, webhook)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
wallet_id,
@@ -271,18 +328,25 @@ def create_payment(
int(pending),
memo,
fee,
json.dumps(extra) if extra and extra != {} and type(extra) is dict else None,
json.dumps(extra)
if extra and extra != {} and type(extra) is dict
else None,
webhook,
),
)
new_payment = get_wallet_payment(wallet_id, payment_hash)
new_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
assert new_payment, "Newly created payment couldn't be retrieved"
return new_payment
def update_payment_status(checking_id: str, pending: bool) -> None:
g.db.execute(
async def update_payment_status(
checking_id: str,
pending: bool,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
(
int(pending),
@@ -291,12 +355,20 @@ def update_payment_status(checking_id: str, pending: bool) -> None:
)
def delete_payment(checking_id: str) -> None:
g.db.execute("DELETE FROM apipayments WHERE checking_id = ?", (checking_id,))
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,)
)
def check_internal(payment_hash: str) -> Optional[str]:
row = g.db.fetchone(
async def check_internal(
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
+35 -20
View File
@@ -1,8 +1,8 @@
import sqlite3
from sqlalchemy.exc import OperationalError # type: ignore
def m000_create_migrations_table(db):
db.execute(
async def m000_create_migrations_table(db):
await db.execute(
"""
CREATE TABLE dbversions (
db TEXT PRIMARY KEY,
@@ -12,11 +12,11 @@ def m000_create_migrations_table(db):
)
def m001_initial(db):
async def m001_initial(db):
"""
Initial LNbits tables.
"""
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
@@ -25,7 +25,7 @@ def m001_initial(db):
);
"""
)
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS extensions (
user TEXT NOT NULL,
@@ -36,7 +36,7 @@ def m001_initial(db):
);
"""
)
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY,
@@ -47,7 +47,7 @@ def m001_initial(db):
);
"""
)
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS apipayments (
payhash TEXT NOT NULL,
@@ -63,7 +63,7 @@ def m001_initial(db):
"""
)
db.execute(
await db.execute(
"""
CREATE VIEW IF NOT EXISTS balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
@@ -82,22 +82,22 @@ def m001_initial(db):
)
def m002_add_fields_to_apipayments(db):
async def m002_add_fields_to_apipayments(db):
"""
Adding fields to apipayments for better accounting,
and renaming payhash to checking_id since that is what it really is.
"""
try:
db.execute("ALTER TABLE apipayments RENAME COLUMN payhash TO checking_id")
db.execute("ALTER TABLE apipayments ADD COLUMN hash TEXT")
db.execute("CREATE INDEX by_hash ON apipayments (hash)")
db.execute("ALTER TABLE apipayments ADD COLUMN preimage TEXT")
db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
await db.execute("ALTER TABLE apipayments RENAME COLUMN payhash TO checking_id")
await db.execute("ALTER TABLE apipayments ADD COLUMN hash TEXT")
await db.execute("CREATE INDEX by_hash ON apipayments (hash)")
await db.execute("ALTER TABLE apipayments ADD COLUMN preimage TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
import json
rows = db.fetchall("SELECT * FROM apipayments")
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
@@ -106,17 +106,32 @@ def m002_add_fields_to_apipayments(db):
prefix = "#" + ext + " "
if row["memo"].startswith(prefix):
new = row["memo"][len(prefix) :]
db.execute(
await db.execute(
"""
UPDATE apipayments SET extra = ?, memo = ?
WHERE checking_id = ? AND memo = ?
""",
(json.dumps({"tag": ext}), new, row["checking_id"], row["memo"]),
(
json.dumps({"tag": ext}),
new,
row["checking_id"],
row["memo"],
),
)
break
except sqlite3.OperationalError:
except OperationalError:
# this is necessary now because it may be the case that this migration will
# run twice in some environments.
# catching errors like this won't be necessary in anymore now that we
# keep track of db versions so no migration ever runs twice.
pass
async def m003_add_invoice_webhook(db):
"""
Special column for webhook endpoints that can be assigned
to each different invoice.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT")
+29 -16
View File
@@ -46,24 +46,24 @@ class Wallet(NamedTuple):
hashfunc=hashlib.sha256,
)
def get_payment(self, payment_hash: str) -> Optional["Payment"]:
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
from .crud import get_wallet_payment
return get_wallet_payment(self.id, payment_hash)
return await get_wallet_payment(self.id, payment_hash)
def get_payments(
async def get_payments(
self,
*,
complete: bool = True,
pending: bool = False,
outgoing: bool = True,
incoming: bool = True,
exclude_uncheckable: bool = False
exclude_uncheckable: bool = False,
) -> List["Payment"]:
from .crud import get_wallet_payments
from .crud import get_payments
return get_wallet_payments(
self.id,
return await get_payments(
wallet_id=self.id,
complete=complete,
pending=pending,
outgoing=outgoing,
@@ -84,6 +84,8 @@ class Payment(NamedTuple):
payment_hash: str
extra: Dict
wallet_id: str
webhook: str
webhook_status: int
@classmethod
def from_row(cls, row: Row):
@@ -99,6 +101,8 @@ class Payment(NamedTuple):
memo=row["memo"],
time=row["time"],
wallet_id=row["wallet"],
webhook=row["webhook"],
webhook_status=row["webhook_status"],
)
@property
@@ -123,25 +127,34 @@ class Payment(NamedTuple):
@property
def is_uncheckable(self) -> bool:
return self.checking_id.startswith("temp_") or self.checking_id.startswith("internal_")
return self.checking_id.startswith("temp_") or self.checking_id.startswith(
"internal_"
)
def set_pending(self, pending: bool) -> None:
async def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
update_payment_status(self.checking_id, pending)
await update_payment_status(self.checking_id, pending)
def check_pending(self) -> None:
async def check_pending(self) -> None:
if self.is_uncheckable:
return
if self.is_out:
pending = WALLET.get_payment_status(self.checking_id)
status = await WALLET.get_payment_status(self.checking_id)
else:
pending = WALLET.get_invoice_status(self.checking_id)
status = await WALLET.get_invoice_status(self.checking_id)
self.set_pending(pending.pending)
print(
f" - checking '{'in' if self.is_in else 'out'}' {self.checking_id}: {status}"
)
def delete(self) -> None:
if self.is_out and status.failed:
await self.delete()
elif not status.pending:
await self.set_pending(status.pending)
async def delete(self) -> None:
from .crud import delete_payment
delete_payment(self.checking_id)
await delete_payment(self.checking_id)
+127 -86
View File
@@ -1,4 +1,3 @@
import trio # type: ignore
import json
import httpx
from io import BytesIO
@@ -14,25 +13,36 @@ 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 .crud import get_wallet, create_payment, delete_payment, check_internal, update_payment_status, get_wallet_payment
from . import db
from .crud import (
get_wallet,
create_payment,
delete_payment,
check_internal,
update_payment_status,
get_wallet_payment,
)
def create_invoice(
async def create_invoice(
*,
wallet_id: str,
amount: int, # in satoshis
memo: str,
description_hash: Optional[bytes] = None,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
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 = WALLET.create_invoice(
ok, checking_id, payment_request, error_message = await WALLET.create_invoice(
amount=amount, memo=invoice_memo, description_hash=description_hash
)
if not ok:
@@ -41,7 +51,7 @@ def create_invoice(
invoice = bolt11.decode(payment_request)
amount_msat = amount * 1000
create_payment(
await create_payment(
wallet_id=wallet_id,
checking_id=checking_id,
payment_request=payment_request,
@@ -49,117 +59,144 @@ def create_invoice(
amount=amount_msat,
memo=storeable_memo,
extra=extra,
webhook=webhook,
conn=conn,
)
g.db.commit()
return invoice.payment_hash, payment_request
def pay_invoice(
async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[Dict] = None,
description: str = "",
conn: Optional[Connection] = None,
) -> str:
temp_id = f"temp_{urlsafe_short_hash()}"
internal_id = f"internal_{urlsafe_short_hash()}"
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:
raise ValueError("Amount in invoice is too high.")
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:
raise ValueError("Amount in invoice is too high.")
# put all parameters that don't change here
PaymentKwargs = TypedDict(
"PaymentKwargs",
{
"wallet_id": str,
"payment_request": str,
"payment_hash": str,
"amount": int,
"memo": str,
"extra": Optional[Dict],
},
)
payment_kwargs: PaymentKwargs = dict(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
memo=description or invoice.description or "",
extra=extra,
)
# put all parameters that don't change here
PaymentKwargs = TypedDict(
"PaymentKwargs",
{
"wallet_id": str,
"payment_request": str,
"payment_hash": str,
"amount": int,
"memo": str,
"extra": Optional[Dict],
},
)
payment_kwargs: PaymentKwargs = dict(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
memo=description or invoice.description or "",
extra=extra,
)
# check_internal() returns the checking_id of the invoice we're waiting for
internal_checking_id = check_internal(invoice.payment_hash)
if internal_checking_id:
# create a new payment from this wallet
create_payment(checking_id=internal_id, fee=0, pending=False, **payment_kwargs)
else:
# create a temporary payment here so we can check if
# the balance is enough in the next step
fee_reserve = max(1000, int(invoice.amount_msat * 0.01))
create_payment(checking_id=temp_id, fee=-fee_reserve, **payment_kwargs)
# do the balance check
wallet = get_wallet(wallet_id)
assert wallet
if wallet.balance_msat < 0:
g.db.rollback()
raise PermissionError("Insufficient balance.")
else:
g.db.commit()
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
update_payment_status(checking_id=internal_checking_id, pending=False)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_paid
try:
internal_invoice_paid.send_nowait(internal_checking_id)
except trio.WouldBlock:
pass
else:
# actually pay the external invoice
payment: PaymentResponse = WALLET.pay_invoice(payment_request)
if payment.ok and payment.checking_id:
create_payment(
checking_id=payment.checking_id,
fee=payment.fee_msat,
preimage=payment.preimage,
# check_internal() returns the checking_id of the invoice we're waiting for
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
# create a new payment from this wallet
await create_payment(
checking_id=internal_id,
fee=0,
pending=False,
conn=conn,
**payment_kwargs,
)
delete_payment(temp_id)
else:
raise Exception(payment.error_message or "Failed to pay_invoice on backend.")
# create a temporary payment here so we can check if
# the balance is enough in the next step
fee_reserve = max(1000, int(invoice.amount_msat * 0.01))
await create_payment(
checking_id=temp_id,
fee=-fee_reserve,
conn=conn,
**payment_kwargs,
)
g.db.commit()
return invoice.payment_hash
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet
if wallet.balance_msat < 0:
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
await update_payment_status(
checking_id=internal_checking_id,
pending=False,
conn=conn,
)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_paid
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:
await create_payment(
checking_id=payment.checking_id,
fee=payment.fee_msat,
preimage=payment.preimage,
pending=payment.ok == None,
conn=conn,
**payment_kwargs,
)
await delete_payment(temp_id, conn=conn)
else:
raise Exception(
payment.error_message or "Failed to pay_invoice on backend."
)
return invoice.payment_hash
async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo: Optional[str] = None) -> None:
_, payment_request = create_invoice(
async def redeem_lnurl_withdraw(
wallet_id: str,
res: LnurlWithdrawResponse,
memo: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=res.max_sats,
memo=memo or res.default_description or "",
extra={"tag": "lnurlwallet"},
conn=conn,
)
async with httpx.AsyncClient() as client:
await client.get(
res.callback.base,
params={**res.callback.query_params, **{"k1": res.k1, "pr": payment_request}},
params={
**res.callback.query_params,
**{"k1": res.k1, "pr": payment_request},
},
)
async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]:
async def perform_lnurlauth(
callback: str,
conn: Optional[Connection] = None,
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
@@ -230,9 +267,13 @@ async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]:
)
def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus:
payment = get_wallet_payment(wallet_id, payment_hash)
async def check_invoice_status(
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 WALLET.get_invoice_status(payment.checking_id)
return await WALLET.get_invoice_status(payment.checking_id)
+10 -23
View File
@@ -584,18 +584,16 @@ new Vue({
LNbits.href.deleteWallet(walletId, user)
})
},
fetchPayments: function (checkPending) {
return LNbits.api
.getPayments(this.g.wallet, checkPending)
.then(response => {
this.payments = response.data
.map(obj => {
return LNbits.map.payment(obj)
})
.sort((a, b) => {
return b.time - a.time
})
})
fetchPayments: function () {
return LNbits.api.getPayments(this.g.wallet).then(response => {
this.payments = response.data
.map(obj => {
return LNbits.map.payment(obj)
})
.sort((a, b) => {
return b.time - a.time
})
})
},
fetchBalance: function () {
LNbits.api.getWallet(this.g.wallet).then(response => {
@@ -606,16 +604,6 @@ new Vue({
])
})
},
checkPendingPayments: function () {
var dismissMsg = this.$q.notify({
timeout: 0,
message: 'Checking pending transactions...'
})
this.fetchPayments(true).then(() => {
dismissMsg()
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments)
}
@@ -628,7 +616,6 @@ new Vue({
created: function () {
this.fetchBalance()
this.fetchPayments()
setTimeout(this.checkPendingPayments(), 1200)
},
mounted: function () {
// show disclaimer
+42 -6
View File
@@ -1,7 +1,10 @@
import trio # type: ignore
import httpx
from typing import List
from lnbits.tasks import register_invoice_listener
from . import db
from .models import Payment
sse_listeners: List[trio.MemorySendChannel] = []
@@ -14,9 +17,42 @@ async def register_listeners():
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
for send_channel in sse_listeners:
try:
send_channel.send_nowait(payment)
except trio.WouldBlock:
print("removing sse listener", send_channel)
sse_listeners.remove(send_channel)
# send information to sse channel
await dispatch_sse(payment)
# dispatch webhook
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
async def dispatch_sse(payment: Payment):
for send_channel in sse_listeners:
try:
send_channel.send_nowait(payment)
except trio.WouldBlock:
print("removing sse listener", send_channel)
sse_listeners.remove(send_channel)
async def dispatch_webhook(payment: Payment):
async with httpx.AsyncClient() as client:
data = payment._asdict()
try:
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)
async def mark_webhook_sent(payment: Payment, status: int) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = ?
WHERE hash = ?
""",
(status, payment.payment_hash),
)
+3 -2
View File
@@ -55,8 +55,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,
"amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
"amount": &lt;int&gt;, "memo": &lt;string&gt;, "webhook":
&lt;url:string&gt;}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
+17 -4
View File
@@ -219,9 +219,6 @@
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn flat color="grey" @click="exportCSV" class="float-right"
>Renew keys</q-btn
>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">LNbits wallet</h6>
<strong>Wallet name: </strong><em>{{ wallet.name }}</em><br />
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
@@ -233,6 +230,22 @@
<q-list>
{% include "core/_api_docs.html" %}
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="settings_cell"
label="Export to Phone with QR Code"
>
<q-card>
<q-card-section>
<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"
@@ -425,7 +438,7 @@
<q-input
filled
dense
v-model.number="parse.data.comment"
v-model="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"
+79 -49
View File
@@ -2,9 +2,8 @@ import trio # type: ignore
import json
import lnurl # type: ignore
import httpx
import traceback
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
from quart import g, jsonify, request, make_response
from quart import g, jsonify, make_response
from http import HTTPStatus
from binascii import unhexlify
from typing import Dict, Union
@@ -12,9 +11,8 @@ from typing import Dict, Union
from lnbits import bolt11
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from .. import core_app
from .. import core_app, db
from ..services import create_invoice, pay_invoice, perform_lnurlauth
from ..crud import delete_expired_invoices
from ..tasks import sse_listeners
@@ -36,22 +34,28 @@ async def api_wallet():
@core_app.route("/api/v1/payments", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_payments():
if "check_pending" in request.args:
delete_expired_invoices()
for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True):
payment.check_pending()
return jsonify(g.wallet.get_payments(pending=True)), HTTPStatus.OK
return jsonify(await g.wallet.get_payments(pending=True)), HTTPStatus.OK
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"amount": {"type": "integer", "min": 1, "required": True},
"memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
"memo": {
"type": "string",
"empty": False,
"required": True,
"excludes": "description_hash",
},
"description_hash": {
"type": "string",
"empty": False,
"required": True,
"excludes": "memo",
},
"lnurl_callback": {"type": "string", "nullable": True, "required": False},
"extra": {"type": "dict", "nullable": True, "required": False},
"webhook": {"type": "string", "empty": False, "required": False},
}
)
async def api_payments_create_invoice():
@@ -62,13 +66,16 @@ async def api_payments_create_invoice():
description_hash = b""
memo = g.data["memo"]
try:
payment_hash, payment_request = create_invoice(
wallet_id=g.wallet.id, amount=g.data["amount"], memo=memo, description_hash=description_hash
async with db.connect() as conn:
payment_hash, payment_request = await create_invoice(
wallet_id=g.wallet.id,
amount=g.data["amount"],
memo=memo,
description_hash=description_hash,
extra=g.data.get("extra"),
webhook=g.data.get("webhook"),
conn=conn,
)
except Exception as e:
g.db.rollback()
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
invoice = bolt11.decode(payment_request)
@@ -107,18 +114,21 @@ async def api_payments_create_invoice():
@api_check_wallet_key("admin")
@api_validate_post_request(schema={"bolt11": {"type": "string", "empty": False, "required": True}})
@api_validate_post_request(
schema={"bolt11": {"type": "string", "empty": False, "required": True}}
)
async def api_payments_pay_invoice():
try:
payment_hash = pay_invoice(wallet_id=g.wallet.id, payment_request=g.data["bolt11"])
payment_hash = await pay_invoice(
wallet_id=g.wallet.id,
payment_request=g.data["bolt11"],
)
except ValueError as e:
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
except PermissionError as e:
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
except Exception as exc:
traceback.print_exc(7)
g.db.rollback()
return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR
raise exc
return (
jsonify(
@@ -147,8 +157,18 @@ async def api_payments_create():
"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},
"comment": {
"type": "string",
"nullable": True,
"empty": True,
"required": False,
},
"description": {
"type": "string",
"nullable": True,
"empty": True,
"required": False,
},
}
)
async def api_payments_pay_lnurl():
@@ -168,7 +188,10 @@ async def api_payments_pay_lnurl():
params = json.loads(r.text)
if params.get("status") == "ERROR":
return jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}), HTTPStatus.BAD_REQUEST
return (
jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}),
HTTPStatus.BAD_REQUEST,
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != g.data["amount"]:
@@ -190,24 +213,19 @@ async def api_payments_pay_lnurl():
HTTPStatus.BAD_REQUEST,
)
try:
extra = {}
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if g.data["comment"]:
extra["comment"] = g.data["comment"]
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if g.data["comment"]:
extra["comment"] = g.data["comment"]
payment_hash = pay_invoice(
wallet_id=g.wallet.id,
payment_request=params["pr"],
description=g.data.get("description", ""),
extra=extra,
)
except Exception as exc:
traceback.print_exc(7)
g.db.rollback()
return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR
payment_hash = await pay_invoice(
wallet_id=g.wallet.id,
payment_request=params["pr"],
description=g.data.get("description", ""),
extra=extra,
)
return (
jsonify(
@@ -225,7 +243,7 @@ async def api_payments_pay_lnurl():
@core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_payment(payment_hash):
payment = g.wallet.get_payment(payment_hash)
payment = await g.wallet.get_payment(payment_hash)
if not payment:
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
@@ -233,17 +251,19 @@ async def api_payment(payment_hash):
return jsonify({"paid": True, "preimage": payment.preimage}), HTTPStatus.OK
try:
payment.check_pending()
await payment.check_pending()
except Exception:
return jsonify({"paid": False}), HTTPStatus.OK
return jsonify({"paid": not payment.pending, "preimage": payment.preimage}), HTTPStatus.OK
return (
jsonify({"paid": not payment.pending, "preimage": payment.preimage}),
HTTPStatus.OK,
)
@core_app.route("/api/v1/payments/sse", methods=["GET"])
@api_check_wallet_key("invoice", accept_querystring=True)
async def api_payments_sse():
g.db.close()
this_wallet_id = g.wallet.id
send_payment, receive_payment = trio.open_memory_channel(0)
@@ -327,12 +347,22 @@ async def api_lnurlscan(code: str):
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
return (
jsonify({"domain": domain, "message": f"got invalid response '{r.text[:200]}'"}),
jsonify(
{
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
}
),
HTTPStatus.SERVICE_UNAVAILABLE,
)
if type(data) is lnurl.LnurlChannelResponse:
return jsonify({"domain": domain, "kind": "channel", "message": "unsupported"}), HTTPStatus.BAD_REQUEST
return (
jsonify(
{"domain": domain, "kind": "channel", "message": "unsupported"}
),
HTTPStatus.BAD_REQUEST,
)
params.update(**data.dict())
+57 -23
View File
@@ -2,13 +2,20 @@ import trio # type: ignore
import httpx
from os import path
from http import HTTPStatus
from quart import g, abort, redirect, request, render_template, send_from_directory, url_for
from quart import (
g,
abort,
redirect,
request,
render_template,
send_from_directory,
url_for,
)
from lnurl import LnurlResponse, LnurlWithdrawResponse, decode as decode_lnurl # type: ignore
from lnbits.core import core_app
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
from lnbits.tasks import run_on_pseudo_request
from ..crud import (
create_account,
@@ -22,12 +29,16 @@ from ..services import redeem_lnurl_withdraw
@core_app.route("/favicon.ico")
async def favicon():
return await send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
return await send_from_directory(
path.join(core_app.root_path, "static"), "favicon.ico"
)
@core_app.route("/")
async def home():
return await render_template("core/index.html", lnurl=request.args.get("lightning", None))
return await render_template(
"core/index.html", lnurl=request.args.get("lightning", None)
)
@core_app.route("/extensions")
@@ -38,14 +49,20 @@ async def extensions():
extension_to_disable = request.args.get("disable", type=str)
if extension_to_enable and extension_to_disable:
abort(HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension.")
abort(
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
)
if extension_to_enable:
update_user_extension(user_id=g.user.id, extension=extension_to_enable, active=1)
await update_user_extension(
user_id=g.user.id, extension=extension_to_enable, active=1
)
elif extension_to_disable:
update_user_extension(user_id=g.user.id, extension=extension_to_disable, active=0)
await update_user_extension(
user_id=g.user.id, extension=extension_to_disable, active=0
)
return await render_template("core/extensions.html", user=get_user(g.user.id))
return await render_template("core/extensions.html", user=await get_user(g.user.id))
@core_app.route("/wallet")
@@ -63,9 +80,12 @@ async def wallet():
# nothing: create everything
if not user_id:
user = get_user(create_account().id)
user = await get_user((await create_account()).id)
else:
user = get_user(user_id) or abort(HTTPStatus.NOT_FOUND, "User does not exist.")
user = await get_user(user_id)
if not user:
abort(HTTPStatus.NOT_FOUND, "User does not exist.")
return
if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS:
abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
@@ -74,7 +94,7 @@ async def wallet():
if user.wallets and not wallet_name:
wallet = user.wallets[0]
else:
wallet = create_wallet(user_id=user.id, wallet_name=wallet_name)
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
@@ -82,7 +102,9 @@ async def wallet():
if not wallet:
abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
return await render_template("core/wallet.html", user=user, wallet=wallet, service_fee=service_fee)
return await render_template(
"core/wallet.html", user=user, wallet=wallet, service_fee=service_fee
)
@core_app.route("/deletewallet")
@@ -95,7 +117,7 @@ async def deletewallet():
if wallet_id not in user_wallet_ids:
abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
else:
delete_wallet(user_id=g.user.id, wallet_id=wallet_id)
await delete_wallet(user_id=g.user.id, wallet_id=wallet_id)
user_wallet_ids.remove(wallet_id)
if user_wallet_ids:
@@ -113,20 +135,32 @@ async def lnurlwallet():
withdraw_res = LnurlResponse.from_dict(r.json())
if not withdraw_res.ok:
return f"Could not process lnurl-withdraw: {withdraw_res.error_msg}", HTTPStatus.BAD_REQUEST
return (
f"Could not process lnurl-withdraw: {withdraw_res.error_msg}",
HTTPStatus.BAD_REQUEST,
)
if not isinstance(withdraw_res, LnurlWithdrawResponse):
return f"Expected an lnurl-withdraw code, got {withdraw_res.tag}", HTTPStatus.BAD_REQUEST
return (
f"Expected an lnurl-withdraw code, got {withdraw_res.tag}",
HTTPStatus.BAD_REQUEST,
)
except Exception as exc:
return f"Could not process lnurl-withdraw: {exc}", HTTPStatus.INTERNAL_SERVER_ERROR
return (
f"Could not process lnurl-withdraw: {exc}",
HTTPStatus.INTERNAL_SERVER_ERROR,
)
account = create_account()
user = get_user(account.id)
wallet = create_wallet(user_id=user.id)
g.db.commit()
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)
await run_on_pseudo_request(
redeem_lnurl_withdraw, wallet.id, withdraw_res, "LNbits initial funding: voucher redeem."
g.nursery.start_soon(
redeem_lnurl_withdraw,
wallet.id,
withdraw_res,
"LNbits initial funding: voucher redeem.",
)
await trio.sleep(3)
+52 -54
View File
@@ -1,66 +1,64 @@
import os
import sqlite3
import trio
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
from .settings import LNBITS_DATA_FOLDER
class Connection:
def __init__(self, conn: AsyncConnection):
self.conn = conn
async def fetchall(self, query: str, values: tuple = ()) -> list:
result = await self.conn.execute(query, values)
return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()):
result = await self.conn.execute(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)
class Database:
def __init__(self, db_path: str):
self.path = db_path
self.connection = sqlite3.connect(db_path)
self.connection.row_factory = sqlite3.Row
self.cursor = self.connection.cursor()
self.closed = False
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()
def close(self):
self.__exit__(None, None, None)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.closed:
return
if exc_val:
self.connection.rollback()
self.cursor.close()
self.connection.close()
else:
self.connection.commit()
self.cursor.close()
self.connection.close()
self.closed = True
def commit(self):
self.connection.commit()
def rollback(self):
self.connection.rollback()
def fetchall(self, query: str, values: tuple = ()) -> list:
"""Given a query, return cursor.fetchall() rows."""
self.execute(query, values)
return self.cursor.fetchall()
def fetchone(self, query: str, values: tuple = ()):
self.execute(query, values)
return self.cursor.fetchone()
def execute(self, query: str, values: tuple = ()) -> None:
"""Given a query, cursor.execute() it."""
@asynccontextmanager
async def connect(self):
await self.lock.acquire()
try:
self.cursor.execute(query, values)
except sqlite3.Error as exc:
self.connection.rollback()
raise exc
async with self.engine.connect() as conn:
async with conn.begin():
yield Connection(conn)
finally:
self.lock.release()
async def fetchall(self, query: str, values: tuple = ()) -> list:
async with self.connect() as conn:
result = await conn.execute(query, values)
return await result.fetchall()
def open_db(db_name: str = "database") -> Database:
db_path = os.path.join(LNBITS_DATA_FOLDER, f"{db_name}.sqlite3")
return Database(db_path=db_path)
async def fetchone(self, query: str, values: tuple = ()):
async with self.connect() as conn:
result = await conn.execute(query, values)
row = await result.fetchone()
await result.close()
return row
async def execute(self, query: str, values: tuple = ()):
async with self.connect() as conn:
return await conn.execute(query, values)
def open_ext_db(extension_name: str) -> Database:
return open_db(f"ext_{extension_name}")
@asynccontextmanager
async def reuse_conn(self, conn: Connection):
yield conn
+10 -4
View File
@@ -15,7 +15,7 @@ def api_check_wallet_key(key_type: str = "invoice", accept_querystring=False):
async def wrapped_view(**kwargs):
try:
key_value = request.headers.get("X-Api-Key") or request.args["api-key"]
g.wallet = get_wallet_for_key(key_value, key_type)
g.wallet = await get_wallet_for_key(key_value, key_type)
except KeyError:
return (
jsonify({"message": "`X-Api-Key` header missing."}),
@@ -63,7 +63,9 @@ def check_user_exists(param: str = "usr"):
def wrap(view):
@wraps(view)
async def wrapped_view(**kwargs):
g.user = get_user(request.args.get(param, type=str)) or abort(HTTPStatus.NOT_FOUND, "User does not exist.")
g.user = await get_user(request.args.get(param, type=str)) or abort(
HTTPStatus.NOT_FOUND, "User does not exist."
)
if LNBITS_ALLOWED_USERS and g.user.id not in LNBITS_ALLOWED_USERS:
abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
@@ -75,11 +77,15 @@ def check_user_exists(param: str = "usr"):
return wrap
def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4):
def validate_uuids(
params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4
):
def wrap(view):
@wraps(view)
async def wrapped_view(**kwargs):
query_params = {param: request.args.get(param, type=str) for param in params}
query_params = {
param: request.args.get(param, type=str) for param in params
}
for param, value in query_params.items():
if not value and (required is True or (required and param in required)):
+5 -1
View File
@@ -1,7 +1,11 @@
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")
amilk_ext: Blueprint = Blueprint(
"amilk", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
+22 -24
View File
@@ -2,43 +2,41 @@ from base64 import urlsafe_b64encode
from uuid import uuid4
from typing import List, Optional, Union
from lnbits.db import open_ext_db
from . import db
from .models import AMilk
def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk:
with open_ext_db("amilk") as db:
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute(
"""
INSERT INTO amilks (id, wallet, lnurl, atime, amount)
VALUES (?, ?, ?, ?, ?)
""",
(amilk_id, wallet_id, lnurl, atime, amount),
)
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),
)
return get_amilk(amilk_id)
amilk = await get_amilk(amilk_id)
assert amilk, "Newly created amilk_id couldn't be retrieved"
return amilk
def get_amilk(amilk_id: str) -> Optional[AMilk]:
with open_ext_db("amilk") as db:
row = db.fetchone("SELECT * FROM amilks WHERE id = ?", (amilk_id,))
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
def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]:
async def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("amilk") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM amilks WHERE wallet IN ({q})", (*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]
def delete_amilk(amilk_id: str) -> None:
with open_ext_db("amilk") as db:
db.execute("DELETE FROM amilks WHERE id = ?", (amilk_id,))
async def delete_amilk(amilk_id: str) -> None:
await db.execute("DELETE FROM amilks WHERE id = ?", (amilk_id,))
+2 -2
View File
@@ -1,8 +1,8 @@
def m001_initial(db):
async def m001_initial(db):
"""
Initial amilks table.
"""
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS amilks (
id TEXT PRIMARY KEY,
+5 -2
View File
@@ -2,8 +2,8 @@ from quart import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.amilk import amilk_ext
from . import amilk_ext
from .crud import get_amilk
@@ -16,5 +16,8 @@ async def index():
@amilk_ext.route("/<amilk_id>")
async def wall(amilk_id):
amilk = get_amilk(amilk_id) or abort(HTTPStatus.NOT_FOUND, "AMilk does not exist.")
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)
+27 -13
View File
@@ -1,15 +1,15 @@
import httpx
from quart import g, jsonify, request, abort
from http import HTTPStatus
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl
from lnurl.exceptions import LnurlException
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 lnbits.extensions.amilk import amilk_ext
from . import amilk_ext
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
@@ -19,14 +19,17 @@ async def api_amilks():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return jsonify([amilk._asdict() for amilk in get_amilks(wallet_ids)]), HTTPStatus.OK
return (
jsonify([amilk._asdict() for amilk in await get_amilks(wallet_ids)]),
HTTPStatus.OK,
)
@amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"])
async def api_amilkit(amilk_id):
milk = get_amilk(amilk_id)
milk = await get_amilk(amilk_id)
memo = milk.id
try:
@@ -34,13 +37,19 @@ async def api_amilkit(amilk_id):
except LnurlException:
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
payment_hash, payment_request = create_invoice(
wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo, extra={"tag": "amilk"}
payment_hash, payment_request = await create_invoice(
wallet_id=milk.wallet,
amount=withdraw_res.max_sats,
memo=memo,
extra={"tag": "amilk"},
)
r = httpx.get(
withdraw_res.callback.base,
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
params={
**withdraw_res.callback.query_params,
**{"k1": withdraw_res.k1, "pr": payment_request},
},
)
if r.is_error:
@@ -48,7 +57,7 @@ async def api_amilkit(amilk_id):
for i in range(10):
sleep(i)
invoice_status = check_invoice_status(milk.wallet, payment_hash)
invoice_status = await check_invoice_status(milk.wallet, payment_hash)
if invoice_status.paid:
return jsonify({"paid": True}), HTTPStatus.OK
else:
@@ -67,7 +76,12 @@ async def api_amilkit(amilk_id):
}
)
async def api_amilk_create():
amilk = create_amilk(wallet_id=g.wallet.id, lnurl=g.data["lnurl"], atime=g.data["atime"], amount=g.data["amount"])
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
@@ -75,7 +89,7 @@ async def api_amilk_create():
@amilk_ext.route("/api/v1/amilk/<amilk_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_amilk_delete(amilk_id):
amilk = get_amilk(amilk_id)
amilk = await get_amilk(amilk_id)
if not amilk:
return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
@@ -83,6 +97,6 @@ async def api_amilk_delete(amilk_id):
if amilk.wallet != g.wallet.id:
return jsonify({"message": "Not your amilk."}), HTTPStatus.FORBIDDEN
delete_amilk(amilk_id)
await delete_amilk(amilk_id)
return "", HTTPStatus.NO_CONTENT
+21
View File
@@ -0,0 +1,21 @@
# Bleskomat Extension for lnbits
This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/).
## Connect Your Bleskomat ATM
* Click the "Add Bleskomat" button on this page to begin.
* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers.
* Choose the fiat currency. This should match the fiat currency that your ATM accepts.
* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds.
* Set your ATM's fee percentage.
* Click the "Done" button.
* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM.
* Copy the configuration file ("bleskomat.conf") to your ATM's SD card.
* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card.
## How Does It Work?
Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet.
+12
View File
@@ -0,0 +1,12 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_bleskomat")
bleskomat_ext: Blueprint = Blueprint(
"bleskomat", __name__, static_folder="static", template_folder="templates"
)
from .lnurl_api import * # noqa
from .views_api import * # noqa
from .views import * # noqa
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Bleskomat",
"short_description": "Connect a Bleskomat ATM to an lnbits",
"icon": "money",
"contributors": ["chill117"]
}
+112
View File
@@ -0,0 +1,112 @@
import secrets
import time
from uuid import uuid4
from typing import List, Optional, Union
from . import db
from .models import Bleskomat, BleskomatLnurl
from .helpers import generate_bleskomat_lnurl_hash
async def create_bleskomat(
*,
wallet_id: str,
name: str,
fiat_currency: str,
exchange_rate_provider: str,
fee: str,
) -> Bleskomat:
bleskomat_id = uuid4().hex
api_key_id = secrets.token_hex(8)
api_key_secret = secrets.token_hex(32)
api_key_encoding = "hex"
await db.execute(
"""
INSERT INTO bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
bleskomat_id,
wallet_id,
api_key_id,
api_key_secret,
api_key_encoding,
name,
fiat_currency,
exchange_rate_provider,
fee,
),
)
bleskomat = await get_bleskomat(bleskomat_id)
assert bleskomat, "Newly created bleskomat couldn't be retrieved"
return bleskomat
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
return Bleskomat(**row) if row else None
async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]:
row = await db.fetchone(
"SELECT * FROM bleskomats WHERE api_key_id = ?", (api_key_id,)
)
return Bleskomat(**row) if row else None
async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Bleskomat(**row) for row in rows]
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE bleskomats SET {q} WHERE id = ?", (*kwargs.values(), bleskomat_id)
)
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
return Bleskomat(**row) if row else None
async def delete_bleskomat(bleskomat_id: str) -> None:
await db.execute("DELETE FROM bleskomats WHERE id = ?", (bleskomat_id,))
async def create_bleskomat_lnurl(
*, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1
) -> BleskomatLnurl:
bleskomat_lnurl_id = uuid4().hex
hash = generate_bleskomat_lnurl_hash(secret)
now = int(time.time())
await db.execute(
"""
INSERT INTO bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
bleskomat_lnurl_id,
bleskomat.id,
bleskomat.wallet,
hash,
tag,
params,
bleskomat.api_key_id,
uses,
uses,
now,
now,
),
)
bleskomat_lnurl = await get_bleskomat_lnurl(secret)
assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved"
return bleskomat_lnurl
async def get_bleskomat_lnurl(secret: str) -> BleskomatLnurl:
hash = generate_bleskomat_lnurl_hash(secret)
row = await db.fetchone("SELECT * FROM bleskomat_lnurls WHERE hash = ?", (hash,))
return BleskomatLnurl(**row) if row else None
@@ -0,0 +1,79 @@
import httpx
import json
import os
fiat_currencies = json.load(
open(
os.path.join(
os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json"
),
"r",
)
)
exchange_rate_providers = {
"bitfinex": {
"name": "Bitfinex",
"domain": "bitfinex.com",
"api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}",
"getter": lambda data, replacements: data["last_price"],
},
"bitstamp": {
"name": "Bitstamp",
"domain": "bitstamp.net",
"api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
"getter": lambda data, replacements: data["last"],
},
"coinbase": {
"name": "Coinbase",
"domain": "coinbase.com",
"api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
"getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]],
},
"coinmate": {
"name": "CoinMate",
"domain": "coinmate.io",
"api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
"getter": lambda data, replacements: data["data"]["last"],
},
"kraken": {
"name": "Kraken",
"domain": "kraken.com",
"api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
"getter": lambda data, replacements: data["result"][
"XXBTZ" + replacements["TO"]
]["c"][0],
},
}
exchange_rate_providers_serializable = {}
for ref, exchange_rate_provider in exchange_rate_providers.items():
exchange_rate_provider_serializable = {}
for key, value in exchange_rate_provider.items():
if not callable(value):
exchange_rate_provider_serializable[key] = value
exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable
async def fetch_fiat_exchange_rate(currency: str, provider: str):
replacements = {
"FROM": "BTC",
"from": "btc",
"TO": currency.upper(),
"to": currency.lower(),
}
url = exchange_rate_providers[provider]["api_url"]
for key in replacements.keys():
url = url.replace("{" + key + "}", replacements[key])
getter = exchange_rate_providers[provider]["getter"]
async with httpx.AsyncClient() as client:
r = await client.get(url)
r.raise_for_status()
data = r.json()
rate = float(getter(data, replacements))
return rate
@@ -0,0 +1,166 @@
{
"AED": "United Arab Emirates Dirham",
"AFN": "Afghan Afghani",
"ALL": "Albanian Lek",
"AMD": "Armenian Dram",
"ANG": "Netherlands Antillean Gulden",
"AOA": "Angolan Kwanza",
"ARS": "Argentine Peso",
"AUD": "Australian Dollar",
"AWG": "Aruban Florin",
"AZN": "Azerbaijani Manat",
"BAM": "Bosnia and Herzegovina Convertible Mark",
"BBD": "Barbadian Dollar",
"BDT": "Bangladeshi Taka",
"BGN": "Bulgarian Lev",
"BHD": "Bahraini Dinar",
"BIF": "Burundian Franc",
"BMD": "Bermudian Dollar",
"BND": "Brunei Dollar",
"BOB": "Bolivian Boliviano",
"BRL": "Brazilian Real",
"BSD": "Bahamian Dollar",
"BTN": "Bhutanese Ngultrum",
"BWP": "Botswana Pula",
"BYN": "Belarusian Ruble",
"BYR": "Belarusian Ruble",
"BZD": "Belize Dollar",
"CAD": "Canadian Dollar",
"CDF": "Congolese Franc",
"CHF": "Swiss Franc",
"CLF": "Unidad de Fomento",
"CLP": "Chilean Peso",
"CNH": "Chinese Renminbi Yuan Offshore",
"CNY": "Chinese Renminbi Yuan",
"COP": "Colombian Peso",
"CRC": "Costa Rican Colón",
"CUC": "Cuban Convertible Peso",
"CVE": "Cape Verdean Escudo",
"CZK": "Czech Koruna",
"DJF": "Djiboutian Franc",
"DKK": "Danish Krone",
"DOP": "Dominican Peso",
"DZD": "Algerian Dinar",
"EGP": "Egyptian Pound",
"ERN": "Eritrean Nakfa",
"ETB": "Ethiopian Birr",
"EUR": "Euro",
"FJD": "Fijian Dollar",
"FKP": "Falkland Pound",
"GBP": "British Pound",
"GEL": "Georgian Lari",
"GGP": "Guernsey Pound",
"GHS": "Ghanaian Cedi",
"GIP": "Gibraltar Pound",
"GMD": "Gambian Dalasi",
"GNF": "Guinean Franc",
"GTQ": "Guatemalan Quetzal",
"GYD": "Guyanese Dollar",
"HKD": "Hong Kong Dollar",
"HNL": "Honduran Lempira",
"HRK": "Croatian Kuna",
"HTG": "Haitian Gourde",
"HUF": "Hungarian Forint",
"IDR": "Indonesian Rupiah",
"ILS": "Israeli New Sheqel",
"IMP": "Isle of Man Pound",
"INR": "Indian Rupee",
"IQD": "Iraqi Dinar",
"ISK": "Icelandic Króna",
"JEP": "Jersey Pound",
"JMD": "Jamaican Dollar",
"JOD": "Jordanian Dinar",
"JPY": "Japanese Yen",
"KES": "Kenyan Shilling",
"KGS": "Kyrgyzstani Som",
"KHR": "Cambodian Riel",
"KMF": "Comorian Franc",
"KRW": "South Korean Won",
"KWD": "Kuwaiti Dinar",
"KYD": "Cayman Islands Dollar",
"KZT": "Kazakhstani Tenge",
"LAK": "Lao Kip",
"LBP": "Lebanese Pound",
"LKR": "Sri Lankan Rupee",
"LRD": "Liberian Dollar",
"LSL": "Lesotho Loti",
"LYD": "Libyan Dinar",
"MAD": "Moroccan Dirham",
"MDL": "Moldovan Leu",
"MGA": "Malagasy Ariary",
"MKD": "Macedonian Denar",
"MMK": "Myanmar Kyat",
"MNT": "Mongolian Tögrög",
"MOP": "Macanese Pataca",
"MRO": "Mauritanian Ouguiya",
"MUR": "Mauritian Rupee",
"MVR": "Maldivian Rufiyaa",
"MWK": "Malawian Kwacha",
"MXN": "Mexican Peso",
"MYR": "Malaysian Ringgit",
"MZN": "Mozambican Metical",
"NAD": "Namibian Dollar",
"NGN": "Nigerian Naira",
"NIO": "Nicaraguan Córdoba",
"NOK": "Norwegian Krone",
"NPR": "Nepalese Rupee",
"NZD": "New Zealand Dollar",
"OMR": "Omani Rial",
"PAB": "Panamanian Balboa",
"PEN": "Peruvian Sol",
"PGK": "Papua New Guinean Kina",
"PHP": "Philippine Peso",
"PKR": "Pakistani Rupee",
"PLN": "Polish Złoty",
"PYG": "Paraguayan Guaraní",
"QAR": "Qatari Riyal",
"RON": "Romanian Leu",
"RSD": "Serbian Dinar",
"RUB": "Russian Ruble",
"RWF": "Rwandan Franc",
"SAR": "Saudi Riyal",
"SBD": "Solomon Islands Dollar",
"SCR": "Seychellois Rupee",
"SEK": "Swedish Krona",
"SGD": "Singapore Dollar",
"SHP": "Saint Helenian Pound",
"SLL": "Sierra Leonean Leone",
"SOS": "Somali Shilling",
"SRD": "Surinamese Dollar",
"SSP": "South Sudanese Pound",
"STD": "São Tomé and Príncipe Dobra",
"SVC": "Salvadoran Colón",
"SZL": "Swazi Lilangeni",
"THB": "Thai Baht",
"TJS": "Tajikistani Somoni",
"TMT": "Turkmenistani Manat",
"TND": "Tunisian Dinar",
"TOP": "Tongan Paʻanga",
"TRY": "Turkish Lira",
"TTD": "Trinidad and Tobago Dollar",
"TWD": "New Taiwan Dollar",
"TZS": "Tanzanian Shilling",
"UAH": "Ukrainian Hryvnia",
"UGX": "Ugandan Shilling",
"USD": "US Dollar",
"UYU": "Uruguayan Peso",
"UZS": "Uzbekistan Som",
"VEF": "Venezuelan Bolívar",
"VES": "Venezuelan Bolívar Soberano",
"VND": "Vietnamese Đồng",
"VUV": "Vanuatu Vatu",
"WST": "Samoan Tala",
"XAF": "Central African Cfa Franc",
"XAG": "Silver (Troy Ounce)",
"XAU": "Gold (Troy Ounce)",
"XCD": "East Caribbean Dollar",
"XDR": "Special Drawing Rights",
"XOF": "West African Cfa Franc",
"XPD": "Palladium",
"XPF": "Cfp Franc",
"XPT": "Platinum",
"YER": "Yemeni Rial",
"ZAR": "South African Rand",
"ZMW": "Zambian Kwacha",
"ZWL": "Zimbabwean Dollar"
}
+153
View File
@@ -0,0 +1,153 @@
import base64
import hashlib
import hmac
from http import HTTPStatus
from binascii import unhexlify
from typing import Dict
from quart import url_for
import urllib
def generate_bleskomat_lnurl_hash(secret: str):
m = hashlib.sha256()
m.update(f"{secret}".encode())
return m.hexdigest()
def generate_bleskomat_lnurl_signature(
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
):
if api_key_encoding == "hex":
key = unhexlify(api_key_secret)
elif api_key_encoding == "base64":
key = base64.b64decode(api_key_secret)
else:
key = bytes(f"{api_key_secret}")
return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest()
def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
# The secret is not randomly generated by the server.
# Instead it is the hash of the API key ID and signature concatenated together.
m = hashlib.sha256()
m.update(f"{api_key_id}-{signature}".encode())
return m.hexdigest()
def get_callback_url():
return url_for("bleskomat.api_bleskomat_lnurl", _external=True)
def is_supported_lnurl_subprotocol(tag: str) -> bool:
return tag == "withdrawRequest"
class LnurlHttpError(Exception):
def __init__(
self,
message: str = "",
http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
):
self.message = message
self.http_status = http_status
super().__init__(self.message)
class LnurlValidationError(Exception):
pass
def prepare_lnurl_params(tag: str, query: Dict[str, str]):
params = {}
if not is_supported_lnurl_subprotocol(tag):
raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"')
if tag == "withdrawRequest":
params["minWithdrawable"] = float(query["minWithdrawable"])
params["maxWithdrawable"] = float(query["maxWithdrawable"])
params["defaultDescription"] = query["defaultDescription"]
if not params["minWithdrawable"] > 0:
raise LnurlValidationError('"minWithdrawable" must be greater than zero')
if not params["maxWithdrawable"] >= params["minWithdrawable"]:
raise LnurlValidationError(
'"maxWithdrawable" must be greater than or equal to "minWithdrawable"'
)
return params
encode_uri_component_safe_chars = (
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()"
)
def query_to_signing_payload(query: Dict[str, str]) -> str:
# Sort the query by key, then stringify it to create the payload.
sorted_keys = sorted(query.keys(), key=str.lower)
payload = []
for key in sorted_keys:
if not key == "signature":
encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars)
encoded_value = urllib.parse.quote(
query[key], safe=encode_uri_component_safe_chars
)
payload.append(f"{encoded_key}={encoded_value}")
return "&".join(payload)
unshorten_rules = {
"query": {"n": "nonce", "s": "signature", "t": "tag"},
"tags": {
"c": "channelRequest",
"l": "login",
"p": "payRequest",
"w": "withdrawRequest",
},
"params": {
"channelRequest": {"pl": "localAmt", "pp": "pushAmt"},
"login": {},
"payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"},
"withdrawRequest": {
"pn": "minWithdrawable",
"px": "maxWithdrawable",
"pd": "defaultDescription",
},
},
}
def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]:
new_query = {}
rules = unshorten_rules
if "tag" in query:
tag = query["tag"]
elif "t" in query:
tag = query["t"]
else:
raise LnurlValidationError('Missing required query parameter: "tag"')
# Unshorten tag:
if tag in rules["tags"]:
long_tag = rules["tags"][tag]
new_query["tag"] = long_tag
tag = long_tag
if not tag in rules["params"]:
raise LnurlValidationError(f'Unknown tag: "{tag}"')
for key in query:
if key in rules["params"][tag]:
short_param_key = key
long_param_key = rules["params"][tag][short_param_key]
if short_param_key in query:
new_query[long_param_key] = query[short_param_key]
else:
new_query[long_param_key] = query[long_param_key]
elif key in rules["query"]:
# Unshorten general keys:
short_key = key
long_key = rules["query"][short_key]
if not long_key in new_query:
if short_key in query:
new_query[long_key] = query[short_key]
else:
new_query[long_key] = query[long_key]
else:
# Keep unknown key/value pairs unchanged:
new_query[key] = query[key]
return new_query
+134
View File
@@ -0,0 +1,134 @@
import json
import math
from quart import jsonify, request
from http import HTTPStatus
import traceback
from . import bleskomat_ext
from .crud import (
create_bleskomat_lnurl,
get_bleskomat_by_api_key_id,
get_bleskomat_lnurl,
)
from .exchange_rates import (
fetch_fiat_exchange_rate,
)
from .helpers import (
generate_bleskomat_lnurl_signature,
generate_bleskomat_lnurl_secret,
LnurlHttpError,
LnurlValidationError,
prepare_lnurl_params,
query_to_signing_payload,
unshorten_lnurl_query,
)
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
@bleskomat_ext.route("/u", methods=["GET"])
async def api_bleskomat_lnurl():
try:
query = request.args.to_dict()
# Unshorten query if "s" is used instead of "signature".
if "s" in query:
query = unshorten_lnurl_query(query)
if "signature" in query:
# Signature provided.
# Use signature to verify that the URL was generated by an authorized device.
# Later validate parameters, auto-generate LNURL, reply with LNURL response object.
signature = query["signature"]
# The API key ID, nonce, and tag should be present in the query string.
for field in ["id", "nonce", "tag"]:
if not field in query:
raise LnurlHttpError(
f'Failed API key signature check: Missing "{field}"',
HTTPStatus.BAD_REQUEST,
)
# URL signing scheme is described here:
# https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme
payload = query_to_signing_payload(query)
api_key_id = query["id"]
bleskomat = await get_bleskomat_by_api_key_id(api_key_id)
if not bleskomat:
raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST)
api_key_secret = bleskomat.api_key_secret
api_key_encoding = bleskomat.api_key_encoding
expected_signature = generate_bleskomat_lnurl_signature(
payload, api_key_secret, api_key_encoding
)
if signature != expected_signature:
raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN)
# Signature is valid.
# In the case of signed URLs, the secret is deterministic based on the API key ID and signature.
secret = generate_bleskomat_lnurl_secret(api_key_id, signature)
lnurl = await get_bleskomat_lnurl(secret)
if not lnurl:
try:
tag = query["tag"]
params = prepare_lnurl_params(tag, query)
if "f" in query:
rate = await fetch_fiat_exchange_rate(
currency=query["f"],
provider=bleskomat.exchange_rate_provider,
)
# Convert fee (%) to decimal:
fee = float(bleskomat.fee) / 100
if tag == "withdrawRequest":
for key in ["minWithdrawable", "maxWithdrawable"]:
amount_sats = int(
math.floor((params[key] / rate) * 1e8)
)
fee_sats = int(math.floor(amount_sats * fee))
amount_sats_less_fee = amount_sats - fee_sats
# Convert to msats:
params[key] = int(amount_sats_less_fee * 1e3)
except LnurlValidationError as e:
raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST)
# Create a new LNURL using the query parameters provided in the signed URL.
params = json.JSONEncoder().encode(params)
lnurl = await create_bleskomat_lnurl(
bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1
)
# Reply with LNURL response object.
return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK
# No signature provided.
# Treat as "action" callback.
if not "k1" in query:
raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST)
secret = query["k1"]
lnurl = await get_bleskomat_lnurl(secret)
if not lnurl:
raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST)
if not lnurl.has_uses_remaining():
raise LnurlHttpError(
"Maximum number of uses already reached", HTTPStatus.BAD_REQUEST
)
try:
await lnurl.execute_action(query)
except LnurlValidationError as e:
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
except LnurlHttpError as e:
return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status
except Exception:
traceback.print_exc()
return (
jsonify({"status": "ERROR", "reason": "Unexpected error"}),
HTTPStatus.INTERNAL_SERVER_ERROR,
)
return jsonify({"status": "OK"}), HTTPStatus.OK
+37
View File
@@ -0,0 +1,37 @@
async def m001_initial(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS bleskomats (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
api_key_id TEXT NOT NULL,
api_key_secret TEXT NOT NULL,
api_key_encoding TEXT NOT NULL,
name TEXT NOT NULL,
fiat_currency TEXT NOT NULL,
exchange_rate_provider TEXT NOT NULL,
fee TEXT NOT NULL,
UNIQUE(api_key_id)
);
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS bleskomat_lnurls (
id TEXT PRIMARY KEY,
bleskomat TEXT NOT NULL,
wallet TEXT NOT NULL,
hash TEXT NOT NULL,
tag TEXT NOT NULL,
params TEXT NOT NULL,
api_key_id TEXT NOT NULL,
initial_uses INTEGER DEFAULT 1,
remaining_uses INTEGER DEFAULT 0,
created_time INTEGER,
updated_time INTEGER,
UNIQUE(hash)
);
"""
)
+107
View File
@@ -0,0 +1,107 @@
import json
import time
from typing import NamedTuple, Dict
from lnbits import bolt11
from lnbits.core.services import pay_invoice
from . import db
from .helpers import get_callback_url, LnurlValidationError
class Bleskomat(NamedTuple):
id: str
wallet: str
api_key_id: str
api_key_secret: str
api_key_encoding: str
name: str
fiat_currency: str
exchange_rate_provider: str
fee: str
class BleskomatLnurl(NamedTuple):
id: str
bleskomat: str
wallet: str
hash: str
tag: str
params: str
api_key_id: str
initial_uses: int
remaining_uses: int
created_time: int
updated_time: int
def has_uses_remaining(self) -> bool:
# When initial uses is 0 then the LNURL has unlimited uses.
return self.initial_uses == 0 or self.remaining_uses > 0
def get_info_response_object(self, secret: str) -> Dict[str, str]:
tag = self.tag
params = json.loads(self.params)
response = {"tag": tag}
if tag == "withdrawRequest":
for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]:
response[key] = params[key]
response["callback"] = get_callback_url()
response["k1"] = secret
return response
def validate_action(self, query: Dict[str, str]) -> None:
tag = self.tag
params = json.loads(self.params)
# Perform tag-specific checks.
if tag == "withdrawRequest":
for field in ["pr"]:
if not field in query:
raise LnurlValidationError(f'Missing required parameter: "{field}"')
# Check the bolt11 invoice(s) provided.
pr = query["pr"]
if "," in pr:
raise LnurlValidationError("Multiple payment requests not supported")
try:
invoice = bolt11.decode(pr)
except ValueError:
raise LnurlValidationError(
'Invalid parameter ("pr"): Lightning payment request expected'
)
if invoice.amount_msat < params["minWithdrawable"]:
raise LnurlValidationError(
'Amount in invoice must be greater than or equal to "minWithdrawable"'
)
if invoice.amount_msat > params["maxWithdrawable"]:
raise LnurlValidationError(
'Amount in invoice must be less than or equal to "maxWithdrawable"'
)
else:
raise LnurlValidationError(f'Unknown subprotocol: "{tag}"')
async def execute_action(self, query: Dict[str, str]):
self.validate_action(query)
used = False
async with db.connect() as conn:
if self.initial_uses > 0:
used = await self.use(conn)
if not used:
raise LnurlValidationError("Maximum number of uses already reached")
tag = self.tag
if tag == "withdrawRequest":
payment_hash = await pay_invoice(
wallet_id=self.wallet,
payment_request=query["pr"],
)
if not payment_hash:
raise LnurlValidationError("Failed to pay invoice")
async def use(self, conn) -> bool:
now = int(time.time())
result = await conn.execute(
"""
UPDATE bleskomat_lnurls
SET remaining_uses = remaining_uses - 1, updated_time = ?
WHERE id = ?
AND remaining_uses > 0
""",
(now, self.id),
)
return result.rowcount > 0
@@ -0,0 +1,216 @@
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */
Vue.component(VueQrcode.name, VueQrcode)
var mapBleskomat = function (obj) {
obj._data = _.clone(obj)
return obj
}
var defaultValues = {
name: 'My Bleskomat',
fiat_currency: 'EUR',
exchange_rate_provider: 'coinbase',
fee: '0.00'
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
checker: null,
bleskomats: [],
bleskomatsTable: {
columns: [
{
name: 'api_key_id',
align: 'left',
label: 'API Key ID',
field: 'api_key_id'
},
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name'
},
{
name: 'fiat_currency',
align: 'left',
label: 'Fiat Currency',
field: 'fiat_currency'
},
{
name: 'exchange_rate_provider',
align: 'left',
label: 'Exchange Rate Provider',
field: 'exchange_rate_provider'
},
{
name: 'fee',
align: 'left',
label: 'Fee (%)',
field: 'fee'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies),
exchangeRateProviders: _.keys(
window.bleskomat_vars.exchange_rate_providers
),
data: _.clone(defaultValues)
}
}
},
computed: {
sortedBleskomats: function () {
return this.bleskomats.sort(function (a, b) {
// Sort by API Key ID alphabetically.
var apiKeyId_A = a.api_key_id.toLowerCase()
var apiKeyId_B = b.api_key_id.toLowerCase()
return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0
})
}
},
methods: {
getBleskomats: function () {
var self = this
LNbits.api
.request(
'GET',
'/bleskomat/api/v1/bleskomats?all_wallets',
this.g.user.wallets[0].adminkey
)
.then(function (response) {
self.bleskomats = response.data.map(function (obj) {
return mapBleskomat(obj)
})
})
.catch(function (error) {
clearInterval(self.checker)
LNbits.utils.notifyApiError(error)
})
},
closeFormDialog: function () {
this.formDialog.data = _.clone(defaultValues)
},
exportConfigFile: function (bleskomatId) {
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
var fieldToKey = {
api_key_id: 'apiKey.id',
api_key_secret: 'apiKey.key',
api_key_encoding: 'apiKey.encoding',
fiat_currency: 'fiatCurrency'
}
var lines = _.chain(bleskomat)
.map(function (value, field) {
var key = fieldToKey[field] || null
return key ? [key, value].join('=') : null
})
.compact()
.value()
lines.push('callbackUrl=' + window.bleskomat_vars.callback_url)
lines.push('shorten=true')
var content = lines.join('\n')
var status = Quasar.utils.exportFile(
'bleskomat.conf',
content,
'text/plain'
)
if (status !== true) {
Quasar.plugins.Notify.create({
message: 'Browser denied file download...',
color: 'negative',
icon: null
})
}
},
openUpdateDialog: function (bleskomatId) {
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
this.formDialog.data = _.clone(bleskomat._data)
this.formDialog.show = true
},
sendFormData: function () {
var wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
if (data.id) {
this.updateBleskomat(wallet, data)
} else {
this.createBleskomat(wallet, data)
}
},
updateBleskomat: function (wallet, data) {
var self = this
LNbits.api
.request(
'PUT',
'/bleskomat/api/v1/bleskomat/' + data.id,
wallet.adminkey,
_.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee')
)
.then(function (response) {
self.bleskomats = _.reject(self.bleskomats, function (obj) {
return obj.id === data.id
})
self.bleskomats.push(mapBleskomat(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createBleskomat: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data)
.then(function (response) {
self.bleskomats.push(mapBleskomat(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteBleskomat: function (bleskomatId) {
var self = this
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
LNbits.utils
.confirmDialog(
'Are you sure you want to delete "' + bleskomat.name + '"?'
)
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/bleskomat/api/v1/bleskomat/' + bleskomatId,
_.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey
)
.then(function (response) {
self.bleskomats = _.reject(self.bleskomats, function (obj) {
return obj.id === bleskomatId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
}
},
created: function () {
if (this.g.user.wallets.length) {
var getBleskomats = this.getBleskomats
getBleskomats()
this.checker = setInterval(function () {
getBleskomats()
}, 20000)
}
}
})
@@ -0,0 +1,65 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Bleskomat Extension for lnbits"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<p>
This extension allows you to connect a Bleskomat ATM to an lnbits
wallet. It will work with both the
<a href="https://github.com/samotari/bleskomat"
>open-source DIY Bleskomat ATM project</a
>
as well as the
<a href="https://www.bleskomat.com/">commercial Bleskomat ATM</a>.
</p>
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
<div>
<ol>
<li>Click the "Add Bleskomat" button on this page to begin.</li>
<li>
Choose a wallet. This will be the wallet that is used to pay
satoshis to your ATM customers.
</li>
<li>
Choose the fiat currency. This should match the fiat currency that
your ATM accepts.
</li>
<li>
Pick an exchange rate provider. This is the API that will be used to
query the fiat to satoshi exchange rate at the time your customer
attempts to withdraw their funds.
</li>
<li>Set your ATM's fee percentage.</li>
<li>Click the "Done" button.</li>
<li>
Find the new Bleskomat in the list and then click the export icon to
download a new configuration file for your ATM.
</li>
<li>
Copy the configuration file ("bleskomat.conf") to your ATM's SD
card.
</li>
<li>
Restart Your Bleskomat ATM. It should automatically reload the
configurations from the SD card.
</li>
</ol>
</div>
<h5 class="text-subtitle1 q-my-none">How does it work?</h5>
<p>
Since the Bleskomat ATMs are designed to be offline, a cryptographic
signing scheme is used to verify that the URL was generated by an
authorized device. When one of your customers inserts fiat money into
the device, a signed URL (lnurl-withdraw) is created and displayed as a
QR code. Your customer scans the QR code with their lnurl-supporting
mobile app, their mobile app communicates with the web API of lnbits to
verify the signature, the fiat currency amount is converted to sats, the
customer accepts the withdrawal, and finally lnbits will pay the
customer from your lnbits wallet.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
@@ -0,0 +1,178 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
<script>
{% if bleskomat_vars %}
window.bleskomat_vars = {{ bleskomat_vars | tojson | safe }}
{% endif %}
</script>
<script src="/bleskomat/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
>Add Bleskomat</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Bleskomats</h5>
</div>
</div>
<q-table
dense
flat
:data="sortedBleskomats"
row-key="id"
:columns="bleskomatsTable.columns"
:pagination.sync="bleskomatsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
flat
dense
size="xs"
icon="file_download"
color="orange"
@click="exportConfigFile(props.row.id)"
>
<q-tooltip content-class="bg-accent"
>Export Configuration</q-tooltip
>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip content-class="bg-accent">Edit</q-tooltip>
</q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteBleskomat(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip content-class="bg-accent">Delete</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits Bleskomat extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "bleskomat/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.name"
type="text"
label="Name *"
></q-input>
<q-select
filled
dense
v-model="formDialog.data.fiat_currency"
:options="formDialog.fiatCurrencies"
label="Fiat Currency *"
>
</q-select>
<q-select
filled
dense
v-model="formDialog.data.exchange_rate_provider"
:options="formDialog.exchangeRateProviders"
label="Exchange Rate Provider *"
>
</q-select>
<q-input
filled
dense
v-model.number="formDialog.data.fee"
type="string"
:default="0.00"
label="Fee (%) *"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="deep-purple"
type="submit"
>Update Bleskomat</q-btn
>
<q-btn
v-else
unelevated
color="deep-purple"
:disable="
formDialog.data.wallet == null ||
formDialog.data.name == null ||
formDialog.data.fiat_currency == null ||
formDialog.data.exchange_rate_provider == null ||
formDialog.data.fee == null"
type="submit"
>Add Bleskomat</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from . import bleskomat_ext
from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies
from .helpers import get_callback_url
@bleskomat_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
bleskomat_vars = {
"callback_url": get_callback_url(),
"exchange_rate_providers": exchange_rate_providers_serializable,
"fiat_currencies": fiat_currencies,
}
return await render_template(
"bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars
)
+120
View File
@@ -0,0 +1,120 @@
from quart import g, jsonify, request
from http import HTTPStatus
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from . import bleskomat_ext
from .crud import (
create_bleskomat,
get_bleskomat,
get_bleskomats,
update_bleskomat,
delete_bleskomat,
)
from .exchange_rates import (
exchange_rate_providers,
fetch_fiat_exchange_rate,
fiat_currencies,
)
@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"])
@api_check_wallet_key("admin")
async def api_bleskomats():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return (
jsonify(
[bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)]
),
HTTPStatus.OK,
)
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["GET"])
@api_check_wallet_key("admin")
async def api_bleskomat_retrieve(bleskomat_id):
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != g.wallet.id:
return (
jsonify({"message": "Bleskomat configuration not found."}),
HTTPStatus.NOT_FOUND,
)
return jsonify(bleskomat._asdict()), HTTPStatus.OK
@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"])
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"name": {"type": "string", "empty": False, "required": True},
"fiat_currency": {
"type": "string",
"allowed": fiat_currencies.keys(),
"required": True,
},
"exchange_rate_provider": {
"type": "string",
"allowed": exchange_rate_providers.keys(),
"required": True,
},
"fee": {"type": ["string", "float", "number", "integer"], "required": True},
}
)
async def api_bleskomat_create_or_update(bleskomat_id=None):
try:
fiat_currency = g.data["fiat_currency"]
exchange_rate_provider = g.data["exchange_rate_provider"]
await fetch_fiat_exchange_rate(
currency=fiat_currency, provider=exchange_rate_provider
)
except Exception as e:
print(e)
return (
jsonify(
{
"message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"'
}
),
HTTPStatus.INTERNAL_SERVER_ERROR,
)
if bleskomat_id:
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != g.wallet.id:
return (
jsonify({"message": "Bleskomat configuration not found."}),
HTTPStatus.NOT_FOUND,
)
bleskomat = await update_bleskomat(bleskomat_id, **g.data)
else:
bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data)
return (
jsonify(bleskomat._asdict()),
HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED,
)
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["DELETE"])
@api_check_wallet_key("admin")
async def api_bleskomat_delete(bleskomat_id):
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != g.wallet.id:
return (
jsonify({"message": "Bleskomat configuration not found."}),
HTTPStatus.NOT_FOUND,
)
await delete_bleskomat(bleskomat_id)
return "", HTTPStatus.NO_CONTENT
+11
View File
@@ -0,0 +1,11 @@
<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>
+12
View File
@@ -0,0 +1,12 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_captcha")
captcha_ext: Blueprint = Blueprint(
"captcha", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Captcha",
"short_description": "Create captcha to stop spam",
"icon": "block",
"contributors": ["pseudozach"]
}
+51
View File
@@ -0,0 +1,51 @@
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,))
+67
View File
@@ -0,0 +1,67 @@
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")
+23
View File
@@ -0,0 +1,23 @@
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)
@@ -0,0 +1,80 @@
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')
return (e.src = 'http://localhost:5000/captcha/' + captchaid), e
}
document.addEventListener('DOMContentLoaded', function () {
if (captchaStyleAdded) console.log('Captcha stuff already added!')
else {
console.log('Adding captcha stuff'), (captchaStyleAdded = !0)
var t = document.createElement('style')
t.innerHTML =
"\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}"
var e = document.querySelector('script')
e.parentNode.insertBefore(t, e)
var i = document.getElementById('captchacheckbox'),
n = i.dataset,
o = 'true' === n.dark
var a = document.createElement('div')
;(a.className += ' modal-captcha-container'),
(a.innerHTML =
'\t\t<div class="modal-captcha-content"> \t<span class="close-button-captcha" style="display: none;">&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)
@@ -0,0 +1,147 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List 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>
@@ -0,0 +1,178 @@
{% 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 %}
@@ -0,0 +1,425 @@
{% 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 %}
+22
View File
@@ -0,0 +1,22 @@
from quart import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from . import captcha_ext
from .crud import get_captcha
@captcha_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("captcha/index.html", user=g.user)
@captcha_ext.route("/<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)
+121
View File
@@ -0,0 +1,121 @@
from quart import g, jsonify, request
from http import HTTPStatus
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import create_invoice, check_invoice_status
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from . import captcha_ext
from .crud import create_captcha, get_captcha, get_captchas, delete_captcha
@captcha_ext.route("/api/v1/captchas", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_captchas():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return (
jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]),
HTTPStatus.OK,
)
@captcha_ext.route("/api/v1/captchas", methods=["POST"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"url": {"type": "string", "empty": False, "required": True},
"memo": {"type": "string", "empty": False, "required": True},
"description": {
"type": "string",
"empty": True,
"nullable": True,
"required": False,
},
"amount": {"type": "integer", "min": 0, "required": True},
"remembers": {"type": "boolean", "required": True},
}
)
async def api_captcha_create():
captcha = await create_captcha(wallet_id=g.wallet.id, **g.data)
return jsonify(captcha._asdict()), HTTPStatus.CREATED
@captcha_ext.route("/api/v1/captchas/<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
+3 -1
View File
@@ -1,7 +1,9 @@
from quart import Blueprint
diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates")
diagonalley_ext: Blueprint = Blueprint(
"diagonalley", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
+53 -11
View File
@@ -21,7 +21,14 @@ regex = re.compile(
def create_diagonalleys_product(
*, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int
*,
wallet_id: str,
product: str,
categories: str,
description: str,
image: str,
price: int,
quantity: int,
) -> Products:
with open_ext_db("diagonalley") as db:
product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
@@ -30,7 +37,16 @@ def create_diagonalleys_product(
INSERT INTO products (id, wallet, product, categories, description, image, price, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(product_id, wallet_id, product, categories, description, image, price, quantity),
(
product_id,
wallet_id,
product,
categories,
description,
image,
price,
quantity,
),
)
return get_diagonalleys_product(product_id)
@@ -40,7 +56,9 @@ def update_diagonalleys_product(product_id: str, **kwargs) -> Optional[Indexers]
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("diagonalley") as db:
db.execute(f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id))
db.execute(
f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id)
)
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
return get_diagonalleys_indexer(product_id)
@@ -59,7 +77,9 @@ def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Product
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,))
rows = db.fetchall(
f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Products(**row) for row in rows]
@@ -110,7 +130,9 @@ def update_diagonalleys_indexer(indexer_id: str, **kwargs) -> Optional[Indexers]
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("diagonalley") as db:
db.execute(f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id))
db.execute(
f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id)
)
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
return get_diagonalleys_indexer(indexer_id)
@@ -154,7 +176,9 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,))
rows = db.fetchall(
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
)
for r in rows:
try:
@@ -181,7 +205,9 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
print("An exception occurred")
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,))
rows = db.fetchall(
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Indexers(**row) for row in rows]
@@ -213,7 +239,19 @@ def create_diagonalleys_order(
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(order_id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, False, False),
(
order_id,
productid,
wallet,
product,
quantity,
shippingzone,
address,
email,
invoiceid,
False,
False,
),
)
return get_diagonalleys_order(order_id)
@@ -232,9 +270,11 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
rows = db.fetchall(
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
)
for r in rows:
PAID = WALLET.get_invoice_status(r["invoiceid"]).paid
PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid
if PAID:
with open_ext_db("diagonalley") as db:
db.execute(
@@ -244,7 +284,9 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
r["id"],
),
)
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
rows = db.fetchall(
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Orders(**row) for row in rows]
+4 -4
View File
@@ -1,8 +1,8 @@
def m001_initial(db):
async def m001_initial(db):
"""
Initial products table.
"""
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
@@ -20,7 +20,7 @@ def m001_initial(db):
"""
Initial indexers table.
"""
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS indexers (
id TEXT PRIMARY KEY,
@@ -41,7 +41,7 @@ def m001_initial(db):
"""
Initial orders table.
"""
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
+65 -15
View File
@@ -36,7 +36,12 @@ async def api_diagonalley_products():
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([product._asdict() for product in get_diagonalleys_products(wallet_ids)]), HTTPStatus.OK
return (
jsonify(
[product._asdict() for product in get_diagonalleys_products(wallet_ids)]
),
HTTPStatus.OK,
)
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"])
@@ -58,16 +63,25 @@ async def api_diagonalley_product_create(product_id=None):
product = get_diagonalleys_indexer(product_id)
if not product:
return jsonify({"message": "Withdraw product does not exist."}), HTTPStatus.NOT_FOUND
return (
jsonify({"message": "Withdraw product does not exist."}),
HTTPStatus.NOT_FOUND,
)
if product.wallet != g.wallet.id:
return jsonify({"message": "Not your withdraw product."}), HTTPStatus.FORBIDDEN
return (
jsonify({"message": "Not your withdraw product."}),
HTTPStatus.FORBIDDEN,
)
product = update_diagonalleys_product(product_id, **g.data)
else:
product = create_diagonalleys_product(wallet_id=g.wallet.id, **g.data)
return jsonify(product._asdict()), HTTPStatus.OK if product_id else HTTPStatus.CREATED
return (
jsonify(product._asdict()),
HTTPStatus.OK if product_id else HTTPStatus.CREATED,
)
@diagonalley_ext.route("/api/v1/diagonalley/products/<product_id>", methods=["DELETE"])
@@ -97,7 +111,12 @@ async def api_diagonalley_indexers():
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]), HTTPStatus.OK
return (
jsonify(
[indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]
),
HTTPStatus.OK,
)
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"])
@@ -120,16 +139,25 @@ async def api_diagonalley_indexer_create(indexer_id=None):
indexer = get_diagonalleys_indexer(indexer_id)
if not indexer:
return jsonify({"message": "Withdraw indexer does not exist."}), HTTPStatus.NOT_FOUND
return (
jsonify({"message": "Withdraw indexer does not exist."}),
HTTPStatus.NOT_FOUND,
)
if indexer.wallet != g.wallet.id:
return jsonify({"message": "Not your withdraw indexer."}), HTTPStatus.FORBIDDEN
return (
jsonify({"message": "Not your withdraw indexer."}),
HTTPStatus.FORBIDDEN,
)
indexer = update_diagonalleys_indexer(indexer_id, **g.data)
else:
indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **g.data)
return jsonify(indexer._asdict()), HTTPStatus.OK if indexer_id else HTTPStatus.CREATED
return (
jsonify(indexer._asdict()),
HTTPStatus.OK if indexer_id else HTTPStatus.CREATED,
)
@diagonalley_ext.route("/api/v1/diagonalley/indexers/<indexer_id>", methods=["DELETE"])
@@ -159,7 +187,10 @@ async def api_diagonalley_orders():
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]), HTTPStatus.OK
return (
jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]),
HTTPStatus.OK,
)
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"])
@@ -221,13 +252,20 @@ async def api_diagonalleys_order_shipped(order_id):
)
order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
return jsonify([order._asdict() for order in get_diagonalleys_orders(order["wallet"])]), HTTPStatus.OK
return (
jsonify(
[order._asdict() for order in get_diagonalleys_orders(order["wallet"])]
),
HTTPStatus.OK,
)
###List products based on indexer id
@diagonalley_ext.route("/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"])
@diagonalley_ext.route(
"/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"]
)
async def api_diagonalleys_stall_products(indexer_id):
with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
@@ -239,13 +277,20 @@ async def api_diagonalleys_stall_products(indexer_id):
if not products:
return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND
return jsonify([products._asdict() for products in get_diagonalleys_products(rows[1])]), HTTPStatus.OK
return (
jsonify(
[products._asdict() for products in get_diagonalleys_products(rows[1])]
),
HTTPStatus.OK,
)
###Check a product has been shipped
@diagonalley_ext.route("/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"])
@diagonalley_ext.route(
"/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"]
)
async def api_diagonalleys_stall_checkshipped(checking_id):
with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,))
@@ -276,7 +321,9 @@ async def api_diagonalley_stall_order(indexer_id):
shippingcost = shipping.zone2cost
checking_id, payment_request = create_invoice(
wallet_id=product.wallet, amount=shippingcost + (g.data["quantity"] * product.price), memo=g.data["id"]
wallet_id=product.wallet,
amount=shippingcost + (g.data["quantity"] * product.price),
memo=g.data["id"],
)
selling_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
with open_ext_db("diagonalley") as db:
@@ -299,4 +346,7 @@ async def api_diagonalley_stall_order(indexer_id):
False,
),
)
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK
return (
jsonify({"checking_id": checking_id, "payment_request": payment_request}),
HTTPStatus.OK,
)
+6 -1
View File
@@ -1,7 +1,12 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_events")
events_ext: Blueprint = Blueprint("events", __name__, static_folder="static", template_folder="templates")
events_ext: Blueprint = Blueprint(
"events", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
+87 -91
View File
@@ -1,45 +1,48 @@
from typing import List, Optional, Union
from lnbits.db import open_ext_db
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Tickets, Events
#######TICKETS########
# TICKETS
def create_ticket(payment_hash: str, wallet: str, event: str, name: str, email: str) -> Tickets:
with open_ext_db("events") as db:
db.execute(
"""
INSERT INTO ticket (id, wallet, event, name, email, registered, paid)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(payment_hash, wallet, event, name, email, False, False),
)
async def create_ticket(
payment_hash: str, wallet: str, event: str, name: str, email: str
) -> Tickets:
await db.execute(
"""
INSERT INTO ticket (id, wallet, event, name, email, registered, paid)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(payment_hash, wallet, event, name, email, False, False),
)
return get_ticket(payment_hash)
ticket = await get_ticket(payment_hash)
assert ticket, "Newly created ticket couldn't be retrieved"
return ticket
def update_ticket(paid: bool, payment_hash: str) -> Tickets:
with open_ext_db("events") as db:
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
if row[6] == True:
return get_ticket(payment_hash)
db.execute(
async def set_ticket_paid(payment_hash: str) -> Tickets:
row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
if row[6] != True:
await db.execute(
"""
UPDATE ticket
SET paid = ?
SET paid = true
WHERE id = ?
""",
(paid, payment_hash),
(payment_hash,),
)
eventdata = get_event(row[2])
eventdata = await get_event(row[2])
assert eventdata, "Couldn't get event from ticket being paid"
sold = eventdata.sold + 1
amount_tickets = eventdata.amount_tickets - 1
db.execute(
await db.execute(
"""
UPDATE events
SET sold = ?, amount_tickets = ?
@@ -47,36 +50,36 @@ def update_ticket(paid: bool, payment_hash: str) -> Tickets:
""",
(sold, amount_tickets, row[2]),
)
return get_ticket(payment_hash)
ticket = await get_ticket(payment_hash)
assert ticket, "Newly updated ticket couldn't be retrieved"
return ticket
def get_ticket(payment_hash: str) -> Optional[Tickets]:
with open_ext_db("events") as db:
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
async def get_ticket(payment_hash: str) -> Optional[Tickets]:
row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
return Tickets(**row) if row else None
def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("events") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,))
print("scrum")
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Tickets(**row) for row in rows]
def delete_ticket(payment_hash: str) -> None:
with open_ext_db("events") as db:
db.execute("DELETE FROM ticket WHERE id = ?", (payment_hash,))
async def delete_ticket(payment_hash: str) -> None:
await db.execute("DELETE FROM ticket WHERE id = ?", (payment_hash,))
########EVENTS#########
# EVENTS
def create_event(
async def create_event(
*,
wallet: str,
name: str,
@@ -87,81 +90,74 @@ def create_event(
amount_tickets: int,
price_per_ticket: int,
) -> Events:
with open_ext_db("events") as db:
event_id = urlsafe_short_hash()
db.execute(
"""
INSERT INTO events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event_id,
wallet,
name,
info,
closing_date,
event_start_date,
event_end_date,
amount_tickets,
price_per_ticket,
0,
),
)
print(event_id)
event_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event_id,
wallet,
name,
info,
closing_date,
event_start_date,
event_end_date,
amount_tickets,
price_per_ticket,
0,
),
)
return get_event(event_id)
event = await get_event(event_id)
assert event, "Newly created event couldn't be retrieved"
return event
def update_event(event_id: str, **kwargs) -> Events:
async def update_event(event_id: str, **kwargs) -> Events:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("events") as db:
db.execute(f"UPDATE events SET {q} WHERE id = ?", (*kwargs.values(), event_id))
await db.execute(
f"UPDATE events SET {q} WHERE id = ?", (*kwargs.values(), event_id)
)
event = await get_event(event_id)
assert event, "Newly updated event couldn't be retrieved"
return event
row = db.fetchone("SELECT * FROM events WHERE id = ?", (event_id,))
async def get_event(event_id: str) -> Optional[Events]:
row = await db.fetchone("SELECT * FROM events WHERE id = ?", (event_id,))
return Events(**row) if row else None
def get_event(event_id: str) -> Optional[Events]:
with open_ext_db("events") as db:
row = db.fetchone("SELECT * FROM events WHERE id = ?", (event_id,))
return Events(**row) if row else None
def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]:
async def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("events") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM events WHERE wallet IN ({q})", (*wallet_ids,))
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM events WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Events(**row) for row in rows]
def delete_event(event_id: str) -> None:
with open_ext_db("events") as db:
db.execute("DELETE FROM events WHERE id = ?", (event_id,))
async def delete_event(event_id: str) -> None:
await db.execute("DELETE FROM events WHERE id = ?", (event_id,))
########EVENTTICKETS#########
# EVENTTICKETS
def get_event_tickets(event_id: str, wallet_id: str) -> Tickets:
with open_ext_db("events") as db:
rows = db.fetchall("SELECT * FROM ticket WHERE wallet = ? AND event = ?", (wallet_id, event_id))
print(rows)
async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]:
rows = await db.fetchall(
"SELECT * FROM ticket WHERE wallet = ? AND event = ?", (wallet_id, event_id)
)
return [Tickets(**row) for row in rows]
def reg_ticket(ticket_id: str) -> Tickets:
with open_ext_db("events") as db:
db.execute("UPDATE ticket SET registered = ? WHERE id = ?", (True, ticket_id))
ticket = db.fetchone("SELECT * FROM ticket WHERE id = ?", (ticket_id,))
print(ticket[1])
rows = db.fetchall("SELECT * FROM ticket WHERE event = ?", (ticket[1],))
async def reg_ticket(ticket_id: str) -> List[Tickets]:
await db.execute("UPDATE ticket SET registered = ? WHERE id = ?", (True, ticket_id))
ticket = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (ticket_id,))
rows = await db.fetchall("SELECT * FROM ticket WHERE event = ?", (ticket[1],))
return [Tickets(**row) for row in rows]
+8 -8
View File
@@ -1,6 +1,6 @@
def m001_initial(db):
async def m001_initial(db):
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
@@ -18,7 +18,7 @@ def m001_initial(db):
"""
)
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
@@ -33,9 +33,9 @@ def m001_initial(db):
)
def m002_changed(db):
async def m002_changed(db):
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS ticket (
id TEXT PRIMARY KEY,
@@ -50,7 +50,7 @@ def m002_changed(db):
"""
)
for row in [list(row) for row in db.fetchall("SELECT * FROM tickets")]:
for row in [list(row) for row in await db.fetchall("SELECT * FROM tickets")]:
usescsv = ""
for i in range(row[5]):
@@ -59,7 +59,7 @@ def m002_changed(db):
else:
usescsv += "," + str(1)
usescsv = usescsv[1:]
db.execute(
await db.execute(
"""
INSERT INTO ticket (
id,
@@ -82,4 +82,4 @@ def m002_changed(db):
True,
),
)
db.execute("DROP TABLE tickets")
await db.execute("DROP TABLE tickets")
+32 -11
View File
@@ -1,10 +1,10 @@
from quart import g, abort, render_template
from datetime import date, datetime
from lnbits.decorators import check_user_exists, validate_uuids
from http import HTTPStatus
from lnbits.extensions.events import events_ext
from lnbits.decorators import check_user_exists, validate_uuids
from . import events_ext
from .crud import get_ticket, get_event
@@ -17,15 +17,22 @@ async def index():
@events_ext.route("/<event_id>")
async def display(event_id):
event = get_event(event_id) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
event = await get_event(event_id)
if not event:
abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
if event.amount_tickets < 1:
return await render_template(
"events/error.html", event_name=event.name, event_error="Sorry, tickets are sold out :("
"events/error.html",
event_name=event.name,
event_error="Sorry, tickets are sold out :(",
)
datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date()
if date.today() > datetime_object:
return await render_template(
"events/error.html", event_name=event.name, event_error="Sorry, ticket closing date has passed :("
"events/error.html",
event_name=event.name,
event_error="Sorry, ticket closing date has passed :(",
)
return await render_template(
@@ -39,17 +46,31 @@ async def display(event_id):
@events_ext.route("/ticket/<ticket_id>")
async def ticket(ticket_id):
ticket = get_ticket(ticket_id) or abort(HTTPStatus.NOT_FOUND, "Ticket does not exist.")
event = get_event(ticket.event) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
ticket = await get_ticket(ticket_id)
if not ticket:
abort(HTTPStatus.NOT_FOUND, "Ticket does not exist.")
event = await get_event(ticket.event)
if not event:
abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
return await render_template(
"events/ticket.html", ticket_id=ticket_id, ticket_name=event.name, ticket_info=event.info
"events/ticket.html",
ticket_id=ticket_id,
ticket_name=event.name,
ticket_info=event.info,
)
@events_ext.route("/register/<event_id>")
async def register(event_id):
event = get_event(event_id) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
event = await get_event(event_id)
if not event:
abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
return await render_template(
"events/register.html", event_id=event_id, event_name=event.name, wallet_id=event.wallet
"events/register.html",
event_id=event_id,
event_name=event.name,
wallet_id=event.wallet,
)
+56 -38
View File
@@ -5,10 +5,10 @@ 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 lnbits.extensions.events import events_ext
from . import events_ext
from .crud import (
create_ticket,
update_ticket,
set_ticket_paid,
get_ticket,
get_tickets,
delete_ticket,
@@ -22,7 +22,7 @@ from .crud import (
)
#########Events##########
# Events
@events_ext.route("/api/v1/events", methods=["GET"])
@@ -31,9 +31,12 @@ async def api_events():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return jsonify([event._asdict() for event in get_events(wallet_ids)]), HTTPStatus.OK
return (
jsonify([event._asdict() for event in await get_events(wallet_ids)]),
HTTPStatus.OK,
)
@events_ext.route("/api/v1/events", methods=["POST"])
@@ -53,35 +56,31 @@ async def api_events():
)
async def api_event_create(event_id=None):
if event_id:
event = get_event(event_id)
print(g.data)
event = await get_event(event_id)
if not event:
return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND
if event.wallet != g.wallet.id:
return jsonify({"message": "Not your event."}), HTTPStatus.FORBIDDEN
event = update_event(event_id, **g.data)
event = await update_event(event_id, **g.data)
else:
event = create_event(**g.data)
print(event)
event = await create_event(**g.data)
return jsonify(event._asdict()), HTTPStatus.CREATED
@events_ext.route("/api/v1/events/<event_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_form_delete(event_id):
event = get_event(event_id)
event = await get_event(event_id)
if not event:
return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND
if event.wallet != g.wallet.id:
return jsonify({"message": "Not your event."}), HTTPStatus.FORBIDDEN
delete_event(event_id)
await delete_event(event_id)
return "", HTTPStatus.NO_CONTENT
@@ -94,9 +93,12 @@ async def api_tickets():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return jsonify([ticket._asdict() for ticket in get_tickets(wallet_ids)]), HTTPStatus.OK
return (
jsonify([ticket._asdict() for ticket in await get_tickets(wallet_ids)]),
HTTPStatus.OK,
)
@events_ext.route("/api/v1/tickets/<event_id>/<sats>", methods=["POST"])
@@ -107,37 +109,47 @@ async def api_tickets():
}
)
async def api_ticket_make_ticket(event_id, sats):
event = get_event(event_id)
event = await get_event(event_id)
if not event:
return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND
try:
payment_hash, payment_request = create_invoice(
wallet_id=event.wallet, amount=int(sats), memo=f"{event_id}", extra={"tag": "events"}
payment_hash, payment_request = await create_invoice(
wallet_id=event.wallet,
amount=int(sats),
memo=f"{event_id}",
extra={"tag": "events"},
)
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
ticket = create_ticket(payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data)
ticket = await create_ticket(
payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data
)
if not ticket:
return jsonify({"message": "Event could not be fetched."}), HTTPStatus.NOT_FOUND
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK
return (
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
HTTPStatus.OK,
)
@events_ext.route("/api/v1/tickets/<payment_hash>", methods=["GET"])
async def api_ticket_send_ticket(payment_hash):
ticket = get_ticket(payment_hash)
ticket = await get_ticket(payment_hash)
try:
is_paid = not check_invoice_status(ticket.wallet, payment_hash).pending
status = await check_invoice_status(ticket.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return jsonify({"message": "Not paid."}), HTTPStatus.NOT_FOUND
if is_paid:
wallet = get_wallet(ticket.wallet)
payment = wallet.get_payment(payment_hash)
payment.set_pending(False)
ticket = update_ticket(paid=True, payment_hash=payment_hash)
wallet = await get_wallet(ticket.wallet)
payment = await wallet.get_payment(payment_hash)
await payment.set_pending(False)
ticket = await set_ticket_paid(payment_hash=payment_hash)
return jsonify({"paid": True, "ticket_id": ticket.id}), HTTPStatus.OK
@@ -147,7 +159,7 @@ async def api_ticket_send_ticket(payment_hash):
@events_ext.route("/api/v1/tickets/<ticket_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_ticket_delete(ticket_id):
ticket = get_ticket(ticket_id)
ticket = await get_ticket(ticket_id)
if not ticket:
return jsonify({"message": "Ticket does not exist."}), HTTPStatus.NOT_FOUND
@@ -155,32 +167,38 @@ async def api_ticket_delete(ticket_id):
if ticket.wallet != g.wallet.id:
return jsonify({"message": "Not your ticket."}), HTTPStatus.FORBIDDEN
delete_ticket(ticket_id)
await delete_ticket(ticket_id)
return "", HTTPStatus.NO_CONTENT
#########EventTickets##########
# Event Tickets
@events_ext.route("/api/v1/eventtickets/<wallet_id>/<event_id>", methods=["GET"])
async def api_event_tickets(wallet_id, event_id):
return (
jsonify([ticket._asdict() for ticket in get_event_tickets(wallet_id=wallet_id, event_id=event_id)]),
jsonify(
[
ticket._asdict()
for ticket in await get_event_tickets(
wallet_id=wallet_id, event_id=event_id
)
]
),
HTTPStatus.OK,
)
@events_ext.route("/api/v1/register/ticket/<ticket_id>", methods=["GET"])
async def api_event_register_ticket(ticket_id):
ticket = get_ticket(ticket_id)
ticket = await get_ticket(ticket_id)
if not ticket:
return jsonify({"message": "Ticket does not exist."}), HTTPStatus.FORBIDDEN
if ticket.registered == True:
return jsonify({"message": "Ticket already registered"}), HTTPStatus.FORBIDDEN
return jsonify([ticket._asdict() for ticket in reg_ticket(ticket_id)]), HTTPStatus.OK
return (
jsonify([ticket._asdict() for ticket in await reg_ticket(ticket_id)]),
HTTPStatus.OK,
)
+5 -1
View File
@@ -1,7 +1,11 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_example")
example_ext: Blueprint = Blueprint("example", __name__, static_folder="static", template_folder="templates")
example_ext: Blueprint = Blueprint(
"example", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
+11
View File
@@ -0,0 +1,11 @@
# async def m001_initial(db):
# await db.execute(
# """
# CREATE TABLE IF NOT EXISTS example (
# id TEXT PRIMARY KEY,
# wallet TEXT NOT NULL,
# time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
# );
# """
# )
+11
View File
@@ -0,0 +1,11 @@
# from sqlite3 import Row
# from typing import NamedTuple
# class Example(NamedTuple):
# id: str
# wallet: str
#
# @classmethod
# def from_row(cls, row: Row) -> "Example":
# return cls(**dict(row))
+2 -1
View File
@@ -1,7 +1,8 @@
from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.example import example_ext
from . import example_ext
@example_ext.route("/")
+3 -3
View File
@@ -10,7 +10,7 @@
from quart import jsonify
from http import HTTPStatus
from lnbits.extensions.example import example_ext
from . import example_ext
# add your endpoints here
@@ -21,8 +21,8 @@ async def api_example():
"""Try to add descriptions for others."""
tools = [
{
"name": "Flask",
"url": "https://flask.palletsprojects.com/",
"name": "Quart",
"url": "https://pgjones.gitlab.io/quart/",
"language": "Python",
},
{
+5 -1
View File
@@ -1,7 +1,11 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_lndhub")
lndhub_ext: Blueprint = Blueprint("lndhub", __name__, static_folder="static", template_folder="templates")
lndhub_ext: Blueprint = Blueprint(
"lndhub", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
+7 -3
View File
@@ -13,11 +13,15 @@ def check_wallet(requires_admin=False):
key_type, key = b64decode(token).decode("utf-8").split(":")
if requires_admin and key_type != "admin":
return jsonify({"error": True, "code": 2, "message": "insufficient permissions"})
return jsonify(
{"error": True, "code": 2, "message": "insufficient permissions"}
)
g.wallet = get_wallet_for_key(key, key_type)
g.wallet = await get_wallet_for_key(key, key_type)
if not g.wallet:
return jsonify({"error": True, "code": 2, "message": "insufficient permissions"})
return jsonify(
{"error": True, "code": 2, "message": "insufficient permissions"}
)
return await view(**kwargs)
return wrapped_view
+1 -1
View File
@@ -1,2 +1,2 @@
def migrate():
async def migrate():
pass
+1 -1
View File
@@ -1,7 +1,7 @@
from quart import render_template, g
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.lndhub import lndhub_ext
from . import lndhub_ext
@lndhub_ext.route("/")
+43 -15
View File
@@ -8,7 +8,7 @@ from lnbits.decorators import api_validate_post_request
from lnbits.settings import WALLET
from lnbits import bolt11
from lnbits.extensions.lndhub import lndhub_ext
from . import lndhub_ext
from .decorators import check_wallet
from .utils import to_buffer, decoded_as_lndhub
@@ -23,14 +23,20 @@ async def lndhub_getinfo():
schema={
"login": {"type": "string", "required": True, "excludes": "refresh_token"},
"password": {"type": "string", "required": True, "excludes": "refresh_token"},
"refresh_token": {"type": "string", "required": True, "excludes": ["login", "password"]},
"refresh_token": {
"type": "string",
"required": True,
"excludes": ["login", "password"],
},
}
)
async def lndhub_auth():
token = (
g.data["refresh_token"]
if "refresh_token" in g.data and g.data["refresh_token"]
else urlsafe_b64encode((g.data["login"] + ":" + g.data["password"]).encode("utf-8")).decode("ascii")
else urlsafe_b64encode(
(g.data["login"] + ":" + g.data["password"]).encode("utf-8")
).decode("ascii")
)
return jsonify({"refresh_token": token, "access_token": token})
@@ -46,7 +52,7 @@ async def lndhub_auth():
)
async def lndhub_addinvoice():
try:
_, pr = create_invoice(
_, pr = await create_invoice(
wallet_id=g.wallet.id,
amount=int(g.data["amt"]),
memo=g.data["memo"],
@@ -78,7 +84,7 @@ async def lndhub_addinvoice():
@api_validate_post_request(schema={"invoice": {"type": "string", "required": True}})
async def lndhub_payinvoice():
try:
pay_invoice(
await pay_invoice(
wallet_id=g.wallet.id,
payment_request=g.data["invoice"],
extra={"tag": "lndhub"},
@@ -119,10 +125,16 @@ async def lndhub_balance():
@lndhub_ext.route("/ext/gettxs", methods=["GET"])
@check_wallet()
async def lndhub_gettxs():
for payment in g.wallet.get_payments(
complete=False, pending=True, outgoing=True, incoming=False, exclude_uncheckable=True
for payment in await g.wallet.get_payments(
complete=False,
pending=True,
outgoing=True,
incoming=False,
exclude_uncheckable=True,
):
payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
await payment.set_pending(
(await WALLET.get_payment_status(payment.checking_id)).pending
)
limit = int(request.args.get("limit", 200))
return jsonify(
@@ -135,10 +147,16 @@ async def lndhub_gettxs():
"fee": payment.fee,
"value": int(payment.amount / 1000),
"timestamp": payment.time,
"memo": payment.memo if not payment.pending else "Payment in transition",
"memo": payment.memo
if not payment.pending
else "Payment in transition",
}
for payment in reversed(
g.wallet.get_payments(pending=True, complete=True, outgoing=True, incoming=False)[:limit]
(
await g.wallet.get_payments(
pending=True, complete=True, outgoing=True, incoming=False
)
)[:limit]
)
]
)
@@ -147,11 +165,17 @@ async def lndhub_gettxs():
@lndhub_ext.route("/ext/getuserinvoices", methods=["GET"])
@check_wallet()
async def lndhub_getuserinvoices():
delete_expired_invoices()
for invoice in g.wallet.get_payments(
complete=False, pending=True, outgoing=False, incoming=True, exclude_uncheckable=True
await delete_expired_invoices()
for invoice in await g.wallet.get_payments(
complete=False,
pending=True,
outgoing=False,
incoming=True,
exclude_uncheckable=True,
):
invoice.set_pending(WALLET.get_invoice_status(invoice.checking_id).pending)
await invoice.set_pending(
(await WALLET.get_invoice_status(invoice.checking_id)).pending
)
limit = int(request.args.get("limit", 200))
return jsonify(
@@ -169,7 +193,11 @@ async def lndhub_getuserinvoices():
"type": "user_invoice",
}
for invoice in reversed(
g.wallet.get_payments(pending=True, complete=True, incoming=True, outgoing=False)[:limit]
(
await g.wallet.get_payments(
pending=True, complete=True, incoming=True, outgoing=False
)
)[:limit]
)
]
)
+5 -1
View File
@@ -1,7 +1,11 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_lnticket")
lnticket_ext: Blueprint = Blueprint("lnticket", __name__, static_folder="static", template_folder="templates")
lnticket_ext: Blueprint = Blueprint(
"lnticket", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "Support Tickets",
"short_description": "LN support ticket system",
"icon": "contact_support",
"contributors": ["benarc"]
"name": "Support Tickets",
"short_description": "LN support ticket system",
"icon": "contact_support",
"contributors": ["benarc"]
}
+97 -68
View File
@@ -1,121 +1,150 @@
from typing import List, Optional, Union
from lnbits.db import open_ext_db
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Tickets, Forms
import httpx
#######TICKETS########
async def create_ticket(
payment_hash: str,
wallet: str,
form: str,
name: str,
email: str,
ltext: str,
sats: int,
) -> Tickets:
await db.execute(
"""
INSERT INTO ticket (id, form, email, ltext, name, wallet, sats, paid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(payment_hash, form, email, ltext, name, wallet, sats, False),
)
ticket = await get_ticket(payment_hash)
assert ticket, "Newly created ticket couldn't be retrieved"
return ticket
def create_ticket(payment_hash: str, wallet: str, form: str, name: str, email: str, ltext: str, sats: int) -> Tickets:
with open_ext_db("lnticket") as db:
db.execute(
"""
INSERT INTO ticket (id, form, email, ltext, name, wallet, sats, paid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(payment_hash, form, email, ltext, name, wallet, sats, False),
)
return get_ticket(payment_hash)
def update_ticket(paid: bool, payment_hash: str) -> Tickets:
with open_ext_db("lnticket") as db:
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
if row[7] == True:
return get_ticket(payment_hash)
db.execute(
async def set_ticket_paid(payment_hash: str) -> Tickets:
row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
if row[7] == False:
await db.execute(
"""
UPDATE ticket
SET paid = ?
SET paid = true
WHERE id = ?
""",
(paid, payment_hash),
(payment_hash,),
)
formdata = get_form(row[1])
formdata = await get_form(row[1])
assert formdata, "Couldn't get form from paid ticket"
amount = formdata.amountmade + row[7]
db.execute(
await db.execute(
"""
UPDATE forms
UPDATE form
SET amountmade = ?
WHERE id = ?
""",
(amount, row[1]),
)
return get_ticket(payment_hash)
ticket = await get_ticket(payment_hash)
if formdata.webhook:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
formdata.webhook,
json={
"form": ticket.form,
"name": ticket.name,
"email": ticket.email,
"content": ticket.ltext,
},
timeout=40,
)
except AssertionError:
webhook = None
return ticket
ticket = await get_ticket(payment_hash)
return
def get_ticket(ticket_id: str) -> Optional[Tickets]:
with open_ext_db("lnticket") as db:
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (ticket_id,))
async def get_ticket(ticket_id: str) -> Optional[Tickets]:
row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (ticket_id,))
return Tickets(**row) if row else None
def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("lnticket") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,))
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Tickets(**row) for row in rows]
def delete_ticket(ticket_id: str) -> None:
with open_ext_db("lnticket") as db:
db.execute("DELETE FROM ticket WHERE id = ?", (ticket_id,))
async def delete_ticket(ticket_id: str) -> None:
await db.execute("DELETE FROM ticket WHERE id = ?", (ticket_id,))
########FORMS#########
# FORMS
def create_form(*, wallet: str, name: str, description: str, costpword: int) -> Forms:
with open_ext_db("lnticket") as db:
form_id = urlsafe_short_hash()
db.execute(
"""
INSERT INTO forms (id, wallet, name, description, costpword, amountmade)
VALUES (?, ?, ?, ?, ?, ?)
""",
(form_id, wallet, name, description, costpword, 0),
)
async def create_form(
*,
wallet: str,
name: str,
webhook: Optional[str] = None,
description: str,
costpword: int,
) -> Forms:
form_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO form (id, wallet, name, webhook, description, costpword, amountmade)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(form_id, wallet, name, webhook, description, costpword, 0),
)
return get_form(form_id)
form = await get_form(form_id)
assert form, "Newly created form couldn't be retrieved"
return form
def update_form(form_id: str, **kwargs) -> Forms:
async def update_form(form_id: str, **kwargs) -> Forms:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("lnticket") as db:
db.execute(f"UPDATE forms SET {q} WHERE id = ?", (*kwargs.values(), form_id))
row = db.fetchone("SELECT * FROM forms WHERE id = ?", (form_id,))
await db.execute(f"UPDATE form SET {q} WHERE id = ?", (*kwargs.values(), form_id))
row = await db.fetchone("SELECT * FROM form WHERE id = ?", (form_id,))
assert row, "Newly updated form couldn't be retrieved"
return Forms(**row)
async def get_form(form_id: str) -> Optional[Forms]:
row = await db.fetchone("SELECT * FROM form WHERE id = ?", (form_id,))
return Forms(**row) if row else None
def get_form(form_id: str) -> Optional[Forms]:
with open_ext_db("lnticket") as db:
row = db.fetchone("SELECT * FROM forms WHERE id = ?", (form_id,))
return Forms(**row) if row else None
def get_forms(wallet_ids: Union[str, List[str]]) -> List[Forms]:
async def get_forms(wallet_ids: Union[str, List[str]]) -> List[Forms]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("lnticket") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM forms WHERE wallet IN ({q})", (*wallet_ids,))
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM form WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Forms(**row) for row in rows]
def delete_form(form_id: str) -> None:
with open_ext_db("lnticket") as db:
db.execute("DELETE FROM forms WHERE id = ?", (form_id,))
async def delete_form(form_id: str) -> None:
await db.execute("DELETE FROM form WHERE id = ?", (form_id,))
+60 -8
View File
@@ -1,6 +1,6 @@
def m001_initial(db):
async def m001_initial(db):
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS forms (
id TEXT PRIMARY KEY,
@@ -14,7 +14,7 @@ def m001_initial(db):
"""
)
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
@@ -30,9 +30,9 @@ def m001_initial(db):
)
def m002_changed(db):
async def m002_changed(db):
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS ticket (
id TEXT PRIMARY KEY,
@@ -48,7 +48,7 @@ def m002_changed(db):
"""
)
for row in [list(row) for row in db.fetchall("SELECT * FROM tickets")]:
for row in [list(row) for row in await db.fetchall("SELECT * FROM tickets")]:
usescsv = ""
for i in range(row[5]):
@@ -57,7 +57,7 @@ def m002_changed(db):
else:
usescsv += "," + str(1)
usescsv = usescsv[1:]
db.execute(
await db.execute(
"""
INSERT INTO ticket (
id,
@@ -82,4 +82,56 @@ def m002_changed(db):
True,
),
)
db.execute("DROP TABLE tickets")
await db.execute("DROP TABLE tickets")
async def m003_changed(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS form (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
webhook TEXT,
description TEXT NOT NULL,
costpword INTEGER NOT NULL,
amountmade INTEGER NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
);
"""
)
for row in [list(row) for row in await db.fetchall("SELECT * FROM forms")]:
usescsv = ""
for i in range(row[5]):
if row[7]:
usescsv += "," + str(i + 1)
else:
usescsv += "," + str(1)
usescsv = usescsv[1:]
await db.execute(
"""
INSERT INTO form (
id,
wallet,
name,
webhook,
description,
costpword,
amountmade
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
row[0],
row[1],
row[2],
row[3],
row[4],
row[5],
row[6],
),
)
await db.execute("DROP TABLE forms")
+1
View File
@@ -5,6 +5,7 @@ class Forms(NamedTuple):
id: str
wallet: str
name: str
webhook: str
description: str
costpword: int
amountmade: int
@@ -113,6 +113,7 @@
if (sats === parseInt('{{ form_costpword }}')) {
return '0 Sats to pay'
} else {
this.formDialog.data.sats = sats
return sats + ' Sats to pay'
}
}
@@ -140,7 +141,8 @@
form: '{{ form_id }}',
name: self.formDialog.data.name,
email: self.formDialog.data.email,
ltext: self.formDialog.data.text
ltext: self.formDialog.data.text,
sats: self.formDialog.data.sats
})
.then(function (response) {
self.paymentReq = response.data.payment_request
@@ -163,21 +165,21 @@
.then(function (res) {
if (res.data.paid) {
clearInterval(paymentChecker)
dismissMsg()
self.formDialog.data.name = ''
self.formDialog.data.email = ''
self.formDialog.data.text = ''
self.$q.notify({
type: 'positive',
message: 'Sent, thank you!',
icon: null
})
self.receive = {
show: false,
status: 'complete',
paymentReq: null
}
dismissMsg()
self.formDialog.data.name = ''
self.formDialog.data.email = ''
self.formDialog.data.text = ''
self.$q.notify({
type: 'positive',
message: 'Sent, thank you!',
icon: 'thumb_up'
})
}
})
.catch(function (error) {
@@ -114,7 +114,7 @@
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-tr :props="props" v-if="props.row.paid">
<q-td auto-width>
<q-btn
unelevated
@@ -181,6 +181,14 @@
type="name"
label="Form name "
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.webhook"
type="text"
label="Webhook (optional)"
hint="A URL to be called whenever this link receives a payment."
></q-input>
<q-input
filled
dense
@@ -244,6 +252,12 @@
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
{
name: 'webhook',
align: 'left',
label: 'Webhook',
field: 'webhook'
},
{
name: 'description',
align: 'left',
@@ -255,12 +269,6 @@
align: 'left',
label: 'Cost Per Word',
field: 'costpword'
},
{
name: 'amountmade',
align: 'left',
label: 'Amount Made',
field: 'amountmade'
}
],
pagination: {
@@ -269,11 +277,11 @@
},
ticketsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'form', align: 'left', label: 'Form', field: 'form'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{name: 'ltext', align: 'left', label: 'Ticket', field: 'ltext'},
{name: 'sats', align: 'left', label: 'Paid', field: 'sats'}
{name: 'sats', align: 'left', label: 'Cost', field: 'sats'}
],
pagination: {
rowsPerPage: 10
+4 -3
View File
@@ -3,7 +3,7 @@ from quart import g, abort, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from http import HTTPStatus
from lnbits.extensions.lnticket import lnticket_ext
from . import lnticket_ext
from .crud import get_form
@@ -16,8 +16,9 @@ async def index():
@lnticket_ext.route("/<form_id>")
async def display(form_id):
form = get_form(form_id) or abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
print(form.id)
form = await get_form(form_id)
if not form:
abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
return await render_template(
"lnticket/display.html",
+52 -37
View File
@@ -6,10 +6,10 @@ 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 lnbits.extensions.lnticket import lnticket_ext
from . import lnticket_ext
from .crud import (
create_ticket,
update_ticket,
set_ticket_paid,
get_ticket,
get_tickets,
delete_ticket,
@@ -21,7 +21,7 @@ from .crud import (
)
#########FORMS##########
# FORMS
@lnticket_ext.route("/api/v1/forms", methods=["GET"])
@@ -30,9 +30,12 @@ async def api_forms():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return jsonify([form._asdict() for form in get_forms(wallet_ids)]), HTTPStatus.OK
return (
jsonify([form._asdict() for form in await get_forms(wallet_ids)]),
HTTPStatus.OK,
)
@lnticket_ext.route("/api/v1/forms", methods=["POST"])
@@ -42,13 +45,14 @@ async def api_forms():
schema={
"wallet": {"type": "string", "empty": False, "required": True},
"name": {"type": "string", "empty": False, "required": True},
"webhook": {"type": "string", "empty": False, "required": False},
"description": {"type": "string", "min": 0, "required": True},
"costpword": {"type": "integer", "min": 0, "required": True},
}
)
async def api_form_create(form_id=None):
if form_id:
form = get_form(form_id)
form = await get_form(form_id)
if not form:
return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND
@@ -56,16 +60,16 @@ async def api_form_create(form_id=None):
if form.wallet != g.wallet.id:
return jsonify({"message": "Not your form."}), HTTPStatus.FORBIDDEN
form = update_form(form_id, **g.data)
form = await update_form(form_id, **g.data)
else:
form = create_form(**g.data)
form = await create_form(**g.data)
return jsonify(form._asdict()), HTTPStatus.CREATED
@lnticket_ext.route("/api/v1/forms/<form_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_form_delete(form_id):
form = get_form(form_id)
form = await get_form(form_id)
if not form:
return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND
@@ -73,7 +77,7 @@ async def api_form_delete(form_id):
if form.wallet != g.wallet.id:
return jsonify({"message": "Not your form."}), HTTPStatus.FORBIDDEN
delete_form(form_id)
await delete_form(form_id)
return "", HTTPStatus.NO_CONTENT
@@ -87,9 +91,12 @@ async def api_tickets():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return jsonify([form._asdict() for form in get_tickets(wallet_ids)]), HTTPStatus.OK
return (
jsonify([form._asdict() for form in await get_tickets(wallet_ids)]),
HTTPStatus.OK,
)
@lnticket_ext.route("/api/v1/tickets/<form_id>", methods=["POST"])
@@ -99,46 +106,54 @@ async def api_tickets():
"name": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": True, "required": True},
"ltext": {"type": "string", "empty": False, "required": True},
"sats": {"type": "integer", "min": 0, "required": True},
}
)
async def api_ticket_make_ticket(form_id):
form = get_form(form_id)
form = await get_form(form_id)
if not form:
return jsonify({"message": "LNTicket does not exist."}), HTTPStatus.NOT_FOUND
try:
nwords = len(re.split(r"\s+", g.data["ltext"]))
sats = nwords * form.costpword
payment_hash, payment_request = create_invoice(
wallet_id=form.wallet,
amount=sats,
memo=f"ticket with {nwords} words on {form_id}",
extra={"tag": "lnticket"},
)
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
ticket = create_ticket(payment_hash=payment_hash, wallet=form.wallet, sats=sats, **g.data)
nwords = len(re.split(r"\s+", g.data["ltext"]))
sats = g.data["sats"]
payment_hash, payment_request = await create_invoice(
wallet_id=form.wallet,
amount=sats,
memo=f"ticket with {nwords} words on {form_id}",
extra={"tag": "lnticket"},
)
ticket = await create_ticket(
payment_hash=payment_hash, wallet=form.wallet, **g.data
)
if not ticket:
return jsonify({"message": "LNTicket could not be fetched."}), HTTPStatus.NOT_FOUND
return (
jsonify({"message": "LNTicket could not be fetched."}),
HTTPStatus.NOT_FOUND,
)
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK
return (
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
HTTPStatus.OK,
)
@lnticket_ext.route("/api/v1/tickets/<payment_hash>", methods=["GET"])
async def api_ticket_send_ticket(payment_hash):
ticket = get_ticket(payment_hash)
ticket = await get_ticket(payment_hash)
try:
is_paid = not check_invoice_status(ticket.wallet, payment_hash).pending
status = await check_invoice_status(ticket.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return jsonify({"message": "Not paid."}), HTTPStatus.NOT_FOUND
return jsonify({"paid": False}), HTTPStatus.OK
if is_paid:
wallet = get_wallet(ticket.wallet)
payment = wallet.get_payment(payment_hash)
payment.set_pending(False)
ticket = update_ticket(paid=True, payment_hash=payment_hash)
return jsonify({"paid": True, "ticket_id": ticket.id}), HTTPStatus.OK
wallet = await get_wallet(ticket.wallet)
payment = await wallet.get_payment(payment_hash)
await payment.set_pending(False)
ticket = await set_ticket_paid(payment_hash=payment_hash)
return jsonify({"paid": True}), HTTPStatus.OK
return jsonify({"paid": False}), HTTPStatus.OK
@@ -146,7 +161,7 @@ async def api_ticket_send_ticket(payment_hash):
@lnticket_ext.route("/api/v1/tickets/<ticket_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_ticket_delete(ticket_id):
ticket = get_ticket(ticket_id)
ticket = await get_ticket(ticket_id)
if not ticket:
return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
@@ -154,6 +169,6 @@ async def api_ticket_delete(ticket_id):
if ticket.wallet != g.wallet.id:
return jsonify({"message": "Not your ticket."}), HTTPStatus.FORBIDDEN
delete_ticket(ticket_id)
await delete_ticket(ticket_id)
return "", HTTPStatus.NO_CONTENT
+5 -1
View File
@@ -1,7 +1,11 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_lnurlp")
lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", template_folder="templates")
lnurlp_ext: Blueprint = Blueprint(
"lnurlp", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
+64 -84
View File
@@ -1,14 +1,10 @@
import json
from typing import List, Optional, Union
from lnbits.db import open_ext_db
from lnbits.core.models import Payment
from quart import g
from . import db
from .models import PayLink
def create_pay_link(
async def create_pay_link(
*,
wallet_id: str,
description: str,
@@ -19,96 +15,80 @@ def create_pay_link(
webhook_url: Optional[str] = None,
success_text: Optional[str] = None,
success_url: Optional[str] = None,
) -> Optional[PayLink]:
with open_ext_db("lnurlp") as db:
db.execute(
"""
INSERT INTO pay_links (
wallet,
description,
min,
max,
served_meta,
served_pr,
webhook_url,
success_text,
success_url,
comment_chars,
currency
)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
""",
(
wallet_id,
description,
min,
max,
webhook_url,
success_text,
success_url,
comment_chars,
currency,
),
) -> PayLink:
result = await db.execute(
"""
INSERT INTO pay_links (
wallet,
description,
min,
max,
served_meta,
served_pr,
webhook_url,
success_text,
success_url,
comment_chars,
currency
)
link_id = db.cursor.lastrowid
return get_pay_link(link_id)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
""",
(
wallet_id,
description,
min,
max,
webhook_url,
success_text,
success_url,
comment_chars,
currency,
),
)
link_id = result._result_proxy.lastrowid
link = await get_pay_link(link_id)
assert link, "Newly created link couldn't be retrieved"
return link
def get_pay_link(link_id: int) -> Optional[PayLink]:
with open_ext_db("lnurlp") as db:
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
async def get_pay_link(link_id: int) -> Optional[PayLink]:
row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("lnurlp") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(
f"""
SELECT * FROM pay_links WHERE wallet IN ({q})
ORDER BY Id
""",
(*wallet_ids,),
)
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"""
SELECT * FROM pay_links WHERE wallet IN ({q})
ORDER BY Id
""",
(*wallet_ids,),
)
return [PayLink.from_row(row) for row in rows]
def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("lnurlp") as db:
db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
with open_ext_db("lnurlp") as db:
db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
def delete_pay_link(link_id: int) -> None:
with open_ext_db("lnurlp") as db:
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status
g.db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
await db.execute(
f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
)
row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
await db.execute(
f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
)
row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
async def delete_pay_link(link_id: int) -> None:
await db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
-48
View File
@@ -1,48 +0,0 @@
import trio # type: ignore
import httpx
async def get_fiat_rate(currency: str):
assert currency == "USD", "Only USD is supported as fiat currency."
return await get_usd_rate()
async def get_usd_rate():
"""
Returns an average satoshi price from multiple sources.
"""
satoshi_prices = [None, None, None]
async def fetch_price(index, url, getter):
try:
async with httpx.AsyncClient() as client:
r = await client.get(url)
r.raise_for_status()
satoshi_price = int(100_000_000 / float(getter(r.json())))
satoshi_prices[index] = satoshi_price
except Exception:
pass
async with trio.open_nursery() as nursery:
nursery.start_soon(
fetch_price,
0,
"https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD",
lambda d: d["result"]["XXBTCZUSD"]["c"][0],
)
nursery.start_soon(
fetch_price,
1,
"https://www.bitstamp.net/api/v2/ticker/btcusd",
lambda d: d["last"],
)
nursery.start_soon(
fetch_price,
2,
"https://api.coincap.io/v2/rates/bitcoin",
lambda d: d["data"]["rateUsd"],
)
satoshi_prices = [x for x in satoshi_prices if x]
return sum(satoshi_prices) / len(satoshi_prices)
+27 -11
View File
@@ -5,19 +5,22 @@ from quart import jsonify, url_for, request
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
from lnbits.core.services import create_invoice
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
from . import lnurlp_ext
from .crud import increment_pay_link
from .helpers import get_fiat_rate
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
async def api_lnurl_response(link_id):
link = increment_pay_link(link_id, served_meta=1)
link = await increment_pay_link(link_id, served_meta=1)
if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
return (
jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}),
HTTPStatus.OK,
)
rate = await get_fiat_rate(link.currency) if link.currency else 1
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
resp = LnurlPayResponse(
callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True),
min_sendable=math.ceil(link.min * rate) * 1000,
@@ -34,12 +37,15 @@ async def api_lnurl_response(link_id):
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"])
async def api_lnurl_callback(link_id):
link = increment_pay_link(link_id, served_pr=1)
link = await increment_pay_link(link_id, served_pr=1)
if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
return (
jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}),
HTTPStatus.OK,
)
min, max = link.min, link.max
rate = await get_fiat_rate(link.currency) if link.currency else 1
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
if link.currency:
# allow some fluctuation (as the fiat price may have changed between the calls)
min = rate * 995 * link.min
@@ -51,12 +57,20 @@ async def api_lnurl_callback(link_id):
amount_received = int(request.args.get("amount"))
if amount_received < min:
return (
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is smaller than minimum {min}.").dict()),
jsonify(
LnurlErrorResponse(
reason=f"Amount {amount_received} is smaller than minimum {min}."
).dict()
),
HTTPStatus.OK,
)
elif amount_received > max:
return (
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict()),
jsonify(
LnurlErrorResponse(
reason=f"Amount {amount_received} is greater than maximum {max}."
).dict()
),
HTTPStatus.OK,
)
@@ -71,11 +85,13 @@ async def api_lnurl_callback(link_id):
HTTPStatus.OK,
)
payment_hash, payment_request = create_invoice(
payment_hash, payment_request = await create_invoice(
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo=link.description,
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
description_hash=hashlib.sha256(
link.lnurlpay_metadata.encode("utf-8")
).digest(),
extra={"tag": "lnurlp", "link": link.id, "comment": comment},
)
+18 -14
View File
@@ -1,8 +1,8 @@
def m001_initial(db):
async def m001_initial(db):
"""
Initial pay table.
"""
db.execute(
await db.execute(
"""
CREATE TABLE IF NOT EXISTS pay_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -16,14 +16,14 @@ def m001_initial(db):
)
def m002_webhooks_and_success_actions(db):
async def m002_webhooks_and_success_actions(db):
"""
Webhooks and success actions.
"""
db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;")
db.execute("ALTER TABLE pay_links ADD COLUMN success_text TEXT;")
db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;")
db.execute(
await db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;")
await db.execute("ALTER TABLE pay_links ADD COLUMN success_text TEXT;")
await db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;")
await db.execute(
"""
CREATE TABLE invoices (
pay_link INTEGER NOT NULL REFERENCES pay_links (id),
@@ -35,14 +35,18 @@ def m002_webhooks_and_success_actions(db):
)
def m003_min_max_comment_fiat(db):
async def m003_min_max_comment_fiat(db):
"""
Support for min/max amounts, comments and fiat prices that get
converted automatically to satoshis based on some API.
"""
db.execute("ALTER TABLE pay_links ADD COLUMN currency TEXT;") # null = satoshis
db.execute("ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;")
db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;")
db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;")
db.execute("UPDATE pay_links SET max = min;")
db.execute("DROP TABLE invoices")
await db.execute(
"ALTER TABLE pay_links ADD COLUMN currency TEXT;"
) # null = satoshis
await db.execute(
"ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;"
)
await db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;")
await db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;")
await db.execute("UPDATE pay_links SET max = min;")
await db.execute("DROP TABLE invoices")
+3 -3
View File
@@ -3,9 +3,9 @@ from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
from quart import url_for
from typing import NamedTuple, Optional, Dict
from sqlite3 import Row
from lnurl import Lnurl, encode as lnurl_encode
from lnurl.types import LnurlPayMetadata
from lnurl.models import LnurlPaySuccessAction, MessageAction, UrlAction
from lnurl import Lnurl, encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from lnurl.models import LnurlPaySuccessAction, MessageAction, UrlAction # type: ignore
class PayLink(NamedTuple):
@@ -26,6 +26,7 @@ new Vue({
mixins: [windowMixin],
data() {
return {
currencies: [],
fiatRates: {},
checker: null,
payLinks: [],
@@ -203,5 +204,14 @@ new Vue({
getPayLinks()
}, 20000)
}
LNbits.api
.request('GET', '/lnurlp/api/v1/currencies')
.then(response => {
this.currencies = ['satoshis', ...response.data]
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
})
+20 -6
View File
@@ -1,10 +1,12 @@
import trio # type: ignore
import json
import httpx
from lnbits.core import db as core_db
from lnbits.core.models import Payment
from lnbits.tasks import run_on_pseudo_request, register_invoice_listener
from lnbits.tasks import register_invoice_listener
from .crud import mark_webhook_sent, get_pay_link
from .crud import get_pay_link
async def register_listeners():
@@ -15,7 +17,7 @@ async def register_listeners():
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
await run_on_pseudo_request(on_invoice_paid, payment)
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
@@ -27,7 +29,7 @@ async def on_invoice_paid(payment: Payment) -> None:
# this webhook has already been sent
return
pay_link = get_pay_link(payment.extra.get("link", -1))
pay_link = await get_pay_link(payment.extra.get("link", -1))
if pay_link and pay_link.webhook_url:
async with httpx.AsyncClient() as client:
try:
@@ -42,6 +44,18 @@ async def on_invoice_paid(payment: Payment) -> None:
},
timeout=40,
)
mark_webhook_sent(payment, r.status_code)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
mark_webhook_sent(payment, -1)
await mark_webhook_sent(payment, -1)
async def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status
await core_db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)
@@ -17,7 +17,7 @@
<code>[&lt;pay_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}pay/api/v1/links -H "X-Api-Key: {{
>curl -X GET {{ request.url_root }}api/v0/links -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
@@ -38,7 +38,7 @@
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt; -H
>curl -X GET {{ request.url_root }}api/v1/links/&lt;pay_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
@@ -63,10 +63,9 @@
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}pay/api/v1/links -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
>curl -X POST {{ request.url_root }}api/v1/links -d '{"description":
&lt;string&gt;, "amount": &lt;integer&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
@@ -93,7 +92,7 @@
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt; -d
>curl -X PUT {{ request.url_root }}api/v1/links/&lt;pay_id&gt; -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
@@ -120,8 +119,8 @@
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
>curl -X DELETE {{ request.url_root }}api/v1/links/&lt;pay_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
@@ -133,7 +133,7 @@
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
@@ -182,7 +182,7 @@
<div class="col">
<q-select
dense
:options='["satoshis", "USD"]'
:options="currencies"
v-model="formDialog.data.currency"
:display-value="formDialog.data.currency || 'satoshis'"
label="Currency"
@@ -205,7 +205,7 @@
v-model="formDialog.data.webhook_url"
type="text"
label="Webhook URL (optional)"
hint="An URL to be called whenever this link receives a payment."
hint="A URL to be called whenever this link receives a payment."
></q-input>
<q-input
filled
+9 -3
View File
@@ -3,7 +3,7 @@ from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.lnurlp import lnurlp_ext
from . import lnurlp_ext
from .crud import get_pay_link
@@ -16,11 +16,17 @@ async def index():
@lnurlp_ext.route("/<link_id>")
async def display(link_id):
link = get_pay_link(link_id) or abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
link = await get_pay_link(link_id)
if not link:
abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
return await render_template("lnurlp/display.html", link=link)
@lnurlp_ext.route("/print/<link_id>")
async def print_qr(link_id):
link = get_pay_link(link_id) or abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
link = await get_pay_link(link_id)
if not link:
abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
return await render_template("lnurlp/print_qr.html", link=link)
+35 -15
View File
@@ -4,8 +4,9 @@ from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
from lnbits.extensions.lnurlp import lnurlp_ext # type: ignore
from . import lnurlp_ext
from .crud import (
create_pay_link,
get_pay_link,
@@ -13,7 +14,11 @@ from .crud import (
update_pay_link,
delete_pay_link,
)
from .helpers import get_fiat_rate
@lnurlp_ext.route("/api/v1/currencies", methods=["GET"])
async def api_list_currencies_available():
return jsonify(list(currencies.keys()))
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
@@ -22,16 +27,25 @@ async def api_links():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
try:
return (
jsonify([{**link._asdict(), **{"lnurl": link.lnurl}} for link in get_pay_links(wallet_ids)]),
jsonify(
[
{**link._asdict(), **{"lnurl": link.lnurl}}
for link in await get_pay_links(wallet_ids)
]
),
HTTPStatus.OK,
)
except LnurlInvalidUrl:
return (
jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}),
jsonify(
{
"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
}
),
HTTPStatus.UPGRADE_REQUIRED,
)
@@ -39,7 +53,7 @@ async def api_links():
@lnurlp_ext.route("/api/v1/links/<link_id>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_link_retrieve(link_id):
link = get_pay_link(link_id)
link = await get_pay_link(link_id)
if not link:
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND
@@ -58,7 +72,7 @@ async def api_link_retrieve(link_id):
"description": {"type": "string", "empty": False, "required": True},
"min": {"type": "number", "min": 0.01, "required": True},
"max": {"type": "number", "min": 0.01, "required": True},
"currency": {"type": "string", "allowed": ["USD"], "nullable": True, "required": False},
"currency": {"type": "string", "nullable": True, "required": False},
"comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800},
"webhook_url": {"type": "string", "required": False},
"success_text": {"type": "string", "required": False},
@@ -75,25 +89,31 @@ async def api_link_create_or_update(link_id=None):
return jsonify({"message": "Must use full satoshis."}), HTTPStatus.BAD_REQUEST
if link_id:
link = get_pay_link(link_id)
link = await get_pay_link(link_id)
if not link:
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND
return (
jsonify({"message": "Pay link does not exist."}),
HTTPStatus.NOT_FOUND,
)
if link.wallet != g.wallet.id:
return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN
link = update_pay_link(link_id, **g.data)
link = await update_pay_link(link_id, **g.data)
else:
link = create_pay_link(wallet_id=g.wallet.id, **g.data)
link = await create_pay_link(wallet_id=g.wallet.id, **g.data)
return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK if link_id else HTTPStatus.CREATED
return (
jsonify({**link._asdict(), **{"lnurl": link.lnurl}}),
HTTPStatus.OK if link_id else HTTPStatus.CREATED,
)
@lnurlp_ext.route("/api/v1/links/<link_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_link_delete(link_id):
link = get_pay_link(link_id)
link = await get_pay_link(link_id)
if not link:
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND
@@ -101,7 +121,7 @@ async def api_link_delete(link_id):
if link.wallet != g.wallet.id:
return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN
delete_pay_link(link_id)
await delete_pay_link(link_id)
return "", HTTPStatus.NO_CONTENT
@@ -109,7 +129,7 @@ async def api_link_delete(link_id):
@lnurlp_ext.route("/api/v1/rate/<currency>", methods=["GET"])
async def api_check_fiat_rate(currency):
try:
rate = await get_fiat_rate(currency)
rate = await get_fiat_rate_satoshis(currency)
except AssertionError:
rate = None
+1
View File
@@ -0,0 +1 @@
# Offline Shop
+14
View File
@@ -0,0 +1,14 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_offlineshop")
offlineshop_ext: Blueprint = Blueprint(
"offlineshop", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa
from .lnurl import * # noqa
@@ -0,0 +1,8 @@
{
"name": "OfflineShop",
"short_description": "Receive payments for products offline!",
"icon": "nature_people",
"contributors": [
"fiatjaf"
]
}
+101
View File
@@ -0,0 +1,101 @@
from typing import List, Optional
from . import db
from .wordlists import animals
from .models import Shop, Item
async def create_shop(*, wallet_id: str) -> int:
result = await db.execute(
"""
INSERT INTO shops (wallet, wordlist, method)
VALUES (?, ?, 'wordlist')
""",
(wallet_id, "\n".join(animals)),
)
return result._result_proxy.lastrowid
async def get_shop(id: int) -> Optional[Shop]:
row = await db.fetchone("SELECT * FROM shops WHERE id = ?", (id,))
return Shop(**dict(row)) if row else None
async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]:
row = await db.fetchone("SELECT * FROM shops WHERE wallet = ?", (wallet,))
if not row:
# create on the fly
ls_id = await create_shop(wallet_id=wallet)
return await get_shop(ls_id)
return Shop(**dict(row)) if row else None
async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]:
await db.execute(
"UPDATE shops SET method = ?, wordlist = ? WHERE id = ?",
(method, wordlist, shop),
)
return await get_shop(shop)
async def add_item(
shop: int,
name: str,
description: str,
image: Optional[str],
price: int,
unit: str,
) -> int:
result = await db.execute(
"""
INSERT INTO items (shop, name, description, image, price, unit)
VALUES (?, ?, ?, ?, ?, ?)
""",
(shop, name, description, image, price, unit),
)
return result._result_proxy.lastrowid
async def update_item(
shop: int,
item_id: int,
name: str,
description: str,
image: Optional[str],
price: int,
unit: str,
) -> int:
await db.execute(
"""
UPDATE items SET
name = ?,
description = ?,
image = ?,
price = ?,
unit = ?
WHERE shop = ? AND id = ?
""",
(name, description, image, price, unit, shop, item_id),
)
return item_id
async def get_item(id: int) -> Optional[Item]:
row = await db.fetchone("SELECT * FROM items WHERE id = ? LIMIT 1", (id,))
return Item(**dict(row)) if row else None
async def get_items(shop: int) -> List[Item]:
rows = await db.fetchall("SELECT * FROM items WHERE shop = ?", (shop,))
return [Item(**dict(row)) for row in rows]
async def delete_item_from_shop(shop: int, item_id: int):
await db.execute(
"""
DELETE FROM items WHERE shop = ? AND id = ?
""",
(shop, item_id),
)

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