Merge branch 'lnbits:main' into main

This commit is contained in:
blackcoffeexbt 2022-11-30 14:10:37 +00:00 committed by GitHub
commit e6c376921d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 1494 additions and 505 deletions

View file

@ -6,14 +6,17 @@ PORT=5000
DEBUG=false DEBUG=false
# Allow users and admins by user IDs (comma separated list)
LNBITS_ALLOWED_USERS="" LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS="" LNBITS_ADMIN_USERS=""
# Extensions only admin can access # Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok" LNBITS_ADMIN_EXTENSIONS="ngrok"
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# csv ad image filepaths or urls, extensions can choose to honor # Ad space description
LNBITS_AD_SPACE="" # LNBITS_AD_SPACE_TITLE="Supported by"
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
# LNBITS_AD_SPACE=""
# Hides wallet api, extensions can choose to honor # Hides wallet api, extensions can choose to honor
LNBITS_HIDE_API=false LNBITS_HIDE_API=false

View file

@ -8,6 +8,7 @@ RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH" ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app WORKDIR /app
RUN mkdir -p lnbits/data
COPY . . COPY . .

View file

@ -229,6 +229,24 @@ async def get_wallet_payment(
return Payment.from_row(row) if row else None return Payment.from_row(row) if row else None
async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: int = 5):
rows = await db.fetchall(
f"""
SELECT * FROM apipayments
WHERE pending = 'false'
AND extra LIKE ?
AND extra LIKE ?
ORDER BY time DESC LIMIT {limit}
""",
(
f"%{ext_name}%",
f"%{ext_id}%",
),
)
return rows
async def get_payments( async def get_payments(
*, *,
wallet_id: Optional[str] = None, wallet_id: Optional[str] = None,

View file

@ -51,7 +51,7 @@ async def m001_initial(db):
f""" f"""
CREATE TABLE IF NOT EXISTS apipayments ( CREATE TABLE IF NOT EXISTS apipayments (
payhash TEXT NOT NULL, payhash TEXT NOT NULL,
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
fee INTEGER NOT NULL DEFAULT 0, fee INTEGER NOT NULL DEFAULT 0,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
pending BOOLEAN NOT NULL, pending BOOLEAN NOT NULL,

View file

@ -183,6 +183,23 @@
<div class="col q-pl-md">&nbsp;</div> <div class="col q-pl-md">&nbsp;</div>
</div> </div>
</div> </div>
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn flat color="secondary" class="full-width q-mb-md"
>{{ AD_TITLE }}</q-btn
>
<a href="{{ AD[0] }}" class="q-ma-md">
<img
v-if="($q.dark.isActive)"
src="{{ AD[1] }}"
style="max-width: 90%"
/>
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
</a>
</div>
{% endfor %} {% endif %}
</div> </div>
</div> </div>
</div> </div>

View file

@ -388,9 +388,14 @@
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = {% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
ADS.split(';') %} ADS.split(';') %}
<q-card> <q-card>
<a href="{{ AD[0] }}" <q-card-section>
><img width="100%" src="{{ AD[1] }}" <h6 class="text-subtitle1 q-mt-none q-mb-sm">{{ AD_TITLE }}</h6>
/></a> </q-card </q-card-section>
<q-card-section class="q-pa-none">
<a href="{{ AD[0] }}" class="q-ma-md">
<img v-if="($q.dark.isActive)" src="{{ AD[1] }}" />
<img v-else src="{{ AD[2] }}" />
</a> </q-card-section></q-card
>{% endfor %} {% endif %} >{% endfor %} {% endif %}
</div> </div>
</div> </div>

View file

@ -12,7 +12,7 @@ from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import async_timeout import async_timeout
import httpx import httpx
import pyqrcode import pyqrcode
from fastapi import Depends, Header, Query, Request from fastapi import Depends, Header, Query, Request, Response
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.params import Body from fastapi.params import Body
from loguru import logger from loguru import logger
@ -155,30 +155,29 @@ class CreateInvoiceData(BaseModel):
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.description_hash: if data.description_hash or data.unhashed_description:
try: try:
description_hash = binascii.unhexlify(data.description_hash) description_hash = (
binascii.unhexlify(data.description_hash)
if data.description_hash
else b""
)
unhashed_description = (
binascii.unhexlify(data.unhashed_description)
if data.unhashed_description
else b""
)
except binascii.Error: except binascii.Error:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string", detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
) )
unhashed_description = b""
memo = ""
elif data.unhashed_description:
try:
unhashed_description = binascii.unhexlify(data.unhashed_description)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
)
description_hash = b""
memo = "" memo = ""
else: else:
description_hash = b"" description_hash = b""
unhashed_description = b"" unhashed_description = b""
memo = data.memo or LNBITS_SITE_TITLE memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat": if data.unit == "sat":
amount = int(data.amount) amount = int(data.amount)
else: else:
@ -585,8 +584,8 @@ class DecodePayment(BaseModel):
data: str data: str
@core_app.post("/api/v1/payments/decode") @core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
async def api_payments_decode(data: DecodePayment): async def api_payments_decode(data: DecodePayment, response: Response):
payment_str = data.data payment_str = data.data
try: try:
if payment_str[:5] == "LNURL": if payment_str[:5] == "LNURL":
@ -607,6 +606,7 @@ async def api_payments_decode(data: DecodePayment):
"min_final_cltv_expiry": invoice.min_final_cltv_expiry, "min_final_cltv_expiry": invoice.min_final_cltv_expiry,
} }
except: except:
response.status_code = HTTPStatus.BAD_REQUEST
return {"message": "Failed to decode"} return {"message": "Failed to decode"}

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import datetime import datetime
import os import os
import re
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Optional from typing import Optional
@ -73,18 +74,39 @@ class Connection(Compat):
query = query.replace("?", "%s") query = query.replace("?", "%s")
return query return query
def rewrite_values(self, values):
# strip html
CLEANR = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
def cleanhtml(raw_html):
if isinstance(raw_html, str):
cleantext = re.sub(CLEANR, "", raw_html)
return cleantext
else:
return raw_html
# tuple to list and back to tuple
values = tuple([cleanhtml(l) for l in list(values)])
return values
async def fetchall(self, query: str, values: tuple = ()) -> list: async def fetchall(self, query: str, values: tuple = ()) -> list:
result = await self.conn.execute(self.rewrite_query(query), values) result = await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
return await result.fetchall() return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()): async def fetchone(self, query: str, values: tuple = ()):
result = await self.conn.execute(self.rewrite_query(query), values) result = await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
row = await result.fetchone() row = await result.fetchone()
await result.close() await result.close()
return row return row
async def execute(self, query: str, values: tuple = ()): async def execute(self, query: str, values: tuple = ()):
return await self.conn.execute(self.rewrite_query(query), values) return await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
class Database(Compat): class Database(Compat):

View file

@ -95,4 +95,4 @@ async def api_bleskomat_delete(
) )
await delete_bleskomat(bleskomat_id) await delete_bleskomat(bleskomat_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -380,7 +380,11 @@
<strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br /> <strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br />
<strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br /> <strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br />
<strong>File key:</strong> {{ qrCodeDialog.data.k2 }}<br /> <strong>File key:</strong> {{ qrCodeDialog.data.k2 }}<br />
<br />
Always backup all keys that you're trying to write on the card. Without
them you may not be able to change them in the future!<br />
</p> </p>
<br /> <br />
<q-btn <q-btn
unelevated unelevated

View file

@ -129,7 +129,7 @@ async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admi
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
await delete_card(card_id) await delete_card(card_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@boltcards_ext.get("/api/v1/hits") @boltcards_ext.get("/api/v1/hits")

View file

@ -65,7 +65,7 @@ async def api_discordbot_users_delete(
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
) )
await delete_discordbot_user(user_id) await delete_discordbot_user(user_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
# Activate Extension # Activate Extension
@ -129,4 +129,4 @@ async def api_discordbot_wallets_delete(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
) )
await delete_discordbot_wallet(wallet_id, get_wallet.user) await delete_discordbot_wallet(wallet_id, get_wallet.user)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -6,7 +6,6 @@ from lnbits.db import Database
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart from lnbits.tasks import catch_everything_and_restart
db = Database("ext_events") db = Database("ext_events")

View file

@ -5,16 +5,16 @@ from urllib.parse import urlparse
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
from loguru import logger
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice from lnbits.core.services import pay_invoice
from lnbits.extensions.events.models import CreateTicket
from lnbits.helpers import get_current_extension_name from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .views_api import api_ticket_send_ticket from .views_api import api_ticket_send_ticket
from loguru import logger
from lnbits.extensions.events.models import CreateTicket
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():

View file

@ -135,7 +135,14 @@
var self = this var self = this
axios axios
.get('/events/api/v1/tickets/' + '{{ event_id }}' + '/' + self.formDialog.data.name + '/' + self.formDialog.data.email) .get(
'/events/api/v1/tickets/' +
'{{ event_id }}' +
'/' +
self.formDialog.data.name +
'/' +
self.formDialog.data.email
)
.then(function (response) { .then(function (response) {
self.paymentReq = response.data.payment_request self.paymentReq = response.data.payment_request
self.paymentCheck = response.data.payment_hash self.paymentCheck = response.data.payment_hash

View file

@ -260,7 +260,7 @@
dense dense
v-model.number="formDialog.data.price_per_ticket" v-model.number="formDialog.data.price_per_ticket"
type="number" type="number"
label="Price per ticket " label="Sats per ticket "
></q-input> ></q-input>
</div> </div>
</div> </div>

View file

@ -2,6 +2,7 @@ from http import HTTPStatus
from fastapi.param_functions import Query from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
from loguru import logger
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.requests import Request from starlette.requests import Request
@ -10,7 +11,6 @@ from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.events.models import CreateEvent, CreateTicket from lnbits.extensions.events.models import CreateEvent, CreateTicket
from loguru import logger
from . import events_ext from . import events_ext
from .crud import ( from .crud import (
@ -79,7 +79,7 @@ async def api_form_delete(event_id, wallet: WalletTypeInfo = Depends(get_key_typ
await delete_event(event_id) await delete_event(event_id)
await delete_event_tickets(event_id) await delete_event_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
#########Tickets########## #########Tickets##########

View file

@ -1,4 +1,4 @@
from typing import List, Optional from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
@ -6,11 +6,9 @@ from . import db
from .models import CreateJukeboxPayment, CreateJukeLinkData, Jukebox, JukeboxPayment from .models import CreateJukeboxPayment, CreateJukeLinkData, Jukebox, JukeboxPayment
async def create_jukebox( async def create_jukebox(data: CreateJukeLinkData) -> Jukebox:
data: CreateJukeLinkData, inkey: Optional[str] = ""
) -> Jukebox:
juke_id = urlsafe_short_hash() juke_id = urlsafe_short_hash()
result = await db.execute( await db.execute(
""" """
INSERT INTO jukebox.jukebox (id, "user", title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit) INSERT INTO jukebox.jukebox (id, "user", title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@ -36,13 +34,13 @@ async def create_jukebox(
async def update_jukebox( async def update_jukebox(
data: CreateJukeLinkData, juke_id: Optional[str] = "" data: Union[CreateJukeLinkData, Jukebox], juke_id: str = ""
) -> Optional[Jukebox]: ) -> Optional[Jukebox]:
q = ", ".join([f"{field[0]} = ?" for field in data]) q = ", ".join([f"{field[0]} = ?" for field in data])
items = [f"{field[1]}" for field in data] items = [f"{field[1]}" for field in data]
items.append(juke_id) items.append(juke_id)
q = q.replace("user", '"user"', 1) # hack to make user be "user"! q = q.replace("user", '"user"', 1) # hack to make user be "user"!
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items)) await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items,))
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,)) row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
return Jukebox(**row) if row else None return Jukebox(**row) if row else None
@ -72,7 +70,7 @@ async def delete_jukebox(juke_id: str):
""" """
DELETE FROM jukebox.jukebox WHERE id = ? DELETE FROM jukebox.jukebox WHERE id = ?
""", """,
(juke_id), (juke_id,),
) )
@ -80,7 +78,7 @@ async def delete_jukebox(juke_id: str):
async def create_jukebox_payment(data: CreateJukeboxPayment) -> JukeboxPayment: async def create_jukebox_payment(data: CreateJukeboxPayment) -> JukeboxPayment:
result = await db.execute( await db.execute(
""" """
INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid) INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)

View file

@ -1,6 +1,3 @@
from sqlite3 import Row
from typing import NamedTuple, Optional
from fastapi.param_functions import Query from fastapi.param_functions import Query
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.main import BaseModel from pydantic.main import BaseModel
@ -20,19 +17,19 @@ class CreateJukeLinkData(BaseModel):
class Jukebox(BaseModel): class Jukebox(BaseModel):
id: Optional[str] id: str
user: Optional[str] user: str
title: Optional[str] title: str
wallet: Optional[str] wallet: str
inkey: Optional[str] inkey: str
sp_user: Optional[str] sp_user: str
sp_secret: Optional[str] sp_secret: str
sp_access_token: Optional[str] sp_access_token: str
sp_refresh_token: Optional[str] sp_refresh_token: str
sp_device: Optional[str] sp_device: str
sp_playlists: Optional[str] sp_playlists: str
price: Optional[int] price: int
profit: Optional[int] profit: int
class JukeboxPayment(BaseModel): class JukeboxPayment(BaseModel):

View file

@ -17,7 +17,8 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "jukebox": if payment.extra:
# not a jukebox invoice if payment.extra.get("tag") != "jukebox":
return # not a jukebox invoice
await update_jukebox_payment(payment.payment_hash, paid=True) return
await update_jukebox_payment(payment.payment_hash, paid=True)

View file

@ -17,7 +17,9 @@ templates = Jinja2Templates(directory="templates")
@jukebox_ext.get("/", response_class=HTMLResponse) @jukebox_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(
request: Request, user: User = Depends(check_user_exists) # type: ignore
):
return jukebox_renderer().TemplateResponse( return jukebox_renderer().TemplateResponse(
"jukebox/index.html", {"request": request, "user": user.dict()} "jukebox/index.html", {"request": request, "user": user.dict()}
) )
@ -31,6 +33,7 @@ async def connect_to_jukebox(request: Request, juke_id):
status_code=HTTPStatus.NOT_FOUND, detail="Jukebox does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Jukebox does not exist."
) )
devices = await api_get_jukebox_device_check(juke_id) devices = await api_get_jukebox_device_check(juke_id)
deviceConnected = False
for device in devices["devices"]: for device in devices["devices"]:
if device["id"] == jukebox.sp_device.split("-")[1]: if device["id"] == jukebox.sp_device.split("-")[1]:
deviceConnected = True deviceConnected = True
@ -48,5 +51,5 @@ async def connect_to_jukebox(request: Request, juke_id):
else: else:
return jukebox_renderer().TemplateResponse( return jukebox_renderer().TemplateResponse(
"jukebox/error.html", "jukebox/error.html",
{"request": request, "jukebox": jukebox.jukebox(req=request)}, {"request": request, "jukebox": jukebox.dict()},
) )

View file

@ -3,7 +3,6 @@ import json
from http import HTTPStatus from http import HTTPStatus
import httpx import httpx
from fastapi import Request
from fastapi.param_functions import Query from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
@ -29,9 +28,7 @@ from .models import CreateJukeboxPayment, CreateJukeLinkData
@jukebox_ext.get("/api/v1/jukebox") @jukebox_ext.get("/api/v1/jukebox")
async def api_get_jukeboxs( async def api_get_jukeboxs(
req: Request, wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
wallet: WalletTypeInfo = Depends(require_admin_key),
all_wallets: bool = Query(False),
): ):
wallet_user = wallet.wallet.user wallet_user = wallet.wallet.user
@ -53,54 +50,52 @@ async def api_check_credentials_callbac(
access_token: str = Query(None), access_token: str = Query(None),
refresh_token: str = Query(None), refresh_token: str = Query(None),
): ):
sp_code = "" jukebox = await get_jukebox(juke_id)
sp_access_token = "" if not jukebox:
sp_refresh_token = ""
try:
jukebox = await get_jukebox(juke_id)
except:
raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN) raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN)
if code: if code:
jukebox.sp_access_token = code jukebox.sp_access_token = code
jukebox = await update_jukebox(jukebox, juke_id=juke_id) await update_jukebox(jukebox, juke_id=juke_id)
if access_token: if access_token:
jukebox.sp_access_token = access_token jukebox.sp_access_token = access_token
jukebox.sp_refresh_token = refresh_token jukebox.sp_refresh_token = refresh_token
jukebox = await update_jukebox(jukebox, juke_id=juke_id) await update_jukebox(jukebox, juke_id=juke_id)
return "<h1>Success!</h1><h2>You can close this window</h2>" return "<h1>Success!</h1><h2>You can close this window</h2>"
@jukebox_ext.get("/api/v1/jukebox/{juke_id}") @jukebox_ext.get("/api/v1/jukebox/{juke_id}", dependencies=[Depends(require_admin_key)])
async def api_check_credentials_check( async def api_check_credentials_check(juke_id: str = Query(None)):
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
):
jukebox = await get_jukebox(juke_id) jukebox = await get_jukebox(juke_id)
return jukebox return jukebox
@jukebox_ext.post("/api/v1/jukebox", status_code=HTTPStatus.CREATED) @jukebox_ext.post(
"/api/v1/jukebox",
status_code=HTTPStatus.CREATED,
dependencies=[Depends(require_admin_key)],
)
@jukebox_ext.put("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK) @jukebox_ext.put("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK)
async def api_create_update_jukebox( async def api_create_update_jukebox(
data: CreateJukeLinkData, data: CreateJukeLinkData, juke_id: str = Query(None)
juke_id: str = Query(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
): ):
if juke_id: if juke_id:
jukebox = await update_jukebox(data, juke_id=juke_id) jukebox = await update_jukebox(data, juke_id=juke_id)
else: else:
jukebox = await create_jukebox(data, inkey=wallet.wallet.inkey) jukebox = await create_jukebox(data)
return jukebox return jukebox
@jukebox_ext.delete("/api/v1/jukebox/{juke_id}") @jukebox_ext.delete(
"/api/v1/jukebox/{juke_id}", dependencies=[Depends(require_admin_key)]
)
async def api_delete_item( async def api_delete_item(
juke_id=None, wallet: WalletTypeInfo = Depends(require_admin_key) juke_id: str = Query(None),
): ):
await delete_jukebox(juke_id) await delete_jukebox(juke_id)
try: # try:
return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)] # return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)]
except: # except:
raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox") # raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox")
################JUKEBOX ENDPOINTS################## ################JUKEBOX ENDPOINTS##################
@ -114,9 +109,8 @@ async def api_get_jukebox_song(
sp_playlist: str = Query(None), sp_playlist: str = Query(None),
retry: bool = Query(False), retry: bool = Query(False),
): ):
try: jukebox = await get_jukebox(juke_id)
jukebox = await get_jukebox(juke_id) if not jukebox:
except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
tracks = [] tracks = []
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -152,14 +146,13 @@ async def api_get_jukebox_song(
} }
) )
except: except:
something = None pass
return [track for track in tracks] return [track for track in tracks]
async def api_get_token(juke_id=None): async def api_get_token(juke_id):
try: jukebox = await get_jukebox(juke_id)
jukebox = await get_jukebox(juke_id) if not jukebox:
except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -187,7 +180,7 @@ async def api_get_token(juke_id=None):
jukebox.sp_access_token = r.json()["access_token"] jukebox.sp_access_token = r.json()["access_token"]
await update_jukebox(jukebox, juke_id=juke_id) await update_jukebox(jukebox, juke_id=juke_id)
except: except:
something = None pass
return True return True
@ -198,9 +191,8 @@ async def api_get_token(juke_id=None):
async def api_get_jukebox_device_check( async def api_get_jukebox_device_check(
juke_id: str = Query(None), retry: bool = Query(False) juke_id: str = Query(None), retry: bool = Query(False)
): ):
try: jukebox = await get_jukebox(juke_id)
jukebox = await get_jukebox(juke_id) if not jukebox:
except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
rDevice = await client.get( rDevice = await client.get(
@ -221,7 +213,7 @@ async def api_get_jukebox_device_check(
status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth" status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
) )
else: else:
return api_get_jukebox_device_check(juke_id, retry=True) return await api_get_jukebox_device_check(juke_id, retry=True)
else: else:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="No device connected" status_code=HTTPStatus.FORBIDDEN, detail="No device connected"
@ -233,10 +225,8 @@ async def api_get_jukebox_device_check(
@jukebox_ext.get("/api/v1/jukebox/jb/invoice/{juke_id}/{song_id}") @jukebox_ext.get("/api/v1/jukebox/jb/invoice/{juke_id}/{song_id}")
async def api_get_jukebox_invoice(juke_id, song_id): async def api_get_jukebox_invoice(juke_id, song_id):
try: jukebox = await get_jukebox(juke_id)
jukebox = await get_jukebox(juke_id) if not jukebox:
except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
try: try:
@ -266,8 +256,7 @@ async def api_get_jukebox_invoice(juke_id, song_id):
invoice=invoice[1], payment_hash=payment_hash, juke_id=juke_id, song_id=song_id invoice=invoice[1], payment_hash=payment_hash, juke_id=juke_id, song_id=song_id
) )
jukebox_payment = await create_jukebox_payment(data) jukebox_payment = await create_jukebox_payment(data)
return jukebox_payment
return data
@jukebox_ext.get("/api/v1/jukebox/jb/checkinvoice/{pay_hash}/{juke_id}") @jukebox_ext.get("/api/v1/jukebox/jb/checkinvoice/{pay_hash}/{juke_id}")
@ -296,13 +285,12 @@ async def api_get_jukebox_invoice_paid(
pay_hash: str = Query(None), pay_hash: str = Query(None),
retry: bool = Query(False), retry: bool = Query(False),
): ):
try: jukebox = await get_jukebox(juke_id)
jukebox = await get_jukebox(juke_id) if not jukebox:
except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
await api_get_jukebox_invoice_check(pay_hash, juke_id) await api_get_jukebox_invoice_check(pay_hash, juke_id)
jukebox_payment = await get_jukebox_payment(pay_hash) jukebox_payment = await get_jukebox_payment(pay_hash)
if jukebox_payment.paid: if jukebox_payment and jukebox_payment.paid:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.get( r = await client.get(
"https://api.spotify.com/v1/me/player/currently-playing?market=ES", "https://api.spotify.com/v1/me/player/currently-playing?market=ES",
@ -407,9 +395,8 @@ async def api_get_jukebox_invoice_paid(
async def api_get_jukebox_currently( async def api_get_jukebox_currently(
retry: bool = Query(False), juke_id: str = Query(None) retry: bool = Query(False), juke_id: str = Query(None)
): ):
try: jukebox = await get_jukebox(juke_id)
jukebox = await get_jukebox(juke_id) if not jukebox:
except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:

View file

@ -60,14 +60,14 @@ async def api_update_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id) ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await update_current_track(ls.id, id) await update_current_track(ls.id, id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@livestream_ext.put("/api/v1/livestream/fee/{fee_pct}") @livestream_ext.put("/api/v1/livestream/fee/{fee_pct}")
async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)): async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id) ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await update_livestream_fee(ls.id, int(fee_pct)) await update_livestream_fee(ls.id, int(fee_pct))
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@livestream_ext.post("/api/v1/livestream/tracks") @livestream_ext.post("/api/v1/livestream/tracks")
@ -93,8 +93,8 @@ async def api_add_track(
return return
@livestream_ext.route("/api/v1/livestream/tracks/{track_id}") @livestream_ext.delete("/api/v1/livestream/tracks/{track_id}")
async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)): async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id) ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await delete_track_from_livestream(ls.id, track_id) await delete_track_from_livestream(ls.id, track_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -93,7 +93,7 @@ async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain")
await delete_domain(domain_id) await delete_domain(domain_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
# ADDRESSES # ADDRESSES
@ -253,4 +253,4 @@ async def api_address_delete(address_id, g: WalletTypeInfo = Depends(get_key_typ
) )
await delete_address(address_id) await delete_address(address_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -78,7 +78,7 @@ async def api_form_delete(form_id, wallet: WalletTypeInfo = Depends(get_key_type
await delete_form(form_id) await delete_form(form_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
#########tickets########## #########tickets##########
@ -160,4 +160,4 @@ async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
await delete_ticket(ticket_id) await delete_ticket(ticket_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -487,6 +487,17 @@
@click="copyText(lnurlValue, 'LNURL copied to clipboard!')" @click="copyText(lnurlValue, 'LNURL copied to clipboard!')"
>Copy LNURL</q-btn >Copy LNURL</q-btn
> >
<q-chip
v-if="websocketMessage == 'WebSocket NOT supported by your Browser!' || websocketMessage == 'Connection closed'"
clickable
color="red"
text-color="white"
icon="error"
>{% raw %}{{ wsMessage }}{% endraw %}</q-chip
>
<q-chip v-else clickable color="green" text-color="white" icon="check"
>{% raw %}{{ wsMessage }}{% endraw %}</q-chip
>
<br /> <br />
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
<q-btn <q-btn
@ -534,6 +545,7 @@
filter: '', filter: '',
currency: 'USD', currency: 'USD',
lnurlValue: '', lnurlValue: '',
websocketMessage: '',
switches: 0, switches: 0,
lnurldeviceLinks: [], lnurldeviceLinks: [],
lnurldeviceLinksObj: [], lnurldeviceLinksObj: [],
@ -622,6 +634,11 @@
} }
} }
}, },
computed: {
wsMessage: function () {
return this.websocketMessage
}
},
methods: { methods: {
openQrCodeDialog: function (lnurldevice_id) { openQrCodeDialog: function (lnurldevice_id) {
var lnurldevice = _.findWhere(this.lnurldeviceLinks, { var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
@ -631,11 +648,17 @@
this.qrCodeDialog.data = _.clone(lnurldevice) this.qrCodeDialog.data = _.clone(lnurldevice)
this.qrCodeDialog.data.url = this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host window.location.protocol + '//' + window.location.host
this.lnurlValueFetch(this.qrCodeDialog.data.switches[0][3]) this.lnurlValueFetch(
this.qrCodeDialog.data.switches[0][3],
this.qrCodeDialog.data.id
)
this.qrCodeDialog.show = true this.qrCodeDialog.show = true
}, },
lnurlValueFetch: function (lnurl) { lnurlValueFetch: function (lnurl, switchId) {
this.lnurlValue = lnurl this.lnurlValue = lnurl
this.websocketConnector(
'wss://' + window.location.host + '/lnurldevice/ws/' + switchId
)
}, },
addSwitch: function () { addSwitch: function () {
var self = this var self = this
@ -797,6 +820,25 @@
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
websocketConnector: function (websocketUrl) {
if ('WebSocket' in window) {
self = this
var ws = new WebSocket(websocketUrl)
self.updateWsMessage('Websocket connected')
ws.onmessage = function (evt) {
var received_msg = evt.data
self.updateWsMessage('Message recieved: ' + received_msg)
}
ws.onclose = function () {
self.updateWsMessage('Connection closed')
}
} else {
self.updateWsMessage('WebSocket NOT supported by your Browser!')
}
},
updateWsMessage: function (message) {
this.websocketMessage = message
},
clearFormDialoglnurldevice() { clearFormDialoglnurldevice() {
this.formDialoglnurldevice.data = { this.formDialoglnurldevice.data = {
lnurl_toggle: false, lnurl_toggle: false,

View file

@ -21,13 +21,15 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
served_meta, served_meta,
served_pr, served_pr,
webhook_url, webhook_url,
webhook_headers,
webhook_body,
success_text, success_text,
success_url, success_url,
comment_chars, comment_chars,
currency, currency,
fiat_base_multiplier fiat_base_multiplier
) )
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
{returning} {returning}
""", """,
( (
@ -36,6 +38,8 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
data.min, data.min,
data.max, data.max,
data.webhook_url, data.webhook_url,
data.webhook_headers,
data.webhook_body,
data.success_text, data.success_text,
data.success_url, data.success_url,
data.comment_chars, data.comment_chars,

View file

@ -8,7 +8,7 @@ async def m001_initial(db):
id {db.serial_primary_key}, id {db.serial_primary_key},
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
served_meta INTEGER NOT NULL, served_meta INTEGER NOT NULL,
served_pr INTEGER NOT NULL served_pr INTEGER NOT NULL
); );
@ -60,3 +60,11 @@ async def m004_fiat_base_multiplier(db):
await db.execute( await db.execute(
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;" "ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
) )
async def m005_webhook_headers_and_body(db):
"""
Add headers and body to webhooks
"""
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_headers TEXT;")
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_body TEXT;")

View file

@ -18,6 +18,8 @@ class CreatePayLinkData(BaseModel):
currency: str = Query(None) currency: str = Query(None)
comment_chars: int = Query(0, ge=0, lt=800) comment_chars: int = Query(0, ge=0, lt=800)
webhook_url: str = Query(None) webhook_url: str = Query(None)
webhook_headers: str = Query(None)
webhook_body: str = Query(None)
success_text: str = Query(None) success_text: str = Query(None)
success_url: str = Query(None) success_url: str = Query(None)
fiat_base_multiplier: int = Query(100, ge=1) fiat_base_multiplier: int = Query(100, ge=1)
@ -31,6 +33,8 @@ class PayLink(BaseModel):
served_meta: int served_meta: int
served_pr: int served_pr: int
webhook_url: Optional[str] webhook_url: Optional[str]
webhook_headers: Optional[str]
webhook_body: Optional[str]
success_text: Optional[str] success_text: Optional[str]
success_url: Optional[str] success_url: Optional[str]
currency: Optional[str] currency: Optional[str]

View file

@ -33,17 +33,22 @@ async def on_invoice_paid(payment: Payment) -> None:
if pay_link and pay_link.webhook_url: if pay_link and pay_link.webhook_url:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
r = await client.post( kwargs = {
pay_link.webhook_url, "json": {
json={
"payment_hash": payment.payment_hash, "payment_hash": payment.payment_hash,
"payment_request": payment.bolt11, "payment_request": payment.bolt11,
"amount": payment.amount, "amount": payment.amount,
"comment": payment.extra.get("comment"), "comment": payment.extra.get("comment"),
"lnurlp": pay_link.id, "lnurlp": pay_link.id,
}, },
timeout=40, "timeout": 40,
) }
if pay_link.webhook_body:
kwargs["json"]["body"] = json.loads(pay_link.webhook_body)
if pay_link.webhook_headers:
kwargs["headers"] = json.loads(pay_link.webhook_headers)
r = await client.post(pay_link.webhook_url, **kwargs)
await mark_webhook_sent(payment, r.status_code) await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError): except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1) await mark_webhook_sent(payment, -1)

View file

@ -213,6 +213,24 @@
label="Webhook URL (optional)" label="Webhook URL (optional)"
hint="A URL to be called whenever this link receives a payment." hint="A URL to be called whenever this link receives a payment."
></q-input> ></q-input>
<q-input
filled
dense
v-if="formDialog.data.webhook_url"
v-model="formDialog.data.webhook_headers"
type="text"
label="Webhook headers (optional)"
hint="Custom data as JSON string, send headers along with the webhook."
></q-input>
<q-input
filled
dense
v-if="formDialog.data.webhook_url"
v-model="formDialog.data.webhook_body"
type="text"
label="Webhook custom data (optional)"
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
></q-input>
<q-input <q-input
filled filled
dense dense

View file

@ -1,3 +1,4 @@
import json
from http import HTTPStatus from http import HTTPStatus
from fastapi import Request from fastapi import Request
@ -90,6 +91,24 @@ async def api_link_create_or_update(
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
) )
if data.webhook_headers:
try:
json.loads(data.webhook_headers)
except ValueError:
raise HTTPException(
detail="Invalid JSON in webhook_headers.",
status_code=HTTPStatus.BAD_REQUEST,
)
if data.webhook_body:
try:
json.loads(data.webhook_body)
except ValueError:
raise HTTPException(
detail="Invalid JSON in webhook_body.",
status_code=HTTPStatus.BAD_REQUEST,
)
# database only allows int4 entries for min and max. For fiat currencies, # database only allows int4 entries for min and max. For fiat currencies,
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents. # we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
if data.currency and data.fiat_base_multiplier: if data.currency and data.fiat_base_multiplier:

View file

@ -80,7 +80,7 @@ async def api_lnurlpayout_delete(
) )
await delete_lnurlpayout(lnurlpayout_id) await delete_lnurlpayout(lnurlpayout_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK) @lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK)

View file

@ -1,3 +1,4 @@
# type: ignore
from os import getenv from os import getenv
from fastapi import Request from fastapi import Request
@ -34,7 +35,9 @@ ngrok_tunnel = ngrok.connect(port)
@ngrok_ext.get("/") @ngrok_ext.get("/")
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(
request: Request, user: User = Depends(check_user_exists) # type: ignore
):
return ngrok_renderer().TemplateResponse( return ngrok_renderer().TemplateResponse(
"ngrok/index.html", {"request": request, "ngrok": string5, "user": user.dict()} "ngrok/index.html", {"request": request, "ngrok": string5, "user": user.dict()}
) )

View file

@ -6,9 +6,9 @@
LNBits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device. LNBits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a costumer chooses an item, scans the QR code, gets the description and price. After payment, the costumer gets a confirmation code that the merchant can validate to be sure the payment was successful. Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a customer chooses an item, scans the QR code, gets the description and price. After payment, the customer gets a confirmation code that the merchant can validate to be sure the payment was successful.
Costumers must use an LNURL pay capable wallet. Customers must use an LNURL pay capable wallet.
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) [**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
@ -18,18 +18,18 @@ Costumers must use an LNURL pay capable wallet.
![offline shop back office](https://i.imgur.com/Ei7cxj9.png) ![offline shop back office](https://i.imgur.com/Ei7cxj9.png)
2. Begin by creating an item, click "ADD NEW ITEM" 2. Begin by creating an item, click "ADD NEW ITEM"
- set the item name and a small description - set the item name and a small description
- you can set an optional, preferably square image, that will show up on the costumer wallet - _depending on wallet_ - you can set an optional, preferably square image, that will show up on the customer wallet - _depending on wallet_
- set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time costumer scans to pay\ - set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time customer scans to pay\
![add new item](https://i.imgur.com/pkZqRgj.png) ![add new item](https://i.imgur.com/pkZqRgj.png)
3. After creating some products, click on "PRINT QR CODES"\ 3. After creating some products, click on "PRINT QR CODES"\
![print qr codes](https://i.imgur.com/2GAiSTe.png) ![print qr codes](https://i.imgur.com/2GAiSTe.png)
4. You'll see a QR code for each product in your LNBits Offline Shop with a title and price ready for printing\ 4. You'll see a QR code for each product in your LNBits Offline Shop with a title and price ready for printing\
![qr codes sheet](https://i.imgur.com/faEqOcd.png) ![qr codes sheet](https://i.imgur.com/faEqOcd.png)
5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet 5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet
6. Choose what type of confirmation do you want costumers to report to merchant after a successful payment\ 6. Choose what type of confirmation do you want customers to report to merchant after a successful payment\
![wordlist](https://i.imgur.com/9aM6NUL.png) ![wordlist](https://i.imgur.com/9aM6NUL.png)
- Wordlist is the default option: after a successful payment the costumer will receive a word from this list, **sequentially**. Starting in _albatross_ as costumers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\ - Wordlist is the default option: after a successful payment the customer will receive a word from this list, **sequentially**. Starting in _albatross_ as customers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\
![totp authenticator](https://i.imgur.com/MrJXFxz.png) ![totp authenticator](https://i.imgur.com/MrJXFxz.png)
- TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\ - TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\
![disable confirmations](https://i.imgur.com/2OFs4yi.png) ![disable confirmations](https://i.imgur.com/2OFs4yi.png)

View file

@ -22,7 +22,7 @@ async def m001_initial(db):
description TEXT NOT NULL, description TEXT NOT NULL,
image TEXT, -- image/png;base64,... image TEXT, -- image/png;base64,...
enabled BOOLEAN NOT NULL DEFAULT true, enabled BOOLEAN NOT NULL DEFAULT true,
price INTEGER NOT NULL, price {db.big_int} NOT NULL,
unit TEXT NOT NULL DEFAULT 'sat' unit TEXT NOT NULL DEFAULT 'sat'
); );
""" """

View file

@ -93,7 +93,7 @@ async def api_add_or_update_item(
async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)): async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)):
shop = await get_or_create_shop_by_wallet(wallet.wallet.id) shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
await delete_item_from_shop(shop.id, item_id) await delete_item_from_shop(shop.id, item_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
class CreateMethodData(BaseModel): class CreateMethodData(BaseModel):

View file

@ -3,14 +3,14 @@ async def m001_initial(db):
Initial paywalls table. Initial paywalls table.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE paywall.paywalls ( CREATE TABLE paywall.paywalls (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
secret TEXT NOT NULL, secret TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
memo TEXT NOT NULL, memo TEXT NOT NULL,
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """ + """
@ -25,14 +25,14 @@ async def m002_redux(db):
""" """
await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old") await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old")
await db.execute( await db.execute(
""" f"""
CREATE TABLE paywall.paywalls ( CREATE TABLE paywall.paywalls (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
memo TEXT NOT NULL, memo TEXT NOT NULL,
description TEXT NULL, description TEXT NULL,
amount INTEGER DEFAULT 0, amount {db.big_int} DEFAULT 0,
time TIMESTAMP NOT NULL DEFAULT """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """, + """,

View file

@ -49,7 +49,7 @@ async def api_paywall_delete(
) )
await delete_paywall(paywall_id) await delete_paywall(paywall_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}") @paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}")

View file

@ -8,7 +8,6 @@ from .models import (
CreateSatsDiceLink, CreateSatsDiceLink,
CreateSatsDicePayment, CreateSatsDicePayment,
CreateSatsDiceWithdraw, CreateSatsDiceWithdraw,
HashCheck,
satsdiceLink, satsdiceLink,
satsdicePayment, satsdicePayment,
satsdiceWithdraw, satsdiceWithdraw,
@ -76,7 +75,7 @@ async def get_satsdice_pays(wallet_ids: Union[str, List[str]]) -> List[satsdiceL
return [satsdiceLink(**row) for row in rows] return [satsdiceLink(**row) for row in rows]
async def update_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]: async def update_satsdice_pay(link_id: str, **kwargs) -> satsdiceLink:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?", f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?",
@ -85,10 +84,10 @@ async def update_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,) "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,)
) )
return satsdiceLink(**row) if row else None return satsdiceLink(**row)
async def increment_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]: async def increment_satsdice_pay(link_id: str, **kwargs) -> Optional[satsdiceLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?", f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?",
@ -100,7 +99,7 @@ async def increment_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLin
return satsdiceLink(**row) if row else None return satsdiceLink(**row) if row else None
async def delete_satsdice_pay(link_id: int) -> None: async def delete_satsdice_pay(link_id: str) -> None:
await db.execute("DELETE FROM satsdice.satsdice_pay WHERE id = ?", (link_id,)) await db.execute("DELETE FROM satsdice.satsdice_pay WHERE id = ?", (link_id,))
@ -119,9 +118,15 @@ async def create_satsdice_payment(data: CreateSatsDicePayment) -> satsdicePaymen
) )
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", """,
(data["payment_hash"], data["satsdice_pay"], data["value"], False, False), (
data.payment_hash,
data.satsdice_pay,
data.value,
False,
False,
),
) )
payment = await get_satsdice_payment(data["payment_hash"]) payment = await get_satsdice_payment(data.payment_hash)
assert payment, "Newly created withdraw couldn't be retrieved" assert payment, "Newly created withdraw couldn't be retrieved"
return payment return payment
@ -134,9 +139,7 @@ async def get_satsdice_payment(payment_hash: str) -> Optional[satsdicePayment]:
return satsdicePayment(**row) if row else None return satsdicePayment(**row) if row else None
async def update_satsdice_payment( async def update_satsdice_payment(payment_hash: str, **kwargs) -> satsdicePayment:
payment_hash: int, **kwargs
) -> Optional[satsdicePayment]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
@ -147,7 +150,7 @@ async def update_satsdice_payment(
"SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?", "SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?",
(payment_hash,), (payment_hash,),
) )
return satsdicePayment(**row) if row else None return satsdicePayment(**row)
##################SATSDICE WITHDRAW LINKS ##################SATSDICE WITHDRAW LINKS
@ -168,16 +171,16 @@ async def create_satsdice_withdraw(data: CreateSatsDiceWithdraw) -> satsdiceWith
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
( (
data["payment_hash"], data.payment_hash,
data["satsdice_pay"], data.satsdice_pay,
data["value"], data.value,
urlsafe_short_hash(), urlsafe_short_hash(),
urlsafe_short_hash(), urlsafe_short_hash(),
int(datetime.now().timestamp()), int(datetime.now().timestamp()),
data["used"], data.used,
), ),
) )
withdraw = await get_satsdice_withdraw(data["payment_hash"], 0) withdraw = await get_satsdice_withdraw(data.payment_hash, 0)
assert withdraw, "Newly created withdraw couldn't be retrieved" assert withdraw, "Newly created withdraw couldn't be retrieved"
return withdraw return withdraw
@ -247,7 +250,7 @@ async def delete_satsdice_withdraw(withdraw_id: str) -> None:
) )
async def create_withdraw_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: async def create_withdraw_hash_check(the_hash: str, lnurl_id: str):
await db.execute( await db.execute(
""" """
INSERT INTO satsdice.hash_checkw ( INSERT INTO satsdice.hash_checkw (
@ -262,19 +265,15 @@ async def create_withdraw_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
return hashCheck return hashCheck
async def get_withdraw_hash_checkw(the_hash: str, lnurl_id: str) -> Optional[HashCheck]: async def get_withdraw_hash_checkw(the_hash: str, lnurl_id: str):
rowid = await db.fetchone( rowid = await db.fetchone(
"SELECT * FROM satsdice.hash_checkw WHERE id = ?", (the_hash,) "SELECT * FROM satsdice.hash_checkw WHERE id = ?", (the_hash,)
) )
rowlnurl = await db.fetchone( rowlnurl = await db.fetchone(
"SELECT * FROM satsdice.hash_checkw WHERE lnurl_id = ?", (lnurl_id,) "SELECT * FROM satsdice.hash_checkw WHERE lnurl_id = ?", (lnurl_id,)
) )
if not rowlnurl: if not rowlnurl or not rowid:
await create_withdraw_hash_check(the_hash, lnurl_id) await create_withdraw_hash_check(the_hash, lnurl_id)
return {"lnurl": True, "hash": False} return {"lnurl": True, "hash": False}
else: else:
if not rowid: return {"lnurl": True, "hash": True}
await create_withdraw_hash_check(the_hash, lnurl_id)
return {"lnurl": True, "hash": False}
else:
return {"lnurl": True, "hash": True}

View file

@ -1,4 +1,3 @@
import hashlib
import json import json
import math import math
from http import HTTPStatus from http import HTTPStatus
@ -83,15 +82,18 @@ async def api_lnurlp_callback(
success_action = link.success_action(payment_hash=payment_hash, req=req) success_action = link.success_action(payment_hash=payment_hash, req=req)
data: CreateSatsDicePayment = { data = CreateSatsDicePayment(
"satsdice_pay": link.id, satsdice_pay=link.id,
"value": amount_received / 1000, value=amount_received / 1000,
"payment_hash": payment_hash, payment_hash=payment_hash,
} )
await create_satsdice_payment(data) await create_satsdice_payment(data)
payResponse = {"pr": payment_request, "successAction": success_action, "routes": []} payResponse: dict = {
"pr": payment_request,
"successAction": success_action,
"routes": [],
}
return json.dumps(payResponse) return json.dumps(payResponse)
@ -133,9 +135,7 @@ async def api_lnurlw_response(req: Request, unique_hash: str = Query(None)):
name="satsdice.api_lnurlw_callback", name="satsdice.api_lnurlw_callback",
) )
async def api_lnurlw_callback( async def api_lnurlw_callback(
req: Request,
unique_hash: str = Query(None), unique_hash: str = Query(None),
k1: str = Query(None),
pr: str = Query(None), pr: str = Query(None),
): ):
@ -146,12 +146,13 @@ async def api_lnurlw_callback(
return {"status": "ERROR", "reason": "spent"} return {"status": "ERROR", "reason": "spent"}
paylink = await get_satsdice_pay(link.satsdice_pay) paylink = await get_satsdice_pay(link.satsdice_pay)
await update_satsdice_withdraw(link.id, used=1) if paylink:
await pay_invoice( await update_satsdice_withdraw(link.id, used=1)
wallet_id=paylink.wallet, await pay_invoice(
payment_request=pr, wallet_id=paylink.wallet,
max_sat=link.value, payment_request=pr,
extra={"tag": "withdraw"}, max_sat=link.value,
) extra={"tag": "withdraw"},
)
return {"status": "OK"} return {"status": "OK"}

View file

@ -3,14 +3,14 @@ async def m001_initial(db):
Creates an improved satsdice table and migrates the existing data. Creates an improved satsdice table and migrates the existing data.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE satsdice.satsdice_pay ( CREATE TABLE satsdice.satsdice_pay (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT, wallet TEXT,
title TEXT, title TEXT,
min_bet INTEGER, min_bet INTEGER,
max_bet INTEGER, max_bet INTEGER,
amount INTEGER DEFAULT 0, amount {db.big_int} DEFAULT 0,
served_meta INTEGER NOT NULL, served_meta INTEGER NOT NULL,
served_pr INTEGER NOT NULL, served_pr INTEGER NOT NULL,
multiplier FLOAT, multiplier FLOAT,
@ -28,11 +28,11 @@ async def m002_initial(db):
Creates an improved satsdice table and migrates the existing data. Creates an improved satsdice table and migrates the existing data.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE satsdice.satsdice_withdraw ( CREATE TABLE satsdice.satsdice_withdraw (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
satsdice_pay TEXT, satsdice_pay TEXT,
value INTEGER DEFAULT 1, value {db.big_int} DEFAULT 1,
unique_hash TEXT UNIQUE, unique_hash TEXT UNIQUE,
k1 TEXT, k1 TEXT,
open_time INTEGER, open_time INTEGER,
@ -47,11 +47,11 @@ async def m003_initial(db):
Creates an improved satsdice table and migrates the existing data. Creates an improved satsdice table and migrates the existing data.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE satsdice.satsdice_payment ( CREATE TABLE satsdice.satsdice_payment (
payment_hash TEXT PRIMARY KEY, payment_hash TEXT PRIMARY KEY,
satsdice_pay TEXT, satsdice_pay TEXT,
value INTEGER, value {db.big_int},
paid BOOL DEFAULT FALSE, paid BOOL DEFAULT FALSE,
lost BOOL DEFAULT FALSE lost BOOL DEFAULT FALSE
); );

View file

@ -4,7 +4,7 @@ from typing import Dict, Optional
from fastapi import Request from fastapi import Request
from fastapi.param_functions import Query from fastapi.param_functions import Query
from lnurl import Lnurl, LnurlWithdrawResponse from lnurl import Lnurl
from lnurl import encode as lnurl_encode # type: ignore from lnurl import encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore from lnurl.types import LnurlPayMetadata # type: ignore
from pydantic import BaseModel from pydantic import BaseModel
@ -80,8 +80,7 @@ class satsdiceWithdraw(BaseModel):
def is_spent(self) -> bool: def is_spent(self) -> bool:
return self.used >= 1 return self.used >= 1
@property def lnurl_response(self, req: Request):
def lnurl_response(self, req: Request) -> LnurlWithdrawResponse:
url = req.url_for("satsdice.api_lnurlw_callback", unique_hash=self.unique_hash) url = req.url_for("satsdice.api_lnurlw_callback", unique_hash=self.unique_hash)
withdrawResponse = { withdrawResponse = {
"tag": "withdrawRequest", "tag": "withdrawRequest",
@ -99,7 +98,7 @@ class HashCheck(BaseModel):
lnurl_id: str lnurl_id: str
@classmethod @classmethod
def from_row(cls, row: Row) -> "Hash": def from_row(cls, row: Row):
return cls(**dict(row)) return cls(**dict(row))

View file

@ -1,6 +1,8 @@
import random import random
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO
import pyqrcode
from fastapi import Request from fastapi import Request
from fastapi.param_functions import Query from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
@ -20,13 +22,15 @@ from .crud import (
get_satsdice_withdraw, get_satsdice_withdraw,
update_satsdice_payment, update_satsdice_payment,
) )
from .models import CreateSatsDiceWithdraw, satsdiceLink from .models import CreateSatsDiceWithdraw
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@satsdice_ext.get("/", response_class=HTMLResponse) @satsdice_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(
request: Request, user: User = Depends(check_user_exists) # type: ignore
):
return satsdice_renderer().TemplateResponse( return satsdice_renderer().TemplateResponse(
"satsdice/index.html", {"request": request, "user": user.dict()} "satsdice/index.html", {"request": request, "user": user.dict()}
) )
@ -67,7 +71,7 @@ async def displaywin(
) )
withdrawLink = await get_satsdice_withdraw(payment_hash) withdrawLink = await get_satsdice_withdraw(payment_hash)
payment = await get_satsdice_payment(payment_hash) payment = await get_satsdice_payment(payment_hash)
if payment.lost: if not payment or payment.lost:
return satsdice_renderer().TemplateResponse( return satsdice_renderer().TemplateResponse(
"satsdice/error.html", "satsdice/error.html",
{"request": request, "link": satsdicelink.id, "paid": False, "lost": True}, {"request": request, "link": satsdicelink.id, "paid": False, "lost": True},
@ -96,13 +100,18 @@ async def displaywin(
) )
await update_satsdice_payment(payment_hash, paid=1) await update_satsdice_payment(payment_hash, paid=1)
paylink = await get_satsdice_payment(payment_hash) paylink = await get_satsdice_payment(payment_hash)
if not paylink:
return satsdice_renderer().TemplateResponse(
"satsdice/error.html",
{"request": request, "link": satsdicelink.id, "paid": False, "lost": True},
)
data: CreateSatsDiceWithdraw = { data = CreateSatsDiceWithdraw(
"satsdice_pay": satsdicelink.id, satsdice_pay=satsdicelink.id,
"value": paylink.value * satsdicelink.multiplier, value=paylink.value * satsdicelink.multiplier,
"payment_hash": payment_hash, payment_hash=payment_hash,
"used": 0, used=0,
} )
withdrawLink = await create_satsdice_withdraw(data) withdrawLink = await create_satsdice_withdraw(data)
return satsdice_renderer().TemplateResponse( return satsdice_renderer().TemplateResponse(
@ -121,9 +130,12 @@ async def displaywin(
@satsdice_ext.get("/img/{link_id}", response_class=HTMLResponse) @satsdice_ext.get("/img/{link_id}", response_class=HTMLResponse)
async def img(link_id): async def img(link_id):
link = await get_satsdice_pay(link_id) or abort( link = await get_satsdice_pay(link_id)
HTTPStatus.NOT_FOUND, "satsdice link does not exist." if not link:
) raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="satsdice link does not exist."
)
qr = pyqrcode.create(link.lnurl) qr = pyqrcode.create(link.lnurl)
stream = BytesIO() stream = BytesIO()
qr.svg(stream, scale=3) qr.svg(stream, scale=3)

View file

@ -15,9 +15,10 @@ from .crud import (
delete_satsdice_pay, delete_satsdice_pay,
get_satsdice_pay, get_satsdice_pay,
get_satsdice_pays, get_satsdice_pays,
get_withdraw_hash_checkw,
update_satsdice_pay, update_satsdice_pay,
) )
from .models import CreateSatsDiceLink, CreateSatsDiceWithdraws, satsdiceLink from .models import CreateSatsDiceLink
################LNURL pay ################LNURL pay
@ -25,13 +26,15 @@ from .models import CreateSatsDiceLink, CreateSatsDiceWithdraws, satsdiceLink
@satsdice_ext.get("/api/v1/links") @satsdice_ext.get("/api/v1/links")
async def api_links( async def api_links(
request: Request, request: Request,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
): ):
wallet_ids = [wallet.wallet.id] wallet_ids = [wallet.wallet.id]
if all_wallets: if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids user = await get_user(wallet.wallet.user)
if user:
wallet_ids = user.wallet_ids
try: try:
links = await get_satsdice_pays(wallet_ids) links = await get_satsdice_pays(wallet_ids)
@ -46,7 +49,7 @@ async def api_links(
@satsdice_ext.get("/api/v1/links/{link_id}") @satsdice_ext.get("/api/v1/links/{link_id}")
async def api_link_retrieve( async def api_link_retrieve(
link_id: str = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) link_id: str = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
): ):
link = await get_satsdice_pay(link_id) link = await get_satsdice_pay(link_id)
@ -67,7 +70,7 @@ async def api_link_retrieve(
@satsdice_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @satsdice_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_create_or_update( async def api_link_create_or_update(
data: CreateSatsDiceLink, data: CreateSatsDiceLink,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
link_id: str = Query(None), link_id: str = Query(None),
): ):
if data.min_bet > data.max_bet: if data.min_bet > data.max_bet:
@ -95,10 +98,10 @@ async def api_link_create_or_update(
@satsdice_ext.delete("/api/v1/links/{link_id}") @satsdice_ext.delete("/api/v1/links/{link_id}")
async def api_link_delete( async def api_link_delete(
wallet: WalletTypeInfo = Depends(get_key_type), link_id: str = Query(None) wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
link_id: str = Query(None),
): ):
link = await get_satsdice_pay(link_id) link = await get_satsdice_pay(link_id)
if not link: if not link:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
@ -117,11 +120,12 @@ async def api_link_delete(
##########LNURL withdraw ##########LNURL withdraw
@satsdice_ext.get("/api/v1/withdraws/{the_hash}/{lnurl_id}") @satsdice_ext.get(
"/api/v1/withdraws/{the_hash}/{lnurl_id}", dependencies=[Depends(get_key_type)]
)
async def api_withdraw_hash_retrieve( async def api_withdraw_hash_retrieve(
wallet: WalletTypeInfo = Depends(get_key_type),
lnurl_id: str = Query(None), lnurl_id: str = Query(None),
the_hash: str = Query(None), the_hash: str = Query(None),
): ):
hashCheck = await get_withdraw_hash_check(the_hash, lnurl_id) hashCheck = await get_withdraw_hash_checkw(the_hash, lnurl_id)
return hashCheck return hashCheck

View file

@ -18,7 +18,7 @@ Easilly create invoices that support Lightning Network and on-chain BTC payment.
![charge form](https://i.imgur.com/F10yRiW.png) ![charge form](https://i.imgur.com/F10yRiW.png)
3. The charge will appear on the _Charges_ section\ 3. The charge will appear on the _Charges_ section\
![charges](https://i.imgur.com/zqHpVxc.png) ![charges](https://i.imgur.com/zqHpVxc.png)
4. Your costumer/payee will get the payment page 4. Your customer/payee will get the payment page
- they can choose to pay on LN\ - they can choose to pay on LN\
![offchain payment](https://i.imgur.com/4191SMV.png) ![offchain payment](https://i.imgur.com/4191SMV.png)
- or pay on chain\ - or pay on chain\

View file

@ -2,7 +2,5 @@
"name": "SatsPay Server", "name": "SatsPay Server",
"short_description": "Create onchain and LN charges", "short_description": "Create onchain and LN charges",
"icon": "payment", "icon": "payment",
"contributors": [ "contributors": ["arcbtc"]
"arcbtc"
]
} }

View file

@ -1,16 +1,16 @@
import json
from typing import List, Optional from typing import List, Optional
import httpx from loguru import logger
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment from lnbits.core.views.api import api_payment
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from ..watchonly.crud import get_config, get_fresh_address from ..watchonly.crud import get_config, get_fresh_address
# from lnbits.db import open_ext_db
from . import db from . import db
from .models import Charges, CreateCharge from .helpers import fetch_onchain_balance
from .models import Charges, CreateCharge, SatsPayThemes
###############CHARGES########################## ###############CHARGES##########################
@ -18,6 +18,10 @@ from .models import Charges, CreateCharge
async def create_charge(user: str, data: CreateCharge) -> Charges: async def create_charge(user: str, data: CreateCharge) -> Charges:
charge_id = urlsafe_short_hash() charge_id = urlsafe_short_hash()
if data.onchainwallet: if data.onchainwallet:
config = await get_config(user)
data.extra = json.dumps(
{"mempool_endpoint": config.mempool_endpoint, "network": config.network}
)
onchain = await get_fresh_address(data.onchainwallet) onchain = await get_fresh_address(data.onchainwallet)
onchainaddress = onchain.address onchainaddress = onchain.address
else: else:
@ -48,9 +52,11 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
completelinktext, completelinktext,
time, time,
amount, amount,
balance balance,
extra,
custom_css
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
charge_id, charge_id,
@ -67,6 +73,8 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
data.time, data.time,
data.amount, data.amount,
0, 0,
data.extra,
data.custom_css,
), ),
) )
return await get_charge(charge_id) return await get_charge(charge_id)
@ -98,34 +106,118 @@ async def delete_charge(charge_id: str) -> None:
await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,)) await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,))
async def check_address_balance(charge_id: str) -> List[Charges]: async def check_address_balance(charge_id: str) -> Optional[Charges]:
charge = await get_charge(charge_id) charge = await get_charge(charge_id)
if not charge.paid: if not charge.paid:
if charge.onchainaddress: if charge.onchainaddress:
config = await get_charge_config(charge_id)
try: try:
async with httpx.AsyncClient() as client: respAmount = await fetch_onchain_balance(charge)
r = await client.get( if respAmount > charge.balance:
config.mempool_endpoint await update_charge(charge_id=charge_id, balance=respAmount)
+ "/api/address/" except Exception as e:
+ charge.onchainaddress logger.warning(e)
)
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
if respAmount > charge.balance:
await update_charge(charge_id=charge_id, balance=respAmount)
except Exception:
pass
if charge.lnbitswallet: if charge.lnbitswallet:
invoice_status = await api_payment(charge.payment_hash) invoice_status = await api_payment(charge.payment_hash)
if invoice_status["paid"]: if invoice_status["paid"]:
return await update_charge(charge_id=charge_id, balance=charge.amount) return await update_charge(charge_id=charge_id, balance=charge.amount)
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) return await get_charge(charge_id)
return Charges.from_row(row) if row else None
async def get_charge_config(charge_id: str): ################## SETTINGS ###################
row = await db.fetchone(
"""SELECT "user" FROM satspay.charges WHERE id = ?""", (charge_id,)
async def save_theme(data: SatsPayThemes, css_id: str = None):
# 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) -> 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 "timestamp" DESC """,
(user_id,),
) )
return await get_config(row.user) return await get_config(row.user)
################## SETTINGS ###################
async def save_theme(data: SatsPayThemes, css_id: str = None):
# 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) -> 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,))

View file

@ -1,8 +1,11 @@
import httpx
from loguru import logger
from .models import Charges from .models import Charges
def compact_charge(charge: Charges): def public_charge(charge: Charges):
return { c = {
"id": charge.id, "id": charge.id,
"description": charge.description, "description": charge.description,
"onchainaddress": charge.onchainaddress, "onchainaddress": charge.onchainaddress,
@ -13,5 +16,40 @@ def compact_charge(charge: Charges):
"balance": charge.balance, "balance": charge.balance,
"paid": charge.paid, "paid": charge.paid,
"timestamp": charge.timestamp, "timestamp": charge.timestamp,
"completelink": charge.completelink, # should be secret? "time_elapsed": charge.time_elapsed,
"time_left": charge.time_left,
"paid": charge.paid,
"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:
r = await client.post(
charge.webhook,
json=public_charge(charge),
timeout=40,
)
return {"webhook_success": r.is_success, "webhook_message": r.reason_phrase}
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
)
async with httpx.AsyncClient() as client:
r = await client.get(endpoint + "/api/address/" + charge.onchainaddress)
return r.json()["chain_stats"]["funded_txo_sum"]

View file

@ -4,7 +4,7 @@ async def m001_initial(db):
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE satspay.charges ( CREATE TABLE satspay.charges (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
"user" TEXT, "user" TEXT,
@ -18,11 +18,47 @@ async def m001_initial(db):
completelink TEXT, completelink TEXT,
completelinktext TEXT, completelinktext TEXT,
time INTEGER, time INTEGER,
amount INTEGER, amount {db.big_int},
balance INTEGER DEFAULT 0, balance {db.big_int} DEFAULT 0,
timestamp TIMESTAMP NOT NULL DEFAULT """ timestamp TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + 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;")

View file

@ -1,3 +1,4 @@
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlite3 import Row from sqlite3 import Row
from typing import Optional from typing import Optional
@ -13,8 +14,17 @@ class CreateCharge(BaseModel):
webhook: str = Query(None) webhook: str = Query(None)
completelink: str = Query(None) completelink: str = Query(None)
completelinktext: str = Query(None) completelinktext: str = Query(None)
custom_css: Optional[str]
time: int = Query(..., ge=1) time: int = Query(..., ge=1)
amount: int = Query(..., ge=1) amount: int = Query(..., ge=1)
extra: str = "{}"
class ChargeConfig(BaseModel):
mempool_endpoint: Optional[str]
network: Optional[str]
webhook_success: Optional[bool] = False
webhook_message: Optional[str]
class Charges(BaseModel): class Charges(BaseModel):
@ -28,6 +38,8 @@ class Charges(BaseModel):
webhook: Optional[str] webhook: Optional[str]
completelink: Optional[str] completelink: Optional[str]
completelinktext: Optional[str] = "Back to Merchant" completelinktext: Optional[str] = "Back to Merchant"
extra: str = "{}"
custom_css: Optional[str]
time: int time: int
amount: int amount: int
balance: int balance: int
@ -54,3 +66,22 @@ class Charges(BaseModel):
return True return True
else: else:
return False 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 == 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))

View file

@ -14,18 +14,22 @@ const retryWithDelay = async function (fn, retryCount = 0) {
} }
const mapCharge = (obj, oldObj = {}) => { const mapCharge = (obj, oldObj = {}) => {
const charge = _.clone(obj) const charge = {...oldObj, ...obj}
charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time
charge.time = minutesToTime(obj.time) charge.time = minutesToTime(obj.time)
charge.timeLeft = minutesToTime(obj.time_left) charge.timeLeft = minutesToTime(obj.time_left)
charge.expanded = false
charge.displayUrl = ['/satspay/', obj.id].join('') charge.displayUrl = ['/satspay/', obj.id].join('')
charge.expanded = oldObj.expanded charge.expanded = oldObj.expanded || false
charge.pendingBalance = oldObj.pendingBalance || 0 charge.pendingBalance = oldObj.pendingBalance || 0
return charge return charge
} }
const mapCSS = (obj, oldObj = {}) => {
const theme = _.clone(obj)
return theme
}
const minutesToTime = min => const minutesToTime = min =>
min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : '' min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : ''

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import json
from loguru import logger from loguru import logger
@ -7,7 +8,8 @@ from lnbits.extensions.satspay.crud import check_address_balance, get_charge
from lnbits.helpers import get_current_extension_name from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
# from .crud import get_ticket, set_ticket_paid from .crud import update_charge
from .helpers import call_webhook
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
@ -30,4 +32,9 @@ async def on_invoice_paid(payment: Payment) -> None:
return return
await payment.set_pending(False) await payment.set_pending(False)
await check_address_balance(charge_id=charge.id) charge = await check_address_balance(charge_id=charge.id)
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))

View file

@ -5,7 +5,13 @@
WatchOnly extension, we highly reccomend using a fresh extended public Key WatchOnly extension, we highly reccomend using a fresh extended public Key
specifically for SatsPayServer!<br /> specifically for SatsPayServer!<br />
<small> <small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small Created by, <a href="https://github.com/benarc">Ben Arc</a>,
<a
target="_blank"
style="color: unset"
href="https://github.com/motorina0"
>motorina0</a
></small
> >
</p> </p>
<br /> <br />

View file

@ -109,7 +109,7 @@
<q-btn <q-btn
flat flat
disable disable
v-if="!charge.lnbitswallet || charge.time_elapsed" v-if="!charge.payment_request || charge.time_elapsed"
style="color: primary; width: 100%" style="color: primary; width: 100%"
label="lightning⚡" label="lightning⚡"
> >
@ -131,7 +131,7 @@
<q-btn <q-btn
flat flat
disable disable
v-if="!charge.onchainwallet || charge.time_elapsed" v-if="!charge.onchainaddress || charge.time_elapsed"
style="color: primary; width: 100%" style="color: primary; width: 100%"
label="onchain⛓" label="onchain⛓"
> >
@ -170,13 +170,17 @@
name="check" name="check"
style="color: green; font-size: 21.4em" style="color: green; font-size: 21.4em"
></q-icon> ></q-icon>
<q-btn <div class="row text-center q-mt-lg">
outline <div class="col text-center">
v-if="charge.webhook" <q-btn
type="a" outline
:href="charge.completelink" v-if="charge.completelink"
:label="charge.completelinktext" type="a"
></q-btn> :href="charge.completelink"
:label="charge.completelinktext"
></q-btn>
</div>
</div>
</div> </div>
<div v-else> <div v-else>
<div class="row text-center q-mb-sm"> <div class="row text-center q-mb-sm">
@ -218,7 +222,7 @@
<div class="col text-center"> <div class="col text-center">
<a <a
style="color: unset" style="color: unset"
:href="mempool_endpoint + '/address/' + charge.onchainaddress" :href="'https://' + mempoolHostname + '/address/' + charge.onchainaddress"
target="_blank" target="_blank"
><span ><span
class="text-subtitle1" class="text-subtitle1"
@ -241,13 +245,17 @@
name="check" name="check"
style="color: green; font-size: 21.4em" style="color: green; font-size: 21.4em"
></q-icon> ></q-icon>
<q-btn <div class="row text-center q-mt-lg">
outline <div class="col text-center">
v-if="charge.webhook" <q-btn
type="a" outline
:href="charge.completelink" v-if="charge.webhook"
:label="charge.completelinktext" type="a"
></q-btn> :href="charge.completelink"
:label="charge.completelinktext"
></q-btn>
</div>
</div>
</div> </div>
<div v-else> <div v-else>
<div class="row items-center q-mb-sm"> <div class="row items-center q-mb-sm">
@ -289,7 +297,17 @@
</div> </div>
<div class="col-lg- 4 col-md-3 col-sm-1"></div> <div class="col-lg- 4 col-md-3 col-sm-1"></div>
</div> </div>
{% endblock %} {% block styles %}
<link
href="/satspay/css/{{ charge_data.custom_css }}"
rel="stylesheet"
type="text/css"
/>
<style>
header button.q-btn-dropdown {
display: none;
}
</style>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="https://mempool.space/mempool.js"></script> <script src="https://mempool.space/mempool.js"></script>
@ -303,7 +321,8 @@
data() { data() {
return { return {
charge: JSON.parse('{{charge_data | tojson}}'), charge: JSON.parse('{{charge_data | tojson}}'),
mempool_endpoint: '{{mempool_endpoint}}', mempoolEndpoint: '{{mempool_endpoint}}',
network: '{{network}}',
pendingFunds: 0, pendingFunds: 0,
ws: null, ws: null,
newProgress: 0.4, newProgress: 0.4,
@ -316,19 +335,19 @@
cancelListener: () => {} cancelListener: () => {}
} }
}, },
computed: {
mempoolHostname: function () {
let hostname = new URL(this.mempoolEndpoint).hostname
if (this.network === 'Testnet') {
hostname += '/testnet'
}
return hostname
}
},
methods: { methods: {
startPaymentNotifier() {
this.cancelListener()
if (!this.lnbitswallet) return
this.cancelListener = LNbits.events.onInvoicePaid(
this.wallet,
payment => {
this.checkInvoiceBalance()
}
)
},
checkBalances: async function () { checkBalances: async function () {
if (this.charge.hasStaleBalance) return if (!this.charge.payment_request && this.charge.hasOnchainStaleBalance)
return
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
@ -345,7 +364,7 @@
const { const {
bitcoin: {addresses: addressesAPI} bitcoin: {addresses: addressesAPI}
} = mempoolJS({ } = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname hostname: new URL(this.mempoolEndpoint).hostname
}) })
try { try {
@ -353,7 +372,8 @@
address: this.charge.onchainaddress address: this.charge.onchainaddress
}) })
const newBalance = utxos.reduce((t, u) => t + u.value, 0) const newBalance = utxos.reduce((t, u) => t + u.value, 0)
this.charge.hasStaleBalance = this.charge.balance === newBalance this.charge.hasOnchainStaleBalance =
this.charge.balance === newBalance
this.pendingFunds = utxos this.pendingFunds = utxos
.filter(u => !u.status.confirmed) .filter(u => !u.status.confirmed)
@ -388,10 +408,10 @@
const { const {
bitcoin: {websocket} bitcoin: {websocket}
} = mempoolJS({ } = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname hostname: new URL(this.mempoolEndpoint).hostname
}) })
this.ws = new WebSocket('wss://mempool.space/api/v1/ws') this.ws = new WebSocket(`wss://${this.mempoolHostname}/api/v1/ws`)
this.ws.addEventListener('open', x => { this.ws.addEventListener('open', x => {
if (this.charge.onchainaddress) { if (this.charge.onchainaddress) {
this.trackAddress(this.charge.onchainaddress) this.trackAddress(this.charge.onchainaddress)
@ -428,13 +448,14 @@
} }
}, },
created: async function () { created: async function () {
if (this.charge.lnbitswallet) this.payInvoice() // Remove a user defined theme
if (this.charge.custom_css) {
document.body.setAttribute('data-theme', '')
}
if (this.charge.payment_request) this.payInvoice()
else this.payOnchain() else this.payOnchain()
await this.checkBalances()
// empty for onchain await this.checkBalances()
this.wallet.inkey = '{{ wallet_inkey }}'
this.startPaymentNotifier()
if (!this.charge.paid) { if (!this.charge.paid) {
this.loopRefresh() this.loopRefresh()

View file

@ -8,6 +8,26 @@
<q-btn unelevated color="primary" @click="formDialogCharge.show = true" <q-btn unelevated color="primary" @click="formDialogCharge.show = true"
>New charge >New charge
</q-btn> </q-btn>
<q-btn
v-if="admin == 'True'"
unelevated
color="primary"
@click="getThemes();formDialogThemes.show = true"
>New CSS Theme
</q-btn>
<q-btn
v-else
disable
unelevated
color="primary"
@click="getThemes();formDialogThemes.show = true"
>New CSS Theme
<q-tooltip
>For security reason, custom css is only available to server
admins.</q-tooltip
></q-btn
>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -203,9 +223,14 @@
:href="props.row.webhook" :href="props.row.webhook"
target="_blank" target="_blank"
style="color: unset; text-decoration: none" style="color: unset; text-decoration: none"
>{{props.row.webhook || props.row.webhook}}</a >{{props.row.webhook}}</a
> >
</div> </div>
<div class="col-4 q-pr-lg">
<q-badge v-if="props.row.webhook_message" color="blue">
{{props.row.webhook_message }}
</q-badge>
</div>
</div> </div>
<div class="row items-center q-mt-md q-mb-lg"> <div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg">ID:</div> <div class="col-2 q-pr-lg">ID:</div>
@ -254,6 +279,63 @@
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
<q-card v-if="admin == 'True'">
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Themes</h5>
</div>
</div>
<q-table
dense
flat
:data="themeLinks"
row-key="id"
:columns="customCSSTable.columns"
:pagination.sync="customCSSTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateformDialog(props.row.css_id)"
icon="edit"
color="light-blue"
></q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteTheme(props.row.css_id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div> </div>
<div class="col-12 col-md-5 q-gutter-y-md"> <div class="col-12 col-md-5 q-gutter-y-md">
@ -298,32 +380,6 @@
> >
</q-input> </q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.webhook"
type="url"
label="Webhook (URL to send transaction data to once paid)"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelink"
type="url"
label="Completed button URL"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelinktext"
type="text"
label="Completed button text (ie 'Back to merchant')"
>
</q-input>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div v-if="walletLinks.length > 0"> <div v-if="walletLinks.length > 0">
@ -372,6 +428,52 @@
label="Wallet *" label="Wallet *"
> >
</q-select> </q-select>
<q-toggle
v-model="showAdvanced"
label="Show advanced options"
></q-toggle>
<div v-if="showAdvanced" class="row">
<div class="col">
<q-input
filled
dense
v-model.trim="formDialogCharge.data.webhook"
type="url"
label="Webhook (URL to send transaction data to once paid)"
class="q-mt-lg"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelink"
type="url"
label="Completed button URL"
class="q-mt-lg"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelinktext"
type="text"
label="Completed button text (ie 'Back to merchant')"
class="q-mt-lg"
>
</q-input>
<q-select
filled
dense
emit-value
v-model="formDialogCharge.data.custom_css"
:options="themeOptions"
label="Custom CSS theme (optional)"
class="q-mt-lg"
>
</q-select>
</div>
</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@ -389,6 +491,43 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="formDialogThemes.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataThemes" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialogThemes.data.title"
type="text"
label="*Title"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogThemes.data.custom_css"
type="textarea"
label="Custom CSS"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialogThemes.data.css_id"
unelevated
color="primary"
type="submit"
>Update CSS theme</q-btn
>
<q-btn v-else unelevated color="primary" type="submit"
>Save CSS theme</q-btn
>
<q-btn @click="cancelThemes" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<!-- lnbits/static/vendor <!-- lnbits/static/vendor
@ -405,15 +544,21 @@
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
return { return {
settings: {},
filter: '', filter: '',
admin: '{{ admin }}',
balance: null, balance: null,
walletLinks: [], walletLinks: [],
chargeLinks: [], chargeLinks: [],
themeLinks: [],
themeOptions: [],
onchainwallet: '', onchainwallet: '',
rescanning: false, rescanning: false,
mempool: { mempool: {
endpoint: '' endpoint: '',
network: 'Mainnet'
}, },
showAdvanced: false,
chargesTable: { chargesTable: {
columns: [ columns: [
@ -488,7 +633,25 @@
rowsPerPage: 10 rowsPerPage: 10
} }
}, },
customCSSTable: {
columns: [
{
name: 'css_id',
align: 'left',
label: 'ID',
field: 'css_id'
},
{
name: 'title',
align: 'left',
label: 'Title',
field: 'title'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialogCharge: { formDialogCharge: {
show: false, show: false,
data: { data: {
@ -496,20 +659,33 @@
onchainwallet: '', onchainwallet: '',
lnbits: false, lnbits: false,
description: '', description: '',
custom_css: '',
time: null, time: null,
amount: null amount: null
} }
},
formDialogThemes: {
show: false,
data: {
custom_css: ''
}
} }
} }
}, },
methods: { methods: {
cancelThemes: function (data) {
this.formDialogCharge.data.custom_css = ''
this.formDialogThemes.show = false
},
cancelCharge: function (data) { cancelCharge: function (data) {
this.formDialogCharge.data.description = '' this.formDialogCharge.data.description = ''
this.formDialogCharge.data.onchain = false
this.formDialogCharge.data.onchainwallet = '' this.formDialogCharge.data.onchainwallet = ''
this.formDialogCharge.data.lnbitswallet = '' this.formDialogCharge.data.lnbitswallet = ''
this.formDialogCharge.data.time = null this.formDialogCharge.data.time = null
this.formDialogCharge.data.amount = null this.formDialogCharge.data.amount = null
this.formDialogCharge.data.webhook = '' this.formDialogCharge.data.webhook = ''
this.formDialogCharge.data.custom_css = ''
this.formDialogCharge.data.completelink = '' this.formDialogCharge.data.completelink = ''
this.formDialogCharge.show = false this.formDialogCharge.show = false
}, },
@ -518,7 +694,7 @@
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
'/watchonly/api/v1/wallet', `/watchonly/api/v1/wallet?network=${this.mempool.network}`,
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.walletLinks = data.map(w => ({ this.walletLinks = data.map(w => ({
@ -538,6 +714,7 @@
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.mempool.endpoint = data.mempool_endpoint this.mempool.endpoint = data.mempool_endpoint
this.mempool.network = data.network || 'Mainnet'
const url = new URL(this.mempool.endpoint) const url = new URL(this.mempool.endpoint)
this.mempool.hostname = url.hostname this.mempool.hostname = url.hostname
} catch (error) { } catch (error) {
@ -572,12 +749,43 @@
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
sendFormDataCharge: function () {
getThemes: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/satspay/api/v1/themes',
this.g.user.wallets[0].inkey
)
console.log(data)
this.themeLinks = data.map(c =>
mapCSS(
c,
this.themeLinks.find(old => old.css_id === c.css_id)
)
)
this.themeOptions = data.map(w => ({
id: w.css_id,
label: w.title + ' - ' + w.css_id
}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendFormDataThemes: function () {
const wallet = this.g.user.wallets[0].inkey const wallet = this.g.user.wallets[0].inkey
const data = this.formDialogThemes.data
this.createTheme(wallet, data)
},
sendFormDataCharge: function () {
this.formDialogCharge.data.custom_css = this.formDialogCharge.data.custom_css?.id
const data = this.formDialogCharge.data const data = this.formDialogCharge.data
const wallet = this.g.user.wallets[0].inkey
data.amount = parseInt(data.amount) data.amount = parseInt(data.amount)
data.time = parseInt(data.time) data.time = parseInt(data.time)
data.onchainwallet = this.onchainwallet?.id data.lnbitswallet = data.lnbits ? data.lnbitswallet : null
data.onchainwallet = data.onchain ? this.onchainwallet?.id : null
this.createCharge(wallet, data) this.createCharge(wallet, data)
}, },
refreshActiveChargesBalance: async function () { refreshActiveChargesBalance: async function () {
@ -642,6 +850,68 @@
this.rescanning = false this.rescanning = false
} }
}, },
updateformDialog: function (themeId) {
const theme = _.findWhere(this.themeLinks, {css_id: themeId})
console.log(theme.css_id)
this.formDialogThemes.data.css_id = theme.css_id
this.formDialogThemes.data.title = theme.title
this.formDialogThemes.data.custom_css = theme.custom_css
this.formDialogThemes.show = true
},
createTheme: async function (wallet, data) {
console.log(data.css_id)
try {
if (data.css_id) {
const resp = await LNbits.api.request(
'POST',
'/satspay/api/v1/themes/' + data.css_id,
wallet,
data
)
this.themeLinks = _.reject(this.themeLinks, function (obj) {
return obj.css_id === data.css_id
})
this.themeLinks.unshift(mapCSS(resp.data))
} else {
const resp = await LNbits.api.request(
'POST',
'/satspay/api/v1/themes',
wallet,
data
)
this.themeLinks.unshift(mapCSS(resp.data))
}
this.formDialogThemes.show = false
this.formDialogThemes.data = {
title: '',
custom_css: ''
}
} catch (error) {
console.log('cun')
LNbits.utils.notifyApiError(error)
}
},
deleteTheme: function (themeId) {
const theme = _.findWhere(this.themeLinks, {id: themeId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this theme?')
.onOk(async () => {
try {
const response = await LNbits.api.request(
'DELETE',
'/satspay/api/v1/themes/' + themeId,
this.g.user.wallets[0].adminkey
)
this.themeLinks = _.reject(this.themeLinks, function (obj) {
return obj.css_id === themeId
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
createCharge: async function (wallet, data) { createCharge: async function (wallet, data) {
try { try {
const resp = await LNbits.api.request( const resp = await LNbits.api.request(
@ -694,9 +964,12 @@
} }
}, },
created: async function () { created: async function () {
if (this.admin == 'True') {
await this.getThemes()
}
await this.getCharges() await this.getCharges()
await this.getWalletLinks()
await this.getWalletConfig() await this.getWalletConfig()
await this.getWalletLinks()
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000) setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
await this.rescanOnchainAddresses() await this.rescanOnchainAddresses()
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000) setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)

View file

@ -1,25 +1,32 @@
import json
from http import HTTPStatus from http import HTTPStatus
from fastapi import Response
from fastapi.param_functions import Depends from fastapi.param_functions import Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from loguru import logger
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from lnbits.core.crud import get_wallet
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.extensions.satspay.helpers import public_charge
from lnbits.settings import LNBITS_ADMIN_USERS
from . import satspay_ext, satspay_renderer from . import satspay_ext, satspay_renderer
from .crud import get_charge, get_charge_config from .crud import get_charge, get_theme
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@satspay_ext.get("/", response_class=HTMLResponse) @satspay_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(request: Request, user: User = Depends(check_user_exists)):
admin = False
if LNBITS_ADMIN_USERS and user.id in LNBITS_ADMIN_USERS:
admin = True
return satspay_renderer().TemplateResponse( return satspay_renderer().TemplateResponse(
"satspay/index.html", {"request": request, "user": user.dict()} "satspay/index.html", {"request": request, "user": user.dict(), "admin": admin}
) )
@ -30,18 +37,21 @@ async def display(request: Request, charge_id: str):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
) )
wallet = await get_wallet(charge.lnbitswallet)
onchainwallet_config = await get_charge_config(charge_id)
inkey = wallet.inkey if wallet else None
mempool_endpoint = (
onchainwallet_config.mempool_endpoint if onchainwallet_config else None
)
return satspay_renderer().TemplateResponse( return satspay_renderer().TemplateResponse(
"satspay/display.html", "satspay/display.html",
{ {
"request": request, "request": request,
"charge_data": charge.dict(), "charge_data": public_charge(charge),
"wallet_inkey": inkey, "mempool_endpoint": charge.config.mempool_endpoint,
"mempool_endpoint": mempool_endpoint, "network": charge.config.network,
}, },
) )
@satspay_ext.get("/css/{css_id}")
async def display(css_id: str, response: Response):
theme = await get_theme(css_id)
if theme:
return Response(content=theme.custom_css, media_type="text/css")
return None

View file

@ -1,9 +1,12 @@
import json
from http import HTTPStatus from http import HTTPStatus
import httpx import httpx
from fastapi.params import Depends from fastapi.params import Depends
from loguru import logger
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from lnbits.core.crud import get_wallet
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
get_key_type, get_key_type,
@ -11,17 +14,22 @@ from lnbits.decorators import (
require_invoice_key, require_invoice_key,
) )
from lnbits.extensions.satspay import satspay_ext from lnbits.extensions.satspay import satspay_ext
from lnbits.settings import LNBITS_ADMIN_EXTENSIONS, LNBITS_ADMIN_USERS
from .crud import ( from .crud import (
check_address_balance, check_address_balance,
create_charge, create_charge,
delete_charge, delete_charge,
delete_theme,
get_charge, get_charge,
get_charges, get_charges,
get_theme,
get_themes,
save_theme,
update_charge, update_charge,
) )
from .helpers import compact_charge from .helpers import call_webhook, public_charge
from .models import CreateCharge from .models import CreateCharge, SatsPayThemes
#############################CHARGES########################## #############################CHARGES##########################
@ -58,6 +66,7 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
**{"time_elapsed": charge.time_elapsed}, **{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left}, **{"time_left": charge.time_left},
**{"paid": charge.paid}, **{"paid": charge.paid},
**{"webhook_message": charge.config.webhook_message},
} }
for charge in await get_charges(wallet.wallet.user) for charge in await get_charges(wallet.wallet.user)
] ]
@ -94,7 +103,7 @@ async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_
) )
await delete_charge(charge_id) await delete_charge(charge_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
#############################BALANCE########################## #############################BALANCE##########################
@ -119,19 +128,55 @@ async def api_charge_balance(charge_id):
status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist."
) )
if charge.paid and charge.webhook: if charge.must_call_webhook():
async with httpx.AsyncClient() as client: resp = await call_webhook(charge)
try: extra = {**charge.config.dict(), **resp}
r = await client.post( await update_charge(charge_id=charge.id, extra=json.dumps(extra))
charge.webhook,
json=compact_charge(charge), return {**public_charge(charge)}
timeout=40,
)
except AssertionError: #############################THEMES##########################
charge.webhook = None
return {
**compact_charge(charge), @satspay_ext.post("/api/v1/themes")
**{"time_elapsed": charge.time_elapsed}, @satspay_ext.post("/api/v1/themes/{css_id}")
**{"time_left": charge.time_left}, async def api_themes_save(
**{"paid": charge.paid}, data: SatsPayThemes,
} wallet: WalletTypeInfo = Depends(require_invoice_key),
css_id: str = None,
):
if LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only server admins can create themes.",
)
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)
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}")
async def api_charge_delete(theme_id, wallet: WalletTypeInfo = Depends(get_key_type)):
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

View file

@ -109,4 +109,4 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi
) )
await delete_scrub_link(link_id) await delete_scrub_link(link_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -31,14 +31,20 @@
style="flex-wrap: nowrap" style="flex-wrap: nowrap"
v-for="(target, t) in targets" v-for="(target, t) in targets"
> >
<q-input <q-select
dense dense
outlined :options="g.user.wallets.filter(w => w.id !== selectedWallet.id).map(o => ({name: o.name, value: o.id}))"
v-model="target.wallet" v-model="target.wallet"
label="Wallet" label="Wallet"
:hint="t === targets.length - 1 ? 'A wallet ID or invoice key.' : undefined" :hint="t === targets.length - 1 ? 'A wallet ID or invoice key.' : undefined"
@input="targetChanged(false)" @input="targetChanged(false)"
></q-input> option-label="name"
style="width: 1000px"
new-value-mode="add-unique"
use-input
input-debounce="0"
emit-value
></q-select>
<q-input <q-input
dense dense
outlined outlined

View file

@ -245,7 +245,7 @@ async def api_delete_donation(donation_id, g: WalletTypeInfo = Depends(get_key_t
detail="Not authorized to delete this donation!", detail="Not authorized to delete this donation!",
) )
await delete_donation(donation_id) await delete_donation(donation_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@streamalerts_ext.delete("/api/v1/services/{service_id}") @streamalerts_ext.delete("/api/v1/services/{service_id}")
@ -262,4 +262,4 @@ async def api_delete_service(service_id, g: WalletTypeInfo = Depends(get_key_typ
detail="Not authorized to delete this service!", detail="Not authorized to delete this service!",
) )
await delete_service(service_id) await delete_service(service_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -81,7 +81,7 @@ async def api_domain_delete(
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain.") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain.")
await delete_domain(domain_id) await delete_domain(domain_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
#########subdomains########## #########subdomains##########
@ -198,4 +198,4 @@ async def api_subdomain_delete(
) )
await delete_subdomain(subdomain_id) await delete_subdomain(subdomain_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -11,5 +11,5 @@ An easy, fast and secure way to accept Bitcoin, over Lightning Network, at your
![create](https://imgur.com/8jNj8Zq.jpg) ![create](https://imgur.com/8jNj8Zq.jpg)
3. Open TPOS on the browser\ 3. Open TPOS on the browser\
![open](https://imgur.com/LZuoWzb.jpg) ![open](https://imgur.com/LZuoWzb.jpg)
4. Present invoice QR to costumer\ 4. Present invoice QR to customer\
![pay](https://imgur.com/tOwxn77.jpg) ![pay](https://imgur.com/tOwxn77.jpg)

View file

@ -139,8 +139,12 @@
input-debounce="0" input-debounce="0"
new-value-mode="add-unique" new-value-mode="add-unique"
label="Tip % Options (hit enter to add values)" label="Tip % Options (hit enter to add values)"
><q-tooltip>Hit enter to add values</q-tooltip></q-select ><q-tooltip>Hit enter to add values</q-tooltip>
> <template v-slot:hint>
You can leave this blank. A default rounding option is available
(round amount to a value)
</template>
</q-select>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated

View file

@ -13,7 +13,7 @@
<q-page-sticky v-if="exchangeRate" expand position="top"> <q-page-sticky v-if="exchangeRate" expand position="top">
<div class="row justify-center full-width"> <div class="row justify-center full-width">
<div class="col-12 col-sm-8 col-md-6 col-lg-4 text-center"> <div class="col-12 col-sm-8 col-md-6 col-lg-4 text-center">
<h3 class="q-mb-md">{% raw %}{{ famount }}{% endraw %}</h3> <h3 class="q-mb-md">{% raw %}{{ amountFormatted }}{% endraw %}</h3>
<h5 class="q-mt-none q-mb-sm"> <h5 class="q-mt-none q-mb-sm">
{% raw %}{{ fsat }}{% endraw %} <small>sat</small> {% raw %}{{ fsat }}{% endraw %} <small>sat</small>
</h5> </h5>
@ -148,6 +148,14 @@
</div> </div>
</div> </div>
</q-page-sticky> </q-page-sticky>
<q-page-sticky position="top-right" :offset="[18, 18]">
<q-btn
@click="showLastPayments"
fab
icon="receipt_long"
color="primary"
/>
</q-page-sticky>
<q-dialog <q-dialog
v-model="invoiceDialog.show" v-model="invoiceDialog.show"
position="top" position="top"
@ -165,12 +173,14 @@
></qrcode> ></qrcode>
</q-responsive> </q-responsive>
<div class="text-center"> <div class="text-center">
<h3 class="q-my-md">{% raw %}{{ famount }}{% endraw %}</h3> <h3 class="q-my-md">
{% raw %}{{ amountWithTipFormatted }}{% endraw %}
</h3>
<h5 class="q-mt-none"> <h5 class="q-mt-none">
{% raw %}{{ fsat }} {% raw %}{{ fsat }}
<small>sat</small> <small>sat</small>
<span v-show="tip_options" style="font-size: 0.75rem" <span v-show="tip_options" style="font-size: 0.75rem"
>( + {{ tipAmountSat }} tip)</span >( + {{ tipAmountFormatted }} tip)</span
> >
{% endraw %} {% endraw %}
</h5> </h5>
@ -204,19 +214,48 @@
style="padding: 10px; margin: 3px" style="padding: 10px; margin: 3px"
unelevated unelevated
@click="processTipSelection(tip)" @click="processTipSelection(tip)"
size="xl" size="lg"
:outline="!($q.dark.isActive)" :outline="!($q.dark.isActive)"
rounded rounded
color="primary" color="primary"
v-for="tip in this.tip_options" v-for="tip in tip_options.filter(f => f != 'Round')"
:key="tip" :key="tip"
>{% raw %}{{ tip }}{% endraw %}%</q-btn >{% raw %}{{ tip }}{% endraw %}%</q-btn
> >
</div> <q-btn
<div class="text-center q-mb-xl"> style="padding: 10px; margin: 3px"
<p><a @click="processTipSelection(0)"> No, thanks</a></p> unelevated
@click="setRounding"
size="lg"
:outline="!($q.dark.isActive)"
rounded
color="primary"
label="Round to"
></q-btn>
<div class="row q-my-lg" v-if="rounding">
<q-input
class="col"
ref="inputRounding"
v-model.number="tipRounding"
:placeholder="roundToSugestion"
type="number"
hint="Total amount including tip"
:prefix="currency"
>
</q-input>
<q-btn
class="q-ml-sm"
style="margin-bottom: 20px"
color="primary"
@click="calculatePercent"
>Ok</q-btn
>
</div>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn flat color="primary" @click="processTipSelection(0)"
>No, thanks</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>
@ -256,6 +295,38 @@
style="font-size: min(90vw, 40em)" style="font-size: min(90vw, 40em)"
></q-icon> ></q-icon>
</q-dialog> </q-dialog>
<q-dialog v-model="lastPaymentsDialog.show" position="bottom">
<q-card class="lnbits__dialog-card">
<q-card-section class="row items-center q-pb-none">
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-list separator class="q-mb-lg">
<q-item v-if="!lastPaymentsDialog.data.length">
<q-item-section>
<q-item-label class="text-bold">No paid invoices</q-item-label>
</q-item-section>
</q-item>
<q-item v-for="(payment, idx) in lastPaymentsDialog.data" :key="idx">
{%raw%}
<q-item-section>
<q-item-label class="text-bold"
>{{payment.amount / 1000}} sats</q-item-label
>
<q-item-label caption lines="2"
>Hash: {{payment.checking_id.slice(0, 30)}}...</q-item-label
>
</q-item-section>
<q-item-section side top>
<q-item-label caption>{{payment.dateFrom}}</q-item-label>
<q-icon name="check" color="green" />
</q-item-section>
{%endraw%}
</q-item>
</q-list>
</q-card>
</q-dialog>
</q-page> </q-page>
</q-page-container> </q-page-container>
{% endblock %} {% block styles %} {% endblock %} {% block styles %}
@ -294,8 +365,13 @@
exchangeRate: null, exchangeRate: null,
stack: [], stack: [],
tipAmount: 0.0, tipAmount: 0.0,
tipRounding: null,
hasNFC: false, hasNFC: false,
nfcTagReading: false, nfcTagReading: false,
lastPaymentsDialog: {
show: false,
data: []
},
invoiceDialog: { invoiceDialog: {
show: false, show: false,
data: null, data: null,
@ -310,32 +386,81 @@
}, },
complete: { complete: {
show: false show: false
} },
rounding: false
} }
}, },
computed: { computed: {
amount: function () { amount: function () {
if (!this.stack.length) return 0.0 if (!this.stack.length) return 0.0
return (Number(this.stack.join('')) / 100).toFixed(2) return Number(this.stack.join('') / 100)
}, },
famount: function () { amountFormatted: function () {
return LNbits.utils.formatCurrency(this.amount, this.currency) return LNbits.utils.formatCurrency(
this.amount.toFixed(2),
this.currency
)
},
amountWithTipFormatted: function () {
return LNbits.utils.formatCurrency(
(this.amount + this.tipAmount).toFixed(2),
this.currency
)
}, },
sat: function () { sat: function () {
if (!this.exchangeRate) return 0 if (!this.exchangeRate) return 0
return Math.ceil( return Math.ceil((this.amount / this.exchangeRate) * 100000000)
((this.amount - this.tipAmount) / this.exchangeRate) * 100000000
)
}, },
tipAmountSat: function () { tipAmountSat: function () {
if (!this.exchangeRate) return 0 if (!this.exchangeRate) return 0
return Math.ceil((this.tipAmount / this.exchangeRate) * 100000000) return Math.ceil((this.tipAmount / this.exchangeRate) * 100000000)
}, },
tipAmountFormatted: function () {
return LNbits.utils.formatSat(this.tipAmountSat)
},
fsat: function () { fsat: function () {
return LNbits.utils.formatSat(this.sat) return LNbits.utils.formatSat(this.sat)
},
isRoundValid() {
return this.tipRounding > this.amount
},
roundToSugestion() {
switch (true) {
case this.amount > 50:
toNext = 10
break
case this.amount > 6:
toNext = 5
break
case this.amount > 2.5:
toNext = 1
break
default:
toNext = 0.5
break
}
return Math.ceil(this.amount / toNext) * toNext
} }
}, },
methods: { methods: {
setRounding() {
this.rounding = true
this.tipRounding = this.roundToSugestion
this.$nextTick(() => this.$refs.inputRounding.focus())
},
calculatePercent() {
let change = ((this.tipRounding - this.amount) / this.amount) * 100
if (change < 0) {
this.$q.notify({
type: 'warning',
message: 'Amount with tip must be greater than initial amount.'
})
this.tipRounding = this.roundToSugestion
return
}
this.processTipSelection(change)
},
closeInvoiceDialog: function () { closeInvoiceDialog: function () {
this.stack = [] this.stack = []
this.tipAmount = 0.0 this.tipAmount = 0.0
@ -348,30 +473,18 @@
processTipSelection: function (selectedTipOption) { processTipSelection: function (selectedTipOption) {
this.tipDialog.show = false this.tipDialog.show = false
if (selectedTipOption) { if (!selectedTipOption) {
const tipAmount = parseFloat( this.tipAmount = 0.0
parseFloat((selectedTipOption / 100) * this.amount) return this.showInvoice()
)
const subtotal = parseFloat(this.amount)
const grandTotal = parseFloat((tipAmount + subtotal).toFixed(2))
const totalString = grandTotal.toFixed(2).toString()
this.stack = []
for (var i = 0; i < totalString.length; i++) {
const char = totalString[i]
if (char !== '.') {
this.stack.push(char)
}
}
this.tipAmount = tipAmount
} }
this.tipAmount = (selectedTipOption / 100) * this.amount
this.showInvoice() this.showInvoice()
}, },
submitForm: function () { submitForm: function () {
if (this.tip_options && this.tip_options.length) { if (this.tip_options && this.tip_options.length) {
this.rounding = false
this.tipRounding = null
this.showTipModal() this.showTipModal()
} else { } else {
this.showInvoice() this.showInvoice()
@ -520,6 +633,24 @@
self.exchangeRate = self.exchangeRate =
response.data.data['BTC' + self.currency][self.currency] response.data.data['BTC' + self.currency][self.currency]
}) })
},
getLastPayments() {
return axios
.get(`/tpos/api/v1/tposs/${this.tposId}/invoices`)
.then(res => {
if (res.data && res.data.length) {
let last = [...res.data]
this.lastPaymentsDialog.data = last.map(obj => {
obj.dateFrom = moment(obj.time * 1000).fromNow()
return obj
})
}
})
.catch(e => console.error(e))
},
showLastPayments() {
this.getLastPayments()
this.lastPaymentsDialog.show = true
} }
}, },
created: function () { created: function () {
@ -529,10 +660,26 @@
'{{ tpos.tip_options | tojson }}' == 'null' '{{ tpos.tip_options | tojson }}' == 'null'
? null ? null
: JSON.parse('{{ tpos.tip_options }}') : JSON.parse('{{ tpos.tip_options }}')
if ('{{ tpos.tip_wallet }}') {
this.tip_options.push('Round')
}
setInterval(function () { setInterval(function () {
getRates() getRates()
}, 120000) }, 120000)
} }
}) })
</script> </script>
<style scoped>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
</style>
{% endblock %} {% endblock %}

View file

@ -7,7 +7,8 @@ from lnurl import decode as decode_lnurl
from loguru import logger from loguru import logger
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user from lnbits.core.crud import get_latest_payments_by_extension, get_user
from lnbits.core.models import Payment
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
@ -51,7 +52,7 @@ async def api_tpos_delete(
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.")
await delete_tpos(tpos_id) await delete_tpos(tpos_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@tpos_ext.post("/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED) @tpos_ext.post("/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED)
@ -81,6 +82,30 @@ async def api_tpos_create_invoice(
return {"payment_hash": payment_hash, "payment_request": payment_request} return {"payment_hash": payment_hash, "payment_request": payment_request}
@tpos_ext.get("/api/v1/tposs/{tpos_id}/invoices")
async def api_tpos_get_latest_invoices(tpos_id: str = None):
try:
payments = [
Payment.from_row(row)
for row in await get_latest_payments_by_extension(
ext_name="tpos", ext_id=tpos_id
)
]
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return [
{
"checking_id": payment.checking_id,
"amount": payment.amount,
"time": payment.time,
"pending": payment.pending,
}
for payment in payments
]
@tpos_ext.post( @tpos_ext.post(
"/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK "/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
) )

View file

@ -3,25 +3,25 @@ async def m001_initial(db):
Initial wallet table. Initial wallet table.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE watchonly.wallets ( CREATE TABLE watchonly.wallets (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
"user" TEXT, "user" TEXT,
masterpub TEXT NOT NULL, masterpub TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
address_no INTEGER NOT NULL DEFAULT 0, address_no INTEGER NOT NULL DEFAULT 0,
balance INTEGER NOT NULL balance {db.big_int} NOT NULL
); );
""" """
) )
await db.execute( await db.execute(
""" f"""
CREATE TABLE watchonly.addresses ( CREATE TABLE watchonly.addresses (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
address TEXT NOT NULL, address TEXT NOT NULL,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
amount INTEGER NOT NULL amount {db.big_int} NOT NULL
); );
""" """
) )

View file

@ -6,6 +6,7 @@
filled filled
dense dense
v-model.number="feeRate" v-model.number="feeRate"
step="any"
:rules="[val => !!val || 'Field is required']" :rules="[val => !!val || 'Field is required']"
type="number" type="number"
label="sats/vbyte" label="sats/vbyte"

View file

@ -9,7 +9,7 @@ from fastapi import HTTPException
from fastapi.param_functions import Query from fastapi.param_functions import Query
from loguru import logger from loguru import logger
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse # type: ignore from starlette.responses import HTMLResponse
from lnbits.core.services import pay_invoice from lnbits.core.services import pay_invoice
@ -51,10 +51,24 @@ async def api_lnurl_response(request: Request, unique_hash):
# CALLBACK # CALLBACK
@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback") @withdraw_ext.get(
"/api/v1/lnurl/cb/{unique_hash}",
name="withdraw.api_lnurl_callback",
summary="lnurl withdraw callback",
description="""
This enpoints allows you to put unique_hash, k1
and a payment_request to get your payment_request paid.
""",
response_description="JSON with status",
responses={
200: {"description": "status: OK"},
400: {"description": "k1 is wrong or link open time or withdraw not working."},
404: {"description": "withdraw link not found."},
405: {"description": "withdraw link is spent."},
},
)
async def api_lnurl_callback( async def api_lnurl_callback(
unique_hash, unique_hash,
request: Request,
k1: str = Query(...), k1: str = Query(...),
pr: str = Query(...), pr: str = Query(...),
id_unique_hash=None, id_unique_hash=None,
@ -63,19 +77,22 @@ async def api_lnurl_callback(
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
if not link: if not link:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found" status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
) )
if link.is_spent: if link.is_spent:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="withdraw is spent."
) )
if link.k1 != k1: if link.k1 != k1:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Bad request.") raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="k1 is wrong.")
if now < link.open_time: if now < link.open_time:
return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."} raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"wait link open_time {link.open_time - now} seconds.",
)
usescsv = "" usescsv = ""
@ -95,7 +112,7 @@ async def api_lnurl_callback(
usescsv = ",".join(useslist) usescsv = ",".join(useslist)
if not found: if not found:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
) )
else: else:
usescsv = usescsv[1:] usescsv = usescsv[1:]
@ -144,7 +161,9 @@ async def api_lnurl_callback(
except Exception as e: except Exception as e:
await update_withdraw_link(link.id, **changesback) await update_withdraw_link(link.id, **changesback)
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"status": "ERROR", "reason": "Link not working"} raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=f"withdraw not working. {str(e)}"
)
# FOR LNURLs WHICH ARE UNIQUE # FOR LNURLs WHICH ARE UNIQUE

View file

@ -3,13 +3,13 @@ async def m001_initial(db):
Creates an improved withdraw table and migrates the existing data. Creates an improved withdraw table and migrates the existing data.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE withdraw.withdraw_links ( CREATE TABLE withdraw.withdraw_links (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT, wallet TEXT,
title TEXT, title TEXT,
min_withdrawable INTEGER DEFAULT 1, min_withdrawable {db.big_int} DEFAULT 1,
max_withdrawable INTEGER DEFAULT 1, max_withdrawable {db.big_int} DEFAULT 1,
uses INTEGER DEFAULT 1, uses INTEGER DEFAULT 1,
wait_time INTEGER, wait_time INTEGER,
is_unique INTEGER DEFAULT 0, is_unique INTEGER DEFAULT 0,
@ -28,13 +28,13 @@ async def m002_change_withdraw_table(db):
Creates an improved withdraw table and migrates the existing data. Creates an improved withdraw table and migrates the existing data.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE withdraw.withdraw_link ( CREATE TABLE withdraw.withdraw_link (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT, wallet TEXT,
title TEXT, title TEXT,
min_withdrawable INTEGER DEFAULT 1, min_withdrawable {db.big_int} DEFAULT 1,
max_withdrawable INTEGER DEFAULT 1, max_withdrawable {db.big_int} DEFAULT 1,
uses INTEGER DEFAULT 1, uses INTEGER DEFAULT 1,
wait_time INTEGER, wait_time INTEGER,
is_unique INTEGER DEFAULT 0, is_unique INTEGER DEFAULT 0,

View file

@ -290,8 +290,12 @@ new Vue({
}) })
} }
}, },
exportCSV: function () { exportCSV() {
LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls) LNbits.utils.exportCSV(
this.withdrawLinksTable.columns,
this.withdrawLinks,
'withdraw-links'
)
} }
}, },
created: function () { created: function () {

View file

@ -163,6 +163,7 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
) )
if settings.LNBITS_AD_SPACE: if settings.LNBITS_AD_SPACE:
t.env.globals["AD_TITLE"] = settings.LNBITS_AD_SPACE_TITLE
t.env.globals["AD_SPACE"] = settings.LNBITS_AD_SPACE t.env.globals["AD_SPACE"] = settings.LNBITS_AD_SPACE
t.env.globals["HIDE_API"] = settings.LNBITS_HIDE_API t.env.globals["HIDE_API"] = settings.LNBITS_HIDE_API
t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE

View file

@ -1,9 +1,7 @@
import time
import click import click
import uvicorn import uvicorn
from lnbits.settings import HOST, PORT from lnbits.settings import FORWARDED_ALLOW_IPS, HOST, PORT
@click.command( @click.command(
@ -14,10 +12,20 @@ from lnbits.settings import HOST, PORT
) )
@click.option("--port", default=PORT, help="Port to listen on") @click.option("--port", default=PORT, help="Port to listen on")
@click.option("--host", default=HOST, help="Host to run LNBits on") @click.option("--host", default=HOST, help="Host to run LNBits on")
@click.option(
"--forwarded-allow-ips", default=FORWARDED_ALLOW_IPS, help="Allowed proxy servers"
)
@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile") @click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile")
@click.option("--ssl-certfile", default=None, help="Path to SSL certificate") @click.option("--ssl-certfile", default=None, help="Path to SSL certificate")
@click.pass_context @click.pass_context
def main(ctx, port: int, host: str, ssl_keyfile: str, ssl_certfile: str): def main(
ctx,
port: int,
host: str,
forwarded_allow_ips: str,
ssl_keyfile: str,
ssl_certfile: str,
):
"""Launched with `poetry run lnbits` at root level""" """Launched with `poetry run lnbits` at root level"""
# this beautiful beast parses all command line arguments and passes them to the uvicorn server # this beautiful beast parses all command line arguments and passes them to the uvicorn server
d = dict() d = dict()
@ -37,6 +45,7 @@ def main(ctx, port: int, host: str, ssl_keyfile: str, ssl_certfile: str):
"lnbits.__main__:app", "lnbits.__main__:app",
port=port, port=port,
host=host, host=host,
forwarded_allow_ips=forwarded_allow_ips,
ssl_keyfile=ssl_keyfile, ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile, ssl_certfile=ssl_certfile,
**d **d

View file

@ -18,6 +18,8 @@ DEBUG = env.bool("DEBUG", default=False)
HOST = env.str("HOST", default="127.0.0.1") HOST = env.str("HOST", default="127.0.0.1")
PORT = env.int("PORT", default=5000) PORT = env.int("PORT", default=5000)
FORWARDED_ALLOW_IPS = env.str("FORWARDED_ALLOW_IPS", default="127.0.0.1")
LNBITS_PATH = path.dirname(path.realpath(__file__)) LNBITS_PATH = path.dirname(path.realpath(__file__))
LNBITS_DATA_FOLDER = env.str( LNBITS_DATA_FOLDER = env.str(
"LNBITS_DATA_FOLDER", default=path.join(LNBITS_PATH, "data") "LNBITS_DATA_FOLDER", default=path.join(LNBITS_PATH, "data")
@ -38,6 +40,9 @@ LNBITS_DISABLED_EXTENSIONS: List[str] = [
for x in env.list("LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str) for x in env.list("LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str)
] ]
LNBITS_AD_SPACE_TITLE = env.str(
"LNBITS_AD_SPACE_TITLE", default="Optional Advert Space"
)
LNBITS_AD_SPACE = [x.strip(" ") for x in env.list("LNBITS_AD_SPACE", default=[])] LNBITS_AD_SPACE = [x.strip(" ") for x in env.list("LNBITS_AD_SPACE", default=[])]
LNBITS_HIDE_API = env.bool("LNBITS_HIDE_API", default=False) LNBITS_HIDE_API = env.bool("LNBITS_HIDE_API", default=False)
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits") LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -124,7 +124,7 @@ async def check_pending_payments():
while True: while True:
async with db.connect() as conn: async with db.connect() as conn:
logger.debug( logger.info(
f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days" f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days"
) )
start_time: float = time.time() start_time: float = time.time()
@ -140,15 +140,15 @@ async def check_pending_payments():
for payment in pending_payments: for payment in pending_payments:
await payment.check_status(conn=conn) await payment.check_status(conn=conn)
logger.debug( logger.info(
f"Task: pending check finished for {len(pending_payments)} payments (took {time.time() - start_time:0.3f} s)" f"Task: pending check finished for {len(pending_payments)} payments (took {time.time() - start_time:0.3f} s)"
) )
# we delete expired invoices once upon the first pending check # we delete expired invoices once upon the first pending check
if incoming: if incoming:
logger.debug("Task: deleting all expired invoices") logger.info("Task: deleting all expired invoices")
start_time: float = time.time() start_time: float = time.time()
await delete_expired_invoices(conn=conn) await delete_expired_invoices(conn=conn)
logger.debug( logger.info(
f"Task: expired invoice deletion finished (took {time.time() - start_time:0.3f} s)" f"Task: expired invoice deletion finished (took {time.time() - start_time:0.3f} s)"
) )

View file

@ -199,6 +199,18 @@
> >
</q-toolbar-title> </q-toolbar-title>
<q-space></q-space> <q-space></q-space>
<q-btn
flat
dense
:color="($q.dark.isActive) ? 'white' : 'primary'"
type="a"
href="/docs"
target="_blank"
rel="noopener"
>
API DOCS
<q-tooltip>View LNbits Swagger API docs</q-tooltip>
</q-btn>
<q-btn <q-btn
flat flat
dense dense

View file

@ -95,7 +95,6 @@ exclude = """(?x)(
| ^lnbits/extensions/events. | ^lnbits/extensions/events.
| ^lnbits/extensions/hivemind. | ^lnbits/extensions/hivemind.
| ^lnbits/extensions/invoices. | ^lnbits/extensions/invoices.
| ^lnbits/extensions/jukebox.
| ^lnbits/extensions/livestream. | ^lnbits/extensions/livestream.
| ^lnbits/extensions/lnaddress. | ^lnbits/extensions/lnaddress.
| ^lnbits/extensions/lndhub. | ^lnbits/extensions/lndhub.
@ -103,10 +102,8 @@ exclude = """(?x)(
| ^lnbits/extensions/lnurldevice. | ^lnbits/extensions/lnurldevice.
| ^lnbits/extensions/lnurlp. | ^lnbits/extensions/lnurlp.
| ^lnbits/extensions/lnurlpayout. | ^lnbits/extensions/lnurlpayout.
| ^lnbits/extensions/ngrok.
| ^lnbits/extensions/offlineshop. | ^lnbits/extensions/offlineshop.
| ^lnbits/extensions/paywall. | ^lnbits/extensions/paywall.
| ^lnbits/extensions/satsdice.
| ^lnbits/extensions/satspay. | ^lnbits/extensions/satspay.
| ^lnbits/extensions/scrub. | ^lnbits/extensions/scrub.
| ^lnbits/extensions/splitpayments. | ^lnbits/extensions/splitpayments.

View file

@ -1,55 +1,72 @@
aiofiles==0.8.0 aiofiles==0.8.0 ; python_version >= "3.7" and python_version < "4.0"
anyio==3.6.1 anyio==3.6.1 ; python_version >= "3.7" and python_version < "4.0"
asyncio==3.4.3 asgiref==3.4.1 ; python_version >= "3.7" and python_version < "4.0"
attrs==21.4.0 asn1crypto==1.5.1 ; python_version >= "3.7" and python_version < "4.0"
bech32==1.2.0 async-timeout==4.0.2 ; python_version >= "3.7" and python_version < "4.0"
bitstring==3.1.9 attrs==21.2.0 ; python_version >= "3.7" and python_version < "4.0"
cerberus==1.3.4 base58==2.1.1 ; python_version >= "3.7" and python_version < "4.0"
certifi==2022.6.15 bech32==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
cffi==1.15.0 bitstring==3.1.9 ; python_version >= "3.7" and python_version < "4.0"
click==8.1.3 cerberus==1.3.4 ; python_version >= "3.7" and python_version < "4.0"
ecdsa==0.18.0 certifi==2021.5.30 ; python_version >= "3.7" and python_version < "4.0"
embit==0.5.0 cffi==1.15.0 ; python_version >= "3.7" and python_version < "4.0"
environs==9.5.0 charset-normalizer==2.0.6 ; python_version >= "3.7" and python_version < "4.0"
fastapi==0.79.0 click==8.0.1 ; python_version >= "3.7" and python_version < "4.0"
h11==0.12.0 coincurve==17.0.0 ; python_version >= "3.7" and python_version < "4.0"
httpcore==0.15.0 colorama==0.4.5 ; python_version >= "3.7" and python_version < "4.0" and platform_system == "Windows" or python_version >= "3.7" and python_version < "4.0" and sys_platform == "win32"
httptools==0.4.0 cryptography==36.0.2 ; python_version >= "3.7" and python_version < "4.0"
httpx==0.23.0 ecdsa==0.17.0 ; python_version >= "3.7" and python_version < "4.0"
idna==3.3 embit==0.4.9 ; python_version >= "3.7" and python_version < "4.0"
jinja2==3.0.1 enum34==1.1.10 ; python_version >= "3.7" and python_version < "4.0"
lnurl==0.3.6 environs==9.3.3 ; python_version >= "3.7" and python_version < "4.0"
loguru==0.6.0 fastapi==0.78.0 ; python_version >= "3.7" and python_version < "4.0"
markupsafe==2.1.1 grpcio==1.49.1 ; python_version >= "3.7" and python_version < "4.0"
marshmallow==3.17.0 h11==0.12.0 ; python_version >= "3.7" and python_version < "4.0"
outcome==1.2.0 httpcore==0.15.0 ; python_version >= "3.7" and python_version < "4.0"
packaging==21.3 httptools==0.4.0 ; python_version >= "3.7" and python_version < "4.0"
psycopg2-binary==2.9.3 httpx==0.23.0 ; python_version >= "3.7" and python_version < "4.0"
pycparser==2.21 idna==3.2 ; python_version >= "3.7" and python_version < "4.0"
pycryptodomex==3.15.0 importlib-metadata==4.8.1 ; python_version >= "3.7" and python_version < "4.0"
pydantic==1.9.1 jinja2==3.0.1 ; python_version >= "3.7" and python_version < "4.0"
pyngrok==5.1.0 lnurl==0.3.6 ; python_version >= "3.7" and python_version < "4.0"
pyparsing==3.0.9 loguru==0.5.3 ; python_version >= "3.7" and python_version < "4.0"
pypng==0.20220715.0 markupsafe==2.0.1 ; python_version >= "3.7" and python_version < "4.0"
pyqrcode==1.2.1 marshmallow==3.17.0 ; python_version >= "3.7" and python_version < "4.0"
pyscss==1.4.0 outcome==1.1.0 ; python_version >= "3.7" and python_version < "4.0"
python-dotenv==0.20.0 packaging==21.3 ; python_version >= "3.7" and python_version < "4.0"
pyyaml==6.0 pathlib2==2.3.7.post1 ; python_version >= "3.7" and python_version < "4.0"
represent==1.6.0.post0 protobuf==4.21.7 ; python_version >= "3.7" and python_version < "4.0"
rfc3986==1.5.0 psycopg2-binary==2.9.1 ; python_version >= "3.7" and python_version < "4.0"
secp256k1==0.14.0 pycparser==2.21 ; python_version >= "3.7" and python_version < "4.0"
shortuuid==1.0.9 pycryptodomex==3.14.1 ; python_version >= "3.7" and python_version < "4.0"
six==1.16.0 pydantic==1.8.2 ; python_version >= "3.7" and python_version < "4.0"
sniffio==1.2.0 pyln-bolt7==1.0.246 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy-aio==0.17.0 pyln-client==0.11.1 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy==1.3.23 pyln-proto==0.11.1 ; python_version >= "3.7" and python_version < "4.0"
sse-starlette==0.10.3 pyparsing==3.0.9 ; python_version >= "3.7" and python_version < "4.0"
starlette==0.19.1 pypng==0.0.21 ; python_version >= "3.7" and python_version < "4.0"
typing-extensions==4.3.0 pyqrcode==1.2.1 ; python_version >= "3.7" and python_version < "4.0"
uvicorn==0.18.2 pyscss==1.4.0 ; python_version >= "3.7" and python_version < "4.0"
uvloop==0.16.0 pysocks==1.7.1 ; python_version >= "3.7" and python_version < "4.0"
watchfiles==0.16.0 python-dotenv==0.19.0 ; python_version >= "3.7" and python_version < "4.0"
websockets==10.3 pyyaml==5.4.1 ; python_version >= "3.7" and python_version < "4.0"
websocket-client==1.3.3 represent==1.6.0.post0 ; python_version >= "3.7" and python_version < "4.0"
async-timeout==4.0.2 rfc3986==1.5.0 ; python_version >= "3.7" and python_version < "4.0"
setuptools==65.4.0 rfc3986[idna2008]==1.5.0 ; python_version >= "3.7" and python_version < "4.0"
secp256k1==0.14.0 ; python_version >= "3.7" and python_version < "4.0"
setuptools==65.4.1 ; python_version >= "3.7" and python_version < "4.0"
shortuuid==1.0.1 ; python_version >= "3.7" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.7" and python_version < "4.0"
sniffio==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy-aio==0.17.0 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy==1.3.23 ; python_version >= "3.7" and python_version < "4.0"
sse-starlette==0.6.2 ; python_version >= "3.7" and python_version < "4.0"
starlette==0.19.1 ; python_version >= "3.7" and python_version < "4.0"
typing-extensions==3.10.0.2 ; python_version >= "3.7" and python_version < "4.0"
uvicorn==0.18.1 ; python_version >= "3.7" and python_version < "4.0"
uvloop==0.16.0 ; python_version >= "3.7" and python_version < "4.0"
watchgod==0.7 ; python_version >= "3.7" and python_version < "4.0"
websocket-client==1.3.3 ; python_version >= "3.7" and python_version < "4.0"
websockets==10.0 ; python_version >= "3.7" and python_version < "4.0"
win32-setctime==1.1.0 ; python_version >= "3.7" and python_version < "4.0" and sys_platform == "win32"
zipp==3.5.0 ; python_version >= "3.7" and python_version < "4.0"

View file

@ -60,7 +60,7 @@ async def from_wallet(from_user):
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from") wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from")
await credit_wallet( await credit_wallet(
wallet_id=wallet.id, wallet_id=wallet.id,
amount=99999999, amount=999999999,
) )
yield wallet yield wallet
@ -77,7 +77,7 @@ async def to_wallet(to_user):
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to") wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to")
await credit_wallet( await credit_wallet(
wallet_id=wallet.id, wallet_id=wallet.id,
amount=99999999, amount=999999999,
) )
yield wallet yield wallet

Binary file not shown.

View file

@ -1,6 +1,7 @@
import argparse import argparse
import os import os
import sqlite3 import sqlite3
import sys
from typing import List from typing import List
import psycopg2 import psycopg2