Compare commits
239
Commits
0.2.0
..
raspiblitz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcecf6d431 | ||
|
|
7134821fcd | ||
|
|
b55cc82f1c | ||
|
|
2aa4a93da1 | ||
|
|
232de067a4 | ||
|
|
daff019920 | ||
|
|
6ae3690797 | ||
|
|
33e2bdb7fb | ||
|
|
d908f912ee | ||
|
|
e7bb78b0c7 | ||
|
|
ffe39de391 | ||
|
|
3d489bf2ee | ||
|
|
42b3359d12 | ||
|
|
cbeb888761 | ||
|
|
a413685160 | ||
|
|
943237e7ff | ||
|
|
efd9c6917f | ||
|
|
f08d86c6df | ||
|
|
27e170d304 | ||
|
|
a3bd36d44f | ||
|
|
1af3964039 | ||
|
|
808ded509d | ||
|
|
0f998419e7 | ||
|
|
05a0861601 | ||
|
|
aa2e09bbeb | ||
|
|
9f7a97f58e | ||
|
|
3d54de7976 | ||
|
|
86c03fa5d9 | ||
|
|
237a6b369f | ||
|
|
9fa05e43f9 | ||
|
|
5eba071acc | ||
|
|
94acbd84e0 | ||
|
|
49827a64da | ||
|
|
63da1b0962 | ||
|
|
492d710bb8 | ||
|
|
b9b8e6a162 | ||
|
|
74074d148e | ||
|
|
f131e18969 | ||
|
|
da9723735c | ||
|
|
2a198d3186 | ||
|
|
c6f4372d23 | ||
|
|
529717a0f6 | ||
|
|
69f867f692 | ||
|
|
52e7a28fc2 | ||
|
|
42cf70e158 | ||
|
|
a3fbcd46cd | ||
|
|
e17b9422ad | ||
|
|
76199c66a1 | ||
|
|
10671e5732 | ||
|
|
95a32b0d7b | ||
|
|
980bb69151 | ||
|
|
e38b945d5c | ||
|
|
fdf4f6c1ae | ||
|
|
61f6e2a4f2 | ||
|
|
d872f0d138 | ||
|
|
6a18766a82 | ||
|
|
f84e965e13 | ||
|
|
cbdf97f949 | ||
|
|
4219513a8a | ||
|
|
64bb1b5444 | ||
|
|
70a0ec8a1e | ||
|
|
b48a44bcd0 | ||
|
|
c26f6fcbdf | ||
|
|
6a7c28eb0e | ||
|
|
8d3d828e49 | ||
|
|
b7090d963b | ||
|
|
2d8f85f77c | ||
|
|
a27a3885f3 | ||
|
|
90319bfd8c | ||
|
|
82731dc901 | ||
|
|
59aaea47fd | ||
|
|
f5d06653a6 | ||
|
|
744662fb63 | ||
|
|
55748b427b | ||
|
|
96082978d1 | ||
|
|
d7c3c4e51c | ||
|
|
ed5aeccb8e | ||
|
|
b2841ee909 | ||
|
|
69ef4d7e63 | ||
|
|
ae5d45a73e | ||
|
|
6f42413107 | ||
|
|
946279674a | ||
|
|
11679a4fa7 | ||
|
|
045e069312 | ||
|
|
0db516b6e0 | ||
|
|
b05b8c0115 | ||
|
|
510ccbc007 | ||
|
|
08d124c62a | ||
|
|
e112258c39 | ||
|
|
b2efd71d3c | ||
|
|
61c8d70fe7 | ||
|
|
3215b5d2bb | ||
|
|
574358a118 | ||
|
|
85011d23c3 | ||
|
|
9cc7052920 | ||
|
|
6164b9d27e | ||
|
|
a787d60d93 | ||
|
|
aabe3364f4 | ||
|
|
d4d069fc77 | ||
|
|
42bd5ea989 | ||
|
|
3333f1f3f3 | ||
|
|
e6ea77e682 | ||
|
|
8e62d9287d | ||
|
|
7e4a42e7ff | ||
|
|
f27c2ebc21 | ||
|
|
e4fb18a53a | ||
|
|
fa383ced1c | ||
|
|
ddb277ff92 | ||
|
|
1e9151cedc | ||
|
|
20ef9f92a1 | ||
|
|
c86bd74506 | ||
|
|
ad545e7fe1 | ||
|
|
66cde0154b | ||
|
|
422298948c | ||
|
|
09f89d07dd | ||
|
|
f42dab5e30 | ||
|
|
86744ebced | ||
|
|
514329045f | ||
|
|
7cd3487bc9 | ||
|
|
1a460a2ce5 | ||
|
|
b5c4fe905f | ||
|
|
d511e92a04 | ||
|
|
d69c5aa2b1 | ||
|
|
b5ea8e2546 | ||
|
|
8df4dd702d | ||
|
|
1bc59974a8 | ||
|
|
0bc66ea150 | ||
|
|
adc3e62573 | ||
|
|
5142f1e29f | ||
|
|
d13ca2afdb | ||
|
|
a653a5327b | ||
|
|
d9b9d1e9b2 | ||
|
|
4c8ce1bfed | ||
|
|
1630a28da0 | ||
|
|
80e7c2c50f | ||
|
|
c7717a611a | ||
|
|
773103f893 | ||
|
|
63ae553565 | ||
|
|
cda0819f64 | ||
|
|
732d06c1e5 | ||
|
|
f571f5f840 | ||
|
|
d0039ca71c | ||
|
|
33a90a8de3 | ||
|
|
1f4218d5c2 | ||
|
|
4fee785229 | ||
|
|
97e519d569 | ||
|
|
3bfca5b7b7 | ||
|
|
13f440d517 | ||
|
|
2d4e9202f1 | ||
|
|
d913ab1306 | ||
|
|
0ed281e0fb | ||
|
|
c03c463171 | ||
|
|
569f43fe81 | ||
|
|
5c894ff707 | ||
|
|
d95159d73b | ||
|
|
198cff8449 | ||
|
|
81326b2368 | ||
|
|
da20588c84 | ||
|
|
be6ee64b4e | ||
|
|
6e4f8431b7 | ||
|
|
a93903a183 | ||
|
|
385406a78a | ||
|
|
ecfcc167f0 | ||
|
|
9724f33111 | ||
|
|
4425c43726 | ||
|
|
9c8836096c | ||
|
|
ca60893701 | ||
|
|
32a3dbb772 | ||
|
|
02a8b86168 | ||
|
|
042052e4e5 | ||
|
|
8cefb2f8fe | ||
|
|
bd46c0be91 | ||
|
|
0ba26975d4 | ||
|
|
4500078773 | ||
|
|
c6437f3fbc | ||
|
|
dbe028e1eb | ||
|
|
ec193ca697 | ||
|
|
ea51af90d0 | ||
|
|
378e408dca | ||
|
|
e251291076 | ||
|
|
88eb8e0e78 | ||
|
|
9c126696fc | ||
|
|
9dd040186d | ||
|
|
45148d285e | ||
|
|
e96c92e6df | ||
|
|
14b6e21a02 | ||
|
|
2a25932a85 | ||
|
|
d811d69dac | ||
|
|
b6cb305e5c | ||
|
|
babaa4459e | ||
|
|
f45aadaafe | ||
|
|
f6010bc139 | ||
|
|
33b48341f1 | ||
|
|
dc4a786dda | ||
|
|
10414be0d2 | ||
|
|
e1abea77f5 | ||
|
|
8a7652c06f | ||
|
|
be0f853346 | ||
|
|
afa7fce1c4 | ||
|
|
94b3416300 | ||
|
|
c082c953f3 | ||
|
|
128888be92 | ||
|
|
c5c5bdb287 | ||
|
|
b91271315a | ||
|
|
bd4a668d23 | ||
|
|
399a07b98a | ||
|
|
493ab5464e | ||
|
|
6c4b5ea406 | ||
|
|
c0d7371137 | ||
|
|
e96ec08f44 | ||
|
|
2eb44674d0 | ||
|
|
307a919d17 | ||
|
|
3c398a8276 | ||
|
|
1c922a5ddc | ||
|
|
4623220316 | ||
|
|
7d44fa1c2d | ||
|
|
4c7c792390 | ||
|
|
54c0c9f3a8 | ||
|
|
257c9c1c64 | ||
|
|
2db3325727 | ||
|
|
c62678b3aa | ||
|
|
48cf23346c | ||
|
|
503c981bc9 | ||
|
|
31b1c0d1d5 | ||
|
|
ae59c74c24 | ||
|
|
913f2a37c1 | ||
|
|
93417e5bdf | ||
|
|
a838706090 | ||
|
|
52956a62a2 | ||
|
|
ccb9d107d5 | ||
|
|
edc533e902 | ||
|
|
9db327d5db | ||
|
|
16fff9899f | ||
|
|
67e8c567a3 | ||
|
|
409f62dee7 | ||
|
|
d4e718d29b | ||
|
|
d84915cb00 | ||
|
|
03706dcbad | ||
|
|
d3fc52cd49 |
+1
-1
@@ -43,7 +43,7 @@ LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bit
|
||||
LND_REST_MACAROON="HEXSTRING"
|
||||
|
||||
# LNPayWallet
|
||||
LNPAY_API_ENDPOINT=https://lnpay.co/v1/
|
||||
LNPAY_API_ENDPOINT=https://api.lnpay.co/v1/
|
||||
LNPAY_API_KEY=LNPAY_API_KEY
|
||||
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
|
||||
|
||||
@@ -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" ./
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
env:
|
||||
LNBITS_BACKEND_WALLET_CLASS: LNPayWallet
|
||||
LNBITS_FORCE_HTTPS: 0
|
||||
LNPAY_API_ENDPOINT: https://lnpay.co/v1/
|
||||
LNPAY_API_ENDPOINT: https://api.lnpay.co/v1/
|
||||
LNPAY_API_KEY: sak_gG5pSFZhFgOLHm26a8hcWvXKt98yd
|
||||
LNPAY_ADMIN_KEY: waka_HqWfOoNE0TPqmQHSYErbF4n9
|
||||
LNPAY_INVOICE_KEY: waki_ZqFEbhrTyopuPlOZButZUw
|
||||
|
||||
+44
-4
@@ -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()'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ bitstring = "*"
|
||||
cerberus = "*"
|
||||
ecdsa = "*"
|
||||
environs = "*"
|
||||
lnurl = "*"
|
||||
lnurl = "==0.3.6"
|
||||
pyscss = "*"
|
||||
shortuuid = "*"
|
||||
quart = "*"
|
||||
@@ -21,12 +21,17 @@ secure = "*"
|
||||
typing-extensions = "*"
|
||||
httpx = "*"
|
||||
quart-trio = "*"
|
||||
trio = "*"
|
||||
trio = "==0.16.0"
|
||||
hypercorn = {extras = ["trio"], version = "*"}
|
||||
sqlalchemy-aio = "*"
|
||||
embit = "*"
|
||||
pyqrcode = "*"
|
||||
pypng = "*"
|
||||
sqlalchemy = "==1.3.23"
|
||||
|
||||
[dev-packages]
|
||||
black = "==20.8b1"
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
mypy = "==0.761"
|
||||
mypy = "latest"
|
||||
pytest-trio = "*"
|
||||
|
||||
Generated
+399
-243
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "193ef930ac0906127fd292b2fc7ba5b4d786227b6795d182dad9b57448fca75e"
|
||||
"sha256": "e12af74353e8bea3f97bf2aea16a1ba0a6e4c3a08042ce7368187a06e7791e2c"
|
||||
},
|
||||
"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"
|
||||
@@ -94,17 +99,18 @@
|
||||
},
|
||||
"cerberus": {
|
||||
"hashes": [
|
||||
"sha256:302e6694f206dd85cb63f13fd5025b31ab6d38c99c50c6d769f8fa0b0f299589"
|
||||
"sha256:7aff49bc793e58a88ac14bffc3eca0f67e077881d3c62c621679a621294dd174",
|
||||
"sha256:eec10585c33044fb7c69650bc5b68018dac0443753337e2b07684ee0f3c83329"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.2"
|
||||
"version": "==1.3.3"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
|
||||
"sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
|
||||
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
|
||||
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
|
||||
],
|
||||
"version": "==2020.6.20"
|
||||
"version": "==2020.12.5"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
@@ -116,26 +122,34 @@
|
||||
},
|
||||
"ecdsa": {
|
||||
"hashes": [
|
||||
"sha256:494c6a853e9ed2e9be33d160b41d47afc50a6629b993d2b9c5ad7bb226add892",
|
||||
"sha256:ca359c971594dceebf334f3d623dae43163ab161c7d09f28cae70a86df26eb7a"
|
||||
"sha256:881fa5e12bb992972d3d1b3d4dfbe149ab76a89f13da02daa5ea1ec7dea6e747",
|
||||
"sha256:cfc046a2ddd425adbd1a78b3c46f0d1325c657811c0f45ecc3a0a6236c1e50ff"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.16.0"
|
||||
"version": "==0.16.1"
|
||||
},
|
||||
"embit": {
|
||||
"hashes": [
|
||||
"sha256:7c4264d7ede8e2c114db10585270874c9df809c68d2e21db918872e3245b5f2b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.2.1"
|
||||
},
|
||||
"environs": {
|
||||
"hashes": [
|
||||
"sha256:a98005aab7613b6fe7a1af7192a5163f72a52d3348d3918e6c7a2a32e4012779",
|
||||
"sha256:bf3fd6bc54fcfd7f512ddcb80a7781f0ced2b0c83dd123d619e9468ecdaaf537"
|
||||
"sha256:2eb671afd37e6e9820131b918bbbcaa6658d0fb420ebf35bdfb750ae39c51a66",
|
||||
"sha256:6bef733b88cc901e787cf24fb2eaa72621b0656226ea4e332ab24ed0cba36fcf"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==8.0.0"
|
||||
"version": "==9.3.2"
|
||||
},
|
||||
"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 +169,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 +204,10 @@
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
"sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16",
|
||||
"sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"
|
||||
],
|
||||
"version": "==2.10"
|
||||
"version": "==3.1"
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
@@ -205,18 +219,19 @@
|
||||
},
|
||||
"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": [
|
||||
"sha256:aaff8552cd4c02f8ae30c82dd3ebe6c6c3a8e06cbc59991281534ba0bbe12890"
|
||||
"sha256:579982fd8c4d25bc84c61c74ec45cb7999fa1fa2426f5d5aeb0160ba333b9c92",
|
||||
"sha256:8af07460115a48f3122a5a9c9a6062bee3897d5f6ab4c9a60f6561a83a8234f6"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.3.5"
|
||||
"version": "==0.3.6"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
@@ -225,8 +240,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 +254,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:0dd42891a5ef288217ed6410917f3c6048f585f8692075a0052c24f9bfff9dfd",
|
||||
"sha256:16e99cb7f630c0ef4d7d364ed0109ac194268dde123966076ab3dafb9ae3906b"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==3.8.0"
|
||||
"version": "==3.11.1"
|
||||
},
|
||||
"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 +316,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 +366,18 @@
|
||||
},
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d",
|
||||
"sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"
|
||||
"sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a",
|
||||
"sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2"
|
||||
],
|
||||
"version": "==0.14.0"
|
||||
"version": "==0.17.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 +389,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 +447,86 @@
|
||||
},
|
||||
"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:040bdfc1d76a9074717a3f43455685f781c581f94472b010cd6c4754754e1862",
|
||||
"sha256:1fe5d8d39118c2b018c215c37b73fd6893c3e1d4895be745ca8ff6eb83333ed3",
|
||||
"sha256:23927c3981d1ec6b4ea71eb99d28424b874d9c696a21e5fbd9fa322718be3708",
|
||||
"sha256:24f9569e82a009a09ce2d263559acb3466eba2617203170e4a0af91e75b4f075",
|
||||
"sha256:2578dbdbe4dbb0e5126fb37ffcd9793a25dcad769a95f171a2161030bea850ff",
|
||||
"sha256:269990b3ab53cb035d662dcde51df0943c1417bdab707dc4a7e4114a710504b4",
|
||||
"sha256:29cccc9606750fe10c5d0e8bd847f17a97f3850b8682aef1f56f5d5e1a5a64b1",
|
||||
"sha256:37b83bf81b4b85dda273aaaed5f35ea20ad80606f672d94d2218afc565fb0173",
|
||||
"sha256:63677d0c08524af4c5893c18dbe42141de7178001360b3de0b86217502ed3601",
|
||||
"sha256:639940bbe1108ac667dcffc79925db2966826c270112e9159439ab6bb14f8d80",
|
||||
"sha256:6a939a868fdaa4b504e8b9d4a61f21aac11e3fecc8a8214455e144939e3d2aea",
|
||||
"sha256:6b8b8c80c7f384f06825612dd078e4a31f0185e8f1f6b8c19e188ff246334205",
|
||||
"sha256:6c9e6cc9237de5660bcddea63f332428bb83c8e2015c26777281f7ffbd2efb84",
|
||||
"sha256:6ec1044908414013ebfe363450c22f14698803ce97fbb47e53284d55c5165848",
|
||||
"sha256:6fca33672578666f657c131552c4ef8979c1606e494f78cd5199742dfb26918b",
|
||||
"sha256:751934967f5336a3e26fc5993ccad1e4fee982029f9317eb6153bc0bc3d2d2da",
|
||||
"sha256:8be835aac18ec85351385e17b8665bd4d63083a7160a017bef3d640e8e65cadb",
|
||||
"sha256:927ce09e49bff3104459e1451ce82983b0a3062437a07d883a4c66f0b344c9b5",
|
||||
"sha256:94208867f34e60f54a33a37f1c117251be91a47e3bfdb9ab8a7847f20886ad06",
|
||||
"sha256:94f667d86be82dd4cb17d08de0c3622e77ca865320e0b95eae6153faa7b4ecaf",
|
||||
"sha256:9e9c25522933e569e8b53ccc644dc993cab87e922fb7e142894653880fdd419d",
|
||||
"sha256:a0e306e9bb76fd93b29ae3a5155298e4c1b504c7cbc620c09c20858d32d16234",
|
||||
"sha256:a8bfc1e1afe523e94974132d7230b82ca7fa2511aedde1f537ec54db0399541a",
|
||||
"sha256:ac2244e64485c3778f012951fdc869969a736cd61375fde6096d08850d8be729",
|
||||
"sha256:b4b0e44d586cd64b65b507fa116a3814a1a53d55dce4836d7c1a6eb2823ff8d1",
|
||||
"sha256:baeb451ee23e264de3f577fee5283c73d9bbaa8cb921d0305c0bbf700094b65b",
|
||||
"sha256:c7dc052432cd5d060d7437e217dd33c97025287f99a69a50e2dc1478dd610d64",
|
||||
"sha256:d1a85dfc5dee741bf49cb9b6b6b8d2725a268e4992507cf151cba26b17d97c37",
|
||||
"sha256:d90010304abb4102123d10cbad2cdf2c25a9f2e66a50974199b24b468509bad5",
|
||||
"sha256:ddfb511e76d016c3a160910642d57f4587dc542ce5ee823b0d415134790eeeb9",
|
||||
"sha256:e273367f4076bd7b9a8dc2e771978ef2bfd6b82526e80775a7db52bff8ca01dd",
|
||||
"sha256:e5bb3463df697279e5459a7316ad5a60b04b0107f9392e88674d0ece70e9cf70",
|
||||
"sha256:e8a1750b44ad6422ace82bf3466638f1aa0862dbb9689690d5f2f48cce3476c8",
|
||||
"sha256:eab063a70cca4a587c28824e18be41d8ecc4457f8f15b2933584c6c6cccd30f0",
|
||||
"sha256:ecce8c021894a77d89808222b1ff9687ad84db54d18e4bd0500ca766737faaf6",
|
||||
"sha256:f4d972139d5000105fcda9539a76452039434013570d6059993120dc2a65e447",
|
||||
"sha256:fd3b96f8c705af8e938eaa99cbd8fd1450f632d38cad55e7367c33b263bf98ec",
|
||||
"sha256:fdd2ed7395df8ac2dbb10cefc44737b66c6a5cd7755c92524733d7a443e5b7e2"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.23"
|
||||
},
|
||||
"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 +547,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 +572,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 +595,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 +702,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 +733,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 +749,138 @@
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9",
|
||||
"sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92"
|
||||
"sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634",
|
||||
"sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==6.1.1"
|
||||
"version": "==6.2.3"
|
||||
},
|
||||
"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:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5",
|
||||
"sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79",
|
||||
"sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31",
|
||||
"sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500",
|
||||
"sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11",
|
||||
"sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14",
|
||||
"sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3",
|
||||
"sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439",
|
||||
"sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c",
|
||||
"sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82",
|
||||
"sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711",
|
||||
"sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093",
|
||||
"sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a",
|
||||
"sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb",
|
||||
"sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8",
|
||||
"sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17",
|
||||
"sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000",
|
||||
"sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d",
|
||||
"sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480",
|
||||
"sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc",
|
||||
"sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0",
|
||||
"sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9",
|
||||
"sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765",
|
||||
"sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e",
|
||||
"sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a",
|
||||
"sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07",
|
||||
"sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f",
|
||||
"sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac",
|
||||
"sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7",
|
||||
"sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed",
|
||||
"sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968",
|
||||
"sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7",
|
||||
"sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2",
|
||||
"sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4",
|
||||
"sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87",
|
||||
"sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8",
|
||||
"sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10",
|
||||
"sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29",
|
||||
"sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605",
|
||||
"sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6",
|
||||
"sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"
|
||||
],
|
||||
"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.4.4"
|
||||
},
|
||||
"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:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace",
|
||||
"sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff",
|
||||
"sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266",
|
||||
"sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528",
|
||||
"sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6",
|
||||
"sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808",
|
||||
"sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4",
|
||||
"sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363",
|
||||
"sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341",
|
||||
"sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04",
|
||||
"sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41",
|
||||
"sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e",
|
||||
"sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3",
|
||||
"sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899",
|
||||
"sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805",
|
||||
"sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c",
|
||||
"sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c",
|
||||
"sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39",
|
||||
"sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a",
|
||||
"sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3",
|
||||
"sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7",
|
||||
"sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f",
|
||||
"sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075",
|
||||
"sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0",
|
||||
"sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40",
|
||||
"sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428",
|
||||
"sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927",
|
||||
"sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3",
|
||||
"sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f",
|
||||
"sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"
|
||||
],
|
||||
"version": "==1.4.1"
|
||||
"version": "==1.4.3"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
|
||||
@@ -10,7 +10,7 @@ LNbits
|
||||
|
||||

|
||||
|
||||
# LNbits v0.1 BETA, free and open-source lightning-network wallet/accounts system
|
||||
# LNbits v0.3 BETA, free and open-source lightning-network wallet/accounts system
|
||||
|
||||
Use [lnbits.com](https://lnbits.com), or run your own LNbits server!
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
## 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)!
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,10 +54,10 @@ Using this wallet requires the installation of the `lndgrpc` and `purerpc` Pytho
|
||||
|
||||
### LNPay
|
||||
|
||||
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret.
|
||||
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
|
||||
- `LNPAY_API_ENDPOINT`: https://lnpay.co/v1/
|
||||
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/
|
||||
- `LNPAY_API_KEY`: sak_apiKey
|
||||
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||
|
||||
|
||||
+15
-4
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+219
-72
@@ -1,41 +1,51 @@
|
||||
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 urllib.parse import urlparse
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME
|
||||
|
||||
from .models import User, Wallet, Payment
|
||||
from . import db
|
||||
from .models import User, Wallet, Payment, BalanceCheck
|
||||
|
||||
|
||||
# accounts
|
||||
# --------
|
||||
|
||||
|
||||
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 +55,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 +85,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 +128,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 +143,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 +168,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 +199,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 +284,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 +297,7 @@ def delete_expired_invoices() -> None:
|
||||
# --------
|
||||
|
||||
|
||||
def create_payment(
|
||||
async def create_payment(
|
||||
*,
|
||||
wallet_id: str,
|
||||
checking_id: str,
|
||||
@@ -253,13 +309,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 +329,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,19 +356,101 @@ 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
|
||||
""",
|
||||
SELECT checking_id FROM apipayments
|
||||
WHERE hash = ? AND pending AND amount > 0
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
else:
|
||||
return row["checking_id"]
|
||||
|
||||
|
||||
# balance_check
|
||||
# -------------
|
||||
|
||||
|
||||
async def save_balance_check(
|
||||
wallet_id: str,
|
||||
url: str,
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
domain = urlparse(url).netloc
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO balance_check (wallet, service, url)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(wallet_id, domain, url),
|
||||
)
|
||||
|
||||
|
||||
async def get_balance_check(
|
||||
wallet_id: str,
|
||||
domain: str,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[BalanceCheck]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT wallet, service, url
|
||||
FROM balance_check
|
||||
WHERE wallet = ? AND service = ?
|
||||
""",
|
||||
(wallet_id, domain),
|
||||
)
|
||||
return BalanceCheck.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_balance_checks(conn: Optional[Connection] = None) -> List[BalanceCheck]:
|
||||
rows = await (conn or db).fetchall("SELECT wallet, service, url FROM balance_check")
|
||||
return [BalanceCheck.from_row(row) for row in rows]
|
||||
|
||||
|
||||
# balance_notify
|
||||
# --------------
|
||||
|
||||
|
||||
async def save_balance_notify(
|
||||
wallet_id: str,
|
||||
url: str,
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO balance_notify (wallet, url)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(wallet_id, url),
|
||||
)
|
||||
|
||||
|
||||
async def get_balance_notify(
|
||||
wallet_id: str,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[str]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT url
|
||||
FROM balance_notify
|
||||
WHERE wallet = ?
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
return row[0] if row else None
|
||||
|
||||
+90
-20
@@ -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,87 @@ 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")
|
||||
|
||||
|
||||
async def m004_ensure_fees_are_always_negative(db):
|
||||
"""
|
||||
Use abs() so wallet backends don't have to care about the sign of the fees.
|
||||
"""
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW IF NOT EXISTS balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
FROM apipayments
|
||||
WHERE amount > 0 AND pending = 0 -- don't sum pending
|
||||
GROUP BY wallet
|
||||
UNION ALL
|
||||
SELECT wallet, SUM(amount - abs(fee)) AS s -- outgoing, sum fees
|
||||
FROM apipayments
|
||||
WHERE amount < 0 -- do sum pending
|
||||
GROUP BY wallet
|
||||
)
|
||||
GROUP BY wallet;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m005_balance_check_balance_notify(db):
|
||||
"""
|
||||
Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE balance_check (
|
||||
wallet INTEGER NOT NULL REFERENCES wallets (id),
|
||||
service TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
|
||||
UNIQUE(wallet, service)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE balance_notify (
|
||||
wallet INTEGER NOT NULL REFERENCES wallets (id),
|
||||
url TEXT NOT NULL,
|
||||
|
||||
UNIQUE(wallet, url)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
+60
-16
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
from quart import url_for
|
||||
from ecdsa import SECP256k1, SigningKey # type: ignore
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from typing import List, NamedTuple, Optional, Dict
|
||||
from sqlite3 import Row
|
||||
|
||||
@@ -36,6 +38,25 @@ class Wallet(NamedTuple):
|
||||
def balance(self) -> int:
|
||||
return self.balance_msat // 1000
|
||||
|
||||
@property
|
||||
def withdrawable_balance(self) -> int:
|
||||
from .services import fee_reserve
|
||||
|
||||
return self.balance_msat - fee_reserve(self.balance_msat)
|
||||
|
||||
@property
|
||||
def lnurlwithdraw_full(self) -> str:
|
||||
url = url_for(
|
||||
"core.lnurl_full_withdraw",
|
||||
usr=self.user,
|
||||
wal=self.id,
|
||||
_external=True,
|
||||
)
|
||||
try:
|
||||
return lnurl_encode(url)
|
||||
except:
|
||||
return ""
|
||||
|
||||
def lnurlauth_key(self, domain: str) -> SigningKey:
|
||||
hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest()
|
||||
linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256")
|
||||
@@ -46,24 +67,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 +105,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 +122,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 +148,44 @@ 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)
|
||||
if self.is_out and status.failed:
|
||||
print(f" - deleting outgoing failed payment {self.checking_id}: {status}")
|
||||
await self.delete()
|
||||
elif not status.pending:
|
||||
print(
|
||||
f" - marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
|
||||
)
|
||||
await self.set_pending(status.pending)
|
||||
|
||||
def delete(self) -> None:
|
||||
async def delete(self) -> None:
|
||||
from .crud import delete_payment
|
||||
|
||||
delete_payment(self.checking_id)
|
||||
await delete_payment(self.checking_id)
|
||||
|
||||
|
||||
class BalanceCheck(NamedTuple):
|
||||
wallet: str
|
||||
service: str
|
||||
url: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
|
||||
|
||||
+181
-94
@@ -5,8 +5,8 @@ from io import BytesIO
|
||||
from binascii import unhexlify
|
||||
from typing import Optional, Tuple, Dict
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from quart import g
|
||||
from lnurl import LnurlErrorResponse, LnurlWithdrawResponse # type: ignore
|
||||
from quart import g, url_for
|
||||
from lnurl import LnurlErrorResponse, decode as decode_lnurl # type: ignore
|
||||
|
||||
try:
|
||||
from typing import TypedDict # type: ignore
|
||||
@@ -14,34 +14,53 @@ 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(
|
||||
class PaymentFailure(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvoiceFailure(Exception):
|
||||
pass
|
||||
|
||||
|
||||
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:
|
||||
raise Exception(error_message or "Unexpected backend error.")
|
||||
raise InvoiceFailure(error_message or "Unexpected backend error.")
|
||||
|
||||
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 +68,177 @@ 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
|
||||
await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve(invoice.amount_msat),
|
||||
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 PaymentFailure(
|
||||
payment.error_message
|
||||
or "Payment failed, but backend didn't give us an error message."
|
||||
)
|
||||
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo: Optional[str] = None) -> None:
|
||||
_, payment_request = create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=res.max_sats,
|
||||
memo=memo or res.default_description or "",
|
||||
extra={"tag": "lnurlwallet"},
|
||||
)
|
||||
async def redeem_lnurl_withdraw(
|
||||
wallet_id: str,
|
||||
lnurl_request: str,
|
||||
memo: Optional[str] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
wait_seconds: int = 0,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
if not lnurl_request:
|
||||
return None
|
||||
|
||||
res = {}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
lnurl = decode_lnurl(lnurl_request)
|
||||
r = await client.get(str(lnurl))
|
||||
res = r.json()
|
||||
|
||||
try:
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=int(res["maxWithdrawable"] / 1000),
|
||||
memo=memo or res["defaultDescription"] or "",
|
||||
extra=extra,
|
||||
conn=conn,
|
||||
)
|
||||
except:
|
||||
print(
|
||||
f"failed to create invoice on redeem_lnurl_withdraw from {lnurl}. params: {res}"
|
||||
)
|
||||
return None
|
||||
|
||||
if wait_seconds:
|
||||
await trio.sleep(wait_seconds)
|
||||
|
||||
params = {
|
||||
"k1": res["k1"],
|
||||
"pr": payment_request,
|
||||
}
|
||||
|
||||
try:
|
||||
params["balanceNotify"] = url_for(
|
||||
"core.lnurl_balance_notify",
|
||||
service=urlparse(lnurl_request).netloc,
|
||||
wal=wallet_id,
|
||||
_external=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.get(
|
||||
res.callback.base,
|
||||
params={**res.callback.query_params, **{"k1": res.k1, "pr": payment_request}},
|
||||
res["callback"],
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
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 +309,17 @@ 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)
|
||||
|
||||
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
return max(1000, int(amount_msat * 0.01))
|
||||
|
||||
@@ -211,6 +211,9 @@ new Vue({
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
paymentTableRowKey: function (row) {
|
||||
return row.payment_hash + row.amount
|
||||
},
|
||||
closeCamera: function () {
|
||||
this.parse.camera.show = false
|
||||
},
|
||||
@@ -584,18 +587,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 +607,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 +619,6 @@ new Vue({
|
||||
created: function () {
|
||||
this.fetchBalance()
|
||||
this.fetchPayments()
|
||||
setTimeout(this.checkPendingPayments(), 1200)
|
||||
},
|
||||
mounted: function () {
|
||||
// show disclaimer
|
||||
|
||||
+57
-6
@@ -1,8 +1,13 @@
|
||||
import trio # type: ignore
|
||||
import httpx
|
||||
from typing import List
|
||||
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from . import db
|
||||
from .crud import get_balance_notify
|
||||
from .models import Payment
|
||||
|
||||
sse_listeners: List[trio.MemorySendChannel] = []
|
||||
|
||||
|
||||
@@ -14,9 +19,55 @@ 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)
|
||||
|
||||
# dispatch balance_notify
|
||||
url = await get_balance_notify(payment.wallet_id)
|
||||
if url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
url,
|
||||
timeout=4,
|
||||
)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
pass
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
@@ -55,8 +60,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": <int>, "memo": <string>}' -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
|
||||
"amount": <int>, "memo": <string>, "webhook":
|
||||
<url:string>}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -88,21 +88,81 @@
|
||||
<div class="col-12 col-md-3 col-lg-3">
|
||||
<div class="row q-col-gutter-lg justify-center">
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn
|
||||
flat
|
||||
color="purple"
|
||||
label="Supporters"
|
||||
type="a"
|
||||
href="mailto:lnbits@pm.me"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
<a href="https://github.com/rootzoll/raspiblitz">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/raspiblitz-negative.svg' : '/static/images/raspiblitz.svg'"
|
||||
style="height: 90px"
|
||||
></q-img>
|
||||
</a>
|
||||
<q-btn flat color="purple" label="Runs on" class="full-width"></q-btn>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://github.com/ElementsProject/lightning">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/cln.png' : '/static/images/clnl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://github.com/lightningnetwork/lnd">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lnd.png' : '/static/images/lnd.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://opennode.com">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/opennode.png' : '/static/images/opennodel.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://lnpay.co/">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lnpay.png' : '/static/images/lnpayl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://github.com/shesek/spark-wallet">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/spark.png' : '/static/images/sparkl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://t.me/lntxbot">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lntxbot.png' : '/static/images/lntxbotl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://github.com/rootzoll/raspiblitz">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/blitz.png' : '/static/images/blitzl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://getumbrel.com/">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/umbrel.png' : '/static/images/umbrell.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user, wallet) }}
|
||||
<script src="/core/static/js/wallet.js"></script>
|
||||
<link rel="manifest" href="/manifest/{{ user.id }}.webmanifest" />
|
||||
{% endblock %}
|
||||
<!---->
|
||||
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
|
||||
@@ -87,9 +88,10 @@
|
||||
dense
|
||||
flat
|
||||
:data="filteredPayments"
|
||||
row-key="payment_hash"
|
||||
:row-key="paymentTableRowKey"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
no-data-label="No transactions made yet"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
@@ -219,20 +221,61 @@
|
||||
<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 />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
LNbits wallet: <strong><em>{{ wallet.name }}</em></strong>
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-list>
|
||||
{% include "core/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
|
||||
{% if wallet.lnurlwithdraw_full %}
|
||||
<q-expansion-item group="extras" icon="crop_free" label="Drain Funds">
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This is an LNURL-withdraw QR code for slurping everything from
|
||||
this wallet. Do not share with anyone.
|
||||
</p>
|
||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||
<qrcode
|
||||
value="{{wallet.lnurlwithdraw_full}}"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</a>
|
||||
<p>
|
||||
It is compatible with <code>balanceCheck</code> and
|
||||
<code>balanceNotify</code> so your wallet may keep pulling the
|
||||
funds continuously from here after the first withdraw.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
{% endif %}
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="settings_cell"
|
||||
label="Export to Phone with QR Code"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This QR code contains your wallet URL with full access. You
|
||||
can scan it from your phone to open your wallet from there.
|
||||
</p>
|
||||
<qrcode
|
||||
:value="'{{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"
|
||||
@@ -291,7 +334,7 @@
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||
:disable="receive.data.memo == null || receive.data.amount == null || receive.data.amount <= 0"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="receive.lnurl">
|
||||
@@ -425,7 +468,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"
|
||||
|
||||
+114
-52
@@ -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, url_for
|
||||
from http import HTTPStatus
|
||||
from binascii import unhexlify
|
||||
from typing import Dict, Union
|
||||
@@ -12,9 +11,15 @@ 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 ..services import create_invoice, pay_invoice, perform_lnurlauth
|
||||
from ..crud import delete_expired_invoices
|
||||
from .. import core_app, db
|
||||
from ..crud import save_balance_check
|
||||
from ..services import (
|
||||
PaymentFailure,
|
||||
InvoiceFailure,
|
||||
create_invoice,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
)
|
||||
from ..tasks import sse_listeners
|
||||
|
||||
|
||||
@@ -36,22 +41,29 @@ 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},
|
||||
"lnurl_balance_check": {"type": "string", "required": False},
|
||||
"extra": {"type": "dict", "nullable": True, "required": False},
|
||||
"webhook": {"type": "string", "empty": False, "required": False},
|
||||
}
|
||||
)
|
||||
async def api_payments_create_invoice():
|
||||
@@ -62,23 +74,42 @@ 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
|
||||
)
|
||||
except Exception as e:
|
||||
g.db.rollback()
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
async with db.connect() as conn:
|
||||
try:
|
||||
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 InvoiceFailure as e:
|
||||
return jsonify({"message": str(e)}), 520
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
lnurl_response: Union[None, bool, str] = None
|
||||
if g.data.get("lnurl_callback"):
|
||||
if "lnurl_balance_check" in g.data:
|
||||
save_balance_check(g.wallet.id, g.data["lnurl_balance_check"])
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
g.data["lnurl_callback"],
|
||||
params={"pr": payment_request},
|
||||
params={
|
||||
"pr": payment_request,
|
||||
"balanceNotify": url_for(
|
||||
"core.lnurl_balance_notify",
|
||||
service=urlparse(g.data["lnurl_callback"]).netloc,
|
||||
wal=g.wallet.id,
|
||||
_external=True,
|
||||
),
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_error:
|
||||
@@ -107,18 +138,23 @@ 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 PaymentFailure as e:
|
||||
return jsonify({"message": str(e)}), 520
|
||||
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 +183,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 +214,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 +239,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 +269,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 +277,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 +373,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())
|
||||
|
||||
@@ -344,6 +400,12 @@ async def api_lnurlscan(code: str):
|
||||
parsed_callback: ParseResult = urlparse(data.callback)
|
||||
qs: Dict = parse_qs(parsed_callback.query)
|
||||
qs["k1"] = data.k1
|
||||
|
||||
# balanceCheck/balanceNotify
|
||||
if "balanceCheck" in jdata:
|
||||
params.update(balanceCheck=jdata["balanceCheck"])
|
||||
|
||||
# format callback url and send to client
|
||||
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
|
||||
params.update(callback=urlunparse(parsed_callback))
|
||||
|
||||
|
||||
+152
-40
@@ -1,14 +1,19 @@
|
||||
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 lnurl import LnurlResponse, LnurlWithdrawResponse, decode as decode_lnurl # type: ignore
|
||||
from quart import (
|
||||
g,
|
||||
abort,
|
||||
jsonify,
|
||||
request,
|
||||
redirect,
|
||||
render_template,
|
||||
send_from_directory,
|
||||
url_for,
|
||||
)
|
||||
|
||||
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 lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE, LNBITS_SITE_TITLE
|
||||
|
||||
from ..crud import (
|
||||
create_account,
|
||||
@@ -16,18 +21,24 @@ from ..crud import (
|
||||
update_user_extension,
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_balance_check,
|
||||
save_balance_notify,
|
||||
)
|
||||
from ..services import redeem_lnurl_withdraw
|
||||
from ..services import redeem_lnurl_withdraw, pay_invoice
|
||||
|
||||
|
||||
@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,65 @@ 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("/withdraw")
|
||||
@validate_uuids(["usr", "wal"], required=True)
|
||||
async def lnurl_full_withdraw():
|
||||
user = await get_user(request.args.get("usr"))
|
||||
if not user:
|
||||
return jsonify({"status": "ERROR", "reason": "User does not exist."})
|
||||
|
||||
wallet = user.get_wallet(request.args.get("wal"))
|
||||
if not wallet:
|
||||
return jsonify({"status": "ERROR", "reason": "Wallet does not exist."})
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"tag": "withdrawRequest",
|
||||
"callback": url_for(
|
||||
"core.lnurl_full_withdraw_callback",
|
||||
usr=user.id,
|
||||
wal=wallet.id,
|
||||
_external=True,
|
||||
),
|
||||
"k1": "0",
|
||||
"minWithdrawable": 1 if wallet.withdrawable_balance else 0,
|
||||
"maxWithdrawable": wallet.withdrawable_balance,
|
||||
"defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
|
||||
"balanceCheck": url_for(
|
||||
"core.lnurl_full_withdraw", usr=user.id, wal=wallet.id, _external=True
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@core_app.route("/withdraw/cb")
|
||||
@validate_uuids(["usr", "wal"], required=True)
|
||||
async def lnurl_full_withdraw_callback():
|
||||
user = await get_user(request.args.get("usr"))
|
||||
if not user:
|
||||
return jsonify({"status": "ERROR", "reason": "User does not exist."})
|
||||
|
||||
wallet = user.get_wallet(request.args.get("wal"))
|
||||
if not wallet:
|
||||
return jsonify({"status": "ERROR", "reason": "Wallet does not exist."})
|
||||
|
||||
pr = request.args.get("pr")
|
||||
|
||||
async def pay():
|
||||
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
|
||||
|
||||
g.nursery.start_soon(pay)
|
||||
|
||||
balance_notify = request.args.get("balanceNotify")
|
||||
if balance_notify:
|
||||
await save_balance_notify(wallet.id, balance_notify)
|
||||
|
||||
return jsonify({"status": "OK"})
|
||||
|
||||
|
||||
@core_app.route("/deletewallet")
|
||||
@@ -95,7 +173,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:
|
||||
@@ -104,30 +182,64 @@ async def deletewallet():
|
||||
return redirect(url_for("core.home"))
|
||||
|
||||
|
||||
@core_app.route("/withdraw/notify/<service>")
|
||||
@validate_uuids(["wal"], required=True)
|
||||
async def lnurl_balance_notify(service: str):
|
||||
bc = await get_balance_check(request.args.get("wal"), service)
|
||||
if bc:
|
||||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
|
||||
@core_app.route("/lnurlwallet")
|
||||
async def lnurlwallet():
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
lnurl = decode_lnurl(request.args.get("lightning"))
|
||||
r = await client.get(str(lnurl))
|
||||
withdraw_res = LnurlResponse.from_dict(r.json())
|
||||
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)
|
||||
|
||||
if not withdraw_res.ok:
|
||||
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
|
||||
except Exception as exc:
|
||||
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()
|
||||
|
||||
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,
|
||||
request.args.get("lightning"),
|
||||
"LNbits initial funding: voucher redeem.",
|
||||
{"tag": "lnurlwallet"},
|
||||
5, # wait 5 seconds before sending the invoice to the service
|
||||
)
|
||||
await trio.sleep(3)
|
||||
|
||||
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
|
||||
|
||||
|
||||
@core_app.route("/manifest/<usr>.webmanifest")
|
||||
async def manifest(usr: str):
|
||||
user = await get_user(usr)
|
||||
if not user:
|
||||
return "", HTTPStatus.NOT_FOUND
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"short_name": "LNbits",
|
||||
"name": "LNbits Wallet",
|
||||
"icons": [
|
||||
{
|
||||
"src": "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
||||
"type": "image/png",
|
||||
"sizes": "900x900",
|
||||
}
|
||||
],
|
||||
"start_url": "/wallet?usr=" + usr,
|
||||
"background_color": "#3367D6",
|
||||
"description": "Weather forecast information",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"theme_color": "#3367D6",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": wallet.name,
|
||||
"short_name": wallet.name,
|
||||
"description": wallet.name,
|
||||
"url": "/wallet?usr=" + usr + "&wal=" + wallet.id,
|
||||
}
|
||||
for wallet in user.wallets
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
+52
-54
@@ -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
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,22 @@ 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"}
|
||||
)
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=milk.wallet,
|
||||
amount=withdraw_res.max_sats,
|
||||
memo=memo,
|
||||
extra={"tag": "amilk"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
r = httpx.get(
|
||||
withdraw_res.callback.base,
|
||||
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
|
||||
params={
|
||||
**withdraw_res.callback.query_params,
|
||||
**{"k1": withdraw_res.k1, "pr": payment_request},
|
||||
},
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
@@ -48,7 +60,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 +79,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 +92,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 +100,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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Bleskomat Extension for lnbits
|
||||
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/).
|
||||
|
||||
|
||||
## Connect Your Bleskomat ATM
|
||||
|
||||
* Click the "Add Bleskomat" button on this page to begin.
|
||||
* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers.
|
||||
* Choose the fiat currency. This should match the fiat currency that your ATM accepts.
|
||||
* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds.
|
||||
* Set your ATM's fee percentage.
|
||||
* Click the "Done" button.
|
||||
* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM.
|
||||
* Copy the configuration file ("bleskomat.conf") to your ATM's SD card.
|
||||
* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card.
|
||||
|
||||
|
||||
## How Does It Work?
|
||||
|
||||
Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet.
|
||||
@@ -0,0 +1,12 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_bleskomat")
|
||||
|
||||
bleskomat_ext: Blueprint = Blueprint(
|
||||
"bleskomat", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
from .lnurl_api import * # noqa
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Bleskomat",
|
||||
"short_description": "Connect a Bleskomat ATM to an lnbits",
|
||||
"icon": "money",
|
||||
"contributors": ["chill117"]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import secrets
|
||||
import time
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Union
|
||||
from . import db
|
||||
from .models import Bleskomat, BleskomatLnurl
|
||||
from .helpers import generate_bleskomat_lnurl_hash
|
||||
|
||||
|
||||
async def create_bleskomat(
|
||||
*,
|
||||
wallet_id: str,
|
||||
name: str,
|
||||
fiat_currency: str,
|
||||
exchange_rate_provider: str,
|
||||
fee: str,
|
||||
) -> Bleskomat:
|
||||
bleskomat_id = uuid4().hex
|
||||
api_key_id = secrets.token_hex(8)
|
||||
api_key_secret = secrets.token_hex(32)
|
||||
api_key_encoding = "hex"
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
bleskomat_id,
|
||||
wallet_id,
|
||||
api_key_id,
|
||||
api_key_secret,
|
||||
api_key_encoding,
|
||||
name,
|
||||
fiat_currency,
|
||||
exchange_rate_provider,
|
||||
fee,
|
||||
),
|
||||
)
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
assert bleskomat, "Newly created bleskomat couldn't be retrieved"
|
||||
return bleskomat
|
||||
|
||||
|
||||
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
|
||||
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomats WHERE api_key_id = ?", (api_key_id,)
|
||||
)
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Bleskomat(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE bleskomats SET {q} WHERE id = ?", (*kwargs.values(), bleskomat_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def delete_bleskomat(bleskomat_id: str) -> None:
|
||||
await db.execute("DELETE FROM bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
|
||||
|
||||
async def create_bleskomat_lnurl(
|
||||
*, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1
|
||||
) -> BleskomatLnurl:
|
||||
bleskomat_lnurl_id = uuid4().hex
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
now = int(time.time())
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
bleskomat_lnurl_id,
|
||||
bleskomat.id,
|
||||
bleskomat.wallet,
|
||||
hash,
|
||||
tag,
|
||||
params,
|
||||
bleskomat.api_key_id,
|
||||
uses,
|
||||
uses,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
bleskomat_lnurl = await get_bleskomat_lnurl(secret)
|
||||
assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved"
|
||||
return bleskomat_lnurl
|
||||
|
||||
|
||||
async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]:
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
row = await db.fetchone("SELECT * FROM bleskomat_lnurls WHERE hash = ?", (hash,))
|
||||
return BleskomatLnurl(**row) if row else None
|
||||
@@ -0,0 +1,79 @@
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
|
||||
fiat_currencies = json.load(
|
||||
open(
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json"
|
||||
),
|
||||
"r",
|
||||
)
|
||||
)
|
||||
|
||||
exchange_rate_providers = {
|
||||
"bitfinex": {
|
||||
"name": "Bitfinex",
|
||||
"domain": "bitfinex.com",
|
||||
"api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}",
|
||||
"getter": lambda data, replacements: data["last_price"],
|
||||
},
|
||||
"bitstamp": {
|
||||
"name": "Bitstamp",
|
||||
"domain": "bitstamp.net",
|
||||
"api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
|
||||
"getter": lambda data, replacements: data["last"],
|
||||
},
|
||||
"coinbase": {
|
||||
"name": "Coinbase",
|
||||
"domain": "coinbase.com",
|
||||
"api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
|
||||
"getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]],
|
||||
},
|
||||
"coinmate": {
|
||||
"name": "CoinMate",
|
||||
"domain": "coinmate.io",
|
||||
"api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
|
||||
"getter": lambda data, replacements: data["data"]["last"],
|
||||
},
|
||||
"kraken": {
|
||||
"name": "Kraken",
|
||||
"domain": "kraken.com",
|
||||
"api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
|
||||
"getter": lambda data, replacements: data["result"][
|
||||
"XXBTZ" + replacements["TO"]
|
||||
]["c"][0],
|
||||
},
|
||||
}
|
||||
|
||||
exchange_rate_providers_serializable = {}
|
||||
for ref, exchange_rate_provider in exchange_rate_providers.items():
|
||||
exchange_rate_provider_serializable = {}
|
||||
for key, value in exchange_rate_provider.items():
|
||||
if not callable(value):
|
||||
exchange_rate_provider_serializable[key] = value
|
||||
exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable
|
||||
|
||||
|
||||
async def fetch_fiat_exchange_rate(currency: str, provider: str):
|
||||
|
||||
replacements = {
|
||||
"FROM": "BTC",
|
||||
"from": "btc",
|
||||
"TO": currency.upper(),
|
||||
"to": currency.lower(),
|
||||
}
|
||||
|
||||
url = exchange_rate_providers[provider]["api_url"]
|
||||
for key in replacements.keys():
|
||||
url = url.replace("{" + key + "}", replacements[key])
|
||||
|
||||
getter = exchange_rate_providers[provider]["getter"]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
rate = float(getter(data, replacements))
|
||||
|
||||
return rate
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"AED": "United Arab Emirates Dirham",
|
||||
"AFN": "Afghan Afghani",
|
||||
"ALL": "Albanian Lek",
|
||||
"AMD": "Armenian Dram",
|
||||
"ANG": "Netherlands Antillean Gulden",
|
||||
"AOA": "Angolan Kwanza",
|
||||
"ARS": "Argentine Peso",
|
||||
"AUD": "Australian Dollar",
|
||||
"AWG": "Aruban Florin",
|
||||
"AZN": "Azerbaijani Manat",
|
||||
"BAM": "Bosnia and Herzegovina Convertible Mark",
|
||||
"BBD": "Barbadian Dollar",
|
||||
"BDT": "Bangladeshi Taka",
|
||||
"BGN": "Bulgarian Lev",
|
||||
"BHD": "Bahraini Dinar",
|
||||
"BIF": "Burundian Franc",
|
||||
"BMD": "Bermudian Dollar",
|
||||
"BND": "Brunei Dollar",
|
||||
"BOB": "Bolivian Boliviano",
|
||||
"BRL": "Brazilian Real",
|
||||
"BSD": "Bahamian Dollar",
|
||||
"BTN": "Bhutanese Ngultrum",
|
||||
"BWP": "Botswana Pula",
|
||||
"BYN": "Belarusian Ruble",
|
||||
"BYR": "Belarusian Ruble",
|
||||
"BZD": "Belize Dollar",
|
||||
"CAD": "Canadian Dollar",
|
||||
"CDF": "Congolese Franc",
|
||||
"CHF": "Swiss Franc",
|
||||
"CLF": "Unidad de Fomento",
|
||||
"CLP": "Chilean Peso",
|
||||
"CNH": "Chinese Renminbi Yuan Offshore",
|
||||
"CNY": "Chinese Renminbi Yuan",
|
||||
"COP": "Colombian Peso",
|
||||
"CRC": "Costa Rican Colón",
|
||||
"CUC": "Cuban Convertible Peso",
|
||||
"CVE": "Cape Verdean Escudo",
|
||||
"CZK": "Czech Koruna",
|
||||
"DJF": "Djiboutian Franc",
|
||||
"DKK": "Danish Krone",
|
||||
"DOP": "Dominican Peso",
|
||||
"DZD": "Algerian Dinar",
|
||||
"EGP": "Egyptian Pound",
|
||||
"ERN": "Eritrean Nakfa",
|
||||
"ETB": "Ethiopian Birr",
|
||||
"EUR": "Euro",
|
||||
"FJD": "Fijian Dollar",
|
||||
"FKP": "Falkland Pound",
|
||||
"GBP": "British Pound",
|
||||
"GEL": "Georgian Lari",
|
||||
"GGP": "Guernsey Pound",
|
||||
"GHS": "Ghanaian Cedi",
|
||||
"GIP": "Gibraltar Pound",
|
||||
"GMD": "Gambian Dalasi",
|
||||
"GNF": "Guinean Franc",
|
||||
"GTQ": "Guatemalan Quetzal",
|
||||
"GYD": "Guyanese Dollar",
|
||||
"HKD": "Hong Kong Dollar",
|
||||
"HNL": "Honduran Lempira",
|
||||
"HRK": "Croatian Kuna",
|
||||
"HTG": "Haitian Gourde",
|
||||
"HUF": "Hungarian Forint",
|
||||
"IDR": "Indonesian Rupiah",
|
||||
"ILS": "Israeli New Sheqel",
|
||||
"IMP": "Isle of Man Pound",
|
||||
"INR": "Indian Rupee",
|
||||
"IQD": "Iraqi Dinar",
|
||||
"ISK": "Icelandic Króna",
|
||||
"JEP": "Jersey Pound",
|
||||
"JMD": "Jamaican Dollar",
|
||||
"JOD": "Jordanian Dinar",
|
||||
"JPY": "Japanese Yen",
|
||||
"KES": "Kenyan Shilling",
|
||||
"KGS": "Kyrgyzstani Som",
|
||||
"KHR": "Cambodian Riel",
|
||||
"KMF": "Comorian Franc",
|
||||
"KRW": "South Korean Won",
|
||||
"KWD": "Kuwaiti Dinar",
|
||||
"KYD": "Cayman Islands Dollar",
|
||||
"KZT": "Kazakhstani Tenge",
|
||||
"LAK": "Lao Kip",
|
||||
"LBP": "Lebanese Pound",
|
||||
"LKR": "Sri Lankan Rupee",
|
||||
"LRD": "Liberian Dollar",
|
||||
"LSL": "Lesotho Loti",
|
||||
"LYD": "Libyan Dinar",
|
||||
"MAD": "Moroccan Dirham",
|
||||
"MDL": "Moldovan Leu",
|
||||
"MGA": "Malagasy Ariary",
|
||||
"MKD": "Macedonian Denar",
|
||||
"MMK": "Myanmar Kyat",
|
||||
"MNT": "Mongolian Tögrög",
|
||||
"MOP": "Macanese Pataca",
|
||||
"MRO": "Mauritanian Ouguiya",
|
||||
"MUR": "Mauritian Rupee",
|
||||
"MVR": "Maldivian Rufiyaa",
|
||||
"MWK": "Malawian Kwacha",
|
||||
"MXN": "Mexican Peso",
|
||||
"MYR": "Malaysian Ringgit",
|
||||
"MZN": "Mozambican Metical",
|
||||
"NAD": "Namibian Dollar",
|
||||
"NGN": "Nigerian Naira",
|
||||
"NIO": "Nicaraguan Córdoba",
|
||||
"NOK": "Norwegian Krone",
|
||||
"NPR": "Nepalese Rupee",
|
||||
"NZD": "New Zealand Dollar",
|
||||
"OMR": "Omani Rial",
|
||||
"PAB": "Panamanian Balboa",
|
||||
"PEN": "Peruvian Sol",
|
||||
"PGK": "Papua New Guinean Kina",
|
||||
"PHP": "Philippine Peso",
|
||||
"PKR": "Pakistani Rupee",
|
||||
"PLN": "Polish Złoty",
|
||||
"PYG": "Paraguayan Guaraní",
|
||||
"QAR": "Qatari Riyal",
|
||||
"RON": "Romanian Leu",
|
||||
"RSD": "Serbian Dinar",
|
||||
"RUB": "Russian Ruble",
|
||||
"RWF": "Rwandan Franc",
|
||||
"SAR": "Saudi Riyal",
|
||||
"SBD": "Solomon Islands Dollar",
|
||||
"SCR": "Seychellois Rupee",
|
||||
"SEK": "Swedish Krona",
|
||||
"SGD": "Singapore Dollar",
|
||||
"SHP": "Saint Helenian Pound",
|
||||
"SLL": "Sierra Leonean Leone",
|
||||
"SOS": "Somali Shilling",
|
||||
"SRD": "Surinamese Dollar",
|
||||
"SSP": "South Sudanese Pound",
|
||||
"STD": "São Tomé and Príncipe Dobra",
|
||||
"SVC": "Salvadoran Colón",
|
||||
"SZL": "Swazi Lilangeni",
|
||||
"THB": "Thai Baht",
|
||||
"TJS": "Tajikistani Somoni",
|
||||
"TMT": "Turkmenistani Manat",
|
||||
"TND": "Tunisian Dinar",
|
||||
"TOP": "Tongan Paʻanga",
|
||||
"TRY": "Turkish Lira",
|
||||
"TTD": "Trinidad and Tobago Dollar",
|
||||
"TWD": "New Taiwan Dollar",
|
||||
"TZS": "Tanzanian Shilling",
|
||||
"UAH": "Ukrainian Hryvnia",
|
||||
"UGX": "Ugandan Shilling",
|
||||
"USD": "US Dollar",
|
||||
"UYU": "Uruguayan Peso",
|
||||
"UZS": "Uzbekistan Som",
|
||||
"VEF": "Venezuelan Bolívar",
|
||||
"VES": "Venezuelan Bolívar Soberano",
|
||||
"VND": "Vietnamese Đồng",
|
||||
"VUV": "Vanuatu Vatu",
|
||||
"WST": "Samoan Tala",
|
||||
"XAF": "Central African Cfa Franc",
|
||||
"XAG": "Silver (Troy Ounce)",
|
||||
"XAU": "Gold (Troy Ounce)",
|
||||
"XCD": "East Caribbean Dollar",
|
||||
"XDR": "Special Drawing Rights",
|
||||
"XOF": "West African Cfa Franc",
|
||||
"XPD": "Palladium",
|
||||
"XPF": "Cfp Franc",
|
||||
"XPT": "Platinum",
|
||||
"YER": "Yemeni Rial",
|
||||
"ZAR": "South African Rand",
|
||||
"ZMW": "Zambian Kwacha",
|
||||
"ZWL": "Zimbabwean Dollar"
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from http import HTTPStatus
|
||||
from binascii import unhexlify
|
||||
from typing import Dict
|
||||
from quart import url_for
|
||||
import urllib
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_hash(secret: str):
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{secret}".encode())
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_signature(
|
||||
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
|
||||
):
|
||||
if api_key_encoding == "hex":
|
||||
key = unhexlify(api_key_secret)
|
||||
elif api_key_encoding == "base64":
|
||||
key = base64.b64decode(api_key_secret)
|
||||
else:
|
||||
key = bytes(f"{api_key_secret}")
|
||||
return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
|
||||
# The secret is not randomly generated by the server.
|
||||
# Instead it is the hash of the API key ID and signature concatenated together.
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{api_key_id}-{signature}".encode())
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def get_callback_url():
|
||||
return url_for("bleskomat.api_bleskomat_lnurl", _external=True)
|
||||
|
||||
|
||||
def is_supported_lnurl_subprotocol(tag: str) -> bool:
|
||||
return tag == "withdrawRequest"
|
||||
|
||||
|
||||
class LnurlHttpError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "",
|
||||
http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
):
|
||||
self.message = message
|
||||
self.http_status = http_status
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class LnurlValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def prepare_lnurl_params(tag: str, query: Dict[str, str]):
|
||||
params = {}
|
||||
if not is_supported_lnurl_subprotocol(tag):
|
||||
raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"')
|
||||
if tag == "withdrawRequest":
|
||||
params["minWithdrawable"] = float(query["minWithdrawable"])
|
||||
params["maxWithdrawable"] = float(query["maxWithdrawable"])
|
||||
params["defaultDescription"] = query["defaultDescription"]
|
||||
if not params["minWithdrawable"] > 0:
|
||||
raise LnurlValidationError('"minWithdrawable" must be greater than zero')
|
||||
if not params["maxWithdrawable"] >= params["minWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'"maxWithdrawable" must be greater than or equal to "minWithdrawable"'
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
encode_uri_component_safe_chars = (
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()"
|
||||
)
|
||||
|
||||
|
||||
def query_to_signing_payload(query: Dict[str, str]) -> str:
|
||||
# Sort the query by key, then stringify it to create the payload.
|
||||
sorted_keys = sorted(query.keys(), key=str.lower)
|
||||
payload = []
|
||||
for key in sorted_keys:
|
||||
if not key == "signature":
|
||||
encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars)
|
||||
encoded_value = urllib.parse.quote(
|
||||
query[key], safe=encode_uri_component_safe_chars
|
||||
)
|
||||
payload.append(f"{encoded_key}={encoded_value}")
|
||||
return "&".join(payload)
|
||||
|
||||
|
||||
unshorten_rules = {
|
||||
"query": {"n": "nonce", "s": "signature", "t": "tag"},
|
||||
"tags": {
|
||||
"c": "channelRequest",
|
||||
"l": "login",
|
||||
"p": "payRequest",
|
||||
"w": "withdrawRequest",
|
||||
},
|
||||
"params": {
|
||||
"channelRequest": {"pl": "localAmt", "pp": "pushAmt"},
|
||||
"login": {},
|
||||
"payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"},
|
||||
"withdrawRequest": {
|
||||
"pn": "minWithdrawable",
|
||||
"px": "maxWithdrawable",
|
||||
"pd": "defaultDescription",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]:
|
||||
new_query = {}
|
||||
rules = unshorten_rules
|
||||
if "tag" in query:
|
||||
tag = query["tag"]
|
||||
elif "t" in query:
|
||||
tag = query["t"]
|
||||
else:
|
||||
raise LnurlValidationError('Missing required query parameter: "tag"')
|
||||
# Unshorten tag:
|
||||
if tag in rules["tags"]:
|
||||
long_tag = rules["tags"][tag]
|
||||
new_query["tag"] = long_tag
|
||||
tag = long_tag
|
||||
if not tag in rules["params"]:
|
||||
raise LnurlValidationError(f'Unknown tag: "{tag}"')
|
||||
for key in query:
|
||||
if key in rules["params"][tag]:
|
||||
short_param_key = key
|
||||
long_param_key = rules["params"][tag][short_param_key]
|
||||
if short_param_key in query:
|
||||
new_query[long_param_key] = query[short_param_key]
|
||||
else:
|
||||
new_query[long_param_key] = query[long_param_key]
|
||||
elif key in rules["query"]:
|
||||
# Unshorten general keys:
|
||||
short_key = key
|
||||
long_key = rules["query"][short_key]
|
||||
if not long_key in new_query:
|
||||
if short_key in query:
|
||||
new_query[long_key] = query[short_key]
|
||||
else:
|
||||
new_query[long_key] = query[long_key]
|
||||
else:
|
||||
# Keep unknown key/value pairs unchanged:
|
||||
new_query[key] = query[key]
|
||||
return new_query
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import math
|
||||
from quart import jsonify, request
|
||||
from http import HTTPStatus
|
||||
import traceback
|
||||
|
||||
from . import bleskomat_ext
|
||||
from .crud import (
|
||||
create_bleskomat_lnurl,
|
||||
get_bleskomat_by_api_key_id,
|
||||
get_bleskomat_lnurl,
|
||||
)
|
||||
|
||||
from .exchange_rates import (
|
||||
fetch_fiat_exchange_rate,
|
||||
)
|
||||
|
||||
from .helpers import (
|
||||
generate_bleskomat_lnurl_signature,
|
||||
generate_bleskomat_lnurl_secret,
|
||||
LnurlHttpError,
|
||||
LnurlValidationError,
|
||||
prepare_lnurl_params,
|
||||
query_to_signing_payload,
|
||||
unshorten_lnurl_query,
|
||||
)
|
||||
|
||||
|
||||
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
|
||||
@bleskomat_ext.route("/u", methods=["GET"])
|
||||
async def api_bleskomat_lnurl():
|
||||
try:
|
||||
query = request.args.to_dict()
|
||||
|
||||
# Unshorten query if "s" is used instead of "signature".
|
||||
if "s" in query:
|
||||
query = unshorten_lnurl_query(query)
|
||||
|
||||
if "signature" in query:
|
||||
|
||||
# Signature provided.
|
||||
# Use signature to verify that the URL was generated by an authorized device.
|
||||
# Later validate parameters, auto-generate LNURL, reply with LNURL response object.
|
||||
signature = query["signature"]
|
||||
|
||||
# The API key ID, nonce, and tag should be present in the query string.
|
||||
for field in ["id", "nonce", "tag"]:
|
||||
if not field in query:
|
||||
raise LnurlHttpError(
|
||||
f'Failed API key signature check: Missing "{field}"',
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
# URL signing scheme is described here:
|
||||
# https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme
|
||||
payload = query_to_signing_payload(query)
|
||||
api_key_id = query["id"]
|
||||
bleskomat = await get_bleskomat_by_api_key_id(api_key_id)
|
||||
if not bleskomat:
|
||||
raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST)
|
||||
api_key_secret = bleskomat.api_key_secret
|
||||
api_key_encoding = bleskomat.api_key_encoding
|
||||
expected_signature = generate_bleskomat_lnurl_signature(
|
||||
payload, api_key_secret, api_key_encoding
|
||||
)
|
||||
if signature != expected_signature:
|
||||
raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN)
|
||||
|
||||
# Signature is valid.
|
||||
# In the case of signed URLs, the secret is deterministic based on the API key ID and signature.
|
||||
secret = generate_bleskomat_lnurl_secret(api_key_id, signature)
|
||||
lnurl = await get_bleskomat_lnurl(secret)
|
||||
if not lnurl:
|
||||
try:
|
||||
tag = query["tag"]
|
||||
params = prepare_lnurl_params(tag, query)
|
||||
if "f" in query:
|
||||
rate = await fetch_fiat_exchange_rate(
|
||||
currency=query["f"],
|
||||
provider=bleskomat.exchange_rate_provider,
|
||||
)
|
||||
# Convert fee (%) to decimal:
|
||||
fee = float(bleskomat.fee) / 100
|
||||
if tag == "withdrawRequest":
|
||||
for key in ["minWithdrawable", "maxWithdrawable"]:
|
||||
amount_sats = int(
|
||||
math.floor((params[key] / rate) * 1e8)
|
||||
)
|
||||
fee_sats = int(math.floor(amount_sats * fee))
|
||||
amount_sats_less_fee = amount_sats - fee_sats
|
||||
# Convert to msats:
|
||||
params[key] = int(amount_sats_less_fee * 1e3)
|
||||
except LnurlValidationError as e:
|
||||
raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST)
|
||||
# Create a new LNURL using the query parameters provided in the signed URL.
|
||||
params = json.JSONEncoder().encode(params)
|
||||
lnurl = await create_bleskomat_lnurl(
|
||||
bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1
|
||||
)
|
||||
|
||||
# Reply with LNURL response object.
|
||||
return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK
|
||||
|
||||
# No signature provided.
|
||||
# Treat as "action" callback.
|
||||
|
||||
if not "k1" in query:
|
||||
raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
secret = query["k1"]
|
||||
lnurl = await get_bleskomat_lnurl(secret)
|
||||
if not lnurl:
|
||||
raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if not lnurl.has_uses_remaining():
|
||||
raise LnurlHttpError(
|
||||
"Maximum number of uses already reached", HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
await lnurl.execute_action(query)
|
||||
except LnurlValidationError as e:
|
||||
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
|
||||
|
||||
except LnurlHttpError as e:
|
||||
return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return (
|
||||
jsonify({"status": "ERROR", "reason": "Unexpected error"}),
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return jsonify({"status": "OK"}), HTTPStatus.OK
|
||||
@@ -0,0 +1,37 @@
|
||||
async def m001_initial(db):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bleskomats (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
api_key_secret TEXT NOT NULL,
|
||||
api_key_encoding TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
fiat_currency TEXT NOT NULL,
|
||||
exchange_rate_provider TEXT NOT NULL,
|
||||
fee TEXT NOT NULL,
|
||||
UNIQUE(api_key_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bleskomat_lnurls (
|
||||
id TEXT PRIMARY KEY,
|
||||
bleskomat TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
initial_uses INTEGER DEFAULT 1,
|
||||
remaining_uses INTEGER DEFAULT 0,
|
||||
created_time INTEGER,
|
||||
updated_time INTEGER,
|
||||
UNIQUE(hash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
import time
|
||||
from typing import NamedTuple, Dict
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.services import pay_invoice
|
||||
from . import db
|
||||
from .helpers import get_callback_url, LnurlValidationError
|
||||
|
||||
|
||||
class Bleskomat(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
api_key_id: str
|
||||
api_key_secret: str
|
||||
api_key_encoding: str
|
||||
name: str
|
||||
fiat_currency: str
|
||||
exchange_rate_provider: str
|
||||
fee: str
|
||||
|
||||
|
||||
class BleskomatLnurl(NamedTuple):
|
||||
id: str
|
||||
bleskomat: str
|
||||
wallet: str
|
||||
hash: str
|
||||
tag: str
|
||||
params: str
|
||||
api_key_id: str
|
||||
initial_uses: int
|
||||
remaining_uses: int
|
||||
created_time: int
|
||||
updated_time: int
|
||||
|
||||
def has_uses_remaining(self) -> bool:
|
||||
# When initial uses is 0 then the LNURL has unlimited uses.
|
||||
return self.initial_uses == 0 or self.remaining_uses > 0
|
||||
|
||||
def get_info_response_object(self, secret: str) -> Dict[str, str]:
|
||||
tag = self.tag
|
||||
params = json.loads(self.params)
|
||||
response = {"tag": tag}
|
||||
if tag == "withdrawRequest":
|
||||
for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]:
|
||||
response[key] = params[key]
|
||||
response["callback"] = get_callback_url()
|
||||
response["k1"] = secret
|
||||
return response
|
||||
|
||||
def validate_action(self, query: Dict[str, str]) -> None:
|
||||
tag = self.tag
|
||||
params = json.loads(self.params)
|
||||
# Perform tag-specific checks.
|
||||
if tag == "withdrawRequest":
|
||||
for field in ["pr"]:
|
||||
if not field in query:
|
||||
raise LnurlValidationError(f'Missing required parameter: "{field}"')
|
||||
# Check the bolt11 invoice(s) provided.
|
||||
pr = query["pr"]
|
||||
if "," in pr:
|
||||
raise LnurlValidationError("Multiple payment requests not supported")
|
||||
try:
|
||||
invoice = bolt11.decode(pr)
|
||||
except ValueError:
|
||||
raise LnurlValidationError(
|
||||
'Invalid parameter ("pr"): Lightning payment request expected'
|
||||
)
|
||||
if invoice.amount_msat < params["minWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'Amount in invoice must be greater than or equal to "minWithdrawable"'
|
||||
)
|
||||
if invoice.amount_msat > params["maxWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'Amount in invoice must be less than or equal to "maxWithdrawable"'
|
||||
)
|
||||
else:
|
||||
raise LnurlValidationError(f'Unknown subprotocol: "{tag}"')
|
||||
|
||||
async def execute_action(self, query: Dict[str, str]):
|
||||
self.validate_action(query)
|
||||
used = False
|
||||
async with db.connect() as conn:
|
||||
if self.initial_uses > 0:
|
||||
used = await self.use(conn)
|
||||
if not used:
|
||||
raise LnurlValidationError("Maximum number of uses already reached")
|
||||
tag = self.tag
|
||||
if tag == "withdrawRequest":
|
||||
try:
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=self.wallet,
|
||||
payment_request=query["pr"],
|
||||
)
|
||||
except Exception:
|
||||
raise LnurlValidationError("Failed to pay invoice")
|
||||
if not payment_hash:
|
||||
raise LnurlValidationError("Failed to pay invoice")
|
||||
|
||||
async def use(self, conn) -> bool:
|
||||
now = int(time.time())
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE bleskomat_lnurls
|
||||
SET remaining_uses = remaining_uses - 1, updated_time = ?
|
||||
WHERE id = ?
|
||||
AND remaining_uses > 0
|
||||
""",
|
||||
(now, self.id),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,216 @@
|
||||
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapBleskomat = function (obj) {
|
||||
obj._data = _.clone(obj)
|
||||
return obj
|
||||
}
|
||||
|
||||
var defaultValues = {
|
||||
name: 'My Bleskomat',
|
||||
fiat_currency: 'EUR',
|
||||
exchange_rate_provider: 'coinbase',
|
||||
fee: '0.00'
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
checker: null,
|
||||
bleskomats: [],
|
||||
bleskomatsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'api_key_id',
|
||||
align: 'left',
|
||||
label: 'API Key ID',
|
||||
field: 'api_key_id'
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
align: 'left',
|
||||
label: 'Name',
|
||||
field: 'name'
|
||||
},
|
||||
{
|
||||
name: 'fiat_currency',
|
||||
align: 'left',
|
||||
label: 'Fiat Currency',
|
||||
field: 'fiat_currency'
|
||||
},
|
||||
{
|
||||
name: 'exchange_rate_provider',
|
||||
align: 'left',
|
||||
label: 'Exchange Rate Provider',
|
||||
field: 'exchange_rate_provider'
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'left',
|
||||
label: 'Fee (%)',
|
||||
field: 'fee'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies),
|
||||
exchangeRateProviders: _.keys(
|
||||
window.bleskomat_vars.exchange_rate_providers
|
||||
),
|
||||
data: _.clone(defaultValues)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedBleskomats: function () {
|
||||
return this.bleskomats.sort(function (a, b) {
|
||||
// Sort by API Key ID alphabetically.
|
||||
var apiKeyId_A = a.api_key_id.toLowerCase()
|
||||
var apiKeyId_B = b.api_key_id.toLowerCase()
|
||||
return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getBleskomats: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/bleskomat/api/v1/bleskomats?all_wallets',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = response.data.map(function (obj) {
|
||||
return mapBleskomat(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
clearInterval(self.checker)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = _.clone(defaultValues)
|
||||
},
|
||||
exportConfigFile: function (bleskomatId) {
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
var fieldToKey = {
|
||||
api_key_id: 'apiKey.id',
|
||||
api_key_secret: 'apiKey.key',
|
||||
api_key_encoding: 'apiKey.encoding',
|
||||
fiat_currency: 'fiatCurrency'
|
||||
}
|
||||
var lines = _.chain(bleskomat)
|
||||
.map(function (value, field) {
|
||||
var key = fieldToKey[field] || null
|
||||
return key ? [key, value].join('=') : null
|
||||
})
|
||||
.compact()
|
||||
.value()
|
||||
lines.push('callbackUrl=' + window.bleskomat_vars.callback_url)
|
||||
lines.push('shorten=true')
|
||||
var content = lines.join('\n')
|
||||
var status = Quasar.utils.exportFile(
|
||||
'bleskomat.conf',
|
||||
content,
|
||||
'text/plain'
|
||||
)
|
||||
if (status !== true) {
|
||||
Quasar.plugins.Notify.create({
|
||||
message: 'Browser denied file download...',
|
||||
color: 'negative',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
},
|
||||
openUpdateDialog: function (bleskomatId) {
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
this.formDialog.data = _.clone(bleskomat._data)
|
||||
this.formDialog.show = true
|
||||
},
|
||||
sendFormData: function () {
|
||||
var wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
if (data.id) {
|
||||
this.updateBleskomat(wallet, data)
|
||||
} else {
|
||||
this.createBleskomat(wallet, data)
|
||||
}
|
||||
},
|
||||
updateBleskomat: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/bleskomat/api/v1/bleskomat/' + data.id,
|
||||
wallet.adminkey,
|
||||
_.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee')
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = _.reject(self.bleskomats, function (obj) {
|
||||
return obj.id === data.id
|
||||
})
|
||||
self.bleskomats.push(mapBleskomat(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
createBleskomat: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data)
|
||||
.then(function (response) {
|
||||
self.bleskomats.push(mapBleskomat(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteBleskomat: function (bleskomatId) {
|
||||
var self = this
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to delete "' + bleskomat.name + '"?'
|
||||
)
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/bleskomat/api/v1/bleskomat/' + bleskomatId,
|
||||
_.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = _.reject(self.bleskomats, function (obj) {
|
||||
return obj.id === bleskomatId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getBleskomats = this.getBleskomats
|
||||
getBleskomats()
|
||||
this.checker = setInterval(function () {
|
||||
getBleskomats()
|
||||
}, 20000)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Bleskomat Extension for lnbits"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits
|
||||
wallet. It will work with both the
|
||||
<a href="https://github.com/samotari/bleskomat"
|
||||
>open-source DIY Bleskomat ATM project</a
|
||||
>
|
||||
as well as the
|
||||
<a href="https://www.bleskomat.com/">commercial Bleskomat ATM</a>.
|
||||
</p>
|
||||
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
|
||||
<div>
|
||||
<ol>
|
||||
<li>Click the "Add Bleskomat" button on this page to begin.</li>
|
||||
<li>
|
||||
Choose a wallet. This will be the wallet that is used to pay
|
||||
satoshis to your ATM customers.
|
||||
</li>
|
||||
<li>
|
||||
Choose the fiat currency. This should match the fiat currency that
|
||||
your ATM accepts.
|
||||
</li>
|
||||
<li>
|
||||
Pick an exchange rate provider. This is the API that will be used to
|
||||
query the fiat to satoshi exchange rate at the time your customer
|
||||
attempts to withdraw their funds.
|
||||
</li>
|
||||
<li>Set your ATM's fee percentage.</li>
|
||||
<li>Click the "Done" button.</li>
|
||||
<li>
|
||||
Find the new Bleskomat in the list and then click the export icon to
|
||||
download a new configuration file for your ATM.
|
||||
</li>
|
||||
<li>
|
||||
Copy the configuration file ("bleskomat.conf") to your ATM's SD
|
||||
card.
|
||||
</li>
|
||||
<li>
|
||||
Restart Your Bleskomat ATM. It should automatically reload the
|
||||
configurations from the SD card.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<h5 class="text-subtitle1 q-my-none">How does it work?</h5>
|
||||
<p>
|
||||
Since the Bleskomat ATMs are designed to be offline, a cryptographic
|
||||
signing scheme is used to verify that the URL was generated by an
|
||||
authorized device. When one of your customers inserts fiat money into
|
||||
the device, a signed URL (lnurl-withdraw) is created and displayed as a
|
||||
QR code. Your customer scans the QR code with their lnurl-supporting
|
||||
mobile app, their mobile app communicates with the web API of lnbits to
|
||||
verify the signature, the fiat currency amount is converted to sats, the
|
||||
customer accepts the withdrawal, and finally lnbits will pay the
|
||||
customer from your lnbits wallet.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,178 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
{% if bleskomat_vars %}
|
||||
window.bleskomat_vars = {{ bleskomat_vars | tojson | safe }}
|
||||
{% endif %}
|
||||
</script>
|
||||
<script src="/bleskomat/static/js/index.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
|
||||
>Add Bleskomat</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Bleskomats</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="sortedBleskomats"
|
||||
row-key="id"
|
||||
:columns="bleskomatsTable.columns"
|
||||
:pagination.sync="bleskomatsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="file_download"
|
||||
color="orange"
|
||||
@click="exportConfigFile(props.row.id)"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent"
|
||||
>Export Configuration</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent">Edit</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteBleskomat(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent">Delete</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits Bleskomat extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "bleskomat/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="text"
|
||||
label="Name *"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.fiat_currency"
|
||||
:options="formDialog.fiatCurrencies"
|
||||
label="Fiat Currency *"
|
||||
>
|
||||
</q-select>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.exchange_rate_provider"
|
||||
:options="formDialog.exchangeRateProviders"
|
||||
label="Exchange Rate Provider *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.fee"
|
||||
type="string"
|
||||
:default="0.00"
|
||||
label="Fee (%) *"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
type="submit"
|
||||
>Update Bleskomat</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.name == null ||
|
||||
formDialog.data.fiat_currency == null ||
|
||||
formDialog.data.exchange_rate_provider == null ||
|
||||
formDialog.data.fee == null"
|
||||
type="submit"
|
||||
>Add Bleskomat</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import bleskomat_ext
|
||||
|
||||
from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies
|
||||
from .helpers import get_callback_url
|
||||
|
||||
|
||||
@bleskomat_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
bleskomat_vars = {
|
||||
"callback_url": get_callback_url(),
|
||||
"exchange_rate_providers": exchange_rate_providers_serializable,
|
||||
"fiat_currencies": fiat_currencies,
|
||||
}
|
||||
return await render_template(
|
||||
"bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from . import bleskomat_ext
|
||||
from .crud import (
|
||||
create_bleskomat,
|
||||
get_bleskomat,
|
||||
get_bleskomats,
|
||||
update_bleskomat,
|
||||
delete_bleskomat,
|
||||
)
|
||||
|
||||
from .exchange_rates import (
|
||||
exchange_rate_providers,
|
||||
fetch_fiat_exchange_rate,
|
||||
fiat_currencies,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomats():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
[bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["GET"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomat_retrieve(bleskomat_id):
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
return jsonify(bleskomat._asdict()), HTTPStatus.OK
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"])
|
||||
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["PUT"])
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"fiat_currency": {
|
||||
"type": "string",
|
||||
"allowed": fiat_currencies.keys(),
|
||||
"required": True,
|
||||
},
|
||||
"exchange_rate_provider": {
|
||||
"type": "string",
|
||||
"allowed": exchange_rate_providers.keys(),
|
||||
"required": True,
|
||||
},
|
||||
"fee": {"type": ["string", "float", "number", "integer"], "required": True},
|
||||
}
|
||||
)
|
||||
async def api_bleskomat_create_or_update(bleskomat_id=None):
|
||||
try:
|
||||
fiat_currency = g.data["fiat_currency"]
|
||||
exchange_rate_provider = g.data["exchange_rate_provider"]
|
||||
await fetch_fiat_exchange_rate(
|
||||
currency=fiat_currency, provider=exchange_rate_provider
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"'
|
||||
}
|
||||
),
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
if bleskomat_id:
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
bleskomat = await update_bleskomat(bleskomat_id, **g.data)
|
||||
else:
|
||||
bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data)
|
||||
|
||||
return (
|
||||
jsonify(bleskomat._asdict()),
|
||||
HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomat_delete(bleskomat_id):
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
await delete_bleskomat(bleskomat_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Captcha",
|
||||
"short_description": "Create captcha to stop spam",
|
||||
"icon": "block",
|
||||
"contributors": ["pseudozach"]
|
||||
}
|
||||
@@ -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,))
|
||||
@@ -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")
|
||||
@@ -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,82 @@
|
||||
var ciframeLoaded = !1,
|
||||
captchaStyleAdded = !1
|
||||
|
||||
function ccreateIframeElement(t = {}) {
|
||||
const e = document.createElement('iframe')
|
||||
// e.style.marginLeft = "25px",
|
||||
;(e.style.border = 'none'),
|
||||
(e.style.width = '100%'),
|
||||
(e.style.height = '100%'),
|
||||
(e.scrolling = 'no'),
|
||||
(e.id = 'captcha-iframe')
|
||||
t.dest, t.amount, t.currency, t.label, t.opReturn
|
||||
var captchaid = document
|
||||
.getElementById('captchascript')
|
||||
.getAttribute('data-captchaid')
|
||||
var lnbhostsrc = document.getElementById('captchascript').getAttribute('src')
|
||||
var lnbhost = lnbhostsrc.split('/captcha/static/js/captcha.js')[0]
|
||||
return (e.src = lnbhost + '/captcha/' + captchaid), e
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (captchaStyleAdded) console.log('Captcha already added!')
|
||||
else {
|
||||
console.log('Adding captcha'), (captchaStyleAdded = !0)
|
||||
var t = document.createElement('style')
|
||||
t.innerHTML =
|
||||
"\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}"
|
||||
var e = document.querySelector('script')
|
||||
e.parentNode.insertBefore(t, e)
|
||||
var i = document.getElementById('captchacheckbox'),
|
||||
n = i.dataset,
|
||||
o = 'true' === n.dark
|
||||
var a = document.createElement('div')
|
||||
;(a.className += ' modal-captcha-container'),
|
||||
(a.innerHTML =
|
||||
'\t\t<div class="modal-captcha-content"> \t<span class="close-button-captcha" style="display: none;">×</span>\t\t</div>\t'),
|
||||
document.getElementsByTagName('body')[0].appendChild(a)
|
||||
var r = document.getElementsByClassName('modal-captcha-content').item(0)
|
||||
document
|
||||
.getElementsByClassName('close-button-captcha')
|
||||
.item(0)
|
||||
.addEventListener('click', d),
|
||||
window.addEventListener('click', function (t) {
|
||||
t.target === a && d()
|
||||
}),
|
||||
i.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
// console.log("checkbox checked");
|
||||
if (0 == ciframeLoaded) {
|
||||
// console.log("n: ", n);
|
||||
var t = ccreateIframeElement(n)
|
||||
r.appendChild(t), (ciframeLoaded = !0)
|
||||
}
|
||||
d()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function d() {
|
||||
a.classList.toggle('show-modal-captcha')
|
||||
}
|
||||
})
|
||||
|
||||
function 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": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<captcha_object>, ...]</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": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"amount": <integer>, "description": <string>, "memo":
|
||||
<string>, "remembers": <boolean>, "url":
|
||||
<string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"amount": <integer>, "description": <string>, "id":
|
||||
<string>, "memo": <string>, "remembers": <boolean>,
|
||||
"time": <int>, "url": <string>, "wallet":
|
||||
<string>}</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": <string>, "memo": <string>, "description":
|
||||
<string>, "amount": <integer>, "remembers":
|
||||
<boolean>}' -H "Content-type: application/json" -H "X-Api-Key:
|
||||
{{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</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/<captcha_id>/invoice</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"amount": <integer>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"payment_hash": <string>, "payment_request":
|
||||
<string>}</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/<captcha_id>/invoice -d '{"amount":
|
||||
<integer>}' -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/<captcha_id>/check_invoice</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"payment_hash": <string>}</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": <string>, "remembers":
|
||||
<boolean>}</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/<captcha_id>/check_invoice -d
|
||||
'{"payment_hash": <string>}' -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/<captcha_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</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/<captcha_id> -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 %}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1,7 +1,9 @@
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates")
|
||||
diagonalley_ext: Blueprint = Blueprint(
|
||||
"diagonalley", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -21,7 +21,14 @@ regex = re.compile(
|
||||
|
||||
|
||||
def create_diagonalleys_product(
|
||||
*, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int
|
||||
*,
|
||||
wallet_id: str,
|
||||
product: str,
|
||||
categories: str,
|
||||
description: str,
|
||||
image: str,
|
||||
price: int,
|
||||
quantity: int,
|
||||
) -> Products:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
@@ -30,7 +37,16 @@ def create_diagonalleys_product(
|
||||
INSERT INTO products (id, wallet, product, categories, description, image, price, quantity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(product_id, wallet_id, product, categories, description, image, price, quantity),
|
||||
(
|
||||
product_id,
|
||||
wallet_id,
|
||||
product,
|
||||
categories,
|
||||
description,
|
||||
image,
|
||||
price,
|
||||
quantity,
|
||||
),
|
||||
)
|
||||
|
||||
return get_diagonalleys_product(product_id)
|
||||
@@ -40,7 +56,9 @@ def update_diagonalleys_product(product_id: str, **kwargs) -> Optional[Indexers]
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id))
|
||||
db.execute(
|
||||
f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id)
|
||||
)
|
||||
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
|
||||
|
||||
return get_diagonalleys_indexer(product_id)
|
||||
@@ -59,7 +77,9 @@ def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Product
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Products(**row) for row in rows]
|
||||
|
||||
@@ -110,7 +130,9 @@ def update_diagonalleys_indexer(indexer_id: str, **kwargs) -> Optional[Indexers]
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id))
|
||||
db.execute(
|
||||
f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id)
|
||||
)
|
||||
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
|
||||
|
||||
return get_diagonalleys_indexer(indexer_id)
|
||||
@@ -154,7 +176,9 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
for r in rows:
|
||||
try:
|
||||
@@ -181,7 +205,9 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
|
||||
print("An exception occurred")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Indexers(**row) for row in rows]
|
||||
|
||||
|
||||
@@ -213,7 +239,19 @@ def create_diagonalleys_order(
|
||||
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(order_id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, False, False),
|
||||
(
|
||||
order_id,
|
||||
productid,
|
||||
wallet,
|
||||
product,
|
||||
quantity,
|
||||
shippingzone,
|
||||
address,
|
||||
email,
|
||||
invoiceid,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
)
|
||||
|
||||
return get_diagonalleys_order(order_id)
|
||||
@@ -232,9 +270,11 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
for r in rows:
|
||||
PAID = WALLET.get_invoice_status(r["invoiceid"]).paid
|
||||
PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid
|
||||
if PAID:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
@@ -244,7 +284,9 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
|
||||
r["id"],
|
||||
),
|
||||
)
|
||||
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Orders(**row) for row in rows]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -41,10 +41,14 @@
|
||||
|
||||
<q-card v-show="ticketLink.show" class="q-pa-lg">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="ticketLink.data.link" target="_blank">
|
||||
<q-btn unelevated size="xl" color="deep-purple"
|
||||
>Link to your ticket!</q-btn
|
||||
></a
|
||||
<q-btn
|
||||
unelevated
|
||||
size="xl"
|
||||
:href="ticketLink.data.link"
|
||||
target="_blank"
|
||||
color="deep-purple"
|
||||
type="a"
|
||||
>Link to your ticket!</q-btn
|
||||
>
|
||||
<br /><br />
|
||||
<p>You'll be redirected in a few moments...</p>
|
||||
|
||||
@@ -176,22 +176,29 @@
|
||||
<q-dialog v-model="formDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendEventData" 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="name"
|
||||
label="Name of event "
|
||||
></q-input>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="name"
|
||||
label="Title of event "
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-pl-sm">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
@@ -199,41 +206,63 @@
|
||||
type="textarea"
|
||||
label="Info about the event "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.closing_date"
|
||||
type="date"
|
||||
label="Closing for tickets "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_start_date"
|
||||
type="date"
|
||||
label="Event begins "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_end_date"
|
||||
type="date"
|
||||
label="Event ends "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.amount_tickets"
|
||||
type="number"
|
||||
label="Amount of tickets "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.price_per_ticket"
|
||||
type="number"
|
||||
label="Price per ticket "
|
||||
></q-input>
|
||||
<div class="row">
|
||||
<div class="col-4">Ticket closing date</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.closing_date"
|
||||
type="date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">Event begins</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_start_date"
|
||||
type="date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">Event ends</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_end_date"
|
||||
type="date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.amount_tickets"
|
||||
type="number"
|
||||
label="Amount of tickets "
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-pl-sm">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.price_per_ticket"
|
||||
type="number"
|
||||
label="Price per ticket "
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
# );
|
||||
# """
|
||||
# )
|
||||
|
||||
@@ -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))
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# DJ Livestream
|
||||
|
||||
An extension to help DJs to conduct music livestreams.
|
||||
|
||||
It produces a single static QR code that can be shown on screen. Once someone scans that QR code with an lnurl-pay capable wallet they will see the name of the track being played at that time and the name of the producer.
|
||||
|
||||
They will then be given the opportunity to send a tip and a message related to that specific track and if they pay an amount over a specific threshold they will be given a link to download it (optional).
|
||||
|
||||
The revenue will be sent to a wallet created specifically for that producer, with optional revenue splitting between the DJ and the producer.
|
||||
|
||||
## Sponsored by
|
||||
|
||||
[](https://cryptograffiti.com/)
|
||||
@@ -0,0 +1,19 @@
|
||||
from quart import Blueprint
|
||||
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_livestream")
|
||||
|
||||
livestream_ext: Blueprint = Blueprint(
|
||||
"livestream", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
||||
livestream_ext.record(record_async(register_listeners))
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "DJ Livestream",
|
||||
"short_description": "Sell tracks and split revenue (lnurl-pay)",
|
||||
"icon": "speaker",
|
||||
"contributors": [
|
||||
"fiatjaf",
|
||||
"cryptograffiti"
|
||||
],
|
||||
"hidden": false
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from lnbits.core.crud import create_account, create_wallet
|
||||
|
||||
from . import db
|
||||
from .models import Livestream, Track, Producer
|
||||
|
||||
|
||||
async def create_livestream(*, wallet_id: str) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO livestreams (wallet)
|
||||
VALUES (?)
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def get_livestream(ls_id: int) -> Optional[Livestream]:
|
||||
row = await db.fetchone("SELECT * FROM livestreams WHERE id = ?", (ls_id,))
|
||||
return Livestream(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_livestream_by_track(track_id: int) -> Optional[Livestream]:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT livestreams.* FROM livestreams
|
||||
INNER JOIN tracks ON tracks.livestream = livestreams.id
|
||||
WHERE tracks.id = ?
|
||||
""",
|
||||
(track_id,),
|
||||
)
|
||||
return Livestream(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]:
|
||||
row = await db.fetchone("SELECT * FROM livestreams WHERE wallet = ?", (wallet,))
|
||||
|
||||
if not row:
|
||||
# create on the fly
|
||||
ls_id = await create_livestream(wallet_id=wallet)
|
||||
return await get_livestream(ls_id)
|
||||
|
||||
return Livestream(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def update_current_track(ls_id: int, track_id: Optional[int]):
|
||||
await db.execute(
|
||||
"UPDATE livestreams SET current_track = ? WHERE id = ?",
|
||||
(track_id, ls_id),
|
||||
)
|
||||
|
||||
|
||||
async def update_livestream_fee(ls_id: int, fee_pct: int):
|
||||
await db.execute(
|
||||
"UPDATE livestreams SET fee_pct = ? WHERE id = ?",
|
||||
(fee_pct, ls_id),
|
||||
)
|
||||
|
||||
|
||||
async def add_track(
|
||||
livestream: int,
|
||||
name: str,
|
||||
download_url: Optional[str],
|
||||
price_msat: int,
|
||||
producer: Optional[int],
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO tracks (livestream, name, download_url, price_msat, producer)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(livestream, name, download_url, price_msat, producer),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def update_track(
|
||||
livestream: int,
|
||||
track_id: int,
|
||||
name: str,
|
||||
download_url: Optional[str],
|
||||
price_msat: int,
|
||||
producer: int,
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
UPDATE tracks SET
|
||||
name = ?,
|
||||
download_url = ?,
|
||||
price_msat = ?,
|
||||
producer = ?
|
||||
WHERE livestream = ? AND id = ?
|
||||
""",
|
||||
(name, download_url, price_msat, producer, livestream, track_id),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def get_track(track_id: Optional[int]) -> Optional[Track]:
|
||||
if not track_id:
|
||||
return None
|
||||
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT id, download_url, price_msat, name, producer
|
||||
FROM tracks WHERE id = ?
|
||||
""",
|
||||
(track_id,),
|
||||
)
|
||||
return Track(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_tracks(livestream: int) -> List[Track]:
|
||||
rows = await db.fetchall(
|
||||
"""
|
||||
SELECT id, download_url, price_msat, name, producer
|
||||
FROM tracks WHERE livestream = ?
|
||||
""",
|
||||
(livestream,),
|
||||
)
|
||||
return [Track(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def delete_track_from_livestream(livestream: int, track_id: int):
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM tracks WHERE livestream = ? AND id = ?
|
||||
""",
|
||||
(livestream, track_id),
|
||||
)
|
||||
|
||||
|
||||
async def add_producer(livestream: int, name: str) -> int:
|
||||
name = name.strip()
|
||||
|
||||
existing = await db.fetchall(
|
||||
"""
|
||||
SELECT id FROM producers
|
||||
WHERE livestream = ? AND lower(name) = ?
|
||||
""",
|
||||
(livestream, name.lower()),
|
||||
)
|
||||
if existing:
|
||||
return existing[0].id
|
||||
|
||||
user = await create_account()
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name="livestream: " + name)
|
||||
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO producers (livestream, name, user, wallet)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(livestream, name, user.id, wallet.id),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def get_producer(producer_id: int) -> Optional[Producer]:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT id, user, wallet, name
|
||||
FROM producers WHERE id = ?
|
||||
""",
|
||||
(producer_id,),
|
||||
)
|
||||
return Producer(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_producers(livestream: int) -> List[Producer]:
|
||||
rows = await db.fetchall(
|
||||
"""
|
||||
SELECT id, user, wallet, name
|
||||
FROM producers WHERE livestream = ?
|
||||
""",
|
||||
(livestream,),
|
||||
)
|
||||
return [Producer(**dict(row)) for row in rows]
|
||||
@@ -0,0 +1,114 @@
|
||||
import hashlib
|
||||
import math
|
||||
from quart import jsonify, url_for, request
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
|
||||
from . import livestream_ext
|
||||
from .crud import get_livestream, get_livestream_by_track, get_track
|
||||
|
||||
|
||||
@livestream_ext.route("/lnurl/<ls_id>", methods=["GET"])
|
||||
async def lnurl_livestream(ls_id):
|
||||
ls = await get_livestream(ls_id)
|
||||
if not ls:
|
||||
return jsonify({"status": "ERROR", "reason": "Livestream not found."})
|
||||
|
||||
track = await get_track(ls.current_track)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "This livestream is offline."})
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for(
|
||||
"livestream.lnurl_callback", track_id=track.id, _external=True
|
||||
),
|
||||
min_sendable=track.min_sendable,
|
||||
max_sendable=track.max_sendable,
|
||||
metadata=await track.lnurlpay_metadata(),
|
||||
)
|
||||
|
||||
params = resp.dict()
|
||||
params["commentAllowed"] = 300
|
||||
|
||||
return jsonify(params)
|
||||
|
||||
|
||||
@livestream_ext.route("/lnurl/t/<track_id>", methods=["GET"])
|
||||
async def lnurl_track(track_id):
|
||||
track = await get_track(track_id)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "Track not found."})
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for(
|
||||
"livestream.lnurl_callback", track_id=track.id, _external=True
|
||||
),
|
||||
min_sendable=track.min_sendable,
|
||||
max_sendable=track.max_sendable,
|
||||
metadata=await track.lnurlpay_metadata(),
|
||||
)
|
||||
|
||||
params = resp.dict()
|
||||
params["commentAllowed"] = 300
|
||||
|
||||
return jsonify(params)
|
||||
|
||||
|
||||
@livestream_ext.route("/lnurl/cb/<track_id>", methods=["GET"])
|
||||
async def lnurl_callback(track_id):
|
||||
track = await get_track(track_id)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "Couldn't find track."})
|
||||
|
||||
amount_received = int(request.args.get("amount"))
|
||||
|
||||
if amount_received < track.min_sendable:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}."
|
||||
).dict()
|
||||
),
|
||||
)
|
||||
elif track.max_sendable < amount_received:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}."
|
||||
).dict()
|
||||
),
|
||||
)
|
||||
|
||||
comment = request.args.get("comment")
|
||||
if len(comment or "") > 300:
|
||||
return jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Got a comment with {len(comment)} characters, but can only accept 300"
|
||||
).dict()
|
||||
)
|
||||
|
||||
ls = await get_livestream_by_track(track_id)
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=ls.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=await track.fullname(),
|
||||
description_hash=hashlib.sha256(
|
||||
(await track.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "livestream", "track": track.id, "comment": comment},
|
||||
)
|
||||
|
||||
if amount_received < track.price_msat:
|
||||
success_action = None
|
||||
else:
|
||||
success_action = track.success_action(payment_hash)
|
||||
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=success_action,
|
||||
routes=[],
|
||||
)
|
||||
|
||||
return jsonify(resp.dict())
|
||||
@@ -0,0 +1,39 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial livestream tables.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE livestreams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wallet TEXT NOT NULL,
|
||||
fee_pct INTEGER NOT NULL DEFAULT 10,
|
||||
current_track INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE producers (
|
||||
livestream INTEGER NOT NULL REFERENCES livestreams (id),
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE tracks (
|
||||
livestream INTEGER NOT NULL REFERENCES livestreams (id),
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_url TEXT,
|
||||
price_msat INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT,
|
||||
producer INTEGER REFERENCES producers (id) NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
import json
|
||||
from quart import url_for
|
||||
from typing import NamedTuple, Optional
|
||||
from lnurl import Lnurl, encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||
|
||||
|
||||
class Livestream(NamedTuple):
|
||||
id: int
|
||||
wallet: str
|
||||
fee_pct: int
|
||||
current_track: Optional[int]
|
||||
|
||||
@property
|
||||
def lnurl(self) -> Lnurl:
|
||||
url = url_for("livestream.lnurl_livestream", ls_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
||||
|
||||
|
||||
class Track(NamedTuple):
|
||||
id: int
|
||||
download_url: str
|
||||
price_msat: int
|
||||
name: str
|
||||
producer: int
|
||||
|
||||
@property
|
||||
def min_sendable(self) -> int:
|
||||
return min(100_000, self.price_msat or 100_000)
|
||||
|
||||
@property
|
||||
def max_sendable(self) -> int:
|
||||
return max(50_000_000, self.price_msat * 5)
|
||||
|
||||
@property
|
||||
def lnurl(self) -> Lnurl:
|
||||
url = url_for("livestream.lnurl_track", track_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
||||
|
||||
async def fullname(self) -> str:
|
||||
from .crud import get_producer
|
||||
|
||||
producer = await get_producer(self.producer)
|
||||
if producer:
|
||||
producer_name = producer.name
|
||||
else:
|
||||
producer_name = "unknown author"
|
||||
|
||||
return f"'{self.name}', from {producer_name}."
|
||||
|
||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
description = (
|
||||
await self.fullname()
|
||||
) + " Like this track? Send some sats in appreciation."
|
||||
|
||||
if self.download_url:
|
||||
description += f" Send {round(self.price_msat/1000)} sats or more and you can download it."
|
||||
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", description]]))
|
||||
|
||||
def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
|
||||
if not self.download_url:
|
||||
return None
|
||||
|
||||
return UrlAction(
|
||||
url=url_for(
|
||||
"livestream.track_redirect_download",
|
||||
track_id=self.id,
|
||||
p=payment_hash,
|
||||
_external=True,
|
||||
),
|
||||
description=f"Download the track {self.name}!",
|
||||
)
|
||||
|
||||
|
||||
class Producer(NamedTuple):
|
||||
id: int
|
||||
user: str
|
||||
wallet: str
|
||||
name: str
|
||||
@@ -0,0 +1,213 @@
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
cancelListener: () => {},
|
||||
selectedWallet: null,
|
||||
nextCurrentTrack: null,
|
||||
livestream: {
|
||||
tracks: [],
|
||||
producers: []
|
||||
},
|
||||
trackDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedTracks() {
|
||||
return this.livestream.tracks.sort((a, b) => a.name - b.name)
|
||||
},
|
||||
tracksMap() {
|
||||
return Object.fromEntries(
|
||||
this.livestream.tracks.map(track => [track.id, track])
|
||||
)
|
||||
},
|
||||
producersMap() {
|
||||
return Object.fromEntries(
|
||||
this.livestream.producers.map(prod => [prod.id, prod])
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getTrackLabel(trackId) {
|
||||
let track = this.tracksMap[trackId]
|
||||
return `${track.name}, ${this.producersMap[track.producer].name}`
|
||||
},
|
||||
disabledAddTrackButton() {
|
||||
return (
|
||||
!this.trackDialog.data.name ||
|
||||
this.trackDialog.data.name.length === 0 ||
|
||||
!this.trackDialog.data.producer ||
|
||||
this.trackDialog.data.producer.length === 0
|
||||
)
|
||||
},
|
||||
changedWallet(wallet) {
|
||||
this.selectedWallet = wallet
|
||||
this.loadLivestream()
|
||||
this.startPaymentNotifier()
|
||||
},
|
||||
loadLivestream() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/livestream/api/v1/livestream',
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.livestream = response.data
|
||||
this.nextCurrentTrack = this.livestream.current_track
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
startPaymentNotifier() {
|
||||
this.cancelListener()
|
||||
|
||||
this.cancelListener = LNbits.events.onInvoicePaid(
|
||||
this.selectedWallet,
|
||||
payment => {
|
||||
let satoshiAmount = Math.round(payment.amount / 1000)
|
||||
let trackName = (
|
||||
this.tracksMap[payment.extra.track] || {name: '[unknown]'}
|
||||
).name
|
||||
|
||||
this.$q.notify({
|
||||
message: `Someone paid <b>${satoshiAmount} sat</b> for the track <em>${trackName}</em>.`,
|
||||
caption: payment.extra.comment
|
||||
? `<em>"${payment.extra.comment}"</em>`
|
||||
: undefined,
|
||||
color: 'secondary',
|
||||
html: true,
|
||||
timeout: 0,
|
||||
actions: [{label: 'Dismiss', color: 'white', handler: () => {}}]
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
addTrack() {
|
||||
let {id, name, producer, price_sat, download_url} = this.trackDialog.data
|
||||
|
||||
const [method, path] = id
|
||||
? ['PUT', `/livestream/api/v1/livestream/tracks/${id}`]
|
||||
: ['POST', '/livestream/api/v1/livestream/tracks']
|
||||
|
||||
LNbits.api
|
||||
.request(method, path, this.selectedWallet.inkey, {
|
||||
download_url:
|
||||
download_url && download_url.length > 0 ? download_url : undefined,
|
||||
name,
|
||||
price_msat: price_sat * 1000 || 0,
|
||||
producer_name: typeof producer === 'string' ? producer : undefined,
|
||||
producer_id: typeof producer === 'object' ? producer.id : undefined
|
||||
})
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Track '${this.trackDialog.data.name}' added.`,
|
||||
timeout: 700
|
||||
})
|
||||
this.loadLivestream()
|
||||
this.trackDialog.show = false
|
||||
this.trackDialog.data = {}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
openAddTrackDialog() {
|
||||
this.trackDialog.show = true
|
||||
this.trackDialog.data = {}
|
||||
},
|
||||
openUpdateDialog(itemId) {
|
||||
this.trackDialog.show = true
|
||||
let item = this.livestream.tracks.find(item => item.id === itemId)
|
||||
this.trackDialog.data = {
|
||||
...item,
|
||||
producer: this.livestream.producers.find(
|
||||
prod => prod.id === item.producer
|
||||
),
|
||||
price_sat: Math.round(item.price_msat / 1000)
|
||||
}
|
||||
},
|
||||
deleteTrack(trackId) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this track?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/livestream/api/v1/livestream/tracks/' + trackId,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Track deleted`,
|
||||
timeout: 700
|
||||
})
|
||||
this.livestream.tracks.splice(
|
||||
this.livestream.tracks.findIndex(track => track.id === trackId),
|
||||
1
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
updateCurrentTrack(track) {
|
||||
if (this.livestream.current_track === track) {
|
||||
// if clicking the same, stop it
|
||||
track = 0
|
||||
}
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/livestream/api/v1/livestream/track/' + track,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(() => {
|
||||
this.livestream.current_track = track
|
||||
this.$q.notify({
|
||||
message: `Current track updated.`,
|
||||
timeout: 700
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
updateFeePct() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/livestream/api/v1/livestream/fee/' + this.livestream.fee_pct,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(() => {
|
||||
this.$q.notify({
|
||||
message: `Percentage updated.`,
|
||||
timeout: 700
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
producerAdded(added, cb) {
|
||||
cb(added)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.selectedWallet = this.g.user.wallets[0]
|
||||
this.loadLivestream()
|
||||
this.startPaymentNotifier()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import json
|
||||
import trio # type: ignore
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.crud import create_payment
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.tasks import register_invoice_listener, internal_invoice_paid
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from .crud import get_track, get_producer, get_livestream_by_track
|
||||
|
||||
|
||||
async def register_listeners():
|
||||
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
|
||||
register_invoice_listener(invoice_paid_chan_send)
|
||||
await wait_for_paid_invoices(invoice_paid_chan_recv)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
|
||||
async for payment in invoice_paid_chan:
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if "livestream" != payment.extra.get("tag"):
|
||||
# not a livestream invoice
|
||||
return
|
||||
|
||||
track = await get_track(payment.extra.get("track", -1))
|
||||
if not track:
|
||||
print("this should never happen", payment)
|
||||
return
|
||||
|
||||
if payment.extra.get("shared_with"):
|
||||
print("payment was shared already", payment)
|
||||
return
|
||||
|
||||
producer = await get_producer(track.producer)
|
||||
assert producer, f"track {track.id} is not associated with a producer"
|
||||
|
||||
ls = await get_livestream_by_track(track.id)
|
||||
assert ls, f"track {track.id} is not associated with a livestream"
|
||||
|
||||
# now we make a special kind of internal transfer
|
||||
amount = int(payment.amount * (100 - ls.fee_pct) / 100)
|
||||
|
||||
# mark the original payment with two extra keys, "shared_with" and "received"
|
||||
# (this prevents us from doing this process again and it's informative)
|
||||
# and reduce it by the amount we're going to send to the producer
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments
|
||||
SET extra = ?, amount = ?
|
||||
WHERE hash = ?
|
||||
AND checking_id NOT LIKE 'internal_%'
|
||||
""",
|
||||
(
|
||||
json.dumps(
|
||||
dict(
|
||||
**payment.extra,
|
||||
shared_with=[producer.name, producer.id],
|
||||
received=payment.amount,
|
||||
)
|
||||
),
|
||||
payment.amount - amount,
|
||||
payment.payment_hash,
|
||||
),
|
||||
)
|
||||
|
||||
# perform an internal transfer using the same payment_hash to the producer wallet
|
||||
internal_checking_id = f"internal_{urlsafe_short_hash()}"
|
||||
await create_payment(
|
||||
wallet_id=producer.wallet,
|
||||
checking_id=internal_checking_id,
|
||||
payment_request="",
|
||||
payment_hash=payment.payment_hash,
|
||||
amount=amount,
|
||||
memo=f"Revenue from '{track.name}'.",
|
||||
pending=False,
|
||||
)
|
||||
|
||||
# manually send this for now
|
||||
await internal_invoice_paid.send(internal_checking_id)
|
||||
|
||||
# so the flow is the following:
|
||||
# - we receive, say, 1000 satoshis
|
||||
# - if the fee_pct is, say, 30%, the amount we will send is 700
|
||||
# - we change the amount of receiving payment on the database from 1000 to 300
|
||||
# - we create a new payment on the producer's wallet with amount 700
|
||||
@@ -0,0 +1,146 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="How to use"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>Add tracks, profit.</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="List livestream links"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/livestream/api/v1/livestream</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<livestream_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update track">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/livestream/api/v1/livestream/track/<track_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root
|
||||
}}api/v1/livestream/track/<track_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update fee">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/livestream/api/v1/livestream/fee/<fee_pct></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root
|
||||
}}api/v1/livestream/fee/<fee_pct> -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="Add track">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span>
|
||||
/livestream/api/v1/livestream/tracks</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"name": <string>, "download_url": <string>,
|
||||
"price_msat": <integer>, "producer_id": <integer>,
|
||||
"producer_name": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/livestream/tracks -d
|
||||
'{"name": <string>, "download_url": <string>,
|
||||
"price_msat": <integer>, "producer_id": <integer>,
|
||||
"producer_name": <string>}' -H "Content-type: application/json"
|
||||
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a withdraw link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/livestream/api/v1/livestream/tracks/<track_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.url_root
|
||||
}}api/v1/livestream/tracks/<track_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,320 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<q-form
|
||||
@submit="updateCurrentTrack(nextCurrentTrack)"
|
||||
class="q-gutter-md"
|
||||
>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-select
|
||||
dense
|
||||
filled
|
||||
v-model="nextCurrentTrack"
|
||||
use-input
|
||||
hide-selected
|
||||
fill-input
|
||||
input-debounce="0"
|
||||
:options="sortedTracks.map(track => track.id)"
|
||||
option-value="id"
|
||||
:option-label="getTrackLabel"
|
||||
options-dense
|
||||
label="Current track"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
{% raw %}
|
||||
<q-btn unelevated color="deep-purple" type="submit">
|
||||
{{ nextCurrentTrack === livestream.current_track ? 'Stop' : 'Set'
|
||||
}} current track
|
||||
</q-btn>
|
||||
{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
<q-form @submit="updateFeePct" class="q-gutter-md">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="livestream.fee_pct"
|
||||
type="number"
|
||||
label="Revenue to keep (%)"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn unelevated color="deep-purple" type="submit"
|
||||
>Set percent rate</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Tracks</h5>
|
||||
</div>
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn unelevated color="deep-purple" @click="openAddTrackDialog"
|
||||
>Add new track</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="sortedTracks"
|
||||
row-key="id"
|
||||
no-data-label="No tracks added yet"
|
||||
:pagination="{rowsPerPage: 0}"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width>Name</q-th>
|
||||
<q-th auto-width>Producer</q-th>
|
||||
<q-th auto-width>Price</q-th>
|
||||
<q-th auto-width>Download URL</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
:icon="livestream.current_track !== props.row.id ? 'play_circle_outline' : 'play_arrow'"
|
||||
:color="livestream.current_track !== props.row.id ? ($q.dark.isActive ? 'grey-7' : 'grey-5') : 'green'"
|
||||
type="a"
|
||||
@click="updateCurrentTrack(props.row.id)"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>{{ props.row.name }}</q-td>
|
||||
<q-td auto-width>
|
||||
{{ producersMap[props.row.producer].name }}
|
||||
</q-td>
|
||||
<q-td class="text-right" auto-width
|
||||
>{{ Math.round(props.row.price_msat / 1000) }}</q-td
|
||||
>
|
||||
<q-td class="text-center" auto-width
|
||||
>{{ props.row.download_url }}</q-td
|
||||
>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
type="a"
|
||||
@click="deleteTrack(props.row.id)"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Producers</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="livestream.producers"
|
||||
row-key="id"
|
||||
no-data-label="To include a producer, add a track"
|
||||
:pagination="{rowsPerPage: 0}"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width>Name</q-th>
|
||||
<q-th auto-width>Wallet</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>{{ props.row.name }}</q-td>
|
||||
<q-td class="text-center" auto-width>
|
||||
<a
|
||||
target="_blank"
|
||||
:href="'/wallet?usr=' + props.row.user + '&wal=' + props.row.wallet"
|
||||
>
|
||||
{{ props.row.wallet }}
|
||||
</a>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm col-5">
|
||||
<q-card-section class="q-pa-none text-center">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
:options="g.user.wallets"
|
||||
:value="selectedWallet"
|
||||
label="Using wallet:"
|
||||
option-label="name"
|
||||
@input="changedWallet"
|
||||
>
|
||||
</q-select>
|
||||
</q-form>
|
||||
|
||||
<a :href="'lightning:' + livestream.lnurl">
|
||||
<q-responsive :ratio="1" class="q-mx-sm">
|
||||
<qrcode
|
||||
:value="livestream.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(livestream.lnurl)"
|
||||
class="text-center q-mb-md"
|
||||
>Copy LNURL-pay code</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits Livestream extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "livestream/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="trackDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-card-section
|
||||
v-if="trackDialog.data.lnurl"
|
||||
class="q-pa-none text-center"
|
||||
>
|
||||
<p class="text-subtitle1 q-my-none">
|
||||
Standalone QR Code for this track
|
||||
</p>
|
||||
<a :href="'lightning:' + trackDialog.data.lnurl">
|
||||
<q-responsive :ratio="1" class="q-mx-sm">
|
||||
<qrcode
|
||||
:value="trackDialog.data.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(trackDialog.data.lnurl)"
|
||||
class="text-center q-mb-md"
|
||||
>Copy LNURL-pay code</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-form @submit="addTrack" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="trackDialog.data.producer"
|
||||
use-input
|
||||
hide-selected
|
||||
fill-input
|
||||
option-label="name"
|
||||
input-debounce="0"
|
||||
@new-value="producerAdded"
|
||||
:options="livestream.producers"
|
||||
options-dense
|
||||
label="Producer"
|
||||
hint="Select an existing producer or add a new one by name (press Enter to add)."
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="trackDialog.data.name"
|
||||
type="text"
|
||||
label="Track name"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="trackDialog.data.price_sat"
|
||||
type="number"
|
||||
min="1"
|
||||
label="Track price (sat)"
|
||||
hint="This is the minimum price for buying the track download link. It does nothing for tracks without a download URL."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="trackDialog.data.download_url"
|
||||
type="text"
|
||||
label="Download URL"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="disabledAddTrackButton()"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="trackDialog.data.id">Update track</span>
|
||||
<span v-else>Add track</span>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/livestream/static/js/index.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
from quart import g, render_template, request, redirect
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.crud import get_wallet_payment
|
||||
|
||||
from . import livestream_ext
|
||||
from .crud import get_track, get_livestream_by_track
|
||||
|
||||
|
||||
@livestream_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("livestream/index.html", user=g.user)
|
||||
|
||||
|
||||
@livestream_ext.route("/track/<track_id>")
|
||||
async def track_redirect_download(track_id):
|
||||
payment_hash = request.args.get("p")
|
||||
track = await get_track(track_id)
|
||||
ls = await get_livestream_by_track(track_id)
|
||||
payment: Payment = await get_wallet_payment(ls.wallet, payment_hash)
|
||||
|
||||
if not payment:
|
||||
return (
|
||||
f"Couldn't find the payment {payment_hash} or track {track.id}.",
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if payment.pending:
|
||||
return (
|
||||
f"Payment {payment_hash} wasn't received yet. Please try again in a minute.",
|
||||
HTTPStatus.PAYMENT_REQUIRED,
|
||||
)
|
||||
|
||||
return redirect(track.download_url)
|
||||
@@ -0,0 +1,135 @@
|
||||
from quart import g, jsonify
|
||||
from http import HTTPStatus
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from . import livestream_ext
|
||||
from .crud import (
|
||||
get_or_create_livestream_by_wallet,
|
||||
add_track,
|
||||
get_tracks,
|
||||
update_track,
|
||||
add_producer,
|
||||
get_producers,
|
||||
update_current_track,
|
||||
update_livestream_fee,
|
||||
delete_track_from_livestream,
|
||||
)
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_livestream_from_wallet():
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
tracks = await get_tracks(ls.id)
|
||||
producers = await get_producers(ls.id)
|
||||
|
||||
try:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
**ls._asdict(),
|
||||
**{
|
||||
"lnurl": ls.lnurl,
|
||||
"tracks": [
|
||||
dict(lnurl=track.lnurl, **track._asdict())
|
||||
for track in tracks
|
||||
],
|
||||
"producers": [producer._asdict() for producer in producers],
|
||||
},
|
||||
}
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except LnurlInvalidUrl:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
|
||||
}
|
||||
),
|
||||
HTTPStatus.UPGRADE_REQUIRED,
|
||||
)
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/track/<track_id>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_update_track(track_id):
|
||||
try:
|
||||
id = int(track_id)
|
||||
except ValueError:
|
||||
id = 0
|
||||
if id <= 0:
|
||||
id = None
|
||||
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await update_current_track(ls.id, id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/fee/<fee_pct>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_update_fee(fee_pct):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await update_livestream_fee(ls.id, int(fee_pct))
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/tracks", methods=["POST"])
|
||||
@livestream_ext.route("/api/v1/livestream/tracks/<id>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"download_url": {"type": "string", "empty": False, "required": False},
|
||||
"price_msat": {"type": "number", "min": 0, "required": False},
|
||||
"producer_id": {
|
||||
"type": "number",
|
||||
"required": True,
|
||||
"excludes": "producer_name",
|
||||
},
|
||||
"producer_name": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"excludes": "producer_id",
|
||||
},
|
||||
}
|
||||
)
|
||||
async def api_add_track(id=None):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
|
||||
if "producer_id" in g.data:
|
||||
p_id = g.data["producer_id"]
|
||||
elif "producer_name" in g.data:
|
||||
p_id = await add_producer(ls.id, g.data["producer_name"])
|
||||
else:
|
||||
raise TypeError("need either producer_id or producer_name arguments")
|
||||
|
||||
if id:
|
||||
await update_track(
|
||||
ls.id,
|
||||
id,
|
||||
g.data["name"],
|
||||
g.data.get("download_url"),
|
||||
g.data.get("price_msat", 0),
|
||||
p_id,
|
||||
)
|
||||
return "", HTTPStatus.OK
|
||||
else:
|
||||
await add_track(
|
||||
ls.id,
|
||||
g.data["name"],
|
||||
g.data.get("download_url"),
|
||||
g.data.get("price_msat", 0),
|
||||
p_id,
|
||||
)
|
||||
return "", HTTPStatus.CREATED
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/tracks/<track_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_delete_track(track_id):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await delete_track_from_livestream(ls.id, track_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@@ -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
|
||||
|
||||
@@ -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,2 +1,2 @@
|
||||
def migrate():
|
||||
async def migrate():
|
||||
pass
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,121 +1,151 @@
|
||||
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)
|
||||
assert ticket, "Newly paid ticket could not be retrieved"
|
||||
|
||||
if formdata.webhook:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(
|
||||
formdata.webhook,
|
||||
json={
|
||||
"form": ticket.form,
|
||||
"name": ticket.name,
|
||||
"email": ticket.email,
|
||||
"content": ticket.ltext,
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
return ticket
|
||||
|
||||
ticket = await get_ticket(payment_hash)
|
||||
assert ticket, "Newly paid ticket could not be retrieved"
|
||||
return ticket
|
||||
|
||||
|
||||
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,))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user