From 6dade2edf3bb613ad66b11d5614b89903f1acb7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Mon, 20 Feb 2023 11:23:43 +0100 Subject: [PATCH] remove satspay (#1520) --- lnbits/extensions/satspay/README.md | 27 - lnbits/extensions/satspay/__init__.py | 35 - lnbits/extensions/satspay/config.json | 6 - lnbits/extensions/satspay/crud.py | 181 --- lnbits/extensions/satspay/helpers.py | 61 - lnbits/extensions/satspay/migrations.py | 64 -- lnbits/extensions/satspay/models.py | 91 -- .../satspay/static/image/satspay.png | Bin 24219 -> 0 bytes lnbits/extensions/satspay/static/js/utils.js | 36 - lnbits/extensions/satspay/tasks.py | 42 - .../satspay/templates/satspay/_api_docs.html | 29 - .../satspay/templates/satspay/display.html | 479 -------- .../satspay/templates/satspay/index.html | 1011 ----------------- lnbits/extensions/satspay/views.py | 49 - lnbits/extensions/satspay/views_api.py | 180 --- 15 files changed, 2291 deletions(-) delete mode 100644 lnbits/extensions/satspay/README.md delete mode 100644 lnbits/extensions/satspay/__init__.py delete mode 100644 lnbits/extensions/satspay/config.json delete mode 100644 lnbits/extensions/satspay/crud.py delete mode 100644 lnbits/extensions/satspay/helpers.py delete mode 100644 lnbits/extensions/satspay/migrations.py delete mode 100644 lnbits/extensions/satspay/models.py delete mode 100644 lnbits/extensions/satspay/static/image/satspay.png delete mode 100644 lnbits/extensions/satspay/static/js/utils.js delete mode 100644 lnbits/extensions/satspay/tasks.py delete mode 100644 lnbits/extensions/satspay/templates/satspay/_api_docs.html delete mode 100644 lnbits/extensions/satspay/templates/satspay/display.html delete mode 100644 lnbits/extensions/satspay/templates/satspay/index.html delete mode 100644 lnbits/extensions/satspay/views.py delete mode 100644 lnbits/extensions/satspay/views_api.py diff --git a/lnbits/extensions/satspay/README.md b/lnbits/extensions/satspay/README.md deleted file mode 100644 index 4fb24980..00000000 --- a/lnbits/extensions/satspay/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# SatsPay Server - -## Create onchain and LN charges. Includes webhooks! - -Easilly create invoices that support Lightning Network and on-chain BTC payment. - -1. Create a "NEW CHARGE"\ - ![new charge](https://i.imgur.com/fUl6p74.png) -2. Fill out the invoice fields - - set a descprition for the payment - - the amount in sats - - the time, in minutes, the invoice is valid for, after this period the invoice can't be payed - - set a webhook that will get the transaction details after a successful payment - - set to where the user should redirect after payment - - set the text for the button that will show after payment (not setting this, will display "NONE" in the button) - - select if you want onchain payment, LN payment or both - - depending on what you select you'll have to choose the respective wallets where to receive your payment\ - ![charge form](https://i.imgur.com/F10yRiW.png) -3. The charge will appear on the _Charges_ section\ - ![charges](https://i.imgur.com/zqHpVxc.png) -4. Your customer/payee will get the payment page - - they can choose to pay on LN\ - ![offchain payment](https://i.imgur.com/4191SMV.png) - - or pay on chain\ - ![onchain payment](https://i.imgur.com/wzLRR5N.png) -5. You can check the state of your charges in LNbits\ - ![invoice state](https://i.imgur.com/JnBd22p.png) diff --git a/lnbits/extensions/satspay/__init__.py b/lnbits/extensions/satspay/__init__.py deleted file mode 100644 index 8f115a3c..00000000 --- a/lnbits/extensions/satspay/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -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_satspay") - - -satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"]) - -satspay_static_files = [ - { - "path": "/satspay/static", - "app": StaticFiles(directory="lnbits/extensions/satspay/static"), - "name": "satspay_static", - } -] - - -def satspay_renderer(): - return template_renderer(["lnbits/extensions/satspay/templates"]) - - -from .tasks import wait_for_paid_invoices -from .views import * # noqa: F401,F403 -from .views_api import * # noqa: F401,F403 - - -def satspay_start(): - loop = asyncio.get_event_loop() - loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/satspay/config.json b/lnbits/extensions/satspay/config.json deleted file mode 100644 index 6104d360..00000000 --- a/lnbits/extensions/satspay/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "SatsPay Server", - "short_description": "Create onchain and LN charges", - "tile": "/satspay/static/image/satspay.png", - "contributors": ["arcbtc"] -} diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py deleted file mode 100644 index c13d0a4b..00000000 --- a/lnbits/extensions/satspay/crud.py +++ /dev/null @@ -1,181 +0,0 @@ -import json -from typing import List, Optional - -from loguru import logger - -from lnbits.core.services import create_invoice -from lnbits.core.views.api import api_payment -from lnbits.helpers import urlsafe_short_hash - -from ..watchonly.crud import get_config, get_fresh_address # type: ignore -from . import db -from .helpers import fetch_onchain_balance -from .models import Charges, CreateCharge, SatsPayThemes - - -async def create_charge(user: str, data: CreateCharge) -> Charges: - data = CreateCharge(**data.dict()) - charge_id = urlsafe_short_hash() - if data.onchainwallet: - config = await get_config(user) - assert config - data.extra = json.dumps( - {"mempool_endpoint": config.mempool_endpoint, "network": config.network} - ) - onchain = await get_fresh_address(data.onchainwallet) - if not onchain: - raise Exception(f"Wallet '{data.onchainwallet}' can no longer be accessed.") - onchainaddress = onchain.address - else: - onchainaddress = None - if data.lnbitswallet: - payment_hash, payment_request = await create_invoice( - wallet_id=data.lnbitswallet, - amount=data.amount, - memo=charge_id, - extra={"tag": "charge"}, - ) - else: - payment_hash = None - payment_request = None - await db.execute( - """ - INSERT INTO satspay.charges ( - id, - "user", - description, - onchainwallet, - onchainaddress, - lnbitswallet, - payment_request, - payment_hash, - webhook, - completelink, - completelinktext, - time, - amount, - balance, - extra, - custom_css - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - charge_id, - user, - data.description, - data.onchainwallet, - onchainaddress, - data.lnbitswallet, - payment_request, - payment_hash, - data.webhook, - data.completelink, - data.completelinktext, - data.time, - data.amount, - 0, - data.extra, - data.custom_css, - ), - ) - charge = await get_charge(charge_id) - assert charge, "Newly created charge does not exist" - return charge - - -async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE satspay.charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id) - ) - row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) - return Charges.from_row(row) if row else None - - -async def get_charge(charge_id: str) -> Optional[Charges]: - row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) - return Charges.from_row(row) if row else None - - -async def get_charges(user: str) -> List[Charges]: - rows = await db.fetchall( - """SELECT * FROM satspay.charges WHERE "user" = ? ORDER BY "timestamp" DESC """, - (user,), - ) - return [Charges.from_row(row) for row in rows] - - -async def delete_charge(charge_id: str) -> None: - await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,)) - - -async def check_address_balance(charge_id: str) -> Optional[Charges]: - charge = await get_charge(charge_id) - assert charge - - if not charge.paid: - if charge.onchainaddress: - try: - respAmount = await fetch_onchain_balance(charge) - if respAmount > charge.balance: - await update_charge(charge_id=charge_id, balance=respAmount) - except Exception as e: - logger.warning(e) - if charge.lnbitswallet: - invoice_status = await api_payment(charge.payment_hash) - - if invoice_status["paid"]: - return await update_charge(charge_id=charge_id, balance=charge.amount) - return await get_charge(charge_id) - - -################## SETTINGS ################### - - -async def save_theme(data: SatsPayThemes, css_id: Optional[str]): - # insert or update - if css_id: - await db.execute( - """ - UPDATE satspay.themes SET custom_css = ?, title = ? WHERE css_id = ? - """, - (data.custom_css, data.title, css_id), - ) - else: - css_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO satspay.themes ( - css_id, - title, - "user", - custom_css - ) - VALUES (?, ?, ?, ?) - """, - ( - css_id, - data.title, - data.user, - data.custom_css, - ), - ) - return await get_theme(css_id) - - -async def get_theme(css_id: str) -> Optional[SatsPayThemes]: - row = await db.fetchone("SELECT * FROM satspay.themes WHERE css_id = ?", (css_id,)) - return SatsPayThemes.from_row(row) if row else None - - -async def get_themes(user_id: str) -> List[SatsPayThemes]: - rows = await db.fetchall( - """SELECT * FROM satspay.themes WHERE "user" = ? ORDER BY "title" DESC """, - (user_id,), - ) - return [SatsPayThemes.from_row(row) for row in rows] - - -async def delete_theme(theme_id: str) -> None: - await db.execute("DELETE FROM satspay.themes WHERE css_id = ?", (theme_id,)) diff --git a/lnbits/extensions/satspay/helpers.py b/lnbits/extensions/satspay/helpers.py deleted file mode 100644 index 1967c79d..00000000 --- a/lnbits/extensions/satspay/helpers.py +++ /dev/null @@ -1,61 +0,0 @@ -import httpx -from loguru import logger - -from .models import Charges - - -def public_charge(charge: Charges): - c = { - "id": charge.id, - "description": charge.description, - "onchainaddress": charge.onchainaddress, - "payment_request": charge.payment_request, - "payment_hash": charge.payment_hash, - "time": charge.time, - "amount": charge.amount, - "balance": charge.balance, - "paid": charge.paid, - "timestamp": charge.timestamp, - "time_elapsed": charge.time_elapsed, - "time_left": charge.time_left, - "custom_css": charge.custom_css, - } - - if charge.paid: - c["completelink"] = charge.completelink - c["completelinktext"] = charge.completelinktext - - return c - - -async def call_webhook(charge: Charges): - async with httpx.AsyncClient() as client: - try: - assert charge.webhook - r = await client.post( - charge.webhook, - json=public_charge(charge), - timeout=40, - ) - return { - "webhook_success": r.is_success, - "webhook_message": r.reason_phrase, - "webhook_response": r.text, - } - except Exception as e: - logger.warning(f"Failed to call webhook for charge {charge.id}") - logger.warning(e) - return {"webhook_success": False, "webhook_message": str(e)} - - -async def fetch_onchain_balance(charge: Charges): - endpoint = ( - f"{charge.config.mempool_endpoint}/testnet" - if charge.config.network == "Testnet" - else charge.config.mempool_endpoint - ) - assert endpoint - assert charge.onchainaddress - async with httpx.AsyncClient() as client: - r = await client.get(endpoint + "/api/address/" + charge.onchainaddress) - return r.json()["chain_stats"]["funded_txo_sum"] diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py deleted file mode 100644 index 48875469..00000000 --- a/lnbits/extensions/satspay/migrations.py +++ /dev/null @@ -1,64 +0,0 @@ -async def m001_initial(db): - """ - Initial wallet table. - """ - - await db.execute( - f""" - CREATE TABLE satspay.charges ( - id TEXT NOT NULL PRIMARY KEY, - "user" TEXT, - description TEXT, - onchainwallet TEXT, - onchainaddress TEXT, - lnbitswallet TEXT, - payment_request TEXT, - payment_hash TEXT, - webhook TEXT, - completelink TEXT, - completelinktext TEXT, - time INTEGER, - amount {db.big_int}, - balance {db.big_int} DEFAULT 0, - timestamp TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - -async def m002_add_charge_extra_data(db): - """ - Add 'extra' column for storing various config about the charge (JSON format) - """ - await db.execute( - """ALTER TABLE satspay.charges - ADD COLUMN extra TEXT DEFAULT '{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}'; - """ - ) - - -async def m003_add_themes_table(db): - """ - Themes table - """ - - await db.execute( - """ - CREATE TABLE satspay.themes ( - css_id TEXT NOT NULL PRIMARY KEY, - "user" TEXT, - title TEXT, - custom_css TEXT - ); - """ - ) - - -async def m004_add_custom_css_to_charges(db): - """ - Add custom css option column to the 'charges' table - """ - - await db.execute("ALTER TABLE satspay.charges ADD COLUMN custom_css TEXT;") diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py deleted file mode 100644 index c9da401a..00000000 --- a/lnbits/extensions/satspay/models.py +++ /dev/null @@ -1,91 +0,0 @@ -import json -from datetime import datetime, timedelta -from sqlite3 import Row -from typing import Optional - -from fastapi.param_functions import Query -from pydantic import BaseModel - -DEFAULT_MEMPOOL_CONFIG = ( - '{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}' -) - - -class CreateCharge(BaseModel): - onchainwallet: str = Query(None) - lnbitswallet: str = Query(None) - description: str = Query(...) - webhook: str = Query(None) - completelink: str = Query(None) - completelinktext: str = Query(None) - custom_css: Optional[str] - time: int = Query(..., ge=1) - amount: int = Query(..., ge=1) - extra: str = DEFAULT_MEMPOOL_CONFIG - - -class ChargeConfig(BaseModel): - mempool_endpoint: Optional[str] - network: Optional[str] - webhook_success: Optional[bool] = False - webhook_message: Optional[str] - - -class Charges(BaseModel): - id: str - description: Optional[str] - onchainwallet: Optional[str] - onchainaddress: Optional[str] - lnbitswallet: Optional[str] - payment_request: Optional[str] - payment_hash: Optional[str] - webhook: Optional[str] - completelink: Optional[str] - completelinktext: Optional[str] = "Back to Merchant" - custom_css: Optional[str] - extra: str = DEFAULT_MEMPOOL_CONFIG - time: int - amount: int - balance: int - timestamp: int - - @classmethod - def from_row(cls, row: Row) -> "Charges": - return cls(**dict(row)) - - @property - def time_left(self): - now = datetime.utcnow().timestamp() - start = datetime.fromtimestamp(self.timestamp) - expiration = (start + timedelta(minutes=self.time)).timestamp() - return (expiration - now) / 60 - - @property - def time_elapsed(self): - return self.time_left < 0 - - @property - def paid(self): - if self.balance >= self.amount: - return True - else: - return False - - @property - def config(self) -> ChargeConfig: - charge_config = json.loads(self.extra) - return ChargeConfig(**charge_config) - - def must_call_webhook(self): - return self.webhook and self.paid and self.config.webhook_success is False - - -class SatsPayThemes(BaseModel): - css_id: str = Query(None) - title: str = Query(None) - custom_css: str = Query(None) - user: Optional[str] - - @classmethod - def from_row(cls, row: Row) -> "SatsPayThemes": - return cls(**dict(row)) diff --git a/lnbits/extensions/satspay/static/image/satspay.png b/lnbits/extensions/satspay/static/image/satspay.png deleted file mode 100644 index 827914075f634eca401bcf328dcea1c498b7bcd6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24219 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

*f>_L*4o(EziN}Y$Nd9SKd(FZ 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 new Promise(r => setTimeout(r, ms)) -const retryWithDelay = async function (fn, retryCount = 0) { - try { - await sleep(25) - // Do not return the call directly, use result. - // Otherwise the error will not be cought in this try-catch block. - const result = await fn() - return result - } catch (err) { - if (retryCount > 100) throw err - await sleep((retryCount + 1) * 1000) - return retryWithDelay(fn, retryCount + 1) - } -} - -const mapCharge = (obj, oldObj = {}) => { - const charge = {...oldObj, ...obj} - - charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time - charge.time = minutesToTime(obj.time) - charge.timeLeft = minutesToTime(obj.time_left) - - charge.displayUrl = ['/satspay/', obj.id].join('') - charge.expanded = oldObj.expanded || false - charge.pendingBalance = oldObj.pendingBalance || 0 - charge.extra = charge.extra ? JSON.parse(charge.extra) : charge.extra - return charge -} - -const mapCSS = (obj, oldObj = {}) => { - const theme = _.clone(obj) - return theme -} - -const minutesToTime = min => - min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : '' diff --git a/lnbits/extensions/satspay/tasks.py b/lnbits/extensions/satspay/tasks.py deleted file mode 100644 index 2c636351..00000000 --- a/lnbits/extensions/satspay/tasks.py +++ /dev/null @@ -1,42 +0,0 @@ -import asyncio -import json - -from loguru import logger - -from lnbits.core.models import Payment -from lnbits.helpers import get_current_extension_name -from lnbits.tasks import register_invoice_listener - -from .crud import check_address_balance, get_charge, update_charge -from .helpers import call_webhook - - -async def wait_for_paid_invoices(): - invoice_queue = asyncio.Queue() - register_invoice_listener(invoice_queue, get_current_extension_name()) - - 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") != "charge": - # not a charge invoice - return - - assert payment.memo - charge = await get_charge(payment.memo) - if not charge: - logger.error("this should never happen", payment) - return - - await payment.set_pending(False) - charge = await check_address_balance(charge_id=charge.id) - assert charge - - if charge.must_call_webhook(): - resp = await call_webhook(charge) - extra = {**charge.config.dict(), **resp} - await update_charge(charge_id=charge.id, extra=json.dumps(extra)) diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html deleted file mode 100644 index 2adab8c1..00000000 --- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html +++ /dev/null @@ -1,29 +0,0 @@ - - -

- SatsPayServer, create Onchain/LN charges.
WARNING: If using with the - WatchOnly extension, we highly reccomend using a fresh extended public Key - specifically for SatsPayServer!
- - Created by, - Ben Arc, - motorina0 -

-
-
- Swagger REST API Documentation - - diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html deleted file mode 100644 index 3cde24c3..00000000 --- a/lnbits/extensions/satspay/templates/satspay/display.html +++ /dev/null @@ -1,479 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
-
- -
-
- -
-
-
-
-
- Time elapsed -
-
- Charge paid -
-
- - - - Awaiting payment... - - {% raw %} {{ charge.timeLeft }} {% endraw %} - - - -
-
-
-
-
-
-
Charge Id:
-
- -
-
-
-
Total to pay:
-
- - sat - -
-
-
-
Amount paid:
-
- - - sat -
-
-
-
Amount pending:
-
- - sat - -
-
-
-
Amount due:
-
- - - sat - - - none -
-
-
-
- -
-
-
-
- - - bitcoin lightning payment method not available - - - - pay with lightning - -
-
- - - bitcoin onchain payment method not available - - - - pay onchain - -
-
- -
-
-
- - -
-
-
-
- -
-
- -
-
- -
-
-
-
-
-
- Pay this lightning-network invoice: -
-
- - - - - - -
-
- Copy invoice -
-
-
-
-
-
-
-
- - -
-
- - -
-
-
-
-
-
- -
-
- -
-
- -
-
-
-
-
-
- Send - - - sats to this onchain address -
-
- - - - - - -
-
- Copy address -
-
-
-
-
-
-
-
-
-
-
-{% endblock %} {% block styles %} - - -{% endblock %} {% block scripts %} - - - - -{% endblock %} diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html deleted file mode 100644 index 74b3d2cc..00000000 --- a/lnbits/extensions/satspay/templates/satspay/index.html +++ /dev/null @@ -1,1011 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - {% raw %} - New charge - - - New CSS Theme - - New CSS Theme - For security reason, custom css is only available to server - admins. - - - - - -
-
-
Charges
-
- -
- - - -
-
- - - - - Export to CSV - - - - -
-
- - - - - {% endraw %} - -
-
- - - -
-
-
Themes
-
-
- - {% raw %} - - - - {% endraw %} - -
-
-
- -
- - -
- {{SITE_TITLE}} satspay Extension -
-
- - - {% include "satspay/_api_docs.html" %} - -
-
- - - - - - - - - - -
-
-
- -
-
- - - Onchain Wallet (watch-only) extension MUST be activated and - have a wallet - - -
-
-
-
- -
-
-
- -
- -
- - - - -
-
- - - - - - - - - -
-
-
- Create Charge - Cancel -
-
-
-
- - - - - - - -
- Update CSS theme - Save CSS theme - Cancel -
-
-
-
- - - - - -
- Close -
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - - - - - - -{% endblock %} diff --git a/lnbits/extensions/satspay/views.py b/lnbits/extensions/satspay/views.py deleted file mode 100644 index 175b00bd..00000000 --- a/lnbits/extensions/satspay/views.py +++ /dev/null @@ -1,49 +0,0 @@ -from http import HTTPStatus - -from fastapi import Depends, HTTPException, Request, Response -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 satspay_ext, satspay_renderer -from .crud import get_charge, get_theme -from .helpers import public_charge - -templates = Jinja2Templates(directory="templates") - - -@satspay_ext.get("/", response_class=HTMLResponse) -async def index(request: Request, user: User = Depends(check_user_exists)): - return satspay_renderer().TemplateResponse( - "satspay/index.html", - {"request": request, "user": user.dict(), "admin": user.admin}, - ) - - -@satspay_ext.get("/{charge_id}", response_class=HTMLResponse) -async def display_charge(request: Request, charge_id: str): - charge = await get_charge(charge_id) - if not charge: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist." - ) - - return satspay_renderer().TemplateResponse( - "satspay/display.html", - { - "request": request, - "charge_data": public_charge(charge), - "mempool_endpoint": charge.config.mempool_endpoint, - "network": charge.config.network, - }, - ) - - -@satspay_ext.get("/css/{css_id}") -async def display_css(css_id: str): - theme = await get_theme(css_id) - if theme: - return Response(content=theme.custom_css, media_type="text/css") - return None diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py deleted file mode 100644 index 200773fb..00000000 --- a/lnbits/extensions/satspay/views_api.py +++ /dev/null @@ -1,180 +0,0 @@ -import json -from http import HTTPStatus - -from fastapi import Depends, HTTPException, Query -from loguru import logger - -from lnbits.decorators import ( - WalletTypeInfo, - check_admin, - get_key_type, - require_admin_key, - require_invoice_key, -) - -from . import satspay_ext -from .crud import ( - check_address_balance, - create_charge, - delete_charge, - delete_theme, - get_charge, - get_charges, - get_theme, - get_themes, - save_theme, - update_charge, -) -from .helpers import call_webhook, public_charge -from .models import CreateCharge, SatsPayThemes - - -@satspay_ext.post("/api/v1/charge") -async def api_charge_create( - data: CreateCharge, wallet: WalletTypeInfo = Depends(require_invoice_key) -): - try: - charge = await create_charge(user=wallet.wallet.user, data=data) - assert charge - return { - **charge.dict(), - **{"time_elapsed": charge.time_elapsed}, - **{"time_left": charge.time_left}, - **{"paid": charge.paid}, - } - except Exception as ex: - logger.debug(f"Satspay error: {str}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex) - ) - - -@satspay_ext.put( - "/api/v1/charge/{charge_id}", dependencies=[Depends(require_admin_key)] -) -async def api_charge_update( - data: CreateCharge, - charge_id: str, -): - charge = await update_charge(charge_id=charge_id, data=data) - assert charge - return charge.dict() - - -@satspay_ext.get("/api/v1/charges") -async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): - try: - return [ - { - **charge.dict(), - **{"time_elapsed": charge.time_elapsed}, - **{"time_left": charge.time_left}, - **{"paid": charge.paid}, - **{"webhook_message": charge.config.webhook_message}, - } - for charge in await get_charges(wallet.wallet.user) - ] - except: - return "" - - -@satspay_ext.get("/api/v1/charge/{charge_id}", dependencies=[Depends(get_key_type)]) -async def api_charge_retrieve(charge_id: str): - charge = await get_charge(charge_id) - - if not charge: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist." - ) - - return { - **charge.dict(), - **{"time_elapsed": charge.time_elapsed}, - **{"time_left": charge.time_left}, - **{"paid": charge.paid}, - } - - -@satspay_ext.delete("/api/v1/charge/{charge_id}", dependencies=[Depends(get_key_type)]) -async def api_charge_delete(charge_id: str): - charge = await get_charge(charge_id) - - if not charge: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist." - ) - - await delete_charge(charge_id) - return "", HTTPStatus.NO_CONTENT - - -#############################BALANCE########################## - - -@satspay_ext.get("/api/v1/charges/balance/{charge_ids}") -async def api_charges_balance(charge_ids): - charge_id_list = charge_ids.split(",") - charges = [] - for charge_id in charge_id_list: - charge = await api_charge_balance(charge_id) - charges.append(charge) - return charges - - -@satspay_ext.get("/api/v1/charge/balance/{charge_id}") -async def api_charge_balance(charge_id): - charge = await check_address_balance(charge_id) - - if not charge: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist." - ) - - if charge.must_call_webhook(): - resp = await call_webhook(charge) - extra = {**charge.config.dict(), **resp} - await update_charge(charge_id=charge.id, extra=json.dumps(extra)) - - return {**public_charge(charge)} - - -#############################THEMES########################## - - -@satspay_ext.post("/api/v1/themes", dependencies=[Depends(check_admin)]) -@satspay_ext.post("/api/v1/themes/{css_id}", dependencies=[Depends(check_admin)]) -async def api_themes_save( - data: SatsPayThemes, - wallet: WalletTypeInfo = Depends(require_admin_key), - css_id: str = Query(...), -): - - if css_id: - theme = await save_theme(css_id=css_id, data=data) - else: - data.user = wallet.wallet.user - theme = await save_theme(data=data, css_id="no_id") - return theme - - -@satspay_ext.get("/api/v1/themes") -async def api_themes_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): - try: - return await get_themes(wallet.wallet.user) - except HTTPException: - logger.error("Error loading satspay themes") - logger.error(HTTPException) - return "" - - -@satspay_ext.delete("/api/v1/themes/{theme_id}", dependencies=[Depends(get_key_type)]) -async def api_theme_delete(theme_id): - theme = await get_theme(theme_id) - - if not theme: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Theme does not exist." - ) - - await delete_theme(theme_id) - return "", HTTPStatus.NO_CONTENT