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
# Allow users and admins by user IDs (comma separated list)
LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok"
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# csv ad image filepaths or urls, extensions can choose to honor
LNBITS_AD_SPACE=""
# Ad space description
# 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
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"
WORKDIR /app
RUN mkdir -p lnbits/data
COPY . .

View file

@ -229,6 +229,24 @@ async def get_wallet_payment(
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(
*,
wallet_id: Optional[str] = None,

View file

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

View file

@ -183,6 +183,23 @@
<div class="col q-pl-md">&nbsp;</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>

View file

@ -388,9 +388,14 @@
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
ADS.split(';') %}
<q-card>
<a href="{{ AD[0] }}"
><img width="100%" src="{{ AD[1] }}"
/></a> </q-card
<q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">{{ AD_TITLE }}</h6>
</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 %}
</div>
</div>

View file

@ -12,7 +12,7 @@ from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import async_timeout
import httpx
import pyqrcode
from fastapi import Depends, Header, Query, Request
from fastapi import Depends, Header, Query, Request, Response
from fastapi.exceptions import HTTPException
from fastapi.params import Body
from loguru import logger
@ -155,30 +155,29 @@ class CreateInvoiceData(BaseModel):
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.description_hash:
if data.description_hash or data.unhashed_description:
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:
raise HTTPException(
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 = ""
else:
description_hash = b""
unhashed_description = b""
memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat":
amount = int(data.amount)
else:
@ -585,8 +584,8 @@ class DecodePayment(BaseModel):
data: str
@core_app.post("/api/v1/payments/decode")
async def api_payments_decode(data: DecodePayment):
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
async def api_payments_decode(data: DecodePayment, response: Response):
payment_str = data.data
try:
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,
}
except:
response.status_code = HTTPStatus.BAD_REQUEST
return {"message": "Failed to decode"}

View file

@ -1,6 +1,7 @@
import asyncio
import datetime
import os
import re
import time
from contextlib import asynccontextmanager
from typing import Optional
@ -73,18 +74,39 @@ class Connection(Compat):
query = query.replace("?", "%s")
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:
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()
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()
await result.close()
return row
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):

View file

@ -95,4 +95,4 @@ async def api_bleskomat_delete(
)
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>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<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>
<br />
<q-btn
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)
await delete_card(card_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT
@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."
)
await delete_discordbot_user(user_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT
# Activate Extension
@ -129,4 +129,4 @@ async def api_discordbot_wallets_delete(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
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.tasks import catch_everything_and_restart
db = Database("ext_events")

View file

@ -5,16 +5,16 @@ from urllib.parse import urlparse
import httpx
from fastapi import HTTPException
from loguru import logger
from lnbits import bolt11
from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice
from lnbits.extensions.events.models import CreateTicket
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
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():

View file

@ -135,7 +135,14 @@
var self = this
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) {
self.paymentReq = response.data.payment_request
self.paymentCheck = response.data.payment_hash

View file

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

View file

@ -2,6 +2,7 @@ from http import HTTPStatus
from fastapi.param_functions import Query
from fastapi.params import Depends
from loguru import logger
from starlette.exceptions import HTTPException
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.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.events.models import CreateEvent, CreateTicket
from loguru import logger
from . import events_ext
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_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT
#########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
@ -6,11 +6,9 @@ from . import db
from .models import CreateJukeboxPayment, CreateJukeLinkData, Jukebox, JukeboxPayment
async def create_jukebox(
data: CreateJukeLinkData, inkey: Optional[str] = ""
) -> Jukebox:
async def create_jukebox(data: CreateJukeLinkData) -> Jukebox:
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@ -36,13 +34,13 @@ async def create_jukebox(
async def update_jukebox(
data: CreateJukeLinkData, juke_id: Optional[str] = ""
data: Union[CreateJukeLinkData, Jukebox], juke_id: str = ""
) -> Optional[Jukebox]:
q = ", ".join([f"{field[0]} = ?" for field in data])
items = [f"{field[1]}" for field in data]
items.append(juke_id)
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,))
return Jukebox(**row) if row else None
@ -72,7 +70,7 @@ async def delete_jukebox(juke_id: str):
"""
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:
result = await db.execute(
await db.execute(
"""
INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
VALUES (?, ?, ?, ?)

View file

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

View file

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

View file

@ -17,7 +17,9 @@ templates = Jinja2Templates(directory="templates")
@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(
"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."
)
devices = await api_get_jukebox_device_check(juke_id)
deviceConnected = False
for device in devices["devices"]:
if device["id"] == jukebox.sp_device.split("-")[1]:
deviceConnected = True
@ -48,5 +51,5 @@ async def connect_to_jukebox(request: Request, juke_id):
else:
return jukebox_renderer().TemplateResponse(
"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
import httpx
from fastapi import Request
from fastapi.param_functions import Query
from fastapi.params import Depends
from starlette.exceptions import HTTPException
@ -29,9 +28,7 @@ from .models import CreateJukeboxPayment, CreateJukeLinkData
@jukebox_ext.get("/api/v1/jukebox")
async def api_get_jukeboxs(
req: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
all_wallets: bool = Query(False),
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
):
wallet_user = wallet.wallet.user
@ -53,54 +50,52 @@ async def api_check_credentials_callbac(
access_token: str = Query(None),
refresh_token: str = Query(None),
):
sp_code = ""
sp_access_token = ""
sp_refresh_token = ""
try:
jukebox = await get_jukebox(juke_id)
except:
if not jukebox:
raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN)
if 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:
jukebox.sp_access_token = access_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>"
@jukebox_ext.get("/api/v1/jukebox/{juke_id}")
async def api_check_credentials_check(
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
):
@jukebox_ext.get("/api/v1/jukebox/{juke_id}", dependencies=[Depends(require_admin_key)])
async def api_check_credentials_check(juke_id: str = Query(None)):
jukebox = await get_jukebox(juke_id)
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)
async def api_create_update_jukebox(
data: CreateJukeLinkData,
juke_id: str = Query(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
data: CreateJukeLinkData, juke_id: str = Query(None)
):
if juke_id:
jukebox = await update_jukebox(data, juke_id=juke_id)
else:
jukebox = await create_jukebox(data, inkey=wallet.wallet.inkey)
jukebox = await create_jukebox(data)
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(
juke_id=None, wallet: WalletTypeInfo = Depends(require_admin_key)
juke_id: str = Query(None),
):
await delete_jukebox(juke_id)
try:
return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)]
except:
raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox")
# try:
# return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)]
# except:
# raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox")
################JUKEBOX ENDPOINTS##################
@ -114,9 +109,8 @@ async def api_get_jukebox_song(
sp_playlist: str = Query(None),
retry: bool = Query(False),
):
try:
jukebox = await get_jukebox(juke_id)
except:
if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
tracks = []
async with httpx.AsyncClient() as client:
@ -152,14 +146,13 @@ async def api_get_jukebox_song(
}
)
except:
something = None
pass
return [track for track in tracks]
async def api_get_token(juke_id=None):
try:
async def api_get_token(juke_id):
jukebox = await get_jukebox(juke_id)
except:
if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
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"]
await update_jukebox(jukebox, juke_id=juke_id)
except:
something = None
pass
return True
@ -198,9 +191,8 @@ async def api_get_token(juke_id=None):
async def api_get_jukebox_device_check(
juke_id: str = Query(None), retry: bool = Query(False)
):
try:
jukebox = await get_jukebox(juke_id)
except:
if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
async with httpx.AsyncClient() as client:
rDevice = await client.get(
@ -221,7 +213,7 @@ async def api_get_jukebox_device_check(
status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
)
else:
return api_get_jukebox_device_check(juke_id, retry=True)
return await api_get_jukebox_device_check(juke_id, retry=True)
else:
raise HTTPException(
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}")
async def api_get_jukebox_invoice(juke_id, song_id):
try:
jukebox = await get_jukebox(juke_id)
except:
if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
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
)
jukebox_payment = await create_jukebox_payment(data)
return data
return jukebox_payment
@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),
retry: bool = Query(False),
):
try:
jukebox = await get_jukebox(juke_id)
except:
if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
await api_get_jukebox_invoice_check(pay_hash, juke_id)
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:
r = await client.get(
"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(
retry: bool = Query(False), juke_id: str = Query(None)
):
try:
jukebox = await get_jukebox(juke_id)
except:
if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
async with httpx.AsyncClient() as client:
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)
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}")
async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
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")
@ -93,8 +93,8 @@ async def api_add_track(
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)):
ls = await get_or_create_livestream_by_wallet(g.wallet.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")
await delete_domain(domain_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT
# ADDRESSES
@ -253,4 +253,4 @@ async def api_address_delete(address_id, g: WalletTypeInfo = Depends(get_key_typ
)
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)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT
#########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.")
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!')"
>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 />
<div class="row q-mt-lg q-gutter-sm">
<q-btn
@ -534,6 +545,7 @@
filter: '',
currency: 'USD',
lnurlValue: '',
websocketMessage: '',
switches: 0,
lnurldeviceLinks: [],
lnurldeviceLinksObj: [],
@ -622,6 +634,11 @@
}
}
},
computed: {
wsMessage: function () {
return this.websocketMessage
}
},
methods: {
openQrCodeDialog: function (lnurldevice_id) {
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
@ -631,11 +648,17 @@
this.qrCodeDialog.data = _.clone(lnurldevice)
this.qrCodeDialog.data.url =
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
},
lnurlValueFetch: function (lnurl) {
lnurlValueFetch: function (lnurl, switchId) {
this.lnurlValue = lnurl
this.websocketConnector(
'wss://' + window.location.host + '/lnurldevice/ws/' + switchId
)
},
addSwitch: function () {
var self = this
@ -797,6 +820,25 @@
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() {
this.formDialoglnurldevice.data = {
lnurl_toggle: false,

View file

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

View file

@ -8,7 +8,7 @@ async def m001_initial(db):
id {db.serial_primary_key},
wallet TEXT NOT NULL,
description TEXT NOT NULL,
amount INTEGER NOT NULL,
amount {db.big_int} NOT NULL,
served_meta INTEGER NOT NULL,
served_pr INTEGER NOT NULL
);
@ -60,3 +60,11 @@ async def m004_fiat_base_multiplier(db):
await db.execute(
"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)
comment_chars: int = Query(0, ge=0, lt=800)
webhook_url: str = Query(None)
webhook_headers: str = Query(None)
webhook_body: str = Query(None)
success_text: str = Query(None)
success_url: str = Query(None)
fiat_base_multiplier: int = Query(100, ge=1)
@ -31,6 +33,8 @@ class PayLink(BaseModel):
served_meta: int
served_pr: int
webhook_url: Optional[str]
webhook_headers: Optional[str]
webhook_body: Optional[str]
success_text: Optional[str]
success_url: 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:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
pay_link.webhook_url,
json={
kwargs = {
"json": {
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": payment.amount,
"comment": payment.extra.get("comment"),
"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)
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)

View file

@ -213,6 +213,24 @@
label="Webhook URL (optional)"
hint="A URL to be called whenever this link receives a payment."
></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
filled
dense

View file

@ -1,3 +1,4 @@
import json
from http import HTTPStatus
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
)
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,
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
if data.currency and data.fiat_base_multiplier:

View file

@ -80,7 +80,7 @@ async def api_lnurlpayout_delete(
)
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)

View file

@ -1,3 +1,4 @@
# type: ignore
from os import getenv
from fastapi import Request
@ -34,7 +35,9 @@ ngrok_tunnel = ngrok.connect(port)
@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(
"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.
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)
@ -18,18 +18,18 @@ Costumers must use an LNURL pay capable wallet.
![offline shop back office](https://i.imgur.com/Ei7cxj9.png)
2. Begin by creating an item, click "ADD NEW ITEM"
- 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_
- set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time costumer scans to pay\
- 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 customer scans to pay\
![add new item](https://i.imgur.com/pkZqRgj.png)
3. After creating some products, click on "PRINT QR CODES"\
![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\
![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
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 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 (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)

View file

@ -22,7 +22,7 @@ async def m001_initial(db):
description TEXT NOT NULL,
image TEXT, -- image/png;base64,...
enabled BOOLEAN NOT NULL DEFAULT true,
price INTEGER NOT NULL,
price {db.big_int} NOT NULL,
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)):
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
await delete_item_from_shop(shop.id, item_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT
class CreateMethodData(BaseModel):

View file

@ -3,14 +3,14 @@ async def m001_initial(db):
Initial paywalls table.
"""
await db.execute(
"""
f"""
CREATE TABLE paywall.paywalls (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
secret TEXT NOT NULL,
url TEXT NOT NULL,
memo TEXT NOT NULL,
amount INTEGER NOT NULL,
amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ 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(
"""
f"""
CREATE TABLE paywall.paywalls (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
url TEXT NOT NULL,
memo TEXT NOT NULL,
description TEXT NULL,
amount INTEGER DEFAULT 0,
amount {db.big_int} DEFAULT 0,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """,

View file

@ -49,7 +49,7 @@ async def api_paywall_delete(
)
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}")

View file

@ -8,7 +8,6 @@ from .models import (
CreateSatsDiceLink,
CreateSatsDicePayment,
CreateSatsDiceWithdraw,
HashCheck,
satsdiceLink,
satsdicePayment,
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]
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()])
await db.execute(
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(
"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()])
await db.execute(
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
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,))
@ -119,9 +118,15 @@ async def create_satsdice_payment(data: CreateSatsDicePayment) -> satsdicePaymen
)
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"
return payment
@ -134,9 +139,7 @@ async def get_satsdice_payment(payment_hash: str) -> Optional[satsdicePayment]:
return satsdicePayment(**row) if row else None
async def update_satsdice_payment(
payment_hash: int, **kwargs
) -> Optional[satsdicePayment]:
async def update_satsdice_payment(payment_hash: str, **kwargs) -> satsdicePayment:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
@ -147,7 +150,7 @@ async def update_satsdice_payment(
"SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?",
(payment_hash,),
)
return satsdicePayment(**row) if row else None
return satsdicePayment(**row)
##################SATSDICE WITHDRAW LINKS
@ -168,16 +171,16 @@ async def create_satsdice_withdraw(data: CreateSatsDiceWithdraw) -> satsdiceWith
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
data["payment_hash"],
data["satsdice_pay"],
data["value"],
data.payment_hash,
data.satsdice_pay,
data.value,
urlsafe_short_hash(),
urlsafe_short_hash(),
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"
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(
"""
INSERT INTO satsdice.hash_checkw (
@ -262,18 +265,14 @@ async def create_withdraw_hash_check(the_hash: str, lnurl_id: str) -> 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(
"SELECT * FROM satsdice.hash_checkw WHERE id = ?", (the_hash,)
)
rowlnurl = await db.fetchone(
"SELECT * FROM satsdice.hash_checkw WHERE lnurl_id = ?", (lnurl_id,)
)
if not rowlnurl:
await create_withdraw_hash_check(the_hash, lnurl_id)
return {"lnurl": True, "hash": False}
else:
if not rowid:
if not rowlnurl or not rowid:
await create_withdraw_hash_check(the_hash, lnurl_id)
return {"lnurl": True, "hash": False}
else:

View file

@ -1,4 +1,3 @@
import hashlib
import json
import math
from http import HTTPStatus
@ -83,15 +82,18 @@ async def api_lnurlp_callback(
success_action = link.success_action(payment_hash=payment_hash, req=req)
data: CreateSatsDicePayment = {
"satsdice_pay": link.id,
"value": amount_received / 1000,
"payment_hash": payment_hash,
}
data = CreateSatsDicePayment(
satsdice_pay=link.id,
value=amount_received / 1000,
payment_hash=payment_hash,
)
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)
@ -133,9 +135,7 @@ async def api_lnurlw_response(req: Request, unique_hash: str = Query(None)):
name="satsdice.api_lnurlw_callback",
)
async def api_lnurlw_callback(
req: Request,
unique_hash: str = Query(None),
k1: str = Query(None),
pr: str = Query(None),
):
@ -146,6 +146,7 @@ async def api_lnurlw_callback(
return {"status": "ERROR", "reason": "spent"}
paylink = await get_satsdice_pay(link.satsdice_pay)
if paylink:
await update_satsdice_withdraw(link.id, used=1)
await pay_invoice(
wallet_id=paylink.wallet,

View file

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

View file

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

View file

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

View file

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

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)
3. The charge will appear on the _Charges_ section\
![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\
![offchain payment](https://i.imgur.com/4191SMV.png)
- or pay on chain\

View file

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

View file

@ -1,16 +1,16 @@
import json
from typing import List, Optional
import httpx
from loguru import logger
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.helpers import urlsafe_short_hash
from ..watchonly.crud import get_config, get_fresh_address
# from lnbits.db import open_ext_db
from . import db
from .models import Charges, CreateCharge
from .helpers import fetch_onchain_balance
from .models import Charges, CreateCharge, SatsPayThemes
###############CHARGES##########################
@ -18,6 +18,10 @@ from .models import Charges, CreateCharge
async def create_charge(user: str, data: CreateCharge) -> Charges:
charge_id = urlsafe_short_hash()
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)
onchainaddress = onchain.address
else:
@ -48,9 +52,11 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
completelinktext,
time,
amount,
balance
balance,
extra,
custom_css
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
charge_id,
@ -67,6 +73,8 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
data.time,
data.amount,
0,
data.extra,
data.custom_css,
),
)
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,))
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)
if not charge.paid:
if charge.onchainaddress:
config = await get_charge_config(charge_id)
try:
async with httpx.AsyncClient() as client:
r = await client.get(
config.mempool_endpoint
+ "/api/address/"
+ charge.onchainaddress
)
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
respAmount = await fetch_onchain_balance(charge)
if respAmount > charge.balance:
await update_charge(charge_id=charge_id, balance=respAmount)
except Exception:
pass
except Exception as e:
logger.warning(e)
if charge.lnbitswallet:
invoice_status = await api_payment(charge.payment_hash)
if invoice_status["paid"]:
return await update_charge(charge_id=charge_id, balance=charge.amount)
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
return Charges.from_row(row) if row else None
return await get_charge(charge_id)
async def get_charge_config(charge_id: str):
row = await db.fetchone(
"""SELECT "user" FROM satspay.charges WHERE id = ?""", (charge_id,)
################## 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 "timestamp" DESC """,
(user_id,),
)
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
def compact_charge(charge: Charges):
return {
def public_charge(charge: Charges):
c = {
"id": charge.id,
"description": charge.description,
"onchainaddress": charge.onchainaddress,
@ -13,5 +16,40 @@ def compact_charge(charge: Charges):
"balance": charge.balance,
"paid": charge.paid,
"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(
"""
f"""
CREATE TABLE satspay.charges (
id TEXT NOT NULL PRIMARY KEY,
"user" TEXT,
@ -18,11 +18,47 @@ async def m001_initial(db):
completelink TEXT,
completelinktext TEXT,
time INTEGER,
amount INTEGER,
balance INTEGER DEFAULT 0,
amount {db.big_int},
balance {db.big_int} DEFAULT 0,
timestamp TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
async def m002_add_charge_extra_data(db):
"""
Add 'extra' column for storing various config about the charge (JSON format)
"""
await db.execute(
"""ALTER TABLE satspay.charges
ADD COLUMN extra TEXT DEFAULT '{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}';
"""
)
async def m003_add_themes_table(db):
"""
Themes table
"""
await db.execute(
"""
CREATE TABLE satspay.themes (
css_id TEXT NOT NULL PRIMARY KEY,
"user" TEXT,
title TEXT,
custom_css TEXT
);
"""
)
async def m004_add_custom_css_to_charges(db):
"""
Add custom css option column to the 'charges' table
"""
await db.execute("ALTER TABLE satspay.charges ADD COLUMN custom_css TEXT;")

View file

@ -1,3 +1,4 @@
import json
from datetime import datetime, timedelta
from sqlite3 import Row
from typing import Optional
@ -13,8 +14,17 @@ class CreateCharge(BaseModel):
webhook: str = Query(None)
completelink: str = Query(None)
completelinktext: str = Query(None)
custom_css: Optional[str]
time: int = Query(..., ge=1)
amount: int = Query(..., ge=1)
extra: str = "{}"
class ChargeConfig(BaseModel):
mempool_endpoint: Optional[str]
network: Optional[str]
webhook_success: Optional[bool] = False
webhook_message: Optional[str]
class Charges(BaseModel):
@ -28,6 +38,8 @@ class Charges(BaseModel):
webhook: Optional[str]
completelink: Optional[str]
completelinktext: Optional[str] = "Back to Merchant"
extra: str = "{}"
custom_css: Optional[str]
time: int
amount: int
balance: int
@ -54,3 +66,22 @@ class Charges(BaseModel):
return True
else:
return False
@property
def config(self) -> ChargeConfig:
charge_config = json.loads(self.extra)
return ChargeConfig(**charge_config)
def must_call_webhook(self):
return self.webhook and self.paid and self.config.webhook_success == 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 charge = _.clone(obj)
const charge = {...oldObj, ...obj}
charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time
charge.time = minutesToTime(obj.time)
charge.timeLeft = minutesToTime(obj.time_left)
charge.expanded = false
charge.displayUrl = ['/satspay/', obj.id].join('')
charge.expanded = oldObj.expanded
charge.expanded = oldObj.expanded || false
charge.pendingBalance = oldObj.pendingBalance || 0
return charge
}
const mapCSS = (obj, oldObj = {}) => {
const theme = _.clone(obj)
return theme
}
const minutesToTime = min =>
min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : ''

View file

@ -1,4 +1,5 @@
import asyncio
import json
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.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():
@ -30,4 +32,9 @@ async def on_invoice_paid(payment: Payment) -> None:
return
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
specifically for SatsPayServer!<br />
<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>
<br />

View file

@ -109,7 +109,7 @@
<q-btn
flat
disable
v-if="!charge.lnbitswallet || charge.time_elapsed"
v-if="!charge.payment_request || charge.time_elapsed"
style="color: primary; width: 100%"
label="lightning⚡"
>
@ -131,7 +131,7 @@
<q-btn
flat
disable
v-if="!charge.onchainwallet || charge.time_elapsed"
v-if="!charge.onchainaddress || charge.time_elapsed"
style="color: primary; width: 100%"
label="onchain⛓"
>
@ -170,14 +170,18 @@
name="check"
style="color: green; font-size: 21.4em"
></q-icon>
<div class="row text-center q-mt-lg">
<div class="col text-center">
<q-btn
outline
v-if="charge.webhook"
v-if="charge.completelink"
type="a"
:href="charge.completelink"
:label="charge.completelinktext"
></q-btn>
</div>
</div>
</div>
<div v-else>
<div class="row text-center q-mb-sm">
<div class="col text-center">
@ -218,7 +222,7 @@
<div class="col text-center">
<a
style="color: unset"
:href="mempool_endpoint + '/address/' + charge.onchainaddress"
:href="'https://' + mempoolHostname + '/address/' + charge.onchainaddress"
target="_blank"
><span
class="text-subtitle1"
@ -241,6 +245,8 @@
name="check"
style="color: green; font-size: 21.4em"
></q-icon>
<div class="row text-center q-mt-lg">
<div class="col text-center">
<q-btn
outline
v-if="charge.webhook"
@ -249,6 +255,8 @@
:label="charge.completelinktext"
></q-btn>
</div>
</div>
</div>
<div v-else>
<div class="row items-center q-mb-sm">
<div class="col text-center">
@ -289,7 +297,17 @@
</div>
<div class="col-lg- 4 col-md-3 col-sm-1"></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 %}
<script src="https://mempool.space/mempool.js"></script>
@ -303,7 +321,8 @@
data() {
return {
charge: JSON.parse('{{charge_data | tojson}}'),
mempool_endpoint: '{{mempool_endpoint}}',
mempoolEndpoint: '{{mempool_endpoint}}',
network: '{{network}}',
pendingFunds: 0,
ws: null,
newProgress: 0.4,
@ -316,19 +335,19 @@
cancelListener: () => {}
}
},
methods: {
startPaymentNotifier() {
this.cancelListener()
if (!this.lnbitswallet) return
this.cancelListener = LNbits.events.onInvoicePaid(
this.wallet,
payment => {
this.checkInvoiceBalance()
computed: {
mempoolHostname: function () {
let hostname = new URL(this.mempoolEndpoint).hostname
if (this.network === 'Testnet') {
hostname += '/testnet'
}
return hostname
}
)
},
methods: {
checkBalances: async function () {
if (this.charge.hasStaleBalance) return
if (!this.charge.payment_request && this.charge.hasOnchainStaleBalance)
return
try {
const {data} = await LNbits.api.request(
'GET',
@ -345,7 +364,7 @@
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname
hostname: new URL(this.mempoolEndpoint).hostname
})
try {
@ -353,7 +372,8 @@
address: this.charge.onchainaddress
})
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
.filter(u => !u.status.confirmed)
@ -388,10 +408,10 @@
const {
bitcoin: {websocket}
} = 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 => {
if (this.charge.onchainaddress) {
this.trackAddress(this.charge.onchainaddress)
@ -428,13 +448,14 @@
}
},
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()
await this.checkBalances()
// empty for onchain
this.wallet.inkey = '{{ wallet_inkey }}'
this.startPaymentNotifier()
await this.checkBalances()
if (!this.charge.paid) {
this.loopRefresh()

View file

@ -8,6 +8,26 @@
<q-btn unelevated color="primary" @click="formDialogCharge.show = true"
>New charge
</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>
@ -203,9 +223,14 @@
:href="props.row.webhook"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.webhook || props.row.webhook}}</a
>{{props.row.webhook}}</a
>
</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 class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg">ID:</div>
@ -254,6 +279,63 @@
</q-table>
</q-card-section>
</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 class="col-12 col-md-5 q-gutter-y-md">
@ -298,32 +380,6 @@
>
</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="col">
<div v-if="walletLinks.length > 0">
@ -372,6 +428,52 @@
label="Wallet *"
>
</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">
<q-btn
unelevated
@ -389,6 +491,43 @@
</q-form>
</q-card>
</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>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<!-- lnbits/static/vendor
@ -405,15 +544,21 @@
mixins: [windowMixin],
data: function () {
return {
settings: {},
filter: '',
admin: '{{ admin }}',
balance: null,
walletLinks: [],
chargeLinks: [],
themeLinks: [],
themeOptions: [],
onchainwallet: '',
rescanning: false,
mempool: {
endpoint: ''
endpoint: '',
network: 'Mainnet'
},
showAdvanced: false,
chargesTable: {
columns: [
@ -488,7 +633,25 @@
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: {
show: false,
data: {
@ -496,20 +659,33 @@
onchainwallet: '',
lnbits: false,
description: '',
custom_css: '',
time: null,
amount: null
}
},
formDialogThemes: {
show: false,
data: {
custom_css: ''
}
}
}
},
methods: {
cancelThemes: function (data) {
this.formDialogCharge.data.custom_css = ''
this.formDialogThemes.show = false
},
cancelCharge: function (data) {
this.formDialogCharge.data.description = ''
this.formDialogCharge.data.onchain = false
this.formDialogCharge.data.onchainwallet = ''
this.formDialogCharge.data.lnbitswallet = ''
this.formDialogCharge.data.time = null
this.formDialogCharge.data.amount = null
this.formDialogCharge.data.webhook = ''
this.formDialogCharge.data.custom_css = ''
this.formDialogCharge.data.completelink = ''
this.formDialogCharge.show = false
},
@ -518,7 +694,7 @@
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/wallet',
`/watchonly/api/v1/wallet?network=${this.mempool.network}`,
this.g.user.wallets[0].inkey
)
this.walletLinks = data.map(w => ({
@ -538,6 +714,7 @@
this.g.user.wallets[0].inkey
)
this.mempool.endpoint = data.mempool_endpoint
this.mempool.network = data.network || 'Mainnet'
const url = new URL(this.mempool.endpoint)
this.mempool.hostname = url.hostname
} catch (error) {
@ -572,12 +749,43 @@
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 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 wallet = this.g.user.wallets[0].inkey
data.amount = parseInt(data.amount)
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)
},
refreshActiveChargesBalance: async function () {
@ -642,6 +850,68 @@
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) {
try {
const resp = await LNbits.api.request(
@ -694,9 +964,12 @@
}
},
created: async function () {
if (this.admin == 'True') {
await this.getThemes()
}
await this.getCharges()
await this.getWalletLinks()
await this.getWalletConfig()
await this.getWalletLinks()
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
await this.rescanOnchainAddresses()
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)

View file

@ -1,25 +1,32 @@
import json
from http import HTTPStatus
from fastapi import Response
from fastapi.param_functions import Depends
from fastapi.templating import Jinja2Templates
from loguru import logger
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits.core.crud import get_wallet
from lnbits.core.models import User
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 .crud import get_charge, get_charge_config
from .crud import get_charge, get_theme
templates = Jinja2Templates(directory="templates")
@satspay_ext.get("/", response_class=HTMLResponse)
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(
"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(
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(
"satspay/display.html",
{
"request": request,
"charge_data": charge.dict(),
"wallet_inkey": inkey,
"mempool_endpoint": mempool_endpoint,
"charge_data": public_charge(charge),
"mempool_endpoint": charge.config.mempool_endpoint,
"network": charge.config.network,
},
)
@satspay_ext.get("/css/{css_id}")
async def display(css_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
import httpx
from fastapi.params import Depends
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_wallet
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
@ -11,17 +14,22 @@ from lnbits.decorators import (
require_invoice_key,
)
from lnbits.extensions.satspay import satspay_ext
from lnbits.settings import LNBITS_ADMIN_EXTENSIONS, LNBITS_ADMIN_USERS
from .crud import (
check_address_balance,
create_charge,
delete_charge,
delete_theme,
get_charge,
get_charges,
get_theme,
get_themes,
save_theme,
update_charge,
)
from .helpers import compact_charge
from .models import CreateCharge
from .helpers import call_webhook, public_charge
from .models import CreateCharge, SatsPayThemes
#############################CHARGES##########################
@ -58,6 +66,7 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
**{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid},
**{"webhook_message": charge.config.webhook_message},
}
for charge in await get_charges(wallet.wallet.user)
]
@ -94,7 +103,7 @@ async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_
)
await delete_charge(charge_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT
#############################BALANCE##########################
@ -119,19 +128,55 @@ async def api_charge_balance(charge_id):
status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist."
)
if charge.paid and charge.webhook:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
charge.webhook,
json=compact_charge(charge),
timeout=40,
if charge.must_call_webhook():
resp = await call_webhook(charge)
extra = {**charge.config.dict(), **resp}
await update_charge(charge_id=charge.id, extra=json.dumps(extra))
return {**public_charge(charge)}
#############################THEMES##########################
@satspay_ext.post("/api/v1/themes")
@satspay_ext.post("/api/v1/themes/{css_id}")
async def api_themes_save(
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.",
)
except AssertionError:
charge.webhook = None
return {
**compact_charge(charge),
**{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid},
}
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)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT

View file

@ -31,14 +31,20 @@
style="flex-wrap: nowrap"
v-for="(target, t) in targets"
>
<q-input
<q-select
dense
outlined
:options="g.user.wallets.filter(w => w.id !== selectedWallet.id).map(o => ({name: o.name, value: o.id}))"
v-model="target.wallet"
label="Wallet"
:hint="t === targets.length - 1 ? 'A wallet ID or invoice key.' : undefined"
@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
dense
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!",
)
await delete_donation(donation_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT
@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!",
)
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.")
await delete_domain(domain_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT
#########subdomains##########
@ -198,4 +198,4 @@ async def api_subdomain_delete(
)
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)
3. Open TPOS on the browser\
![open](https://imgur.com/LZuoWzb.jpg)
4. Present invoice QR to costumer\
4. Present invoice QR to customer\
![pay](https://imgur.com/tOwxn77.jpg)

View file

@ -139,8 +139,12 @@
input-debounce="0"
new-value-mode="add-unique"
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">
<q-btn
unelevated

View file

@ -13,7 +13,7 @@
<q-page-sticky v-if="exchangeRate" expand position="top">
<div class="row justify-center full-width">
<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">
{% raw %}{{ fsat }}{% endraw %} <small>sat</small>
</h5>
@ -148,6 +148,14 @@
</div>
</div>
</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
v-model="invoiceDialog.show"
position="top"
@ -165,12 +173,14 @@
></qrcode>
</q-responsive>
<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">
{% raw %}{{ fsat }}
<small>sat</small>
<span v-show="tip_options" style="font-size: 0.75rem"
>( + {{ tipAmountSat }} tip)</span
>( + {{ tipAmountFormatted }} tip)</span
>
{% endraw %}
</h5>
@ -204,19 +214,48 @@
style="padding: 10px; margin: 3px"
unelevated
@click="processTipSelection(tip)"
size="xl"
size="lg"
:outline="!($q.dark.isActive)"
rounded
color="primary"
v-for="tip in this.tip_options"
v-for="tip in tip_options.filter(f => f != 'Round')"
:key="tip"
>{% raw %}{{ tip }}{% endraw %}%</q-btn
>
<q-btn
style="padding: 10px; margin: 3px"
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 class="text-center q-mb-xl">
<p><a @click="processTipSelection(0)"> No, thanks</a></p>
</div>
<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>
</div>
</q-card>
@ -256,6 +295,38 @@
style="font-size: min(90vw, 40em)"
></q-icon>
</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-container>
{% endblock %} {% block styles %}
@ -294,8 +365,13 @@
exchangeRate: null,
stack: [],
tipAmount: 0.0,
tipRounding: null,
hasNFC: false,
nfcTagReading: false,
lastPaymentsDialog: {
show: false,
data: []
},
invoiceDialog: {
show: false,
data: null,
@ -310,32 +386,81 @@
},
complete: {
show: false
}
},
rounding: false
}
},
computed: {
amount: function () {
if (!this.stack.length) return 0.0
return (Number(this.stack.join('')) / 100).toFixed(2)
return Number(this.stack.join('') / 100)
},
famount: function () {
return LNbits.utils.formatCurrency(this.amount, this.currency)
amountFormatted: function () {
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 () {
if (!this.exchangeRate) return 0
return Math.ceil(
((this.amount - this.tipAmount) / this.exchangeRate) * 100000000
)
return Math.ceil((this.amount / this.exchangeRate) * 100000000)
},
tipAmountSat: function () {
if (!this.exchangeRate) return 0
return Math.ceil((this.tipAmount / this.exchangeRate) * 100000000)
},
tipAmountFormatted: function () {
return LNbits.utils.formatSat(this.tipAmountSat)
},
fsat: function () {
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: {
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 () {
this.stack = []
this.tipAmount = 0.0
@ -348,30 +473,18 @@
processTipSelection: function (selectedTipOption) {
this.tipDialog.show = false
if (selectedTipOption) {
const tipAmount = parseFloat(
parseFloat((selectedTipOption / 100) * this.amount)
)
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
if (!selectedTipOption) {
this.tipAmount = 0.0
return this.showInvoice()
}
this.tipAmount = (selectedTipOption / 100) * this.amount
this.showInvoice()
},
submitForm: function () {
if (this.tip_options && this.tip_options.length) {
this.rounding = false
this.tipRounding = null
this.showTipModal()
} else {
this.showInvoice()
@ -520,6 +633,24 @@
self.exchangeRate =
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 () {
@ -529,10 +660,26 @@
'{{ tpos.tip_options | tojson }}' == 'null'
? null
: JSON.parse('{{ tpos.tip_options }}')
if ('{{ tpos.tip_wallet }}') {
this.tip_options.push('Round')
}
setInterval(function () {
getRates()
}, 120000)
}
})
</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 %}

View file

@ -7,7 +7,8 @@ from lnurl import decode as decode_lnurl
from loguru import logger
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.views.api import api_payment
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.")
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)
@ -81,6 +82,30 @@ async def api_tpos_create_invoice(
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(
"/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.
"""
await db.execute(
"""
f"""
CREATE TABLE watchonly.wallets (
id TEXT NOT NULL PRIMARY KEY,
"user" TEXT,
masterpub TEXT NOT NULL,
title TEXT NOT NULL,
address_no INTEGER NOT NULL DEFAULT 0,
balance INTEGER NOT NULL
balance {db.big_int} NOT NULL
);
"""
)
await db.execute(
"""
f"""
CREATE TABLE watchonly.addresses (
id TEXT NOT NULL PRIMARY KEY,
address TEXT NOT NULL,
wallet TEXT NOT NULL,
amount INTEGER NOT NULL
amount {db.big_int} NOT NULL
);
"""
)

View file

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

View file

@ -9,7 +9,7 @@ from fastapi import HTTPException
from fastapi.param_functions import Query
from loguru import logger
from starlette.requests import Request
from starlette.responses import HTMLResponse # type: ignore
from starlette.responses import HTMLResponse
from lnbits.core.services import pay_invoice
@ -51,10 +51,24 @@ async def api_lnurl_response(request: Request, unique_hash):
# 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(
unique_hash,
request: Request,
k1: str = Query(...),
pr: str = Query(...),
id_unique_hash=None,
@ -63,19 +77,22 @@ async def api_lnurl_callback(
now = int(datetime.now().timestamp())
if not link:
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:
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:
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:
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 = ""
@ -95,7 +112,7 @@ async def api_lnurl_callback(
usescsv = ",".join(useslist)
if not found:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
)
else:
usescsv = usescsv[1:]
@ -144,7 +161,9 @@ async def api_lnurl_callback(
except Exception as e:
await update_withdraw_link(link.id, **changesback)
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

View file

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

View file

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

View file

@ -163,6 +163,7 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
)
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["HIDE_API"] = settings.LNBITS_HIDE_API
t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE

View file

@ -1,9 +1,7 @@
import time
import click
import uvicorn
from lnbits.settings import HOST, PORT
from lnbits.settings import FORWARDED_ALLOW_IPS, HOST, PORT
@click.command(
@ -14,10 +12,20 @@ from lnbits.settings import HOST, PORT
)
@click.option("--port", default=PORT, help="Port to listen 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-certfile", default=None, help="Path to SSL certificate")
@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"""
# this beautiful beast parses all command line arguments and passes them to the uvicorn server
d = dict()
@ -37,6 +45,7 @@ def main(ctx, port: int, host: str, ssl_keyfile: str, ssl_certfile: str):
"lnbits.__main__:app",
port=port,
host=host,
forwarded_allow_ips=forwarded_allow_ips,
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
**d

View file

@ -18,6 +18,8 @@ DEBUG = env.bool("DEBUG", default=False)
HOST = env.str("HOST", default="127.0.0.1")
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_DATA_FOLDER = env.str(
"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)
]
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_HIDE_API = env.bool("LNBITS_HIDE_API", default=False)
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:
async with db.connect() as conn:
logger.debug(
logger.info(
f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days"
)
start_time: float = time.time()
@ -140,15 +140,15 @@ async def check_pending_payments():
for payment in pending_payments:
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)"
)
# we delete expired invoices once upon the first pending check
if incoming:
logger.debug("Task: deleting all expired invoices")
logger.info("Task: deleting all expired invoices")
start_time: float = time.time()
await delete_expired_invoices(conn=conn)
logger.debug(
logger.info(
f"Task: expired invoice deletion finished (took {time.time() - start_time:0.3f} s)"
)

View file

@ -199,6 +199,18 @@
>
</q-toolbar-title>
<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
flat
dense

View file

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

View file

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

Binary file not shown.

View file

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