Compare commits
No commits in common. "main" and "v0.1.8" have entirely different histories.
40 changed files with 783 additions and 3849 deletions
10
.github/workflows/lint.yml
vendored
10
.github/workflows/lint.yml
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
name: lint
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
uses: lnbits/lnbits/.github/workflows/lint.yml@dev
|
|
||||||
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
|
|
@ -1,13 +1,14 @@
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- name: Create github release
|
- name: Create github release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
@ -19,7 +20,7 @@ jobs:
|
||||||
needs: [release]
|
needs: [release]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.EXT_GITHUB }}
|
token: ${{ secrets.EXT_GITHUB }}
|
||||||
repository: lnbits/lnbits-extensions
|
repository: lnbits/lnbits-extensions
|
||||||
|
|
@ -33,12 +34,12 @@ jobs:
|
||||||
- name: Create pull request in extensions repo
|
- name: Create pull request in extensions repo
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
||||||
repo_name: '${{ github.event.repository.name }}'
|
repo_name: "${{ github.event.repository.name }}"
|
||||||
tag: '${{ github.ref_name }}'
|
tag: "${{ github.ref_name }}"
|
||||||
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
|
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
|
||||||
title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
|
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
|
||||||
body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
|
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
|
||||||
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
|
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
|
||||||
run: |
|
run: |
|
||||||
cd lnbits-extensions
|
cd lnbits-extensions
|
||||||
git checkout -b $branch
|
git checkout -b $branch
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,4 +1 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
node_modules
|
|
||||||
.mypy_cache
|
|
||||||
.venv
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v4.3.0
|
|
||||||
hooks:
|
|
||||||
- id: trailing-whitespace
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: check-yaml
|
|
||||||
- id: check-added-large-files
|
|
||||||
- id: check-docstring-first
|
|
||||||
- id: check-json
|
|
||||||
- id: debug-statements
|
|
||||||
- id: mixed-line-ending
|
|
||||||
- id: check-case-conflict
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 24.2.0
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
||||||
rev: v0.3.2
|
|
||||||
hooks:
|
|
||||||
- id: ruff
|
|
||||||
args: [--fix, --exit-non-zero-on-fix]
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: 'v4.0.0-alpha.8'
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
types_or: [css, javascript, html, json]
|
|
||||||
12
.prettierrc
12
.prettierrc
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"insertPragma": false,
|
|
||||||
"printWidth": 80,
|
|
||||||
"proseWrap": "preserve",
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"useTabs": false,
|
|
||||||
"bracketSameLine": false,
|
|
||||||
"bracketSpacing": false
|
|
||||||
}
|
|
||||||
47
Makefile
47
Makefile
|
|
@ -1,47 +0,0 @@
|
||||||
all: format check
|
|
||||||
|
|
||||||
format: prettier black ruff
|
|
||||||
|
|
||||||
check: mypy pyright checkblack checkruff checkprettier
|
|
||||||
|
|
||||||
prettier:
|
|
||||||
uv run ./node_modules/.bin/prettier --write .
|
|
||||||
pyright:
|
|
||||||
uv run ./node_modules/.bin/pyright
|
|
||||||
|
|
||||||
mypy:
|
|
||||||
uv run mypy .
|
|
||||||
|
|
||||||
black:
|
|
||||||
uv run black .
|
|
||||||
|
|
||||||
ruff:
|
|
||||||
uv run ruff check . --fix
|
|
||||||
|
|
||||||
checkruff:
|
|
||||||
uv run ruff check .
|
|
||||||
|
|
||||||
checkprettier:
|
|
||||||
uv run ./node_modules/.bin/prettier --check .
|
|
||||||
|
|
||||||
checkblack:
|
|
||||||
uv run black --check .
|
|
||||||
|
|
||||||
checkeditorconfig:
|
|
||||||
editorconfig-checker
|
|
||||||
|
|
||||||
test:
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
DEBUG=true \
|
|
||||||
uv run pytest
|
|
||||||
install-pre-commit-hook:
|
|
||||||
@echo "Installing pre-commit hook to git"
|
|
||||||
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
|
||||||
uv run pre-commit install
|
|
||||||
|
|
||||||
pre-commit:
|
|
||||||
uv run pre-commit run --all-files
|
|
||||||
|
|
||||||
|
|
||||||
checkbundle:
|
|
||||||
@echo "skipping checkbundle"
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
# LNURLw - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
|
# LNURLw - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
|
||||||
|
|
||||||
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
|
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
|
||||||
|
|
||||||
## Create a static QR code people can use to withdraw funds from a Lightning Network wallet
|
## Create a static QR code people can use to withdraw funds from a Lightning Network wallet
|
||||||
|
|
|
||||||
90
__init__.py
90
__init__.py
|
|
@ -1,9 +1,13 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Request, Response
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
from .crud import db
|
from fastapi.responses import JSONResponse
|
||||||
from .views import withdraw_ext_generic
|
|
||||||
from .views_api import withdraw_ext_api
|
from lnbits.db import Database
|
||||||
from .views_lnurl import withdraw_ext_lnurl
|
from lnbits.helpers import template_renderer
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
db = Database("ext_withdraw")
|
||||||
|
|
||||||
withdraw_static_files = [
|
withdraw_static_files = [
|
||||||
{
|
{
|
||||||
|
|
@ -12,56 +16,36 @@ withdraw_static_files = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class LNURLErrorResponseHandler(APIRoute):
|
||||||
|
def get_route_handler(self) -> Callable:
|
||||||
|
original_route_handler = super().get_route_handler()
|
||||||
|
|
||||||
|
async def custom_route_handler(request: Request) -> Response:
|
||||||
|
try:
|
||||||
|
response = await original_route_handler(request)
|
||||||
|
except HTTPException as exc:
|
||||||
|
logger.debug(f"HTTPException: {exc}")
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"status": "ERROR", "reason": f"{exc.detail}"},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
return custom_route_handler
|
||||||
|
|
||||||
|
|
||||||
withdraw_ext: APIRouter = APIRouter(prefix="/withdraw", tags=["withdraw"])
|
withdraw_ext: APIRouter = APIRouter(prefix="/withdraw", tags=["withdraw"])
|
||||||
withdraw_ext.include_router(withdraw_ext_generic)
|
withdraw_ext.route_class = LNURLErrorResponseHandler
|
||||||
withdraw_ext.include_router(withdraw_ext_api)
|
|
||||||
withdraw_ext.include_router(withdraw_ext_lnurl)
|
|
||||||
|
|
||||||
|
|
||||||
def withdraw_start() -> None:
|
def withdraw_renderer():
|
||||||
"""
|
return template_renderer(["withdraw/templates"])
|
||||||
Register this extension's RPCs with the LNbits nostr transport so an
|
|
||||||
HTTP-allergic client (e.g. lamassu-next ATM) can manage LNURL-withdraw
|
|
||||||
links without touching the HTTP API. Also wires the link-owner
|
|
||||||
resolver so subscribe_payments({tag:"withdraw", link_id:...}) can
|
|
||||||
verify ownership.
|
|
||||||
|
|
||||||
No-op if the core transport module isn't present in the LNbits build.
|
|
||||||
No runtime `if nostr_transport_enabled` guard is needed — when
|
|
||||||
disabled, the relay pool never publishes, so registered RPCs are
|
|
||||||
simply unreachable.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from lnbits.core.services.nostr_transport.dispatcher import (
|
|
||||||
AUTH_ACCOUNT,
|
|
||||||
AUTH_WALLET,
|
|
||||||
register_rpc,
|
|
||||||
)
|
|
||||||
from lnbits.core.services.nostr_transport.subscriptions import (
|
|
||||||
register_link_owner_resolver,
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
return
|
|
||||||
|
|
||||||
from .transport_rpcs import (
|
|
||||||
handle_lnurlw_create_link,
|
|
||||||
handle_lnurlw_delete_link,
|
|
||||||
handle_lnurlw_get_link,
|
|
||||||
handle_lnurlw_list_links,
|
|
||||||
handle_lnurlw_unique_hashes,
|
|
||||||
handle_lnurlw_update_link,
|
|
||||||
resolve_withdraw_owner,
|
|
||||||
)
|
|
||||||
|
|
||||||
register_rpc("lnurlw_create_link", handle_lnurlw_create_link, AUTH_WALLET)
|
|
||||||
register_rpc("lnurlw_get_link", handle_lnurlw_get_link, AUTH_WALLET)
|
|
||||||
register_rpc("lnurlw_list_links", handle_lnurlw_list_links, AUTH_ACCOUNT)
|
|
||||||
register_rpc("lnurlw_unique_hashes", handle_lnurlw_unique_hashes, AUTH_WALLET)
|
|
||||||
register_rpc("lnurlw_update_link", handle_lnurlw_update_link, AUTH_WALLET)
|
|
||||||
register_rpc("lnurlw_delete_link", handle_lnurlw_delete_link, AUTH_WALLET)
|
|
||||||
register_link_owner_resolver(
|
|
||||||
"withdraw", resolve_withdraw_owner, link_extra_key="withdrawal_link_id"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["db", "withdraw_ext", "withdraw_start", "withdraw_static_files"]
|
from .lnurl import * # noqa: F401,F403
|
||||||
|
from .views import * # noqa: F401,F403
|
||||||
|
from .views_api import * # noqa: F401,F403
|
||||||
|
|
|
||||||
79
config.json
79
config.json
|
|
@ -1,80 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "Withdraw Links",
|
"name": "LNURLw",
|
||||||
"short_description": "Make LNURL withdraw links",
|
"short_description": "Make LNURL withdraw links",
|
||||||
"tile": "/withdraw/static/image/lnurl-withdraw.png",
|
"tile": "/withdraw/static/image/lnurl-withdraw.png",
|
||||||
"version": "1.2.2-aio.2",
|
"contributors": ["arcbtc", "eillarra"],
|
||||||
"min_lnbits_version": "1.3.0",
|
"min_lnbits_version": "0.11.0"
|
||||||
"contributors": [
|
|
||||||
{
|
|
||||||
"name": "arcbtc",
|
|
||||||
"uri": "https://github.com/arcbtc",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "talvasconcelos",
|
|
||||||
"uri": "https://github.com/talvasconcelos",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "eillarra",
|
|
||||||
"uri": "https://github.com/eillarra",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "dni",
|
|
||||||
"uri": "https://github.com/dni",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "motorina0",
|
|
||||||
"uri": "https://github.com/motorina0",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "prusnak",
|
|
||||||
"uri": "https://github.com/prusnak",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "callebtc",
|
|
||||||
"uri": "https://github.com/callebtc",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Liongrass",
|
|
||||||
"uri": "https://github.com/Liongrass",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "supiiik",
|
|
||||||
"uri": "https://github.com/supiiik",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Jakub-Dv",
|
|
||||||
"uri": "https://github.com/Jakub-Dv",
|
|
||||||
"role": "Developer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/1.jpg",
|
|
||||||
"link": "https://www.youtube.com/embed/TUmsHpJtveQ?si=3_l1cg0JC8CXHtYf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/1.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/2.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/3.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/4.png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description_md": "https://raw.githubusercontent.com/lnbits/withdraw/main/description.md",
|
|
||||||
"terms_and_conditions_md": "https://raw.githubusercontent.com/withdraw/lnurldevice/main/toc.md",
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
223
crud.py
223
crud.py
|
|
@ -1,12 +1,12 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
import shortuuid
|
import shortuuid
|
||||||
from lnbits.db import Database
|
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
|
from . import db
|
||||||
|
from .models import CreateWithdrawData, HashCheck, WithdrawLink
|
||||||
db = Database("ext_withdraw")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_withdraw_link(
|
async def create_withdraw_link(
|
||||||
|
|
@ -14,87 +14,87 @@ async def create_withdraw_link(
|
||||||
) -> WithdrawLink:
|
) -> WithdrawLink:
|
||||||
link_id = urlsafe_short_hash()[:22]
|
link_id = urlsafe_short_hash()[:22]
|
||||||
available_links = ",".join([str(i) for i in range(data.uses)])
|
available_links = ",".join([str(i) for i in range(data.uses)])
|
||||||
withdraw_link = WithdrawLink(
|
await db.execute(
|
||||||
id=link_id,
|
"""
|
||||||
wallet=wallet_id,
|
INSERT INTO withdraw.withdraw_link (
|
||||||
unique_hash=urlsafe_short_hash(),
|
id,
|
||||||
k1=urlsafe_short_hash(),
|
wallet,
|
||||||
created_at=datetime.now(),
|
title,
|
||||||
open_time=int(datetime.now().timestamp()) + data.wait_time,
|
min_withdrawable,
|
||||||
title=data.title,
|
max_withdrawable,
|
||||||
min_withdrawable=data.min_withdrawable,
|
uses,
|
||||||
max_withdrawable=data.max_withdrawable,
|
wait_time,
|
||||||
uses=data.uses,
|
is_unique,
|
||||||
wait_time=data.wait_time,
|
unique_hash,
|
||||||
is_unique=data.is_unique,
|
k1,
|
||||||
usescsv=available_links,
|
open_time,
|
||||||
webhook_url=data.webhook_url,
|
usescsv,
|
||||||
webhook_headers=data.webhook_headers,
|
webhook_url,
|
||||||
webhook_body=data.webhook_body,
|
webhook_headers,
|
||||||
custom_url=data.custom_url,
|
webhook_body,
|
||||||
extra=data.extra,
|
custom_url
|
||||||
number=0,
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
link_id,
|
||||||
|
wallet_id,
|
||||||
|
data.title,
|
||||||
|
data.min_withdrawable,
|
||||||
|
data.max_withdrawable,
|
||||||
|
data.uses,
|
||||||
|
data.wait_time,
|
||||||
|
int(data.is_unique),
|
||||||
|
urlsafe_short_hash(),
|
||||||
|
urlsafe_short_hash(),
|
||||||
|
int(datetime.now().timestamp()) + data.wait_time,
|
||||||
|
available_links,
|
||||||
|
data.webhook_url,
|
||||||
|
data.webhook_headers,
|
||||||
|
data.webhook_body,
|
||||||
|
data.custom_url,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await db.insert("withdraw.withdraw_link", withdraw_link)
|
link = await get_withdraw_link(link_id, 0)
|
||||||
return withdraw_link
|
assert link, "Newly created link couldn't be retrieved"
|
||||||
|
|
||||||
|
|
||||||
async def get_withdraw_link(link_id: str, num=0) -> WithdrawLink | None:
|
|
||||||
link = await db.fetchone(
|
|
||||||
"SELECT * FROM withdraw.withdraw_link WHERE id = :id",
|
|
||||||
{"id": link_id},
|
|
||||||
WithdrawLink,
|
|
||||||
)
|
|
||||||
if not link:
|
|
||||||
return None
|
|
||||||
|
|
||||||
link.number = num
|
|
||||||
return link
|
return link
|
||||||
|
|
||||||
|
|
||||||
async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> WithdrawLink | None:
|
async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]:
|
||||||
link = await db.fetchone(
|
row = await db.fetchone(
|
||||||
"SELECT * FROM withdraw.withdraw_link WHERE unique_hash = :hash",
|
"SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,)
|
||||||
{"hash": unique_hash},
|
|
||||||
WithdrawLink,
|
|
||||||
)
|
)
|
||||||
if not link:
|
if not row:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
link.number = num
|
link = dict(**row)
|
||||||
return link
|
link["number"] = num
|
||||||
|
|
||||||
|
return WithdrawLink.parse_obj(link)
|
||||||
|
|
||||||
|
|
||||||
async def get_withdraw_links(
|
async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]:
|
||||||
wallet_ids: list[str], limit: int, offset: int
|
row = await db.fetchone(
|
||||||
) -> PaginatedWithdraws:
|
"SELECT * FROM withdraw.withdraw_link WHERE unique_hash = ?", (unique_hash,)
|
||||||
q = ",".join([f"'{w}'" for w in wallet_ids])
|
|
||||||
|
|
||||||
query_str = f"""
|
|
||||||
SELECT * FROM withdraw.withdraw_link WHERE wallet IN ({q})
|
|
||||||
ORDER BY open_time DESC
|
|
||||||
"""
|
|
||||||
|
|
||||||
if limit > 0:
|
|
||||||
query_str += """ LIMIT :limit OFFSET :offset"""
|
|
||||||
query_params = {"limit": limit, "offset": offset}
|
|
||||||
else:
|
|
||||||
query_params = {}
|
|
||||||
|
|
||||||
links = await db.fetchall(
|
|
||||||
query_str,
|
|
||||||
query_params,
|
|
||||||
WithdrawLink,
|
|
||||||
)
|
)
|
||||||
result = await db.execute(
|
if not row:
|
||||||
f"""
|
return None
|
||||||
SELECT COUNT(*) as total FROM withdraw.withdraw_link
|
|
||||||
WHERE wallet IN ({q})
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
result2 = result.mappings().first()
|
|
||||||
|
|
||||||
return PaginatedWithdraws(data=links, total=int(result2.total))
|
link = dict(**row)
|
||||||
|
link["number"] = num
|
||||||
|
|
||||||
|
return WithdrawLink.parse_obj(link)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[WithdrawLink]:
|
||||||
|
if isinstance(wallet_ids, str):
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
|
q = ",".join(["?"] * len(wallet_ids))
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"SELECT * FROM withdraw.withdraw_link WHERE wallet IN ({q}) ORDER BY open_time DESC", (*wallet_ids,)
|
||||||
|
)
|
||||||
|
return [WithdrawLink(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None:
|
async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None:
|
||||||
|
|
@ -103,25 +103,35 @@ async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> N
|
||||||
for x in link.usescsv.split(",")
|
for x in link.usescsv.split(",")
|
||||||
if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
|
if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
|
||||||
]
|
]
|
||||||
link.usescsv = ",".join(unique_links)
|
await update_withdraw_link(
|
||||||
await update_withdraw_link(link)
|
link.id,
|
||||||
|
usescsv=",".join(unique_links),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def increment_withdraw_link(link: WithdrawLink) -> None:
|
async def increment_withdraw_link(link: WithdrawLink) -> None:
|
||||||
link.used = link.used + 1
|
await update_withdraw_link(
|
||||||
link.open_time = int(datetime.now().timestamp())
|
link.id,
|
||||||
await update_withdraw_link(link)
|
used=link.used + 1,
|
||||||
|
open_time=link.wait_time + int(datetime.now().timestamp()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]:
|
||||||
async def update_withdraw_link(link: WithdrawLink) -> WithdrawLink:
|
if "is_unique" in kwargs:
|
||||||
await db.update("withdraw.withdraw_link", link)
|
kwargs["is_unique"] = int(kwargs["is_unique"])
|
||||||
return link
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE withdraw.withdraw_link SET {q} WHERE id = ?",
|
||||||
|
(*kwargs.values(), link_id),
|
||||||
|
)
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,)
|
||||||
|
)
|
||||||
|
return WithdrawLink(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def delete_withdraw_link(link_id: str) -> None:
|
async def delete_withdraw_link(link_id: str) -> None:
|
||||||
await db.execute(
|
await db.execute("DELETE FROM withdraw.withdraw_link WHERE id = ?", (link_id,))
|
||||||
"DELETE FROM withdraw.withdraw_link WHERE id = :id", {"id": link_id}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def chunks(lst, n):
|
def chunks(lst, n):
|
||||||
|
|
@ -132,45 +142,34 @@ def chunks(lst, n):
|
||||||
async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
|
async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO withdraw.hash_check (id, lnurl_id)
|
INSERT INTO withdraw.hash_check (
|
||||||
VALUES (:id, :lnurl_id)
|
id,
|
||||||
|
lnurl_id
|
||||||
|
)
|
||||||
|
VALUES (?, ?)
|
||||||
""",
|
""",
|
||||||
{"id": the_hash, "lnurl_id": lnurl_id},
|
(the_hash, lnurl_id),
|
||||||
)
|
)
|
||||||
hash_check = await get_hash_check(the_hash, lnurl_id)
|
hashCheck = await get_hash_check(the_hash, lnurl_id)
|
||||||
return hash_check
|
return hashCheck
|
||||||
|
|
||||||
|
|
||||||
async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
|
async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
|
||||||
|
rowid = await db.fetchone(
|
||||||
hash_check = await db.fetchone(
|
"SELECT * FROM withdraw.hash_check WHERE id = ?", (the_hash,)
|
||||||
"""
|
|
||||||
SELECT id as hash, lnurl_id as lnurl
|
|
||||||
FROM withdraw.hash_check WHERE id = :id
|
|
||||||
""",
|
|
||||||
{"id": the_hash},
|
|
||||||
HashCheck,
|
|
||||||
)
|
)
|
||||||
hash_check_lnurl = await db.fetchone(
|
rowlnurl = await db.fetchone(
|
||||||
"""
|
"SELECT * FROM withdraw.hash_check WHERE lnurl_id = ?", (lnurl_id,)
|
||||||
SELECT id as hash, lnurl_id as lnurl
|
|
||||||
FROM withdraw.hash_check WHERE lnurl_id = :id
|
|
||||||
""",
|
|
||||||
{"id": lnurl_id},
|
|
||||||
HashCheck,
|
|
||||||
)
|
)
|
||||||
if not hash_check_lnurl:
|
if not rowlnurl:
|
||||||
await create_hash_check(the_hash, lnurl_id)
|
await create_hash_check(the_hash, lnurl_id)
|
||||||
return HashCheck(lnurl=True, hash=False)
|
return HashCheck(lnurl=True, hash=False)
|
||||||
else:
|
else:
|
||||||
if not hash_check:
|
if not rowid:
|
||||||
await create_hash_check(the_hash, lnurl_id)
|
await create_hash_check(the_hash, lnurl_id)
|
||||||
return HashCheck(lnurl=True, hash=False)
|
return HashCheck(lnurl=True, hash=False)
|
||||||
else:
|
else:
|
||||||
return HashCheck(lnurl=True, hash=True)
|
return HashCheck(lnurl=True, hash=True)
|
||||||
|
|
||||||
|
|
||||||
async def delete_hash_check(the_hash: str) -> None:
|
async def delete_hash_check(the_hash: str) -> None:
|
||||||
await db.execute(
|
await db.execute("DELETE FROM withdraw.hash_check WHERE id = ?", (the_hash,))
|
||||||
"DELETE FROM withdraw.hash_check WHERE id = :hash", {"hash": the_hash}
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
Create a static QR code people can use to withdraw funds from a Lightning Network wallet
|
|
||||||
|
|
||||||
LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL withdraw is the permission for someone to pull a certain amount of funds from a lightning wallet.
|
|
||||||
|
|
||||||
The most common use case for an LNURL withdraw is a faucet, although it is a very powerful technology, with much further reaching implications. For example, an LNURL withdraw could be minted to pay for a subscription service. Or you can have a LNURLw as an offline Lightning wallet (a pre paid "card"), you use to pay for something without having to even reach your smartphone.
|
|
||||||
|
|
||||||
LNURL withdraw is a very powerful tool and should not have his use limited to just faucet applications. With LNURL withdraw, you have the ability to give someone the right to spend a range, once or multiple times. This functionality has not existed in money before.
|
|
||||||
54
helpers.py
54
helpers.py
|
|
@ -1,54 +0,0 @@
|
||||||
from fastapi import Request
|
|
||||||
from lnbits.settings import settings
|
|
||||||
from lnurl import Lnurl
|
|
||||||
from lnurl import encode as lnurl_encode
|
|
||||||
from shortuuid import uuid
|
|
||||||
|
|
||||||
from .models import WithdrawLink
|
|
||||||
|
|
||||||
|
|
||||||
def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
|
|
||||||
if link.is_unique:
|
|
||||||
usescssv = link.usescsv.split(",")
|
|
||||||
tohash = link.id + link.unique_hash + usescssv[link.number]
|
|
||||||
multihash = uuid(name=tohash)
|
|
||||||
url = req.url_for(
|
|
||||||
"withdraw.api_lnurl_multi_response",
|
|
||||||
unique_hash=link.unique_hash,
|
|
||||||
id_unique_hash=multihash,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return lnurl_encode(str(url))
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"Error creating LNURL with url: `{url!s}`, "
|
|
||||||
"check your webserver proxy configuration."
|
|
||||||
) from e
|
|
||||||
|
|
||||||
|
|
||||||
def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl:
|
|
||||||
"""
|
|
||||||
Same shape as `create_lnurl`, but composes the callback URL from
|
|
||||||
`settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by
|
|
||||||
the nostr-transport RPC handlers, which have no HTTP request to
|
|
||||||
derive a base URL from.
|
|
||||||
"""
|
|
||||||
base = settings.lnbits_baseurl.rstrip("/")
|
|
||||||
if link.is_unique:
|
|
||||||
usescssv = link.usescsv.split(",")
|
|
||||||
tohash = link.id + link.unique_hash + usescssv[link.number]
|
|
||||||
multihash = uuid(name=tohash)
|
|
||||||
url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}/{multihash}"
|
|
||||||
else:
|
|
||||||
url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
return lnurl_encode(url)
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"Error creating LNURL with url: `{url!s}`, "
|
|
||||||
"check your `LNBITS_BASEURL` configuration."
|
|
||||||
) from e
|
|
||||||
234
lnurl.py
Normal file
234
lnurl.py
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from http import HTTPStatus
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import shortuuid
|
||||||
|
from fastapi import HTTPException, Query, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.crud import update_payment_extra
|
||||||
|
from lnbits.core.services import pay_invoice
|
||||||
|
|
||||||
|
from . import withdraw_ext
|
||||||
|
from .crud import (
|
||||||
|
get_withdraw_link_by_hash,
|
||||||
|
increment_withdraw_link,
|
||||||
|
remove_unique_withdraw_link,
|
||||||
|
delete_hash_check,
|
||||||
|
create_hash_check
|
||||||
|
)
|
||||||
|
from .models import WithdrawLink
|
||||||
|
|
||||||
|
|
||||||
|
@withdraw_ext.get(
|
||||||
|
"/api/v1/lnurl/{unique_hash}",
|
||||||
|
response_class=JSONResponse,
|
||||||
|
name="withdraw.api_lnurl_response",
|
||||||
|
)
|
||||||
|
async def api_lnurl_response(request: Request, unique_hash: str):
|
||||||
|
link = await get_withdraw_link_by_hash(unique_hash)
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
if link.is_spent:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
||||||
|
)
|
||||||
|
url = str(request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash))
|
||||||
|
|
||||||
|
# Check if url is .onion and change to http
|
||||||
|
if urlparse(url).netloc.endswith(".onion"):
|
||||||
|
# change url string scheme to http
|
||||||
|
url = url.replace("https://", "http://")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tag": "withdrawRequest",
|
||||||
|
"callback": url,
|
||||||
|
"k1": link.k1,
|
||||||
|
"minWithdrawable": link.min_withdrawable * 1000,
|
||||||
|
"maxWithdrawable": link.max_withdrawable * 1000,
|
||||||
|
"defaultDescription": link.title,
|
||||||
|
"webhook_url": link.webhook_url,
|
||||||
|
"webhook_headers": link.webhook_headers,
|
||||||
|
"webhook_body": link.webhook_body,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@withdraw_ext.get(
|
||||||
|
"/api/v1/lnurl/cb/{unique_hash}",
|
||||||
|
name="withdraw.api_lnurl_callback",
|
||||||
|
summary="lnurl withdraw callback",
|
||||||
|
description="""
|
||||||
|
This endpoints allows you to put unique_hash, k1
|
||||||
|
and a payment_request to get your payment_request paid.
|
||||||
|
""",
|
||||||
|
response_class=JSONResponse,
|
||||||
|
response_description="JSON with status",
|
||||||
|
responses={
|
||||||
|
200: {"description": "status: OK"},
|
||||||
|
400: {"description": "k1 is wrong or link open time or withdraw not working."},
|
||||||
|
404: {"description": "withdraw link not found."},
|
||||||
|
405: {"description": "withdraw link is spent."},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def api_lnurl_callback(
|
||||||
|
unique_hash,
|
||||||
|
k1: str = Query(...),
|
||||||
|
pr: str = Query(...),
|
||||||
|
id_unique_hash=None,
|
||||||
|
):
|
||||||
|
# Create a record with the id_unique_hash or unique_hash, if it already exists, raise an exception thus preventing the same LNURL from being processed twice.
|
||||||
|
try:
|
||||||
|
if id_unique_hash:
|
||||||
|
await create_hash_check(id_unique_hash, k1)
|
||||||
|
else:
|
||||||
|
await create_hash_check(unique_hash, k1)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail="LNURL already being processed."
|
||||||
|
)
|
||||||
|
|
||||||
|
link = await get_withdraw_link_by_hash(unique_hash)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
if link.is_spent:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="withdraw is spent."
|
||||||
|
)
|
||||||
|
|
||||||
|
if link.k1 != k1:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="k1 is wrong.")
|
||||||
|
|
||||||
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
if now < link.open_time:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"wait link open_time {link.open_time - now} seconds.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if id_unique_hash:
|
||||||
|
if check_unique_link(link, id_unique_hash):
|
||||||
|
await remove_unique_withdraw_link(link, id_unique_hash)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_hash = await pay_invoice(
|
||||||
|
wallet_id=link.wallet,
|
||||||
|
payment_request=pr,
|
||||||
|
max_sat=link.max_withdrawable,
|
||||||
|
extra={"tag": "withdraw", "withdrawal_link_id": link.id},
|
||||||
|
)
|
||||||
|
await increment_withdraw_link(link)
|
||||||
|
# If the payment succeeds, delete the record with the unique_hash. If it has unique_hash, do not delete to prevent the same LNURL from being processed twice.
|
||||||
|
if not id_unique_hash:
|
||||||
|
await delete_hash_check(unique_hash)
|
||||||
|
if link.webhook_url:
|
||||||
|
await dispatch_webhook(link, payment_hash, pr)
|
||||||
|
return {"status": "OK"}
|
||||||
|
except Exception as e:
|
||||||
|
# If payment fails, delete the hash stored so another attempt can be made.
|
||||||
|
if id_unique_hash:
|
||||||
|
await delete_hash_check(id_unique_hash)
|
||||||
|
else:
|
||||||
|
await delete_hash_check(unique_hash)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail=f"withdraw not working. {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
|
||||||
|
return any(
|
||||||
|
unique_hash == shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
|
||||||
|
for x in link.usescsv.split(",")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_webhook(
|
||||||
|
link: WithdrawLink, payment_hash: str, payment_request: str
|
||||||
|
) -> None:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
r: httpx.Response = await client.post(
|
||||||
|
link.webhook_url,
|
||||||
|
json={
|
||||||
|
"payment_hash": payment_hash,
|
||||||
|
"payment_request": payment_request,
|
||||||
|
"lnurlw": link.id,
|
||||||
|
"body": json.loads(link.webhook_body) if link.webhook_body else "",
|
||||||
|
},
|
||||||
|
headers=json.loads(link.webhook_headers)
|
||||||
|
if link.webhook_headers
|
||||||
|
else None,
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
await update_payment_extra(
|
||||||
|
payment_hash=payment_hash,
|
||||||
|
extra={
|
||||||
|
"wh_success": r.is_success,
|
||||||
|
"wh_message": r.reason_phrase,
|
||||||
|
"wh_response": r.text,
|
||||||
|
},
|
||||||
|
outgoing=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# webhook fails shouldn't cause the lnurlw to fail since invoice is already paid
|
||||||
|
logger.error(f"Caught exception when dispatching webhook url: {str(exc)}")
|
||||||
|
await update_payment_extra(
|
||||||
|
payment_hash=payment_hash,
|
||||||
|
extra={"wh_success": False, "wh_message": str(exc)},
|
||||||
|
outgoing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# FOR LNURLs WHICH ARE UNIQUE
|
||||||
|
@withdraw_ext.get(
|
||||||
|
"/api/v1/lnurl/{unique_hash}/{id_unique_hash}",
|
||||||
|
response_class=JSONResponse,
|
||||||
|
name="withdraw.api_lnurl_multi_response",
|
||||||
|
)
|
||||||
|
async def api_lnurl_multi_response(request: Request, unique_hash: str, id_unique_hash: str):
|
||||||
|
link = await get_withdraw_link_by_hash(unique_hash)
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
if link.is_spent:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not check_unique_link(link, id_unique_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
url = str(request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash))
|
||||||
|
|
||||||
|
# Check if url is .onion and change to http
|
||||||
|
if urlparse(url).netloc.endswith(".onion"):
|
||||||
|
# change url string scheme to http
|
||||||
|
url = url.replace("https://", "http://")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tag": "withdrawRequest",
|
||||||
|
"callback": f"{url}?id_unique_hash={id_unique_hash}",
|
||||||
|
"k1": link.k1,
|
||||||
|
"minWithdrawable": link.min_withdrawable * 1000,
|
||||||
|
"maxWithdrawable": link.max_withdrawable * 1000,
|
||||||
|
"defaultDescription": link.title,
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"repos": [
|
"repos": [
|
||||||
{
|
{
|
||||||
"id": "withdraw",
|
"id": "withdraw",
|
||||||
"organisation": "lnbits",
|
"organisation": "lnbits",
|
||||||
"repository": "withdraw"
|
"repository": "withdraw"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,16 +132,3 @@ async def m006_webhook_headers_and_body(db):
|
||||||
"ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_headers TEXT;"
|
"ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_headers TEXT;"
|
||||||
)
|
)
|
||||||
await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_body TEXT;")
|
await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_body TEXT;")
|
||||||
|
|
||||||
|
|
||||||
async def m007_add_created_at_timestamp(db):
|
|
||||||
await db.execute(
|
|
||||||
"ALTER TABLE withdraw.withdraw_link "
|
|
||||||
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def m008_add_enabled_column(db):
|
|
||||||
await db.execute(
|
|
||||||
"ALTER TABLE withdraw.withdraw_link ADD COLUMN enabled BOOLEAN DEFAULT true;"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
"""
|
|
||||||
Fork-specific database migrations for the aiolabs withdraw extension.
|
|
||||||
|
|
||||||
These migrations are tracked separately under `withdraw_fork` in the
|
|
||||||
`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`),
|
|
||||||
so they do not collide with upstream's `m{NNN}_*` numbering in
|
|
||||||
`migrations.py`. Keeping the upstream-tracked file untouched means
|
|
||||||
`git pull upstream` stays rebase-clean for schema changes.
|
|
||||||
|
|
||||||
Conventions:
|
|
||||||
- Sequential numbering starting from m001.
|
|
||||||
- Each migration is `async def m{NNN}_<description>(db)`.
|
|
||||||
- DDL must be idempotent: a fresh install runs every migration; an
|
|
||||||
install that already carries the column must not crash. Use
|
|
||||||
`_alter_add_column_safe` so re-runs are no-ops.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
async def _alter_add_column_safe(db, sql: str) -> None:
|
|
||||||
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors, so a
|
|
||||||
re-run on a DB that already has the column is a silent no-op."""
|
|
||||||
try:
|
|
||||||
await db.execute(sql)
|
|
||||||
except Exception as exc:
|
|
||||||
msg = str(exc).lower()
|
|
||||||
if "duplicate column" in msg or "already exists" in msg:
|
|
||||||
return
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def m001_aio_withdraw_schema(db):
|
|
||||||
"""
|
|
||||||
Apply every aiolabs schema delta on top of upstream withdraw.
|
|
||||||
|
|
||||||
`withdraw_link.extra` — arbitrary JSON merged into the payout payment's
|
|
||||||
`extra` when the link is claimed (see views_lnurl). Lets a caller tag the
|
|
||||||
resulting payment with settlement/attribution metadata an external listener
|
|
||||||
can key on — e.g. bitSpire stamps {source, type, principal_sats, fee_sats,
|
|
||||||
...} so the spirekeeper cash-in settlement fires off an LNURL-withdraw
|
|
||||||
payout. Stored as TEXT; (de)serialized to a dict by the WithdrawLink model.
|
|
||||||
"""
|
|
||||||
await _alter_add_column_safe(
|
|
||||||
db, "ALTER TABLE withdraw.withdraw_link ADD COLUMN extra TEXT"
|
|
||||||
)
|
|
||||||
73
models.py
73
models.py
|
|
@ -1,7 +1,10 @@
|
||||||
from datetime import datetime
|
import shortuuid
|
||||||
|
|
||||||
from fastapi import Query
|
from fastapi import Query
|
||||||
from pydantic import BaseModel, Field
|
from lnurl import Lnurl, LnurlWithdrawResponse
|
||||||
|
from lnurl import encode as lnurl_encode
|
||||||
|
from lnurl.models import ClearnetUrl, MilliSatoshi
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
||||||
class CreateWithdrawData(BaseModel):
|
class CreateWithdrawData(BaseModel):
|
||||||
|
|
@ -15,13 +18,6 @@ class CreateWithdrawData(BaseModel):
|
||||||
webhook_headers: str = Query(None)
|
webhook_headers: str = Query(None)
|
||||||
webhook_body: str = Query(None)
|
webhook_body: str = Query(None)
|
||||||
custom_url: str = Query(None)
|
custom_url: str = Query(None)
|
||||||
enabled: bool = Query(True)
|
|
||||||
# Arbitrary JSON merged into the payout payment's `extra` when this link is
|
|
||||||
# claimed (see views_lnurl). Lets a caller tag the resulting payment with
|
|
||||||
# settlement/attribution metadata an external listener can key on — e.g.
|
|
||||||
# bitSpire stamps {source, type, principal_sats, fee_sats, ...} so the
|
|
||||||
# spirekeeper cash-in settlement fires off an LNURL-withdraw payout.
|
|
||||||
extra: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WithdrawLink(BaseModel):
|
class WithdrawLink(BaseModel):
|
||||||
|
|
@ -38,43 +34,46 @@ class WithdrawLink(BaseModel):
|
||||||
open_time: int = Query(0)
|
open_time: int = Query(0)
|
||||||
used: int = Query(0)
|
used: int = Query(0)
|
||||||
usescsv: str = Query(None)
|
usescsv: str = Query(None)
|
||||||
number: int = Field(default=0, no_database=True)
|
number: int = Query(0)
|
||||||
webhook_url: str = Query(None)
|
webhook_url: str = Query(None)
|
||||||
webhook_headers: str = Query(None)
|
webhook_headers: str = Query(None)
|
||||||
webhook_body: str = Query(None)
|
webhook_body: str = Query(None)
|
||||||
custom_url: str = Query(None)
|
custom_url: str = Query(None)
|
||||||
# Persisted as TEXT (JSON); merged into the payout payment's `extra` on
|
|
||||||
# claim. LNbits' db layer (de)serializes dict-typed columns to/from JSON
|
|
||||||
# natively (same as Payment.extra) — no per-field validator needed.
|
|
||||||
extra: dict | None = None
|
|
||||||
created_at: datetime
|
|
||||||
enabled: bool = Query(True)
|
|
||||||
lnurl: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
no_database=True,
|
|
||||||
deprecated=True,
|
|
||||||
description=(
|
|
||||||
"Deprecated: Instead of using this bech32 encoded string, dynamically "
|
|
||||||
"generate your own static link (lud17/bech32) on the client side. "
|
|
||||||
"Example: lnurlw://${window.location.hostname}/lnurlw/${id}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
lnurl_url: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
no_database=True,
|
|
||||||
description="The raw LNURL callback URL (use for QR code generation)",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_spent(self) -> bool:
|
def is_spent(self) -> bool:
|
||||||
return self.used >= self.uses
|
return self.used >= self.uses
|
||||||
|
|
||||||
|
def lnurl(self, req: Request) -> Lnurl:
|
||||||
|
if self.is_unique:
|
||||||
|
usescssv = self.usescsv.split(",")
|
||||||
|
tohash = self.id + self.unique_hash + usescssv[self.number]
|
||||||
|
multihash = shortuuid.uuid(name=tohash)
|
||||||
|
url = str(req.url_for(
|
||||||
|
"withdraw.api_lnurl_multi_response",
|
||||||
|
unique_hash=self.unique_hash,
|
||||||
|
id_unique_hash=multihash,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
url = str(req.url_for(
|
||||||
|
"withdraw.api_lnurl_response", unique_hash=self.unique_hash
|
||||||
|
))
|
||||||
|
|
||||||
|
return lnurl_encode(url)
|
||||||
|
|
||||||
|
def lnurl_response(self, req: Request) -> LnurlWithdrawResponse:
|
||||||
|
url = str(req.url_for(
|
||||||
|
name="withdraw.api_lnurl_callback", unique_hash=self.unique_hash
|
||||||
|
))
|
||||||
|
return LnurlWithdrawResponse(
|
||||||
|
callback=ClearnetUrl(url, scheme="https"),
|
||||||
|
k1=self.k1,
|
||||||
|
minWithdrawable=MilliSatoshi(self.min_withdrawable * 1000),
|
||||||
|
maxWithdrawable=MilliSatoshi(self.max_withdrawable * 1000),
|
||||||
|
defaultDescription=self.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HashCheck(BaseModel):
|
class HashCheck(BaseModel):
|
||||||
hash: bool
|
hash: bool
|
||||||
lnurl: bool
|
lnurl: bool
|
||||||
|
|
||||||
|
|
||||||
class PaginatedWithdraws(BaseModel):
|
|
||||||
data: list[WithdrawLink]
|
|
||||||
total: int
|
|
||||||
|
|
|
||||||
59
package-lock.json
generated
59
package-lock.json
generated
|
|
@ -1,59 +0,0 @@
|
||||||
{
|
|
||||||
"name": "withdraw",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "withdraw",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"prettier": "^3.2.5",
|
|
||||||
"pyright": "^1.1.358"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prettier": {
|
|
||||||
"version": "3.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
|
|
||||||
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
|
|
||||||
"bin": {
|
|
||||||
"prettier": "bin/prettier.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pyright": {
|
|
||||||
"version": "1.1.359",
|
|
||||||
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.359.tgz",
|
|
||||||
"integrity": "sha512-rtdQDlVfZy10MUDuTlY75wKaQt4hbd/kSAKHIJqaStZs4UPQMVrhpZBEDf1NQGAiSGCuKQn0qVpNNuGUEicqlQ==",
|
|
||||||
"bin": {
|
|
||||||
"pyright": "index.js",
|
|
||||||
"pyright-langserver": "langserver.index.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
package.json
15
package.json
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"name": "withdraw",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"prettier": "^3.2.5",
|
|
||||||
"pyright": "^1.1.358"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
[project]
|
|
||||||
name = "lnbits-withdraw"
|
|
||||||
version = "0.0.0"
|
|
||||||
requires-python = ">=3.10,<3.13"
|
|
||||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
|
||||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
|
||||||
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/bitcoinswitch_extension" }
|
|
||||||
dependencies = [ "lnbits>1" ]
|
|
||||||
|
|
||||||
[tool.poetry]
|
|
||||||
package-mode = false
|
|
||||||
|
|
||||||
[tool.uv]
|
|
||||||
dev-dependencies = [
|
|
||||||
"black",
|
|
||||||
"pytest-asyncio",
|
|
||||||
"pytest",
|
|
||||||
"mypy",
|
|
||||||
"pre-commit",
|
|
||||||
"ruff",
|
|
||||||
"pytest-md",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
plugins = ["pydantic.mypy"]
|
|
||||||
|
|
||||||
[tool.pydantic-mypy]
|
|
||||||
init_forbid_extra = true
|
|
||||||
init_typed = true
|
|
||||||
warn_required_dynamic_aliases = true
|
|
||||||
warn_untyped_fields = true
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
log_cli = false
|
|
||||||
testpaths = [
|
|
||||||
"tests"
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
line-length = 88
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
# Same as Black. + 10% rule of black
|
|
||||||
line-length = 88
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
# Enable:
|
|
||||||
# F - pyflakes
|
|
||||||
# E - pycodestyle errors
|
|
||||||
# W - pycodestyle warnings
|
|
||||||
# I - isort
|
|
||||||
# A - flake8-builtins
|
|
||||||
# C - mccabe
|
|
||||||
# N - naming
|
|
||||||
# UP - pyupgrade
|
|
||||||
# RUF - ruff
|
|
||||||
# B - bugbear
|
|
||||||
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
|
|
||||||
# UP007: pyupgrade: use X | Y instead of Optional. (python3.10)
|
|
||||||
# C901 `api_link_create_or_update` is too complex (15 > 10)
|
|
||||||
ignore = ["UP007", "C901"]
|
|
||||||
|
|
||||||
# Allow autofix for all enabled rules (when `--fix`) is provided.
|
|
||||||
fixable = ["ALL"]
|
|
||||||
unfixable = []
|
|
||||||
|
|
||||||
# Allow unused variables when underscore-prefixed.
|
|
||||||
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
||||||
|
|
||||||
# needed for pydantic
|
|
||||||
[tool.ruff.lint.pep8-naming]
|
|
||||||
classmethod-decorators = [
|
|
||||||
"root_validator",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Ignore unused imports in __init__.py files.
|
|
||||||
# [tool.ruff.lint.extend-per-file-ignores]
|
|
||||||
# "__init__.py" = ["F401", "F403"]
|
|
||||||
|
|
||||||
# [tool.ruff.lint.mccabe]
|
|
||||||
# max-complexity = 10
|
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-bugbear]
|
|
||||||
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
|
|
||||||
extend-immutable-calls = [
|
|
||||||
"fastapi.Depends",
|
|
||||||
"fastapi.Query",
|
|
||||||
]
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 304 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 155 KiB |
|
|
@ -1,33 +1,42 @@
|
||||||
const mapWithdrawLink = function (obj) {
|
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */
|
||||||
|
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
var locationPath = [
|
||||||
|
window.location.protocol,
|
||||||
|
'//',
|
||||||
|
window.location.host,
|
||||||
|
window.location.pathname
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
var mapWithdrawLink = function (obj) {
|
||||||
obj._data = _.clone(obj)
|
obj._data = _.clone(obj)
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
obj.min_fsat = new Intl.NumberFormat(LOCALE).format(obj.min_withdrawable)
|
||||||
|
obj.max_fsat = new Intl.NumberFormat(LOCALE).format(obj.max_withdrawable)
|
||||||
obj.uses_left = obj.uses - obj.used
|
obj.uses_left = obj.uses - obj.used
|
||||||
|
obj.print_url = [locationPath, 'print/', obj.id].join('')
|
||||||
|
obj.withdraw_url = [locationPath, obj.id].join('')
|
||||||
obj._data.use_custom = Boolean(obj.custom_url)
|
obj._data.use_custom = Boolean(obj.custom_url)
|
||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
const CUSTOM_URL = '/static/images/default_voucher.png'
|
const CUSTOM_URL = '/static/images/default_voucher.png'
|
||||||
|
|
||||||
window.app = Vue.createApp({
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data() {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
checker: null,
|
checker: null,
|
||||||
withdrawLinks: [],
|
withdrawLinks: [],
|
||||||
lnurl: '',
|
|
||||||
withdrawLinksTable: {
|
withdrawLinksTable: {
|
||||||
columns: [
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
{name: 'title', align: 'left', label: 'Title', field: 'title'},
|
{name: 'title', align: 'left', label: 'Title', field: 'title'},
|
||||||
{
|
|
||||||
name: 'created_at',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Created At',
|
|
||||||
field: 'created_at',
|
|
||||||
sortable: true,
|
|
||||||
format: function (val) {
|
|
||||||
return new Date(val).toLocaleString()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'wait_time',
|
name: 'wait_time',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
|
|
@ -37,7 +46,7 @@ window.app = Vue.createApp({
|
||||||
{
|
{
|
||||||
name: 'uses',
|
name: 'uses',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
label: 'Uses',
|
label: 'Created',
|
||||||
field: 'uses'
|
field: 'uses'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -46,18 +55,11 @@ window.app = Vue.createApp({
|
||||||
label: 'Uses left',
|
label: 'Uses left',
|
||||||
field: 'uses_left'
|
field: 'uses_left'
|
||||||
},
|
},
|
||||||
{
|
{name: 'min', align: 'right', label: 'Min (sat)', field: 'min_fsat'},
|
||||||
name: 'max_withdrawable',
|
{name: 'max', align: 'right', label: 'Max (sat)', field: 'max_fsat'}
|
||||||
align: 'right',
|
|
||||||
label: 'Max (sat)',
|
|
||||||
field: 'max_withdrawable',
|
|
||||||
format: LNbits.utils.formatSat
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
page: 1,
|
rowsPerPage: 10
|
||||||
rowsPerPage: 10,
|
|
||||||
rowsNumber: 0
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
nfcTagWriting: false,
|
nfcTagWriting: false,
|
||||||
|
|
@ -68,8 +70,7 @@ window.app = Vue.createApp({
|
||||||
data: {
|
data: {
|
||||||
is_unique: false,
|
is_unique: false,
|
||||||
use_custom: false,
|
use_custom: false,
|
||||||
has_webhook: false,
|
has_webhook: false
|
||||||
enabled: true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
simpleformDialog: {
|
simpleformDialog: {
|
||||||
|
|
@ -79,8 +80,7 @@ window.app = Vue.createApp({
|
||||||
use_custom: false,
|
use_custom: false,
|
||||||
title: 'Vouchers',
|
title: 'Vouchers',
|
||||||
min_withdrawable: 0,
|
min_withdrawable: 0,
|
||||||
wait_time: 1,
|
wait_time: 1
|
||||||
enabled: true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
qrCodeDialog: {
|
qrCodeDialog: {
|
||||||
|
|
@ -90,71 +90,64 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
sortedWithdrawLinks() {
|
sortedWithdrawLinks: function () {
|
||||||
return this.withdrawLinks.sort(function (a, b) {
|
return this.withdrawLinks.sort(function (a, b) {
|
||||||
return b.uses_left - a.uses_left
|
return b.uses_left - a.uses_left
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getWithdrawLinks(props) {
|
getWithdrawLinks: function () {
|
||||||
if (props) {
|
var self = this
|
||||||
this.withdrawLinksTable.pagination = props.pagination
|
|
||||||
}
|
|
||||||
|
|
||||||
let pagination = this.withdrawLinksTable.pagination
|
|
||||||
const query = {
|
|
||||||
limit: pagination.rowsPerPage,
|
|
||||||
offset: (pagination.page - 1) * pagination.rowsPerPage
|
|
||||||
}
|
|
||||||
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'GET',
|
'GET',
|
||||||
`/withdraw/api/v1/links?all_wallets=true&limit=${query.limit}&offset=${query.offset}`,
|
'/withdraw/api/v1/links?all_wallets=true',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(function (response) {
|
||||||
this.withdrawLinks = response.data.data.map(mapWithdrawLink)
|
self.withdrawLinks = response.data.map(function (obj) {
|
||||||
this.withdrawLinksTable.pagination.rowsNumber = response.data.total
|
return mapWithdrawLink(obj)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(function (error) {
|
||||||
clearInterval(this.checker)
|
clearInterval(self.checker)
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
closeFormDialog() {
|
closeFormDialog: function () {
|
||||||
this.formDialog.data = {
|
this.formDialog.data = {
|
||||||
is_unique: false,
|
is_unique: false,
|
||||||
use_custom: false,
|
use_custom: false,
|
||||||
has_webhook: false,
|
has_webhook: false
|
||||||
enabled: true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
simplecloseFormDialog() {
|
simplecloseFormDialog: function () {
|
||||||
this.simpleformDialog.data = {
|
this.simpleformDialog.data = {
|
||||||
is_unique: false,
|
is_unique: false,
|
||||||
use_custom: false,
|
use_custom: false
|
||||||
enabled: true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
openQrCodeDialog(linkId) {
|
openQrCodeDialog: function (linkId) {
|
||||||
const link = _.findWhere(this.withdrawLinks, {id: linkId})
|
var link = _.findWhere(this.withdrawLinks, {id: linkId})
|
||||||
|
|
||||||
this.qrCodeDialog.data = _.clone(link)
|
this.qrCodeDialog.data = _.clone(link)
|
||||||
|
this.qrCodeDialog.data.url =
|
||||||
|
window.location.protocol + '//' + window.location.host
|
||||||
this.qrCodeDialog.show = true
|
this.qrCodeDialog.show = true
|
||||||
this.activeUrl = link.lnurl_url
|
|
||||||
},
|
},
|
||||||
openUpdateDialog(linkId) {
|
openUpdateDialog: function (linkId) {
|
||||||
let link = _.findWhere(this.withdrawLinks, {id: linkId})
|
var link = _.findWhere(this.withdrawLinks, {id: linkId})
|
||||||
link._data.has_webhook = link._data.webhook_url ? true : false
|
link._data.has_webhook = link._data.webhook_url ? true : false
|
||||||
this.formDialog.data = _.clone(link._data)
|
this.formDialog.data = _.clone(link._data)
|
||||||
this.formDialog.show = true
|
this.formDialog.show = true
|
||||||
},
|
},
|
||||||
sendFormData() {
|
sendFormData: function () {
|
||||||
const wallet = _.findWhere(this.g.user.wallets, {
|
var wallet = _.findWhere(this.g.user.wallets, {
|
||||||
id: this.formDialog.data.wallet
|
id: this.formDialog.data.wallet
|
||||||
})
|
})
|
||||||
const data = _.omit(this.formDialog.data, 'wallet')
|
var data = _.omit(this.formDialog.data, 'wallet')
|
||||||
|
|
||||||
if (!data.use_custom) {
|
if (!data.use_custom) {
|
||||||
data.custom_url = null
|
data.custom_url = null
|
||||||
|
|
@ -178,11 +171,11 @@ window.app = Vue.createApp({
|
||||||
this.createWithdrawLink(wallet, data)
|
this.createWithdrawLink(wallet, data)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
simplesendFormData() {
|
simplesendFormData: function () {
|
||||||
const wallet = _.findWhere(this.g.user.wallets, {
|
var wallet = _.findWhere(this.g.user.wallets, {
|
||||||
id: this.simpleformDialog.data.wallet
|
id: this.simpleformDialog.data.wallet
|
||||||
})
|
})
|
||||||
const data = _.omit(this.simpleformDialog.data, 'wallet')
|
var data = _.omit(this.simpleformDialog.data, 'wallet')
|
||||||
|
|
||||||
data.wait_time = 1
|
data.wait_time = 1
|
||||||
data.min_withdrawable = data.max_withdrawable
|
data.min_withdrawable = data.max_withdrawable
|
||||||
|
|
@ -203,7 +196,9 @@ window.app = Vue.createApp({
|
||||||
this.createWithdrawLink(wallet, data)
|
this.createWithdrawLink(wallet, data)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateWithdrawLink(wallet, data) {
|
updateWithdrawLink: function (wallet, data) {
|
||||||
|
var self = this
|
||||||
|
|
||||||
// Remove webhook info if toggle is set to false
|
// Remove webhook info if toggle is set to false
|
||||||
if (!data.has_webhook) {
|
if (!data.has_webhook) {
|
||||||
data.webhook_url = null
|
data.webhook_url = null
|
||||||
|
|
@ -218,45 +213,48 @@ window.app = Vue.createApp({
|
||||||
wallet.adminkey,
|
wallet.adminkey,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) {
|
self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) {
|
||||||
return obj.id === data.id
|
return obj.id === data.id
|
||||||
})
|
})
|
||||||
this.withdrawLinks.push(mapWithdrawLink(response.data))
|
self.withdrawLinks.push(mapWithdrawLink(response.data))
|
||||||
this.formDialog.show = false
|
self.formDialog.show = false
|
||||||
this.closeFormDialog()
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
createWithdrawLink(wallet, data) {
|
|
||||||
LNbits.api
|
|
||||||
.request('POST', '/withdraw/api/v1/links', wallet.adminkey, data)
|
|
||||||
.then(response => {
|
|
||||||
this.withdrawLinks.push(mapWithdrawLink(response.data))
|
|
||||||
this.formDialog.show = false
|
|
||||||
this.simpleformDialog.show = false
|
|
||||||
this.closeFormDialog()
|
this.closeFormDialog()
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
deleteWithdrawLink(linkId) {
|
createWithdrawLink: function (wallet, data) {
|
||||||
const link = _.findWhere(this.withdrawLinks, {id: linkId})
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/withdraw/api/v1/links', wallet.adminkey, data)
|
||||||
|
.then((response) => {
|
||||||
|
self.withdrawLinks.push(mapWithdrawLink(response.data))
|
||||||
|
self.formDialog.show = false
|
||||||
|
self.simpleformDialog.show = false
|
||||||
|
this.closeFormDialog()
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteWithdrawLink: function (linkId) {
|
||||||
|
var self = this
|
||||||
|
var link = _.findWhere(this.withdrawLinks, {id: linkId})
|
||||||
|
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog('Are you sure you want to delete this withdraw link?')
|
.confirmDialog('Are you sure you want to delete this withdraw link?')
|
||||||
.onOk(() => {
|
.onOk(function () {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
'/withdraw/api/v1/links/' + linkId,
|
'/withdraw/api/v1/links/' + linkId,
|
||||||
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
|
_.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(function (response) {
|
||||||
this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) {
|
self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) {
|
||||||
return obj.id === linkId
|
return obj.id === linkId
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -265,7 +263,7 @@ window.app = Vue.createApp({
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async writeNfcTag(lnurl) {
|
writeNfcTag: async function (lnurl) {
|
||||||
try {
|
try {
|
||||||
if (typeof NDEFReader == 'undefined') {
|
if (typeof NDEFReader == 'undefined') {
|
||||||
throw {
|
throw {
|
||||||
|
|
@ -307,12 +305,15 @@ window.app = Vue.createApp({
|
||||||
this.withdrawLinks,
|
this.withdrawLinks,
|
||||||
'withdraw-links'
|
'withdraw-links'
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
created() {
|
created: function () {
|
||||||
if (this.g.user.wallets.length) {
|
if (this.g.user.wallets.length) {
|
||||||
this.getWithdrawLinks()
|
var getWithdrawLinks = this.getWithdrawLinks
|
||||||
this.checker = setInterval(this.getWithdrawLinks, 300000)
|
getWithdrawLinks()
|
||||||
|
this.checker = setInterval(function () {
|
||||||
|
getWithdrawLinks()
|
||||||
|
}, 300000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.base_url }}withdraw/api/v1/links -H
|
>curl -X GET {{ request.base_url }}withdraw/api/v1/links -H
|
||||||
"X-Api-Key: <span v-text="g.user.wallets[0].inkey"></span>"
|
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -51,8 +51,8 @@
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.base_url
|
>curl -X GET {{ request.base_url
|
||||||
}}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key:
|
}}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{
|
||||||
<span v-text="g.user.wallets[0].inkey"></span>"
|
user.wallets[0].inkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -86,7 +86,7 @@
|
||||||
"max_withdrawable": <integer>, "uses": <integer>,
|
"max_withdrawable": <integer>, "uses": <integer>,
|
||||||
"wait_time": <integer>, "is_unique": <boolean>,
|
"wait_time": <integer>, "is_unique": <boolean>,
|
||||||
"webhook_url": <string>}' -H "Content-type: application/json" -H
|
"webhook_url": <string>}' -H "Content-type: application/json" -H
|
||||||
"X-Api-Key: <span v-text="g.user.wallets[0].adminkey"></span>"
|
"X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -122,8 +122,8 @@
|
||||||
<string>, "min_withdrawable": <integer>,
|
<string>, "min_withdrawable": <integer>,
|
||||||
"max_withdrawable": <integer>, "uses": <integer>,
|
"max_withdrawable": <integer>, "uses": <integer>,
|
||||||
"wait_time": <integer>, "is_unique": <boolean>}' -H
|
"wait_time": <integer>, "is_unique": <boolean>}' -H
|
||||||
"Content-type: application/json" -H "X-Api-Key:
|
"Content-type: application/json" -H "X-Api-Key: {{
|
||||||
<span v-text="g.user.wallets[0].adminkey"></span>"
|
user.wallets[0].adminkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -147,8 +147,8 @@
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
<code
|
<code
|
||||||
>curl -X DELETE {{ request.base_url
|
>curl -X DELETE {{ request.base_url
|
||||||
}}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key:
|
}}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{
|
||||||
<span v-text="g.user.wallets[0].adminkey"></span>"
|
user.wallets[0].adminkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -176,7 +176,7 @@
|
||||||
<code
|
<code
|
||||||
>curl -X GET {{ request.base_url
|
>curl -X GET {{ request.base_url
|
||||||
}}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H
|
}}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H
|
||||||
"X-Api-Key: <span v-text="g.user.wallets[0].inkey"></span>"
|
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||||
</code>
|
</code>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
|
||||||
12
templates/withdraw/csv.html
Normal file
12
templates/withdraw/csv.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes
|
||||||
|
in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor
|
||||||
|
%} {% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
data: function () {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -4,32 +4,29 @@
|
||||||
<q-card class="q-pa-lg">
|
<q-card class="q-pa-lg">
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<q-badge v-if="spent" color="red" class="q-mb-md"
|
{% if link.is_spent %}
|
||||||
>Withdraw is spent.</q-badge
|
<q-badge color="red" class="q-mb-md">Withdraw is spent.</q-badge>
|
||||||
>
|
{% endif %}
|
||||||
<q-badge v-if="spent" color="red" class="q-mb-md"
|
<a class="text-secondary" href="lightning:{{ lnurl }}">
|
||||||
>Withdraw is spent.</q-badge
|
<q-responsive :ratio="1" class="q-mx-md">
|
||||||
>
|
<qrcode
|
||||||
<q-badge v-else-if="!enabled" color="grey" class="q-mb-md"
|
:value="this.here + '/?lightning={{lnurl }}'"
|
||||||
>Withdraw is disabled.</q-badge
|
:options="{width: 800}"
|
||||||
>
|
class="rounded-borders"
|
||||||
<a v-else class="text-secondary" :href="link">
|
>
|
||||||
<lnbits-qrcode-lnurl
|
</qrcode>
|
||||||
prefix="lnurlw"
|
</q-responsive>
|
||||||
:url="url"
|
|
||||||
@update:lnurl="v => lnurl = v"
|
|
||||||
></lnbits-qrcode-lnurl>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-mt-lg q-gutter-sm">
|
<div class="row q-mt-lg q-gutter-sm">
|
||||||
<q-btn outline color="grey" @click="copyText(lnurl)"
|
<q-btn outline color="grey" @click="copyText('{{ lnurl }}')"
|
||||||
>Copy LNURL</q-btn
|
>Copy LNURL</q-btn
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
icon="nfc"
|
icon="nfc"
|
||||||
@click="writeNfcTag(lnurl)"
|
@click="writeNfcTag(' {{ lnurl }} ')"
|
||||||
:disable="nfcTagWriting"
|
:disable="nfcTagWriting"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -55,16 +52,15 @@
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
window.app = Vue.createApp({
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [window.windowMixin],
|
mixins: [windowMixin],
|
||||||
data() {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
spent: {{ 'true' if spent else 'false' }},
|
here: location.protocol + '//' + location.host,
|
||||||
url: '{{ lnurl_url }}',
|
nfcTagWriting: false
|
||||||
lnurl: '',
|
|
||||||
nfcTagWriting: false,
|
|
||||||
enabled: {{ 'true' if enabled else 'false' }}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
%} {% block page %}
|
%} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="/withdraw/static/js/index.js"></script>
|
||||||
|
{% endblock %} {% block page %}
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
|
|
@ -7,11 +9,7 @@
|
||||||
<q-btn unelevated color="primary" @click="simpleformDialog.show = true"
|
<q-btn unelevated color="primary" @click="simpleformDialog.show = true"
|
||||||
>Quick vouchers</q-btn
|
>Quick vouchers</q-btn
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
@click="formDialog.show = true"
|
|
||||||
class="q-ml-md"
|
|
||||||
>Advanced withdraw link(s)</q-btn
|
>Advanced withdraw link(s)</q-btn
|
||||||
>
|
>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
@ -30,72 +28,77 @@
|
||||||
<q-table
|
<q-table
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:rows="sortedWithdrawLinks"
|
:data="sortedWithdrawLinks"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
:columns="withdrawLinksTable.columns"
|
:columns="withdrawLinksTable.columns"
|
||||||
v-model:pagination="withdrawLinksTable.pagination"
|
:pagination.sync="withdrawLinksTable.pagination"
|
||||||
@request="getWithdrawLinks"
|
|
||||||
>
|
>
|
||||||
|
{% raw %}
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
<q-th auto-width></q-th>
|
<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-th auto-width></q-th>
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
<q-th
|
|
||||||
v-for="col in props.cols"
|
|
||||||
:key="col.name"
|
|
||||||
:props="props"
|
|
||||||
v-text="col.label"
|
|
||||||
></q-th>
|
|
||||||
<q-th auto-width></q-th>
|
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
<q-td auto-width>
|
|
||||||
<q-icon
|
|
||||||
name="power_settings_new"
|
|
||||||
:color="props.row.enabled ? 'green' : 'red'"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
<span
|
|
||||||
v-text="props.row.enabled ? 'Withdraw link is enabled' : 'Withdraw link is disabled'"
|
|
||||||
></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</q-td>
|
|
||||||
<q-td auto-width>
|
<q-td auto-width>
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
dense
|
dense
|
||||||
size="xs"
|
size="xs"
|
||||||
icon="launch"
|
icon="launch"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
type="a"
|
type="a"
|
||||||
:href="'/withdraw/' + props.row.id"
|
:href="props.row.withdraw_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<q-tooltip>Shareable link</q-tooltip></q-btn
|
<q-tooltip> shareable link </q-tooltip></q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="web_asset"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'/withdraw/img/' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
><q-tooltip> embeddable image </q-tooltip></q-btn
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
dense
|
dense
|
||||||
size="xs"
|
size="xs"
|
||||||
icon="reorder"
|
icon="reorder"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
type="a"
|
type="a"
|
||||||
:href="'/withdraw/csv/' + props.row.id"
|
:href="'/withdraw/csv/' + props.row.id"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
><q-tooltip>CSV download</q-tooltip></q-btn
|
><q-tooltip> csv list </q-tooltip></q-btn
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
dense
|
dense
|
||||||
size="xs"
|
size="xs"
|
||||||
icon="visibility"
|
icon="visibility"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
@click="openQrCodeDialog(props.row.id)"
|
@click="openQrCodeDialog(props.row.id)"
|
||||||
><q-tooltip>view LNURL</q-tooltip></q-btn
|
><q-tooltip> view LNURL </q-tooltip></q-btn
|
||||||
>
|
>
|
||||||
</q-td>
|
</q-td>
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
|
||||||
|
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-td>
|
||||||
<q-td auto-width>
|
<q-td auto-width>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
|
|
@ -114,22 +117,9 @@
|
||||||
color="pink"
|
color="pink"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td
|
|
||||||
v-for="col in props.cols"
|
|
||||||
:key="col.name"
|
|
||||||
:props="props"
|
|
||||||
v-text="col.value"
|
|
||||||
>
|
|
||||||
</q-td>
|
|
||||||
<q-td>
|
|
||||||
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
|
|
||||||
<q-tooltip
|
|
||||||
>Webhook to <span v-text="props.row.webhook_url"></span
|
|
||||||
></q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
|
{% endraw %}
|
||||||
</q-table>
|
</q-table>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -139,7 +129,7 @@
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h6 class="text-subtitle1 q-my-none">
|
<h6 class="text-subtitle1 q-my-none">
|
||||||
LNbits LNURL withdraw extension
|
{{SITE_TITLE}} LNURL-withdraw extension
|
||||||
</h6>
|
</h6>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
|
|
@ -252,20 +242,6 @@
|
||||||
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
|
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
|
||||||
></q-input>
|
></q-input>
|
||||||
<q-list>
|
<q-list>
|
||||||
<q-item tag="label" class="rounded-borders">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-checkbox
|
|
||||||
v-model="formDialog.data.enabled"
|
|
||||||
color="primary"
|
|
||||||
></q-checkbox>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Enable / Disable </q-item-label>
|
|
||||||
<q-item-label caption
|
|
||||||
>You can enable or disable these vouchers</q-item-label
|
|
||||||
>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item tag="label" class="rounded-borders">
|
<q-item tag="label" class="rounded-borders">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
|
|
@ -378,20 +354,6 @@
|
||||||
label="Number of vouchers"
|
label="Number of vouchers"
|
||||||
></q-input>
|
></q-input>
|
||||||
<q-list>
|
<q-list>
|
||||||
<q-item tag="label" class="rounded-borders">
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-checkbox
|
|
||||||
v-model="simpleformDialog.data.enabled"
|
|
||||||
color="primary"
|
|
||||||
></q-checkbox>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Enable / Disable </q-item-label>
|
|
||||||
<q-item-label caption
|
|
||||||
>You can enable or disable these vouchers</q-item-label
|
|
||||||
>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item tag="label" class="rounded-borders">
|
<q-item tag="label" class="rounded-borders">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
|
|
@ -441,61 +403,63 @@
|
||||||
|
|
||||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||||
<lnbits-qrcode-lnurl
|
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||||
:url="activeUrl"
|
<qrcode
|
||||||
@update:lnurl="v => lnurl = v"
|
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
|
||||||
prefix="lnurlw"
|
:options="{width: 800}"
|
||||||
></lnbits-qrcode-lnurl>
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
{% raw %}
|
||||||
|
</q-responsive>
|
||||||
<p style="word-break: break-all">
|
<p style="word-break: break-all">
|
||||||
<strong>ID:</strong> <span v-text="qrCodeDialog.data.id"></span><br />
|
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
||||||
<strong>Unique:</strong>
|
<strong>Unique:</strong> {{ qrCodeDialog.data.is_unique }}<span
|
||||||
<span v-text="qrCodeDialog.data.is_unique"></span>
|
v-if="qrCodeDialog.data.is_unique"
|
||||||
<span v-if="qrCodeDialog.data.is_unique" class="text-deep-purple">
|
class="text-deep-purple"
|
||||||
|
>
|
||||||
(QR code will change after each withdrawal)</span
|
(QR code will change after each withdrawal)</span
|
||||||
><br />
|
><br />
|
||||||
<strong>Max. withdrawable:</strong>
|
<strong>Max. withdrawable:</strong> {{
|
||||||
<span v-text="qrCodeDialog.data.max_withdrawable"></span> sat<br />
|
qrCodeDialog.data.max_withdrawable }} sat<br />
|
||||||
<strong>Wait time:</strong>
|
<strong>Wait time:</strong> {{ qrCodeDialog.data.wait_time }} seconds<br />
|
||||||
<span v-text="qrCodeDialog.data.wait_time"></span> seconds<br />
|
<strong>Withdraws:</strong> {{ qrCodeDialog.data.used }} / {{
|
||||||
<strong>Withdraws:</strong>
|
qrCodeDialog.data.uses }}
|
||||||
<span v-text="qrCodeDialog.data.used"></span>/
|
|
||||||
<span v-text="qrCodeDialog.data.uses"></span><br />
|
|
||||||
<q-linear-progress
|
<q-linear-progress
|
||||||
:value="qrCodeDialog.data.used / qrCodeDialog.data.uses"
|
:value="qrCodeDialog.data.used / qrCodeDialog.data.uses"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="q-mt-sm"
|
class="q-mt-sm"
|
||||||
></q-linear-progress>
|
></q-linear-progress>
|
||||||
</p>
|
</p>
|
||||||
|
{% endraw %}
|
||||||
<div class="row q-mt-lg q-gutter-sm">
|
<div class="row q-mt-lg q-gutter-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
@click="copyText(lnurl, 'LNURL copied to clipboard!')"
|
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')"
|
||||||
class="q-ml-sm"
|
class="q-ml-sm"
|
||||||
>Copy LNURL</q-btn
|
>Copy LNURL</q-btn
|
||||||
>
|
>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
icon="link"
|
||||||
|
@click="copyText(qrCodeDialog.data.withdraw_url, 'Link copied to clipboard!')"
|
||||||
|
><q-tooltip>Copy sharable link</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
icon="nfc"
|
icon="nfc"
|
||||||
@click="writeNfcTag(lnurl)"
|
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
|
||||||
:disable="nfcTagWriting"
|
:disable="nfcTagWriting"
|
||||||
><q-tooltip>Write to NFC</q-tooltip></q-btn
|
><q-tooltip>Write to NFC</q-tooltip></q-btn
|
||||||
>
|
>
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
icon="link"
|
|
||||||
:href="'/withdraw/' + qrCodeDialog.data.id"
|
|
||||||
target="_blank"
|
|
||||||
><q-tooltip>Open sharable link</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
icon="print"
|
icon="print"
|
||||||
type="a"
|
type="a"
|
||||||
:href="'/withdraw/print/' + qrCodeDialog.data.id"
|
:href="qrCodeDialog.data.print_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
><q-tooltip>Print</q-tooltip></q-btn
|
><q-tooltip>Print</q-tooltip></q-btn
|
||||||
>
|
>
|
||||||
|
|
@ -504,6 +468,4 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}{% block scripts %} {{ window_vars(user) }}
|
|
||||||
<script src="{{ static_url_for('withdraw/static', path='js/index.js') }}"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,22 @@
|
||||||
<div class="" id="vue">
|
<div class="" id="vue">
|
||||||
{% for page in link %}
|
{% for page in link %}
|
||||||
<page size="A4" id="pdfprint">
|
<page size="A4" id="pdfprint">
|
||||||
<div class="full-height content-center">
|
<table style="width: 100%">
|
||||||
{% for row in page %}
|
{% for threes in page %}
|
||||||
<div class="row" style="max-height: 54mm">
|
<tr style="height: 59.4mm">
|
||||||
{% for one in row %}
|
{% for one in threes %}
|
||||||
<div class="col-6">
|
<td style="width: 105mm">
|
||||||
<lnbits-qrcode
|
<center>
|
||||||
style="width: 50mm"
|
<qrcode
|
||||||
:value="theurl + '/?lightning={{one}}'"
|
:value="theurl + '/?lightning={{one}}'"
|
||||||
:show-buttons="false"
|
:options="{width: 150}"
|
||||||
></lnbits-qrcode>
|
></qrcode>
|
||||||
</div>
|
</center>
|
||||||
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</table>
|
||||||
</page>
|
</page>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -52,9 +53,11 @@
|
||||||
</style>
|
</style>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
window.app = Vue.createApp({
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
data() {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
theurl: location.protocol + '//' + location.host,
|
theurl: location.protocol + '//' + location.host,
|
||||||
printDialog: {
|
printDialog: {
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,13 @@
|
||||||
<page size="A4" id="pdfprint">
|
<page size="A4" id="pdfprint">
|
||||||
{% for one in page %}
|
{% for one in page %}
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<img class="lnurlw_design" src="{{custom_url}}" alt="..." />
|
<img src="{{custom_url}}" alt="..." />
|
||||||
<span>{{ amt }} sats</span>
|
<span>{{ amt }} sats</span>
|
||||||
<div class="lnurlw">
|
<div class="lnurlw">
|
||||||
<lnbits-qrcode
|
<qrcode
|
||||||
:value="theurl + '/?lightning={{one}}'"
|
:value="theurl + '/?lightning={{one}}'"
|
||||||
:show-buttons="false"
|
:options="{width: 95, margin: 1}"
|
||||||
:options="{width: 150}"
|
></qrcode>
|
||||||
></lnbits-qrcode>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -53,7 +52,7 @@
|
||||||
top: calc(3.2mm + 1rem);
|
top: calc(3.2mm + 1rem);
|
||||||
right: calc(4mm + 1rem);
|
right: calc(4mm + 1rem);
|
||||||
}
|
}
|
||||||
.wrapper img.lnurlw_design {
|
.wrapper img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 187mm;
|
width: 187mm;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
@ -62,10 +61,9 @@
|
||||||
.wrapper .lnurlw {
|
.wrapper .lnurlw {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(3mm + 1rem);
|
top: calc(7.3mm + 1rem);
|
||||||
left: calc(6mm + 1rem);
|
left: calc(7.5mm + 1rem);
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
width: 27mm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
|
@ -85,15 +83,17 @@
|
||||||
.wrapper .lnurlw {
|
.wrapper .lnurlw {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 3mm;
|
top: 7.3mm;
|
||||||
left: 6mm;
|
left: 7.5mm;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
window.app = Vue.createApp({
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import pytest
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from .. import withdraw_ext
|
|
||||||
|
|
||||||
|
|
||||||
# just import router and add it to a test router
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_router():
|
|
||||||
router = APIRouter()
|
|
||||||
router.include_router(withdraw_ext)
|
|
||||||
29
toc.md
29
toc.md
|
|
@ -1,29 +0,0 @@
|
||||||
# Terms and Conditions for LNbits Extension
|
|
||||||
|
|
||||||
## 1. Acceptance of Terms
|
|
||||||
|
|
||||||
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
|
|
||||||
|
|
||||||
## 2. License
|
|
||||||
|
|
||||||
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
|
|
||||||
|
|
||||||
## 3. No Warranty
|
|
||||||
|
|
||||||
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
|
|
||||||
|
|
||||||
## 4. Limitation of Liability
|
|
||||||
|
|
||||||
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
|
|
||||||
|
|
||||||
## 5. Modification of Terms
|
|
||||||
|
|
||||||
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
|
|
||||||
|
|
||||||
## 6. General Provisions
|
|
||||||
|
|
||||||
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
|
|
||||||
|
|
||||||
## 7. Contact Information
|
|
||||||
|
|
||||||
If you have any questions about these Terms, please contact the developer at [developer's contact information].
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
"""
|
|
||||||
Nostr-transport RPC handlers for the withdraw (LNURL-withdraw) extension.
|
|
||||||
|
|
||||||
Names mirror the Lightning.Pub `withdraw.*` contract that the lamassu-next
|
|
||||||
ATM consumes (see ~/dev/shocknet/lamassu-next/packages/lightning/src/client.ts
|
|
||||||
lines ~301–351). That keeps the lamassu-next-side adapter a pure name
|
|
||||||
translation — no semantic reshaping.
|
|
||||||
|
|
||||||
Auth model (set in `__init__.py:withdraw_start`):
|
|
||||||
- create / get / update / delete → AUTH_WALLET; the calling pubkey must
|
|
||||||
own the wallet the link is scoped to. *_get / *_update / *_delete also
|
|
||||||
verify the link's stored `wallet` matches the caller's wallet id.
|
|
||||||
|
|
||||||
`resolve_withdraw_owner` is registered with the core subscription module
|
|
||||||
under tag `"withdraw"` and extras-key `"withdrawal_link_id"` (matching
|
|
||||||
where the extension stamps the link id on settlement — see
|
|
||||||
`views_lnurl.py:144`). That lets `subscribe_payments({tag:"withdraw",
|
|
||||||
link_id:...})` enforce ownership without core importing this module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from lnbits.core.crud.wallets import get_wallets
|
|
||||||
from lnbits.core.models import Account
|
|
||||||
from lnbits.core.models.wallets import WalletTypeInfo
|
|
||||||
from lnbits.core.services.nostr_transport.models import NostrRpcRequest
|
|
||||||
from shortuuid import uuid
|
|
||||||
|
|
||||||
from .crud import (
|
|
||||||
create_withdraw_link,
|
|
||||||
delete_withdraw_link,
|
|
||||||
get_withdraw_link,
|
|
||||||
get_withdraw_links,
|
|
||||||
update_withdraw_link,
|
|
||||||
)
|
|
||||||
from .helpers import create_lnurl_from_baseurl
|
|
||||||
from .models import CreateWithdrawData, WithdrawLink
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_lnurlw_create_link(
|
|
||||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
|
||||||
) -> dict:
|
|
||||||
body = request.body or {}
|
|
||||||
data = CreateWithdrawData(**body)
|
|
||||||
link = await create_withdraw_link(data, auth.wallet.id)
|
|
||||||
return _to_dict(_populate_lnurl(link))
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_lnurlw_get_link(
|
|
||||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
|
||||||
) -> dict:
|
|
||||||
link_id = _require_id(request)
|
|
||||||
link = await _require_owned_link(link_id, auth.wallet.id)
|
|
||||||
return _to_dict(_populate_lnurl(link))
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_lnurlw_update_link(
|
|
||||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
|
||||||
) -> dict:
|
|
||||||
link_id = _require_id(request)
|
|
||||||
link = await _require_owned_link(link_id, auth.wallet.id)
|
|
||||||
body = request.body or {}
|
|
||||||
_MUTABLE = {
|
|
||||||
"title",
|
|
||||||
"min_withdrawable",
|
|
||||||
"max_withdrawable",
|
|
||||||
"uses",
|
|
||||||
"wait_time",
|
|
||||||
"is_unique",
|
|
||||||
"webhook_url",
|
|
||||||
"webhook_headers",
|
|
||||||
"webhook_body",
|
|
||||||
"custom_url",
|
|
||||||
"enabled",
|
|
||||||
}
|
|
||||||
for k, v in body.items():
|
|
||||||
if k in _MUTABLE:
|
|
||||||
setattr(link, k, v)
|
|
||||||
updated = await update_withdraw_link(link)
|
|
||||||
return _to_dict(_populate_lnurl(updated))
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_lnurlw_delete_link(
|
|
||||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
|
||||||
) -> dict:
|
|
||||||
link_id = _require_id(request)
|
|
||||||
await _require_owned_link(link_id, auth.wallet.id)
|
|
||||||
await delete_withdraw_link(link_id)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_lnurlw_list_links(auth: Account, request: NostrRpcRequest) -> dict:
|
|
||||||
"""List withdraw links across all wallets owned by the calling account.
|
|
||||||
Useful for ATMs to re-discover their links after a reconnect.
|
|
||||||
|
|
||||||
Body fields:
|
|
||||||
- limit: int (0 means no limit; default 0)
|
|
||||||
- offset: int (default 0)
|
|
||||||
If `request.wallet_id` is set and is one of the caller's wallets,
|
|
||||||
narrow to just that wallet.
|
|
||||||
"""
|
|
||||||
body = request.body or {}
|
|
||||||
limit = int(body.get("limit") or 0)
|
|
||||||
offset = int(body.get("offset") or 0)
|
|
||||||
|
|
||||||
wallets = await get_wallets(auth.id)
|
|
||||||
wallet_ids = [w.id for w in wallets]
|
|
||||||
if not wallet_ids:
|
|
||||||
return {"data": [], "total": 0}
|
|
||||||
if request.wallet_id and request.wallet_id in wallet_ids:
|
|
||||||
wallet_ids = [request.wallet_id]
|
|
||||||
|
|
||||||
page = await get_withdraw_links(wallet_ids, limit, offset)
|
|
||||||
return {
|
|
||||||
"data": [_to_dict(_populate_lnurl(link)) for link in page.data],
|
|
||||||
"total": page.total,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_lnurlw_unique_hashes(
|
|
||||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
For a `is_unique=True` link, return the per-use `id_unique_hash`
|
|
||||||
values that the ATM uses to generate distinct QR codes — one per
|
|
||||||
unredeemed slot. Mirrors the formula in `helpers.py:create_lnurl`
|
|
||||||
exactly so an ATM never has to re-implement the derivation:
|
|
||||||
|
|
||||||
id_unique_hash = shortuuid.uuid(name=link.id + link.unique_hash + index)
|
|
||||||
|
|
||||||
`link.usescsv` is the canonical list of *unredeemed* slot indexes;
|
|
||||||
after a customer claims a slot it gets removed there (see
|
|
||||||
`crud.remove_unique_withdraw_link`). The hashes returned here are
|
|
||||||
therefore exactly the ones still claimable.
|
|
||||||
|
|
||||||
Response:
|
|
||||||
{
|
|
||||||
"link_id": str,
|
|
||||||
"unique_hash": str, # base hash
|
|
||||||
"is_unique": bool,
|
|
||||||
"unredeemed_hashes": [ # one entry per remaining slot
|
|
||||||
{"index": str, "id_unique_hash": str}, ...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
For `is_unique=False` links the list is empty and `unique_hash`
|
|
||||||
alone identifies the callback path
|
|
||||||
(`/withdraw/api/v1/lnurl/<unique_hash>`). For `is_unique=True`
|
|
||||||
each callback path is
|
|
||||||
`/withdraw/api/v1/lnurl/<unique_hash>/<id_unique_hash>`.
|
|
||||||
"""
|
|
||||||
link_id = _require_id(request)
|
|
||||||
link = await _require_owned_link(link_id, auth.wallet.id)
|
|
||||||
|
|
||||||
unredeemed = []
|
|
||||||
if link.is_unique:
|
|
||||||
# usescsv is comma-separated; split and skip empties (after the
|
|
||||||
# last slot is consumed it becomes the empty string).
|
|
||||||
for index_str in [s for s in link.usescsv.split(",") if s.strip()]:
|
|
||||||
tohash = link.id + link.unique_hash + index_str
|
|
||||||
unredeemed.append(
|
|
||||||
{
|
|
||||||
"index": index_str.strip(),
|
|
||||||
"id_unique_hash": uuid(name=tohash),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"link_id": link.id,
|
|
||||||
"unique_hash": link.unique_hash,
|
|
||||||
"is_unique": link.is_unique,
|
|
||||||
"unredeemed_hashes": unredeemed,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_withdraw_owner(link_id: str) -> str | None:
|
|
||||||
"""For the core subscription module: link_id -> wallet_id (or None)."""
|
|
||||||
link = await get_withdraw_link(link_id)
|
|
||||||
return link.wallet if link else None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _require_id(request: NostrRpcRequest) -> str:
|
|
||||||
body = request.body or {}
|
|
||||||
link_id = body.get("id")
|
|
||||||
if not link_id:
|
|
||||||
raise ValueError("withdraw: body.id is required")
|
|
||||||
return str(link_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_owned_link(link_id: str, wallet_id: str):
|
|
||||||
link = await get_withdraw_link(link_id)
|
|
||||||
if link is None:
|
|
||||||
raise ValueError(f"withdraw: link not found: {link_id}")
|
|
||||||
if link.wallet != wallet_id:
|
|
||||||
raise PermissionError("withdraw: link does not belong to caller's wallet")
|
|
||||||
return link
|
|
||||||
|
|
||||||
|
|
||||||
def _populate_lnurl(link: WithdrawLink) -> WithdrawLink:
|
|
||||||
"""
|
|
||||||
Compose `lnurl` / `lnurl_url` from `settings.lnbits_baseurl` so
|
|
||||||
nostr-transport responses match the HTTP `views_api` shape, where
|
|
||||||
these fields are populated from `request.url_for(...)`. Without
|
|
||||||
this, consumers (ATMs, etc.) would have to re-derive the callback
|
|
||||||
URL themselves from a separately-provisioned LNbits HTTPS URL —
|
|
||||||
duplicating state LNbits already knows. See aiolabs/withdraw#1.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
encoded = create_lnurl_from_baseurl(link)
|
|
||||||
link.lnurl = str(encoded.bech32)
|
|
||||||
link.lnurl_url = str(encoded.url)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return link
|
|
||||||
|
|
||||||
|
|
||||||
def _to_dict(link) -> dict:
|
|
||||||
import json
|
|
||||||
|
|
||||||
return json.loads(link.json())
|
|
||||||
123
views.py
123
views.py
|
|
@ -1,30 +1,28 @@
|
||||||
import io
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import pyqrcode
|
||||||
|
from fastapi import Depends, HTTPException, Request
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.responses import HTMLResponse, StreamingResponse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import check_user_exists
|
||||||
from lnbits.helpers import template_renderer
|
|
||||||
|
|
||||||
|
from . import withdraw_ext, withdraw_renderer
|
||||||
from .crud import chunks, get_withdraw_link
|
from .crud import chunks, get_withdraw_link
|
||||||
from .helpers import create_lnurl
|
|
||||||
|
|
||||||
withdraw_ext_generic = APIRouter()
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
def withdraw_renderer():
|
@withdraw_ext.get("/", response_class=HTMLResponse)
|
||||||
return template_renderer(["withdraw/templates"])
|
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_generic.get("/", response_class=HTMLResponse)
|
|
||||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
return withdraw_renderer().TemplateResponse(
|
return withdraw_renderer().TemplateResponse(
|
||||||
"withdraw/index.html", {"request": request, "user": user.json()}
|
"withdraw/index.html", {"request": request, "user": user.dict()}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_generic.get("/{link_id}", response_class=HTMLResponse)
|
@withdraw_ext.get("/{link_id}", response_class=HTMLResponse)
|
||||||
async def display(request: Request, link_id):
|
async def display(request: Request, link_id):
|
||||||
link = await get_withdraw_link(link_id, 0)
|
link = await get_withdraw_link(link_id, 0)
|
||||||
|
|
||||||
|
|
@ -32,56 +30,69 @@ async def display(request: Request, link_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
lnurl = create_lnurl(link, request)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
return withdraw_renderer().TemplateResponse(
|
return withdraw_renderer().TemplateResponse(
|
||||||
"withdraw/display.html",
|
"withdraw/display.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"spent": link.is_spent,
|
"link": link.dict(),
|
||||||
"lnurl_url": str(lnurl.url),
|
"lnurl": link.lnurl(req=request),
|
||||||
"enabled": link.enabled,
|
"unique": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_generic.get("/print/{link_id}", response_class=HTMLResponse)
|
@withdraw_ext.get("/img/{link_id}", response_class=StreamingResponse)
|
||||||
|
async def img(request: Request, link_id):
|
||||||
|
link = await get_withdraw_link(link_id, 0)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||||
|
)
|
||||||
|
qr = pyqrcode.create(link.lnurl(request))
|
||||||
|
stream = BytesIO()
|
||||||
|
qr.svg(stream, scale=3)
|
||||||
|
stream.seek(0)
|
||||||
|
|
||||||
|
async def _generator(stream: BytesIO):
|
||||||
|
yield stream.getvalue()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_generator(stream),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "image/svg+xml",
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@withdraw_ext.get("/print/{link_id}", response_class=HTMLResponse)
|
||||||
async def print_qr(request: Request, link_id):
|
async def print_qr(request: Request, link_id):
|
||||||
link = await get_withdraw_link(link_id)
|
link = await get_withdraw_link(link_id)
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||||
)
|
)
|
||||||
|
# response.status_code = HTTPStatus.NOT_FOUND
|
||||||
|
# return "Withdraw link does not exist."
|
||||||
|
|
||||||
if link.uses == 0:
|
if link.uses == 0:
|
||||||
|
|
||||||
return withdraw_renderer().TemplateResponse(
|
return withdraw_renderer().TemplateResponse(
|
||||||
"withdraw/print_qr.html",
|
"withdraw/print_qr.html",
|
||||||
{"request": request, "link": link.json(), "unique": False},
|
{"request": request, "link": link.dict(), "unique": False},
|
||||||
)
|
)
|
||||||
links = []
|
links = []
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
for _ in link.usescsv.split(","):
|
for x in link.usescsv.split(","):
|
||||||
linkk = await get_withdraw_link(link_id, count)
|
linkk = await get_withdraw_link(link_id, count)
|
||||||
if not linkk:
|
if not linkk:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||||
)
|
)
|
||||||
try:
|
links.append(str(linkk.lnurl(request)))
|
||||||
lnurl = create_lnurl(linkk, request)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
links.append(str(lnurl.bech32))
|
|
||||||
count = count + 1
|
count = count + 1
|
||||||
page_link = list(chunks(links, 2))
|
page_link = list(chunks(links, 2))
|
||||||
linked = list(chunks(page_link, 5))
|
linked = list(chunks(page_link, 5))
|
||||||
|
|
@ -103,44 +114,36 @@ async def print_qr(request: Request, link_id):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_generic.get("/csv/{link_id}", response_class=HTMLResponse)
|
@withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse)
|
||||||
async def csv(request: Request, link_id):
|
async def csv(request: Request, link_id):
|
||||||
link = await get_withdraw_link(link_id)
|
link = await get_withdraw_link(link_id)
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||||
)
|
)
|
||||||
|
# response.status_code = HTTPStatus.NOT_FOUND
|
||||||
|
# return "Withdraw link does not exist."
|
||||||
|
|
||||||
if link.uses == 0:
|
if link.uses == 0:
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Withdraw is spent."
|
|
||||||
)
|
|
||||||
|
|
||||||
buffer = io.StringIO()
|
return withdraw_renderer().TemplateResponse(
|
||||||
|
"withdraw/csv.html",
|
||||||
|
{"request": request, "link": link.dict(), "unique": False},
|
||||||
|
)
|
||||||
|
links = []
|
||||||
count = 0
|
count = 0
|
||||||
for _ in link.usescsv.split(","):
|
|
||||||
|
for x in link.usescsv.split(","):
|
||||||
linkk = await get_withdraw_link(link_id, count)
|
linkk = await get_withdraw_link(link_id, count)
|
||||||
if not linkk:
|
if not linkk:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||||
)
|
)
|
||||||
try:
|
links.append(str(linkk.lnurl(request)))
|
||||||
lnurl = create_lnurl(linkk, request)
|
count = count + 1
|
||||||
except ValueError as exc:
|
page_link = list(chunks(links, 2))
|
||||||
raise HTTPException(
|
linked = list(chunks(page_link, 5))
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
buffer.write(f"{lnurl.bech32!s}\n")
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
# Move buffer cursor to the beginning
|
return withdraw_renderer().TemplateResponse(
|
||||||
buffer.seek(0)
|
"withdraw/csv.html", {"request": request, "link": linked, "unique": True}
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
buffer,
|
|
||||||
media_type="text/csv",
|
|
||||||
headers={
|
|
||||||
"Content-Disposition": f"attachment; filename=withdraw-links-{link_id}.csv"
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
161
views_api.py
161
views_api.py
|
|
@ -1,11 +1,14 @@
|
||||||
import json
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Query, Request
|
||||||
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.core.models import SimpleStatus, WalletTypeInfo
|
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||||
from lnbits.decorators import require_admin_key, require_invoice_key
|
|
||||||
|
|
||||||
|
from . import withdraw_ext
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_withdraw_link,
|
create_withdraw_link,
|
||||||
delete_withdraw_link,
|
delete_withdraw_link,
|
||||||
|
|
@ -14,48 +17,38 @@ from .crud import (
|
||||||
get_withdraw_links,
|
get_withdraw_links,
|
||||||
update_withdraw_link,
|
update_withdraw_link,
|
||||||
)
|
)
|
||||||
from .helpers import create_lnurl
|
from .models import CreateWithdrawData
|
||||||
from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
|
|
||||||
|
|
||||||
withdraw_ext_api = APIRouter(prefix="/api/v1")
|
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_api.get("/links", status_code=HTTPStatus.OK)
|
@withdraw_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
|
||||||
async def api_links(
|
async def api_links(
|
||||||
request: Request,
|
req: Request,
|
||||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
all_wallets: bool = Query(False),
|
all_wallets: bool = Query(False),
|
||||||
offset: int = Query(0),
|
):
|
||||||
limit: int = Query(0),
|
wallet_ids = [wallet.wallet.id]
|
||||||
) -> PaginatedWithdraws:
|
|
||||||
wallet_ids = [key_info.wallet.id]
|
|
||||||
|
|
||||||
if all_wallets:
|
if all_wallets:
|
||||||
user = await get_user(key_info.wallet.user)
|
user = await get_user(wallet.wallet.user)
|
||||||
wallet_ids = user.wallet_ids if user else []
|
wallet_ids = user.wallet_ids if user else []
|
||||||
|
|
||||||
links = await get_withdraw_links(wallet_ids, limit, offset)
|
try:
|
||||||
|
return [
|
||||||
|
{**link.dict(), **{"lnurl": link.lnurl(req)}}
|
||||||
|
for link in await get_withdraw_links(wallet_ids)
|
||||||
|
]
|
||||||
|
|
||||||
for linkk in links.data:
|
except LnurlInvalidUrl:
|
||||||
try:
|
raise HTTPException(
|
||||||
lnurl = create_lnurl(linkk, request)
|
status_code=HTTPStatus.UPGRADE_REQUIRED,
|
||||||
except ValueError as exc:
|
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
|
||||||
raise HTTPException(
|
)
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
linkk.lnurl = str(lnurl.bech32)
|
|
||||||
linkk.lnurl_url = str(lnurl.url)
|
|
||||||
|
|
||||||
return links
|
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_api.get("/links/{link_id}", status_code=HTTPStatus.OK)
|
@withdraw_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
async def api_link_retrieve(
|
async def api_link_retrieve(
|
||||||
request: Request,
|
link_id: str, request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
link_id: str,
|
):
|
||||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
) -> WithdrawLink:
|
|
||||||
link = await get_withdraw_link(link_id, 0)
|
link = await get_withdraw_link(link_id, 0)
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
|
|
@ -63,31 +56,21 @@ async def api_link_retrieve(
|
||||||
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
if link.wallet != key_info.wallet.id:
|
if link.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
|
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
)
|
)
|
||||||
|
return {**link.dict(), **{"lnurl": link.lnurl(request)}}
|
||||||
try:
|
|
||||||
lnurl = create_lnurl(link, request)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
link.lnurl = str(lnurl.bech32)
|
|
||||||
link.lnurl_url = str(lnurl.url)
|
|
||||||
return link
|
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_api.post("/links", status_code=HTTPStatus.CREATED)
|
@withdraw_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
||||||
@withdraw_ext_api.put("/links/{link_id}")
|
@withdraw_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
async def api_link_create_or_update(
|
async def api_link_create_or_update(
|
||||||
request: Request,
|
req: Request,
|
||||||
data: CreateWithdrawData,
|
data: CreateWithdrawData,
|
||||||
link_id: str | None = None,
|
link_id: Optional[str] = None,
|
||||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> WithdrawLink:
|
):
|
||||||
if data.uses > 250:
|
if data.uses > 250:
|
||||||
raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST)
|
raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
|
@ -105,20 +88,20 @@ async def api_link_create_or_update(
|
||||||
if data.webhook_body:
|
if data.webhook_body:
|
||||||
try:
|
try:
|
||||||
json.loads(data.webhook_body)
|
json.loads(data.webhook_body)
|
||||||
except Exception as exc:
|
except:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="`webhook_body` can not parse JSON.",
|
detail="`webhook_body` can not parse JSON.",
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
) from exc
|
)
|
||||||
|
|
||||||
if data.webhook_headers:
|
if data.webhook_headers:
|
||||||
try:
|
try:
|
||||||
json.loads(data.webhook_headers)
|
json.loads(data.webhook_headers)
|
||||||
except Exception as exc:
|
except:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="`webhook_headers` can not parse JSON.",
|
detail="`webhook_headers` can not parse JSON.",
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
) from exc
|
)
|
||||||
|
|
||||||
if link_id:
|
if link_id:
|
||||||
link = await get_withdraw_link(link_id, 0)
|
link = await get_withdraw_link(link_id, 0)
|
||||||
|
|
@ -126,57 +109,39 @@ async def api_link_create_or_update(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
)
|
)
|
||||||
if link.wallet != key_info.wallet.id:
|
if link.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
|
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data_dict = data.dict()
|
||||||
if link.uses > data.uses:
|
if link.uses > data.uses:
|
||||||
if data.uses - link.used <= 0:
|
if data.uses - link.used <= 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Cannot reduce uses below current used.",
|
detail="Cannot reduce uses below current used.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
)
|
)
|
||||||
numbers = link.usescsv.split(",")
|
numbers = link.usescsv.split(",")
|
||||||
link.usescsv = ",".join(numbers[: data.uses - link.used])
|
usescsv = ",".join(numbers[:data.uses - link.used])
|
||||||
|
data_dict["usescsv"] = usescsv
|
||||||
|
|
||||||
if link.uses < data.uses:
|
if link.uses < data.uses:
|
||||||
numbers = link.usescsv.split(",")
|
numbers = link.usescsv.split(",")
|
||||||
if numbers[-1] == "":
|
current_number = int(numbers[-1])
|
||||||
current_number = int(link.uses)
|
|
||||||
numbers[-1] = str(link.uses)
|
|
||||||
else:
|
|
||||||
current_number = int(numbers[-1])
|
|
||||||
while len(numbers) < (data.uses - link.used):
|
while len(numbers) < (data.uses - link.used):
|
||||||
current_number += 1
|
current_number += 1
|
||||||
numbers.append(str(current_number))
|
numbers.append(str(current_number))
|
||||||
link.usescsv = ",".join(numbers)
|
usescsv = ",".join(numbers)
|
||||||
|
data_dict["usescsv"] = usescsv
|
||||||
for k, v in data.dict().items():
|
|
||||||
if v is not None:
|
link = await update_withdraw_link(link_id, **data_dict)
|
||||||
setattr(link, k, v)
|
|
||||||
|
|
||||||
link = await update_withdraw_link(link)
|
|
||||||
else:
|
else:
|
||||||
link = await create_withdraw_link(wallet_id=key_info.wallet.id, data=data)
|
link = await create_withdraw_link(wallet_id=wallet.wallet.id, data=data)
|
||||||
try:
|
assert link
|
||||||
lnurl = create_lnurl(link, request)
|
return {**link.dict(), **{"lnurl": link.lnurl(req)}}
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
link.lnurl = str(lnurl.bech32)
|
|
||||||
link.lnurl_url = str(lnurl.url)
|
|
||||||
|
|
||||||
return link
|
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_api.delete("/links/{link_id}")
|
@withdraw_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
async def api_link_delete(
|
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||||
link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
|
||||||
) -> SimpleStatus:
|
|
||||||
link = await get_withdraw_link(link_id)
|
link = await get_withdraw_link(link_id)
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
|
|
@ -184,20 +149,20 @@ async def api_link_delete(
|
||||||
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
if link.wallet != key_info.wallet.id:
|
if link.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
|
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
await delete_withdraw_link(link_id)
|
await delete_withdraw_link(link_id)
|
||||||
return SimpleStatus(success=True, message="Withdraw link deleted.")
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_api.get(
|
@withdraw_ext.get(
|
||||||
"/links/{the_hash}/{lnurl_id}",
|
"/api/v1/links/{the_hash}/{lnurl_id}",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
dependencies=[Depends(require_invoice_key)],
|
dependencies=[Depends(get_key_type)],
|
||||||
)
|
)
|
||||||
async def api_hash_retrieve(the_hash, lnurl_id) -> HashCheck:
|
async def api_hash_retrieve(the_hash, lnurl_id):
|
||||||
hash_check = await get_hash_check(the_hash, lnurl_id)
|
hashCheck = await get_hash_check(the_hash, lnurl_id)
|
||||||
return hash_check
|
return hashCheck
|
||||||
|
|
|
||||||
240
views_lnurl.py
240
views_lnurl.py
|
|
@ -1,240 +0,0 @@
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import shortuuid
|
|
||||||
from bolt11 import decode as decode_bolt11
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from lnbits.core.crud import update_payment
|
|
||||||
from lnbits.core.models import Payment
|
|
||||||
from lnbits.core.services import pay_invoice
|
|
||||||
from lnurl import (
|
|
||||||
CallbackUrl,
|
|
||||||
LnurlErrorResponse,
|
|
||||||
LnurlSuccessResponse,
|
|
||||||
LnurlWithdrawResponse,
|
|
||||||
MilliSatoshi,
|
|
||||||
)
|
|
||||||
from loguru import logger
|
|
||||||
from pydantic import parse_obj_as
|
|
||||||
|
|
||||||
from .crud import (
|
|
||||||
create_hash_check,
|
|
||||||
delete_hash_check,
|
|
||||||
get_withdraw_link_by_hash,
|
|
||||||
increment_withdraw_link,
|
|
||||||
remove_unique_withdraw_link,
|
|
||||||
)
|
|
||||||
from .models import WithdrawLink
|
|
||||||
|
|
||||||
withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
|
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_lnurl.get(
|
|
||||||
"/{unique_hash}",
|
|
||||||
response_class=JSONResponse,
|
|
||||||
name="withdraw.api_lnurl_response",
|
|
||||||
)
|
|
||||||
async def api_lnurl_response(
|
|
||||||
request: Request, unique_hash: str
|
|
||||||
) -> LnurlWithdrawResponse | LnurlErrorResponse:
|
|
||||||
link = await get_withdraw_link_by_hash(unique_hash)
|
|
||||||
|
|
||||||
if not link:
|
|
||||||
return LnurlErrorResponse(reason="Withdraw link does not exist.")
|
|
||||||
|
|
||||||
if not link.enabled:
|
|
||||||
return LnurlErrorResponse(reason="Withdraw link is disabled.")
|
|
||||||
|
|
||||||
if link.is_spent:
|
|
||||||
return LnurlErrorResponse(reason="Withdraw is spent.")
|
|
||||||
|
|
||||||
if link.is_unique:
|
|
||||||
return LnurlErrorResponse(reason="This link requires an id_unique_hash.")
|
|
||||||
|
|
||||||
url = str(
|
|
||||||
request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
|
||||||
)
|
|
||||||
|
|
||||||
callback_url = parse_obj_as(CallbackUrl, url)
|
|
||||||
return LnurlWithdrawResponse(
|
|
||||||
callback=callback_url,
|
|
||||||
k1=link.k1,
|
|
||||||
minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000),
|
|
||||||
maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000),
|
|
||||||
defaultDescription=link.title,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_lnurl.get(
|
|
||||||
"/cb/{unique_hash}",
|
|
||||||
name="withdraw.api_lnurl_callback",
|
|
||||||
summary="lnurl withdraw callback",
|
|
||||||
description="""
|
|
||||||
This endpoints allows you to put unique_hash, k1
|
|
||||||
and a payment_request to get your payment_request paid.
|
|
||||||
""",
|
|
||||||
response_class=JSONResponse,
|
|
||||||
response_description="JSON with status",
|
|
||||||
responses={
|
|
||||||
200: {"description": "status: OK"},
|
|
||||||
400: {"description": "k1 is wrong or link open time or withdraw not working."},
|
|
||||||
404: {"description": "withdraw link not found."},
|
|
||||||
405: {"description": "withdraw link is spent."},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def api_lnurl_callback(
|
|
||||||
unique_hash: str,
|
|
||||||
k1: str,
|
|
||||||
pr: str,
|
|
||||||
id_unique_hash: str | None = None,
|
|
||||||
) -> LnurlErrorResponse | LnurlSuccessResponse:
|
|
||||||
link = await get_withdraw_link_by_hash(unique_hash)
|
|
||||||
if not link:
|
|
||||||
return LnurlErrorResponse(reason="withdraw link not found.")
|
|
||||||
|
|
||||||
if not link.enabled:
|
|
||||||
return LnurlErrorResponse(reason="Withdraw link is disabled.")
|
|
||||||
|
|
||||||
bolt11 = decode_bolt11(pr)
|
|
||||||
if not bolt11.amount_msat:
|
|
||||||
return LnurlErrorResponse(reason="0 amount invoices are not supported.")
|
|
||||||
|
|
||||||
if (
|
|
||||||
link.min_withdrawable * 1000 > bolt11.amount_msat
|
|
||||||
or bolt11.amount_msat > link.max_withdrawable * 1000
|
|
||||||
):
|
|
||||||
return LnurlErrorResponse(reason="Amount not within limits.")
|
|
||||||
|
|
||||||
if link.is_spent:
|
|
||||||
return LnurlErrorResponse(reason="withdraw is spent.")
|
|
||||||
|
|
||||||
if link.k1 != k1:
|
|
||||||
return LnurlErrorResponse(reason="k1 is wrong.")
|
|
||||||
|
|
||||||
now = int(datetime.now().timestamp())
|
|
||||||
|
|
||||||
if now < link.open_time + link.wait_time:
|
|
||||||
return LnurlErrorResponse(
|
|
||||||
reason=f"Wait {link.open_time + link.wait_time - now} seconds."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not id_unique_hash and link.is_unique:
|
|
||||||
return LnurlErrorResponse(reason="id_unique_hash is required for this link.")
|
|
||||||
|
|
||||||
if id_unique_hash:
|
|
||||||
if check_unique_link(link, id_unique_hash):
|
|
||||||
await remove_unique_withdraw_link(link, id_unique_hash)
|
|
||||||
else:
|
|
||||||
return LnurlErrorResponse(reason="id_unique_hash not found.")
|
|
||||||
|
|
||||||
# Create a record with the id_unique_hash or unique_hash, if it already exists,
|
|
||||||
# raise an exception thus preventing the same LNURL from being processed twice.
|
|
||||||
try:
|
|
||||||
await create_hash_check(id_unique_hash or unique_hash, k1)
|
|
||||||
except Exception:
|
|
||||||
return LnurlErrorResponse(reason="LNURL already being processed.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
payment = await pay_invoice(
|
|
||||||
wallet_id=link.wallet,
|
|
||||||
payment_request=pr,
|
|
||||||
max_sat=link.max_withdrawable,
|
|
||||||
# Merge the link's caller-supplied `extra` onto the payout so an
|
|
||||||
# external listener can key on it (e.g. bitSpire cash-in
|
|
||||||
# settlements via spirekeeper). The withdraw extension's own
|
|
||||||
# `tag`/`withdrawal_link_id` are written last so a caller cannot
|
|
||||||
# clobber them.
|
|
||||||
extra={
|
|
||||||
**(link.extra or {}),
|
|
||||||
"tag": "withdraw",
|
|
||||||
"withdrawal_link_id": link.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await increment_withdraw_link(link)
|
|
||||||
# If the payment succeeds, delete the record with the unique_hash.
|
|
||||||
# TODO: we delete this now: "If it has unique_hash, do not delete to prevent
|
|
||||||
# the same LNURL from being processed twice."
|
|
||||||
await delete_hash_check(id_unique_hash or unique_hash)
|
|
||||||
|
|
||||||
if link.webhook_url:
|
|
||||||
await dispatch_webhook(link, payment, pr)
|
|
||||||
return LnurlSuccessResponse()
|
|
||||||
except Exception as exc:
|
|
||||||
# If payment fails, delete the hash stored so another attempt can be made.
|
|
||||||
await delete_hash_check(id_unique_hash or unique_hash)
|
|
||||||
return LnurlErrorResponse(reason=f"withdraw not working. {exc!s}")
|
|
||||||
|
|
||||||
|
|
||||||
def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
|
|
||||||
return any(
|
|
||||||
unique_hash == shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
|
|
||||||
for x in link.usescsv.split(",")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def dispatch_webhook(
|
|
||||||
link: WithdrawLink, payment: Payment, payment_request: str
|
|
||||||
) -> None:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
try:
|
|
||||||
r: httpx.Response = await client.post(
|
|
||||||
link.webhook_url,
|
|
||||||
json={
|
|
||||||
"payment_hash": payment.payment_hash,
|
|
||||||
"payment_request": payment_request,
|
|
||||||
"lnurlw": link.id,
|
|
||||||
"body": json.loads(link.webhook_body) if link.webhook_body else "",
|
|
||||||
},
|
|
||||||
headers=(
|
|
||||||
json.loads(link.webhook_headers) if link.webhook_headers else None
|
|
||||||
),
|
|
||||||
timeout=40,
|
|
||||||
)
|
|
||||||
payment.extra["wh_success"] = r.is_success
|
|
||||||
payment.extra["wh_message"] = r.reason_phrase
|
|
||||||
payment.extra["wh_response"] = r.text
|
|
||||||
await update_payment(payment)
|
|
||||||
except Exception as exc:
|
|
||||||
# webhook fails shouldn't cause the lnurlw to fail
|
|
||||||
# since invoice is already paid
|
|
||||||
logger.error(f"Caught exception when dispatching webhook url: {exc!s}")
|
|
||||||
payment.extra["wh_success"] = False
|
|
||||||
payment.extra["wh_message"] = str(exc)
|
|
||||||
await update_payment(payment)
|
|
||||||
|
|
||||||
|
|
||||||
# FOR LNURLs WHICH ARE UNIQUE
|
|
||||||
@withdraw_ext_lnurl.get(
|
|
||||||
"/{unique_hash}/{id_unique_hash}",
|
|
||||||
response_class=JSONResponse,
|
|
||||||
name="withdraw.api_lnurl_multi_response",
|
|
||||||
)
|
|
||||||
async def api_lnurl_multi_response(
|
|
||||||
request: Request, unique_hash: str, id_unique_hash: str
|
|
||||||
) -> LnurlWithdrawResponse | LnurlErrorResponse:
|
|
||||||
link = await get_withdraw_link_by_hash(unique_hash)
|
|
||||||
|
|
||||||
if not link:
|
|
||||||
return LnurlErrorResponse(reason="Withdraw link does not exist.")
|
|
||||||
|
|
||||||
if not link.enabled:
|
|
||||||
return LnurlErrorResponse(reason="Withdraw link is disabled.")
|
|
||||||
|
|
||||||
if link.is_spent:
|
|
||||||
return LnurlErrorResponse(reason="Withdraw is spent.")
|
|
||||||
|
|
||||||
if not check_unique_link(link, id_unique_hash):
|
|
||||||
return LnurlErrorResponse(reason="id_unique_hash not found for this link.")
|
|
||||||
|
|
||||||
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
|
||||||
|
|
||||||
callback_url = parse_obj_as(CallbackUrl, f"{url!s}?id_unique_hash={id_unique_hash}")
|
|
||||||
return LnurlWithdrawResponse(
|
|
||||||
callback=callback_url,
|
|
||||||
k1=link.k1,
|
|
||||||
minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000),
|
|
||||||
maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000),
|
|
||||||
defaultDescription=link.title,
|
|
||||||
)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue