Compare commits

..

No commits in common. "main" and "v0.1.12" have entirely different histories.

23 changed files with 3126 additions and 3236 deletions

View file

@ -7,7 +7,7 @@ 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 +19,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

View file

@ -5,27 +5,27 @@ format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier check: mypy pyright checkblack checkruff checkprettier
prettier: prettier:
uv run ./node_modules/.bin/prettier --write . poetry run ./node_modules/.bin/prettier --write .
pyright: pyright:
uv run ./node_modules/.bin/pyright poetry run ./node_modules/.bin/pyright
mypy: mypy:
uv run mypy . poetry run mypy .
black: black:
uv run black . poetry run black .
ruff: ruff:
uv run ruff check . --fix poetry run ruff check . --fix
checkruff: checkruff:
uv run ruff check . poetry run ruff check .
checkprettier: checkprettier:
uv run ./node_modules/.bin/prettier --check . poetry run ./node_modules/.bin/prettier --check .
checkblack: checkblack:
uv run black --check . poetry run black --check .
checkeditorconfig: checkeditorconfig:
editorconfig-checker editorconfig-checker
@ -33,14 +33,14 @@ checkeditorconfig:
test: test:
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
uv run pytest poetry run pytest
install-pre-commit-hook: install-pre-commit-hook:
@echo "Installing pre-commit hook to git" @echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with uv run pre-commit uninstall" @echo "Uninstall the hook with poetry run pre-commit uninstall"
uv run pre-commit install poetry run pre-commit install
pre-commit: pre-commit:
uv run pre-commit run --all-files poetry run pre-commit run --all-files
checkbundle: checkbundle:

View file

@ -17,51 +17,4 @@ withdraw_ext.include_router(withdraw_ext_generic)
withdraw_ext.include_router(withdraw_ext_api) withdraw_ext.include_router(withdraw_ext_api)
withdraw_ext.include_router(withdraw_ext_lnurl) withdraw_ext.include_router(withdraw_ext_lnurl)
__all__ = ["withdraw_ext", "withdraw_static_files", "db"]
def withdraw_start() -> None:
"""
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"]

View file

@ -2,8 +2,7 @@
"name": "Withdraw Links", "name": "Withdraw Links",
"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", "min_lnbits_version": "0.12.11",
"min_lnbits_version": "1.3.0",
"contributors": [ "contributors": [
{ {
"name": "arcbtc", "name": "arcbtc",

222
crud.py
View file

@ -1,10 +1,12 @@
from datetime import datetime from datetime import datetime
from time import time
from typing import List, Optional, Tuple
import shortuuid import shortuuid
from lnbits.db import Database 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 .models import CreateWithdrawData, HashCheck, WithdrawLink
db = Database("ext_withdraw") db = Database("ext_withdraw")
@ -14,87 +16,106 @@ 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, created_at
) )
await db.insert("withdraw.withdraw_link", withdraw_link) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
return withdraw_link """,
(
link_id,
async def get_withdraw_link(link_id: str, num=0) -> WithdrawLink | None: wallet_id,
link = await db.fetchone( data.title,
"SELECT * FROM withdraw.withdraw_link WHERE id = :id", data.min_withdrawable,
{"id": link_id}, data.max_withdrawable,
WithdrawLink, 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,
int(time()),
),
) )
if not link: link = await get_withdraw_link(link_id, 0)
return None assert link, "Newly created link couldn't be retrieved"
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_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]:
row = await db.fetchone(
"SELECT * FROM withdraw.withdraw_link WHERE unique_hash = ?", (unique_hash,)
)
if not row:
return None
link = dict(**row)
link["number"] = num
return WithdrawLink.parse_obj(link)
async def get_withdraw_links( async def get_withdraw_links(
wallet_ids: list[str], limit: int, offset: int wallet_ids: List[str], limit: int, offset: int
) -> PaginatedWithdraws: ) -> Tuple[List[WithdrawLink], int]:
q = ",".join([f"'{w}'" for w in wallet_ids]) rows = await db.fetchall(
"""
query_str = f""" SELECT * FROM withdraw.withdraw_link
SELECT * FROM withdraw.withdraw_link WHERE wallet IN ({q}) WHERE wallet IN ({})
ORDER BY open_time DESC ORDER BY open_time DESC
""" LIMIT ? OFFSET ?
""".format(
if limit > 0: ",".join("?" * len(wallet_ids))
query_str += """ LIMIT :limit OFFSET :offset""" ),
query_params = {"limit": limit, "offset": offset} (*wallet_ids, limit, offset),
else:
query_params = {}
links = await db.fetchall(
query_str,
query_params,
WithdrawLink,
) )
result = await db.execute(
f""" total = await db.fetchone(
"""
SELECT COUNT(*) as total FROM withdraw.withdraw_link SELECT COUNT(*) as total FROM withdraw.withdraw_link
WHERE wallet IN ({q}) WHERE wallet IN ({})
""" """.format(
",".join("?" * len(wallet_ids))
),
(*wallet_ids,),
) )
result2 = result.mappings().first()
return PaginatedWithdraws(data=links, total=int(result2.total)) return [WithdrawLink(**row) for row in rows], total["total"]
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 +124,36 @@ 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: WithdrawLink) -> WithdrawLink: async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]:
await db.update("withdraw.withdraw_link", link) if "is_unique" in kwargs:
return link kwargs["is_unique"] = int(kwargs["is_unique"])
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,38 +164,30 @@ 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) hash_check = await get_hash_check(the_hash, lnurl_id)
return hash_check return hash_check
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:
@ -171,6 +195,4 @@ async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
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}
)

View file

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

View file

@ -1,3 +1,6 @@
from time import time
async def m001_initial(db): async def m001_initial(db):
""" """
Creates an improved withdraw table and migrates the existing data. Creates an improved withdraw table and migrates the existing data.
@ -139,9 +142,10 @@ async def m007_add_created_at_timestamp(db):
"ALTER TABLE withdraw.withdraw_link " "ALTER TABLE withdraw.withdraw_link "
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}" f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
) )
# Set created_at to current time for all existing rows
async def m008_add_enabled_column(db):
await db.execute( await db.execute(
"ALTER TABLE withdraw.withdraw_link ADD COLUMN enabled BOOLEAN DEFAULT true;" f"""
UPDATE withdraw.withdraw_link SET created_at = {db.timestamp_placeholder}
""",
(int(time()),),
) )

View file

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

View file

@ -1,7 +1,11 @@
from datetime import datetime import datetime
from fastapi import Query import shortuuid
from pydantic import BaseModel, Field from fastapi import Query, Request
from lnurl import Lnurl, LnurlWithdrawResponse
from lnurl import encode as lnurl_encode
from lnurl.models import ClearnetUrl, MilliSatoshi
from pydantic import BaseModel
class CreateWithdrawData(BaseModel): class CreateWithdrawData(BaseModel):
@ -15,17 +19,11 @@ 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):
id: str id: str
created_at: datetime.datetime
wallet: str = Query(None) wallet: str = Query(None)
title: str = Query(None) title: str = Query(None)
min_withdrawable: int = Query(0) min_withdrawable: int = Query(0)
@ -38,43 +36,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 = req.url_for("withdraw.api_lnurl_callback", unique_hash=self.unique_hash)
return LnurlWithdrawResponse(
callback=ClearnetUrl(url, scheme="https"), # type: ignore
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

2494
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,34 +1,37 @@
[project] [tool.poetry]
name = "lnbits-withdraw" name = "lnbits-withdraw"
version = "0.0.0" version = "0.0.0"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] authors = ["Alan Bits <alan@lnbits.com>"]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/bitcoinswitch_extension" }
dependencies = [ "lnbits>1" ]
[tool.poetry] [tool.poetry.dependencies]
package-mode = false python = "^3.10 | ^3.9"
lnbits = "*"
[tool.uv] [tool.poetry.group.dev.dependencies]
dev-dependencies = [ black = "^24.3.0"
"black", pytest-asyncio = "^0.21.0"
"pytest-asyncio", pytest = "^7.3.2"
"pytest", mypy = "^1.5.1"
"mypy", pre-commit = "^3.2.2"
"pre-commit", ruff = "^0.3.2"
"ruff",
"pytest-md", [build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[[tool.mypy.overrides]]
module = [
"lnbits.*",
"lnurl.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"pyqrcode.*",
"shortuuid.*",
"httpx.*",
] ]
ignore_missing_imports = "True"
[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] [tool.pytest.ini_options]
log_cli = false log_cli = false

View file

@ -1,20 +1,34 @@
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.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: 'title', align: 'left', label: 'Title', field: 'title'}, {name: 'title', align: 'left', label: 'Title', field: 'title'},
@ -24,7 +38,7 @@ window.app = Vue.createApp({
label: 'Created At', label: 'Created At',
field: 'created_at', field: 'created_at',
sortable: true, sortable: true,
format: function (val) { format: function (val, row) {
return new Date(val).toLocaleString() return new Date(val).toLocaleString()
} }
}, },
@ -37,7 +51,7 @@ window.app = Vue.createApp({
{ {
name: 'uses', name: 'uses',
align: 'right', align: 'right',
label: 'Uses', label: 'Created',
field: 'uses' field: 'uses'
}, },
{ {
@ -46,13 +60,8 @@ 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, page: 1,
@ -68,8 +77,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 +87,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,14 +97,14 @@ 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 (props) {
if (props) { if (props) {
this.withdrawLinksTable.pagination = props.pagination this.withdrawLinksTable.pagination = props.pagination
} }
@ -108,6 +115,8 @@ window.app = Vue.createApp({
offset: (pagination.page - 1) * pagination.rowsPerPage offset: (pagination.page - 1) * pagination.rowsPerPage
} }
var self = this
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
@ -115,46 +124,48 @@ window.app = Vue.createApp({
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(response => { .then(response => {
this.withdrawLinks = response.data.data.map(mapWithdrawLink) this.withdrawLinks = response.data.data.map(function (obj) {
return mapWithdrawLink(obj)
})
this.withdrawLinksTable.pagination.rowsNumber = response.data.total this.withdrawLinksTable.pagination.rowsNumber = response.data.total
}) })
.catch(error => { .catch(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 +189,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 +214,7 @@ window.app = Vue.createApp({
this.createWithdrawLink(wallet, data) this.createWithdrawLink(wallet, data)
} }
}, },
updateWithdrawLink(wallet, data) { updateWithdrawLink: function (wallet, data) {
// 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
@ -230,7 +241,7 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
createWithdrawLink(wallet, data) { createWithdrawLink: function (wallet, data) {
LNbits.api LNbits.api
.request('POST', '/withdraw/api/v1/links', wallet.adminkey, data) .request('POST', '/withdraw/api/v1/links', wallet.adminkey, data)
.then(response => { .then(response => {
@ -243,20 +254,21 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
deleteWithdrawLink(linkId) { deleteWithdrawLink: function (linkId) {
const link = _.findWhere(this.withdrawLinks, {id: 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 +277,7 @@ window.app = Vue.createApp({
}) })
}) })
}, },
async writeNfcTag(lnurl) { writeNfcTag: async function (lnurl) {
try { try {
if (typeof NDEFReader == 'undefined') { if (typeof NDEFReader == 'undefined') {
throw { throw {
@ -309,7 +321,7 @@ window.app = Vue.createApp({
) )
} }
}, },
created() { created: function () {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getWithdrawLinks() this.getWithdrawLinks()
this.checker = setInterval(this.getWithdrawLinks, 300000) this.checker = setInterval(this.getWithdrawLinks, 300000)

View file

@ -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/&lt;withdraw_id&gt; -H "X-Api-Key: }}withdraw/api/v1/links/&lt;withdraw_id&gt; -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": &lt;integer&gt;, "uses": &lt;integer&gt;, "max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;, "wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;,
"webhook_url": &lt;string&gt;}' -H "Content-type: application/json" -H "webhook_url": &lt;string&gt;}' -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 @@
&lt;string&gt;, "min_withdrawable": &lt;integer&gt;, &lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;, "max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -H "wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -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/&lt;withdraw_id&gt; -H "X-Api-Key: }}withdraw/api/v1/links/&lt;withdraw_id&gt; -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/&lt;the_hash&gt;/&lt;lnurl_id&gt; -H }}withdraw/api/v1/links/&lt;the_hash&gt;/&lt;lnurl_id&gt; -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>

View 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 %}

View file

@ -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 %}
<a class="text-secondary" href="lightning:{{ lnurl }}">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
:value="this.here + '/?lightning={{lnurl }}'"
:options="{width: 800}"
class="rounded-borders"
> >
<q-badge v-if="spent" color="red" class="q-mb-md" </qrcode>
>Withdraw is spent.</q-badge </q-responsive>
>
<q-badge v-else-if="!enabled" color="grey" class="q-mb-md"
>Withdraw is disabled.</q-badge
>
<a v-else class="text-secondary" :href="link">
<lnbits-qrcode-lnurl
prefix="lnurlw"
: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' }}
} }
} }
}) })

View file

@ -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,68 +28,66 @@
<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" @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 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">
<q-th {{ col.label }}
v-for="col in props.cols" </q-th>
:key="col.name"
:props="props"
v-text="col.label"
></q-th>
<q-th auto-width></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
> >
@ -114,22 +110,17 @@
color="pink" color="pink"
></q-btn> ></q-btn>
</q-td> </q-td>
<q-td <q-td v-for="col in props.cols" :key="col.name" :props="props">
v-for="col in props.cols" {{ col.value }}
:key="col.name"
:props="props"
v-text="col.value"
>
</q-td> </q-td>
<q-td> <q-td>
<q-icon v-if="props.row.webhook_url" size="14px" name="http"> <q-icon v-if="props.row.webhook_url" size="14px" name="http">
<q-tooltip <q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
>Webhook to <span v-text="props.row.webhook_url"></span
></q-tooltip>
</q-icon> </q-icon>
</q-td> </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 +130,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 +243,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 +355,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 +404,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 +469,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 %}

View file

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

View file

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

View file

@ -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 ~301351). 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())

2267
uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
import io
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO
import pyqrcode
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.responses import HTMLResponse, StreamingResponse
from lnbits.core.models import User from lnbits.core.models import User
@ -8,7 +9,6 @@ from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from .crud import chunks, get_withdraw_link from .crud import chunks, get_withdraw_link
from .helpers import create_lnurl
withdraw_ext_generic = APIRouter() withdraw_ext_generic = APIRouter()
@ -20,7 +20,7 @@ def withdraw_renderer():
@withdraw_ext_generic.get("/", response_class=HTMLResponse) @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()}
) )
@ -32,22 +32,39 @@ 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("/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",
}, },
) )
@ -59,11 +76,14 @@ async def print_qr(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."
) )
# 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
@ -74,14 +94,7 @@ async def print_qr(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: 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))
@ -110,37 +123,29 @@ async def csv(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."
) )
# 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 _ 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"
},
) )

View file

@ -1,10 +1,11 @@
import json import json
from http import HTTPStatus from http import HTTPStatus
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request 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 lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from .crud import ( from .crud import (
create_withdraw_link, create_withdraw_link,
@ -14,48 +15,46 @@ 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 = APIRouter(prefix="/api/v1")
@withdraw_ext_api.get("/links", status_code=HTTPStatus.OK) @withdraw_ext_api.get("/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), offset: int = Query(0),
limit: int = Query(0), limit: int = Query(0),
) -> PaginatedWithdraws: ):
wallet_ids = [key_info.wallet.id] wallet_ids = [wallet.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)
for linkk in links.data:
try: try:
lnurl = create_lnurl(linkk, request) links, total = await get_withdraw_links(wallet_ids, limit, offset)
except ValueError as exc: return {
raise HTTPException( "data": [{**link.dict(), **{"lnurl": link.lnurl(req)}} for link in links],
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, "total": total,
detail=str(exc), }
) from exc
linkk.lnurl = str(lnurl.bech32)
linkk.lnurl_url = str(lnurl.url)
return links except LnurlInvalidUrl as exc:
raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED,
detail="""
LNURLs need to be delivered over a publically
accessible `https` domain or Tor.
""",
) from exc
@withdraw_ext_api.get("/links/{link_id}", status_code=HTTPStatus.OK) @withdraw_ext_api.get("/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 +62,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_api.post("/links", status_code=HTTPStatus.CREATED)
@withdraw_ext_api.put("/links/{link_id}") @withdraw_ext_api.put("/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)
@ -126,11 +115,12 @@ 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(
@ -138,45 +128,33 @@ async def api_link_create_or_update(
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] == "": if numbers[-1] == "":
current_number = int(link.uses) current_number = int(link.uses)
numbers[-1] = str(link.uses) numbers[-1] = str(link.uses)
else: else:
current_number = int(numbers[-1]) 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(): link = await update_withdraw_link(link_id, **data_dict)
if v is not None:
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_api.delete("/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 +162,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_api.get(
"/links/{the_hash}/{lnurl_id}", "/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) hash_check = await get_hash_check(the_hash, lnurl_id)
return hash_check return hash_check

View file

@ -1,23 +1,17 @@
import json import json
from datetime import datetime from datetime import datetime
from http import HTTPStatus
from typing import Callable, Optional
from urllib.parse import urlparse
import httpx import httpx
import shortuuid import shortuuid
from bolt11 import decode as decode_bolt11 from fastapi import APIRouter, HTTPException, Request, Response
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from lnbits.core.crud import update_payment from fastapi.routing import APIRoute
from lnbits.core.models import Payment from lnbits.core.crud import update_payment_extra
from lnbits.core.services import pay_invoice from lnbits.core.services import pay_invoice
from lnurl import (
CallbackUrl,
LnurlErrorResponse,
LnurlSuccessResponse,
LnurlWithdrawResponse,
MilliSatoshi,
)
from loguru import logger from loguru import logger
from pydantic import parse_obj_as
from .crud import ( from .crud import (
create_hash_check, create_hash_check,
@ -28,7 +22,28 @@ from .crud import (
) )
from .models import WithdrawLink from .models import WithdrawLink
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)
return response
except HTTPException as exc:
logger.debug(f"HTTPException: {exc}")
response = JSONResponse(
status_code=200,
content={"status": "ERROR", "reason": f"{exc.detail}"},
)
return response
return custom_route_handler
withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl") withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
withdraw_ext_lnurl.route_class = LNURLErrorResponseHandler
@withdraw_ext_lnurl.get( @withdraw_ext_lnurl.get(
@ -36,35 +51,45 @@ withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
response_class=JSONResponse, response_class=JSONResponse,
name="withdraw.api_lnurl_response", name="withdraw.api_lnurl_response",
) )
async def api_lnurl_response( async def api_lnurl_response(request: Request, unique_hash: str):
request: Request, unique_hash: str
) -> LnurlWithdrawResponse | LnurlErrorResponse:
link = await get_withdraw_link_by_hash(unique_hash) link = await get_withdraw_link_by_hash(unique_hash)
if not link: if not link:
return LnurlErrorResponse(reason="Withdraw link does not exist.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
if not link.enabled: )
return LnurlErrorResponse(reason="Withdraw link is disabled.")
if link.is_spent: if link.is_spent:
return LnurlErrorResponse(reason="Withdraw is spent.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
)
if link.is_unique: if link.is_unique:
return LnurlErrorResponse(reason="This link requires an id_unique_hash.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="This link requires an id_unique_hash.",
)
url = str( url = str(
request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
) )
callback_url = parse_obj_as(CallbackUrl, url) # Check if url is .onion and change to http
return LnurlWithdrawResponse( if urlparse(url).netloc.endswith(".onion"):
callback=callback_url, # change url string scheme to http
k1=link.k1, url = url.replace("https://", "http://")
minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000),
maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000), return {
defaultDescription=link.title, "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_lnurl.get( @withdraw_ext_lnurl.get(
@ -88,69 +113,60 @@ async def api_lnurl_callback(
unique_hash: str, unique_hash: str,
k1: str, k1: str,
pr: str, pr: str,
id_unique_hash: str | None = None, id_unique_hash: Optional[str] = None,
) -> LnurlErrorResponse | LnurlSuccessResponse: ):
link = await get_withdraw_link_by_hash(unique_hash) link = await get_withdraw_link_by_hash(unique_hash)
if not link: if not link:
return LnurlErrorResponse(reason="withdraw link not found.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="withdraw 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: if link.is_spent:
return LnurlErrorResponse(reason="withdraw is spent.") raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="withdraw is spent."
)
if link.k1 != k1: if link.k1 != k1:
return LnurlErrorResponse(reason="k1 is wrong.") raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="k1 is wrong.")
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
if now < link.open_time + link.wait_time: if now < link.open_time:
return LnurlErrorResponse( raise HTTPException(
reason=f"Wait {link.open_time + link.wait_time - now} seconds." status_code=HTTPStatus.BAD_REQUEST,
detail=f"wait link open_time {link.open_time - now} seconds.",
) )
if not id_unique_hash and link.is_unique: if not id_unique_hash and link.is_unique:
return LnurlErrorResponse(reason="id_unique_hash is required for this link.") raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="id_unique_hash is required for this link.",
)
if id_unique_hash: if id_unique_hash:
if check_unique_link(link, id_unique_hash): if check_unique_link(link, id_unique_hash):
await remove_unique_withdraw_link(link, id_unique_hash) await remove_unique_withdraw_link(link, id_unique_hash)
else: else:
return LnurlErrorResponse(reason="id_unique_hash not found.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
)
# Create a record with the id_unique_hash or unique_hash, if it already exists, # 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. # raise an exception thus preventing the same LNURL from being processed twice.
try: try:
await create_hash_check(id_unique_hash or unique_hash, k1) await create_hash_check(id_unique_hash or unique_hash, k1)
except Exception: except Exception as exc:
return LnurlErrorResponse(reason="LNURL already being processed.") raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="LNURL already being processed."
) from exc
try: try:
payment = await pay_invoice( payment_hash = await pay_invoice(
wallet_id=link.wallet, wallet_id=link.wallet,
payment_request=pr, payment_request=pr,
max_sat=link.max_withdrawable, max_sat=link.max_withdrawable,
# Merge the link's caller-supplied `extra` onto the payout so an extra={"tag": "withdraw", "withdrawal_link_id": link.id},
# 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) await increment_withdraw_link(link)
# If the payment succeeds, delete the record with the unique_hash. # If the payment succeeds, delete the record with the unique_hash.
@ -159,12 +175,14 @@ async def api_lnurl_callback(
await delete_hash_check(id_unique_hash or unique_hash) await delete_hash_check(id_unique_hash or unique_hash)
if link.webhook_url: if link.webhook_url:
await dispatch_webhook(link, payment, pr) await dispatch_webhook(link, payment_hash, pr)
return LnurlSuccessResponse() return {"status": "OK"}
except Exception as exc: except Exception as exc:
# If payment fails, delete the hash stored so another attempt can be made. # If payment fails, delete the hash stored so another attempt can be made.
await delete_hash_check(id_unique_hash or unique_hash) await delete_hash_check(id_unique_hash or unique_hash)
return LnurlErrorResponse(reason=f"withdraw not working. {exc!s}") raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=f"withdraw not working. {exc!s}"
) from exc
def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool: def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
@ -175,14 +193,14 @@ def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
async def dispatch_webhook( async def dispatch_webhook(
link: WithdrawLink, payment: Payment, payment_request: str link: WithdrawLink, payment_hash: str, payment_request: str
) -> None: ) -> None:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
r: httpx.Response = await client.post( r: httpx.Response = await client.post(
link.webhook_url, link.webhook_url,
json={ json={
"payment_hash": payment.payment_hash, "payment_hash": payment_hash,
"payment_request": payment_request, "payment_request": payment_request,
"lnurlw": link.id, "lnurlw": link.id,
"body": json.loads(link.webhook_body) if link.webhook_body else "", "body": json.loads(link.webhook_body) if link.webhook_body else "",
@ -192,17 +210,24 @@ async def dispatch_webhook(
), ),
timeout=40, timeout=40,
) )
payment.extra["wh_success"] = r.is_success await update_payment_extra(
payment.extra["wh_message"] = r.reason_phrase payment_hash=payment_hash,
payment.extra["wh_response"] = r.text extra={
await update_payment(payment) "wh_success": r.is_success,
"wh_message": r.reason_phrase,
"wh_response": r.text,
},
outgoing=True,
)
except Exception as exc: except Exception as exc:
# webhook fails shouldn't cause the lnurlw to fail # webhook fails shouldn't cause the lnurlw to fail
# since invoice is already paid # since invoice is already paid
logger.error(f"Caught exception when dispatching webhook url: {exc!s}") logger.error(f"Caught exception when dispatching webhook url: {exc!s}")
payment.extra["wh_success"] = False await update_payment_extra(
payment.extra["wh_message"] = str(exc) payment_hash=payment_hash,
await update_payment(payment) extra={"wh_success": False, "wh_message": str(exc)},
outgoing=True,
)
# FOR LNURLs WHICH ARE UNIQUE # FOR LNURLs WHICH ARE UNIQUE
@ -213,28 +238,38 @@ async def dispatch_webhook(
) )
async def api_lnurl_multi_response( async def api_lnurl_multi_response(
request: Request, unique_hash: str, id_unique_hash: str request: Request, unique_hash: str, id_unique_hash: str
) -> LnurlWithdrawResponse | LnurlErrorResponse: ):
link = await get_withdraw_link_by_hash(unique_hash) link = await get_withdraw_link_by_hash(unique_hash)
if not link: if not link:
return LnurlErrorResponse(reason="Withdraw link does not exist.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
if not link.enabled: )
return LnurlErrorResponse(reason="Withdraw link is disabled.")
if link.is_spent: if link.is_spent:
return LnurlErrorResponse(reason="Withdraw is spent.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
)
if not check_unique_link(link, id_unique_hash): if not check_unique_link(link, id_unique_hash):
return LnurlErrorResponse(reason="id_unique_hash not found for this link.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
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,
) )
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,
}