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 4fb249806..000000000 --- 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 8f115a3cf..000000000 --- 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 6104d3609..000000000 --- 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 c13d0a4b8..000000000 --- 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 1967c79d9..000000000 --- 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 488754699..000000000 --- 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 c9da401a2..000000000 --- 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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 diff --git a/lnbits/extensions/satspay/static/js/utils.js b/lnbits/extensions/satspay/static/js/utils.js deleted file mode 100644 index 5317673f0..000000000 --- a/lnbits/extensions/satspay/static/js/utils.js +++ /dev/null @@ -1,36 +0,0 @@ -const sleep = ms => 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 2c636351b..000000000 --- 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 2adab8c1f..000000000 --- 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 3cde24c39..000000000 --- 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 74b3d2ccc..000000000 --- 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 175b00bd4..000000000 --- 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 200773fb8..000000000 --- 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