feat: add linters and ci (#28)

* feat: introduce linting and ci
* add locks
* prettier
* black and sorting
* f405 missing imports
* E902
* mypy
* renderer
* circular imports
* check comment
* add exports
* add lnurlerrorhandler only on lnurl routes
* add test case
This commit is contained in:
dni ⚡ 2024-07-11 10:30:28 +02:00 committed by GitHub
commit a44820f61f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2934 additions and 145 deletions

10
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,10 @@
name: lint
on:
push:
branches:
- main
pull_request:
jobs:
lint:
uses: lnbits/lnbits/.github/workflows/lint.yml@dev

View file

@ -1,10 +1,9 @@
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:
@ -34,12 +33,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
View file

@ -1 +1,4 @@
__pycache__ __pycache__
node_modules
.mypy_cache
.venv

27
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,27 @@
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 Normal file
View file

@ -0,0 +1,12 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"bracketSameLine": false,
"bracketSpacing": false
}

47
Makefile Normal file
View file

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

View file

@ -1,4 +1,5 @@
# 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

View file

@ -1,13 +1,9 @@
from fastapi import APIRouter, Request, Response from fastapi import APIRouter
from fastapi.routing import APIRoute
from fastapi.responses import JSONResponse from .crud import db
from .views import withdraw_ext_generic
from lnbits.db import Database from .views_api import withdraw_ext_api
from lnbits.helpers import template_renderer from .views_lnurl import withdraw_ext_lnurl
from typing import Callable
db = Database("ext_withdraw")
withdraw_static_files = [ withdraw_static_files = [
{ {
@ -16,36 +12,9 @@ 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.route_class = LNURLErrorResponseHandler withdraw_ext.include_router(withdraw_ext_generic)
withdraw_ext.include_router(withdraw_ext_api)
withdraw_ext.include_router(withdraw_ext_lnurl)
__all__ = ["withdraw_ext", "withdraw_static_files", "db"]
def withdraw_renderer():
return template_renderer(["withdraw/templates"])
from .lnurl import * # noqa: F401,F403
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403

17
crud.py
View file

@ -2,12 +2,13 @@ from datetime import datetime
from typing import List, Optional, Union 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 . import db
from .models import CreateWithdrawData, HashCheck, WithdrawLink from .models import CreateWithdrawData, HashCheck, WithdrawLink
db = Database("ext_withdraw")
async def create_withdraw_link( async def create_withdraw_link(
data: CreateWithdrawData, wallet_id: str data: CreateWithdrawData, wallet_id: str
@ -92,7 +93,11 @@ async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[Withdraw
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall( rows = await db.fetchall(
f"SELECT * FROM withdraw.withdraw_link WHERE wallet IN ({q}) ORDER BY open_time DESC", (*wallet_ids,) f"""
SELECT * FROM withdraw.withdraw_link
WHERE wallet IN ({q}) ORDER BY open_time DESC
""",
(*wallet_ids,),
) )
return [WithdrawLink(**row) for row in rows] return [WithdrawLink(**row) for row in rows]
@ -116,6 +121,7 @@ async def increment_withdraw_link(link: WithdrawLink) -> None:
open_time=link.wait_time + int(datetime.now().timestamp()), 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_id: str, **kwargs) -> Optional[WithdrawLink]:
if "is_unique" in kwargs: if "is_unique" in kwargs:
kwargs["is_unique"] = int(kwargs["is_unique"]) kwargs["is_unique"] = int(kwargs["is_unique"])
@ -150,8 +156,8 @@ async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
""", """,
(the_hash, lnurl_id), (the_hash, lnurl_id),
) )
hashCheck = await get_hash_check(the_hash, lnurl_id) hash_check = await get_hash_check(the_hash, lnurl_id)
return hashCheck 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:
@ -171,5 +177,6 @@ async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
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("DELETE FROM withdraw.hash_check WHERE id = ?", (the_hash,)) await db.execute("DELETE FROM withdraw.hash_check WHERE id = ?", (the_hash,))

View file

@ -1,9 +1,9 @@
{ {
"repos": [ "repos": [
{ {
"id": "withdraw", "id": "withdraw",
"organisation": "lnbits", "organisation": "lnbits",
"repository": "withdraw" "repository": "withdraw"
} }
] ]
} }

View file

@ -1,10 +1,9 @@
import shortuuid import shortuuid
from fastapi import Query from fastapi import Query, Request
from lnurl import Lnurl, LnurlWithdrawResponse from lnurl import Lnurl, LnurlWithdrawResponse
from lnurl import encode as lnurl_encode from lnurl import encode as lnurl_encode
from lnurl.models import ClearnetUrl, MilliSatoshi from lnurl.types import ClearnetUrl, MilliSatoshi
from pydantic import BaseModel from pydantic import BaseModel
from starlette.requests import Request
class CreateWithdrawData(BaseModel): class CreateWithdrawData(BaseModel):
@ -49,24 +48,24 @@ class WithdrawLink(BaseModel):
usescssv = self.usescsv.split(",") usescssv = self.usescsv.split(",")
tohash = self.id + self.unique_hash + usescssv[self.number] tohash = self.id + self.unique_hash + usescssv[self.number]
multihash = shortuuid.uuid(name=tohash) multihash = shortuuid.uuid(name=tohash)
url = str(req.url_for( url = str(
"withdraw.api_lnurl_multi_response", req.url_for(
unique_hash=self.unique_hash, "withdraw.api_lnurl_multi_response",
id_unique_hash=multihash, unique_hash=self.unique_hash,
)) id_unique_hash=multihash,
)
)
else: else:
url = str(req.url_for( url = str(
"withdraw.api_lnurl_response", unique_hash=self.unique_hash req.url_for("withdraw.api_lnurl_response", unique_hash=self.unique_hash)
)) )
return lnurl_encode(url) return lnurl_encode(url)
def lnurl_response(self, req: Request) -> LnurlWithdrawResponse: def lnurl_response(self, req: Request) -> LnurlWithdrawResponse:
url = str(req.url_for( url = req.url_for("withdraw.api_lnurl_callback", unique_hash=self.unique_hash)
name="withdraw.api_lnurl_callback", unique_hash=self.unique_hash
))
return LnurlWithdrawResponse( return LnurlWithdrawResponse(
callback=ClearnetUrl(url, scheme="https"), callback=ClearnetUrl(url, scheme="https"), # type: ignore
k1=self.k1, k1=self.k1,
minWithdrawable=MilliSatoshi(self.min_withdrawable * 1000), minWithdrawable=MilliSatoshi(self.min_withdrawable * 1000),
maxWithdrawable=MilliSatoshi(self.max_withdrawable * 1000), maxWithdrawable=MilliSatoshi(self.max_withdrawable * 1000),

59
package-lock.json generated Normal file
View file

@ -0,0 +1,59 @@
{
"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 Normal file
View file

@ -0,0 +1,15 @@
{
"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"
}
}

2494
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

91
pyproject.toml Normal file
View file

@ -0,0 +1,91 @@
[tool.poetry]
name = "lnbits-withdraw"
version = "0.0.0"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
[tool.poetry.dependencies]
python = "^3.10 | ^3.9"
lnbits = "*"
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
[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.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",
]

View file

@ -213,7 +213,7 @@ new Vue({
wallet.adminkey, wallet.adminkey,
data data
) )
.then((response) => { .then(response => {
self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) {
return obj.id === data.id return obj.id === data.id
}) })
@ -230,7 +230,7 @@ new Vue({
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 => {
self.withdrawLinks.push(mapWithdrawLink(response.data)) self.withdrawLinks.push(mapWithdrawLink(response.data))
self.formDialog.show = false self.formDialog.show = false
self.simpleformDialog.show = false self.simpleformDialog.show = false
@ -305,7 +305,7 @@ new Vue({
this.withdrawLinks, this.withdrawLinks,
'withdraw-links' 'withdraw-links'
) )
}, }
}, },
created: function () { created: function () {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {

0
tests/__init__.py Normal file
View file

11
tests/test_init.py Normal file
View file

@ -0,0 +1,11 @@
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)

7
toc.md
View file

@ -1,22 +1,29 @@
# Terms and Conditions for LNbits Extension # Terms and Conditions for LNbits Extension
## 1. Acceptance of Terms ## 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. 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 ## 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. 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 ## 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. 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 ## 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. 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 ## 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. 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 ## 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. 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 ## 7. Contact Information
If you have any questions about these Terms, please contact the developer at [developer's contact information]. If you have any questions about these Terms, please contact the developer at [developer's contact information].

View file

@ -2,27 +2,29 @@ from http import HTTPStatus
from io import BytesIO from io import BytesIO
import pyqrcode import pyqrcode
from fastapi import Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, StreamingResponse
from starlette.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
templates = Jinja2Templates(directory="templates") withdraw_ext_generic = APIRouter()
@withdraw_ext.get("/", response_class=HTMLResponse) def withdraw_renderer():
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.dict()} "withdraw/index.html", {"request": request, "user": user.dict()}
) )
@withdraw_ext.get("/{link_id}", response_class=HTMLResponse) @withdraw_ext_generic.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)
@ -41,7 +43,7 @@ async def display(request: Request, link_id):
) )
@withdraw_ext.get("/img/{link_id}", response_class=StreamingResponse) @withdraw_ext_generic.get("/img/{link_id}", response_class=StreamingResponse)
async def img(request: Request, link_id): async def img(request: Request, link_id):
link = await get_withdraw_link(link_id, 0) link = await get_withdraw_link(link_id, 0)
if not link: if not link:
@ -67,7 +69,7 @@ async def img(request: Request, link_id):
) )
@withdraw_ext.get("/print/{link_id}", response_class=HTMLResponse) @withdraw_ext_generic.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:
@ -86,7 +88,7 @@ async def print_qr(request: Request, link_id):
links = [] links = []
count = 0 count = 0
for x 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(
@ -114,7 +116,7 @@ async def print_qr(request: Request, link_id):
) )
@withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse) @withdraw_ext_generic.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:
@ -133,7 +135,7 @@ async def csv(request: Request, link_id):
links = [] links = []
count = 0 count = 0
for x 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(

View file

@ -1,14 +1,12 @@
import json
from http import HTTPStatus from http import HTTPStatus
from typing import Optional 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.decorators import WalletTypeInfo, get_key_type, require_admin_key from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from . import withdraw_ext
from .crud import ( from .crud import (
create_withdraw_link, create_withdraw_link,
delete_withdraw_link, delete_withdraw_link,
@ -19,8 +17,10 @@ from .crud import (
) )
from .models import CreateWithdrawData from .models import CreateWithdrawData
withdraw_ext_api = APIRouter(prefix="/api/v1")
@withdraw_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
@withdraw_ext_api.get("/links", status_code=HTTPStatus.OK)
async def api_links( async def api_links(
req: Request, req: Request,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type),
@ -38,14 +38,17 @@ async def api_links(
for link in await get_withdraw_links(wallet_ids) for link in await get_withdraw_links(wallet_ids)
] ]
except LnurlInvalidUrl: except LnurlInvalidUrl as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED, status_code=HTTPStatus.UPGRADE_REQUIRED,
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.", detail="""
) LNURLs need to be delivered over a publically
accessible `https` domain or Tor.
""",
) from exc
@withdraw_ext.get("/api/v1/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(
link_id: str, request: Request, wallet: WalletTypeInfo = Depends(get_key_type) link_id: str, request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
): ):
@ -63,8 +66,8 @@ async def api_link_retrieve(
return {**link.dict(), **{"lnurl": link.lnurl(request)}} return {**link.dict(), **{"lnurl": link.lnurl(request)}}
@withdraw_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) @withdraw_ext_api.post("/links", status_code=HTTPStatus.CREATED)
@withdraw_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @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(
req: Request, req: Request,
data: CreateWithdrawData, data: CreateWithdrawData,
@ -88,20 +91,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: except Exception as exc:
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: except Exception as exc:
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)
@ -118,10 +121,11 @@ async def api_link_create_or_update(
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.", status_code=HTTPStatus.BAD_REQUEST detail="Cannot reduce uses below current used.",
status_code=HTTPStatus.BAD_REQUEST,
) )
numbers = link.usescsv.split(",") numbers = link.usescsv.split(",")
usescsv = ",".join(numbers[:data.uses - link.used]) usescsv = ",".join(numbers[: data.uses - link.used])
data_dict["usescsv"] = usescsv data_dict["usescsv"] = usescsv
if link.uses < data.uses: if link.uses < data.uses:
@ -146,7 +150,7 @@ async def api_link_create_or_update(
return {**link.dict(), **{"lnurl": link.lnurl(req)}} return {**link.dict(), **{"lnurl": link.lnurl(req)}}
@withdraw_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @withdraw_ext_api.delete("/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)): async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
link = await get_withdraw_link(link_id) link = await get_withdraw_link(link_id)
@ -164,11 +168,11 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi
return {"success": True} return {"success": True}
@withdraw_ext.get( @withdraw_ext_api.get(
"/api/v1/links/{the_hash}/{lnurl_id}", "/links/{the_hash}/{lnurl_id}",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
dependencies=[Depends(get_key_type)], dependencies=[Depends(get_key_type)],
) )
async def api_hash_retrieve(the_hash, lnurl_id): async def api_hash_retrieve(the_hash, lnurl_id):
hashCheck = await get_hash_check(the_hash, lnurl_id) hash_check = await get_hash_check(the_hash, lnurl_id)
return hashCheck return hash_check

View file

@ -1,30 +1,55 @@
import json import json
from datetime import datetime from datetime import datetime
from http import HTTPStatus from http import HTTPStatus
from typing import Callable
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
import shortuuid import shortuuid
from fastapi import HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from loguru import logger from fastapi.routing import APIRoute
from lnbits.core.crud import update_payment_extra from lnbits.core.crud import update_payment_extra
from lnbits.core.services import pay_invoice from lnbits.core.services import pay_invoice
from loguru import logger
from . import withdraw_ext
from .crud import ( from .crud import (
create_hash_check,
delete_hash_check,
get_withdraw_link_by_hash, get_withdraw_link_by_hash,
increment_withdraw_link, increment_withdraw_link,
remove_unique_withdraw_link, remove_unique_withdraw_link,
delete_hash_check,
create_hash_check
) )
from .models import WithdrawLink from .models import WithdrawLink
@withdraw_ext.get( class LNURLErrorResponseHandler(APIRoute):
"/api/v1/lnurl/{unique_hash}", 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_lnurl = APIRouter(prefix="/api/v1/lnurl")
withdraw_ext_lnurl.route_class = LNURLErrorResponseHandler
@withdraw_ext_lnurl.get(
"/{unique_hash}",
response_class=JSONResponse, response_class=JSONResponse,
name="withdraw.api_lnurl_response", name="withdraw.api_lnurl_response",
) )
@ -40,7 +65,9 @@ async def api_lnurl_response(request: Request, unique_hash: str):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
) )
url = str(request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)) url = str(
request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
)
# Check if url is .onion and change to http # Check if url is .onion and change to http
if urlparse(url).netloc.endswith(".onion"): if urlparse(url).netloc.endswith(".onion"):
@ -60,8 +87,8 @@ async def api_lnurl_response(request: Request, unique_hash: str):
} }
@withdraw_ext.get( @withdraw_ext_lnurl.get(
"/api/v1/lnurl/cb/{unique_hash}", "/cb/{unique_hash}",
name="withdraw.api_lnurl_callback", name="withdraw.api_lnurl_callback",
summary="lnurl withdraw callback", summary="lnurl withdraw callback",
description=""" description="""
@ -106,7 +133,6 @@ async def api_lnurl_callback(
detail=f"wait link open_time {link.open_time - now} seconds.", detail=f"wait link open_time {link.open_time - now} seconds.",
) )
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)
@ -133,7 +159,8 @@ async def api_lnurl_callback(
) )
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.
# If it has unique_hash, do not delete to prevent the same LNURL from being processed twice. # 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) await delete_hash_check(id_unique_hash or unique_hash)
if link.webhook_url: if link.webhook_url:
@ -143,7 +170,7 @@ async def api_lnurl_callback(
# 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)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=f"withdraw not working. {str(exc)}" status_code=HTTPStatus.BAD_REQUEST, detail=f"withdraw not working. {exc!s}"
) from exc ) from exc
@ -167,9 +194,9 @@ async def dispatch_webhook(
"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 "",
}, },
headers=json.loads(link.webhook_headers) headers=(
if link.webhook_headers json.loads(link.webhook_headers) if link.webhook_headers else None
else None, ),
timeout=40, timeout=40,
) )
await update_payment_extra( await update_payment_extra(
@ -182,8 +209,9 @@ async def dispatch_webhook(
outgoing=True, outgoing=True,
) )
except Exception as exc: except Exception as exc:
# webhook fails shouldn't cause the lnurlw to fail since invoice is already paid # webhook fails shouldn't cause the lnurlw to fail
logger.error(f"Caught exception when dispatching webhook url: {str(exc)}") # since invoice is already paid
logger.error(f"Caught exception when dispatching webhook url: {exc!s}")
await update_payment_extra( await update_payment_extra(
payment_hash=payment_hash, payment_hash=payment_hash,
extra={"wh_success": False, "wh_message": str(exc)}, extra={"wh_success": False, "wh_message": str(exc)},
@ -192,12 +220,14 @@ async def dispatch_webhook(
# FOR LNURLs WHICH ARE UNIQUE # FOR LNURLs WHICH ARE UNIQUE
@withdraw_ext.get( @withdraw_ext_lnurl.get(
"/api/v1/lnurl/{unique_hash}/{id_unique_hash}", "/{unique_hash}/{id_unique_hash}",
response_class=JSONResponse, response_class=JSONResponse,
name="withdraw.api_lnurl_multi_response", name="withdraw.api_lnurl_multi_response",
) )
async def api_lnurl_multi_response(request: Request, unique_hash: str, id_unique_hash: str): async def api_lnurl_multi_response(
request: Request, unique_hash: str, id_unique_hash: str
):
link = await get_withdraw_link_by_hash(unique_hash) link = await get_withdraw_link_by_hash(unique_hash)
if not link: if not link:
@ -215,7 +245,9 @@ async def api_lnurl_multi_response(request: Request, unique_hash: str, id_unique
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
) )
url = str(request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)) url = str(
request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
)
# Check if url is .onion and change to http # Check if url is .onion and change to http
if urlparse(url).netloc.endswith(".onion"): if urlparse(url).netloc.endswith(".onion"):