diff --git a/README.md b/README.md index 3bc169dd..0f81b99f 100644 --- a/README.md +++ b/README.md @@ -7,29 +7,29 @@ LNbits ![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png) -# LNbits v0.9 BETA, free and open-source lightning-network wallet/accounts system +# LNbits v0.9 BETA, free and open-source Lightning wallet accounts system (Join us on [https://t.me/lnbits](https://t.me/lnbits)) -(LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me) +LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me Use [legend.lnbits.com](https://legend.lnbits.com), or run your own LNbits server! -LNbits is a very simple Python server that sits on top of any funding source, and can be used as: +LNbits is a Python server that sits on top of any funding source. It can be used as: -* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet -* Extendable platform for exploring lightning-network functionality via LNbits extension framework +* Accounts system to mitigate the risk of exposing applications to your full balance via unique API keys for each wallet +* Extendable platform for exploring Lightning network functionality via the LNbits extension framework * Part of a development stack via LNbits API * Fallback wallet for the LNURL scheme * Instant wallet for LN demonstrations -LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly. +LNbits can run on top of any Lightning funding source. It supports LND, CLN, Eclair, Spark, LNpay, OpenNode, lntxbot, LightningTipBot, and with more being added regularly. See [docs.lnbits.org](https://docs.lnbits.org) for more detailed documentation. Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series. -LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as excellent funding sources for LNbits. +LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as funding sources for LNbits. ## Running LNbits @@ -58,16 +58,15 @@ Example use would be an ATM, which utilises LNURL, if the user scans the QR with ![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg) -## LNbits as an insta-wallet +## LNbits as an instant wallet -Wallets can be easily generated and given out to people at events (one click multi-wallet generation to be added soon). -"Go to this website", has a lot less friction than "Download this app". +Wallets can be easily generated and given out to people at events. "Go to this website", has a lot less friction than "Download this app". ![lnurl ATM](https://i.imgur.com/xFWDnwy.png) ## Tip us -If you like this project and might even use or extend it, why not [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)! +If you like this project [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)! [docs]: https://docs.lnbits.org/ diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 9f8b26da..2bbdfb11 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -206,6 +206,10 @@ poetry add setuptools wheel ./venv/bin/pip install setuptools wheel ``` +#### Poetry + +If your Poetry version is older than 1.2, for `poetry install`, ignore the `--only main` flag. + ### Optional: PostgreSQL database If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and setup a database for LNbits: diff --git a/lnbits/extensions/deezy/README.md b/lnbits/extensions/deezy/README.md new file mode 100644 index 00000000..c8c0678a --- /dev/null +++ b/lnbits/extensions/deezy/README.md @@ -0,0 +1,11 @@ +# Deezy: Home for Lightning Liquidity +Swap lightning bitcoin for on-chain bitcoin to get inbound liquidity. Or get an on-chain deposit address for your lightning address. +* [Website](https://deezy.io) +* [Lightning Node](https://amboss.space/node/024bfaf0cabe7f874fd33ebf7c6f4e5385971fc504ef3f492432e9e3ec77e1b5cf) +* [Documentation](https://docs.deezy.io) +* [Discord](https://discord.gg/nEBbrUAvPy) + +# Usage +This extension lets you swap lightning btc for on-chain btc and vice versa. +* Swap Lightning -> BTC to get inbound liquidity +* Swap BTC -> Lightning to generate an on-chain deposit address for your lightning address \ No newline at end of file diff --git a/lnbits/extensions/deezy/__init__.py b/lnbits/extensions/deezy/__init__.py new file mode 100644 index 00000000..05d1c9a7 --- /dev/null +++ b/lnbits/extensions/deezy/__init__.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter +from starlette.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_deezy") + +deezy_ext: APIRouter = APIRouter(prefix="/deezy", tags=["deezy"]) + +deezy_static_files = [ + { + "path": "/deezy/static", + "app": StaticFiles(directory="lnbits/extensions/deezy/static"), + "name": "deezy_static", + } +] + + +def deezy_renderer(): + return template_renderer(["lnbits/extensions/deezy/templates"]) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/deezy/config.json b/lnbits/extensions/deezy/config.json new file mode 100644 index 00000000..4f945a79 --- /dev/null +++ b/lnbits/extensions/deezy/config.json @@ -0,0 +1,6 @@ +{ + "name": "Deezy", + "short_description": "LN to onchain, onchain to LN swaps", + "tile": "/deezy/static/deezy.png", + "contributors": ["Uthpala"] +} diff --git a/lnbits/extensions/deezy/crud.py b/lnbits/extensions/deezy/crud.py new file mode 100644 index 00000000..75549349 --- /dev/null +++ b/lnbits/extensions/deezy/crud.py @@ -0,0 +1,115 @@ +from http import HTTPStatus +from typing import List, Optional + +from . import db +from .models import BtcToLnSwap, LnToBtcSwap, Token, UpdateLnToBtcSwap + + +async def get_ln_to_btc() -> List[LnToBtcSwap]: + + rows = await db.fetchall( + f"SELECT * FROM deezy.ln_to_btc_swap ORDER BY created_at DESC", + ) + + return [LnToBtcSwap(**row) for row in rows] + + +async def get_btc_to_ln() -> List[BtcToLnSwap]: + + rows = await db.fetchall( + f"SELECT * FROM deezy.btc_to_ln_swap ORDER BY created_at DESC", + ) + + return [BtcToLnSwap(**row) for row in rows] + + +async def get_token() -> Optional[Token]: + + row = await db.fetchone( + f"SELECT * FROM deezy.token ORDER BY created_at DESC", + ) + + return Token(**row) if row else None + + +async def save_token( + data: Token, +) -> Token: + + await db.execute( + """ + INSERT INTO deezy.token ( + deezy_token + ) + VALUES (?) + """, + (data.deezy_token,), + ) + return data + + +async def save_ln_to_btc( + data: LnToBtcSwap, +) -> LnToBtcSwap: + + return await db.execute( + """ + INSERT INTO deezy.ln_to_btc_swap ( + amount_sats, + on_chain_address, + on_chain_sats_per_vbyte, + bolt11_invoice, + fee_sats, + txid, + tx_hex + ) + VALUES (?,?,?,?,?,?,?) + """, + ( + data.amount_sats, + data.on_chain_address, + data.on_chain_sats_per_vbyte, + data.bolt11_invoice, + data.fee_sats, + data.txid, + data.tx_hex, + ), + ) + + +async def update_ln_to_btc(data: UpdateLnToBtcSwap) -> str: + await db.execute( + """ + UPDATE deezy.ln_to_btc_swap + SET txid = ?, tx_hex = ? + WHERE bolt11_invoice = ? + """, + (data.txid, data.tx_hex, data.bolt11_invoice), + ) + + return data.txid + + +async def save_btc_to_ln( + data: BtcToLnSwap, +) -> BtcToLnSwap: + + return await db.execute( + """ + INSERT INTO deezy.btc_to_ln_swap ( + ln_address, + on_chain_address, + secret_access_key, + commitment, + signature + ) + VALUES (?,?,?,?,?) + """, + ( + data.ln_address, + data.on_chain_address, + data.secret_access_key, + data.commitment, + data.signature, + ), + ) diff --git a/lnbits/extensions/deezy/migrations.py b/lnbits/extensions/deezy/migrations.py new file mode 100644 index 00000000..67455d6b --- /dev/null +++ b/lnbits/extensions/deezy/migrations.py @@ -0,0 +1,37 @@ +async def m001_initial(db): + await db.execute( + f""" + CREATE TABLE deezy.ln_to_btc_swap ( + id TEXT PRIMARY KEY, + amount_sats {db.big_int} NOT NULL, + on_chain_address TEXT NOT NULL, + on_chain_sats_per_vbyte INT NOT NULL, + bolt11_invoice TEXT NOT NULL, + fee_sats {db.big_int} NOT NULL, + txid TEXT NULL, + tx_hex TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + await db.execute( + f""" + CREATE TABLE deezy.btc_to_ln_swap ( + id TEXT PRIMARY KEY, + ln_address TEXT NOT NULL, + on_chain_address TEXT NOT NULL, + secret_access_key TEXT NOT NULL, + commitment TEXT NOT NULL, + signature TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + await db.execute( + f""" + CREATE TABLE deezy.token ( + deezy_token TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """ + ) diff --git a/lnbits/extensions/deezy/models.py b/lnbits/extensions/deezy/models.py new file mode 100644 index 00000000..e69db355 --- /dev/null +++ b/lnbits/extensions/deezy/models.py @@ -0,0 +1,34 @@ +from typing import Optional + +from pydantic.main import BaseModel +from sqlalchemy.engine import base # type: ignore + + +class Token(BaseModel): + deezy_token: str + + +class LnToBtcSwap(BaseModel): + amount_sats: int + on_chain_address: str + on_chain_sats_per_vbyte: int + bolt11_invoice: str + fee_sats: int + txid: str = "" + tx_hex: str = "" + created_at: str = "" + + +class UpdateLnToBtcSwap(BaseModel): + txid: str + tx_hex: str + bolt11_invoice: str + + +class BtcToLnSwap(BaseModel): + ln_address: str + on_chain_address: str + secret_access_key: str + commitment: str + signature: str + created_at: str = "" diff --git a/lnbits/extensions/deezy/static/deezy.png b/lnbits/extensions/deezy/static/deezy.png new file mode 100644 index 00000000..cb526705 Binary files /dev/null and b/lnbits/extensions/deezy/static/deezy.png differ diff --git a/lnbits/extensions/deezy/templates/deezy/_api_docs.html b/lnbits/extensions/deezy/templates/deezy/_api_docs.html new file mode 100644 index 00000000..4a4e9e30 --- /dev/null +++ b/lnbits/extensions/deezy/templates/deezy/_api_docs.html @@ -0,0 +1,253 @@ + + + + +
+ Deezy.io: Do onchain to offchain and vice-versa swaps +
+

+ Link : + + https://deezy.io/ + +

+

+ API DOCS +

+

+ Created by, + Uthpala +

+
+
+
+ + + + + +
+ Get the current info about the swap service for converting LN btc to + on-chain BTC. +
+ + GET (mainnet) + https://api.deezy.io/v1/swap/info + +
+ + GET (testnet) + https://api-testnet.deezy.io/v1/swap/info + +
Response
+
+            {
+              "liquidity_fee_ppm": 2000,
+              "on_chain_bytes_estimate": 300,
+              "max_swap_amount_sats": 100000000,
+              "min_swap_amount_sats": 100000,
+              "available": true
+            }
+          
+
+
+
+ + + +
+ Initiate a new swap to send lightning btc in exchange for on-chain + btc +
+ + POST (mainnet) + https://api.deezy.io/v1/swap + +
+ + POST (testnet) + https://api-testnet.deezy.io/v1/swap + +
Payload
+
+            {
+              "amount_sats": 500000,
+              "on_chain_address": "tb1qrcdhlm0m...",
+              "on_chain_sats_per_vbyte": 2
+            }
+          
+
Response
+
+            {
+              "bolt11_invoice": "lntb603u1p3vmxj7p...",
+              "fee_sats": 600
+            }
+          
+
+
+
+ + + +
+ Lookup the on-chain transaction information for an existing swap +
+ + GET (mainnet) + https://api.deezy.io/v1/swap/lookup + +
+ + GET (testnet) + https://api-testnet.deezy.io/v1/swap/lookup + +
Query Parameter
+
+            "bolt11_invoice": "lntb603u1p3vmxj7pp54...",
+          
+
Response
+
+            {
+              "on_chain_txid": "string",
+              "tx_hex": "string"
+            }
+          
+
+
+
+
+ + + + +
+ Generate an on-chain deposit address for your lnurl or lightning + address. +
+ + POST (mainnet) + https://api.deezy.io/v1/source + +
+ + POST (testnet) + https://api-testnet.deezy.io/v1/source + +
Payload
+
+            {
+              "lnurl_or_lnaddress": "LNURL1DP68GURN8GHJ...",
+              "secret_access_key": "b3c6056d2845867fa7..",
+              "webhook_url": "https://your.website.com/dee.."
+            }
+          
+
Response
+
+            {
+              "address": "bc1qkceyc5...",
+              "secret_access_key": "b3c6056d28458...",
+              "commitment": "for any satoshis sent to bc1..",
+              "signature": "d69j6aj1ssz5egmsr..",
+              "webhook_url": "https://your.website.com/deez.."
+            }
+          
+
+
+
+ + + +
+ Lookup (BTC to LN) swaps +
+ + GET (mainnet) + https://api.deezy.io/v1/source/lookup + +
+ + GET (testnet) + https://api-testnet.deezy.io/v1/source/lookup + +
Response
+
+            {
+              "swaps": [
+                {
+                  "lnurl_or_lnaddress": "string",
+                  "deposit_address": "string",
+                  "utxo_key": "string",
+                  "deposit_amount_sats": 0,
+                  "target_payout_amount_sats": 0,
+                  "paid_amount_sats": 0,
+                  "deezy_fee_sats": 0,
+                  "status": "string"
+                }
+              ],
+              "total_sent_sats": 0,
+              "total_received_sats": 0,
+              "total_pending_payout_sats": 0,
+              "total_deezy_fees_sats": 0
+            }
+          
+
+
+
+
+
diff --git a/lnbits/extensions/deezy/templates/deezy/index.html b/lnbits/extensions/deezy/templates/deezy/index.html new file mode 100644 index 00000000..858d3255 --- /dev/null +++ b/lnbits/extensions/deezy/templates/deezy/index.html @@ -0,0 +1,588 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
Deezy
+

+ An access token is required to use the swap service. Email + support@deezy.io or contact @dannydeezy on telegram to get one. +

+
+
+ Deezy token + Add or Update token +
+

+
+ + + + + + + + + + Send lightning btc and receive on-chain btc + + + + + Send on-chain btc and receive via lightning + + + + +
+
LIGHTNING BTC -> BTC
+ + + + + + + Cancel + + + + +
+
Pay invoice to complete swap
+ + + +
+
+ + + + + + + +
+
+
+
+
BTC -> LIGHTNING BTC
+ + + + Cancel + + + + +
+
Onchain Address
+ + + +
+
+ + + + + + + + + +
+
+
+
+
+ {% raw %} + + + +
Success Bitcoin is on its way
+
+ + + Onchain tx id {{ swapLnToBtc.onchainTxId }} + + + + + +
+
+ {% endraw %} +
+
+ + +
{{SITE_TITLE}} Boltz extension
+
+ + + {% include "deezy/_api_docs.html" %} + +
+
+
+ +
+
+ +
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/deezy/views.py b/lnbits/extensions/deezy/views.py new file mode 100644 index 00000000..131c03b2 --- /dev/null +++ b/lnbits/extensions/deezy/views.py @@ -0,0 +1,21 @@ +from fastapi import FastAPI, Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import deezy_ext, deezy_renderer + +templates = Jinja2Templates(directory="templates") + + +@deezy_ext.get("/", response_class=HTMLResponse) +async def index( + request: Request, + user: User = Depends(check_user_exists), # type: ignore +): + return deezy_renderer().TemplateResponse( + "deezy/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/deezy/views_api.py b/lnbits/extensions/deezy/views_api.py new file mode 100644 index 00000000..1006edeb --- /dev/null +++ b/lnbits/extensions/deezy/views_api.py @@ -0,0 +1,65 @@ +# views_api.py is for you API endpoints that could be hit by another service + +# add your dependencies here + +# import httpx +# (use httpx just like requests, except instead of response.ok there's only the +# response.is_error that is its inverse) + +from . import deezy_ext +from .crud import ( + get_btc_to_ln, + get_ln_to_btc, + get_token, + save_btc_to_ln, + save_ln_to_btc, + save_token, + update_ln_to_btc, +) +from .models import BtcToLnSwap, LnToBtcSwap, Token, UpdateLnToBtcSwap + + +@deezy_ext.get("/api/v1/token") +async def api_deezy_get_token(): + rows = await get_token() + return rows + + +@deezy_ext.get("/api/v1/ln-to-btc") +async def api_deezy_get_ln_to_btc(): + rows = await get_ln_to_btc() + return rows + + +@deezy_ext.get("/api/v1/btc-to-ln") +async def api_deezy_get_btc_to_ln(): + rows = await get_btc_to_ln() + return rows + + +@deezy_ext.post("/api/v1/store-token") +async def api_deezy_save_toke(data: Token): + await save_token(data) + + return data.deezy_token + + +@deezy_ext.post("/api/v1/store-ln-to-btc") +async def api_deezy_save_ln_to_btc(data: LnToBtcSwap): + response = await save_ln_to_btc(data) + + return response + + +@deezy_ext.post("/api/v1/update-ln-to-btc") +async def api_deezy_update_ln_to_btc(data: UpdateLnToBtcSwap): + response = await update_ln_to_btc(data) + + return response + + +@deezy_ext.post("/api/v1/store-btc-to-ln") +async def api_deezy_save_btc_to_ln(data: BtcToLnSwap): + response = await save_btc_to_ln(data) + + return response diff --git a/lnbits/extensions/example/templates/example/index.html b/lnbits/extensions/example/templates/example/index.html index 64a4d3dd..03f66b5f 100644 --- a/lnbits/extensions/example/templates/example/index.html +++ b/lnbits/extensions/example/templates/example/index.html @@ -67,7 +67,7 @@ > - + @@ -79,200 +79,189 @@
Frameworks
- - + -
Useful Tools
- - + -
- -
File Structure
+ +
Good Practice
Coming soon...
diff --git a/lnbits/extensions/livestream/lnurl.py b/lnbits/extensions/livestream/lnurl.py index 63523f33..e3e1b1be 100644 --- a/lnbits/extensions/livestream/lnurl.py +++ b/lnbits/extensions/livestream/lnurl.py @@ -1,12 +1,9 @@ -import hashlib import math from http import HTTPStatus -from os import name -from fastapi.exceptions import HTTPException -from fastapi.params import Query +from fastapi import HTTPException, Query, Request from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse -from starlette.requests import Request # type: ignore +from lnurl.models import ClearnetUrl, LightningInvoice, MilliSatoshi from lnbits.core.services import create_invoice @@ -29,9 +26,12 @@ async def lnurl_livestream(ls_id, request: Request): ) resp = LnurlPayResponse( - callback=request.url_for("livestream.lnurl_callback", track_id=track.id), - min_sendable=track.min_sendable, - max_sendable=track.max_sendable, + callback=ClearnetUrl( + request.url_for("livestream.lnurl_callback", track_id=track.id), + scheme="https", + ), + minSendable=MilliSatoshi(track.min_sendable), + maxSendable=MilliSatoshi(track.max_sendable), metadata=await track.lnurlpay_metadata(), ) @@ -48,9 +48,12 @@ async def lnurl_track(track_id, request: Request): raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Track not found.") resp = LnurlPayResponse( - callback=request.url_for("livestream.lnurl_callback", track_id=track.id), - min_sendable=track.min_sendable, - max_sendable=track.max_sendable, + callback=ClearnetUrl( + request.url_for("livestream.lnurl_callback", track_id=track.id), + scheme="https", + ), + minSendable=MilliSatoshi(track.min_sendable), + maxSendable=MilliSatoshi(track.max_sendable), metadata=await track.lnurlpay_metadata(), ) @@ -85,6 +88,7 @@ async def lnurl_callback( ).dict() ls = await get_livestream_by_track(track_id) + assert ls extra_amount = amount_received - int(amount_received * (100 - ls.fee_pct) / 100) @@ -101,13 +105,14 @@ async def lnurl_callback( }, ) + assert track.price_msat if amount_received < track.price_msat: success_action = None else: success_action = track.success_action(payment_hash, request=request) resp = LnurlPayActionResponse( - pr=payment_request, success_action=success_action, routes=[] + pr=LightningInvoice(payment_request), successAction=success_action, routes=[] ) return resp.dict() diff --git a/lnbits/extensions/livestream/models.py b/lnbits/extensions/livestream/models.py index 0034f4a7..5d617da9 100644 --- a/lnbits/extensions/livestream/models.py +++ b/lnbits/extensions/livestream/models.py @@ -1,13 +1,12 @@ import json from typing import Optional -from fastapi import Query +from fastapi import Query, Request from lnurl import Lnurl -from lnurl import encode as lnurl_encode # type: ignore -from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore -from lnurl.types import LnurlPayMetadata # type: ignore +from lnurl import encode as lnurl_encode +from lnurl.models import ClearnetUrl, Max144Str, UrlAction +from lnurl.types import LnurlPayMetadata from pydantic import BaseModel -from starlette.requests import Request class CreateTrack(BaseModel): @@ -32,7 +31,7 @@ class Livestream(BaseModel): class Track(BaseModel): id: int download_url: Optional[str] - price_msat: Optional[int] + price_msat: int = 0 name: str producer: int @@ -71,7 +70,7 @@ class Track(BaseModel): def success_action( self, payment_hash: str, request: Request - ) -> Optional[LnurlPaySuccessAction]: + ) -> Optional[UrlAction]: if not self.download_url: return None @@ -79,7 +78,8 @@ class Track(BaseModel): url_with_query = f"{url}?p={payment_hash}" return UrlAction( - url=url_with_query, description=f"Download the track {self.name}!" + url=ClearnetUrl(url_with_query, scheme="https"), + description=Max144Str(f"Download the track {self.name}!"), ) diff --git a/lnbits/extensions/livestream/views.py b/lnbits/extensions/livestream/views.py index 97f803a3..ca12f16b 100644 --- a/lnbits/extensions/livestream/views.py +++ b/lnbits/extensions/livestream/views.py @@ -1,20 +1,16 @@ from http import HTTPStatus -from fastapi.param_functions import Depends -from fastapi.params import Query -from starlette.exceptions import HTTPException -from starlette.requests import Request +from fastapi import Depends, HTTPException, Query, Request +from starlette.datastructures import URL from starlette.responses import HTMLResponse, RedirectResponse from lnbits.core.crud import get_wallet_payment -from lnbits.core.models import Payment, User +from lnbits.core.models import User from lnbits.decorators import check_user_exists from . import livestream_ext, livestream_renderer from .crud import get_livestream_by_track, get_track -# from mmap import MAP_DENYWRITE - @livestream_ext.get("/", response_class=HTMLResponse) async def index(request: Request, user: User = Depends(check_user_exists)): @@ -28,12 +24,18 @@ async def track_redirect_download(track_id, p: str = Query(...)): payment_hash = p track = await get_track(track_id) ls = await get_livestream_by_track(track_id) - payment: Payment = await get_wallet_payment(ls.wallet, payment_hash) + assert ls + payment = await get_wallet_payment(ls.wallet, payment_hash) if not payment: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, - detail=f"Couldn't find the payment {payment_hash} or track {track.id}.", + detail=f"Couldn't find the payment {payment_hash}.", + ) + if not track: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Couldn't find the track {track_id}.", ) if payment.pending: @@ -41,4 +43,6 @@ async def track_redirect_download(track_id, p: str = Query(...)): status_code=HTTPStatus.PAYMENT_REQUIRED, detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute.", ) - return RedirectResponse(url=track.download_url) + + assert track.download_url + return RedirectResponse(url=URL(track.download_url)) diff --git a/lnbits/extensions/livestream/views_api.py b/lnbits/extensions/livestream/views_api.py index 0c169a71..63a01742 100644 --- a/lnbits/extensions/livestream/views_api.py +++ b/lnbits/extensions/livestream/views_api.py @@ -1,9 +1,7 @@ from http import HTTPStatus -from fastapi.param_functions import Depends +from fastapi import Depends, HTTPException, Request from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl -from starlette.exceptions import HTTPException -from starlette.requests import Request # type: ignore from lnbits.decorators import WalletTypeInfo, get_key_type from lnbits.extensions.livestream.models import CreateTrack @@ -27,6 +25,7 @@ async def api_livestream_from_wallet( req: Request, g: WalletTypeInfo = Depends(get_key_type) ): ls = await get_or_create_livestream_by_wallet(g.wallet.id) + assert ls tracks = await get_tracks(ls.id) producers = await get_producers(ls.id) @@ -55,17 +54,17 @@ async def api_update_track(track_id, g: WalletTypeInfo = Depends(get_key_type)): id = int(track_id) except ValueError: id = 0 - if id <= 0: - id = None ls = await get_or_create_livestream_by_wallet(g.wallet.id) - await update_current_track(ls.id, id) + assert ls + await update_current_track(ls.id, None if id <= 0 else id) return "", HTTPStatus.NO_CONTENT @livestream_ext.put("/api/v1/livestream/fee/{fee_pct}") async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)): ls = await get_or_create_livestream_by_wallet(g.wallet.id) + assert ls await update_livestream_fee(ls.id, int(fee_pct)) return "", HTTPStatus.NO_CONTENT @@ -76,9 +75,10 @@ async def api_add_track( data: CreateTrack, id=None, g: WalletTypeInfo = Depends(get_key_type) ): ls = await get_or_create_livestream_by_wallet(g.wallet.id) + assert ls if data.producer_id: - p_id = data.producer_id + p_id = int(data.producer_id) elif data.producer_name: p_id = await add_producer(ls.id, data.producer_name) else: @@ -96,5 +96,6 @@ async def api_add_track( @livestream_ext.delete("/api/v1/livestream/tracks/{track_id}") async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)): ls = await get_or_create_livestream_by_wallet(g.wallet.id) + assert ls await delete_track_from_livestream(ls.id, track_id) return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/lnurldevice/crud.py b/lnbits/extensions/lnurldevice/crud.py index 182df743..0ab520da 100644 --- a/lnbits/extensions/lnurldevice/crud.py +++ b/lnbits/extensions/lnurldevice/crud.py @@ -7,8 +7,6 @@ from lnbits.helpers import urlsafe_short_hash from . import db from .models import createLnurldevice, lnurldevicepayment, lnurldevices -###############lnurldeviceS########################## - async def create_lnurldevice( data: createLnurldevice, @@ -69,10 +67,12 @@ async def create_lnurldevice( data.pin4, ), ) - return await get_lnurldevice(lnurldevice_id) + device = await get_lnurldevice(lnurldevice_id) + assert device + return device -async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldevices]: +async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> lnurldevices: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) await db.execute( f"UPDATE lnurldevice.lnurldevices SET {q} WHERE id = ?", @@ -81,19 +81,18 @@ async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldev row = await db.fetchone( "SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,) ) - return lnurldevices(**row) if row else None + return lnurldevices(**row) -async def get_lnurldevice(lnurldevice_id: str) -> lnurldevices: +async def get_lnurldevice(lnurldevice_id: str) -> Optional[lnurldevices]: row = await db.fetchone( "SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,) ) return lnurldevices(**row) if row else None -async def get_lnurldevices(wallet_ids: Union[str, List[str]]) -> List[lnurldevices]: - wallet_ids = [wallet_ids] - q = ",".join(["?"] * len(wallet_ids[0])) +async def get_lnurldevices(wallet_ids: List[str]) -> List[lnurldevices]: + q = ",".join(["?"] * len(wallet_ids)) rows = await db.fetchall( f""" SELECT * FROM lnurldevice.lnurldevices WHERE wallet IN ({q}) @@ -102,7 +101,7 @@ async def get_lnurldevices(wallet_ids: Union[str, List[str]]) -> List[lnurldevic (*wallet_ids,), ) - return [lnurldevices(**row) if row else None for row in rows] + return [lnurldevices(**row) for row in rows] async def delete_lnurldevice(lnurldevice_id: str) -> None: @@ -110,8 +109,6 @@ async def delete_lnurldevice(lnurldevice_id: str) -> None: "DELETE FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,) ) - ########################lnuldevice payments########################### - async def create_lnurldevicepayment( deviceid: str, @@ -121,6 +118,7 @@ async def create_lnurldevicepayment( sats: Optional[int] = 0, ) -> lnurldevicepayment: device = await get_lnurldevice(deviceid) + assert device if device.device == "atm": lnurldevicepayment_id = shortuuid.uuid(name=payload) else: @@ -139,7 +137,9 @@ async def create_lnurldevicepayment( """, (lnurldevicepayment_id, deviceid, payload, pin, payhash, sats), ) - return await get_lnurldevicepayment(lnurldevicepayment_id) + dpayment = await get_lnurldevicepayment(lnurldevicepayment_id) + assert dpayment + return dpayment async def update_lnurldevicepayment( @@ -157,7 +157,9 @@ async def update_lnurldevicepayment( return lnurldevicepayment(**row) if row else None -async def get_lnurldevicepayment(lnurldevicepayment_id: str) -> lnurldevicepayment: +async def get_lnurldevicepayment( + lnurldevicepayment_id: str, +) -> Optional[lnurldevicepayment]: row = await db.fetchone( "SELECT * FROM lnurldevice.lnurldevicepayment WHERE id = ?", (lnurldevicepayment_id,), @@ -165,7 +167,9 @@ async def get_lnurldevicepayment(lnurldevicepayment_id: str) -> lnurldevicepayme return lnurldevicepayment(**row) if row else None -async def get_lnurlpayload(lnurldevicepayment_payload: str) -> lnurldevicepayment: +async def get_lnurlpayload( + lnurldevicepayment_payload: str, +) -> Optional[lnurldevicepayment]: row = await db.fetchone( "SELECT * FROM lnurldevice.lnurldevicepayment WHERE payload = ?", (lnurldevicepayment_payload,), diff --git a/lnbits/extensions/lnurldevice/lnurl.py b/lnbits/extensions/lnurldevice/lnurl.py index 34de20fa..eba2a693 100644 --- a/lnbits/extensions/lnurldevice/lnurl.py +++ b/lnbits/extensions/lnurldevice/lnurl.py @@ -1,16 +1,11 @@ import base64 -import hashlib import hmac from http import HTTPStatus from io import BytesIO -from typing import Optional import shortuuid from embit import bech32, compact -from fastapi import Request -from fastapi.param_functions import Query -from loguru import logger -from starlette.exceptions import HTTPException +from fastapi import HTTPException, Query, Request from lnbits import bolt11 from lnbits.core.services import create_invoice @@ -44,7 +39,9 @@ def bech32_decode(bech): encoding = bech32.bech32_verify_checksum(hrp, data) if encoding is None: return - return bytes(bech32.convertbits(data[:-6], 5, 8, False)) + bits = bech32.convertbits(data[:-6], 5, 8, False) + assert bits + return bytes(bits) def xor_decrypt(key, blob): @@ -105,6 +102,8 @@ async def lnurl_v1_params( "reason": f"lnurldevice {device_id} not found on this server", } if device.device == "switch": + # TODO: AMOUNT IN CENT was never reference here + amount_in_cent = 0 price_msat = ( await fiat_amount_as_satoshis(float(profit), device.currency) if device.currency != "sat" @@ -160,23 +159,18 @@ async def lnurl_v1_params( if device.device != "atm": return {"status": "ERROR", "reason": "Not ATM device."} price_msat = int(price_msat * (1 - (device.profit / 100)) / 1000) - lnurldevicepayment = await get_lnurldevicepayment(shortuuid.uuid(name=p)) - if lnurldevicepayment: - logger.debug("lnurldevicepayment") - logger.debug(lnurldevicepayment) - logger.debug("lnurldevicepayment") - if lnurldevicepayment.payload == lnurldevicepayment.payhash: - return {"status": "ERROR", "reason": f"Payment already claimed"} - else: + try: lnurldevicepayment = await create_lnurldevicepayment( deviceid=device.id, payload=p, sats=price_msat * 1000, - pin=pin, + pin=str(pin), payhash="payment_hash", ) + except: + return {"status": "ERROR", "reason": "Could not create ATM payment."} if not lnurldevicepayment: - return {"status": "ERROR", "reason": "Could not create payment."} + return {"status": "ERROR", "reason": "Could not create ATM payment."} return { "tag": "withdrawRequest", "callback": request.url_for( @@ -193,7 +187,7 @@ async def lnurl_v1_params( deviceid=device.id, payload=p, sats=price_msat * 1000, - pin=pin, + pin=str(pin), payhash="payment_hash", ) if not lnurldevicepayment: @@ -221,6 +215,10 @@ async def lnurl_callback( k1: str = Query(None), ): lnurldevicepayment = await get_lnurldevicepayment(paymentid) + if not lnurldevicepayment: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="lnurldevicepayment not found." + ) device = await get_lnurldevice(lnurldevicepayment.deviceid) if not device: raise HTTPException( @@ -241,13 +239,17 @@ async def lnurl_callback( else: if lnurldevicepayment.payload != k1: return {"status": "ERROR", "reason": "Bad K1"} - lnurldevicepayment = await update_lnurldevicepayment( + if lnurldevicepayment.payhash != "payment_hash": + return {"status": "ERROR", "reason": f"Payment already claimed"} + + lnurldevicepayment_updated = await update_lnurldevicepayment( lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload ) + assert lnurldevicepayment_updated await pay_invoice( wallet_id=device.wallet, payment_request=pr, - max_sat=lnurldevicepayment.sats / 1000, + max_sat=int(lnurldevicepayment_updated.sats / 1000), extra={"tag": "withdraw"}, ) return {"status": "OK"} diff --git a/lnbits/extensions/lnurldevice/models.py b/lnbits/extensions/lnurldevice/models.py index 66b215f2..f9640de1 100644 --- a/lnbits/extensions/lnurldevice/models.py +++ b/lnbits/extensions/lnurldevice/models.py @@ -3,13 +3,9 @@ from sqlite3 import Row from typing import List, Optional from fastapi import Request -from lnurl import Lnurl -from lnurl import encode as lnurl_encode # type: ignore -from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore -from lnurl.types import LnurlPayMetadata # type: ignore -from loguru import logger +from lnurl import encode as lnurl_encode +from lnurl.types import LnurlPayMetadata from pydantic import BaseModel -from pydantic.main import BaseModel class createLnurldevice(BaseModel): @@ -58,6 +54,7 @@ class lnurldevices(BaseModel): pin4: int timestamp: str + @classmethod def from_row(cls, row: Row) -> "lnurldevices": return cls(**dict(row)) diff --git a/lnbits/extensions/lnurldevice/tasks.py b/lnbits/extensions/lnurldevice/tasks.py index 8ad9772c..9aec173e 100644 --- a/lnbits/extensions/lnurldevice/tasks.py +++ b/lnbits/extensions/lnurldevice/tasks.py @@ -1,18 +1,11 @@ import asyncio -import json -from http import HTTPStatus -from urllib.parse import urlparse -import httpx -from fastapi import HTTPException - -from lnbits import bolt11 from lnbits.core.models import Payment -from lnbits.core.services import pay_invoice, websocketUpdater +from lnbits.core.services import websocketUpdater from lnbits.helpers import get_current_extension_name from lnbits.tasks import register_invoice_listener -from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment +from .crud import get_lnurldevicepayment, update_lnurldevicepayment async def wait_for_paid_invoices(): @@ -27,14 +20,15 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: # (avoid loops) if "Switch" == payment.extra.get("tag"): - lnurldevicepayment = await get_lnurldevicepayment(payment.extra.get("id")) + lnurldevicepayment = await get_lnurldevicepayment(payment.extra["id"]) if not lnurldevicepayment: return if lnurldevicepayment.payhash == "used": return lnurldevicepayment = await update_lnurldevicepayment( - lnurldevicepayment_id=payment.extra.get("id"), payhash="used" + lnurldevicepayment_id=payment.extra["id"], payhash="used" ) + assert lnurldevicepayment return await websocketUpdater( lnurldevicepayment.deviceid, str(lnurldevicepayment.pin) + "-" + str(lnurldevicepayment.payload), diff --git a/lnbits/extensions/lnurldevice/views.py b/lnbits/extensions/lnurldevice/views.py index f1be4f0d..a6256a41 100644 --- a/lnbits/extensions/lnurldevice/views.py +++ b/lnbits/extensions/lnurldevice/views.py @@ -1,12 +1,7 @@ from http import HTTPStatus -from io import BytesIO -import pyqrcode -from fastapi import Request -from fastapi.param_functions import Query -from fastapi.params import Depends +from fastapi import Depends, HTTPException, Query, Request from fastapi.templating import Jinja2Templates -from starlette.exceptions import HTTPException from starlette.responses import HTMLResponse, StreamingResponse from lnbits.core.crud import update_payment_status @@ -62,4 +57,6 @@ async def img(request: Request, lnurldevice_id): raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist." ) - return lnurldevice.lnurl(request) + # error: "lnurldevices" has no attribute "lnurl" + # return lnurldevice.lnurl(request) + return None diff --git a/lnbits/extensions/lnurldevice/views_api.py b/lnbits/extensions/lnurldevice/views_api.py index c6766423..d657c879 100644 --- a/lnbits/extensions/lnurldevice/views_api.py +++ b/lnbits/extensions/lnurldevice/views_api.py @@ -1,9 +1,6 @@ from http import HTTPStatus -from fastapi import Request -from fastapi.param_functions import Query -from fastapi.params import Depends -from starlette.exceptions import HTTPException +from fastapi import Depends, HTTPException, Query, Request from lnbits.core.crud import get_user from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key @@ -26,9 +23,6 @@ async def api_list_currencies_available(): return list(currencies.keys()) -#######################lnurldevice########################## - - @lnurldevice_ext.post("/api/v1/lnurlpos") @lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}") async def api_lnurldevice_create_or_update( @@ -41,7 +35,7 @@ async def api_lnurldevice_create_or_update( lnurldevice = await create_lnurldevice(data) return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}} else: - lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id) + lnurldevice = await update_lnurldevice(lnurldevice_id, **data.dict()) return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}} @@ -49,7 +43,8 @@ async def api_lnurldevice_create_or_update( async def api_lnurldevices_retrieve( req: Request, wallet: WalletTypeInfo = Depends(get_key_type) ): - wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] try: return [ {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}} @@ -65,10 +60,11 @@ async def api_lnurldevices_retrieve( return "" -@lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}") +@lnurldevice_ext.get( + "/api/v1/lnurlpos/{lnurldevice_id}", dependencies=[Depends(get_key_type)] +) async def api_lnurldevice_retrieve( req: Request, - wallet: WalletTypeInfo = Depends(get_key_type), lnurldevice_id: str = Query(None), ): lnurldevice = await get_lnurldevice(lnurldevice_id) @@ -76,23 +72,18 @@ async def api_lnurldevice_retrieve( raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="lnurldevice does not exist" ) - if not lnurldevice.lnurl_toggle: - return {**lnurldevice.dict()} return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}} -@lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}") -async def api_lnurldevice_delete( - wallet: WalletTypeInfo = Depends(require_admin_key), - lnurldevice_id: str = Query(None), -): +@lnurldevice_ext.delete( + "/api/v1/lnurlpos/{lnurldevice_id}", dependencies=[Depends(require_admin_key)] +) +async def api_lnurldevice_delete(lnurldevice_id: str = Query(None)): lnurldevice = await get_lnurldevice(lnurldevice_id) - if not lnurldevice: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Wallet link does not exist." ) await delete_lnurldevice(lnurldevice_id) - return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/nostrnip5/README.md b/lnbits/extensions/nostrnip5/README.md index b8912fa2..2bcbf054 100644 --- a/lnbits/extensions/nostrnip5/README.md +++ b/lnbits/extensions/nostrnip5/README.md @@ -41,4 +41,19 @@ location /.well-known/nostr.json { proxy_cache_valid 200 300s; proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504; } +``` + +Example Caddy configuration + +``` +my.lnbits.instance { + reverse_proxy {your_lnbits} +} + +nip.5.domain { + route /.well-known/nostr.json { + rewrite * /nostrnip5/api/v1/domain/{domain_id}/nostr.json + reverse_proxy {your_lnbits} + } +} ``` \ No newline at end of file diff --git a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html index b1459ee3..820d8718 100644 --- a/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html +++ b/lnbits/extensions/nostrnip5/templates/nostrnip5/index.html @@ -201,7 +201,7 @@ dense v-model.trim="formDialog.data.amount" label="Amount" - placeholder="10.00" + placeholder="How much do you want to charge?" > SMTP Extension + +This extension allows you to setup a smtp, to offer sending emails with it for a small fee. + +## Requirements + +- SMTP Server + +## Usage + +1. Create new emailaddress +2. Verify if email goes to your testemail. Testmail is sent on create and update +3. Share the link with the email form. + diff --git a/lnbits/extensions/smtp/__init__.py b/lnbits/extensions/smtp/__init__.py new file mode 100644 index 00000000..e7419852 --- /dev/null +++ b/lnbits/extensions/smtp/__init__.py @@ -0,0 +1,34 @@ +import asyncio + +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_smtp") + +smtp_static_files = [ + { + "path": "/smtp/static", + "app": StaticFiles(directory="lnbits/extensions/smtp/static"), + "name": "smtp_static", + } +] + +smtp_ext: APIRouter = APIRouter(prefix="/smtp", tags=["smtp"]) + + +def smtp_renderer(): + return template_renderer(["lnbits/extensions/smtp/templates"]) + + +from .tasks import wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa + + +def smtp_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/smtp/config.json b/lnbits/extensions/smtp/config.json new file mode 100644 index 00000000..325ebfa7 --- /dev/null +++ b/lnbits/extensions/smtp/config.json @@ -0,0 +1,6 @@ +{ + "name": "SMTP", + "short_description": "Charge sats for sending emails", + "tile": "/smtp/static/smtp-bitcoin-email.png", + "contributors": ["dni"] +} diff --git a/lnbits/extensions/smtp/crud.py b/lnbits/extensions/smtp/crud.py new file mode 100644 index 00000000..2eee4c3d --- /dev/null +++ b/lnbits/extensions/smtp/crud.py @@ -0,0 +1,168 @@ +from http import HTTPStatus +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import CreateEmail, CreateEmailaddress, Emailaddresses, Emails +from .smtp import send_mail + + +def get_test_mail(email, testemail): + return CreateEmail( + emailaddress_id=email, + subject="LNBits SMTP - Test Email", + message="This is a test email from the LNBits SMTP extension! email is working!", + receiver=testemail, + ) + + +async def create_emailaddress(data: CreateEmailaddress) -> Emailaddresses: + + emailaddress_id = urlsafe_short_hash() + + # send test mail for checking connection + email = get_test_mail(data.email, data.testemail) + await send_mail(data, email) + + await db.execute( + """ + INSERT INTO smtp.emailaddress (id, wallet, email, testemail, smtp_server, smtp_user, smtp_password, smtp_port, anonymize, description, cost) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + emailaddress_id, + data.wallet, + data.email, + data.testemail, + data.smtp_server, + data.smtp_user, + data.smtp_password, + data.smtp_port, + data.anonymize, + data.description, + data.cost, + ), + ) + + new_emailaddress = await get_emailaddress(emailaddress_id) + assert new_emailaddress, "Newly created emailaddress couldn't be retrieved" + return new_emailaddress + + +async def update_emailaddress(emailaddress_id: str, **kwargs) -> Emailaddresses: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE smtp.emailaddress SET {q} WHERE id = ?", + (*kwargs.values(), emailaddress_id), + ) + row = await db.fetchone( + "SELECT * FROM smtp.emailaddress WHERE id = ?", (emailaddress_id,) + ) + + # send test mail for checking connection + email = get_test_mail(row.email, row.testemail) + await send_mail(row, email) + + assert row, "Newly updated emailaddress couldn't be retrieved" + return Emailaddresses(**row) + + +async def get_emailaddress(emailaddress_id: str) -> Optional[Emailaddresses]: + row = await db.fetchone( + "SELECT * FROM smtp.emailaddress WHERE id = ?", (emailaddress_id,) + ) + return Emailaddresses(**row) if row else None + + +async def get_emailaddress_by_email(email: str) -> Optional[Emailaddresses]: + row = await db.fetchone("SELECT * FROM smtp.emailaddress WHERE email = ?", (email,)) + return Emailaddresses(**row) if row else None + + +# async def get_emailAddressByEmail(email: str) -> Optional[Emails]: +# row = await db.fetchone( +# "SELECT s.*, d.emailaddress as emailaddress FROM smtp.email s INNER JOIN smtp.emailaddress d ON (s.emailaddress_id = d.id) WHERE s.emailaddress = ?", +# (email,), +# ) +# return Subdomains(**row) if row else None + + +async def get_emailaddresses(wallet_ids: Union[str, List[str]]) -> List[Emailaddresses]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM smtp.emailaddress WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Emailaddresses(**row) for row in rows] + + +async def delete_emailaddress(emailaddress_id: str) -> None: + await db.execute("DELETE FROM smtp.emailaddress WHERE id = ?", (emailaddress_id,)) + + +## create emails +async def create_email(payment_hash, wallet, data: CreateEmail) -> Emails: + await db.execute( + """ + INSERT INTO smtp.email (id, wallet, emailaddress_id, subject, receiver, message, paid) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + payment_hash, + wallet, + data.emailaddress_id, + data.subject, + data.receiver, + data.message, + False, + ), + ) + + new_email = await get_email(payment_hash) + assert new_email, "Newly created email couldn't be retrieved" + return new_email + + +async def set_email_paid(payment_hash: str) -> Emails: + email = await get_email(payment_hash) + if email and email.paid == False: + await db.execute( + """ + UPDATE smtp.email + SET paid = true + WHERE id = ? + """, + (payment_hash,), + ) + new_email = await get_email(payment_hash) + assert new_email, "Newly paid email couldn't be retrieved" + return new_email + + +async def get_email(email_id: str) -> Optional[Emails]: + row = await db.fetchone( + "SELECT s.*, d.email as emailaddress FROM smtp.email s INNER JOIN smtp.emailaddress d ON (s.emailaddress_id = d.id) WHERE s.id = ?", + (email_id,), + ) + return Emails(**row) if row else None + + +async def get_emails(wallet_ids: Union[str, List[str]]) -> List[Emails]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT s.*, d.email as emailaddress FROM smtp.email s INNER JOIN smtp.emailaddress d ON (s.emailaddress_id = d.id) WHERE s.wallet IN ({q})", + (*wallet_ids,), + ) + + return [Emails(**row) for row in rows] + + +async def delete_email(email_id: str) -> None: + await db.execute("DELETE FROM smtp.email WHERE id = ?", (email_id,)) diff --git a/lnbits/extensions/smtp/migrations.py b/lnbits/extensions/smtp/migrations.py new file mode 100644 index 00000000..16d50166 --- /dev/null +++ b/lnbits/extensions/smtp/migrations.py @@ -0,0 +1,35 @@ +async def m001_initial(db): + + await db.execute( + f""" + CREATE TABLE smtp.emailaddress ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + email TEXT NOT NULL, + testemail TEXT NOT NULL, + smtp_server TEXT NOT NULL, + smtp_user TEXT NOT NULL, + smtp_password TEXT NOT NULL, + smtp_port TEXT NOT NULL, + anonymize BOOLEAN NOT NULL, + description TEXT NOT NULL, + cost INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE smtp.email ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + emailaddress_id TEXT NOT NULL, + subject TEXT NOT NULL, + receiver TEXT NOT NULL, + message TEXT NOT NULL, + paid BOOLEAN NOT NULL, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) diff --git a/lnbits/extensions/smtp/models.py b/lnbits/extensions/smtp/models.py new file mode 100644 index 00000000..e2f3fc13 --- /dev/null +++ b/lnbits/extensions/smtp/models.py @@ -0,0 +1,47 @@ +from fastapi import Query +from pydantic import BaseModel + + +class CreateEmailaddress(BaseModel): + wallet: str = Query(...) + email: str = Query(...) + testemail: str = Query(...) + smtp_server: str = Query(...) + smtp_user: str = Query(...) + smtp_password: str = Query(...) + smtp_port: str = Query(...) + description: str = Query(...) + anonymize: bool + cost: int = Query(..., ge=0) + + +class Emailaddresses(BaseModel): + id: str + wallet: str + email: str + testemail: str + smtp_server: str + smtp_user: str + smtp_password: str + smtp_port: str + anonymize: bool + description: str + cost: int + + +class CreateEmail(BaseModel): + emailaddress_id: str = Query(...) + subject: str = Query(...) + receiver: str = Query(...) + message: str = Query(...) + + +class Emails(BaseModel): + id: str + wallet: str + emailaddress_id: str + subject: str + receiver: str + message: str + paid: bool + time: int diff --git a/lnbits/extensions/smtp/smtp.py b/lnbits/extensions/smtp/smtp.py new file mode 100644 index 00000000..e77bc0fa --- /dev/null +++ b/lnbits/extensions/smtp/smtp.py @@ -0,0 +1,86 @@ +import re +import socket +import time +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formatdate +from http import HTTPStatus +from smtplib import SMTP_SSL as SMTP + +from loguru import logger +from starlette.exceptions import HTTPException + + +def valid_email(s): + # https://regexr.com/2rhq7 + pat = "[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?" + if re.match(pat, s): + return True + msg = f"SMTP - invalid email: {s}." + logger.error(msg) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) + + +async def send_mail(emailaddress, email): + valid_email(emailaddress.email) + valid_email(email.receiver) + + ts = time.time() + date = formatdate(ts, True) + + msg = MIMEMultipart("alternative") + msg = MIMEMultipart("alternative") + msg["Date"] = date + msg["Subject"] = email.subject + msg["From"] = emailaddress.email + msg["To"] = email.receiver + + signature = "Email sent anonymiously by LNbits Sendmail extension." + text = f""" +{email.message} + +{signature} +""" + + html = f""" + + + +

{email.message}

+
+

{signature}

+ + +""" + + part1 = MIMEText(text, "plain") + part2 = MIMEText(html, "html") + msg.attach(part1) + msg.attach(part2) + + try: + conn = SMTP( + host=emailaddress.smtp_server, port=emailaddress.smtp_port, timeout=10 + ) + logger.debug("SMTP - connected to smtp server.") + # conn.set_debuglevel(True) + except: + msg = f"SMTP - error connecting to smtp server: {emailaddress.smtp_server}:{emailaddress.smtp_port}." + logger.error(msg) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) + try: + conn.login(emailaddress.smtp_user, emailaddress.smtp_password) + logger.debug("SMTP - successful login to smtp server.") + except: + msg = f"SMTP - error login into smtp {emailaddress.smtp_user}." + logger.error(msg) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) + try: + conn.sendmail(emailaddress.email, email.receiver, msg.as_string()) + logger.debug("SMTP - successfully send email.") + except socket.error as e: + msg = f"SMTP - error sending email: {str(e)}." + logger.error(msg) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) + finally: + conn.quit() diff --git a/lnbits/extensions/smtp/static/smtp-bitcoin-email.png b/lnbits/extensions/smtp/static/smtp-bitcoin-email.png new file mode 100644 index 00000000..e80b6c9a Binary files /dev/null and b/lnbits/extensions/smtp/static/smtp-bitcoin-email.png differ diff --git a/lnbits/extensions/smtp/tasks.py b/lnbits/extensions/smtp/tasks.py new file mode 100644 index 00000000..9c544473 --- /dev/null +++ b/lnbits/extensions/smtp/tasks.py @@ -0,0 +1,36 @@ +import asyncio + +from loguru import logger + +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener + +from .crud import get_email, get_emailaddress, set_email_paid +from .smtp import send_mail + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + if payment.extra.get("tag") != "smtp": + return + + email = await get_email(payment.checking_id) + if not email: + logger.error("SMTP: email can not by fetched") + return + + emailaddress = await get_emailaddress(email.emailaddress_id) + if not emailaddress: + logger.error("SMTP: emailaddress can not by fetched") + return + + await payment.set_pending(False) + await send_mail(emailaddress, email) + await set_email_paid(payment_hash=payment.payment_hash) diff --git a/lnbits/extensions/smtp/templates/smtp/_api_docs.html b/lnbits/extensions/smtp/templates/smtp/_api_docs.html new file mode 100644 index 00000000..cfb811d1 --- /dev/null +++ b/lnbits/extensions/smtp/templates/smtp/_api_docs.html @@ -0,0 +1,23 @@ + + + +
+ LNBits SMTP: Get paid sats to send emails +
+

+ Charge people for using sending an email via your smtp server
+ More details +
+ Created by, dni +

+
+
+
diff --git a/lnbits/extensions/smtp/templates/smtp/display.html b/lnbits/extensions/smtp/templates/smtp/display.html new file mode 100644 index 00000000..7db4a0d6 --- /dev/null +++ b/lnbits/extensions/smtp/templates/smtp/display.html @@ -0,0 +1,185 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +

{{ email }}

+
+
{{ desc }}
+
+ + + + +

Total cost: {{ cost }} sats

+
+ Submit + Cancel +
+
+
+
+
+ + + + + + +
+ Copy invoice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/smtp/templates/smtp/index.html b/lnbits/extensions/smtp/templates/smtp/index.html new file mode 100644 index 00000000..bf43ad7f --- /dev/null +++ b/lnbits/extensions/smtp/templates/smtp/index.html @@ -0,0 +1,528 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + + New Emailaddress + + + + + +
+
+
Emailaddresses
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Emails
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Sendmail extension +
+
+ + + {% include "smtp/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ Update Form + Create Emailaddress + Cancel +
+
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/smtp/views.py b/lnbits/extensions/smtp/views.py new file mode 100644 index 00000000..df208a77 --- /dev/null +++ b/lnbits/extensions/smtp/views.py @@ -0,0 +1,40 @@ +from http import HTTPStatus + +from fastapi import Depends, HTTPException, Request +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import smtp_ext, smtp_renderer +from .crud import get_emailaddress + +templates = Jinja2Templates(directory="templates") + + +@smtp_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return smtp_renderer().TemplateResponse( + "smtp/index.html", {"request": request, "user": user.dict()} + ) + + +@smtp_ext.get("/{emailaddress_id}") +async def display(request: Request, emailaddress_id): + emailaddress = await get_emailaddress(emailaddress_id) + if not emailaddress: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Emailaddress does not exist." + ) + + return smtp_renderer().TemplateResponse( + "smtp/display.html", + { + "request": request, + "emailaddress_id": emailaddress.id, + "email": emailaddress.email, + "desc": emailaddress.description, + "cost": emailaddress.cost, + }, + ) diff --git a/lnbits/extensions/smtp/views_api.py b/lnbits/extensions/smtp/views_api.py new file mode 100644 index 00000000..08a05ef3 --- /dev/null +++ b/lnbits/extensions/smtp/views_api.py @@ -0,0 +1,170 @@ +from http import HTTPStatus + +from fastapi import Depends, HTTPException, Query + +from lnbits.core.crud import get_user +from lnbits.core.services import check_transaction_status, create_invoice +from lnbits.decorators import WalletTypeInfo, get_key_type +from lnbits.extensions.smtp.models import CreateEmail, CreateEmailaddress + +from . import smtp_ext +from .crud import ( + create_email, + create_emailaddress, + delete_email, + delete_emailaddress, + get_email, + get_emailaddress, + get_emailaddresses, + get_emails, + update_emailaddress, +) +from .smtp import valid_email + + +## EMAILS +@smtp_ext.get("/api/v1/email") +async def api_email( + g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) +): + wallet_ids = [g.wallet.id] + if all_wallets: + user = await get_user(g.wallet.user) + if user: + wallet_ids = user.wallet_ids + return [email.dict() for email in await get_emails(wallet_ids)] + + +@smtp_ext.get("/api/v1/email/{payment_hash}") +async def api_smtp_send_email(payment_hash): + email = await get_email(payment_hash) + if not email: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="paymenthash is wrong" + ) + + emailaddress = await get_emailaddress(email.emailaddress_id) + + try: + status = await check_transaction_status(email.wallet, payment_hash) + is_paid = not status.pending + except Exception: + return {"paid": False} + if is_paid: + if emailaddress.anonymize: + await delete_email(email.id) + return {"paid": True} + return {"paid": False} + + +@smtp_ext.post("/api/v1/email/{emailaddress_id}") +async def api_smtp_make_email(emailaddress_id, data: CreateEmail): + + valid_email(data.receiver) + + emailaddress = await get_emailaddress(emailaddress_id) + # If the request is coming for the non-existant emailaddress + if not emailaddress: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Emailaddress address does not exist.", + ) + try: + memo = f"sent email from {emailaddress.email} to {data.receiver}" + if emailaddress.anonymize: + memo = "sent email" + + payment_hash, payment_request = await create_invoice( + wallet_id=emailaddress.wallet, + amount=emailaddress.cost, + memo=memo, + extra={"tag": "smtp"}, + ) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + email = await create_email( + payment_hash=payment_hash, wallet=emailaddress.wallet, data=data + ) + + if not email: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Email could not be fetched." + ) + return {"payment_hash": payment_hash, "payment_request": payment_request} + + +@smtp_ext.delete("/api/v1/email/{email_id}") +async def api_email_delete(email_id, g: WalletTypeInfo = Depends(get_key_type)): + email = await get_email(email_id) + + if not email: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="LNsubdomain does not exist." + ) + + if email.wallet != g.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your email.") + + await delete_email(email_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +## EMAILADDRESSES +@smtp_ext.get("/api/v1/emailaddress") +async def api_emailaddresses( + g: WalletTypeInfo = Depends(get_key_type), + all_wallets: bool = Query(False), +): + wallet_ids = [g.wallet.id] + if all_wallets: + user = await get_user(g.wallet.user) + if user: + wallet_ids = user.wallet_ids + return [ + emailaddress.dict() for emailaddress in await get_emailaddresses(wallet_ids) + ] + + +@smtp_ext.post("/api/v1/emailaddress") +@smtp_ext.put("/api/v1/emailaddress/{emailaddress_id}") +async def api_emailaddress_create( + data: CreateEmailaddress, + emailaddress_id=None, + g: WalletTypeInfo = Depends(get_key_type), +): + if emailaddress_id: + emailaddress = await get_emailaddress(emailaddress_id) + + if not emailaddress: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Emailadress does not exist." + ) + if emailaddress.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your emailaddress." + ) + + emailaddress = await update_emailaddress(emailaddress_id, **data.dict()) + else: + emailaddress = await create_emailaddress(data=data) + return emailaddress.dict() + + +@smtp_ext.delete("/api/v1/emailaddress/{emailaddress_id}") +async def api_emailaddress_delete( + emailaddress_id, g: WalletTypeInfo = Depends(get_key_type) +): + emailaddress = await get_emailaddress(emailaddress_id) + + if not emailaddress: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Emailaddress does not exist." + ) + if emailaddress.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your Emailaddress." + ) + + await delete_emailaddress(emailaddress_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) diff --git a/lnbits/extensions/tipjar/crud.py b/lnbits/extensions/tipjar/crud.py index 1b58a43d..080eaf1c 100644 --- a/lnbits/extensions/tipjar/crud.py +++ b/lnbits/extensions/tipjar/crud.py @@ -33,7 +33,11 @@ async def create_tip( async def create_tipjar(data: createTipJar) -> TipJar: """Create a new TipJar""" - await db.execute( + + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await (method)( f""" INSERT INTO tipjar.TipJars ( name, @@ -42,11 +46,16 @@ async def create_tipjar(data: createTipJar) -> TipJar: onchain ) VALUES (?, ?, ?, ?) + {returning} """, (data.name, data.wallet, data.webhook, data.onchain), ) - row = await db.fetchone("SELECT * FROM tipjar.TipJars LIMIT 1") - tipjar = TipJar(**row) + if db.type == SQLITE: + tipjar_id = result._result_proxy.lastrowid + else: + tipjar_id = result[0] + + tipjar = await get_tipjar(tipjar_id) assert tipjar return tipjar diff --git a/lnbits/extensions/tpos/tasks.py b/lnbits/extensions/tpos/tasks.py index 80ee1085..4b7bd9f9 100644 --- a/lnbits/extensions/tpos/tasks.py +++ b/lnbits/extensions/tpos/tasks.py @@ -20,9 +20,6 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - if not payment.extra: - return - if payment.extra.get("tag") != "tpos": return diff --git a/lnbits/extensions/watchonly/crud.py b/lnbits/extensions/watchonly/crud.py index 61e47cfe..1d9abcec 100644 --- a/lnbits/extensions/watchonly/crud.py +++ b/lnbits/extensions/watchonly/crud.py @@ -41,8 +41,9 @@ async def create_watch_wallet(user: str, w: WalletAccount) -> WalletAccount: w.meta, ), ) - - return await get_watch_wallet(wallet_id) + wallet = await get_watch_wallet(wallet_id) + assert wallet + return wallet async def get_watch_wallet(wallet_id: str) -> Optional[WalletAccount]: @@ -121,11 +122,11 @@ async def create_fresh_addresses( change_address=False, ) -> List[Address]: if start_address_index > end_address_index: - return None + return [] wallet = await get_watch_wallet(wallet_id) if not wallet: - return None + return [] branch_index = 1 if change_address else 0 @@ -150,7 +151,7 @@ async def create_fresh_addresses( # return fresh addresses rows = await db.fetchall( """ - SELECT * FROM watchonly.addresses + SELECT * FROM watchonly.addresses WHERE wallet = ? AND branch_index = ? AND address_index >= ? AND address_index < ? ORDER BY branch_index, address_index """, @@ -172,7 +173,7 @@ async def get_address_at_index( ) -> Optional[Address]: row = await db.fetchone( """ - SELECT * FROM watchonly.addresses + SELECT * FROM watchonly.addresses WHERE wallet = ? AND branch_index = ? AND address_index = ? """, ( diff --git a/lnbits/extensions/watchonly/helpers.py b/lnbits/extensions/watchonly/helpers.py index 74125dde..8db9ff57 100644 --- a/lnbits/extensions/watchonly/helpers.py +++ b/lnbits/extensions/watchonly/helpers.py @@ -1,6 +1,6 @@ -from embit.descriptor import Descriptor, Key # type: ignore -from embit.descriptor.arguments import AllowedDerivation # type: ignore -from embit.networks import NETWORKS # type: ignore +from embit.descriptor import Descriptor, Key +from embit.descriptor.arguments import AllowedDerivation +from embit.networks import NETWORKS def detect_network(k): diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py index c6265d6c..24d63bfd 100644 --- a/lnbits/extensions/watchonly/models.py +++ b/lnbits/extensions/watchonly/models.py @@ -1,7 +1,7 @@ from sqlite3 import Row from typing import List, Optional -from fastapi.param_functions import Query +from fastapi import Query from pydantic import BaseModel @@ -35,7 +35,7 @@ class Address(BaseModel): amount: int = 0 branch_index: int = 0 address_index: int - note: str = None + note: Optional[str] = None has_activity: bool = False @classmethod @@ -57,9 +57,9 @@ class TransactionInput(BaseModel): class TransactionOutput(BaseModel): amount: int address: str - branch_index: int = None - address_index: int = None - wallet: str = None + branch_index: Optional[int] = None + address_index: Optional[int] = None + wallet: Optional[str] = None class MasterPublicKey(BaseModel): diff --git a/lnbits/extensions/watchonly/views.py b/lnbits/extensions/watchonly/views.py index 819d1248..8cebc6cc 100644 --- a/lnbits/extensions/watchonly/views.py +++ b/lnbits/extensions/watchonly/views.py @@ -1,6 +1,5 @@ -from fastapi.params import Depends +from fastapi import Depends, Request from fastapi.templating import Jinja2Templates -from starlette.requests import Request from starlette.responses import HTMLResponse from lnbits.core.models import User diff --git a/lnbits/extensions/watchonly/views_api.py b/lnbits/extensions/watchonly/views_api.py index c6e15ea6..a7086423 100644 --- a/lnbits/extensions/watchonly/views_api.py +++ b/lnbits/extensions/watchonly/views_api.py @@ -1,5 +1,6 @@ import json from http import HTTPStatus +from typing import List import httpx from embit import finalizer, script @@ -7,9 +8,7 @@ from embit.ec import PublicKey from embit.networks import NETWORKS from embit.psbt import PSBT, DerivationPath from embit.transaction import Transaction, TransactionInput, TransactionOutput -from fastapi import Query, Request -from fastapi.params import Depends -from starlette.exceptions import HTTPException +from fastapi import Depends, HTTPException, Query, Request from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key from lnbits.extensions.watchonly import watchonly_ext @@ -57,10 +56,8 @@ async def api_wallets_retrieve( return [] -@watchonly_ext.get("/api/v1/wallet/{wallet_id}") -async def api_wallet_retrieve( - wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) -): +@watchonly_ext.get("/api/v1/wallet/{wallet_id}", dependencies=[Depends(get_key_type)]) +async def api_wallet_retrieve(wallet_id: str): w_wallet = await get_watch_wallet(wallet_id) if not w_wallet: @@ -126,8 +123,10 @@ async def api_wallet_create_or_update( return wallet.dict() -@watchonly_ext.delete("/api/v1/wallet/{wallet_id}") -async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin_key)): +@watchonly_ext.delete( + "/api/v1/wallet/{wallet_id}", dependencies=[Depends(require_admin_key)] +) +async def api_wallet_delete(wallet_id: str): wallet = await get_watch_wallet(wallet_id) if not wallet: @@ -144,16 +143,15 @@ async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin #############################ADDRESSES########################## -@watchonly_ext.get("/api/v1/address/{wallet_id}") -async def api_fresh_address(wallet_id, w: WalletTypeInfo = Depends(get_key_type)): +@watchonly_ext.get("/api/v1/address/{wallet_id}", dependencies=[Depends(get_key_type)]) +async def api_fresh_address(wallet_id: str): address = await get_fresh_address(wallet_id) + assert address return address.dict() -@watchonly_ext.put("/api/v1/address/{id}") -async def api_update_address( - id: str, req: Request, w: WalletTypeInfo = Depends(require_admin_key) -): +@watchonly_ext.put("/api/v1/address/{id}", dependencies=[Depends(require_admin_key)]) +async def api_update_address(id: str, req: Request): body = await req.json() params = {} # amout is only updated if the address has history @@ -162,9 +160,10 @@ async def api_update_address( params["has_activity"] = True if "note" in body: - params["note"] = str(body["note"]) + params["note"] = body["note"] address = await update_address(**params, id=id) + assert address wallet = ( await get_watch_wallet(address.wallet) @@ -189,6 +188,7 @@ async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type) addresses = await get_addresses(wallet_id) config = await get_config(w.wallet.user) + assert config if not addresses: await create_fresh_addresses(wallet_id, 0, config.receive_gap_limit) @@ -229,10 +229,8 @@ async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type) #############################PSBT########################## -@watchonly_ext.post("/api/v1/psbt") -async def api_psbt_create( - data: CreatePsbt, w: WalletTypeInfo = Depends(require_admin_key) -): +@watchonly_ext.post("/api/v1/psbt", dependencies=[Depends(require_admin_key)]) +async def api_psbt_create(data: CreatePsbt): try: vin = [ TransactionInput(bytes.fromhex(inp.tx_id), inp.vout) for inp in data.inputs @@ -246,7 +244,7 @@ async def api_psbt_create( for _, masterpub in enumerate(data.masterpubs): descriptors[masterpub.id] = parse_key(masterpub.public_key) - inputs_extra = [] + inputs_extra: List[dict] = [] for i, inp in enumerate(data.inputs): bip32_derivations = {} @@ -266,14 +264,15 @@ async def api_psbt_create( tx = Transaction(vin=vin, vout=vout) psbt = PSBT(tx) - for i, inp in enumerate(inputs_extra): - psbt.inputs[i].bip32_derivations = inp["bip32_derivations"] - psbt.inputs[i].non_witness_utxo = inp.get("non_witness_utxo", None) + for i, inp_extra in enumerate(inputs_extra): + psbt.inputs[i].bip32_derivations = inp_extra["bip32_derivations"] + psbt.inputs[i].non_witness_utxo = inp_extra.get("non_witness_utxo", None) outputs_extra = [] bip32_derivations = {} for i, out in enumerate(data.outputs): if out.branch_index == 1: + assert out.wallet descriptor = descriptors[out.wallet][0] d = descriptor.derive(out.address_index, out.branch_index) for k in d.keys: @@ -282,8 +281,8 @@ async def api_psbt_create( ) outputs_extra.append({"bip32_derivations": bip32_derivations}) - for i, out in enumerate(outputs_extra): - psbt.outputs[i].bip32_derivations = out["bip32_derivations"] + for i, out_extra in enumerate(outputs_extra): + psbt.outputs[i].bip32_derivations = out_extra["bip32_derivations"] return psbt.to_string() @@ -360,7 +359,8 @@ async def api_tx_broadcast( else config.mempool_endpoint + "/testnet" ) async with httpx.AsyncClient() as client: - r = await client.post(endpoint + "/api/tx", data=data.tx_hex) + r = await client.post(endpoint + "/api/tx", content=data.tx_hex) + r.raise_for_status() tx_id = r.text return tx_id except Exception as e: @@ -375,6 +375,7 @@ async def api_update_config( data: Config, w: WalletTypeInfo = Depends(require_admin_key) ): config = await update_config(data, user=w.wallet.user) + assert config return config.dict() diff --git a/pyproject.toml b/pyproject.toml index 03dbbc8d..c3026c6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,8 +92,6 @@ exclude = """(?x)( ^lnbits/extensions/bleskomat. | ^lnbits/extensions/boltz. | ^lnbits/extensions/livestream. - | ^lnbits/extensions/lnurldevice. - | ^lnbits/extensions/watchonly. | ^lnbits/wallets/lnd_grpc_files. )""" diff --git a/tests/data/mock_data.zip b/tests/data/mock_data.zip index 4070bee7..d5169e12 100644 Binary files a/tests/data/mock_data.zip and b/tests/data/mock_data.zip differ