overwrite mock data

This commit is contained in:
callebtc 2022-11-24 16:08:12 +01:00
commit 7bfd8f3a62
105 changed files with 1550 additions and 734 deletions

View File

@ -1,19 +1,25 @@
HOST=127.0.0.1
PORT=5000
# uvicorn variable, allow https behind a proxy
# FORWARDED_ALLOW_IPS="*"
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
LNBITS_HIDE_API=false
# Disable extensions for all users, use "all" to disable all extensions
LNBITS_DISABLED_EXTENSIONS="amilk"
@ -67,7 +73,7 @@ LNBITS_KEY=LNBITS_ADMIN_KEY
LND_REST_ENDPOINT=https://127.0.0.1:8080/
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_REST_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
# To use an AES-encrypted macaroon, set
# To use an AES-encrypted macaroon, set
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
# LNPayWallet

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- LNbits version: [e.g. 0.9.2 or commit hash]
- Database [e.g. sqlite, postgres]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature request]"
labels: feature request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -0,0 +1,10 @@
---
name: Something else
about: Anything else that you need to say
title: ''
labels: ''
assignees: ''
---

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

@ -48,7 +48,9 @@ poetry run lnbits
# Note that you have to add the line DEBUG=true in your .env file, too.
```
## Option 2: Nix
## Option 2: Nix
> note: currently not supported while we make some architectural changes on the path to leave beta
```sh
git clone https://github.com/lnbits/lnbits-legend.git
@ -155,6 +157,7 @@ kill_timeout = 30
HOST="127.0.0.1"
PORT=5000
LNBITS_FORCE_HTTPS=true
FORWARDED_ALLOW_IPS="*"
LNBITS_DATA_FOLDER="/data"
${PUT_YOUR_LNBITS_ENV_VARS_HERE}
@ -217,8 +220,8 @@ You need to edit the `.env` file.
```sh
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
# postgres://<user>:<myPassword>@<host>/<lnbits> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# postgres://<user>:<myPassword>@<host>:<port>/<lnbits> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost:5432/lnbits"
# save and exit
```

View File

@ -79,3 +79,8 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey
### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000

View File

@ -91,7 +91,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
# app.add_middleware(ASGIProxyFix)
check_funding_source(app)
register_assets(app)

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

@ -361,6 +361,35 @@ new Vue({
this.receive.status = 'pending'
})
},
onInitQR: async function (promise) {
try {
await promise
} catch (error) {
let mapping = {
NotAllowedError: 'ERROR: you need to grant camera access permission',
NotFoundError: 'ERROR: no camera on this device',
NotSupportedError:
'ERROR: secure context required (HTTPS, localhost)',
NotReadableError: 'ERROR: is the camera already in use?',
OverconstrainedError: 'ERROR: installed cameras are not suitable',
StreamApiNotSupportedError:
'ERROR: Stream API is not supported in this browser',
InsecureContextError:
'ERROR: Camera access is only permitted in secure context. Use HTTPS or localhost rather than HTTP.'
}
let valid_error = Object.keys(mapping).filter(key => {
return error.name === key
})
let camera_error = valid_error
? mapping[valid_error]
: `ERROR: Camera error (${error.name})`
this.parse.camera.show = false
this.$q.notify({
message: camera_error,
type: 'negative'
})
}
},
decodeQR: function (res) {
this.parse.data.request = res
this.decodeRequest()

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>
@ -653,6 +658,7 @@
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
@init="onInitQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
@ -671,6 +677,7 @@
<div class="text-center q-mb-lg">
<qrcode-stream
@decode="decodeQR"
@init="onInitQR"
class="rounded-borders"
></qrcode-stream>
</div>

View File

@ -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:
@ -476,7 +475,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
except:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) == 2:
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
name, domain = name_domain
url = (
("http://" if domain.endswith(".onion") else "https://")

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

@ -57,7 +57,7 @@ async def check_for_pending_swaps():
swap_status = get_swap_status(swap)
# should only happen while development when regtest is reset
if swap_status.exists is False:
logger.warning(f"Boltz - swap: {swap.boltz_id} does not exist.")
logger.debug(f"Boltz - swap: {swap.boltz_id} does not exist.")
await update_swap_status(swap.id, "failed")
continue
@ -73,7 +73,7 @@ async def check_for_pending_swaps():
else:
if swap_status.hit_timeout:
if not swap_status.has_lockup:
logger.warning(
logger.debug(
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
)
await update_swap_status(swap.id, "timeout")

View File

@ -10,7 +10,7 @@ from .models import Copilots, CreateCopilotData
async def create_copilot(
data: CreateCopilotData, inkey: Optional[str] = ""
) -> Copilots:
) -> Optional[Copilots]:
copilot_id = urlsafe_short_hash()
await db.execute(
"""
@ -67,19 +67,19 @@ async def create_copilot(
async def update_copilot(
data: CreateCopilotData, copilot_id: Optional[str] = ""
data: CreateCopilotData, copilot_id: str
) -> Optional[Copilots]:
q = ", ".join([f"{field[0]} = ?" for field in data])
items = [f"{field[1]}" for field in data]
items.append(copilot_id)
await db.execute(f"UPDATE copilot.newer_copilots SET {q} WHERE id = ?", (items))
await db.execute(f"UPDATE copilot.newer_copilots SET {q} WHERE id = ?", (items,))
row = await db.fetchone(
"SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
)
return Copilots(**row) if row else None
async def get_copilot(copilot_id: str) -> Copilots:
async def get_copilot(copilot_id: str) -> Optional[Copilots]:
row = await db.fetchone(
"SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
)

View File

@ -26,7 +26,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
webhook = None
data = None
if payment.extra.get("tag") != "copilot":
if not payment.extra or payment.extra.get("tag") != "copilot":
# not an copilot invoice
return
@ -71,12 +71,12 @@ async def on_invoice_paid(payment: Payment) -> None:
async def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status
await core_db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)
if payment.extra:
payment.extra["wh_status"] = status
await core_db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)

View File

@ -15,7 +15,9 @@ templates = Jinja2Templates(directory="templates")
@copilot_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 copilot_renderer().TemplateResponse(
"copilot/index.html", {"request": request, "user": user.dict()}
)
@ -44,7 +46,7 @@ class ConnectionManager:
async def connect(self, websocket: WebSocket, copilot_id: str):
await websocket.accept()
websocket.id = copilot_id
websocket.id = copilot_id # type: ignore
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
@ -52,7 +54,7 @@ class ConnectionManager:
async def send_personal_message(self, message: str, copilot_id: str):
for connection in self.active_connections:
if connection.id == copilot_id:
if connection.id == copilot_id: # type: ignore
await connection.send_text(message)
async def broadcast(self, message: str):

View File

@ -23,7 +23,7 @@ from .views import updater
@copilot_ext.get("/api/v1/copilot")
async def api_copilots_retrieve(
req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
req: Request, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
wallet_user = wallet.wallet.user
copilots = [copilot.dict() for copilot in await get_copilots(wallet_user)]
@ -37,7 +37,7 @@ async def api_copilots_retrieve(
async def api_copilot_retrieve(
req: Request,
copilot_id: str = Query(None),
wallet: WalletTypeInfo = Depends(get_key_type),
wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
):
copilot = await get_copilot(copilot_id)
if not copilot:
@ -54,7 +54,7 @@ async def api_copilot_retrieve(
async def api_copilot_create_or_update(
data: CreateCopilotData,
copilot_id: str = Query(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
):
data.user = wallet.wallet.user
data.wallet = wallet.wallet.id
@ -67,7 +67,8 @@ async def api_copilot_create_or_update(
@copilot_ext.delete("/api/v1/copilot/{copilot_id}")
async def api_copilot_delete(
copilot_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
copilot_id: str = Query(None),
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
):
copilot = await get_copilot(copilot_id)

View File

@ -98,21 +98,21 @@ async def get_discordbot_wallet(wallet_id: str) -> Optional[Wallets]:
return Wallets(**row) if row else None
async def get_discordbot_wallets(admin_id: str) -> Optional[Wallets]:
async def get_discordbot_wallets(admin_id: str) -> List[Wallets]:
rows = await db.fetchall(
"SELECT * FROM discordbot.wallets WHERE admin = ?", (admin_id,)
)
return [Wallets(**row) for row in rows]
async def get_discordbot_users_wallets(user_id: str) -> Optional[Wallets]:
async def get_discordbot_users_wallets(user_id: str) -> List[Wallets]:
rows = await db.fetchall(
"""SELECT * FROM discordbot.wallets WHERE "user" = ?""", (user_id,)
)
return [Wallets(**row) for row in rows]
async def get_discordbot_wallet_transactions(wallet_id: str) -> Optional[Payment]:
async def get_discordbot_wallet_transactions(wallet_id: str) -> List[Payment]:
return await get_payments(
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True
)

View File

@ -9,7 +9,9 @@ from . import discordbot_ext, discordbot_renderer
@discordbot_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 discordbot_renderer().TemplateResponse(
"discordbot/index.html", {"request": request, "user": user.dict()}
)

View File

@ -27,32 +27,37 @@ from .models import CreateUserData, CreateUserWallet
@discordbot_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
async def api_discordbot_users(wallet: WalletTypeInfo = Depends(get_key_type)):
async def api_discordbot_users(
wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
):
user_id = wallet.wallet.user
return [user.dict() for user in await get_discordbot_users(user_id)]
@discordbot_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK)
async def api_discordbot_user(user_id, wallet: WalletTypeInfo = Depends(get_key_type)):
async def api_discordbot_user(
user_id, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
user = await get_discordbot_user(user_id)
return user.dict()
if user:
return user.dict()
@discordbot_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED)
async def api_discordbot_users_create(
data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type)
data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
user = await create_discordbot_user(data)
full = user.dict()
full["wallets"] = [
wallet.dict() for wallet in await get_discordbot_users_wallets(user.id)
]
wallets = await get_discordbot_users_wallets(user.id)
if wallets:
full["wallets"] = [wallet for wallet in wallets]
return full
@discordbot_ext.delete("/api/v1/users/{user_id}")
async def api_discordbot_users_delete(
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
user_id, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
user = await get_discordbot_user(user_id)
if not user:
@ -60,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
@ -75,7 +80,7 @@ async def api_discordbot_activate_extension(
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
)
update_user_extension(user_id=userid, extension=extension, active=active)
await update_user_extension(user_id=userid, extension=extension, active=active)
return {"extension": "updated"}
@ -84,7 +89,7 @@ async def api_discordbot_activate_extension(
@discordbot_ext.post("/api/v1/wallets")
async def api_discordbot_wallets_create(
data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type)
data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
user = await create_discordbot_wallet(
user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id
@ -93,28 +98,30 @@ async def api_discordbot_wallets_create(
@discordbot_ext.get("/api/v1/wallets")
async def api_discordbot_wallets(wallet: WalletTypeInfo = Depends(get_key_type)):
async def api_discordbot_wallets(
wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
):
admin_id = wallet.wallet.user
return [wallet.dict() for wallet in await get_discordbot_wallets(admin_id)]
return await get_discordbot_wallets(admin_id)
@discordbot_ext.get("/api/v1/transactions/{wallet_id}")
async def api_discordbot_wallet_transactions(
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
return await get_discordbot_wallet_transactions(wallet_id)
@discordbot_ext.get("/api/v1/wallets/{user_id}")
async def api_discordbot_users_wallets(
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
user_id, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
return [s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id)]
return await get_discordbot_users_wallets(user_id)
@discordbot_ext.delete("/api/v1/wallets/{wallet_id}")
async def api_discordbot_wallets_delete(
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
get_wallet = await get_discordbot_wallet(wallet_id)
if not get_wallet:
@ -122,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

@ -1,7 +1,10 @@
import asyncio
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_events")
@ -13,5 +16,11 @@ def events_renderer():
return template_renderer(["lnbits/extensions/events/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def events_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -0,0 +1,39 @@
import asyncio
import json
from http import HTTPStatus
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
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name())
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
# (avoid loops)
if (
"events" == payment.extra.get("tag")
and payment.extra.get("name")
and payment.extra.get("email")
):
CreateTicket.name = str(payment.extra.get("name"))
CreateTicket.email = str(payment.extra.get("email"))
await api_ticket_send_ticket(payment.memo, payment.payment_hash, CreateTicket)
return

View File

@ -135,7 +135,14 @@
var self = this
axios
.get('/events/api/v1/tickets/' + '{{ event_id }}')
.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
@ -78,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##########
@ -96,8 +97,8 @@ async def api_tickets(
return [ticket.dict() for ticket in await get_tickets(wallet_ids)]
@events_ext.get("/api/v1/tickets/{event_id}")
async def api_ticket_make_ticket(event_id):
@events_ext.get("/api/v1/tickets/{event_id}/{name}/{email}")
async def api_ticket_make_ticket(event_id, name, email):
event = await get_event(event_id)
if not event:
raise HTTPException(
@ -108,11 +109,10 @@ async def api_ticket_make_ticket(event_id):
wallet_id=event.wallet,
amount=event.price_per_ticket,
memo=f"{event_id}",
extra={"tag": "events"},
extra={"tag": "events", "name": name, "email": email},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {"payment_hash": payment_hash, "payment_request": payment_request}
@ -156,7 +156,7 @@ async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_
)
await delete_ticket(ticket_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT
# Event Tickets

View File

@ -12,7 +12,10 @@ templates = Jinja2Templates(directory="templates")
@example_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 example_renderer().TemplateResponse(
"example/index.html", {"request": request, "user": user.dict()}
)

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

View File

@ -17,7 +17,9 @@ templates = Jinja2Templates(directory="templates")
@jukebox_ext.get("/", response_class=HTMLResponse)
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:
jukebox = await get_jukebox(juke_id)
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:
jukebox = await get_jukebox(juke_id)
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:
jukebox = await get_jukebox(juke_id)
except:
async def api_get_token(juke_id):
jukebox = await get_jukebox(juke_id)
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:
jukebox = await get_jukebox(juke_id)
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:
jukebox = await get_jukebox(juke_id)
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:
jukebox = await get_jukebox(juke_id)
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:
jukebox = await get_jukebox(juke_id)
if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
async with httpx.AsyncClient() as client:
try:

View File

@ -4,10 +4,10 @@ import json
from loguru import logger
from lnbits.core import db as core_db
from lnbits.core.crud import create_payment
from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name, urlsafe_short_hash
from lnbits.tasks import internal_invoice_listener, register_invoice_listener
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_livestream_by_track, get_producer, get_track
@ -44,44 +44,20 @@ async def on_invoice_paid(payment: Payment) -> None:
# now we make a special kind of internal transfer
amount = int(payment.amount * (100 - ls.fee_pct) / 100)
# mark the original payment with two extra keys, "shared_with" and "received"
# (this prevents us from doing this process again and it's informative)
# and reduce it by the amount we're going to send to the producer
await core_db.execute(
"""
UPDATE apipayments
SET extra = ?, amount = ?
WHERE hash = ?
AND checking_id NOT LIKE 'internal_%'
""",
(
json.dumps(
dict(
**payment.extra,
shared_with=[producer.name, producer.id],
received=payment.amount,
)
),
payment.amount - amount,
payment.payment_hash,
),
)
# perform an internal transfer using the same payment_hash to the producer wallet
internal_checking_id = f"internal_{urlsafe_short_hash()}"
await create_payment(
wallet_id=producer.wallet,
checking_id=internal_checking_id,
payment_request="",
payment_hash=payment.payment_hash,
amount=amount,
payment_hash, payment_request = await create_invoice(
wallet_id=tpos.tip_wallet,
amount=amount, # sats
internal=True,
memo=f"Revenue from '{track.name}'.",
pending=False,
)
logger.debug(f"livestream: producer invoice created: {payment_hash}")
# manually send this for now
# await internal_invoice_paid.send(internal_checking_id)
await internal_invoice_listener.put(internal_checking_id)
checking_id = await pay_invoice(
payment_request=payment_request,
wallet_id=payment.wallet_id,
extra={"tag": "livestream"},
)
logger.debug(f"livestream: producer invoice paid: {checking_id}")
# so the flow is the following:
# - we receive, say, 1000 satoshis

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

@ -23,9 +23,22 @@ async def create_lnurldevice(
currency,
device,
profit,
amount
amount,
pin,
profit1,
amount1,
pin1,
profit2,
amount2,
pin2,
profit3,
amount3,
pin3,
profit4,
amount4,
pin4
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
lnurldevice_id,
@ -36,6 +49,19 @@ async def create_lnurldevice(
data.device,
data.profit,
data.amount,
data.pin,
data.profit1,
data.amount1,
data.pin1,
data.profit2,
data.amount2,
data.pin2,
data.profit3,
data.amount3,
data.pin3,
data.profit4,
data.amount4,
data.pin4,
),
)
return await get_lnurldevice(lnurldevice_id)

View File

@ -8,6 +8,7 @@ from typing import Optional
from embit import bech32, compact
from fastapi import Request
from fastapi.param_functions import Query
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.services import create_invoice
@ -91,6 +92,9 @@ async def lnurl_v1_params(
device_id: str = Query(None),
p: str = Query(None),
atm: str = Query(None),
gpio: str = Query(None),
profit: str = Query(None),
amount: str = Query(None),
):
device = await get_lnurldevice(device_id)
if not device:
@ -101,20 +105,28 @@ async def lnurl_v1_params(
paymentcheck = await get_lnurlpayload(p)
if device.device == "atm":
if paymentcheck:
return {"status": "ERROR", "reason": f"Payment already claimed"}
if paymentcheck.payhash != "payment_hash":
return {"status": "ERROR", "reason": f"Payment already claimed"}
if device.device == "switch":
price_msat = (
await fiat_amount_as_satoshis(float(device.profit), device.currency)
await fiat_amount_as_satoshis(float(profit), device.currency)
if device.currency != "sat"
else amount_in_cent
) * 1000
# Check they're not trying to trick the switch!
check = False
for switch in device.switches(request):
if switch[0] == gpio and switch[1] == profit and switch[2] == amount:
check = True
if not check:
return {"status": "ERROR", "reason": f"Switch params wrong"}
lnurldevicepayment = await create_lnurldevicepayment(
deviceid=device.id,
payload="bla",
payload=amount,
sats=price_msat,
pin=1,
pin=gpio,
payhash="bla",
)
if not lnurldevicepayment:
@ -126,7 +138,7 @@ async def lnurl_v1_params(
),
"minSendable": price_msat,
"maxSendable": price_msat,
"metadata": await device.lnurlpay_metadata(),
"metadata": device.lnurlpay_metadata,
}
if len(p) % 4 > 0:
p += "=" * (4 - (len(p) % 4))
@ -165,7 +177,7 @@ async def lnurl_v1_params(
"callback": request.url_for(
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
),
"k1": lnurldevicepayment.id,
"k1": p,
"minWithdrawable": price_msat * 1000,
"maxWithdrawable": price_msat * 1000,
"defaultDescription": device.title,
@ -188,7 +200,7 @@ async def lnurl_v1_params(
),
"minSendable": price_msat * 1000,
"maxSendable": price_msat * 1000,
"metadata": await device.lnurlpay_metadata(),
"metadata": device.lnurlpay_metadata,
}
@ -215,14 +227,13 @@ async def lnurl_callback(
status_code=HTTPStatus.FORBIDDEN, detail="No payment request"
)
else:
if lnurldevicepayment.id != k1:
if lnurldevicepayment.payload != k1:
return {"status": "ERROR", "reason": "Bad K1"}
if lnurldevicepayment.payhash != "payment_hash":
return {"status": "ERROR", "reason": f"Payment already claimed"}
lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload
)
await pay_invoice(
wallet_id=device.wallet,
payment_request=pr,
@ -233,11 +244,17 @@ async def lnurl_callback(
if device.device == "switch":
payment_hash, payment_request = await create_invoice(
wallet_id=device.wallet,
amount=lnurldevicepayment.sats / 1000,
memo=device.title + "-" + lnurldevicepayment.id,
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "Switch", "id": paymentid, "time": device.amount},
amount=int(lnurldevicepayment.sats / 1000),
memo=device.id + " PIN " + str(lnurldevicepayment.pin),
unhashed_description=device.lnurlpay_metadata.encode("utf-8"),
extra={
"tag": "Switch",
"pin": str(lnurldevicepayment.pin),
"amount": str(lnurldevicepayment.payload),
"id": paymentid,
},
)
lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=payment_hash
)
@ -248,9 +265,9 @@ async def lnurl_callback(
payment_hash, payment_request = await create_invoice(
wallet_id=device.wallet,
amount=lnurldevicepayment.sats / 1000,
amount=int(lnurldevicepayment.sats / 1000),
memo=device.title,
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
unhashed_description=device.lnurlpay_metadata.encode("utf-8"),
extra={"tag": "PoS"},
)
lnurldevicepayment = await update_lnurldevicepayment(

View File

@ -88,3 +88,52 @@ async def m003_redux(db):
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount INT DEFAULT 0;"
)
async def m004_redux(db):
"""
Add 'meta' for storing various metadata about the wallet
"""
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit1 FLOAT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount1 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin1 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit2 FLOAT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount2 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin2 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit3 FLOAT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount3 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin3 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit4 FLOAT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount4 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin4 INT DEFAULT 0"
)

View File

@ -1,12 +1,13 @@
import json
from sqlite3 import Row
from typing import Optional
from typing import List, Optional
from fastapi import Request
from lnurl import Lnurl
from lnurl import encode as lnurl_encode # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from loguru import logger
from pydantic import BaseModel
from pydantic.main import BaseModel
@ -18,6 +19,19 @@ class createLnurldevice(BaseModel):
device: str
profit: float
amount: int
pin: int = 0
profit1: float = 0
amount1: int = 0
pin1: int = 0
profit2: float = 0
amount2: int = 0
pin2: int = 0
profit3: float = 0
amount3: int = 0
pin3: int = 0
profit4: float = 0
amount4: int = 0
pin4: int = 0
class lnurldevices(BaseModel):
@ -29,18 +43,122 @@ class lnurldevices(BaseModel):
device: str
profit: float
amount: int
pin: int
profit1: float
amount1: int
pin1: int
profit2: float
amount2: int
pin2: int
profit3: float
amount3: int
pin3: int
profit4: float
amount4: int
pin4: int
timestamp: str
def from_row(cls, row: Row) -> "lnurldevices":
return cls(**dict(row))
def lnurl(self, req: Request) -> Lnurl:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
return lnurl_encode(url)
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
@property
def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.title]]))
def switches(self, req: Request) -> List:
switches = []
if self.profit > 0:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
switches.append(
[
str(self.pin),
str(self.profit),
str(self.amount),
lnurl_encode(
url
+ "?gpio="
+ str(self.pin)
+ "&profit="
+ str(self.profit)
+ "&amount="
+ str(self.amount)
),
]
)
if self.profit1 > 0:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
switches.append(
[
str(self.pin1),
str(self.profit1),
str(self.amount1),
lnurl_encode(
url
+ "?gpio="
+ str(self.pin1)
+ "&profit="
+ str(self.profit1)
+ "&amount="
+ str(self.amount1)
),
]
)
if self.profit2 > 0:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
switches.append(
[
str(self.pin2),
str(self.profit2),
str(self.amount2),
lnurl_encode(
url
+ "?gpio="
+ str(self.pin2)
+ "&profit="
+ str(self.profit2)
+ "&amount="
+ str(self.amount2)
),
]
)
if self.profit3 > 0:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
switches.append(
[
str(self.pin3),
str(self.profit3),
str(self.amount3),
lnurl_encode(
url
+ "?gpio="
+ str(self.pin3)
+ "&profit="
+ str(self.profit3)
+ "&amount="
+ str(self.amount3)
),
]
)
if self.profit4 > 0:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
switches.append(
[
str(self.pin4),
str(self.profit4),
str(self.amount4),
lnurl_encode(
url
+ "?gpio="
+ str(self.pin4)
+ "&profit="
+ str(self.profit4)
+ "&amount="
+ str(self.amount4)
),
]
)
return switches
class lnurldevicepayment(BaseModel):
id: str

View File

@ -36,5 +36,9 @@ async def on_invoice_paid(payment: Payment) -> None:
lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
)
return await updater(lnurldevicepayment.deviceid)
return await updater(
lnurldevicepayment.deviceid,
lnurldevicepayment.pin,
lnurldevicepayment.payload,
)
return

View File

@ -105,7 +105,7 @@
@click="openQrCodeDialog(props.row.id)"
><q-tooltip v-if="protocol == 'http:'">
LNURLs only work over HTTPS </q-tooltip
><q-tooltip v-else> view LNURL </q-tooltip></q-btn
><q-tooltip v-else> view LNURLS </q-tooltip></q-btn
>
</q-td>
<q-td
@ -230,29 +230,221 @@
label="Profit margin (% added to invoices/deducted from faucets)"
></q-input>
<div v-else>
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<q-btn
unelevated
class="q-mb-lg"
round
size="sm"
icon="add"
@click="addSwitch"
v-model="switches"
color="primary"
></q-btn>
<q-btn
unelevated
class="q-mb-lg"
round
size="sm"
icon="remove"
@click="removeSwitch"
v-model="switches"
color="primary"
></q-btn>
<div v-if="switches >= 0">
<div class="row">
<div class="col">
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.pin"
type="number"
label="GPIO to turn on"
></q-input>
</div>
</div>
</div>
<div v-if="switches >= 1">
<div class="row">
<div class="col">
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit1"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount1"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.pin1"
type="number"
label="GPIO to turn on"
></q-input>
</div>
</div>
</div>
<div v-if="switches >= 2">
<div class="row">
<div class="col">
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit2"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount2"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.pin2"
type="number"
label="GPIO to turn on"
></q-input>
</div>
</div>
</div>
<div v-if="switches >= 3">
<div class="row">
<div class="col">
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit3"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount3"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.pin3"
type="number"
label="GPIO to turn on"
></q-input>
</div>
</div>
</div>
<div v-if="switches >= 4">
<div class="row">
<div class="col">
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit4"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount4"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.pin4"
type="number"
label="GPIO to turn on"
></q-input>
</div>
</div>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="formDialoglnurldevice.data.id"
@ -284,24 +476,26 @@
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
:value="lnurlValue"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
{% raw %}
</q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
</p>
{% endraw %}
<q-btn
outline
color="grey"
@click="copyText(lnurlValue, 'LNURL copied to clipboard!')"
>Copy LNURL</q-btn
>
<br />
<div class="row q-mt-lg q-gutter-sm">
<q-btn
v-for="switch_ in qrCodeDialog.data.switches"
outline
color="grey"
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')"
class="q-ml-sm"
>Copy LNURL</q-btn
>
color="primary"
:label="'Switch PIN:' + switch_[0]"
@click="lnurlValueFetch(switch_[3])"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
@ -333,11 +527,14 @@
mixins: [windowMixin],
data: function () {
return {
tab: 'mails',
protocol: window.location.protocol,
location: window.location.hostname,
wslocation: window.location.hostname,
filter: '',
currency: 'USD',
lnurlValue: '',
switches: 0,
lnurldeviceLinks: [],
lnurldeviceLinksObj: [],
devices: [
@ -386,12 +583,6 @@
label: 'device',
field: 'device'
},
{
name: 'profit',
align: 'left',
label: 'profit',
field: 'profit'
},
{
name: 'currency',
align: 'left',
@ -440,8 +631,20 @@
this.qrCodeDialog.data = _.clone(lnurldevice)
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.lnurlValueFetch(this.qrCodeDialog.data.switches[0][3])
this.qrCodeDialog.show = true
},
lnurlValueFetch: function (lnurl) {
this.lnurlValue = lnurl
},
addSwitch: function () {
var self = this
self.switches = self.switches + 1
},
removeSwitch: function () {
var self = this
self.switches = self.switches - 1
},
cancellnurldevice: function (data) {
var self = this
self.formDialoglnurldevice.show = false
@ -498,7 +701,9 @@
.then(function (response) {
if (response.data) {
self.lnurldeviceLinks = response.data.map(maplnurldevice)
console.log('response.data')
console.log(response.data)
console.log('response.data')
}
})
.catch(function (error) {

View File

@ -103,8 +103,10 @@ async def websocket_endpoint(websocket: WebSocket, lnurldevice_id: str):
manager.disconnect(websocket)
async def updater(lnurldevice_id):
async def updater(lnurldevice_id, lnurldevice_pin, lnurldevice_amount):
lnurldevice = await get_lnurldevice(lnurldevice_id)
if not lnurldevice:
return
await manager.send_personal_message(f"{lnurldevice.amount}", lnurldevice_id)
return await manager.send_personal_message(
f"{lnurldevice_pin}-{lnurldevice_amount}", lnurldevice_id
)

View File

@ -39,10 +39,10 @@ async def api_lnurldevice_create_or_update(
):
if not lnurldevice_id:
lnurldevice = await create_lnurldevice(data)
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
else:
lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id)
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
@lnurldevice_ext.get("/api/v1/lnurlpos")
@ -52,7 +52,7 @@ async def api_lnurldevices_retrieve(
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
try:
return [
{**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
{**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
for lnurldevice in await get_lnurldevices(wallet_ids)
]
except:
@ -78,7 +78,7 @@ async def api_lnurldevice_retrieve(
)
if not lnurldevice.lnurl_toggle:
return {**lnurldevice.dict()}
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
@lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}")

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

@ -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

@ -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

@ -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,19 +265,15 @@ 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:
if not rowlnurl or not rowid:
await create_withdraw_hash_check(the_hash, lnurl_id)
return {"lnurl": True, "hash": False}
else:
if not rowid:
await create_withdraw_hash_check(the_hash, lnurl_id)
return {"lnurl": True, "hash": False}
else:
return {"lnurl": True, "hash": True}
return {"lnurl": True, "hash": True}

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,12 +146,13 @@ async def api_lnurlw_callback(
return {"status": "ERROR", "reason": "spent"}
paylink = await get_satsdice_pay(link.satsdice_pay)
await update_satsdice_withdraw(link.id, used=1)
await pay_invoice(
wallet_id=paylink.wallet,
payment_request=pr,
max_sat=link.value,
extra={"tag": "withdraw"},
)
if paylink:
await update_satsdice_withdraw(link.id, used=1)
await pay_invoice(
wallet_id=paylink.wallet,
payment_request=pr,
max_sat=link.value,
extra={"tag": "withdraw"},
)
return {"status": "OK"}
return {"status": "OK"}

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

@ -102,7 +102,7 @@ async def check_address_balance(charge_id: str) -> List[Charges]:
charge = await get_charge(charge_id)
if not charge.paid:
if charge.onchainaddress:
config = await get_config(charge.user)
config = await get_charge_config(charge_id)
try:
async with httpx.AsyncClient() as client:
r = await client.get(
@ -122,3 +122,10 @@ async def check_address_balance(charge_id: str) -> List[Charges]:
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
async def get_charge_config(charge_id: str):
row = await db.fetchone(
"""SELECT "user" FROM satspay.charges WHERE id = ?""", (charge_id,)
)
return await get_config(row.user)

View File

@ -0,0 +1,17 @@
from .models import Charges
def compact_charge(charge: Charges):
return {
"id": charge.id,
"description": charge.description,
"onchainaddress": charge.onchainaddress,
"payment_request": charge.payment_request,
"payment_hash": charge.payment_hash,
"time": charge.time,
"amount": charge.amount,
"balance": charge.balance,
"paid": charge.paid,
"timestamp": charge.timestamp,
"completelink": charge.completelink, # should be secret?
}

View File

@ -19,7 +19,6 @@ class CreateCharge(BaseModel):
class Charges(BaseModel):
id: str
user: str
description: Optional[str]
onchainwallet: Optional[str]
onchainaddress: Optional[str]

View File

@ -328,7 +328,7 @@
)
},
checkBalances: async function () {
if (!this.charge.hasStaleBalance) await this.refreshCharge()
if (this.charge.hasStaleBalance) return
try {
const {data} = await LNbits.api.request(
'GET',
@ -339,18 +339,9 @@
LNbits.utils.notifyApiError(error)
}
},
refreshCharge: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
`/satspay/api/v1/charge/${this.charge.id}`
)
this.charge = mapCharge(data, this.charge)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
checkPendingOnchain: async function () {
if (!this.charge.onchainaddress) return
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS({

View File

@ -9,10 +9,9 @@ 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.watchonly.crud import get_config
from . import satspay_ext, satspay_renderer
from .crud import get_charge
from .crud import get_charge, get_charge_config
templates = Jinja2Templates(directory="templates")
@ -32,7 +31,7 @@ async def display(request: Request, charge_id: str):
status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
)
wallet = await get_wallet(charge.lnbitswallet)
onchainwallet_config = await get_config(charge.user)
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

View File

@ -20,6 +20,7 @@ from .crud import (
get_charges,
update_charge,
)
from .helpers import compact_charge
from .models import CreateCharge
#############################CHARGES##########################
@ -93,7 +94,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##########################
@ -123,25 +124,13 @@ async def api_charge_balance(charge_id):
try:
r = await client.post(
charge.webhook,
json={
"id": charge.id,
"description": charge.description,
"onchainaddress": charge.onchainaddress,
"payment_request": charge.payment_request,
"payment_hash": charge.payment_hash,
"time": charge.time,
"amount": charge.amount,
"balance": charge.balance,
"paid": charge.paid,
"timestamp": charge.timestamp,
"completelink": charge.completelink,
},
json=compact_charge(charge),
timeout=40,
)
except AssertionError:
charge.webhook = None
return {
**charge.dict(),
**compact_charge(charge),
**{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid},

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

@ -14,7 +14,7 @@ class Target(BaseModel):
class TargetPutList(BaseModel):
wallet: str = Query(...)
alias: str = Query("")
percent: float = Query(..., ge=0.01)
percent: float = Query(..., ge=0.01, lt=100)
class TargetPut(BaseModel):

View File

@ -1,13 +1,11 @@
import asyncio
import json
from loguru import logger
from lnbits.core import db as core_db
from lnbits.core.crud import create_payment
from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name, urlsafe_short_hash
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_targets
@ -22,60 +20,36 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") == "splitpayments" or payment.extra.get("splitted"):
# already splitted, ignore
if payment.extra.get("tag") == "splitpayments":
# already a splitted payment, ignore
return
# now we make some special internal transfers (from no one to the receiver)
targets = await get_targets(payment.wallet_id)
if not targets:
return
transfers = [
(target.wallet, int(target.percent * payment.amount / 100))
for target in targets
]
transfers = [(wallet, amount) for wallet, amount in transfers if amount > 0]
amount_left = payment.amount - sum([amount for _, amount in transfers])
total_percent = sum([target.percent for target in targets])
if amount_left < 0:
logger.error(
"splitpayments failure: amount_left is negative.", payment.payment_hash
)
if total_percent > 100:
logger.error("splitpayment failure: total percent adds up to more than 100%")
return
# mark the original payment with one extra key, "splitted"
# (this prevents us from doing this process again and it's informative)
# and reduce it by the amount we're going to send to the producer
await core_db.execute(
"""
UPDATE apipayments
SET extra = ?, amount = ?
WHERE hash = ?
AND checking_id NOT LIKE 'internal_%'
""",
(
json.dumps(dict(**payment.extra, splitted=True)),
amount_left,
payment.payment_hash,
),
)
# perform the internal transfer using the same payment_hash
for wallet, amount in transfers:
internal_checking_id = f"internal_{urlsafe_short_hash()}"
await create_payment(
wallet_id=wallet,
checking_id=internal_checking_id,
payment_request="",
payment_hash=payment.payment_hash,
amount=amount,
memo=payment.memo,
pending=False,
logger.debug(f"performing split payments to {len(targets)} targets")
for target in targets:
amount = int(payment.amount * target.percent / 100) # msats
payment_hash, payment_request = await create_invoice(
wallet_id=target.wallet,
amount=int(amount / 1000), # sats
internal=True,
memo=f"split payment: {target.percent}% for {target.alias or target.wallet}",
extra={"tag": "splitpayments"},
)
logger.debug(f"created split invoice: {payment_hash}")
# manually send this for now
await internal_invoice_queue.put(internal_checking_id)
return
checking_id = await pay_invoice(
payment_request=payment_request,
wallet_id=payment.wallet_id,
extra={"tag": "splitpayments"},
)
logger.debug(f"paid split invoice: {checking_id}")

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

@ -3,10 +3,10 @@ from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import CreateDomain, Domains, Subdomains
from .models import CreateDomain, CreateSubdomain, Domains, Subdomains
async def create_subdomain(payment_hash, wallet, data: CreateDomain) -> Subdomains:
async def create_subdomain(payment_hash, wallet, data: CreateSubdomain) -> Subdomains:
await db.execute(
"""
INSERT INTO subdomains.subdomain (id, domain, email, subdomain, ip, wallet, sats, duration, paid, record_type)

View File

@ -3,24 +3,24 @@ from pydantic.main import BaseModel
class CreateDomain(BaseModel):
wallet: str = Query(...)
domain: str = Query(...)
cf_token: str = Query(...)
cf_zone_id: str = Query(...)
webhook: str = Query("")
description: str = Query(..., min_length=0)
cost: int = Query(..., ge=0)
allowed_record_types: str = Query(...)
wallet: str = Query(...) # type: ignore
domain: str = Query(...) # type: ignore
cf_token: str = Query(...) # type: ignore
cf_zone_id: str = Query(...) # type: ignore
webhook: str = Query("") # type: ignore
description: str = Query(..., min_length=0) # type: ignore
cost: int = Query(..., ge=0) # type: ignore
allowed_record_types: str = Query(...) # type: ignore
class CreateSubdomain(BaseModel):
domain: str = Query(...)
subdomain: str = Query(...)
email: str = Query(...)
ip: str = Query(...)
sats: int = Query(..., ge=0)
duration: int = Query(...)
record_type: str = Query(...)
domain: str = Query(...) # type: ignore
subdomain: str = Query(...) # type: ignore
email: str = Query(...) # type: ignore
ip: str = Query(...) # type: ignore
sats: int = Query(..., ge=0) # type: ignore
duration: int = Query(...) # type: ignore
record_type: str = Query(...) # type: ignore
class Domains(BaseModel):

View File

@ -20,7 +20,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "lnsubdomain":
if not payment.extra or payment.extra.get("tag") != "lnsubdomain":
# not an lnurlp invoice
return
@ -37,7 +37,7 @@ async def on_invoice_paid(payment: Payment) -> None:
)
### Use webhook to notify about cloudflare registration
if domain.webhook:
if domain and domain.webhook:
async with httpx.AsyncClient() as client:
try:
r = await client.post(

View File

@ -16,7 +16,9 @@ templates = Jinja2Templates(directory="templates")
@subdomains_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 subdomains_renderer().TemplateResponse(
"subdomains/index.html", {"request": request, "user": user.dict()}
)

View File

@ -29,12 +29,15 @@ from .crud import (
@subdomains_ext.get("/api/v1/domains")
async def api_domains(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
g: WalletTypeInfo = Depends(get_key_type), # type: ignore
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
user = await get_user(g.wallet.user)
if user is not None:
wallet_ids = user.wallet_ids
return [domain.dict() for domain in await get_domains(wallet_ids)]
@ -42,7 +45,9 @@ async def api_domains(
@subdomains_ext.post("/api/v1/domains")
@subdomains_ext.put("/api/v1/domains/{domain_id}")
async def api_domain_create(
data: CreateDomain, domain_id=None, g: WalletTypeInfo = Depends(get_key_type)
data: CreateDomain,
domain_id=None,
g: WalletTypeInfo = Depends(get_key_type), # type: ignore
):
if domain_id:
domain = await get_domain(domain_id)
@ -63,7 +68,9 @@ async def api_domain_create(
@subdomains_ext.delete("/api/v1/domains/{domain_id}")
async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)):
async def api_domain_delete(
domain_id, g: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
domain = await get_domain(domain_id)
if not domain:
@ -74,7 +81,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
#########subdomains##########
@ -82,12 +89,14 @@ async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)
@subdomains_ext.get("/api/v1/subdomains")
async def api_subdomains(
all_wallets: bool = Query(False), g: WalletTypeInfo = Depends(get_key_type)
all_wallets: bool = Query(False), g: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
user = await get_user(g.wallet.user)
if user is not None:
wallet_ids = user.wallet_ids
return [domain.dict() for domain in await get_subdomains(wallet_ids)]
@ -173,7 +182,9 @@ async def api_subdomain_send_subdomain(payment_hash):
@subdomains_ext.delete("/api/v1/subdomains/{subdomain_id}")
async def api_subdomain_delete(subdomain_id, g: WalletTypeInfo = Depends(get_key_type)):
async def api_subdomain_delete(
subdomain_id, g: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
subdomain = await get_subdomain(subdomain_id)
if not subdomain:
@ -187,4 +198,4 @@ async def api_subdomain_delete(subdomain_id, g: WalletTypeInfo = Depends(get_key
)
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

@ -1,11 +1,11 @@
import asyncio
import json
from lnbits.core import db as core_db
from lnbits.core.crud import create_payment
from loguru import logger
from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name, urlsafe_short_hash
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_tpos
@ -20,11 +20,9 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") == "tpos" and payment.extra.get("tipSplitted"):
# already splitted, ignore
if payment.extra.get("tag") != "tpos":
return
# now we make some special internal transfers (from no one to the receiver)
tpos = await get_tpos(payment.extra.get("tposId"))
tipAmount = payment.extra.get("tipAmount")
@ -32,39 +30,17 @@ async def on_invoice_paid(payment: Payment) -> None:
# no tip amount
return
tipAmount = tipAmount * 1000
amount = payment.amount - tipAmount
# mark the original payment with one extra key, "splitted"
# (this prevents us from doing this process again and it's informative)
# and reduce it by the amount we're going to send to the producer
await core_db.execute(
"""
UPDATE apipayments
SET extra = ?, amount = ?
WHERE hash = ?
AND checking_id NOT LIKE 'internal_%'
""",
(
json.dumps(dict(**payment.extra, tipSplitted=True)),
amount,
payment.payment_hash,
),
)
# perform the internal transfer using the same payment_hash
internal_checking_id = f"internal_{urlsafe_short_hash()}"
await create_payment(
payment_hash, payment_request = await create_invoice(
wallet_id=tpos.tip_wallet,
checking_id=internal_checking_id,
payment_request="",
payment_hash=payment.payment_hash,
amount=tipAmount,
memo=f"Tip for {payment.memo}",
pending=False,
extra={"tipSplitted": True},
amount=int(tipAmount), # sats
internal=True,
memo=f"tpos tip",
)
logger.debug(f"tpos: tip invoice created: {payment_hash}")
# manually send this for now
await internal_invoice_queue.put(internal_checking_id)
return
checking_id = await pay_invoice(
payment_request=payment_request,
wallet_id=payment.wallet_id,
extra={"tag": "tpos"},
)
logger.debug(f"tpos: tip invoice paid: {checking_id}")

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
>
</div>
<div class="text-center q-mb-xl">
<p><a @click="processTipSelection(0)"> No, thanks</a></p>
<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>
<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

@ -63,10 +63,11 @@ async def get_usermanager_users(user_id: str) -> List[Users]:
return [Users(**row) for row in rows]
async def delete_usermanager_user(user_id: str) -> None:
wallets = await get_usermanager_wallets(user_id)
for wallet in wallets:
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
async def delete_usermanager_user(user_id: str, delete_core: bool = True) -> None:
if delete_core:
wallets = await get_usermanager_wallets(user_id)
for wallet in wallets:
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
await db.execute("DELETE FROM usermanager.users WHERE id = ?", (user_id,))
await db.execute("""DELETE FROM usermanager.wallets WHERE "user" = ?""", (user_id,))

View File

@ -38,13 +38,13 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
Returns 200 OK (application/json)
</h5>
<code>JSON list of users</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}usermanager/api/v1/users -H
"X-Api-Key: {{ user.wallets[0].inkey }}"
"X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
@ -57,10 +57,16 @@
/usermanager/api/v1/users/&lt;user_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
Returns 200 OK (application/json)
</h5>
<code>JSON list of users</code>
<code
>{"id": &lt;string&gt;, "name": &lt;string&gt;, "admin":
&lt;string&gt;, "email": &lt;string&gt;, "password":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
@ -75,20 +81,19 @@
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/usermanager/api/v1/wallets/&lt;user_id&gt;</code
/usermanager/api/v1/wallets</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
Returns 200 OK (application/json)
</h5>
<code>JSON wallet data</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}usermanager/api/v1/wallets/&lt;user_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
>curl -X GET {{ request.base_url }}usermanager/api/v1/wallets -H
"X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
@ -104,7 +109,7 @@
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
Returns 200 OK (application/json)
</h5>
<code>JSON a wallets transactions</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
@ -215,7 +220,7 @@
<code
>curl -X DELETE {{ request.base_url
}}usermanager/api/v1/users/&lt;user_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
@ -233,7 +238,7 @@
<code
>curl -X DELETE {{ request.base_url
}}usermanager/api/v1/wallets/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
@ -254,11 +259,15 @@
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}usermanager/api/v1/extensions -d
'{"userid": &lt;string&gt;, "extension": &lt;string&gt;, "active":
&lt;integer&gt;}' -H "X-Api-Key: {{ user.wallets[0].inkey }}" -H
"Content-type: application/json"
>curl -X POST {{ request.base_url
}}usermanager/api/v1/extensions?extension=withdraw&userid=user_id&active=true
-H "X-Api-Key: {{ user.wallets[0].inkey }}" -H "Content-type:
application/json"
</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"extension": "updated"}</code>
</q-card-section>
</q-card>
</q-expansion-item>

View File

@ -52,15 +52,17 @@ async def api_usermanager_users_create(
@usermanager_ext.delete("/api/v1/users/{user_id}")
async def api_usermanager_users_delete(
user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
user_id,
delete_core: bool = Query(True),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
user = await get_usermanager_user(user_id)
if not user:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
)
await delete_usermanager_user(user_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
await delete_usermanager_user(user_id, delete_core)
return "", HTTPStatus.NO_CONTENT
# Activate Extension
@ -124,4 +126,4 @@ async def api_usermanager_wallets_delete(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
await delete_usermanager_wallet(wallet_id, get_wallet.user)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return "", HTTPStatus.NO_CONTENT

View File

@ -10,7 +10,7 @@ from .models import Address, Config, WalletAccount
##########################WALLETS####################
async def create_watch_wallet(w: WalletAccount) -> WalletAccount:
async def create_watch_wallet(user: str, w: WalletAccount) -> WalletAccount:
wallet_id = urlsafe_short_hash()
await db.execute(
"""
@ -30,7 +30,7 @@ async def create_watch_wallet(w: WalletAccount) -> WalletAccount:
""",
(
wallet_id,
w.user,
user,
w.masterpub,
w.fingerprint,
w.title,

View File

@ -14,7 +14,6 @@ class CreateWallet(BaseModel):
class WalletAccount(BaseModel):
id: str
user: str
masterpub: str
fingerprint: str
title: str

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

@ -86,7 +86,6 @@ async def api_wallet_create_or_update(
new_wallet = WalletAccount(
id="none",
user=w.wallet.user,
masterpub=data.masterpub,
fingerprint=descriptor.keys[0].fingerprint.hex(),
type=descriptor.scriptpubkey_type(),
@ -115,7 +114,7 @@ async def api_wallet_create_or_update(
)
)
wallet = await create_watch_wallet(new_wallet)
wallet = await create_watch_wallet(w.wallet.user, new_wallet)
await api_get_addresses(wallet.id, w)
except Exception as e:

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,49 +77,53 @@ 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 = ""
for x in range(1, link.uses - link.used):
usecv = link.usescsv.split(",")
usescsv += "," + str(usecv[x])
usecsvback = usescsv
found = False
if id_unique_hash is not None:
useslist = link.usescsv.split(",")
for ind, x in enumerate(useslist):
tohash = link.id + link.unique_hash + str(x)
if id_unique_hash == shortuuid.uuid(name=tohash):
found = True
useslist.pop(ind)
usescsv = ",".join(useslist)
if not found:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
)
else:
usescsv = usescsv[1:]
changesback = {
"open_time": link.wait_time,
"used": link.used,
"usescsv": usecsvback,
}
try:
for x in range(1, link.uses - link.used):
usecv = link.usescsv.split(",")
usescsv += "," + str(usecv[x])
usecsvback = usescsv
found = False
if id_unique_hash is not None:
useslist = link.usescsv.split(",")
for ind, x in enumerate(useslist):
tohash = link.id + link.unique_hash + str(x)
if id_unique_hash == shortuuid.uuid(name=tohash):
found = True
useslist.pop(ind)
usescsv = ",".join(useslist)
if not found:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
)
else:
usescsv = usescsv[1:]
changesback = {
"open_time": link.wait_time,
"used": link.used,
"usescsv": usecsvback,
}
changes = {
"open_time": link.wait_time + now,
"used": link.used + 1,
@ -143,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

@ -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

@ -167,6 +167,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,95 +0,0 @@
from functools import partial
from typing import Callable, List, Optional
from urllib.parse import urlparse
from urllib.request import parse_http_list as _parse_list_header
from quart import Request
from quart_trio.asgi import TrioASGIHTTPConnection
from werkzeug.datastructures import Headers
class ASGIProxyFix(TrioASGIHTTPConnection):
def _create_request_from_scope(self, send: Callable) -> Request:
headers = Headers()
headers["Remote-Addr"] = (self.scope.get("client") or ["<local>"])[0]
for name, value in self.scope["headers"]:
headers.add(name.decode("latin1").title(), value.decode("latin1"))
if self.scope["http_version"] < "1.1":
headers.setdefault("Host", self.app.config["SERVER_NAME"] or "")
path = self.scope["path"]
path = path if path[0] == "/" else urlparse(path).path
x_proto = self._get_real_value(1, headers.get("X-Forwarded-Proto"))
if x_proto:
self.scope["scheme"] = x_proto
x_host = self._get_real_value(1, headers.get("X-Forwarded-Host"))
if x_host:
headers["host"] = x_host.lower()
return self.app.request_class(
self.scope["method"],
self.scope["scheme"],
path,
self.scope["query_string"],
headers,
self.scope.get("root_path", ""),
self.scope["http_version"],
max_content_length=self.app.config["MAX_CONTENT_LENGTH"],
body_timeout=self.app.config["BODY_TIMEOUT"],
send_push_promise=partial(self._send_push_promise, send),
scope=self.scope,
)
def _get_real_value(self, trusted: int, value: Optional[str]) -> Optional[str]:
"""Get the real value from a list header based on the configured
number of trusted proxies.
:param trusted: Number of values to trust in the header.
:param value: Comma separated list header value to parse.
:return: The real value, or ``None`` if there are fewer values
than the number of trusted proxies.
.. versionchanged:: 1.0
Renamed from ``_get_trusted_comma``.
.. versionadded:: 0.15
"""
if not (trusted and value):
return None
values = self.parse_list_header(value)
if len(values) >= trusted:
return values[-trusted]
return None
def parse_list_header(self, value: str) -> List[str]:
result = []
for item in _parse_list_header(value):
if item[:1] == item[-1:] == '"':
item = self.unquote_header_value(item[1:-1])
result.append(item)
return result
def unquote_header_value(self, value: str, is_filename: bool = False) -> str:
r"""Unquotes a header value. (Reversal of :func:`quote_header_value`).
This does not use the real unquoting but what browsers are actually
using for quoting.
.. versionadded:: 0.5
:param value: the header value to unquote.
:param is_filename: The value represents a filename or path.
"""
if value and value[0] == value[-1] == '"':
# this is not the real unquoting, but fixing this so that the
# RFC is met will result in bugs with internet explorer and
# probably some other browsers as well. IE for example is
# uploading files with "C:\foo\bar.txt" as filename
value = value[1:-1]
# if this is a filename and the starting characters look like
# a UNC path, then just return the value without quotes. Using the
# replace sequence below on a UNC path has the effect of turning
# the leading double slash into a single slash and then
# _fix_ie_filename() doesn't work correctly. See #458.
if not is_filename or value[:2] != "\\\\":
return value.replace("\\\\", "\\").replace('\\"', '"')
return value

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

Some files were not shown because too many files have changed in this diff Show More