Merge remote-tracking branch 'origin/diagon-alley' into diagon-alley

This commit is contained in:
ben 2022-11-30 11:59:51 +00:00
commit 5dbcbb7489
110 changed files with 3353 additions and 904 deletions

View file

@ -9,8 +9,11 @@ LNBITS_ADMIN_USERS=""
LNBITS_ADMIN_EXTENSIONS="nostradmin" LNBITS_ADMIN_EXTENSIONS="nostradmin"
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
LNBITS_AD_SPACE="" # csv ad image filepaths or urls, extensions can choose to honor # csv ad image filepaths or urls, extensions can choose to honor
LNBITS_HIDE_API=false # Hides wallet api, extensions can choose to honor LNBITS_AD_SPACE=""
# Hides wallet api, extensions can choose to honor
LNBITS_HIDE_API=false
# Disable extensions for all users, use "all" to disable all extensions # Disable extensions for all users, use "all" to disable all extensions
LNBITS_DISABLED_EXTENSIONS="amilk" LNBITS_DISABLED_EXTENSIONS="amilk"
@ -25,18 +28,20 @@ LNBITS_DATA_FOLDER="./data"
LNBITS_FORCE_HTTPS=true LNBITS_FORCE_HTTPS=true
LNBITS_SERVICE_FEE="0.0" LNBITS_SERVICE_FEE="0.0"
LNBITS_RESERVE_FEE_MIN=2000 # value in millisats # value in millisats
LNBITS_RESERVE_FEE_PERCENT=1.0 # value in percent LNBITS_RESERVE_FEE_MIN=2000
# value in percent
LNBITS_RESERVE_FEE_PERCENT=1.0
# Change theme # Change theme
LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet" LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'" LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic # Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador" LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet, LnTipsWallet
# LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet # LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
@ -86,4 +91,9 @@ LNBITS_DENOMINATION=sats
# EclairWallet # EclairWallet
ECLAIR_URL=http://127.0.0.1:8283 ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw ECLAIR_PASS=eclairpw
# LnTipsWallet
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips

View file

@ -9,9 +9,20 @@ on:
jobs: jobs:
checks: checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: abatilo/actions-poetry@v2.1.3 - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Install packages - name: Install packages
run: poetry install run: poetry install
- name: Check black - name: Check black

View file

@ -22,14 +22,18 @@ jobs:
--health-retries 5 --health-retries 5
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3 - name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Install dependencies - name: Install dependencies
run: | run: |
poetry install poetry install

View file

@ -7,14 +7,18 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3 - name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Install dependencies - name: Install dependencies
run: | run: |
poetry install poetry install

View file

@ -7,14 +7,18 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3 - name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Setup Regtest - name: Setup Regtest
run: | run: |
docker build -t lnbitsdocker/lnbits-legend . docker build -t lnbitsdocker/lnbits-legend .
@ -46,14 +50,18 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.8] python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3 - name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Setup Regtest - name: Setup Regtest
run: | run: |
docker build -t lnbitsdocker/lnbits-legend . docker build -t lnbitsdocker/lnbits-legend .
@ -65,7 +73,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
poetry install poetry install
poetry add grpcio protobuf
- name: Run tests - name: Run tests
env: env:
PYTHONUNBUFFERED: 1 PYTHONUNBUFFERED: 1
@ -87,14 +94,18 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3 - name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Setup Regtest - name: Setup Regtest
run: | run: |
docker build -t lnbitsdocker/lnbits-legend . docker build -t lnbitsdocker/lnbits-legend .
@ -106,7 +117,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
poetry install poetry install
poetry add pyln-client
- name: Run tests - name: Run tests
env: env:
PYTHONUNBUFFERED: 1 PYTHONUNBUFFERED: 1

View file

@ -7,7 +7,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
@ -29,14 +30,18 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3 - name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Install dependencies - name: Install dependencies
env: env:
VIRTUAL_ENV: ./venv VIRTUAL_ENV: ./venv
@ -64,14 +69,18 @@ jobs:
--health-retries 5 --health-retries 5
strategy: strategy:
matrix: matrix:
python-version: [3.9] python-version: ["3.9"]
poetry-version: ["1.2.1"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3 - name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Install dependencies - name: Install dependencies
run: | run: |
poetry install poetry install

View file

@ -1,13 +1,23 @@
FROM python:3.9-slim FROM python:3.9-slim
RUN apt-get clean RUN apt-get clean
RUN apt-get update RUN apt-get update
RUN apt-get install -y curl pkg-config build-essential RUN apt-get install -y curl pkg-config build-essential
RUN curl -sSL https://install.python-poetry.org | python3 - RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH" ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN poetry config virtualenvs.create false RUN poetry config virtualenvs.create false
RUN poetry install --no-dev --no-root RUN poetry install --no-dev --no-root
RUN poetry run python build.py RUN poetry run python build.py
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000 EXPOSE 5000
CMD ["poetry", "run", "lnbits", "--port", "5000", "--host", "0.0.0.0"]
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]

View file

@ -28,6 +28,10 @@ checkisort:
poetry run isort --check-only . poetry run isort --check-only .
test: test:
BOLTZ_NETWORK="regtest" \
BOLTZ_URL="http://127.0.0.1:9001" \
BOLTZ_MEMPOOL_SPACE_URL="http://127.0.0.1:8080" \
BOLTZ_MEMPOOL_SPACE_URL_WS="ws://127.0.0.1:8080" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
FAKE_WALLET_SECRET="ToTheMoon1" \ FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
@ -46,6 +50,10 @@ test-real-wallet:
poetry run pytest poetry run pytest
test-venv: test-venv:
BOLTZ_NETWORK="regtest" \
BOLTZ_URL="http://127.0.0.1:9001" \
BOLTZ_MEMPOOL_SPACE_URL="http://127.0.0.1:8080" \
BOLTZ_MEMPOOL_SPACE_URL_WS="ws://127.0.0.1:8080" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
FAKE_WALLET_SECRET="ToTheMoon1" \ FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \

87
docs/devs/websockets.md Normal file
View file

@ -0,0 +1,87 @@
---
layout: default
parent: For developers
title: Websockets
nav_order: 2
---
Websockets
=================
`websockets` are a great way to add a two way instant data channel between server and client. This example was taken from the `copilot` extension, we create a websocket endpoint which can be restricted by `id`, then can feed it data to broadcast to any client on the socket using the `updater(extension_id, data)` function (`extension` has been used in place of an extension name, wreplace to your own extension):
```sh
from fastapi import Request, WebSocket, WebSocketDisconnect
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, extension_id: str):
await websocket.accept()
websocket.id = extension_id
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, extension_id: str):
for connection in self.active_connections:
if connection.id == extension_id:
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@extension_ext.websocket("/ws/{extension_id}", name="extension.websocket_by_id")
async def websocket_endpoint(websocket: WebSocket, extension_id: str):
await manager.connect(websocket, extension_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(extension_id, data):
extension = await get_extension(extension_id)
if not extension:
return
await manager.send_personal_message(f"{data}", extension_id)
```
Example vue-js function for listening to the websocket:
```
initWs: async function () {
if (location.protocol !== 'http:') {
localUrl =
'wss://' +
document.domain +
':' +
location.port +
'/extension/ws/' +
self.extension.id
} else {
localUrl =
'ws://' +
document.domain +
':' +
location.port +
'/extension/ws/' +
self.extension.id
}
this.ws = new WebSocket(localUrl)
this.ws.addEventListener('message', async ({data}) => {
const res = JSON.parse(data.toString())
console.log(res)
})
},
```

View file

@ -18,21 +18,25 @@ If you have problems installing LNbits using these instructions, please have a l
git clone https://github.com/lnbits/lnbits-legend.git git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/ cd lnbits-legend/
# for making sure python 3.9 is installed, skip if installed # for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
sudo apt update sudo apt update
sudo apt install software-properties-common sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils sudo apt install python3.9 python3.9-distutils
curl -sSL https://install.python-poetry.org | python3 - curl -sSL https://install.python-poetry.org | python3 -
export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal # Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
export PATH="/home/user/.local/bin:$PATH"
# Next command, you can exchange with python3.10 or newer versions.
# Identify your version with python3 --version and specify in the next line
# command is only needed when your default python is not ^3.9 or ^3.10
poetry env use python3.9 poetry env use python3.9
poetry install --no-dev poetry install --only main
poetry run python build.py
mkdir data mkdir data
cp .env.example .env cp .env.example .env
nano .env # set funding source # set funding source amongst other options
nano .env
``` ```
#### Running the server #### Running the server
@ -40,6 +44,8 @@ nano .env # set funding source
```sh ```sh
poetry run lnbits poetry run lnbits
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0' # To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
# Note that you have to add the line DEBUG=true in your .env file, too.
``` ```
## Option 2: Nix ## Option 2: Nix
@ -292,6 +298,43 @@ Save the file and run the following commands:
sudo systemctl enable lnbits.service sudo systemctl enable lnbits.service
sudo systemctl start lnbits.service sudo systemctl start lnbits.service
``` ```
## Reverse proxy with automatic https using Caddy
Use Caddy to make your LNbits install accessible over clearnet with a domain and https cert.
Point your domain at the IP of the server you're running LNbits on, by making an `A` record.
Install Caddy on the server
https://caddyserver.com/docs/install#debian-ubuntu-raspbian
```
sudo caddy stop
```
Create a Caddyfile
```
sudo nano Caddyfile
```
Assuming your LNbits is running on port `5000` add:
```
yourdomain.com {
handle /api/v1/payments/sse* {
reverse_proxy 0.0.0.0:5000 {
header_up X-Forwarded-Host yourdomain.com
transport http {
keepalive off
compression off
}
}
}
reverse_proxy 0.0.0.0:5000 {
header_up X-Forwarded-Host yourdomain.com
}
}
```
Save and exit `CTRL + x`
```
sudo caddy start
```
## Running behind an apache2 reverse proxy over https ## Running behind an apache2 reverse proxy over https
Install apache2 and enable apache2 mods Install apache2 and enable apache2 mods

View file

@ -15,8 +15,6 @@ A backend wallet can be configured using the following LNbits environment variab
### CoreLightning ### CoreLightning
Using this wallet requires the installation of the `pylightning` Python package.
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet** - `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc - `CORELIGHTNING_RPC`: /file/path/lightning-rpc
@ -39,8 +37,6 @@ or
### LND (gRPC) ### LND (gRPC)
Using this wallet requires the installation of the `grpcio` and `protobuf` Python packages.
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet** - `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
- `LND_GRPC_ENDPOINT`: ip_address - `LND_GRPC_ENDPOINT`: ip_address
- `LND_GRPC_PORT`: port - `LND_GRPC_PORT`: port
@ -83,3 +79,8 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet** - `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/ - `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey - `OPENNODE_KEY`: opennodeAdminApiKey
### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000

View file

@ -34,7 +34,6 @@ from .tasks import (
check_pending_payments, check_pending_payments,
internal_invoice_listener, internal_invoice_listener,
invoice_listener, invoice_listener,
run_deferred_async,
webhook_handler, webhook_handler,
) )
@ -92,7 +91,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
) )
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
# app.add_middleware(ASGIProxyFix)
check_funding_source(app) check_funding_source(app)
register_assets(app) register_assets(app)
@ -127,7 +125,7 @@ def check_funding_source(app: FastAPI) -> None:
logger.info("Retrying connection to backend in 5 seconds...") logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5) await asyncio.sleep(5)
signal.signal(signal.SIGINT, original_sigint_handler) signal.signal(signal.SIGINT, original_sigint_handler)
logger.info( logger.success(
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat." f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
) )
@ -185,7 +183,7 @@ def register_async_tasks(app):
loop.create_task(catch_everything_and_restart(invoice_listener)) loop.create_task(catch_everything_and_restart(invoice_listener))
loop.create_task(catch_everything_and_restart(internal_invoice_listener)) loop.create_task(catch_everything_and_restart(internal_invoice_listener))
await register_task_listeners() await register_task_listeners()
await run_deferred_async() # await run_deferred_async() # calle: doesn't do anyting?
@app.on_event("shutdown") @app.on_event("shutdown")
async def stop_listeners(): async def stop_listeners():

View file

@ -177,6 +177,11 @@ async def get_wallet_for_key(
return Wallet(**row) return Wallet(**row)
async def get_total_balance(conn: Optional[Connection] = None):
row = await (conn or db).fetchone("SELECT SUM(balance) FROM balances")
return 0 if row[0] is None else row[0]
# wallet payments # wallet payments
# --------------- # ---------------
@ -328,7 +333,7 @@ async def delete_expired_invoices(
""" """
) )
logger.debug(f"Checking expiry of {len(rows)} invoices") logger.debug(f"Checking expiry of {len(rows)} invoices")
for (payment_request,) in rows: for i, (payment_request,) in enumerate(rows):
try: try:
invoice = bolt11.decode(payment_request) invoice = bolt11.decode(payment_request)
except: except:
@ -338,7 +343,7 @@ async def delete_expired_invoices(
if expiration_date > datetime.datetime.utcnow(): if expiration_date > datetime.datetime.utcnow():
continue continue
logger.debug( logger.debug(
f"Deleting expired invoice: {invoice.payment_hash} (expired: {expiration_date})" f"Deleting expired invoice {i}/{len(rows)}: {invoice.payment_hash} (expired: {expiration_date})"
) )
await (conn or db).execute( await (conn or db).execute(
""" """

View file

@ -186,9 +186,9 @@ async def pay_invoice(
) )
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
await internal_invoice_queue.put(internal_checking_id) await internal_invoice_queue.put(internal_checking_id)
else: else:
logger.debug(f"backend: sending payment {temp_id}") logger.debug(f"backend: sending payment {temp_id}")

View file

@ -3,7 +3,11 @@ const CACHE_VERSION = 1
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-` const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
const getApiKey = request => { const getApiKey = request => {
return request.headers.get('X-Api-Key') || 'none' let api_key = request.headers.get('X-Api-Key')
if (!api_key || api_key == 'undefined') {
api_key = 'no_api_key'
}
return api_key
} }
// on activation we clean up the previously registered service workers // on activation we clean up the previously registered service workers
@ -26,8 +30,10 @@ self.addEventListener('activate', evt =>
// If no response is found, it populates the runtime cache with the response // If no response is found, it populates the runtime cache with the response
// from the network before returning it to the page. // from the network before returning it to the page.
self.addEventListener('fetch', event => { self.addEventListener('fetch', event => {
// Skip cross-origin requests, like those for Google Analytics.
if ( if (
!event.request.url.startsWith(
self.location.origin + '/api/v1/payments/sse'
) &&
event.request.url.startsWith(self.location.origin) && event.request.url.startsWith(self.location.origin) &&
event.request.method == 'GET' event.request.method == 'GET'
) { ) {

View file

@ -361,6 +361,35 @@ new Vue({
this.receive.status = 'pending' 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) { decodeQR: function (res) {
this.parse.data.request = res this.parse.data.request = res
this.decodeRequest() this.decodeRequest()
@ -675,7 +704,7 @@ new Vue({
// status is important for export but it is not in paymentsTable // status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons // because it is manually added with payment detail link and icons
// and would cause duplication in the list // and would cause duplication in the list
let columns = this.paymentsTable.columns let columns = structuredClone(this.paymentsTable.columns)
columns.unshift({ columns.unshift({
name: 'pending', name: 'pending',
align: 'left', align: 'left',

View file

@ -1,30 +1,43 @@
import asyncio import asyncio
from typing import List from typing import Dict
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.tasks import register_invoice_listener from lnbits.helpers import get_current_extension_name
from lnbits.tasks import SseListenersDict, register_invoice_listener
from . import db from . import db
from .crud import get_balance_notify from .crud import get_balance_notify
from .models import Payment from .models import Payment
api_invoice_listeners: List[asyncio.Queue] = [] api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
"api_invoice_listeners"
)
async def register_task_listeners(): async def register_task_listeners():
"""
Registers an invoice listener queue for the core tasks.
Incoming payaments in this queue will eventually trigger the signals sent to all other extensions
and fulfill other core tasks such as dispatching webhooks.
"""
invoice_paid_queue = asyncio.Queue(5) invoice_paid_queue = asyncio.Queue(5)
register_invoice_listener(invoice_paid_queue) # we register invoice_paid_queue to receive all incoming invoices
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
# register a worker that will react to invoices
asyncio.create_task(wait_for_paid_invoices(invoice_paid_queue)) asyncio.create_task(wait_for_paid_invoices(invoice_paid_queue))
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue): async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
"""
This worker dispatches events to all extensions, dispatches webhooks and balance notifys.
"""
while True: while True:
payment = await invoice_paid_queue.get() payment = await invoice_paid_queue.get()
logger.debug("received invoice paid event") logger.trace("received invoice paid event")
# send information to sse channel # send information to sse channel
await dispatch_invoice_listener(payment) await dispatch_api_invoice_listeners(payment)
# dispatch webhook # dispatch webhook
if payment.webhook and not payment.webhook_status: if payment.webhook and not payment.webhook_status:
@ -41,16 +54,23 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
pass pass
async def dispatch_invoice_listener(payment: Payment): async def dispatch_api_invoice_listeners(payment: Payment):
for send_channel in api_invoice_listeners: """
Emits events to invoice listener subscribed from the API.
"""
for chan_name, send_channel in api_invoice_listeners.items():
try: try:
logger.debug(f"sending invoice paid event to {chan_name}")
send_channel.put_nowait(payment) send_channel.put_nowait(payment)
except asyncio.QueueFull: except asyncio.QueueFull:
logger.debug("removing sse listener", send_channel) logger.error(f"removing sse listener {send_channel}:{chan_name}")
api_invoice_listeners.remove(send_channel) api_invoice_listeners.pop(chan_name)
async def dispatch_webhook(payment: Payment): async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
data = payment.dict() data = payment.dict()
try: try:

View file

@ -171,6 +171,17 @@
</a> </a>
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<a href="https://mynodebtc.com">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/mynode.png' : '/static/images/mynodel.png'"
></q-img>
</a>
</div>
<div class="col q-pl-md">&nbsp;</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

@ -2,11 +2,14 @@ import asyncio
import binascii import binascii
import hashlib import hashlib
import json import json
import time
import uuid
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO from io import BytesIO
from typing import Dict, List, Optional, Tuple, Union from typing import Dict, List, Optional, Tuple, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import async_timeout
import httpx import httpx
import pyqrcode import pyqrcode
from fastapi import Depends, Header, Query, Request from fastapi import Depends, Header, Query, Request
@ -15,7 +18,7 @@ from fastapi.params import Body
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.fields import Field from pydantic.fields import Field
from sse_starlette.sse import EventSourceResponse from sse_starlette.sse import EventSourceResponse, ServerSentEvent
from starlette.responses import HTMLResponse, StreamingResponse from starlette.responses import HTMLResponse, StreamingResponse
from lnbits import bolt11, lnurl from lnbits import bolt11, lnurl
@ -27,7 +30,7 @@ from lnbits.decorators import (
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import url_for, urlsafe_short_hash from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE, WALLET
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
currencies, currencies,
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
@ -39,6 +42,7 @@ from ..crud import (
create_payment, create_payment,
get_payments, get_payments,
get_standalone_payment, get_standalone_payment,
get_total_balance,
get_wallet, get_wallet,
get_wallet_for_key, get_wallet_for_key,
save_balance_check, save_balance_check,
@ -364,37 +368,48 @@ async def api_payments_pay_lnurl(
} }
async def subscribe(request: Request, wallet: Wallet): async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
"""
Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse.
Listenes invoming payments for a wallet and yields jsons with payment details.
"""
this_wallet_id = wallet.id this_wallet_id = wallet.id
payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0) payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0)
logger.debug("adding sse listener", payment_queue) uid = f"{this_wallet_id}_{str(uuid.uuid4())[:8]}"
api_invoice_listeners.append(payment_queue) logger.debug(f"adding sse listener for wallet: {uid}")
api_invoice_listeners[uid] = payment_queue
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0) send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
async def payment_received() -> None: async def payment_received() -> None:
while True: while True:
payment: Payment = await payment_queue.get() try:
if payment.wallet_id == this_wallet_id: async with async_timeout.timeout(1):
logger.debug("payment received", payment) payment: Payment = await payment_queue.get()
await send_queue.put(("payment-received", payment)) if payment.wallet_id == this_wallet_id:
logger.debug("sse listener: payment receieved", payment)
await send_queue.put(("payment-received", payment))
except asyncio.TimeoutError:
pass
asyncio.create_task(payment_received()) task = asyncio.create_task(payment_received())
try: try:
while True: while True:
if await request.is_disconnected():
await request.close()
break
typ, data = await send_queue.get() typ, data = await send_queue.get()
if data: if data:
jdata = json.dumps(dict(data.dict(), pending=False)) jdata = json.dumps(dict(data.dict(), pending=False))
# yield dict(id=1, event="this", data="1234")
# await asyncio.sleep(2)
yield dict(data=jdata, event=typ) yield dict(data=jdata, event=typ)
# yield dict(data=jdata.encode("utf-8"), event=typ.encode("utf-8")) except asyncio.CancelledError as e:
except asyncio.CancelledError: logger.debug(f"CancelledError on listener {uid}: {e}")
api_invoice_listeners.pop(uid)
task.cancel()
return return
@ -403,7 +418,9 @@ async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type) request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
): ):
return EventSourceResponse( return EventSourceResponse(
subscribe(request, wallet.wallet), ping=20, media_type="text/event-stream" subscribe_wallet_invoices(request, wallet.wallet),
ping=20,
media_type="text/event-stream",
) )
@ -459,7 +476,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
except: except:
# parse internet identifier (user@domain.com) # parse internet identifier (user@domain.com)
name_domain = code.split("@") 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 name, domain = name_domain
url = ( url = (
("http://" if domain.endswith(".onion") else "https://") ("http://" if domain.endswith(".onion") else "https://")
@ -657,3 +674,26 @@ async def img(request: Request, data):
"Expires": "0", "Expires": "0",
}, },
) )
@core_app.get("/api/v1/audit/")
async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)):
if wallet.wallet.user not in LNBITS_ADMIN_USERS:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not an admin user"
)
total_balance = await get_total_balance()
error_message, node_balance = await WALLET.status()
if not error_message:
delta = node_balance - total_balance
else:
node_balance, delta = None, None
return {
"node_balance_msats": node_balance,
"lnbits_balance_msats": total_balance,
"delta_msats": delta,
"timestamp": int(time.time()),
}

View file

@ -46,8 +46,8 @@ async def api_public_payment_longpolling(payment_hash):
payment_queue = asyncio.Queue(0) payment_queue = asyncio.Queue(0)
logger.debug("adding standalone invoice listener", payment_hash, payment_queue) logger.debug(f"adding standalone invoice listener for hash: {payment_hash}")
api_invoice_listeners.append(payment_queue) api_invoice_listeners[payment_hash] = payment_queue
response = None response = None

View file

@ -52,6 +52,12 @@ class Compat:
return "" return ""
return "<nothing>" return "<nothing>"
@property
def big_int(self) -> str:
if self.type in {POSTGRES}:
return "BIGINT"
return "INT"
class Connection(Compat): class Connection(Compat):
def __init__(self, conn: AsyncConnection, txn, typ, name, schema): def __init__(self, conn: AsyncConnection, txn, typ, name, schema):

View file

@ -29,7 +29,7 @@ async def m001_initial(db):
) )
await db.execute( await db.execute(
""" f"""
CREATE TABLE boltcards.hits ( CREATE TABLE boltcards.hits (
id TEXT PRIMARY KEY UNIQUE, id TEXT PRIMARY KEY UNIQUE,
card_id TEXT NOT NULL, card_id TEXT NOT NULL,
@ -38,7 +38,7 @@ async def m001_initial(db):
useragent TEXT, useragent TEXT,
old_ctr INT NOT NULL DEFAULT 0, old_ctr INT NOT NULL DEFAULT 0,
new_ctr INT NOT NULL DEFAULT 0, new_ctr INT NOT NULL DEFAULT 0,
amount INT NOT NULL, amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """ + """
@ -47,11 +47,11 @@ async def m001_initial(db):
) )
await db.execute( await db.execute(
""" f"""
CREATE TABLE boltcards.refunds ( CREATE TABLE boltcards.refunds (
id TEXT PRIMARY KEY UNIQUE, id TEXT PRIMARY KEY UNIQUE,
hit_id TEXT NOT NULL, hit_id TEXT NOT NULL,
refund_amount INT NOT NULL, refund_amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """ + """

View file

@ -5,6 +5,7 @@ import httpx
from lnbits.core import db as core_db from lnbits.core import db as core_db
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import create_refund, get_hit from .crud import create_refund, get_hit
@ -12,7 +13,7 @@ from .crud import create_refund, get_hit
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()

View file

@ -34,8 +34,8 @@ from .models import (
from .utils import check_balance, get_timestamp, req_wrap from .utils import check_balance, get_timestamp, req_wrap
net = NETWORKS[BOLTZ_NETWORK] net = NETWORKS[BOLTZ_NETWORK]
logger.debug(f"BOLTZ_URL: {BOLTZ_URL}") logger.trace(f"BOLTZ_URL: {BOLTZ_URL}")
logger.debug(f"Bitcoin Network: {net['name']}") logger.trace(f"Bitcoin Network: {net['name']}")
async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap: async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:

View file

@ -11,8 +11,8 @@ from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL, BOLTZ_MEMPOOL_SPACE_URL_WS
from .utils import req_wrap from .utils import req_wrap
logger.debug(f"BOLTZ_MEMPOOL_SPACE_URL: {BOLTZ_MEMPOOL_SPACE_URL}") logger.trace(f"BOLTZ_MEMPOOL_SPACE_URL: {BOLTZ_MEMPOOL_SPACE_URL}")
logger.debug(f"BOLTZ_MEMPOOL_SPACE_URL_WS: {BOLTZ_MEMPOOL_SPACE_URL_WS}") logger.trace(f"BOLTZ_MEMPOOL_SPACE_URL_WS: {BOLTZ_MEMPOOL_SPACE_URL_WS}")
websocket_url = f"{BOLTZ_MEMPOOL_SPACE_URL_WS}/api/v1/ws" websocket_url = f"{BOLTZ_MEMPOOL_SPACE_URL_WS}/api/v1/ws"

View file

@ -1,16 +1,16 @@
async def m001_initial(db): async def m001_initial(db):
await db.execute( await db.execute(
""" f"""
CREATE TABLE boltz.submarineswap ( CREATE TABLE boltz.submarineswap (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
payment_hash TEXT NOT NULL, payment_hash TEXT NOT NULL,
amount INT NOT NULL, amount {db.big_int} NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
boltz_id TEXT NOT NULL, boltz_id TEXT NOT NULL,
refund_address TEXT NOT NULL, refund_address TEXT NOT NULL,
refund_privkey TEXT NOT NULL, refund_privkey TEXT NOT NULL,
expected_amount INT NOT NULL, expected_amount {db.big_int} NOT NULL,
timeout_block_height INT NOT NULL, timeout_block_height INT NOT NULL,
address TEXT NOT NULL, address TEXT NOT NULL,
bip21 TEXT NOT NULL, bip21 TEXT NOT NULL,
@ -22,12 +22,12 @@ async def m001_initial(db):
""" """
) )
await db.execute( await db.execute(
""" f"""
CREATE TABLE boltz.reverse_submarineswap ( CREATE TABLE boltz.reverse_submarineswap (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
onchain_address TEXT NOT NULL, onchain_address TEXT NOT NULL,
amount INT NOT NULL, amount {db.big_int} NOT NULL,
instant_settlement BOOLEAN NOT NULL, instant_settlement BOOLEAN NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
boltz_id TEXT NOT NULL, boltz_id TEXT NOT NULL,
@ -37,7 +37,7 @@ async def m001_initial(db):
claim_privkey TEXT NOT NULL, claim_privkey TEXT NOT NULL,
lockup_address TEXT NOT NULL, lockup_address TEXT NOT NULL,
invoice TEXT NOT NULL, invoice TEXT NOT NULL,
onchain_amount INT NOT NULL, onchain_amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """ + """

View file

@ -5,6 +5,7 @@ from loguru import logger
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.services import check_transaction_status from lnbits.core.services import check_transaction_status
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .boltz import ( from .boltz import (
@ -56,7 +57,7 @@ async def check_for_pending_swaps():
swap_status = get_swap_status(swap) swap_status = get_swap_status(swap)
# should only happen while development when regtest is reset # should only happen while development when regtest is reset
if swap_status.exists is False: 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") await update_swap_status(swap.id, "failed")
continue continue
@ -72,7 +73,7 @@ async def check_for_pending_swaps():
else: else:
if swap_status.hit_timeout: if swap_status.hit_timeout:
if not swap_status.has_lockup: if not swap_status.has_lockup:
logger.warning( logger.debug(
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..." f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
) )
await update_swap_status(swap.id, "timeout") await update_swap_status(swap.id, "timeout")
@ -127,7 +128,7 @@ async def check_for_pending_swaps():
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()

View file

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

View file

@ -7,6 +7,7 @@ from starlette.exceptions import HTTPException
from lnbits.core import db as core_db from lnbits.core import db as core_db
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import get_copilot from .crud import get_copilot
@ -15,7 +16,7 @@ from .views import updater
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()
@ -25,7 +26,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
webhook = None webhook = None
data = None data = None
if payment.extra.get("tag") != "copilot": if not payment.extra or payment.extra.get("tag") != "copilot":
# not an copilot invoice # not an copilot invoice
return return
@ -70,12 +71,12 @@ async def on_invoice_paid(payment: Payment) -> None:
async def mark_webhook_sent(payment: Payment, status: int) -> None: async def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status if payment.extra:
payment.extra["wh_status"] = status
await core_db.execute( await core_db.execute(
""" """
UPDATE apipayments SET extra = ? UPDATE apipayments SET extra = ?
WHERE hash = ? WHERE hash = ?
""", """,
(json.dumps(payment.extra), payment.payment_hash), (json.dumps(payment.extra), payment.payment_hash),
) )

View file

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

View file

@ -23,7 +23,7 @@ from .views import updater
@copilot_ext.get("/api/v1/copilot") @copilot_ext.get("/api/v1/copilot")
async def api_copilots_retrieve( 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 wallet_user = wallet.wallet.user
copilots = [copilot.dict() for copilot in await get_copilots(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( async def api_copilot_retrieve(
req: Request, req: Request,
copilot_id: str = Query(None), 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) copilot = await get_copilot(copilot_id)
if not copilot: if not copilot:
@ -54,7 +54,7 @@ async def api_copilot_retrieve(
async def api_copilot_create_or_update( async def api_copilot_create_or_update(
data: CreateCopilotData, data: CreateCopilotData,
copilot_id: str = Query(None), 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.user = wallet.wallet.user
data.wallet = wallet.wallet.id data.wallet = wallet.wallet.id
@ -67,7 +67,8 @@ async def api_copilot_create_or_update(
@copilot_ext.delete("/api/v1/copilot/{copilot_id}") @copilot_ext.delete("/api/v1/copilot/{copilot_id}")
async def api_copilot_delete( 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) copilot = await get_copilot(copilot_id)

View file

@ -2,5 +2,5 @@
"name": "Diagon Alley", "name": "Diagon Alley",
"short_description": "Nostr shop system", "short_description": "Nostr shop system",
"icon": "add_shopping_cart", "icon": "add_shopping_cart",
"contributors": ["benarc"] "contributors": ["benarc", "talvasconcelos"]
} }

View file

@ -9,6 +9,8 @@ from lnbits.settings import WALLET
from . import db from . import db
from .models import ( from .models import (
ChatMessage,
CreateChatMessage,
CreateMarket, CreateMarket,
CreateMarketStalls, CreateMarketStalls,
Market, Market,
@ -190,7 +192,6 @@ async def get_diagonalley_stall(stall_id: str) -> Optional[Stalls]:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM diagonalley.stalls WHERE id = ?", (stall_id,) "SELECT * FROM diagonalley.stalls WHERE id = ?", (stall_id,)
) )
print("ROW", row)
return Stalls(**row) if row else None return Stalls(**row) if row else None
@ -303,6 +304,20 @@ async def set_diagonalley_order_paid(payment_hash: str) -> Orders:
) )
async def set_diagonalley_order_pubkey(payment_hash: str, pubkey: str):
await db.execute(
"""
UPDATE diagonalley.orders
SET pubkey = ?
WHERE invoiceid = ?
""",
(
pubkey,
payment_hash,
),
)
async def update_diagonalley_product_stock(products): async def update_diagonalley_product_stock(products):
q = "\n".join( q = "\n".join(
@ -405,3 +420,48 @@ async def create_diagonalley_market_stalls(
async def update_diagonalley_market(market_id): async def update_diagonalley_market(market_id):
pass pass
### CHAT / MESSAGES
async def create_chat_message(data: CreateChatMessage):
await db.execute(
"""
INSERT INTO diagonalley.messages (msg, pubkey, id_conversation)
VALUES (?, ?, ?)
""",
(
data.msg,
data.pubkey,
data.room_name,
),
)
async def get_diagonalley_latest_chat_messages(room_name: str):
rows = await db.fetchall(
"SELECT * FROM diagonalley.messages WHERE id_conversation = ? ORDER BY timestamp DESC LIMIT 20",
(room_name,),
)
return [ChatMessage(**row) for row in rows]
async def get_diagonalley_chat_messages(room_name: str):
rows = await db.fetchall(
"SELECT * FROM diagonalley.messages WHERE id_conversation = ? ORDER BY timestamp DESC",
(room_name,),
)
return [ChatMessage(**row) for row in rows]
async def get_diagonalley_chat_by_merchant(ids: List[str]) -> List[ChatMessage]:
q = ",".join(["?"] * len(ids))
rows = await db.fetchall(
f"SELECT * FROM diagonalley.messages WHERE id_conversation IN ({q})",
(*ids,),
)
return [ChatMessage(**row) for row in rows]

View file

@ -113,3 +113,33 @@ async def m001_initial(db):
); );
""" """
) )
async def m002_add_chat_messages(db):
"""
Initial chat messages table.
"""
await db.execute(
f"""
CREATE TABLE diagonalley.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 diagonalley.messages (timestamp DESC)"
)
await db.execute(
"CREATE INDEX idx_messages_conversations ON diagonalley.messages (id_conversation)"
)

View file

@ -70,6 +70,7 @@ class createOrderDetails(BaseModel):
class createOrder(BaseModel): class createOrder(BaseModel):
wallet: str = Query(...) wallet: str = Query(...)
username: str = Query(None)
pubkey: str = Query(None) pubkey: str = Query(None)
shippingzone: str = Query(...) shippingzone: str = Query(...)
address: str = Query(...) address: str = Query(...)
@ -107,3 +108,17 @@ class Market(BaseModel):
class CreateMarketStalls(BaseModel): class CreateMarketStalls(BaseModel):
stallid: str 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(...)

View file

@ -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.diagonalley.crud import create_chat_message
from lnbits.extensions.diagonalley.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

View file

@ -252,29 +252,33 @@
label="Wallet *" label="Wallet *"
> >
</q-select> </q-select>
<!-- NOSTR -->
<!-- <div class="row">
<div class="col-5">
<q-btn unelevated @onclick="generateKeys" color="primary">Generate keys</q-btn>
</div>
<div class="col-5">
<q-btn unelevated @onclick="restoreKeys" color="primary">Restore keys</q-btn>
</div>
</div>
<q-input <q-input
v-if="stallDialog.restorekeys" v-if="keys"
filled filled
dense dense
v-model.trim="stallDialog.data.publickey" v-model.trim="stallDialog.data.publickey"
label="Public Key" label="Public Key"
></q-input> ></q-input>
<q-input <q-input
v-if="stallDialog.restorekeys" v-if="keys"
filled filled
dense dense
v-model.trim="stallDialog.data.privatekey" v-model.trim="stallDialog.data.privatekey"
label="Private Key" label="Private Key"
></q-input> --> ></q-input>
<!-- NOSTR -->
<div class="row">
<div class="col-5">
<q-btn unelevated @click="generateKeys" color="primary"
>Generate keys</q-btn
>
</div>
<div class="col-5">
<q-btn unelevated @click="restoreKeys" color="primary"
>Restore keys</q-btn
>
</div>
</div>
<q-select <q-select
:options="zoneOptions" :options="zoneOptions"
filled filled
@ -314,16 +318,18 @@
unelevated unelevated
color="primary" color="primary"
type="submit" type="submit"
>Update Store</q-btn >Update Stall</q-btn
> >
<q-btn <q-btn
v-else v-else
unelevated unelevated
color="primary" color="primary"
:disable="stallDialog.data.wallet == null :disable="stallDialog.data.wallet == null
|| stallDialog.data.shippingzones == null" || stallDialog.data.shippingzones == null
|| stallDialog.data.publickey == null
|| stallDialog.data.privatekey == null"
type="submit" type="submit"
>Create Store</q-btn >Create Stall</q-btn
> >
<q-btn <q-btn
v-close-popup v-close-popup
@ -341,6 +347,29 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="primary" @click="zoneDialog.show = true"
>+ Shipping Zone<q-tooltip> Create a shipping zone </q-tooltip></q-btn
>
<q-btn
unelevated
v-if="zones.length > 0"
color="primary"
@click="openStallDialog()"
>+ Stall
<q-tooltip>
Create a market stall to list products on
</q-tooltip></q-btn
>
<q-btn
unelevated
v-else
color="primary"
@click="errorMessage('First set shipping zone(s).')"
>+ Stall
<q-tooltip>
Create a market stall to list products on
</q-tooltip></q-btn
>
<q-btn <q-btn
unelevated unelevated
v-if="stalls.length > 0" v-if="stalls.length > 0"
@ -355,29 +384,15 @@
@click="errorMessage('First set shipping zone(s), then create a stall.')" @click="errorMessage('First set shipping zone(s), then create a stall.')"
>+ Product <q-tooltip> List a product </q-tooltip></q-btn >+ Product <q-tooltip> List a product </q-tooltip></q-btn
> >
<q-btn unelevated color="primary" @click="zoneDialog.show = true"
>+ Shipping Zone<q-tooltip> Create a shipping zone </q-tooltip></q-btn
>
<q-btn <q-btn
class="float-right"
unelevated unelevated
v-if="zones.length > 0" flat
color="primary" color="primary"
@click="openStallDialog()" @click="marketDialog.show = true"
>+ Store >Create Market
<q-tooltip> Create a stall to list products on </q-tooltip></q-btn
>
<q-btn
unelevated
v-else
color="primary"
@click="errorMessage('First set shipping zone(s).')"
>+ Store
<q-tooltip> Create a store to list products on </q-tooltip></q-btn
>
<q-btn unelevated color="primary" @click="marketDialog.show = true"
>Launch frontend shop (not Nostr)
<q-tooltip> <q-tooltip>
Makes a simple frontend shop for your stalls</q-tooltip Makes a simple frontend shop for your stalls (not NOSTR)</q-tooltip
></q-btn ></q-btn
> >
</q-card-section> </q-card-section>
@ -407,6 +422,7 @@
{% raw %} {% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props"> <q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }} {{ col.label }}
@ -426,6 +442,23 @@
:icon="props.expand ? 'remove' : 'add'" :icon="props.expand ? 'remove' : 'add'"
/> />
</q-td> </q-td>
<q-td auto-width>
<q-btn
size="sm"
color="green"
dense
icon="chat"
@click="chatRoom(props.row.invoiceid)"
>
<q-badge
v-if="props.row.unread"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }} {{ col.value }}
</q-td> </q-td>
@ -549,6 +582,7 @@
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
disabled
unelevated unelevated
dense dense
size="xs" size="xs"
@ -596,11 +630,11 @@
</q-card> </q-card>
<q-card> <q-card>
<!-- STORES TABLE --> <!-- STALLS TABLE -->
<q-card-section> <q-card-section>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col"> <div class="col">
<h5 class="text-subtitle1 q-my-none">Stores</h5> <h5 class="text-subtitle1 q-my-none">Market Stalls</h5>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<q-btn flat color="grey" @click="exportStallsCSV" <q-btn flat color="grey" @click="exportStallsCSV"
@ -636,10 +670,10 @@
icon="storefront" icon="storefront"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a" type="a"
:href="'/diagonalley/' + props.row.id" :href="'/diagonalley/stalls/' + props.row.id"
target="_blank" target="_blank"
></q-btn> ></q-btn>
<q-tooltip> Link to pass to stall relay </q-tooltip> <q-tooltip> Stall simple UI shopping cart </q-tooltip>
</q-td> </q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }} {{ col.value }}
@ -802,6 +836,52 @@
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
<!-- KEYS -->
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Keys</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportKeysCSV"
>Export to CSV</q-btn
>
</div>
</div>
</q-card-section>
<q-card-section>
<div v-if="keys" class="row">
<div
class="col-6"
v-for="type in ['pubkey', 'privkey']"
v-bind:key="type"
>
<div class="text-center q-mb-lg">
{% raw %}
<q-responsive
:ratio="1"
class="q-mx-xl"
@click="copyText(keys[type])"
>
<qrcode
:value="keys[type]"
:options="{width: 250}"
class="rounded-borders"
></qrcode>
<q-tooltip>{{ keys[type] }}</q-tooltip>
</q-responsive>
<p>
{{ type == 'pubkey' ? 'Public Key' : 'Private Key' }}<br /><small
>Click to copy</small
>
</p>
{% endraw %}
</div>
</div>
</div>
</q-card-section>
</q-card>
</div> </div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md"> <div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
@ -816,22 +896,115 @@
<q-list> {% include "diagonalley/_api_docs.html" %} </q-list> <q-list> {% include "diagonalley/_api_docs.html" %} </q-list>
</q-card-section> </q-card-section>
</q-card> </q-card>
<!-- CHAT BOX -->
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none">Messages</h6> <h6 class="text-subtitle1 q-my-none">Messages</h6>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>
</q-card-section>
<div class="column q-ma-md q-pb-lg" style="height: 350px"> <q-card-section>
<div class="col q-pb-md"> <q-select
v-model="customerKey"
:options="Object.keys(messages).map(k => ({label: `${k.slice(0, 25)}...`, value: k}))"
label="Customers"
@input="chatRoom(customerKey)"
emit-value
></q-select>
</q-card-section>
<div class="chat-container q-pa-md" ref="chatCard">
<div class="chat-box">
<!-- <p v-if="Object.keys(messages).length === 0">No messages yet</p> -->
<div class="chat-messages">
<q-chat-message
:key="index"
v-for="(message, index) in orderMessages"
:name="message.pubkey == keys.pubkey ? 'me' : 'customer'"
:text="[message.msg]"
:sent="message.pubkey == keys.pubkey ? true : false"
:bg-color="message.pubkey == keys.pubkey ? 'white' : 'light-green-2'"
/>
</div>
</div>
<q-form @submit="sendMessage" class="full-width chat-input">
<q-input
ref="newMessage"
v-model="newMessage"
placeholder="Message"
class="full-width"
dense
outlined
>
<template>
<q-btn
round
dense
flat
type="submit"
icon="send"
color="primary"
/>
</template>
</q-input>
</q-form>
</div>
</q-card>
<!-- <q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">Messages</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<div
ref="chatCard"
class="q-ma-md q-pb-lg"
style="height: 350px"
>
<div class="q-pb-md">
<q-select <q-select
v-model="customerKey" v-model="customerKey"
style="width: 80%" style="width: 80%"
:options="customerKeys" :options="Object.keys(messages)"
label="Customers" label="Customers"
@input="getMessages(customerKey)" @input="chatRoom(customerKey)"
></q-select> ></q-select>
<div class="chat-container q-pa-md">
<div class="chat-box">
<p v-if="Object.keys(messages).length === 0">No messages yet</p>
<div class="chat-messages">
<q-chat-message
:key="index"
v-for="(message, index) in orderMessages"
:name="message.pubkey == keys.pubkey ? 'me' : 'customer'"
:text="[message.msg]"
:sent="message.pubkey == keys.pubkey ? true : false"
:bg-color="message.pubkey == keys.pubkey ? 'white' : 'light-green-2'"
/>
</div>
</div>
<q-form @submit="sendMessage" class="full-width chat-input">
<q-input
ref="newMessage"
v-model="newMessage"
placeholder="Message"
class="full-width"
dense
outlined
>
<template>
<q-btn
round
dense
flat
type="submit"
icon="send"
color="primary"
/>
</template>
</q-input>
</q-form>
</div>
</div> </div>
<div class="col-8 q-px-md"> <div class="col-8 q-px-md">
<div v-for="message in customerMessages"> <div v-for="message in customerMessages">
@ -852,18 +1025,73 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</q-card> </q-card> -->
</div> </div>
<q-dialog v-model="onboarding.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">How to use Diagon Alley</h6>
<q-stepper v-model="step" color="primary" vertical animated>
<q-step
:name="1"
title="Create a Shipping Zone"
icon="settings"
:done="step > 1"
>
Create Shipping Zones you're willing to ship to. You can define
different values for different zones.
<q-stepper-navigation>
<q-btn @click="step = step + 1" color="primary" label="Next" />
</q-stepper-navigation>
</q-step>
<q-step
:name="2"
title="Create a Stall"
icon="create_new_folder"
:done="step > 2"
>
Create a Stall and provide private and public keys to use for
communication. If you don't have one, LNbits will create a key pair
for you. It will be saved and can be used on other stalls.
<q-stepper-navigation>
<q-btn @click="step = step + 1" color="primary" label="Next" />
</q-stepper-navigation>
</q-step>
<q-step :name="3" title="Create Products" icon="assignment">
Create your products, add a small description and an image. Choose to
what stall, if you have more than one, it belongs to
<q-stepper-navigation>
<q-btn @click="onboarding.finish" color="primary" label="Finish" />
</q-stepper-navigation>
<div>
<q-checkbox
v-model="onboarding.showAgain"
label="Show this again?"
/>
</div>
</q-step>
</q-stepper>
</q-card>
</q-dialog>
</div> </div>
<!-- </div>
</div> -->
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) Vue.component(VueQrcode.name, VueQrcode)
const pica = window.pica() const pica = window.pica()
function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) {
let ratio = Math.min(
1,
maxWidth / img.naturalWidth,
maxHeight / img.naturalHeight
)
return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio}
}
const mapStalls = obj => { const mapStalls = obj => {
obj._data = _.clone(obj) obj._data = _.clone(obj)
return obj return obj
@ -882,6 +1110,7 @@
new Date(obj.time * 1000), new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm' 'YYYY-MM-DD HH:mm'
) )
// obj.unread = false
return obj return obj
} }
const mapKeys = obj => { const mapKeys = obj => {
@ -914,6 +1143,19 @@
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
return { return {
step: 1,
onboarding: {
show: true,
showAgain: false,
finish: () => {
this.$q.localStorage.set(
'lnbits.DAOnboarding',
this.onboarding.showAgain
)
this.onboarding.show = false
}
},
keys: null,
products: [], products: [],
orders: [], orders: [],
stalls: [], stalls: [],
@ -923,8 +1165,12 @@
customerKeys: [], customerKeys: [],
customerKey: '', customerKey: '',
customerMessages: {}, customerMessages: {},
messages: {},
newMessage: '',
orderMessages: {},
shippedModel: false, shippedModel: false,
shippingZoneOptions: [ shippingZoneOptions: [
'Free (digital)',
'Worldwide', 'Worldwide',
'Europe', 'Europe',
'Australia', 'Australia',
@ -989,17 +1235,17 @@
ordersTable: { ordersTable: {
columns: [ columns: [
/*{ /*{
name: 'product', name: 'product',
align: 'left', align: 'left',
label: 'Product', label: 'Product',
field: 'product' field: 'product'
}, },
{ {
name: 'quantity', name: 'quantity',
align: 'left', align: 'left',
label: 'Quantity', label: 'Quantity',
field: 'quantity' field: 'quantity'
},*/ },*/
{ {
name: 'id', name: 'id',
align: 'left', align: 'left',
@ -1030,7 +1276,7 @@
{ {
name: 'stall', name: 'stall',
align: 'left', align: 'left',
label: 'Store', label: 'Stall',
field: 'stall' field: 'stall'
}, },
{ {
@ -1118,7 +1364,7 @@
{ {
name: 'stores', name: 'stores',
align: 'left', align: 'left',
label: 'Stores', label: 'Stalls',
field: 'stores' field: 'stores'
} }
], ],
@ -1193,24 +1439,60 @@
this[dialog].show = false this[dialog].show = false
this[dialog].data = {} this[dialog].data = {}
}, },
generateKeys: function () { generateKeys() {
var self = this
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/diagonalley/api/v1/keys', '/diagonalley/api/v1/keys',
self.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
.then(function (response) { .then(response => {
if (response.data) { if (response.data) {
self.keys = response.data.map(mapKeys) this.keys = response.data
this.stallDialog.data.publickey = this.keys.pubkey
this.stallDialog.data.privatekey = this.keys.privkey
this.$q.localStorage.set(
`lnbits.diagonalley.${this.g.user.id}`,
this.keys
)
} }
}) })
.catch(function (error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
restoreKeys: function () {}, restoreKeys() {
let keys = this.$q.localStorage.getItem(
`lnbits.diagonalley.${this.g.user.id}`
)
if (keys) {
this.keys = keys
this.stallDialog.data.publickey = this.keys.pubkey
this.stallDialog.data.privatekey = this.keys.privkey
} else {
this.$q.notify({
type: 'warning',
message: 'No keys found.'
})
}
},
exportKeysCSV: function () {
let colls = [
{
name: 'privatekey',
align: 'left',
label: 'Private Key',
field: 'privkey'
},
{
name: 'publickey',
align: 'left',
label: 'Public Key',
field: 'pubkey'
}
]
LNbits.utils.exportCSV(colls, [this.keys])
},
capitalizeFirstLetter: function (string) { capitalizeFirstLetter: function (string) {
return string.charAt(0).toUpperCase() + string.slice(1) return string.charAt(0).toUpperCase() + string.slice(1)
}, },
@ -1414,9 +1696,10 @@
let image = new Image() let image = new Image()
image.src = blobURL image.src = blobURL
image.onload = async () => { image.onload = async () => {
let fit = imgSizeFit(image)
let canvas = document.createElement('canvas') let canvas = document.createElement('canvas')
canvas.setAttribute('width', 760) canvas.setAttribute('width', fit.width)
canvas.setAttribute('height', 490) canvas.setAttribute('height', fit.height)
await pica.resize(image, canvas, { await pica.resize(image, canvas, {
quality: 0, quality: 0,
alpha: true, alpha: true,
@ -1628,7 +1911,6 @@
.then(response => { .then(response => {
if (response.data) { if (response.data) {
this.markets = response.data.map(mapMarkets) this.markets = response.data.map(mapMarkets)
console.log(this.markets)
} }
}) })
.catch(error => { .catch(error => {
@ -1727,10 +2009,10 @@
//////////////////////////////////////// ////////////////////////////////////////
////////////////ORDERS////////////////// ////////////////ORDERS//////////////////
//////////////////////////////////////// ////////////////////////////////////////
getOrders: function () { getOrders: async function () {
var self = this var self = this
LNbits.api await LNbits.api
.request( .request(
'GET', 'GET',
'/diagonalley/api/v1/orders?all_wallets=true', '/diagonalley/api/v1/orders?all_wallets=true',
@ -1739,14 +2021,13 @@
.then(function (response) { .then(function (response) {
if (response.data) { if (response.data) {
self.orders = response.data.map(mapOrders) self.orders = response.data.map(mapOrders)
console.log(self.orders)
} }
}) })
.catch(function (error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
createOrder: function () { /*createOrder: function () {
var data = { var data = {
address: this.orderDialog.data.address, address: this.orderDialog.data.address,
email: this.orderDialog.data.email, email: this.orderDialog.data.email,
@ -1771,7 +2052,7 @@
.catch(function (error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },*/
deleteOrder: function (orderId) { deleteOrder: function (orderId) {
var self = this var self = this
var order = _.findWhere(self.orders, {id: orderId}) var order = _.findWhere(self.orders, {id: orderId})
@ -1783,7 +2064,7 @@
.request( .request(
'DELETE', 'DELETE',
'/diagonalley/api/v1/orders/' + orderId, '/diagonalley/api/v1/orders/' + orderId,
_.findWhere(self.g.user.wallets, {id: order.wallet}).inkey _.findWhere(self.g.user.wallets, {id: order.wallet}).adminkey
) )
.then(function (response) { .then(function (response) {
self.orders = _.reject(self.orders, function (obj) { self.orders = _.reject(self.orders, function (obj) {
@ -1795,37 +2076,202 @@
}) })
}) })
}, },
shipOrder: function (order_id) { shipOrder(order_id) {
var self = this
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/diagonalley/api/v1/orders/shipped/' + order_id, '/diagonalley/api/v1/orders/shipped/' + order_id,
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(function (response) { .then(response => {
self.orders.push(mapOrders(response.data)) this.orders = _.reject(this.orders, obj => {
return obj.id == order_id
})
this.orders.push(mapOrders(response.data))
})
.catch(error => {
LNbits.utils.notifyApiError(error)
}) })
}, },
exportOrdersCSV: function () { exportOrdersCSV: function () {
LNbits.utils.exportCSV(this.ordersTable.columns, this.orders) LNbits.utils.exportCSV(this.ordersTable.columns, this.orders)
},
/// CHAT
async getAllMessages() {
await LNbits.api
.request(
'GET',
`/diagonalley/api/v1/chat/messages/merchant?orders=${this.orders
.map(o => o.invoiceid)
.toString()}`,
this.g.user.wallets[0].adminkey
)
.then(res => {
this.messages = _.groupBy(res.data, 'id_conversation')
this.checkUnreadMessages()
})
.catch(error => {
LNbits.utils.notifyApiError(error)
})
},
updateLastSeenMsg(id) {
let data = this.$q.localStorage.getItem(
`lnbits.diagonalley.${this.g.user.id}`
)
let chat = {
...data.chat,
[`${id}`]: {
timestamp: Object.keys(this.orderMessages)[
Object.keys(this.orderMessages).length - 1
]
}
}
this.$q.localStorage.set(`lnbits.diagonalley.${this.g.user.id}`, {
...data,
chat
})
this.checkUnreadMessages()
},
checkUnreadMessages() {
let lastMsgs = this.$q.localStorage.getItem(
`lnbits.diagonalley.${this.g.user.id}`
).chat
for (let key in this.messages) {
let idx = this.orders.findIndex(f => f.invoiceid == key)
if (!lastMsgs[key]) {
this.updateLastSeenMsg(key)
return
}
if (
lastMsgs[key].timestamp <
Math.max(...this.messages[key].map(c => c.timestamp))
) {
this.$set(this.orders[idx], 'unread', true)
} else {
this.$set(this.orders[idx], 'unread', false)
}
}
},
clearMessage() {
this.newMessage = ''
this.$refs.newMessage.focus()
},
sendMessage() {
let message = {
msg: this.newMessage,
pubkey: this.keys.pubkey
}
this.ws.send(JSON.stringify(message))
this.clearMessage()
},
chatRoom(id) {
this.startChat(id)
this.orderMessages = {}
this.messages[id].map(m => {
this.$set(this.orderMessages, m.timestamp, {
msg: m.msg,
pubkey: m.pubkey
})
})
this.$refs.chatCard.scrollIntoView({
behavior: 'smooth',
inline: 'nearest'
})
this.updateLastSeenMsg(id)
//"ea2fbf6c91aa228603681e2cc34bb06e34e6d1375fa4d6c35756182b2fa3307f"
//"c7435a04875c26e28db91a377bd6e991dbfefeefea8258415f3ae0c716ed2335"
},
startChat(room_name) {
if (this.ws) {
this.ws.close()
}
if (location.protocol == 'https:') {
ws_scheme = 'wss://'
} else {
ws_scheme = 'ws://'
}
ws = new WebSocket(
ws_scheme + location.host + '/diagonalley/ws/' + room_name
)
function checkWebSocket(event) {
if (ws.readyState === WebSocket.CLOSED) {
console.log('WebSocket CLOSED: Reopening')
ws = new WebSocket(
ws_scheme + location.host + '/diagonalley/ws/' + room_name
)
}
}
ws.onmessage = event => {
let event_data = JSON.parse(event.data)
this.$set(this.orderMessages, Date.now(), event_data)
this.updateLastSeenMsg(room_name)
}
ws.onclose = event => {
this.updateLastSeenMsg(room_name)
}
this.ws = ws
} }
}, },
created: function () { async created() {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
let showOnboard = this.$q.localStorage.getItem('lnbits.DAOnboarding')
this.onboarding.show = showOnboard === true || showOnboard == null
this.onboarding.showAgain = showOnboard || false
this.getStalls() this.getStalls()
this.getProducts() this.getProducts()
this.getZones() this.getZones()
this.getOrders() await this.getOrders()
this.getMarkets() this.getMarkets()
this.customerKeys = [ await this.getAllMessages()
'cb4c0164fe03fcdadcbfb4f76611c71620790944c24f21a1cd119395cdedfe1b', let keys = this.$q.localStorage.getItem(
'a9c17358a6dc4ceb3bb4d883eb87967a66b3453a0f3199f0b1c8eef8070c6a07' `lnbits.diagonalley.${this.g.user.id}`
] )
console.log(_.pick(this.g.user, 'id')) if (keys) {
this.keys = keys
}
setInterval(() => {
this.getAllMessages()
}, 300000)
} }
} }
}) })
</script> </script>
<style scoped>
.q-field__native span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-container {
position: relative;
display: grid;
grid-template-rows: 1fr auto;
height: calc(100vh - 140px);
}
.chat-box {
display: flex;
flex-direction: column-reverse;
padding: 1rem;
overflow-y: auto;
}
.chat-messages {
width: auto;
}
.chat-input {
position: relative;
display: flex;
align-items: end;
margin-top: 1rem;
}
</style>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,511 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md flex">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card>
<div class="chat-container q-pa-md">
<div class="chat-box">
<!-- <p v-if="Object.keys(messages).length === 0">No messages yet</p> -->
<div class="chat-messages">
<q-chat-message
:key="index"
v-for="(message, index) in messages"
:name="message.pubkey == user.keys.publickey ? 'me' : 'merchant'"
:text="[message.msg]"
:sent="message.pubkey == user.keys.publickey ? true : false"
:bg-color="message.pubkey == user.keys.publickey ? 'white' : 'light-green-2'"
/>
</div>
</div>
<q-form @submit="sendMessage" class="full-width chat-input">
<q-input
ref="newMessage"
v-model="newMessage"
placeholder="Message"
class="full-width"
dense
outlined
>
<template>
<q-btn
round
dense
flat
type="submit"
icon="send"
color="primary"
/>
</template>
</q-input>
</q-form>
</div>
</q-card>
</div>
<div class="col-12 col-md-5 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
{% raw %}
<h6 class="text-subtitle1 q-my-none">{{ stall.name }}</h6>
<p @click="copyText(stall.publickey)" style="width: max-content">
Public Key: {{ sliceKey(stall.publickey) }}
<q-tooltip>Click to copy</q-tooltip>
</p>
{% endraw %}
</q-card-section>
<q-card-section v-if="user">
<q-form @submit="" class="q-gutter-md">
<!-- <q-select
filled
dense
emit-value
v-model="model"
:options="mockMerch"
label="Merchant"
hint="Select a merchant you've opened an order to"
></q-select>
<br /> -->
<q-select
filled
dense
emit-value
v-model="selectedOrder"
:options="Object.keys(user.orders).map(o => ({label: `${o.slice(0, 25)}...`, value: o}))"
label="Order"
hint="Select an order from this merchant"
@input="val => { changeOrder() }"
emit-value
></q-select>
</q-form>
</q-card-section>
<q-card-section>
<q-list>
{% raw %}
<q-item clickable :key="p.id" v-for="p in products">
<q-item-section side>
<span>{{p.quantity}} x </span>
</q-item-section>
<q-item-section avatar>
<q-avatar color="primary">
<img size="sm" :src="p.image" />
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ p.name }}</q-item-label>
</q-item-section>
<q-item-section side>
<span> {{p.price}} sats</span>
</q-item-section>
</q-item>
{% endraw %}
</q-list>
</q-card-section>
<q-card-section>
<q-separator></q-separator>
<q-list>
<q-expansion-item group="extras" icon="vpn_key" label="Keys"
><p>
Bellow are the keys needed to contact the merchant. They are
stored in the browser!
</p>
<div v-if="user?.keys" class="row q-col-gutter-md">
<div
class="col-12 col-sm-6"
v-for="type in ['publickey', 'privatekey']"
v-bind:key="type"
>
<div class="text-center q-mb-lg">
{% raw %}
<q-responsive
:ratio="1"
class="q-mx-auto"
style="max-width: 250px"
>
<qrcode
:value="user.keys[type]"
:options="{width: 500}"
class="rounded-borders"
></qrcode>
<q-tooltip>{{ user.keys[type] }}</q-tooltip>
</q-responsive>
<p>
{{ type == 'publickey' ? 'Public Key' : 'Private Key' }}
</p>
{% endraw %}
</div>
</div>
</div>
<q-separator></q-separator>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="downloadKeys"
>Backup keys
<q-tooltip>Download your keys</q-tooltip>
</q-btn>
<q-btn
outline
color="grey"
class="q-mx-sm"
@click="keysDialog.show = true"
:disabled="this.user.keys"
>Restore keys
<q-tooltip>Restore keys</q-tooltip>
</q-btn>
<q-btn
@click="deleteData"
v-close-popup
flat
color="grey"
class="q-ml-auto"
>Delete data
<q-tooltip>Delete all data from browser</q-tooltip>
</q-btn>
</div>
</q-expansion-item>
</q-list>
<q-expansion-item icon="qr_code" label="Export page">
<p>Export, or send, this page to another device</p>
<div class="text-center q-mb-lg">
<q-responsive
:ratio="1"
class="q-my-xl q-mx-auto"
style="max-width: 250px"
@click="copyText(exportURL)"
>
<qrcode
:value="exportURL"
:options="{width: 500}"
class="rounded-borders"
></qrcode>
<q-tooltip>Click to copy</q-tooltip>
</q-responsive>
</div>
<div class="row q-mt-lg">
<q-btn
@click="copyText(exportURL)"
v-close-popup
flat
color="grey"
class="q-ml-auto"
>Copy URL
<q-tooltip
>Export, or send, this page to another device</q-tooltip
>
</q-btn>
</div>
</q-expansion-item>
</q-card-section>
</q-card>
</div>
<!-- RESTORE KEYS DIALOG -->
<q-dialog
v-model="keysDialog.show"
position="top"
@hide="clearRestoreKeyDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> </q-card>
<q-card class="q-pa-lg lnbits__dialog-card">
<q-form @submit="restoreKeys" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="keysDialog.data.publickey"
label="Public Key"
></q-input>
<q-input
filled
dense
v-model.trim="keysDialog.data.privatekey"
label="Private Key *optional"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="keysDialog.data.publickey == null"
type="submit"
label="Submit"
></q-btn>
<q-btn
v-close-popup
flat
@click="clearRestoreKeyDialog"
color="grey"
class="q-ml-auto"
label="Cancel"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
const mapChatMsg = msg => {
let obj = {}
obj.timestamp = {
msg: msg,
pubkey: pubkey
}
return obj
}
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
newMessage: '',
showMessages: false,
messages: {},
stall: null,
selectedOrder: null,
products: [],
orders: [],
user: {
keys: {}
},
keysDialog: {
show: false,
data: {}
}
}
},
computed: {
exportURL() {
return (
'{{request.url}}' +
`&keys=${this.user.keys.publickey},${this.user.keys.privatekey}`
)
}
},
methods: {
clearMessage() {
this.newMessage = ''
this.$refs.newMessage.focus()
},
clearRestoreKeyDialog() {
this.keysDialog = {
show: false,
data: {}
}
},
sendMessage() {
let message = {
msg: this.newMessage,
pubkey: this.user.keys.publickey
}
this.ws.send(JSON.stringify(message))
this.clearMessage()
},
sliceKey(key) {
if (!key) return ''
return `${key.slice(0, 4)}...${key.slice(-4)}`
},
downloadKeys() {
const file = new File(
[JSON.stringify(this.user.keys)],
'backup_keys.json',
{
type: 'text/json'
}
)
const link = document.createElement('a')
const url = URL.createObjectURL(file)
link.href = url
link.download = file.name
link.click()
window.URL.revokeObjectURL(url)
},
restoreKeys() {
this.user.keys = this.keysDialog.data
let data = this.$q.localStorage.getItem(`lnbits.diagonalley.data`)
this.$q.localStorage.set(`lnbits.diagonalley.data`, {
...data,
keys: this.user.keys
})
this.clearRestoreKeyDialog()
},
deleteData() {
LNbits.utils
.confirmDialog('Are you sure you want to delete your stored data?')
.onOk(() => {
this.$q.localStorage.remove('lnbits.diagonalley.data')
this.user = null
})
},
async generateKeys(payment_hash) {
//check if the keys are set
if ('publickey' in this.user.keys && 'privatekey' in this.user.keys)
return
return await LNbits.api
.request('GET', `/diagonalley/api/v1/keys/${payment_hash}`, null)
.then(response => {
if (response.data) {
let data = {
keys: {
privatekey: response.data.privkey,
publickey: response.data.pubkey
}
}
this.user.keys = data.keys
return
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
async getMessages(room_name, all = false) {
await LNbits.api
.request(
'GET',
`/diagonalley/api/v1/chat/messages/${room_name}${
all ? '?all_messages=true' : ''
}`
)
.then(response => {
if (response.data) {
response.data.reverse().map(m => {
this.$set(this.messages, m.timestamp * 1000, {
msg: m.msg,
pubkey: m.pubkey
})
})
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
async changeOrder() {
this.products = this.user.orders[this.selectedOrder]
this.messages = {}
await this.getMessages(this.selectedOrder)
this.startChat(this.selectedOrder)
},
startChat(room_name) {
if (this.ws) {
this.ws.close()
}
if (location.protocol == 'https:') {
ws_scheme = 'wss://'
} else {
ws_scheme = 'ws://'
}
ws = new WebSocket(
ws_scheme + location.host + '/diagonalley/ws/' + room_name
)
function checkWebSocket(event) {
if (ws.readyState === WebSocket.CLOSED) {
console.log('WebSocket CLOSED: Reopening')
ws = new WebSocket(
ws_scheme + location.host + '/diagonalley/ws/' + room_name
)
}
}
ws.onmessage = event => {
let event_data = JSON.parse(event.data)
this.$set(this.messages, Date.now(), event_data)
}
this.ws = ws
}
},
async created() {
console.log('{{request.url}}')
let order_details = JSON.parse('{{ order | tojson }}')
let products = JSON.parse('{{ products | tojson }}')
let order_id = '{{ order_id }}'
let hasKeys = Boolean(
JSON.parse('{{ publickey | tojson }}') &&
JSON.parse('{{ privatekey | tojson }}')
)
if (hasKeys) {
this.user.keys = {
privatekey: '{{ privatekey }}',
publickey: '{{ publickey }}'
}
}
this.stall = JSON.parse('{{ stall | tojson }}')
this.products = order_details.map(o => {
let product = products.find(p => p.id == o.product_id)
return {
quantity: o.quantity,
name: product.product,
image: product.image,
price: product.price
}
})
let data =
this.$q.localStorage.getItem(`lnbits.diagonalley.data`) || false
if (data) {
this.user = data
//add chat key (merchant pubkey) if not set
if (!this.user.orders[`${order_id}`]) {
this.$set(this.user.orders, order_id, this.products)
}
} else {
// generate keys
this.generateKeys(order_id)
// populate user data
this.user.orders = {
[`${order_id}`]: this.products
}
}
this.selectedOrder = order_id
await this.getMessages(order_id)
this.$q.localStorage.set(`lnbits.diagonalley.data`, this.user)
this.startChat(order_id)
}
})
</script>
<style scoped>
.q-field__native span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-container {
position: relative;
display: grid;
grid-template-rows: 1fr auto;
height: calc(100vh - 133px);
}
.chat-box {
display: flex;
flex-direction: column-reverse;
padding: 1rem;
overflow-y: auto;
}
.chat-messages {
width: auto;
}
.chat-other {
}
.chat-input {
position: relative;
display: flex;
align-items: end;
margin-top: 1rem;
}
</style>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "public.html" %} {% block page %}
<h1>Product page</h1>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {}
}
})
</script>
{% endblock %}

View file

@ -125,7 +125,7 @@
> >
</div> </div>
<div v-if="item.categories" class="text-subtitle1"> <div v-if="item.categories" class="text-subtitle1">
<q-chip v-for="cat in item.categories.split(',')" dense <q-chip v-for="(cat, i) in item.categories.split(',')" :key="i" dense
>{{cat}}</q-chip >{{cat}}</q-chip
> >
</div> </div>
@ -162,6 +162,17 @@
v-model.trim="checkoutDialog.data.username" v-model.trim="checkoutDialog.data.username"
label="Name *optional" label="Name *optional"
></q-input> ></q-input>
<q-input
filled
dense
v-model.trim="checkoutDialog.data.pubkey"
label="Public key *optional"
>
<template v-slot:append>
<q-icon @click="getPubkey" name="settings_backup_restore" />
<q-tooltip>Click to restore saved public key</q-tooltip>
</template>
</q-input>
<q-input <q-input
filled filled
dense dense
@ -206,7 +217,7 @@
<q-btn <q-btn
v-close-popup v-close-popup
flat flat
@click="checkoutDialog = {show: false, data: {}}" @click="checkoutDialog = {show: false, data: {pubkey: ''}}"
color="grey" color="grey"
class="q-ml-auto" class="q-ml-auto"
>Cancel</q-btn >Cancel</q-btn
@ -276,7 +287,9 @@
cartMenu: [], cartMenu: [],
checkoutDialog: { checkoutDialog: {
show: false, show: false,
data: {} data: {
pubkey: ''
}
}, },
qrCodeDialog: { qrCodeDialog: {
data: { data: {
@ -344,7 +357,18 @@
this.cartMenu = Array.from(this.cart.products, item => { this.cartMenu = Array.from(this.cart.products, item => {
return {id: item[0], ...item[1]} return {id: item[0], ...item[1]}
}) })
console.log(this.cartMenu, this.cart) },
getPubkey() {
let data = this.$q.localStorage.getItem(`lnbits.diagonalley.data`)
if (data && data.keys.publickey) {
this.checkoutDialog.data.pubkey = data.keys.publickey
} else {
this.$q.notify({
type: 'warning',
message: 'No public key stored!',
icon: 'settings_backup_restore'
})
}
}, },
placeOrder() { placeOrder() {
let dialog = this.checkoutDialog.data let dialog = this.checkoutDialog.data
@ -384,12 +408,25 @@
if (res.data.paid) { if (res.data.paid) {
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
message: 'Sats received, thanks!', multiLine: true,
icon: 'thumb_up' message:
"Sats received, thanks! You'l be redirected to the order page...",
icon: 'thumb_up',
actions: [
{
label: 'See Order',
handler: () => {
window.location.href = `/diagonalley/order/?merch=${this.stall.id}&invoice_id=${this.qrCodeDialog.data.payment_hash}`
}
}
]
}) })
clearInterval(this.qrCodeDialog.paymentChecker) clearInterval(this.qrCodeDialog.paymentChecker)
this.resetCart() this.resetCart()
this.closeQrCodeDialog() this.closeQrCodeDialog()
setTimeout(() => {
window.location.href = `/diagonalley/order/?merch=${this.stall.id}&invoice_id=${this.qrCodeDialog.data.payment_hash}`
}, 5000)
} }
}) })
.catch(error => { .catch(error => {
@ -407,8 +444,6 @@
created() { created() {
this.stall = JSON.parse('{{ stall | tojson }}') this.stall = JSON.parse('{{ stall | tojson }}')
this.products = JSON.parse('{{ products | tojson }}') this.products = JSON.parse('{{ products | tojson }}')
console.log(this.stall, this.products)
} }
}) })
</script> </script>

View file

@ -1,6 +1,8 @@
import json
from http import HTTPStatus from http import HTTPStatus
from typing import List
from fastapi import Request from fastapi import BackgroundTasks, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from loguru import logger from loguru import logger
@ -10,11 +12,15 @@ from starlette.responses import HTMLResponse
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_user_exists # type: ignore from lnbits.decorators import check_user_exists # type: ignore
from lnbits.extensions.diagonalley import diagonalley_ext, diagonalley_renderer from lnbits.extensions.diagonalley import diagonalley_ext, diagonalley_renderer
from lnbits.extensions.diagonalley.models import CreateChatMessage
from lnbits.extensions.diagonalley.notifier import Notifier
from ...core.crud import get_wallet
from .crud import ( from .crud import (
create_chat_message,
get_diagonalley_market, get_diagonalley_market,
get_diagonalley_market_stalls, get_diagonalley_market_stalls,
get_diagonalley_order_details,
get_diagonalley_order_invoiceid,
get_diagonalley_products, get_diagonalley_products,
get_diagonalley_stall, get_diagonalley_stall,
get_diagonalley_zone, get_diagonalley_zone,
@ -32,7 +38,7 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
) )
@diagonalley_ext.get("/{stall_id}", response_class=HTMLResponse) @diagonalley_ext.get("/stalls/{stall_id}", response_class=HTMLResponse)
async def display(request: Request, stall_id): async def display(request: Request, stall_id):
stall = await get_diagonalley_stall(stall_id) stall = await get_diagonalley_stall(stall_id)
products = await get_diagonalley_products(stall_id) products = await get_diagonalley_products(stall_id)
@ -85,3 +91,102 @@ async def display(request: Request, market_id):
"products": products, "products": products,
}, },
) )
@diagonalley_ext.get("/order", response_class=HTMLResponse)
async def chat_page(
request: Request,
merch: str = Query(...),
invoice_id: str = Query(...),
keys: str = Query(None),
):
stall = await get_diagonalley_stall(merch)
order = await get_diagonalley_order_invoiceid(invoice_id)
_order = await get_diagonalley_order_details(order.id)
products = await get_diagonalley_products(stall.id)
return diagonalley_renderer().TemplateResponse(
"diagonalley/order.html",
{
"request": request,
"stall": {
"id": stall.id,
"name": stall.name,
"publickey": stall.publickey,
"wallet": stall.wallet,
},
"publickey": keys.split(",")[0] if keys else None,
"privatekey": keys.split(",")[1] if keys else None,
"order_id": order.invoiceid,
"order": [details.dict() for details in _order],
"products": [product.dict() for product in products],
},
)
##################WEBSOCKET ROUTES########################
# Initialize Notifier:
notifier = Notifier()
# class ConnectionManager:
# def __init__(self):
# self.active_connections: List[WebSocket] = []
# async def connect(self, websocket: WebSocket, room_name: str):
# await websocket.accept()
# websocket.id = room_name
# self.active_connections.append(websocket)
# def disconnect(self, websocket: WebSocket):
# self.active_connections.remove(websocket)
# async def send_personal_message(self, message: str, room_name: str):
# for connection in self.active_connections:
# if connection.id == room_name:
# await connection.send_text(message)
# async def broadcast(self, message: str):
# for connection in self.active_connections:
# await connection.send_text(message)
# manager = ConnectionManager()
# @diagonalley_ext.websocket("/ws/{room_name}")
# async def websocket_endpoint(websocket: WebSocket, room_name: str):
# await manager.connect(websocket, room_name)
# try:
# while True:
# data = await websocket.receive_text()
# except WebSocketDisconnect:
# manager.disconnect(websocket)
@diagonalley_ext.websocket("/ws/{room_name}")
async def websocket_endpoint(
websocket: WebSocket, room_name: str, background_tasks: BackgroundTasks
):
await notifier.connect(websocket, room_name)
try:
while True:
data = await websocket.receive_text()
d = json.loads(data)
d["room_name"] = room_name
room_members = (
notifier.get_members(room_name)
if notifier.get_members(room_name) is not None
else []
)
if websocket not in room_members:
print("Sender not in room member: Reconnecting...")
await notifier.connect(websocket, room_name)
print("ENDPOINT", data)
await notifier._notify(data, room_name)
except WebSocketDisconnect:
notifier.remove(websocket, room_name)

View file

@ -1,12 +1,13 @@
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
from http import HTTPStatus from http import HTTPStatus
from typing import List from typing import List, Union
from uuid import uuid4 from uuid import uuid4
from fastapi import Request from fastapi import Request
from fastapi.param_functions import Query from fastapi.param_functions import Body, Query
from fastapi.params import Depends from fastapi.params import Depends
from loguru import logger from loguru import logger
from secp256k1 import PrivateKey, PublicKey
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
@ -33,6 +34,9 @@ from .crud import (
delete_diagonalley_product, delete_diagonalley_product,
delete_diagonalley_stall, delete_diagonalley_stall,
delete_diagonalley_zone, delete_diagonalley_zone,
get_diagonalley_chat_by_merchant,
get_diagonalley_chat_messages,
get_diagonalley_latest_chat_messages,
get_diagonalley_market, get_diagonalley_market,
get_diagonalley_market_stalls, get_diagonalley_market_stalls,
get_diagonalley_markets, get_diagonalley_markets,
@ -47,6 +51,7 @@ from .crud import (
get_diagonalley_stalls_by_ids, get_diagonalley_stalls_by_ids,
get_diagonalley_zone, get_diagonalley_zone,
get_diagonalley_zones, get_diagonalley_zones,
set_diagonalley_order_pubkey,
update_diagonalley_market, update_diagonalley_market,
update_diagonalley_product, update_diagonalley_product,
update_diagonalley_stall, update_diagonalley_stall,
@ -198,7 +203,6 @@ async def api_diagonalley_stall_create(
if stall_id: if stall_id:
stall = await get_diagonalley_stall(stall_id) stall = await get_diagonalley_stall(stall_id)
print("ID", stall_id)
if not stall: if not stall:
return {"message": "Withdraw stall does not exist."} return {"message": "Withdraw stall does not exist."}
@ -252,6 +256,14 @@ async def api_diagonalley_orders(
return {"message": "We could not retrieve the orders."} return {"message": "We could not retrieve the orders."}
@diagonalley_ext.get("/api/v1/orders/{order_id}")
async def api_diagonalley_order_by_id(order_id: str):
order = (await get_diagonalley_order(order_id)).dict()
order["details"] = await get_diagonalley_order_details(order_id)
return order
@diagonalley_ext.post("/api/v1/orders") @diagonalley_ext.post("/api/v1/orders")
async def api_diagonalley_order_create(data: createOrder): async def api_diagonalley_order_create(data: createOrder):
ref = urlsafe_short_hash() ref = urlsafe_short_hash()
@ -274,8 +286,6 @@ async def api_diagonalley_order_create(data: createOrder):
"payment_request": payment_request, "payment_request": payment_request,
"order_reference": ref, "order_reference": ref,
} }
# order = await create_diagonalley_order(wallet_id=wallet.wallet.id, data=data)
# return order.dict()
@diagonalley_ext.get("/api/v1/orders/payments/{payment_hash}") @diagonalley_ext.get("/api/v1/orders/payments/{payment_hash}")
@ -296,7 +306,7 @@ async def api_diagonalley_check_payment(payment_hash: str):
@diagonalley_ext.delete("/api/v1/orders/{order_id}") @diagonalley_ext.delete("/api/v1/orders/{order_id}")
async def api_diagonalley_order_delete( async def api_diagonalley_order_delete(
order_id: str, wallet: WalletTypeInfo = Depends(get_key_type) order_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
order = await get_diagonalley_order(order_id) order = await get_diagonalley_order(order_id)
@ -340,7 +350,7 @@ async def api_diagonalley_order_shipped(
"SELECT * FROM diagonalley.orders WHERE id = ?", (order_id,) "SELECT * FROM diagonalley.orders WHERE id = ?", (order_id,)
) )
return [order.dict() for order in get_diagonalley_orders(order["wallet"])] return order
###List products based on stall id ###List products based on stall id
@ -354,7 +364,6 @@ async def api_diagonalley_stall_products(
rows = await db.fetchone( rows = await db.fetchone(
"SELECT * FROM diagonalley.stalls WHERE id = ?", (stall_id,) "SELECT * FROM diagonalley.stalls WHERE id = ?", (stall_id,)
) )
print(rows[1])
if not rows: if not rows:
return {"message": "Stall does not exist."} return {"message": "Stall does not exist."}
@ -383,44 +392,44 @@ async def api_diagonalley_stall_checkshipped(
###Place order ###Place order
@diagonalley_ext.post("/api/v1/stall/order/{stall_id}") # @diagonalley_ext.post("/api/v1/stall/order/{stall_id}")
async def api_diagonalley_stall_order( # async def api_diagonalley_stall_order(
stall_id, data: createOrder, wallet: WalletTypeInfo = Depends(get_key_type) # stall_id, data: createOrder, wallet: WalletTypeInfo = Depends(get_key_type)
): # ):
product = await get_diagonalley_product(data.productid) # product = await get_diagonalley_product(data.productid)
shipping = await get_diagonalley_stall(stall_id) # shipping = await get_diagonalley_stall(stall_id)
if data.shippingzone == 1: # if data.shippingzone == 1:
shippingcost = shipping.zone1cost # missing in model # shippingcost = shipping.zone1cost # missing in model
else: # else:
shippingcost = shipping.zone2cost # missing in model # shippingcost = shipping.zone2cost # missing in model
checking_id, payment_request = await create_invoice( # checking_id, payment_request = await create_invoice(
wallet_id=product.wallet, # wallet_id=product.wallet,
amount=shippingcost + (data.quantity * product.price), # amount=shippingcost + (data.quantity * product.price),
memo=shipping.wallet, # memo=shipping.wallet,
) # )
selling_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") # selling_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
await db.execute( # await db.execute(
""" # """
INSERT INTO diagonalley.orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped) # INSERT INTO diagonalley.orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) # VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", # """,
( # (
selling_id, # selling_id,
data.productid, # data.productid,
product.wallet, # doesn't exist in model # product.wallet, # doesn't exist in model
product.product, # product.product,
data.quantity, # data.quantity,
data.shippingzone, # data.shippingzone,
data.address, # data.address,
data.email, # data.email,
checking_id, # checking_id,
False, # False,
False, # False,
), # ),
) # )
return {"checking_id": checking_id, "payment_request": payment_request} # return {"checking_id": checking_id, "payment_request": payment_request}
## ##
@ -467,3 +476,42 @@ async def api_diagonalley_stall_create(
await create_diagonalley_market_stalls(market_id=market.id, data=data.stalls) await create_diagonalley_market_stalls(market_id=market.id, data=data.stalls)
return market.dict() return market.dict()
## KEYS
@diagonalley_ext.get("/api/v1/keys/{payment_hash}")
async def api_diagonalley_generate_keys(payment_hash: str):
private_key = PrivateKey()
public_key = private_key.pubkey.serialize().hex()
while not public_key.startswith("02"):
private_key = PrivateKey()
public_key = private_key.pubkey.serialize().hex()
# set pubkey in order
await set_diagonalley_order_pubkey(payment_hash, pubkey=public_key[2:])
return {"privkey": private_key.serialize(), "pubkey": public_key[2:]}
## MESSAGES/CHAT
@diagonalley_ext.get("/api/v1/chat/messages/merchant")
async def api_get_merchant_messages(
orders: str = Query(...), wallet: WalletTypeInfo = Depends(require_admin_key)
):
return [
msg.dict() for msg in await get_diagonalley_chat_by_merchant(orders.split(","))
]
@diagonalley_ext.get("/api/v1/chat/messages/{room_name}")
async def api_get_latest_chat_msg(room_name: str, all_messages: bool = Query(False)):
if all_messages:
messages = await get_diagonalley_chat_messages(room_name)
else:
messages = await get_diagonalley_latest_chat_messages(room_name)
return messages

View file

@ -98,21 +98,21 @@ async def get_discordbot_wallet(wallet_id: str) -> Optional[Wallets]:
return Wallets(**row) if row else None 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( rows = await db.fetchall(
"SELECT * FROM discordbot.wallets WHERE admin = ?", (admin_id,) "SELECT * FROM discordbot.wallets WHERE admin = ?", (admin_id,)
) )
return [Wallets(**row) for row in rows] 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( rows = await db.fetchall(
"""SELECT * FROM discordbot.wallets WHERE "user" = ?""", (user_id,) """SELECT * FROM discordbot.wallets WHERE "user" = ?""", (user_id,)
) )
return [Wallets(**row) for row in rows] 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( return await get_payments(
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True 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) @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( return discordbot_renderer().TemplateResponse(
"discordbot/index.html", {"request": request, "user": user.dict()} "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) @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 user_id = wallet.wallet.user
return [user.dict() for user in await get_discordbot_users(user_id)] return [user.dict() for user in await get_discordbot_users(user_id)]
@discordbot_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK) @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) 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) @discordbot_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED)
async def api_discordbot_users_create( 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) user = await create_discordbot_user(data)
full = user.dict() full = user.dict()
full["wallets"] = [ wallets = await get_discordbot_users_wallets(user.id)
wallet.dict() for wallet in await get_discordbot_users_wallets(user.id) if wallets:
] full["wallets"] = [wallet for wallet in wallets]
return full return full
@discordbot_ext.delete("/api/v1/users/{user_id}") @discordbot_ext.delete("/api/v1/users/{user_id}")
async def api_discordbot_users_delete( 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) user = await get_discordbot_user(user_id)
if not user: if not user:
@ -75,7 +80,7 @@ async def api_discordbot_activate_extension(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." 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"} return {"extension": "updated"}
@ -84,7 +89,7 @@ async def api_discordbot_activate_extension(
@discordbot_ext.post("/api/v1/wallets") @discordbot_ext.post("/api/v1/wallets")
async def api_discordbot_wallets_create( 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 = await create_discordbot_wallet(
user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id 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") @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 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}") @discordbot_ext.get("/api/v1/transactions/{wallet_id}")
async def api_discordbot_wallet_transactions( 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) return await get_discordbot_wallet_transactions(wallet_id)
@discordbot_ext.get("/api/v1/wallets/{user_id}") @discordbot_ext.get("/api/v1/wallets/{user_id}")
async def api_discordbot_users_wallets( 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}") @discordbot_ext.delete("/api/v1/wallets/{wallet_id}")
async def api_discordbot_wallets_delete( 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) get_wallet = await get_discordbot_wallet(wallet_id)
if not get_wallet: if not get_wallet:

View file

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

View file

@ -45,7 +45,7 @@ async def m001_initial_invoices(db):
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL, invoice_id TEXT NOT NULL,
amount INT NOT NULL, amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import update_jukebox_payment from .crud import update_jukebox_payment
@ -8,7 +9,7 @@ from .crud import update_jukebox_payment
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()

View file

@ -4,17 +4,17 @@ import json
from loguru import logger from loguru import logger
from lnbits.core import db as core_db from lnbits.core import db as core_db
from lnbits.core.crud import create_payment
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import urlsafe_short_hash from lnbits.core.services import create_invoice, pay_invoice
from lnbits.tasks import internal_invoice_listener, register_invoice_listener 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 from .crud import get_livestream_by_track, get_producer, get_track
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()
@ -44,44 +44,20 @@ async def on_invoice_paid(payment: Payment) -> None:
# now we make a special kind of internal transfer # now we make a special kind of internal transfer
amount = int(payment.amount * (100 - ls.fee_pct) / 100) amount = int(payment.amount * (100 - ls.fee_pct) / 100)
# mark the original payment with two extra keys, "shared_with" and "received" payment_hash, payment_request = await create_invoice(
# (this prevents us from doing this process again and it's informative) wallet_id=tpos.tip_wallet,
# and reduce it by the amount we're going to send to the producer amount=amount, # sats
await core_db.execute( internal=True,
"""
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,
memo=f"Revenue from '{track.name}'.", memo=f"Revenue from '{track.name}'.",
pending=False,
) )
logger.debug(f"livestream: producer invoice created: {payment_hash}")
# manually send this for now checking_id = await pay_invoice(
# await internal_invoice_paid.send(internal_checking_id) payment_request=payment_request,
await internal_invoice_listener.put(internal_checking_id) wallet_id=payment.wallet_id,
extra={"tag": "livestream"},
)
logger.debug(f"livestream: producer invoice paid: {checking_id}")
# so the flow is the following: # so the flow is the following:
# - we receive, say, 1000 satoshis # - we receive, say, 1000 satoshis

View file

@ -3,6 +3,7 @@ import asyncio
import httpx import httpx
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import get_address, get_domain, set_address_paid, set_address_renewed from .crud import get_address, get_domain, set_address_paid, set_address_renewed
@ -10,7 +11,7 @@ from .crud import get_address, get_domain, set_address_paid, set_address_renewed
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import json
from fastapi import APIRouter from fastapi import APIRouter

View file

@ -3,6 +3,7 @@ import asyncio
from loguru import logger from loguru import logger
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import get_ticket, set_ticket_paid from .crud import get_ticket, set_ticket_paid
@ -10,7 +11,7 @@ from .crud import get_ticket, set_ticket_paid
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()

View file

@ -1,7 +1,10 @@
import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_lnurldevice") db = Database("ext_lnurldevice")
@ -13,5 +16,11 @@ def lnurldevice_renderer():
from .lnurl import * # noqa from .lnurl import * # noqa
from .tasks import wait_for_paid_invoices
from .views import * # noqa from .views import * # noqa
from .views_api import * # noqa from .views_api import * # noqa
def lnurldevice_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View file

@ -22,9 +22,10 @@ async def create_lnurldevice(
wallet, wallet,
currency, currency,
device, device,
profit profit,
amount
) )
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
lnurldevice_id, lnurldevice_id,
@ -34,6 +35,7 @@ async def create_lnurldevice(
data.currency, data.currency,
data.device, data.device,
data.profit, data.profit,
data.amount,
), ),
) )
return await get_lnurldevice(lnurldevice_id) return await get_lnurldevice(lnurldevice_id)

View file

@ -102,7 +102,32 @@ async def lnurl_v1_params(
if device.device == "atm": if device.device == "atm":
if paymentcheck: if paymentcheck:
return {"status": "ERROR", "reason": f"Payment already claimed"} return {"status": "ERROR", "reason": f"Payment already claimed"}
if device.device == "switch":
price_msat = (
await fiat_amount_as_satoshis(float(device.profit), device.currency)
if device.currency != "sat"
else amount_in_cent
) * 1000
lnurldevicepayment = await create_lnurldevicepayment(
deviceid=device.id,
payload="bla",
sats=price_msat,
pin=1,
payhash="bla",
)
if not lnurldevicepayment:
return {"status": "ERROR", "reason": "Could not create payment."}
return {
"tag": "payRequest",
"callback": request.url_for(
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
),
"minSendable": price_msat,
"maxSendable": price_msat,
"metadata": await device.lnurlpay_metadata(),
}
if len(p) % 4 > 0: if len(p) % 4 > 0:
p += "=" * (4 - (len(p) % 4)) p += "=" * (4 - (len(p) % 4))
@ -184,22 +209,42 @@ async def lnurl_callback(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="lnurldevice not found." status_code=HTTPStatus.FORBIDDEN, detail="lnurldevice not found."
) )
if pr: if device.device == "atm":
if lnurldevicepayment.id != k1: if not pr:
return {"status": "ERROR", "reason": "Bad K1"} raise HTTPException(
if lnurldevicepayment.payhash != "payment_hash": status_code=HTTPStatus.FORBIDDEN, detail="No payment request"
return {"status": "ERROR", "reason": f"Payment already claimed"} )
else:
if lnurldevicepayment.id != 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 = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload
) )
await pay_invoice( await pay_invoice(
wallet_id=device.wallet,
payment_request=pr,
max_sat=lnurldevicepayment.sats / 1000,
extra={"tag": "withdraw"},
)
return {"status": "OK"}
if device.device == "switch":
payment_hash, payment_request = await create_invoice(
wallet_id=device.wallet, wallet_id=device.wallet,
payment_request=pr, amount=lnurldevicepayment.sats / 1000,
max_sat=lnurldevicepayment.sats / 1000, memo=device.title + "-" + lnurldevicepayment.id,
extra={"tag": "withdraw"}, unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "Switch", "id": paymentid, "time": device.amount},
) )
return {"status": "OK"} lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=payment_hash
)
return {
"pr": payment_request,
"routes": [],
}
payment_hash, payment_request = await create_invoice( payment_hash, payment_request = await create_invoice(
wallet_id=device.wallet, wallet_id=device.wallet,
@ -221,5 +266,3 @@ async def lnurl_callback(
}, },
"routes": [], "routes": [],
} }
return resp.dict()

View file

@ -29,7 +29,7 @@ async def m001_initial(db):
payhash TEXT, payhash TEXT,
payload TEXT NOT NULL, payload TEXT NOT NULL,
pin INT, pin INT,
sats INT, sats {db.big_int},
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
); );
""" """
@ -79,3 +79,12 @@ async def m002_redux(db):
) )
except: except:
return return
async def m003_redux(db):
"""
Add 'meta' for storing various metadata about the wallet
"""
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount INT DEFAULT 0;"
)

View file

@ -17,6 +17,7 @@ class createLnurldevice(BaseModel):
currency: str currency: str
device: str device: str
profit: float profit: float
amount: int
class lnurldevices(BaseModel): class lnurldevices(BaseModel):
@ -27,15 +28,14 @@ class lnurldevices(BaseModel):
currency: str currency: str
device: str device: str
profit: float profit: float
amount: int
timestamp: str timestamp: str
def from_row(cls, row: Row) -> "lnurldevices": def from_row(cls, row: Row) -> "lnurldevices":
return cls(**dict(row)) return cls(**dict(row))
def lnurl(self, req: Request) -> Lnurl: def lnurl(self, req: Request) -> Lnurl:
url = req.url_for( url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
"lnurldevice.lnurl_response", device_id=self.id, _external=True
)
return lnurl_encode(url) return lnurl_encode(url)
async def lnurlpay_metadata(self) -> LnurlPayMetadata: async def lnurlpay_metadata(self) -> LnurlPayMetadata:

View file

@ -0,0 +1,40 @@
import asyncio
import json
from http import HTTPStatus
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException
from lnbits import bolt11
from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment
from .views import updater
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 "Switch" == payment.extra.get("tag"):
lnurldevicepayment = await get_lnurldevicepayment(payment.extra.get("id"))
if not lnurldevicepayment:
return
if lnurldevicepayment.payhash == "used":
return
lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
)
return await updater(lnurldevicepayment.deviceid)
return

View file

@ -1,13 +1,24 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<p> <p>
Register LNURLDevice devices to receive payments in your LNbits wallet.<br /> For LNURL based Points of Sale, ATMs, and relay devices<br />
Build your own here Use with: <br />
<a href="https://github.com/arcbtc/bitcoinpos" LNPoS
>https://github.com/arcbtc/bitcoinpos</a <a href="https://lnbits.github.io/lnpos">
https://lnbits.github.io/lnpos</a
><br />
bitcoinSwitch
<a href="https://github.com/lnbits/bitcoinSwitch">
https://github.com/lnbits/bitcoinSwitch</a
><br />
FOSSA
<a href="https://github.com/lnbits/fossa">
https://github.com/lnbits/fossa</a
><br /> ><br />
<small> <small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small Created by, <a href="https://github.com/benarc">Ben Arc</a>,
<a href="https://github.com/blackcoffeexbt">BC</a>,
<a href="https://github.com/motorina0">Vlad Stan</a></small
> >
</p> </p>
</q-card-section> </q-card-section>

View file

@ -51,6 +51,7 @@
<q-tr :props="props"> <q-tr :props="props">
<q-th style="width: 5%"></q-th> <q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th> <q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th <q-th
v-for="col in props.cols" v-for="col in props.cols"
@ -91,6 +92,22 @@
<q-tooltip> LNURLDevice Settings </q-tooltip> <q-tooltip> LNURLDevice Settings </q-tooltip>
</q-btn> </q-btn>
</q-td> </q-td>
<q-td>
<q-btn
v-if="props.row.device == 'switch'"
:disable="protocol == 'http:'"
flat
unelevated
dense
size="xs"
icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@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-td>
<q-td <q-td
v-for="col in props.cols" v-for="col in props.cols"
:key="col.name" :key="col.name"
@ -132,20 +149,33 @@
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
<div class="text-h6">LNURLDevice device string</div> <div class="text-h6">LNURLDevice device string</div>
<q-btn <center>
dense <q-btn
outline v-if="settingsDialog.data.device == 'switch'"
unelevated dense
color="primary" outline
size="md" unelevated
@click="copyText(location + '/lnurldevice/api/v1/lnurl/' + settingsDialog.data.id + ',' + color="primary"
size="md"
@click="copyText(wslocation + '/lnurldevice/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
>{% raw %}{{wslocation}}/lnurldevice/ws/{{settingsDialog.data.id}}{%
endraw %}<q-tooltip> Click to copy URL </q-tooltip>
</q-btn>
<q-btn
v-else
dense
outline
unelevated
color="primary"
size="md"
@click="copyText(location + '/lnurldevice/api/v1/lnurl/' + settingsDialog.data.id + ',' +
settingsDialog.data.key + ',' + settingsDialog.data.currency, 'Link copied to clipboard!')" settingsDialog.data.key + ',' + settingsDialog.data.currency, 'Link copied to clipboard!')"
>{% raw >{% raw
%}{{location}}/lnurldevice/api/v1/lnurl/{{settingsDialog.data.id}}, %}{{location}}/lnurldevice/api/v1/lnurl/{{settingsDialog.data.id}},
{{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw {{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw
%}<q-tooltip> Click to copy URL </q-tooltip> %}<q-tooltip> Click to copy URL </q-tooltip>
</q-btn> </q-btn>
</center>
<div class="text-subtitle2"> <div class="text-subtitle2">
<small> </small> <small> </small>
</div> </div>
@ -191,6 +221,7 @@
label="Type of device" label="Type of device"
></q-option-group> ></q-option-group>
<q-input <q-input
v-if="formDialoglnurldevice.data.device != 'switch'"
filled filled
dense dense
v-model.trim="formDialoglnurldevice.data.profit" v-model.trim="formDialoglnurldevice.data.profit"
@ -198,6 +229,29 @@
max="90" max="90"
label="Profit margin (% added to invoices/deducted from faucets)" label="Profit margin (% added to invoices/deducted from faucets)"
></q-input> ></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>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
@ -225,6 +279,33 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<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"
: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 %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')"
class="q-ml-sm"
>Copy LNURL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
@ -252,7 +333,9 @@
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
return { return {
protocol: window.location.protocol,
location: window.location.hostname, location: window.location.hostname,
wslocation: window.location.hostname,
filter: '', filter: '',
currency: 'USD', currency: 'USD',
lnurldeviceLinks: [], lnurldeviceLinks: [],
@ -265,6 +348,10 @@
{ {
label: 'ATM', label: 'ATM',
value: 'atm' value: 'atm'
},
{
label: 'Switch',
value: 'switch'
} }
], ],
lnurldevicesTable: { lnurldevicesTable: {
@ -333,7 +420,8 @@
show_ack: false, show_ack: false,
show_price: 'None', show_price: 'None',
device: 'pos', device: 'pos',
profit: 2, profit: 0,
amount: 1,
title: '' title: ''
} }
}, },
@ -344,6 +432,16 @@
} }
}, },
methods: { methods: {
openQrCodeDialog: function (lnurldevice_id) {
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
id: lnurldevice_id
})
console.log(lnurldevice)
this.qrCodeDialog.data = _.clone(lnurldevice)
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true
},
cancellnurldevice: function (data) { cancellnurldevice: function (data) {
var self = this var self = this
self.formDialoglnurldevice.show = false self.formDialoglnurldevice.show = false
@ -400,6 +498,7 @@
.then(function (response) { .then(function (response) {
if (response.data) { if (response.data) {
self.lnurldeviceLinks = response.data.map(maplnurldevice) self.lnurldeviceLinks = response.data.map(maplnurldevice)
console.log(response.data)
} }
}) })
.catch(function (error) { .catch(function (error) {
@ -519,6 +618,7 @@
'//', '//',
window.location.host window.location.host
].join('') ].join('')
self.wslocation = ['ws://', window.location.host].join('')
LNbits.api LNbits.api
.request('GET', '/api/v1/currencies') .request('GET', '/api/v1/currencies')
.then(response => { .then(response => {

View file

@ -1,11 +1,13 @@
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO
from fastapi import Request import pyqrcode
from fastapi import Request, WebSocket, WebSocketDisconnect
from fastapi.param_functions import Query from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse, StreamingResponse
from lnbits.core.crud import update_payment_status from lnbits.core.crud import update_payment_status
from lnbits.core.models import User from lnbits.core.models import User
@ -51,3 +53,58 @@ async def displaypin(request: Request, paymentid: str = Query(None)):
"lnurldevice/error.html", "lnurldevice/error.html",
{"request": request, "pin": "filler", "not_paid": True}, {"request": request, "pin": "filler", "not_paid": True},
) )
@lnurldevice_ext.get("/img/{lnurldevice_id}", response_class=StreamingResponse)
async def img(request: Request, lnurldevice_id):
lnurldevice = await get_lnurldevice(lnurldevice_id)
if not lnurldevice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist."
)
return lnurldevice.lnurl(request)
##################WEBSOCKET ROUTES########################
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, lnurldevice_id: str):
await websocket.accept()
websocket.id = lnurldevice_id
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, lnurldevice_id: str):
for connection in self.active_connections:
if connection.id == lnurldevice_id:
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@lnurldevice_ext.websocket("/ws/{lnurldevice_id}", name="lnurldevice.lnurldevice_by_id")
async def websocket_endpoint(websocket: WebSocket, lnurldevice_id: str):
await manager.connect(websocket, lnurldevice_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(lnurldevice_id):
lnurldevice = await get_lnurldevice(lnurldevice_id)
if not lnurldevice:
return
await manager.send_personal_message(f"{lnurldevice.amount}", lnurldevice_id)

View file

@ -32,32 +32,42 @@ async def api_list_currencies_available():
@lnurldevice_ext.post("/api/v1/lnurlpos") @lnurldevice_ext.post("/api/v1/lnurlpos")
@lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}") @lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}")
async def api_lnurldevice_create_or_update( async def api_lnurldevice_create_or_update(
req: Request,
data: createLnurldevice, data: createLnurldevice,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
lnurldevice_id: str = Query(None), lnurldevice_id: str = Query(None),
): ):
if not lnurldevice_id: if not lnurldevice_id:
lnurldevice = await create_lnurldevice(data) lnurldevice = await create_lnurldevice(data)
return lnurldevice.dict() return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
else: else:
lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id) lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id)
return lnurldevice.dict() return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
@lnurldevice_ext.get("/api/v1/lnurlpos") @lnurldevice_ext.get("/api/v1/lnurlpos")
async def api_lnurldevices_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_lnurldevices_retrieve(
req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
try: try:
return [ return [
{**lnurldevice.dict()} for lnurldevice in await get_lnurldevices(wallet_ids) {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
for lnurldevice in await get_lnurldevices(wallet_ids)
] ]
except: except:
return "" try:
return [
{**lnurldevice.dict()}
for lnurldevice in await get_lnurldevices(wallet_ids)
]
except:
return ""
@lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}") @lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}")
async def api_lnurldevice_retrieve( async def api_lnurldevice_retrieve(
request: Request, req: Request,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type),
lnurldevice_id: str = Query(None), lnurldevice_id: str = Query(None),
): ):
@ -68,7 +78,7 @@ async def api_lnurldevice_retrieve(
) )
if not lnurldevice.lnurl_toggle: if not lnurldevice.lnurl_toggle:
return {**lnurldevice.dict()} return {**lnurldevice.dict()}
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(request=request)}} return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
@lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}") @lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}")

View file

@ -5,6 +5,7 @@ import httpx
from lnbits.core import db as core_db from lnbits.core import db as core_db
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import get_pay_link from .crud import get_pay_link
@ -12,7 +13,7 @@ from .crud import get_pay_link
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()

View file

@ -3,14 +3,14 @@ async def m001_initial(db):
Initial lnurlpayouts table. Initial lnurlpayouts table.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE lnurlpayout.lnurlpayouts ( CREATE TABLE lnurlpayout.lnurlpayouts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
admin_key TEXT NOT NULL, admin_key TEXT NOT NULL,
lnurlpay TEXT NOT NULL, lnurlpay TEXT NOT NULL,
threshold INT NOT NULL threshold {db.big_int} NOT NULL
); );
""" """
) )

View file

@ -10,6 +10,7 @@ from lnbits.core.crud import get_wallet
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice from lnbits.core.services import pay_invoice
from lnbits.core.views.api import api_payments_decode from lnbits.core.views.api import api_payments_decode
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import get_lnurlpayout_from_wallet from .crud import get_lnurlpayout_from_wallet
@ -17,7 +18,7 @@ from .crud import get_lnurlpayout_from_wallet
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()

View file

@ -102,7 +102,7 @@ async def check_address_balance(charge_id: str) -> List[Charges]:
charge = await get_charge(charge_id) charge = await get_charge(charge_id)
if not charge.paid: if not charge.paid:
if charge.onchainaddress: if charge.onchainaddress:
config = await get_config(charge.user) config = await get_charge_config(charge_id)
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.get( 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) return await update_charge(charge_id=charge_id, balance=charge.amount)
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
return Charges.from_row(row) if row else None 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): class Charges(BaseModel):
id: str id: str
user: str
description: Optional[str] description: Optional[str]
onchainwallet: Optional[str] onchainwallet: Optional[str]
onchainaddress: Optional[str] onchainaddress: Optional[str]

View file

@ -4,6 +4,7 @@ from loguru import logger
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.extensions.satspay.crud import check_address_balance, get_charge from lnbits.extensions.satspay.crud import check_address_balance, get_charge
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
# from .crud import get_ticket, set_ticket_paid # from .crud import get_ticket, set_ticket_paid
@ -11,7 +12,7 @@ from lnbits.tasks import register_invoice_listener
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()

View file

@ -328,7 +328,7 @@
) )
}, },
checkBalances: async function () { checkBalances: async function () {
if (!this.charge.hasStaleBalance) await this.refreshCharge() if (this.charge.hasStaleBalance) return
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
@ -339,18 +339,9 @@
LNbits.utils.notifyApiError(error) 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 () { checkPendingOnchain: async function () {
if (!this.charge.onchainaddress) return
const { const {
bitcoin: {addresses: addressesAPI} bitcoin: {addresses: addressesAPI}
} = mempoolJS({ } = mempoolJS({

View file

@ -9,10 +9,9 @@ from starlette.responses import HTMLResponse
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.extensions.watchonly.crud import get_config
from . import satspay_ext, satspay_renderer from . import satspay_ext, satspay_renderer
from .crud import get_charge from .crud import get_charge, get_charge_config
templates = Jinja2Templates(directory="templates") 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." status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
) )
wallet = await get_wallet(charge.lnbitswallet) 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 inkey = wallet.inkey if wallet else None
mempool_endpoint = ( mempool_endpoint = (
onchainwallet_config.mempool_endpoint if onchainwallet_config else None onchainwallet_config.mempool_endpoint if onchainwallet_config else None

View file

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

View file

@ -4,6 +4,8 @@
SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress! SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress!
<small>Only whole values, integers, are Scrubbed, amounts will be rounded down (example: 6.3 will be 6)! The decimals, if existing, will be kept in your wallet!</small>
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) [**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
## Usage ## Usage

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
from http import HTTPStatus from http import HTTPStatus
from math import floor
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
@ -9,6 +10,7 @@ from fastapi import HTTPException
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice from lnbits.core.services import pay_invoice
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import get_scrub_by_wallet from .crud import get_scrub_by_wallet
@ -16,7 +18,7 @@ from .crud import get_scrub_by_wallet
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()
@ -25,7 +27,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
# (avoid loops) # (avoid loops)
if "scrubed" == payment.extra.get("tag"): if payment.extra.get("tag") == "scrubed":
# already scrubbed # already scrubbed
return return
@ -41,12 +43,13 @@ async def on_invoice_paid(payment: Payment) -> None:
# I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267 # I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267
domain = urlparse(data["callback"]).netloc domain = urlparse(data["callback"]).netloc
rounded_amount = floor(payment.amount / 1000) * 1000
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
r = await client.get( r = await client.get(
data["callback"], data["callback"],
params={"amount": payment.amount}, params={"amount": rounded_amount},
timeout=40, timeout=40,
) )
if r.is_error: if r.is_error:
@ -65,7 +68,8 @@ async def on_invoice_paid(payment: Payment) -> None:
) )
invoice = bolt11.decode(params["pr"]) invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != payment.amount:
if invoice.amount_msat != rounded_amount:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.", detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.",

View file

@ -68,6 +68,21 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Scrub extension</h6> <h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Scrub extension</h6>
<p>
Automatically forward funds (Scrub) that get paid to the LNbits
wallet, to an LNURLpay or Lightning Address.
<br />
More info in Scrub's
<a
href="https://github.com/lnbits/lnbits/blob/main/lnbits/extensions/scrub/README.md#scrub"
target="_blank"
>readme</a
>.
</p>
<p style="font-size: 90%">
<strong>Important: </strong>wallet will need a float to account for
any fees, before being able to push a payment
</p>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>

View file

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

View file

@ -1,20 +1,18 @@
import asyncio import asyncio
import json
from loguru import logger 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.core.models import Payment
from lnbits.helpers import urlsafe_short_hash from lnbits.core.services import create_invoice, pay_invoice
from lnbits.tasks import internal_invoice_queue, register_invoice_listener from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_targets from .crud import get_targets
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()
@ -22,59 +20,36 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") == "splitpayments" or payment.extra.get("splitted"): if payment.extra.get("tag") == "splitpayments":
# already splitted, ignore # already a splitted payment, ignore
return return
# now we make some special internal transfers (from no one to the receiver)
targets = await get_targets(payment.wallet_id) targets = await get_targets(payment.wallet_id)
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])
if amount_left < 0:
logger.error(
"splitpayments failure: amount_left is negative.", payment.payment_hash
)
return
if not targets: if not targets:
return return
# mark the original payment with one extra key, "splitted" total_percent = sum([target.percent for target in targets])
# (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 if total_percent > 100:
for wallet, amount in transfers: logger.error("splitpayment failure: total percent adds up to more than 100%")
internal_checking_id = f"internal_{urlsafe_short_hash()}" return
await create_payment(
wallet_id=wallet, logger.debug(f"performing split payments to {len(targets)} targets")
checking_id=internal_checking_id, for target in targets:
payment_request="", amount = int(payment.amount * target.percent / 100) # msats
payment_hash=payment.payment_hash, payment_hash, payment_request = await create_invoice(
amount=amount, wallet_id=target.wallet,
memo=payment.memo, amount=int(amount / 1000), # sats
pending=False, internal=True,
memo=f"split payment: {target.percent}% for {target.alias or target.wallet}",
extra={"tag": "splitpayments"}, extra={"tag": "splitpayments"},
) )
logger.debug(f"created split invoice: {payment_hash}")
# manually send this for now checking_id = await pay_invoice(
await internal_invoice_queue.put(internal_checking_id) payment_request=payment_request,
return wallet_id=payment.wallet_id,
extra={"tag": "splitpayments"},
)
logger.debug(f"paid split invoice: {checking_id}")

View file

@ -25,7 +25,7 @@ async def m001_initial(db):
name TEXT NOT NULL, name TEXT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
cur_code TEXT NOT NULL, cur_code TEXT NOT NULL,
sats INT NOT NULL, sats {db.big_int} NOT NULL,
amount FLOAT NOT NULL, amount FLOAT NOT NULL,
service INTEGER NOT NULL, service INTEGER NOT NULL,
posted BOOLEAN NOT NULL, posted BOOLEAN NOT NULL,

View file

@ -3,10 +3,10 @@ from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from . import db 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( await db.execute(
""" """
INSERT INTO subdomains.subdomain (id, domain, email, subdomain, ip, wallet, sats, duration, paid, record_type) 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): class CreateDomain(BaseModel):
wallet: str = Query(...) wallet: str = Query(...) # type: ignore
domain: str = Query(...) domain: str = Query(...) # type: ignore
cf_token: str = Query(...) cf_token: str = Query(...) # type: ignore
cf_zone_id: str = Query(...) cf_zone_id: str = Query(...) # type: ignore
webhook: str = Query("") webhook: str = Query("") # type: ignore
description: str = Query(..., min_length=0) description: str = Query(..., min_length=0) # type: ignore
cost: int = Query(..., ge=0) cost: int = Query(..., ge=0) # type: ignore
allowed_record_types: str = Query(...) allowed_record_types: str = Query(...) # type: ignore
class CreateSubdomain(BaseModel): class CreateSubdomain(BaseModel):
domain: str = Query(...) domain: str = Query(...) # type: ignore
subdomain: str = Query(...) subdomain: str = Query(...) # type: ignore
email: str = Query(...) email: str = Query(...) # type: ignore
ip: str = Query(...) ip: str = Query(...) # type: ignore
sats: int = Query(..., ge=0) sats: int = Query(..., ge=0) # type: ignore
duration: int = Query(...) duration: int = Query(...) # type: ignore
record_type: str = Query(...) record_type: str = Query(...) # type: ignore
class Domains(BaseModel): class Domains(BaseModel):

View file

@ -3,6 +3,7 @@ import asyncio
import httpx import httpx
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .cloudflare import cloudflare_create_subdomain from .cloudflare import cloudflare_create_subdomain
@ -11,7 +12,7 @@ from .crud import get_domain, set_subdomain_paid
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()
@ -19,7 +20,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "lnsubdomain": if not payment.extra or payment.extra.get("tag") != "lnsubdomain":
# not an lnurlp invoice # not an lnurlp invoice
return return
@ -36,7 +37,7 @@ async def on_invoice_paid(payment: Payment) -> None:
) )
### Use webhook to notify about cloudflare registration ### Use webhook to notify about cloudflare registration
if domain.webhook: if domain and domain.webhook:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
r = await client.post( r = await client.post(

View file

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

View file

@ -29,12 +29,15 @@ from .crud import (
@subdomains_ext.get("/api/v1/domains") @subdomains_ext.get("/api/v1/domains")
async def api_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] wallet_ids = [g.wallet.id]
if all_wallets: 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)] 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.post("/api/v1/domains")
@subdomains_ext.put("/api/v1/domains/{domain_id}") @subdomains_ext.put("/api/v1/domains/{domain_id}")
async def api_domain_create( 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: if domain_id:
domain = await get_domain(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}") @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) domain = await get_domain(domain_id)
if not domain: if not domain:
@ -82,12 +89,14 @@ async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)
@subdomains_ext.get("/api/v1/subdomains") @subdomains_ext.get("/api/v1/subdomains")
async def api_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] wallet_ids = [g.wallet.id]
if all_wallets: 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)] 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}") @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) subdomain = await get_subdomain(subdomain_id)
if not subdomain: if not subdomain:

View file

@ -19,8 +19,8 @@ async def m001_initial(db):
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
sats INT NOT NULL, sats {db.big_int} NOT NULL,
tipjar INT NOT NULL, tipjar {db.big_int} NOT NULL,
FOREIGN KEY(tipjar) REFERENCES {db.references_schema}TipJars(id) FOREIGN KEY(tipjar) REFERENCES {db.references_schema}TipJars(id)
); );
""" """

View file

@ -1,18 +1,18 @@
import asyncio import asyncio
import json
from lnbits.core import db as core_db from loguru import logger
from lnbits.core.crud import create_payment
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import urlsafe_short_hash from lnbits.core.services import create_invoice, pay_invoice
from lnbits.tasks import internal_invoice_queue, register_invoice_listener from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_tpos from .crud import get_tpos
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue) register_invoice_listener(invoice_queue, get_current_extension_name())
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()
@ -20,11 +20,9 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") == "tpos" and payment.extra.get("tipSplitted"): if payment.extra.get("tag") != "tpos":
# already splitted, ignore
return return
# now we make some special internal transfers (from no one to the receiver)
tpos = await get_tpos(payment.extra.get("tposId")) tpos = await get_tpos(payment.extra.get("tposId"))
tipAmount = payment.extra.get("tipAmount") tipAmount = payment.extra.get("tipAmount")
@ -32,39 +30,17 @@ async def on_invoice_paid(payment: Payment) -> None:
# no tip amount # no tip amount
return return
tipAmount = tipAmount * 1000 payment_hash, payment_request = await create_invoice(
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(
wallet_id=tpos.tip_wallet, wallet_id=tpos.tip_wallet,
checking_id=internal_checking_id, amount=int(tipAmount), # sats
payment_request="", internal=True,
payment_hash=payment.payment_hash, memo=f"tpos tip",
amount=tipAmount,
memo=f"Tip for {payment.memo}",
pending=False,
extra={"tipSplitted": True},
) )
logger.debug(f"tpos: tip invoice created: {payment_hash}")
# manually send this for now checking_id = await pay_invoice(
await internal_invoice_queue.put(internal_checking_id) payment_request=payment_request,
return wallet_id=payment.wallet_id,
extra={"tag": "tpos"},
)
logger.debug(f"tpos: tip invoice paid: {checking_id}")

View file

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

View file

@ -14,7 +14,6 @@ class CreateWallet(BaseModel):
class WalletAccount(BaseModel): class WalletAccount(BaseModel):
id: str id: str
user: str
masterpub: str masterpub: str
fingerprint: str fingerprint: str
title: str title: str
@ -80,6 +79,7 @@ class CreatePsbt(BaseModel):
class ExtractPsbt(BaseModel): class ExtractPsbt(BaseModel):
psbtBase64 = "" # // todo snake case psbtBase64 = "" # // todo snake case
inputs: List[TransactionInput] inputs: List[TransactionInput]
network = "Mainnet"
class SignedTransaction(BaseModel): class SignedTransaction(BaseModel):

View file

@ -5,7 +5,7 @@
<send-to <send-to
:data.sync="sendToList" :data.sync="sendToList"
:fee-rate="feeRate" :fee-rate="feeRate"
:tx-size="txSizeNoChange" :tx-size="txSize"
:selected-amount="selectedAmount" :selected-amount="selectedAmount"
:sats-denominated="satsDenominated" :sats-denominated="satsDenominated"
@update:outputs="handleOutputsChange" @update:outputs="handleOutputsChange"

View file

@ -11,7 +11,8 @@ async function payment(path) {
'mempool-endpoint', 'mempool-endpoint',
'sats-denominated', 'sats-denominated',
'serial-signer-ref', 'serial-signer-ref',
'adminkey' 'adminkey',
'network'
], ],
watch: { watch: {
immediate: true, immediate: true,
@ -279,7 +280,8 @@ async function payment(path) {
this.adminkey, this.adminkey,
{ {
psbtBase64, psbtBase64,
inputs: this.tx.inputs inputs: this.tx.inputs,
network: this.network
} }
) )
return data return data

View file

@ -49,7 +49,7 @@
<q-item <q-item
v-for="device in pairedDevices" v-for="device in pairedDevices"
:key="device.id" :key="device.id"
v-if="!selectedPort" v-if="!selectedPort && showPairedDevices"
clickable clickable
v-close-popup v-close-popup
> >

View file

@ -15,7 +15,7 @@ async function serialSigner(path) {
receivedData: '', receivedData: '',
config: {}, config: {},
decryptionKey: null, decryptionKey: null,
sharedSecret: null, // todo: store in secure local storage sharedSecret: null,
hww: { hww: {
password: null, password: null,
@ -51,12 +51,14 @@ async function serialSigner(path) {
}, },
tx: null, // todo: move to hww tx: null, // todo: move to hww
showConsole: false showConsole: false,
showPairedDevices: true
} }
}, },
computed: { computed: {
pairedDevices: { pairedDevices: {
cache: false,
get: function () { get: function () {
return ( return (
JSON.parse(window.localStorage.getItem('lnbits-paired-devices')) || JSON.parse(window.localStorage.getItem('lnbits-paired-devices')) ||
@ -109,7 +111,10 @@ async function serialSigner(path) {
// Wait for the serial port to open. // Wait for the serial port to open.
await this.selectedPort.open(config) await this.selectedPort.open(config)
// do not await
this.startSerialPortReading() this.startSerialPortReading()
// wait to init
sleep(1000)
const textEncoder = new TextEncoderStream() const textEncoder = new TextEncoderStream()
this.writableStreamClosed = textEncoder.readable.pipeTo( this.writableStreamClosed = textEncoder.readable.pipeTo(
@ -225,8 +230,9 @@ async function serialSigner(path) {
while (true) { while (true) {
const {value, done} = await readStringUntil('\n') const {value, done} = await readStringUntil('\n')
if (value) { if (value) {
this.handleSerialPortResponse(value) const {command, commandData} = await this.extractCommand(value)
this.updateSerialPortConsole(value) this.handleSerialPortResponse(command, commandData)
this.updateSerialPortConsole(command)
} }
if (done) return if (done) return
} }
@ -240,8 +246,7 @@ async function serialSigner(path) {
} }
} }
}, },
handleSerialPortResponse: async function (value) { handleSerialPortResponse: async function (command, commandData) {
const {command, commandData} = await this.extractCommand(value)
this.logPublicCommandsResponse(command, commandData) this.logPublicCommandsResponse(command, commandData)
switch (command) { switch (command) {
@ -282,7 +287,7 @@ async function serialSigner(path) {
) )
break break
default: default:
console.log(` %c${value}`, 'background: #222; color: red') console.log(` %c${command}`, 'background: #222; color: red')
} }
}, },
logPublicCommandsResponse: function (command, commandData) { logPublicCommandsResponse: function (command, commandData) {
@ -307,6 +312,8 @@ async function serialSigner(path) {
}, },
hwwPing: async function () { hwwPing: async function () {
try { try {
// Send an empty ping. The serial port buffer might have some jubk data. Flush it.
await this.sendCommandClearText(COMMAND_PING)
await this.sendCommandClearText(COMMAND_PING, [window.location.host]) await this.sendCommandClearText(COMMAND_PING, [window.location.host])
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
@ -582,7 +589,7 @@ async function serialSigner(path) {
hwwCheckPairing: async function () { hwwCheckPairing: async function () {
const iv = window.crypto.getRandomValues(new Uint8Array(16)) const iv = window.crypto.getRandomValues(new Uint8Array(16))
const encrypted = await this.encryptMessage( const encrypted = await this.encryptMessage(
this.sharedSecret, this.sharedSecret, // todo: revisit
iv, iv,
PAIRING_CONTROL_TEXT.length + ' ' + PAIRING_CONTROL_TEXT PAIRING_CONTROL_TEXT.length + ' ' + PAIRING_CONTROL_TEXT
) )
@ -603,10 +610,10 @@ async function serialSigner(path) {
} }
}, },
handleCheckPairingResponse: async function (res = '') { handleCheckPairingResponse: async function (res = '') {
const [statusCode, encryptedMessage] = res.split(' ') const [statusCode, message] = res.split(' ')
switch (statusCode) { switch (statusCode) {
case '0': case '0':
const controlText = await this.decryptData(encryptedMessage) const controlText = await this.decryptData(message)
if (controlText == PAIRING_CONTROL_TEXT) { if (controlText == PAIRING_CONTROL_TEXT) {
this.$q.notify({ this.$q.notify({
type: 'positive', type: 'positive',
@ -622,6 +629,16 @@ async function serialSigner(path) {
}) })
} }
break break
case '1':
this.closeSerialPort()
this.$q.notify({
type: 'warning',
message:
'Re-pairing failed. Remove (forget) device and try again!',
caption: `Error: ${message}`,
timeout: 10000
})
break
default: default:
// noting to do here yet // noting to do here yet
break break
@ -746,7 +763,7 @@ async function serialSigner(path) {
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
message: 'Failed to ask for help!', message: 'Failed to wipe!',
caption: `${error}`, caption: `${error}`,
timeout: 10000 timeout: 10000
}) })
@ -862,6 +879,11 @@ async function serialSigner(path) {
sendCommandSecure: async function (command, attrs = []) { sendCommandSecure: async function (command, attrs = []) {
const message = [command].concat(attrs).join(' ') const message = [command].concat(attrs).join(' ')
const iv = window.crypto.getRandomValues(new Uint8Array(16)) const iv = window.crypto.getRandomValues(new Uint8Array(16))
if (!this.sharedSecret || !this.sharedSecret.length) {
throw new Error(
`Secure connection not estabileshed. Tried to run command: ${command}`
)
}
const encrypted = await this.encryptMessage( const encrypted = await this.encryptMessage(
this.sharedSecret, this.sharedSecret,
iv, iv,
@ -901,6 +923,7 @@ async function serialSigner(path) {
}, },
decryptData: async function (value) { decryptData: async function (value) {
if (!this.sharedSecret) { if (!this.sharedSecret) {
console.log('/error Secure session not established!')
return '/error Secure session not established!' return '/error Secure session not established!'
} }
try { try {
@ -921,6 +944,7 @@ async function serialSigner(path) {
.trim() .trim()
return command return command
} catch (error) { } catch (error) {
console.log('/error Failed to decrypt message from device!')
return '/error Failed to decrypt message from device!' return '/error Failed to decrypt message from device!'
} }
}, },
@ -949,6 +973,11 @@ async function serialSigner(path) {
devices.splice(deviceIndex, 1) devices.splice(deviceIndex, 1)
} }
this.pairedDevices = devices this.pairedDevices = devices
this.showPairedDevices = false
setTimeout(() => {
// force UI refresh
this.showPairedDevices = true
})
}, },
addPairedDevice: function (deviceId, sharedSecretHex, config) { addPairedDevice: function (deviceId, sharedSecretHex, config) {
const devices = this.pairedDevices const devices = this.pairedDevices
@ -960,6 +989,11 @@ async function serialSigner(path) {
config config
}) })
this.pairedDevices = devices this.pairedDevices = devices
this.showPairedDevices = false
setTimeout(() => {
// force UI refresh
this.showPairedDevices = true
})
}, },
updatePairedDeviceConfig(deviceId, config) { updatePairedDeviceConfig(deviceId, config) {
const device = this.getPairedDevice(deviceId) const device = this.getPairedDevice(deviceId)

View file

@ -18,9 +18,21 @@
>directly from browser</a >directly from browser</a
> >
<small> <small>
<br />Created by, <br />Created by
<a target="_blank" style="color: unset" href="https://github.com/arcbtc" <a target="_blank" style="color: unset" href="https://github.com/arcbtc"
>Ben Arc</a >Ben Arc</a
>,
<a
target="_blank"
style="color: unset"
href="https://github.com/talvasconcelos"
>Tiago Vasconcelos</a
>,
<a
target="_blank"
style="color: unset"
href="https://github.com/motorina0"
>motorina0</a
> >
(using, (using,
<a <a

View file

@ -136,6 +136,7 @@
:adminkey="g.user.wallets[0].adminkey" :adminkey="g.user.wallets[0].adminkey"
:serial-signer-ref="$refs.serialSigner" :serial-signer-ref="$refs.serialSigner"
:sats-denominated="config.sats_denominated" :sats-denominated="config.sats_denominated"
:network="config.network"
@broadcast-done="handleBroadcastSuccess" @broadcast-done="handleBroadcastSuccess"
></payment> ></payment>
<!-- todo: no more utxos.data --> <!-- todo: no more utxos.data -->
@ -149,7 +150,7 @@
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none"> <h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Onchain Wallet (watch-only) Extension {{SITE_TITLE}} Onchain Wallet (watch-only) Extension
<small>(v0.2)</small> <small>(v0.3)</small>
</h6> </h6>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">

View file

@ -4,6 +4,7 @@ from http import HTTPStatus
import httpx import httpx
from embit import finalizer, script from embit import finalizer, script
from embit.ec import PublicKey from embit.ec import PublicKey
from embit.networks import NETWORKS
from embit.psbt import PSBT, DerivationPath from embit.psbt import PSBT, DerivationPath
from embit.transaction import Transaction, TransactionInput, TransactionOutput from embit.transaction import Transaction, TransactionInput, TransactionOutput
from fastapi import Query, Request from fastapi import Query, Request
@ -85,7 +86,6 @@ async def api_wallet_create_or_update(
new_wallet = WalletAccount( new_wallet = WalletAccount(
id="none", id="none",
user=w.wallet.user,
masterpub=data.masterpub, masterpub=data.masterpub,
fingerprint=descriptor.keys[0].fingerprint.hex(), fingerprint=descriptor.keys[0].fingerprint.hex(),
type=descriptor.scriptpubkey_type(), type=descriptor.scriptpubkey_type(),
@ -114,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) await api_get_addresses(wallet.id, w)
except Exception as e: except Exception as e:
@ -295,6 +295,7 @@ async def api_psbt_create(
async def api_psbt_extract_tx( async def api_psbt_extract_tx(
data: ExtractPsbt, w: WalletTypeInfo = Depends(require_admin_key) data: ExtractPsbt, w: WalletTypeInfo = Depends(require_admin_key)
): ):
network = NETWORKS["main"] if data.network == "Mainnet" else NETWORKS["test"]
res = SignedTransaction() res = SignedTransaction()
try: try:
psbt = PSBT.from_base64(data.psbtBase64) psbt = PSBT.from_base64(data.psbtBase64)
@ -316,7 +317,7 @@ async def api_psbt_extract_tx(
for out in transaction.vout: for out in transaction.vout:
tx["outputs"].append( tx["outputs"].append(
{"amount": out.value, "address": out.script_pubkey.address()} {"amount": out.value, "address": out.script_pubkey.address(network)}
) )
res.tx_json = json.dumps(tx) res.tx_json = json.dumps(tx)
except Exception as e: except Exception as e:

View file

@ -183,3 +183,26 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
t.env.globals["VENDORED_CSS"] = ["/static/bundle.css"] t.env.globals["VENDORED_CSS"] = ["/static/bundle.css"]
return t return t
def get_current_extension_name() -> str:
"""
Returns the name of the extension that calls this method.
"""
import inspect
import json
import os
callee_filepath = inspect.stack()[1].filename
callee_dirname, callee_filename = os.path.split(callee_filepath)
path = os.path.normpath(callee_dirname)
extension_director_name = path.split(os.sep)[-1]
try:
config_path = os.path.join(callee_dirname, "config.json")
with open(config_path) as json_file:
config = json.load(json_file)
ext_name = config["name"]
except:
ext_name = extension_director_name
return ext_name

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

@ -24,18 +24,21 @@ LNBITS_DATA_FOLDER = env.str(
) )
LNBITS_DATABASE_URL = env.str("LNBITS_DATABASE_URL", default=None) LNBITS_DATABASE_URL = env.str("LNBITS_DATABASE_URL", default=None)
LNBITS_ALLOWED_USERS: List[str] = env.list( LNBITS_ALLOWED_USERS: List[str] = [
"LNBITS_ALLOWED_USERS", default=[], subcast=str x.strip(" ") for x in env.list("LNBITS_ALLOWED_USERS", default=[], subcast=str)
) ]
LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str) LNBITS_ADMIN_USERS: List[str] = [
LNBITS_ADMIN_EXTENSIONS: List[str] = env.list( x.strip(" ") for x in env.list("LNBITS_ADMIN_USERS", default=[], subcast=str)
"LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str ]
) LNBITS_ADMIN_EXTENSIONS: List[str] = [
LNBITS_DISABLED_EXTENSIONS: List[str] = env.list( x.strip(" ") for x in env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str)
"LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str ]
) LNBITS_DISABLED_EXTENSIONS: List[str] = [
x.strip(" ")
for x in env.list("LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str)
]
LNBITS_AD_SPACE = env.list("LNBITS_AD_SPACE", default=[]) LNBITS_AD_SPACE = [x.strip(" ") for x in env.list("LNBITS_AD_SPACE", default=[])]
LNBITS_HIDE_API = env.bool("LNBITS_HIDE_API", default=False) LNBITS_HIDE_API = env.bool("LNBITS_HIDE_API", default=False)
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits") LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")
LNBITS_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats") LNBITS_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats")
@ -43,11 +46,14 @@ LNBITS_SITE_TAGLINE = env.str(
"LNBITS_SITE_TAGLINE", default="free and open-source lightning wallet" "LNBITS_SITE_TAGLINE", default="free and open-source lightning wallet"
) )
LNBITS_SITE_DESCRIPTION = env.str("LNBITS_SITE_DESCRIPTION", default="") LNBITS_SITE_DESCRIPTION = env.str("LNBITS_SITE_DESCRIPTION", default="")
LNBITS_THEME_OPTIONS: List[str] = env.list( LNBITS_THEME_OPTIONS: List[str] = [
"LNBITS_THEME_OPTIONS", x.strip(" ")
default="classic, flamingo, mint, salvador, monochrome, autumn", for x in env.list(
subcast=str, "LNBITS_THEME_OPTIONS",
) default="classic, flamingo, mint, salvador, monochrome, autumn",
subcast=str,
)
]
LNBITS_CUSTOM_LOGO = env.str("LNBITS_CUSTOM_LOGO", default="") LNBITS_CUSTOM_LOGO = env.str("LNBITS_CUSTOM_LOGO", default="")
WALLET = wallet_class() WALLET = wallet_class()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before After
Before After

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