diff --git a/.env.example b/.env.example
index bb4e64a1..6a3710c2 100644
--- a/.env.example
+++ b/.env.example
@@ -11,7 +11,11 @@ LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
+
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available
+# Warning: Enabling this will make LNbits ignore this configuration file. Your settings will
+# be stored in your database and you will be able to change them only through the Admin UI.
+# Disable this to make LNbits use this config file again.
LNBITS_ADMIN_UI=false
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
diff --git a/Makefile b/Makefile
index 4f99f1da..ebf2a872 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ format: prettier isort black
check: mypy checkprettier checkisort checkblack
-prettier: $(shell find lnbits -name "*.js" -name ".html")
+prettier: $(shell find lnbits -name "*.js" -o -name ".html")
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
black:
@@ -18,7 +18,7 @@ mypy:
isort:
poetry run isort .
-checkprettier: $(shell find lnbits -name "*.js" -name ".html")
+checkprettier: $(shell find lnbits -name "*.js" -o -name ".html")
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
checkblack:
diff --git a/README.md b/README.md
index a22c857c..3bc169dd 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly.
-See [legend.lnbits.org](https://legend.lnbits.org) for more detailed documentation.
+See [docs.lnbits.org](https://docs.lnbits.org) for more detailed documentation.
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
@@ -70,7 +70,7 @@ Wallets can be easily generated and given out to people at events (one click mul
If you like this project and might even use or extend it, why not [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
-[docs]: https://legend.lnbits.org/
+[docs]: https://docs.lnbits.org/
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
[github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
[github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
diff --git a/docs/CNAME b/docs/CNAME
index 9981b110..e7e04e60 100644
--- a/docs/CNAME
+++ b/docs/CNAME
@@ -1 +1 @@
-legend.lnbits.org
\ No newline at end of file
+docs.lnbits.org
\ No newline at end of file
diff --git a/docs/_config.yml b/docs/_config.yml
index 6c3d6512..d937f5dc 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -3,7 +3,7 @@ remote_theme: pmarsceill/just-the-docs
color_scheme: dark
logo: "/logos/lnbits-full--inverse.png"
search_enabled: true
-url: https://legend.lnbits.org
+url: https://docs.lnbits.org
aux_links:
"LNbits on GitHub":
- "//github.com/lnbits/lnbits"
diff --git a/docs/devs/api.md b/docs/devs/api.md
index a8217b9c..8e150889 100644
--- a/docs/devs/api.md
+++ b/docs/devs/api.md
@@ -9,4 +9,4 @@ nav_order: 3
API reference
=============
-[Swagger Docs](https://legend.lnbits.org/devs/swagger.html)
+[Swagger Docs](https://docs.lnbits.org/devs/swagger.html)
diff --git a/docs/guide/admin_ui.md b/docs/guide/admin_ui.md
index 1248d3f3..9637a989 100644
--- a/docs/guide/admin_ui.md
+++ b/docs/guide/admin_ui.md
@@ -40,3 +40,33 @@ Allowed Users
=============
enviroment variable: LNBITS_ALLOWED_USERS, comma-seperated list of user ids
By defining this users, LNbits will no longer be useable by the public, only defined users and admins can then access the LNbits frontend.
+
+
+How to activate
+=============
+```
+$ sudo systemctl stop lnbits.service
+$ cd ~/lnbits-legend
+$ sudo nano .env
+```
+-> set: `LNBITS_ADMIN_UI=true`
+
+Now start LNbits once in the terminal window
+```
+$ poetry run lnbits
+```
+It will now show you the Super User Account:
+
+`SUCCESS | ✔️ Access super user account at: https://127.0.0.1:5000/wallet?usr=5711d7..`
+
+The `/wallet?usr=..` is your super user account. You just have to append it to your normal LNbits web domain.
+
+After that you will find the __`Admin` / `Manage Server`__ between `Wallets` and `Extensions`
+
+Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
+
+Do not forget
+```
+sudo systemctl start lnbits.service
+```
+A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
diff --git a/flake.nix b/flake.nix
index af25ba5c..d9f0f1f0 100644
--- a/flake.nix
+++ b/flake.nix
@@ -5,7 +5,7 @@
};
outputs = { self, nixpkgs, poetry2nix }@inputs:
let
- supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
+ supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forSystems = systems: f:
nixpkgs.lib.genAttrs systems
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlay self.overlays.default ]; }));
diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py
index 32b43feb..0bc40158 100644
--- a/lnbits/bolt11.py
+++ b/lnbits/bolt11.py
@@ -1,7 +1,6 @@
import hashlib
import re
import time
-from binascii import unhexlify
from decimal import Decimal
from typing import List, NamedTuple, Optional
@@ -75,7 +74,7 @@ def decode(pr: str) -> Invoice:
data_length = len(tagdata) / 5
if tag == "d":
- invoice.description = _trim_to_bytes(tagdata).decode("utf-8")
+ invoice.description = _trim_to_bytes(tagdata).decode()
elif tag == "h" and data_length == 52:
invoice.description_hash = _trim_to_bytes(tagdata).hex()
elif tag == "p" and data_length == 52:
@@ -108,7 +107,7 @@ def decode(pr: str) -> Invoice:
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
sig = signature[0:64]
if invoice.payee:
- key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1)
+ key = VerifyingKey.from_string(bytes.fromhex(invoice.payee), curve=SECP256k1)
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
else:
keys = VerifyingKey.from_public_key_recovery(
@@ -131,7 +130,7 @@ def encode(options):
if options["timestamp"]:
addr.date = int(options["timestamp"])
- addr.paymenthash = unhexlify(options["paymenthash"])
+ addr.paymenthash = bytes.fromhex(options["paymenthash"])
if options["description"]:
addr.tags.append(("d", options["description"]))
@@ -149,8 +148,8 @@ def encode(options):
while len(splits) >= 5:
route.append(
(
- unhexlify(splits[0]),
- unhexlify(splits[1]),
+ bytes.fromhex(splits[0]),
+ bytes.fromhex(splits[1]),
int(splits[2]),
int(splits[3]),
int(splits[4]),
@@ -235,7 +234,7 @@ def lnencode(addr, privkey):
raise ValueError("Must include either 'd' or 'h'")
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
- privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey)))
+ privkey = secp256k1.PrivateKey(bytes.fromhex(privkey))
sig = privkey.ecdsa_sign_recoverable(
bytearray([ord(c) for c in hrp]) + data.tobytes()
)
@@ -261,7 +260,7 @@ class LnAddr(object):
def __str__(self):
return "LnAddr[{}, amount={}{} tags=[{}]]".format(
- hexlify(self.pubkey.serialize()).decode("utf-8"),
+ bytes.hex(self.pubkey.serialize()).decode(),
self.amount,
self.currency,
", ".join([k + "=" + str(v) for k, v in self.tags]),
diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py
index 1c8c71ad..a80fadf2 100644
--- a/lnbits/core/crud.py
+++ b/lnbits/core/crud.py
@@ -451,6 +451,34 @@ async def update_payment_details(
return
+async def update_payment_extra(
+ payment_hash: str,
+ extra: dict,
+ outgoing: bool = False,
+ conn: Optional[Connection] = None,
+) -> None:
+ """
+ Only update the `extra` field for the payment.
+ Old values in the `extra` JSON object will be kept unless the new `extra` overwrites them.
+ """
+
+ amount_clause = "AND amount < 0" if outgoing else "AND amount > 0"
+
+ row = await (conn or db).fetchone(
+ f"SELECT hash, extra from apipayments WHERE hash = ? {amount_clause}",
+ (payment_hash,),
+ )
+ if not row:
+ return
+ db_extra = json.loads(row["extra"] if row["extra"] else "{}")
+ db_extra.update(extra)
+
+ await (conn or db).execute(
+ f"UPDATE apipayments SET extra = ? WHERE hash = ? {amount_clause} ",
+ (json.dumps(db_extra), payment_hash),
+ )
+
+
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py
index 81413246..41ba5644 100644
--- a/lnbits/core/migrations.py
+++ b/lnbits/core/migrations.py
@@ -224,7 +224,7 @@ async def m007_set_invoice_expiries(db):
)
).fetchall()
if len(rows):
- logger.info(f"Mirgraion: Checking expiry of {len(rows)} invoices")
+ logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, (
payment_request,
checking_id,
@@ -238,7 +238,7 @@ async def m007_set_invoice_expiries(db):
invoice.date + invoice.expiry
)
logger.info(
- f"Mirgraion: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
+ f"Migration: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
"""
diff --git a/lnbits/core/models.py b/lnbits/core/models.py
index 65c72b41..31383667 100644
--- a/lnbits/core/models.py
+++ b/lnbits/core/models.py
@@ -4,13 +4,13 @@ import hmac
import json
import time
from sqlite3 import Row
-from typing import Dict, List, NamedTuple, Optional
+from typing import Dict, List, Optional
from ecdsa import SECP256k1, SigningKey # type: ignore
from fastapi import Query
from lnurl import encode as lnurl_encode # type: ignore
from loguru import logger
-from pydantic import BaseModel, Extra, validator
+from pydantic import BaseModel
from lnbits.db import Connection
from lnbits.helpers import url_for
@@ -46,8 +46,8 @@ class Wallet(BaseModel):
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
- hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest()
- linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256")
+ hashing_key = hashlib.sha256(self.id.encode()).digest()
+ linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
@@ -88,7 +88,7 @@ class Payment(BaseModel):
preimage: str
payment_hash: str
expiry: Optional[float]
- extra: Optional[Dict] = {}
+ extra: Dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
diff --git a/lnbits/core/services.py b/lnbits/core/services.py
index 336d2665..8dc973e7 100644
--- a/lnbits/core/services.py
+++ b/lnbits/core/services.py
@@ -1,6 +1,5 @@
import asyncio
import json
-from binascii import unhexlify
from io import BytesIO
from typing import Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urlparse
@@ -13,12 +12,7 @@ from loguru import logger
from lnbits import bolt11
from lnbits.db import Connection
-from lnbits.decorators import (
- WalletTypeInfo,
- get_key_type,
- require_admin_key,
- require_invoice_key,
-)
+from lnbits.decorators import WalletTypeInfo, require_admin_key
from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.requestvars import g
from lnbits.settings import (
@@ -308,7 +302,7 @@ async def perform_lnurlauth(
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
- k1 = unhexlify(parse_qs(cb.query)["k1"][0])
+ k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py
index b57e2625..e11f764b 100644
--- a/lnbits/core/tasks.py
+++ b/lnbits/core/tasks.py
@@ -4,7 +4,6 @@ from typing import Dict
import httpx
from loguru import logger
-from lnbits.helpers import get_current_extension_name
from lnbits.tasks import SseListenersDict, register_invoice_listener
from . import db
diff --git a/lnbits/core/templates/core/extensions.html b/lnbits/core/templates/core/extensions.html
index 1b527903..88e50269 100644
--- a/lnbits/core/templates/core/extensions.html
+++ b/lnbits/core/templates/core/extensions.html
@@ -23,14 +23,55 @@
>
{{ extension.name }}
- {{ extension.shortDescription }} {% endraw %}
+
This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the - open-source DIY Bleskomat ATM project as well as the - commercial Bleskomat ATM. + commercial Bleskomat ATM.
(QR for create the card in (QR for wipe the card in SubmarineSwap: raise refund_privkey = ec.PrivateKey(os.urandom(32), True, net) - refund_pubkey_hex = hexlify(refund_privkey.sec()).decode("UTF-8") + refund_pubkey_hex = bytes.hex(refund_privkey.sec()).decode() res = req_wrap( "post", @@ -121,7 +120,7 @@ async def create_reverse_swap( return False claim_privkey = ec.PrivateKey(os.urandom(32), True, net) - claim_pubkey_hex = hexlify(claim_privkey.sec()).decode("UTF-8") + claim_pubkey_hex = bytes.hex(claim_privkey.sec()).decode() preimage = os.urandom(32) preimage_hash = sha256(preimage).hexdigest() @@ -311,12 +310,12 @@ async def create_onchain_tx( sequence = 0xFFFFFFFE else: privkey = ec.PrivateKey.from_wif(swap.claim_privkey) - preimage = unhexlify(swap.preimage) + preimage = bytes.fromhex(swap.preimage) onchain_address = swap.onchain_address sequence = 0xFFFFFFFF locktime = swap.timeout_block_height - redeem_script = unhexlify(swap.redeem_script) + redeem_script = bytes.fromhex(swap.redeem_script) fees = get_fee_estimation() @@ -324,7 +323,7 @@ async def create_onchain_tx( script_pubkey = script.address_to_scriptpubkey(onchain_address) - vin = [TransactionInput(unhexlify(txid), vout_cnt, sequence=sequence)] + vin = [TransactionInput(bytes.fromhex(txid), vout_cnt, sequence=sequence)] vout = [TransactionOutput(vout_amount - fees, script_pubkey)] tx = Transaction(vin=vin, vout=vout) diff --git a/lnbits/extensions/boltz/config.json b/lnbits/extensions/boltz/config.json index 0f69d2a5..db678207 100644 --- a/lnbits/extensions/boltz/config.json +++ b/lnbits/extensions/boltz/config.json @@ -1,6 +1,6 @@ { "name": "Boltz", "short_description": "Perform onchain/offchain swaps", - "icon": "swap_horiz", + "tile": "/boltz/static/image/boltz.png", "contributors": ["dni"] } diff --git a/lnbits/extensions/boltz/mempool.py b/lnbits/extensions/boltz/mempool.py index a64cadad..c7d572a9 100644 --- a/lnbits/extensions/boltz/mempool.py +++ b/lnbits/extensions/boltz/mempool.py @@ -1,6 +1,5 @@ import asyncio import json -from binascii import hexlify import httpx import websockets @@ -84,7 +83,7 @@ def get_mempool_blockheight() -> int: async def send_onchain_tx(tx: Transaction): - raw = hexlify(tx.serialize()) + raw = bytes.hex(tx.serialize()) logger.debug(f"Boltz - mempool sending onchain tx...") req_wrap( "post", diff --git a/lnbits/extensions/boltz/static/image/boltz.png b/lnbits/extensions/boltz/static/image/boltz.png new file mode 100644 index 00000000..2dcefc94 Binary files /dev/null and b/lnbits/extensions/boltz/static/image/boltz.png differ diff --git a/lnbits/extensions/boltz/templates/boltz/_api_docs.html b/lnbits/extensions/boltz/templates/boltz/_api_docs.html index eea35ab6..0edc413a 100644 --- a/lnbits/extensions/boltz/templates/boltz/_api_docs.html +++ b/lnbits/extensions/boltz/templates/boltz/_api_docs.html @@ -24,12 +24,13 @@
Link : - https://boltz.exchange
More details Created by, - dnidni
diff --git a/lnbits/extensions/cashu/config.json b/lnbits/extensions/cashu/config.json index af202d43..14ff1743 100644 --- a/lnbits/extensions/cashu/config.json +++ b/lnbits/extensions/cashu/config.json @@ -1,7 +1,7 @@ { "name": "Cashu", "short_description": "Ecash mint and wallet", - "icon": "account_balance", + "tile": "/cashu/static/image/cashu.png", "contributors": ["calle", "vlad", "arcbtc"], "hidden": false } diff --git a/lnbits/extensions/cashu/crud.py b/lnbits/extensions/cashu/crud.py index 773a11fd..23f808c1 100644 --- a/lnbits/extensions/cashu/crud.py +++ b/lnbits/extensions/cashu/crud.py @@ -1,7 +1,6 @@ import os import random import time -from binascii import hexlify, unhexlify from typing import Any, List, Optional, Union from cashu.core.base import MintKeyset diff --git a/lnbits/extensions/cashu/static/image/cashu.png b/lnbits/extensions/cashu/static/image/cashu.png new file mode 100644 index 00000000..e90611fc Binary files /dev/null and b/lnbits/extensions/cashu/static/image/cashu.png differ diff --git a/lnbits/extensions/cashu/tasks.py b/lnbits/extensions/cashu/tasks.py index 9de17a1c..bf49171c 100644 --- a/lnbits/extensions/cashu/tasks.py +++ b/lnbits/extensions/cashu/tasks.py @@ -28,6 +28,7 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - if payment.extra and not payment.extra.get("tag") == "cashu": + if payment.extra.get("tag") != "cashu": return + return diff --git a/lnbits/extensions/cashu/templates/cashu/_cashu.html b/lnbits/extensions/cashu/templates/cashu/_cashu.html index 952fe7e1..370e9eb4 100644 --- a/lnbits/extensions/cashu/templates/cashu/_cashu.html +++ b/lnbits/extensions/cashu/templates/cashu/_cashu.html @@ -4,9 +4,24 @@Create Cashu ecash mints and wallets.
Created by - arcbtc, - vlad, - calle.arcbtc, + vlad, + calle. diff --git a/lnbits/extensions/cashu/templates/cashu/mint.html b/lnbits/extensions/cashu/templates/cashu/mint.html index ee6ab606..bcb919ff 100644 --- a/lnbits/extensions/cashu/templates/cashu/mint.html +++ b/lnbits/extensions/cashu/templates/cashu/mint.html @@ -11,6 +11,7 @@ >This is a - Cashu mint. Cashu is an ecash system for Bitcoin. diff --git a/lnbits/extensions/cashu/templates/cashu/wallet.html b/lnbits/extensions/cashu/templates/cashu/wallet.html index 88dffe7c..d075009e 100644 --- a/lnbits/extensions/cashu/templates/cashu/wallet.html +++ b/lnbits/extensions/cashu/templates/cashu/wallet.html @@ -159,7 +159,7 @@ page_container %} size="lg" color="secondary" class="q-mr-md cursor-pointer" - @click="recheckInvoice(props.row.hash)" + @click="checkInvoice(props.row.hash)" > Check @@ -616,10 +616,10 @@ page_container %} >
Connect your LNbits instance to a
- Discord Bot
leveraging LNbits as a community based lightning node.
Created by,
- Chris LennonChris Lennon
Based on User Manager, by
- Ben ArcBen Arc
+ Gerty (your bitcoin assistant): Use the software Gerty or
+ hardware Gerty
+
+ Created by,
+ Black Coffee,
+ Ben Arc
+
+++"{{fun_satoshi_quotes["quote"]}}"
+ ~ Satoshi {{fun_satoshi_quotes["date"]}} +
+ {{item[0].value}}: {{item[1].value}} +
++ {{item[0].value}}: {{item[1].value}} +
++ {{item[0].value}}: {{item[1].value}} +
+- Hivemind is a Bitcoin sidechain - project for a peer-to-peer oracle protocol that absorbs accurate data into - a blockchain so that Bitcoin users can speculate in prediction markets. + Hivemind + is a Bitcoin sidechain project for a peer-to-peer oracle protocol that + absorbs accurate data into a blockchain so that Bitcoin users can + speculate in prediction markets.
These markets have the potential to revolutionize the emergence of @@ -17,8 +18,8 @@
This extension will become fully operative when the - BIP300 soft-fork gets activated and - Bitcoin Hivemind is launched. + BIP300 + soft-fork gets activated and Bitcoin Hivemind is launched.
diff --git a/lnbits/extensions/invoices/config.json b/lnbits/extensions/invoices/config.json index 0811e0ef..a604fec5 100644 --- a/lnbits/extensions/invoices/config.json +++ b/lnbits/extensions/invoices/config.json @@ -1,6 +1,6 @@ { "name": "Invoices", "short_description": "Create invoices for your clients.", - "icon": "request_quote", + "tile": "/invoices/static/image/invoices.png", "contributors": ["leesalminen"] } diff --git a/lnbits/extensions/invoices/crud.py b/lnbits/extensions/invoices/crud.py index 4fd055e9..9a05f9c5 100644 --- a/lnbits/extensions/invoices/crud.py +++ b/lnbits/extensions/invoices/crud.py @@ -6,7 +6,6 @@ from . import db from .models import ( CreateInvoiceData, CreateInvoiceItemData, - CreatePaymentData, Invoice, InvoiceItem, Payment, @@ -30,7 +29,7 @@ async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]: return [InvoiceItem.from_row(row) for row in rows] -async def get_invoice_item(item_id: str) -> InvoiceItem: +async def get_invoice_item(item_id: str) -> Optional[InvoiceItem]: row = await db.fetchone( "SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,) ) @@ -61,7 +60,7 @@ async def get_invoice_payments(invoice_id: str) -> List[Payment]: return [Payment.from_row(row) for row in rows] -async def get_invoice_payment(payment_id: str) -> Payment: +async def get_invoice_payment(payment_id: str) -> Optional[Payment]: row = await db.fetchone( "SELECT * FROM invoices.payments WHERE id = ?", (payment_id,) ) @@ -120,7 +119,9 @@ async def create_invoice_items( return invoice_items -async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice: +async def update_invoice_internal( + wallet_id: str, data: Union[UpdateInvoiceData, Invoice] +) -> Invoice: await db.execute( """ UPDATE invoices.invoices @@ -155,21 +156,21 @@ async def update_invoice_items( updated_items.append(item.id) await db.execute( """ - UPDATE invoices.invoice_items + UPDATE invoices.invoice_items SET description = ?, amount = ? WHERE id = ? """, (item.description, int(item.amount * 100), item.id), ) - placeholders = ",".join("?" for i in range(len(updated_items))) + placeholders = ",".join("?" for _ in range(len(updated_items))) if not placeholders: placeholders = "?" - updated_items = ("skip",) + updated_items = ["skip"] await db.execute( f""" - DELETE FROM invoices.invoice_items + DELETE FROM invoices.invoice_items WHERE invoice_id = ? AND id NOT IN ({placeholders}) """, @@ -180,8 +181,11 @@ async def update_invoice_items( ) for item in data: - if not item.id: - await create_invoice_items(invoice_id=invoice_id, data=[item]) + if not item: + await create_invoice_items( + invoice_id=invoice_id, + data=[CreateInvoiceItemData(description=item.description)], + ) invoice_items = await get_invoice_items(invoice_id) return invoice_items diff --git a/lnbits/extensions/invoices/models.py b/lnbits/extensions/invoices/models.py index adf03e46..6f0e63cb 100644 --- a/lnbits/extensions/invoices/models.py +++ b/lnbits/extensions/invoices/models.py @@ -2,7 +2,7 @@ from enum import Enum from sqlite3 import Row from typing import List, Optional -from fastapi.param_functions import Query +from fastapi import Query from pydantic import BaseModel diff --git a/lnbits/extensions/invoices/static/image/invoices.png b/lnbits/extensions/invoices/static/image/invoices.png new file mode 100644 index 00000000..823f9dee Binary files /dev/null and b/lnbits/extensions/invoices/static/image/invoices.png differ diff --git a/lnbits/extensions/invoices/tasks.py b/lnbits/extensions/invoices/tasks.py index 61bcb7b4..c8a829db 100644 --- a/lnbits/extensions/invoices/tasks.py +++ b/lnbits/extensions/invoices/tasks.py @@ -1,9 +1,7 @@ import asyncio -import json from lnbits.core.models import Payment -from lnbits.helpers import urlsafe_short_hash -from lnbits.tasks import internal_invoice_queue, register_invoice_listener +from lnbits.tasks import register_invoice_listener from .crud import ( create_invoice_payment, @@ -14,6 +12,7 @@ from .crud import ( get_payments_total, update_invoice_internal, ) +from .models import InvoiceStatusEnum async def wait_for_paid_invoices(): @@ -27,16 +26,18 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: if payment.extra.get("tag") != "invoices": - # not relevant return invoice_id = payment.extra.get("invoice_id") + assert invoice_id - payment = await create_invoice_payment( - invoice_id=invoice_id, amount=payment.extra.get("famount") - ) + amount = payment.extra.get("famount") + assert amount + + await create_invoice_payment(invoice_id=invoice_id, amount=amount) invoice = await get_invoice(invoice_id) + assert invoice invoice_items = await get_invoice_items(invoice_id) invoice_total = await get_invoice_total(invoice_items) @@ -45,7 +46,7 @@ async def on_invoice_paid(payment: Payment) -> None: payments_total = await get_payments_total(invoice_payments) if payments_total >= invoice_total: - invoice.status = "paid" + invoice.status = InvoiceStatusEnum.paid await update_invoice_internal(invoice.wallet, invoice) return diff --git a/lnbits/extensions/invoices/templates/invoices/pay.html b/lnbits/extensions/invoices/templates/invoices/pay.html index 7b6452dc..82f1765e 100644 --- a/lnbits/extensions/invoices/templates/invoices/pay.html +++ b/lnbits/extensions/invoices/templates/invoices/pay.html @@ -251,10 +251,13 @@ block page %} @hide="closeQrCodeDialog" >Add a wallet / Import wallet on BlueWallet or
diff --git a/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html b/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html
index a15cab8f..73097dbf 100644
--- a/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html
+++ b/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html
@@ -3,16 +3,17 @@
LndHub is a protocol invented by - BlueWallet that allows mobile - wallets to query payments and balances, generate invoices and make - payments from accounts that exist on a server. The protocol is a - collection of HTTP endpoints exposed through the internet. + BlueWallet + that allows mobile wallets to query payments and balances, generate + invoices and make payments from accounts that exist on a server. The + protocol is a collection of HTTP endpoints exposed through the internet.
For a wallet that supports it, reading a QR code that contains the URL along with secret access credentials should enable access. Currently it - is supported by Zeus and - BlueWallet. + is supported by + Zeus and + BlueWallet.
GET
- /lnurlpayout/api/v1/lnurlpayouts
- {"X-Api-Key": <invoice_key>}[<lnurlpayout_object>, ...]
- curl -X GET {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -H
- "X-Api-Key: <invoice_key>"
-
- POST
- /lnurlpayout/api/v1/lnurlpayouts
- {"X-Api-Key": <invoice_key>}{"name": <string>, "currency": <string*ie USD*>}
- {"currency": <string>, "id": <string>, "name":
- <string>, "wallet": <string>}
- curl -X POST {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -d
- '{"name": <string>, "currency": <string>}' -H
- "Content-type: application/json" -H "X-Api-Key: <admin_key>"
-
- DELETE
- /lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id>
- {"X-Api-Key": <admin_key>}
- curl -X DELETE {{ request.base_url
- }}lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -H
- "X-Api-Key: <admin_key>"
-
- GET
- /lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id>
- {"X-Api-Key": <invoice_key>}[<lnurlpayout_object>, ...]
- curl -X GET {{ request.base_url
- }}lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -H
- "X-Api-Key: <invoice_key>"
-
-
+
+curl -X GET http://YOUR-TOR-ADDRESS
diff --git a/lnbits/extensions/market/__init__.py b/lnbits/extensions/market/__init__.py
new file mode 100644
index 00000000..3795ec73
--- /dev/null
+++ b/lnbits/extensions/market/__init__.py
@@ -0,0 +1,43 @@
+import asyncio
+
+from fastapi import APIRouter
+from starlette.staticfiles import StaticFiles
+
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
+
+db = Database("ext_market")
+
+market_ext: APIRouter = APIRouter(prefix="/market", tags=["market"])
+
+market_static_files = [
+ {
+ "path": "/market/static",
+ "app": StaticFiles(directory="lnbits/extensions/market/static"),
+ "name": "market_static",
+ }
+]
+
+# if 'nostradmin' not in LNBITS_ADMIN_EXTENSIONS:
+# @market_ext.get("/", response_class=HTMLResponse)
+# async def index(request: Request):
+# return template_renderer().TemplateResponse(
+# "error.html", {"request": request, "err": "Ask system admin to enable NostrAdmin!"}
+# )
+# else:
+
+
+def market_renderer():
+ return template_renderer(["lnbits/extensions/market/templates"])
+ # return template_renderer(["lnbits/extensions/market/templates"])
+
+
+from .tasks import wait_for_paid_invoices
+from .views import * # noqa
+from .views_api import * # noqa
+
+
+def market_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/market/config.json b/lnbits/extensions/market/config.json
new file mode 100644
index 00000000..8a294867
--- /dev/null
+++ b/lnbits/extensions/market/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Marketplace",
+ "short_description": "Webshop/market on LNbits",
+ "tile": "/market/static/images/bitcoin-shop.png",
+ "contributors": ["benarc", "talvasconcelos"]
+}
diff --git a/lnbits/extensions/market/crud.py b/lnbits/extensions/market/crud.py
new file mode 100644
index 00000000..1d9c28be
--- /dev/null
+++ b/lnbits/extensions/market/crud.py
@@ -0,0 +1,492 @@
+from base64 import urlsafe_b64encode
+from typing import List, Optional, Union
+from uuid import uuid4
+
+# from lnbits.db import open_ext_db
+from lnbits.db import SQLITE
+from lnbits.helpers import urlsafe_short_hash
+from lnbits.settings import WALLET
+
+from . import db
+from .models import (
+ ChatMessage,
+ CreateChatMessage,
+ CreateMarket,
+ CreateMarketStalls,
+ Market,
+ MarketSettings,
+ OrderDetail,
+ Orders,
+ Products,
+ Stalls,
+ Zones,
+ createOrder,
+ createOrderDetails,
+ createProduct,
+ createStalls,
+ createZones,
+)
+
+###Products
+
+
+async def create_market_product(data: createProduct) -> Products:
+ product_id = urlsafe_short_hash()
+ await db.execute(
+ f"""
+ INSERT INTO market.products (id, stall, product, categories, description, image, price, quantity)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ product_id,
+ data.stall,
+ data.product,
+ data.categories,
+ data.description,
+ data.image,
+ data.price,
+ data.quantity,
+ ),
+ )
+ product = await get_market_product(product_id)
+ assert product, "Newly created product couldn't be retrieved"
+ return product
+
+
+async def update_market_product(product_id: str, **kwargs) -> Optional[Products]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+
+ await db.execute(
+ f"UPDATE market.products SET {q} WHERE id = ?",
+ (*kwargs.values(), product_id),
+ )
+ row = await db.fetchone("SELECT * FROM market.products WHERE id = ?", (product_id,))
+
+ return Products(**row) if row else None
+
+
+async def get_market_product(product_id: str) -> Optional[Products]:
+ row = await db.fetchone("SELECT * FROM market.products WHERE id = ?", (product_id,))
+ return Products(**row) if row else None
+
+
+async def get_market_products(stall_ids: Union[str, List[str]]) -> List[Products]:
+ if isinstance(stall_ids, str):
+ stall_ids = [stall_ids]
+
+ # with open_ext_db("market") as db:
+ q = ",".join(["?"] * len(stall_ids))
+ rows = await db.fetchall(
+ f"""
+ SELECT * FROM market.products WHERE stall IN ({q})
+ """,
+ (*stall_ids,),
+ )
+ return [Products(**row) for row in rows]
+
+
+async def delete_market_product(product_id: str) -> None:
+ await db.execute("DELETE FROM market.products WHERE id = ?", (product_id,))
+
+
+###zones
+
+
+async def create_market_zone(user, data: createZones) -> Zones:
+ zone_id = urlsafe_short_hash()
+ await db.execute(
+ f"""
+ INSERT INTO market.zones (
+ id,
+ "user",
+ cost,
+ countries
+
+ )
+ VALUES (?, ?, ?, ?)
+ """,
+ (zone_id, user, data.cost, data.countries.lower()),
+ )
+
+ zone = await get_market_zone(zone_id)
+ assert zone, "Newly created zone couldn't be retrieved"
+ return zone
+
+
+async def update_market_zone(zone_id: str, **kwargs) -> Optional[Zones]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE market.zones SET {q} WHERE id = ?",
+ (*kwargs.values(), zone_id),
+ )
+ row = await db.fetchone("SELECT * FROM market.zones WHERE id = ?", (zone_id,))
+ return Zones(**row) if row else None
+
+
+async def get_market_zone(zone_id: str) -> Optional[Zones]:
+ row = await db.fetchone("SELECT * FROM market.zones WHERE id = ?", (zone_id,))
+ return Zones(**row) if row else None
+
+
+async def get_market_zones(user: str) -> List[Zones]:
+ rows = await db.fetchall('SELECT * FROM market.zones WHERE "user" = ?', (user,))
+ return [Zones(**row) for row in rows]
+
+
+async def delete_market_zone(zone_id: str) -> None:
+ await db.execute("DELETE FROM market.zones WHERE id = ?", (zone_id,))
+
+
+###Stalls
+
+
+async def create_market_stall(data: createStalls) -> Stalls:
+ stall_id = urlsafe_short_hash()
+ await db.execute(
+ f"""
+ INSERT INTO market.stalls (
+ id,
+ wallet,
+ name,
+ currency,
+ publickey,
+ relays,
+ shippingzones
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ stall_id,
+ data.wallet,
+ data.name,
+ data.currency,
+ data.publickey,
+ data.relays,
+ data.shippingzones,
+ ),
+ )
+
+ stall = await get_market_stall(stall_id)
+ assert stall, "Newly created stall couldn't be retrieved"
+ return stall
+
+
+async def update_market_stall(stall_id: str, **kwargs) -> Optional[Stalls]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE market.stalls SET {q} WHERE id = ?",
+ (*kwargs.values(), stall_id),
+ )
+ row = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,))
+ return Stalls(**row) if row else None
+
+
+async def get_market_stall(stall_id: str) -> Optional[Stalls]:
+ row = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,))
+ return Stalls(**row) if row else None
+
+
+async def get_market_stalls(wallet_ids: Union[str, List[str]]) -> List[Stalls]:
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM market.stalls WHERE wallet IN ({q})", (*wallet_ids,)
+ )
+ return [Stalls(**row) for row in rows]
+
+
+async def get_market_stalls_by_ids(stall_ids: Union[str, List[str]]) -> List[Stalls]:
+ q = ",".join(["?"] * len(stall_ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM market.stalls WHERE id IN ({q})", (*stall_ids,)
+ )
+ return [Stalls(**row) for row in rows]
+
+
+async def delete_market_stall(stall_id: str) -> None:
+ await db.execute("DELETE FROM market.stalls WHERE id = ?", (stall_id,))
+
+
+###Orders
+
+
+async def create_market_order(data: createOrder, invoiceid: str):
+ returning = "" if db.type == SQLITE else "RETURNING ID"
+ method = db.execute if db.type == SQLITE else db.fetchone
+
+ result = await (method)(
+ f"""
+ INSERT INTO market.orders (wallet, shippingzone, address, email, total, invoiceid, paid, shipped)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ {returning}
+ """,
+ (
+ data.wallet,
+ data.shippingzone,
+ data.address,
+ data.email,
+ data.total,
+ invoiceid,
+ False,
+ False,
+ ),
+ )
+ if db.type == SQLITE:
+ return result._result_proxy.lastrowid
+ else:
+ return result[0]
+
+
+async def create_market_order_details(order_id: str, data: List[createOrderDetails]):
+ for item in data:
+ item_id = urlsafe_short_hash()
+ await db.execute(
+ """
+ INSERT INTO market.order_details (id, order_id, product_id, quantity)
+ VALUES (?, ?, ?, ?)
+ """,
+ (
+ item_id,
+ order_id,
+ item.product_id,
+ item.quantity,
+ ),
+ )
+ order_details = await get_market_order_details(order_id)
+ return order_details
+
+
+async def get_market_order_details(order_id: str) -> List[OrderDetail]:
+ rows = await db.fetchall(
+ f"SELECT * FROM market.order_details WHERE order_id = ?", (order_id,)
+ )
+
+ return [OrderDetail(**row) for row in rows]
+
+
+async def get_market_order(order_id: str) -> Optional[Orders]:
+ row = await db.fetchone("SELECT * FROM market.orders WHERE id = ?", (order_id,))
+ return Orders(**row) if row else None
+
+
+async def get_market_order_invoiceid(invoice_id: str) -> Optional[Orders]:
+ row = await db.fetchone(
+ "SELECT * FROM market.orders WHERE invoiceid = ?", (invoice_id,)
+ )
+ return Orders(**row) if row else None
+
+
+async def set_market_order_paid(payment_hash: str):
+ await db.execute(
+ """
+ UPDATE market.orders
+ SET paid = true
+ WHERE invoiceid = ?
+ """,
+ (payment_hash,),
+ )
+
+
+async def set_market_order_pubkey(payment_hash: str, pubkey: str):
+ await db.execute(
+ """
+ UPDATE market.orders
+ SET pubkey = ?
+ WHERE invoiceid = ?
+ """,
+ (
+ pubkey,
+ payment_hash,
+ ),
+ )
+
+
+async def update_market_product_stock(products):
+
+ q = "\n".join(
+ [f"""WHEN id='{p.product_id}' THEN quantity - {p.quantity}""" for p in products]
+ )
+ v = ",".join(["?"] * len(products))
+
+ await db.execute(
+ f"""
+ UPDATE market.products
+ SET quantity=(CASE
+ {q}
+ END)
+ WHERE id IN ({v});
+ """,
+ (*[p.product_id for p in products],),
+ )
+
+
+async def get_market_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM market.orders WHERE wallet IN ({q})", (*wallet_ids,)
+ )
+ #
+ return [Orders(**row) for row in rows]
+
+
+async def delete_market_order(order_id: str) -> None:
+ await db.execute("DELETE FROM market.orders WHERE id = ?", (order_id,))
+
+
+### Market/Marketplace
+
+
+async def get_market_markets(user: str) -> List[Market]:
+ rows = await db.fetchall("SELECT * FROM market.markets WHERE usr = ?", (user,))
+ return [Market(**row) for row in rows]
+
+
+async def get_market_market(market_id: str) -> Optional[Market]:
+ row = await db.fetchone("SELECT * FROM market.markets WHERE id = ?", (market_id,))
+ return Market(**row) if row else None
+
+
+async def get_market_market_stalls(market_id: str):
+ rows = await db.fetchall(
+ "SELECT * FROM market.market_stalls WHERE marketid = ?", (market_id,)
+ )
+
+ ids = [row["stallid"] for row in rows]
+
+ return await get_market_stalls_by_ids(ids)
+
+
+async def create_market_market(data: CreateMarket):
+ market_id = urlsafe_short_hash()
+
+ await db.execute(
+ """
+ INSERT INTO market.markets (id, usr, name)
+ VALUES (?, ?, ?)
+ """,
+ (
+ market_id,
+ data.usr,
+ data.name,
+ ),
+ )
+ market = await get_market_market(market_id)
+ assert market, "Newly created market couldn't be retrieved"
+ return market
+
+
+async def create_market_market_stalls(market_id: str, data: List[str]):
+ for stallid in data:
+ id = urlsafe_short_hash()
+
+ await db.execute(
+ """
+ INSERT INTO market.market_stalls (id, marketid, stallid)
+ VALUES (?, ?, ?)
+ """,
+ (
+ id,
+ market_id,
+ stallid,
+ ),
+ )
+ market_stalls = await get_market_market_stalls(market_id)
+ return market_stalls
+
+
+async def update_market_market(market_id: str, name: str):
+ await db.execute(
+ "UPDATE market.markets SET name = ? WHERE id = ?",
+ (name, market_id),
+ )
+ await db.execute(
+ "DELETE FROM market.market_stalls WHERE marketid = ?",
+ (market_id,),
+ )
+
+ market = await get_market_market(market_id)
+ return market
+
+
+### CHAT / MESSAGES
+
+
+async def create_chat_message(data: CreateChatMessage):
+ await db.execute(
+ """
+ INSERT INTO market.messages (msg, pubkey, id_conversation)
+ VALUES (?, ?, ?)
+ """,
+ (
+ data.msg,
+ data.pubkey,
+ data.room_name,
+ ),
+ )
+
+
+async def get_market_latest_chat_messages(room_name: str):
+ rows = await db.fetchall(
+ "SELECT * FROM market.messages WHERE id_conversation = ? ORDER BY timestamp DESC LIMIT 20",
+ (room_name,),
+ )
+
+ return [ChatMessage(**row) for row in rows]
+
+
+async def get_market_chat_messages(room_name: str):
+ rows = await db.fetchall(
+ "SELECT * FROM market.messages WHERE id_conversation = ? ORDER BY timestamp DESC",
+ (room_name,),
+ )
+
+ return [ChatMessage(**row) for row in rows]
+
+
+async def get_market_chat_by_merchant(ids: List[str]) -> List[ChatMessage]:
+
+ q = ",".join(["?"] * len(ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM market.messages WHERE id_conversation IN ({q})",
+ (*ids,),
+ )
+ return [ChatMessage(**row) for row in rows]
+
+
+async def get_market_settings(user) -> Optional[MarketSettings]:
+ row = await db.fetchone(
+ """SELECT * FROM market.settings WHERE "user" = ?""", (user,)
+ )
+
+ return MarketSettings(**row) if row else None
+
+
+async def create_market_settings(user: str, data):
+ await db.execute(
+ """
+ INSERT INTO market.settings ("user", currency, fiat_base_multiplier)
+ VALUES (?, ?, ?)
+ """,
+ (
+ user,
+ data.currency,
+ data.fiat_base_multiplier,
+ ),
+ )
+
+
+async def set_market_settings(user: str, data):
+ await db.execute(
+ """
+ UPDATE market.settings
+ SET currency = ?, fiat_base_multiplier = ?
+ WHERE "user" = ?;
+ """,
+ (
+ data.currency,
+ data.fiat_base_multiplier,
+ user,
+ ),
+ )
diff --git a/lnbits/extensions/market/migrations.py b/lnbits/extensions/market/migrations.py
new file mode 100644
index 00000000..72b584f9
--- /dev/null
+++ b/lnbits/extensions/market/migrations.py
@@ -0,0 +1,156 @@
+async def m001_initial(db):
+ """
+ Initial Market settings table.
+ """
+ await db.execute(
+ """
+ CREATE TABLE market.settings (
+ "user" TEXT PRIMARY KEY,
+ currency TEXT DEFAULT 'sat',
+ fiat_base_multiplier INTEGER DEFAULT 1
+ );
+ """
+ )
+
+ """
+ Initial stalls table.
+ """
+ await db.execute(
+ """
+ CREATE TABLE market.stalls (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ name TEXT NOT NULL,
+ currency TEXT,
+ publickey TEXT,
+ relays TEXT,
+ shippingzones TEXT NOT NULL,
+ rating INTEGER DEFAULT 0
+ );
+ """
+ )
+
+ """
+ Initial products table.
+ """
+ await db.execute(
+ f"""
+ CREATE TABLE market.products (
+ id TEXT PRIMARY KEY,
+ stall TEXT NOT NULL REFERENCES {db.references_schema}stalls (id) ON DELETE CASCADE,
+ product TEXT NOT NULL,
+ categories TEXT,
+ description TEXT,
+ image TEXT,
+ price INTEGER NOT NULL,
+ quantity INTEGER NOT NULL,
+ rating INTEGER DEFAULT 0
+ );
+ """
+ )
+
+ """
+ Initial zones table.
+ """
+ await db.execute(
+ """
+ CREATE TABLE market.zones (
+ id TEXT PRIMARY KEY,
+ "user" TEXT NOT NULL,
+ cost TEXT NOT NULL,
+ countries TEXT NOT NULL
+ );
+ """
+ )
+
+ """
+ Initial orders table.
+ """
+ await db.execute(
+ f"""
+ CREATE TABLE market.orders (
+ id {db.serial_primary_key},
+ wallet TEXT NOT NULL,
+ username TEXT,
+ pubkey TEXT,
+ shippingzone TEXT NOT NULL,
+ address TEXT NOT NULL,
+ email TEXT NOT NULL,
+ total INTEGER NOT NULL,
+ invoiceid TEXT NOT NULL,
+ paid BOOLEAN NOT NULL,
+ shipped BOOLEAN NOT NULL,
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
+ );
+ """
+ )
+
+ """
+ Initial order details table.
+ """
+ await db.execute(
+ f"""
+ CREATE TABLE market.order_details (
+ id TEXT PRIMARY KEY,
+ order_id INTEGER NOT NULL REFERENCES {db.references_schema}orders (id) ON DELETE CASCADE,
+ product_id TEXT NOT NULL REFERENCES {db.references_schema}products (id) ON DELETE CASCADE,
+ quantity INTEGER NOT NULL
+ );
+ """
+ )
+
+ """
+ Initial market table.
+ """
+ await db.execute(
+ """
+ CREATE TABLE market.markets (
+ id TEXT PRIMARY KEY,
+ usr TEXT NOT NULL,
+ name TEXT
+ );
+ """
+ )
+
+ """
+ Initial market stalls table.
+ """
+ await db.execute(
+ f"""
+ CREATE TABLE market.market_stalls (
+ id TEXT PRIMARY KEY,
+ marketid TEXT NOT NULL REFERENCES {db.references_schema}markets (id) ON DELETE CASCADE,
+ stallid TEXT NOT NULL REFERENCES {db.references_schema}stalls (id) ON DELETE CASCADE
+ );
+ """
+ )
+
+ """
+ Initial chat messages table.
+ """
+ await db.execute(
+ f"""
+ CREATE TABLE market.messages (
+ id {db.serial_primary_key},
+ msg TEXT NOT NULL,
+ pubkey TEXT NOT NULL,
+ id_conversation TEXT NOT NULL,
+ timestamp TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
+ );
+ """
+ )
+
+ if db.type != "SQLITE":
+ """
+ Create indexes for message fetching
+ """
+ await db.execute(
+ "CREATE INDEX idx_messages_timestamp ON market.messages (timestamp DESC)"
+ )
+ await db.execute(
+ "CREATE INDEX idx_messages_conversations ON market.messages (id_conversation)"
+ )
diff --git a/lnbits/extensions/market/models.py b/lnbits/extensions/market/models.py
new file mode 100644
index 00000000..ea7f6f20
--- /dev/null
+++ b/lnbits/extensions/market/models.py
@@ -0,0 +1,135 @@
+from typing import List, Optional
+
+from fastapi.param_functions import Query
+from pydantic import BaseModel
+
+
+class MarketSettings(BaseModel):
+ user: str
+ currency: str
+ fiat_base_multiplier: int
+
+
+class SetSettings(BaseModel):
+ currency: str
+ fiat_base_multiplier: int = Query(100, ge=1)
+
+
+class Stalls(BaseModel):
+ id: str
+ wallet: str
+ name: str
+ currency: str
+ publickey: Optional[str]
+ relays: Optional[str]
+ shippingzones: str
+
+
+class createStalls(BaseModel):
+ wallet: str = Query(...)
+ name: str = Query(...)
+ currency: str = Query("sat")
+ publickey: str = Query(None)
+ relays: str = Query(None)
+ shippingzones: str = Query(...)
+
+
+class createProduct(BaseModel):
+ stall: str = Query(...)
+ product: str = Query(...)
+ categories: str = Query(None)
+ description: str = Query(None)
+ image: str = Query(None)
+ price: float = Query(0, ge=0)
+ quantity: int = Query(0, ge=0)
+
+
+class Products(BaseModel):
+ id: str
+ stall: str
+ product: str
+ categories: Optional[str]
+ description: Optional[str]
+ image: Optional[str]
+ price: float
+ quantity: int
+
+
+class createZones(BaseModel):
+ cost: float = Query(0, ge=0)
+ countries: str = Query(...)
+
+
+class Zones(BaseModel):
+ id: str
+ user: str
+ cost: float
+ countries: str
+
+
+class OrderDetail(BaseModel):
+ id: str
+ order_id: str
+ product_id: str
+ quantity: int
+
+
+class createOrderDetails(BaseModel):
+ product_id: str = Query(...)
+ quantity: int = Query(..., ge=1)
+
+
+class createOrder(BaseModel):
+ wallet: str = Query(...)
+ username: str = Query(None)
+ pubkey: str = Query(None)
+ shippingzone: str = Query(...)
+ address: str = Query(...)
+ email: str = Query(...)
+ total: int = Query(...)
+ products: List[createOrderDetails]
+
+
+class Orders(BaseModel):
+ id: str
+ wallet: str
+ username: Optional[str]
+ pubkey: Optional[str]
+ shippingzone: str
+ address: str
+ email: str
+ total: int
+ invoiceid: str
+ paid: bool
+ shipped: bool
+ time: int
+
+
+class CreateMarket(BaseModel):
+ usr: str = Query(...)
+ name: str = Query(None)
+ stalls: List[str] = Query(...)
+
+
+class Market(BaseModel):
+ id: str
+ usr: str
+ name: Optional[str]
+
+
+class CreateMarketStalls(BaseModel):
+ stallid: str
+
+
+class ChatMessage(BaseModel):
+ id: str
+ msg: str
+ pubkey: str
+ id_conversation: str
+ timestamp: int
+
+
+class CreateChatMessage(BaseModel):
+ msg: str = Query(..., min_length=1)
+ pubkey: str = Query(...)
+ room_name: str = Query(...)
diff --git a/lnbits/extensions/market/notifier.py b/lnbits/extensions/market/notifier.py
new file mode 100644
index 00000000..e2bf7c91
--- /dev/null
+++ b/lnbits/extensions/market/notifier.py
@@ -0,0 +1,91 @@
+## adapted from https://github.com/Sentymental/chat-fastapi-websocket
+"""
+Create a class Notifier that will handle messages
+and delivery to the specific person
+"""
+
+import json
+from collections import defaultdict
+
+from fastapi import WebSocket
+from loguru import logger
+
+from lnbits.extensions.market.crud import create_chat_message
+from lnbits.extensions.market.models import CreateChatMessage
+
+
+class Notifier:
+ """
+ Manages chatrooms, sessions and members.
+
+ Methods:
+ - get_notification_generator(self): async generator with notification messages
+ - get_members(self, room_name: str): get members in room
+ - push(message: str, room_name: str): push message
+ - connect(websocket: WebSocket, room_name: str): connect to room
+ - remove(websocket: WebSocket, room_name: str): remove
+ - _notify(message: str, room_name: str): notifier
+ """
+
+ def __init__(self):
+ # Create sessions as a dict:
+ self.sessions: dict = defaultdict(dict)
+
+ # Create notification generator:
+ self.generator = self.get_notification_generator()
+
+ async def get_notification_generator(self):
+ """Notification Generator"""
+
+ while True:
+ message = yield
+ msg = message["message"]
+ room_name = message["room_name"]
+ await self._notify(msg, room_name)
+
+ def get_members(self, room_name: str):
+ """Get all members in a room"""
+
+ try:
+ logger.info(f"Looking for members in room: {room_name}")
+ return self.sessions[room_name]
+
+ except Exception:
+ logger.exception(f"There is no member in room: {room_name}")
+ return None
+
+ async def push(self, message: str, room_name: str = None):
+ """Push a message"""
+
+ message_body = {"message": message, "room_name": room_name}
+ await self.generator.asend(message_body)
+
+ async def connect(self, websocket: WebSocket, room_name: str):
+ """Connect to room"""
+
+ await websocket.accept()
+ if self.sessions[room_name] == {} or len(self.sessions[room_name]) == 0:
+ self.sessions[room_name] = []
+
+ self.sessions[room_name].append(websocket)
+ print(f"Connections ...: {self.sessions[room_name]}")
+
+ def remove(self, websocket: WebSocket, room_name: str):
+ """Remove websocket from room"""
+
+ self.sessions[room_name].remove(websocket)
+ print(f"Connection removed...\nOpen connections...: {self.sessions[room_name]}")
+
+ async def _notify(self, message: str, room_name: str):
+ """Notifier"""
+ d = json.loads(message)
+ d["room_name"] = room_name
+ db_msg = CreateChatMessage.parse_obj(d)
+ await create_chat_message(data=db_msg)
+
+ remaining_sessions = []
+ while len(self.sessions[room_name]) > 0:
+ websocket = self.sessions[room_name].pop()
+ await websocket.send_text(message)
+ remaining_sessions.append(websocket)
+ self.sessions[room_name] = remaining_sessions
diff --git a/lnbits/extensions/market/static/images/bitcoin-shop.png b/lnbits/extensions/market/static/images/bitcoin-shop.png
new file mode 100644
index 00000000..debffbb2
Binary files /dev/null and b/lnbits/extensions/market/static/images/bitcoin-shop.png differ
diff --git a/lnbits/extensions/market/static/images/placeholder.png b/lnbits/extensions/market/static/images/placeholder.png
new file mode 100644
index 00000000..c7d3a947
Binary files /dev/null and b/lnbits/extensions/market/static/images/placeholder.png differ
diff --git a/lnbits/extensions/market/tasks.py b/lnbits/extensions/market/tasks.py
new file mode 100644
index 00000000..b102e0f1
--- /dev/null
+++ b/lnbits/extensions/market/tasks.py
@@ -0,0 +1,39 @@
+import asyncio
+
+from loguru import logger
+
+from lnbits.core.models import Payment
+from lnbits.tasks import register_invoice_listener
+
+from .crud import (
+ get_market_order_details,
+ get_market_order_invoiceid,
+ set_market_order_paid,
+ update_market_product_stock,
+)
+
+
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
+
+ while True:
+ payment = await invoice_queue.get()
+ await on_invoice_paid(payment)
+
+
+async def on_invoice_paid(payment: Payment) -> None:
+ if payment.extra.get("tag") != "market":
+ return
+
+ order = await get_market_order_invoiceid(payment.payment_hash)
+ if not order:
+ logger.error("this should never happen", payment)
+ return
+
+ # set order as paid
+ await set_market_order_paid(payment.payment_hash)
+
+ # deduct items sold from stock
+ details = await get_market_order_details(order.id)
+ await update_market_product_stock(details)
diff --git a/lnbits/extensions/market/templates/market/_api_docs.html b/lnbits/extensions/market/templates/market/_api_docs.html
new file mode 100644
index 00000000..f0d97dbf
--- /dev/null
+++ b/lnbits/extensions/market/templates/market/_api_docs.html
@@ -0,0 +1,128 @@
+GET
+ /market/api/v1/stall/products/<relay_id>
+ Product JSON list
+ curl -X GET {{ request.url_root
+ }}api/v1/stall/products/<relay_id>
+ POST
+ /market/api/v1/stall/order/<relay_id>
+ {"id": <string>, "address": <string>, "shippingzone":
+ <integer>, "email": <string>, "quantity":
+ <integer>}
+ {"checking_id": <string>,"payment_request":
+ <string>}
+ curl -X POST {{ request.url_root
+ }}api/v1/stall/order/<relay_id> -d '{"id": <product_id&>,
+ "email": <customer_email>, "address": <customer_address>,
+ "quantity": 2, "shippingzone": 1}' -H "Content-type: application/json"
+
+ GET
+ /market/api/v1/stall/checkshipped/<checking_id>
+ {"shipped": <boolean>}
+ curl -X GET {{ request.url_root
+ }}api/v1/stall/checkshipped/<checking_id> -H "Content-type:
+ application/json"
+
+ {{ type == 'pubkey' ? 'Public Key' : 'Private Key' }}
Click to copy
+
{{ item.description }}
+
+ Public Key: {{ sliceKey(stall.publickey) }}
+
+ Bellow are the keys needed to contact the merchant. They are + stored in the browser! +
++ {{ type == 'publickey' ? 'Public Key' : 'Private Key' }} +
+ {% endraw %} +Export, or send, this page to another device
++ Don't forget to bookmark this page to be able to check on your order! +
++ You can backup your keys, and export the page to another device also. +
+{{ item.description }}
+Select the shipping zone:
++ {{ ngrok }} +
+
+
+ 1. Create a Domain by clicking "NEW DOMAIN"\
+ 2. Fill the options for your DOMAIN
+ - select the wallet
+ - select the fiat currency the invoice will be denominated in
+ - select an amount in fiat to charge users for verification
+ - enter the domain (or subdomain) you want to provide verification
+ for
+ 3. You can then use share your signup link with your users to allow them
+ to sign up *Note, you must own this domain and have access to a web
+ server*
+
+ Installation
+
+ In order for this to work, you need to have ownership of a domain name,
+ and access to a web server that this domain is pointed to. Then, you'll
+ need to set up a proxy that points
+ `https://{your_domain}/.well-known/nostr.json` to
+ `https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json`
+
+ Example nginx configuration
+
+
+
+
+ location /.well-known/nostr.json {
+ proxy_pass
+ https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json;
+ proxy_set_header Host {your_lnbits};
+ proxy_ssl_server_name on;
+
+ expires 5m;
+ add_header Cache-Control "public,
+ no-transform";
+
+ proxy_cache nip5_cache;
+ proxy_cache_lock on;
+ proxy_cache_valid 200 300s;
+ proxy_cache_use_stale error timeout
+ invalid_header updating http_500 http_502 http_503 http_504;
+ }
+
+
GET /nostrnip5/api/v1/domains
+ {"X-Api-Key": <invoice_key>}[<domain_object>, ...]
+ curl -X GET {{ request.base_url }}nostrnip5/api/v1/domains -H
+ "X-Api-Key: <invoice_key>"
+
+ GET /nostrnip5/api/v1/addresses
+ {"X-Api-Key": <invoice_key>}[<address_object>, ...]
+ curl -X GET {{ request.base_url }}nostrnip5/api/v1/addresses -H
+ "X-Api-Key: <invoice_key>"
+
+ GET
+ /nostrnip5/api/v1/domain/{domain_id}
+ {"X-Api-Key": <invoice_key>}{domain_object}
+ curl -X GET {{ request.base_url }}nostrnip5/api/v1/domain/{domain_id}
+ -H "X-Api-Key: <invoice_key>"
+
+ POST /nostrnip5/api/v1/domain
+ {"X-Api-Key": <invoice_key>}{domain_object}
+ curl -X POST {{ request.base_url }}nostrnip5/api/v1/domain -H
+ "X-Api-Key: <invoice_key>"
+
+ POST
+ /nostrnip5/api/v1/domain/{domain_id}/address
+ {"X-Api-Key": <invoice_key>}{address_object}
+ curl -X POST {{ request.base_url
+ }}nostrnip5/api/v1/domain/{domain_id}/address -H "X-Api-Key:
+ <invoice_key>"
+
+ POST
+ /invoices/api/v1/invoice/{invoice_id}/payments
+ {payment_object}
+ curl -X POST {{ request.base_url
+ }}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
+ <invoice_key>"
+
+ GET
+ /nostrnip5/api/v1/domain/{domain_id}/payments/{payment_hash}
+ curl -X GET {{ request.base_url
+ }}nostrnip5/api/v1/domain/{domain_id}/payments/{payment_hash} -H
+ "X-Api-Key: <invoice_key>"
+
+ + Allow users to NIP-05 verify themselves at a domain you + control +
++ You can use this page to change the public key associated with your + NIP-5 identity. +
++ Your current NIP-5 identity is {{ address.local_part }}@{{ domain.domain + }} with nostr public key {{ address.pubkey }}. +
+ +Input your new pubkey below to update it.
+ ++ Success! Your username is now active at {{ successData.local_part }}@{{ + domain }}. Please add this to your nostr profile accordingly. If you ever + need to rotate your keys, you can still keep your identity! +
+ ++ Bookmark this link: + {{ base_url }}nostrnip5/rotate/{{ domain_id }}/{{ + successData.address_id }} +
++ In case you ever need to change your pubkey, you can still keep this NIP-5 + identity. Just come back to the above linked page to change the pubkey + associated to your identity. +
+ {% endraw %} ++ You can use this page to get NIP-5 verified on the nostr protocol under + the {{ domain.domain }} domain. +
++ The current price is + {{ "{:0,.2f}".format(domain.amount / 100) }} {{ domain.currency }} + for an account (if you do not own the domain, the service provider can + disable at any time). +
+ +After submitting payment, your address will be
+ +and will be tied to this nostr pubkey
+ +