Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11173e6460 | ||
|
|
069a7922fc | ||
|
|
98338ffb00 | ||
|
|
62dec118af | ||
|
|
84b406534d | ||
|
|
d9d2d59b73 | ||
|
|
20d4b954c0 | ||
|
|
5edb845d8f | ||
|
|
cfe2b43d2a | ||
|
|
a0ac41a9ce | ||
|
|
194a527736 | ||
|
|
6f135c0696 | ||
|
|
9a767b627f | ||
|
|
c1c4eda69d | ||
|
|
6c46867698 | ||
|
|
a73eb569ae | ||
|
|
f07b40735e | ||
|
|
94f74d3f56 | ||
|
|
d9a5e63e51 | ||
|
|
c8e5c23ac2 | ||
|
|
59968e38d1 | ||
|
|
4f45781319 | ||
|
|
22ea83e843 | ||
|
|
55fed49be1 | ||
|
|
311d9a668b | ||
|
|
165f5093f4 | ||
|
|
43d92ddb51 | ||
|
|
10944bf100 | ||
|
|
ce9b2c3b77 | ||
|
|
4c102a08e8 | ||
|
|
1d2b939e06 | ||
|
|
e1bb2113ed | ||
|
|
b71113700a | ||
|
|
ebfcc46c9e | ||
|
|
c047ae6808 | ||
|
|
023a1a088e | ||
|
|
8eabf53642 | ||
|
|
824a8fa7c8 | ||
|
|
a3b7c76523 | ||
|
|
26ca8c71d7 | ||
|
|
0d2447faf3 | ||
|
|
4cea06c5a5 | ||
|
|
031ce14857 | ||
|
|
bd143f5c14 | ||
|
|
4e9c992a91 | ||
|
|
d6ac3d2672 | ||
|
|
1933d896cb | ||
|
|
168a3bcddb | ||
|
|
adc342ab9a | ||
|
|
bbf3cd96f1 | ||
|
|
e8c9304519 | ||
|
|
815c3e61e4 | ||
|
|
4e55ea18e5 | ||
|
|
81b70acf5f | ||
|
|
165d7499ea | ||
|
|
abbcfbeb6a | ||
|
|
bff44b7097 | ||
|
|
95f6ae2bb6 | ||
|
|
a15af87f9b | ||
|
|
97f8a4ab38 | ||
|
|
7b9d370452 | ||
|
|
db72953310 | ||
|
|
bb918a8523 | ||
|
|
24b02cc656 | ||
|
|
9877ca602f | ||
|
|
c9093715b7 |
@@ -1,5 +1,28 @@
|
||||
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
||||
|
||||
######################################
|
||||
########### Admin Settings ###########
|
||||
######################################
|
||||
|
||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
||||
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
||||
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
||||
# The rest of the settings will be stored in your database and you will be able to change them
|
||||
# only through the Admin UI.
|
||||
# Disable this to make LNbits use this config file again.
|
||||
LNBITS_ADMIN_UI=false
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
######################################
|
||||
########## Funding Source ############
|
||||
######################################
|
||||
@@ -79,25 +102,27 @@ LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
######################################
|
||||
########### Admin Settings ###########
|
||||
####### Auth Configurations ##########
|
||||
######################################
|
||||
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
|
||||
AUTH_SECRET_KEY=""
|
||||
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth
|
||||
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||
# Set this flag if HTTP is used for OAuth
|
||||
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||
|
||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available
|
||||
# Warning: Enabling this will make LNbits ignore this configuration file. Your settings will
|
||||
# be stored in your database and you will be able to change them only through the Admin UI.
|
||||
# Disable this to make LNbits use this config file again.
|
||||
LNBITS_ADMIN_UI=false
|
||||
# Google OAuth Config
|
||||
# Make sure thant the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
# GitHub OAuth Config
|
||||
# Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/github/token
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
######################################
|
||||
|
||||
# uvicorn variable, uncomment to allow https behind a proxy
|
||||
# FORWARDED_ALLOW_IPS="*"
|
||||
@@ -118,6 +143,9 @@ LNBITS_ADMIN_USERS=""
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
|
||||
|
||||
# Start LNbits core only. The extensions are not loaded.
|
||||
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
|
||||
|
||||
# Disable account creation for new users
|
||||
# LNBITS_ALLOW_NEW_ACCOUNTS=false
|
||||
|
||||
@@ -172,6 +200,13 @@ LNBITS_RESERVE_FEE_MIN=2000
|
||||
# value in percent
|
||||
LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||
|
||||
# limit the maximum balance for each wallet
|
||||
# throw an error if the wallet attempts to create a new invoice
|
||||
|
||||
# LNBITS_WALLET_LIMIT_MAX_BALANCE=1000000
|
||||
# LNBITS_WALLET_LIMIT_DAILY_MAX_WITHDRAW=1000000
|
||||
# LNBITS_WALLET_LIMIT_SECS_BETWEEN_TRANS=60
|
||||
|
||||
# Limit fiat currencies allowed to see in UI
|
||||
# LNBITS_ALLOWED_CURRENCIES="EUR, USD"
|
||||
|
||||
@@ -188,3 +223,6 @@ ENABLE_LOG_TO_FILE=true
|
||||
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||
LOG_ROTATION="100 MB"
|
||||
LOG_RETENTION="3 months"
|
||||
|
||||
# for database cleanup commands
|
||||
# CLEANUP_WALLETS_DAYS=90
|
||||
|
||||
@@ -38,9 +38,6 @@ jobs:
|
||||
with:
|
||||
make: openapi
|
||||
|
||||
# docker:
|
||||
# uses: ./.github/workflows/docker.yml
|
||||
|
||||
regtest:
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
|
||||
@@ -1,20 +1,58 @@
|
||||
name: docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
default: latest
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
default: latest
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
push_to_dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v4
|
||||
id: cache
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: lnbitsdocker/lnbits-legend:latest
|
||||
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest
|
||||
cache-to: type=inline
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:${{ inputs.tag }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
- name: Build and push latest tag
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
name: LNbits CI / nix
|
||||
|
||||
# - run : on main, dev, nix and cachix branches when relevant files change
|
||||
# - cache : on main, dev and cachix branches when relevant files change
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- nix
|
||||
- cachix
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'poetry.lock'
|
||||
- '.github/workflows/nix.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
@@ -21,8 +27,15 @@ jobs:
|
||||
nix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: cachix/install-nix-action@v23
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v24
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- run: nix build
|
||||
nix_path: nixpkgs=channel:nixos-23.11
|
||||
- uses: cachix/cachix-action@v13
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
|
||||
with:
|
||||
name: lnbits
|
||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||
- run: nix build -L
|
||||
- run: cachix push lnbits ./result
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
name: Build and push Docker image on tag
|
||||
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
name: Build and push lnbits image
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Import environment variables
|
||||
id: import-env
|
||||
shell: bash
|
||||
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||
|
||||
- name: Show set environment variables
|
||||
run: |
|
||||
printf " TAG: %s\n" "$TAG"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
id: qemu
|
||||
|
||||
- name: Setup Docker buildx action
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: buildx
|
||||
|
||||
- name: Show available Docker buildx platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
id: cache
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Run Docker buildx against tag
|
||||
run: |
|
||||
docker buildx build \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:${TAG} \
|
||||
--output "type=registry" ./
|
||||
|
||||
- name: Run Docker buildx against latest
|
||||
run: |
|
||||
docker buildx build \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:latest \
|
||||
--output "type=registry" ./
|
||||
@@ -33,8 +33,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: lnbitsdocker/lnbits-legend:latest
|
||||
cache-from: type=registry,ref=lnbitsdocker/lnbits-legend:latest
|
||||
tags: lnbits/lnbits:latest
|
||||
cache-from: type=registry,ref=lnbits/lnbits:latest
|
||||
cache-to: type=inline
|
||||
|
||||
- name: Setup Regtest
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Create fake admin
|
||||
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
|
||||
run: docker exec lnbits-legend-lnbits-1 poetry run python tools/create_fake_admin.py
|
||||
run: docker exec lnbits-lnbits-1 poetry run python tools/create_fake_admin.py
|
||||
|
||||
- name: Run Tests
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Create github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --generate-notes --draft
|
||||
|
||||
docker:
|
||||
needs: [ release ]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
@@ -86,7 +86,7 @@ bak:
|
||||
sass:
|
||||
npm run sass
|
||||
|
||||
bundle:
|
||||
bundle_no_bump:
|
||||
npm install
|
||||
npm run sass
|
||||
npm run vendor_copy
|
||||
@@ -96,6 +96,9 @@ bundle:
|
||||
npm run vendor_minify_css
|
||||
npm run vendor_bundle_js
|
||||
npm run vendor_minify_js
|
||||
|
||||
bundle:
|
||||
make bundle_no_bump
|
||||
# increment serviceworker version
|
||||
awk '/CACHE_VERSION =/ {sub(/[0-9]+$$/, $$NF+1)} 1' lnbits/static/js/service-worker.js > lnbits/static/js/service-worker.js.new
|
||||
mv lnbits/static/js/service-worker.js.new lnbits/static/js/service-worker.js
|
||||
@@ -103,7 +106,7 @@ bundle:
|
||||
checkbundle:
|
||||
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
|
||||
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
|
||||
make bundle
|
||||
make bundle_no_bump
|
||||
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
|
||||
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
|
||||
@echo "Bundle is OK"
|
||||
|
||||
@@ -65,17 +65,31 @@ poetry install --only main
|
||||
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
|
||||
sh <(curl -L https://nixos.org/nix/install) --daemon
|
||||
|
||||
# Enable nix-command and flakes experimental features for nix:
|
||||
echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
|
||||
|
||||
# Add cachix for cached binaries
|
||||
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
||||
cachix use lnbits
|
||||
|
||||
# Clone and build LNbits
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
nix build .#lnbits
|
||||
mkdir data
|
||||
nix build
|
||||
|
||||
mkdir data
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
nix run
|
||||
```
|
||||
|
||||
Ideally you would set the environment via the `.env` file,
|
||||
but you can also set the env variables or pass command line arguments:
|
||||
|
||||
``` sh
|
||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
|
||||
@@ -83,24 +97,23 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
```
|
||||
|
||||
|
||||
## Option 3: Docker
|
||||
|
||||
use latest version from docker hub
|
||||
```sh
|
||||
docker pull lnbitsdocker/lnbits-legend
|
||||
docker pull lnbits/lnbits
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
build the image yourself
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
docker build -t lnbits/lnbits .
|
||||
cp .env.example .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
## Option 4: Fly.io
|
||||
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1701539137,
|
||||
"narHash": "sha256-nVO/5QYpf1GwjvtpXhyxx5M3U/WN0MwBro4Lsk+9mL0=",
|
||||
"lastModified": 1702233072,
|
||||
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "933d7dc155096e7575d207be6fb7792bc9f34f6d",
|
||||
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -66,11 +66,11 @@
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1701861752,
|
||||
"narHash": "sha256-QfrE05P66856b1SMan69NPhjc9e82VtLxBKg3yiQGW8=",
|
||||
"lastModified": 1702334837,
|
||||
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "9fc487b32a68473da4bf9573f85b388043c5ecda",
|
||||
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -18,14 +18,6 @@
|
||||
projectName = "lnbits";
|
||||
in
|
||||
{
|
||||
devShells = forAllSystems (system: pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodePackages.prettier
|
||||
poetry-core
|
||||
];
|
||||
};
|
||||
});
|
||||
overlays = {
|
||||
default = final: prev: {
|
||||
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
|
||||
@@ -36,6 +28,7 @@
|
||||
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
||||
projectDir = ./.;
|
||||
meta.rev = self.dirtyRev or self.rev;
|
||||
meta.mainProgram = projectName;
|
||||
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
|
||||
protobuf = prev.protobuf.override { preferWheel = true; };
|
||||
ruff = prev.ruff.override { preferWheel = true; };
|
||||
|
||||
@@ -10,18 +10,20 @@ import traceback
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Callable, List
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from lnbits.core.crud import get_installed_extensions
|
||||
from lnbits.core.crud import get_dbversions, get_installed_extensions
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog,
|
||||
@@ -33,7 +35,7 @@ from lnbits.tasks import cancel_all_tasks, create_permanent_task
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.wallets import get_wallet_class, set_wallet_class
|
||||
|
||||
from .commands import db_versions, load_disabled_extension_list, migrate_databases
|
||||
from .commands import migrate_databases
|
||||
from .core import init_core_routers
|
||||
from .core.db import core_app_extra
|
||||
from .core.services import check_admin_settings, check_webpush_settings
|
||||
@@ -50,6 +52,7 @@ from .middleware import (
|
||||
CustomGZipMiddleware,
|
||||
ExtensionsRedirectMiddleware,
|
||||
InstalledExtensionMiddleware,
|
||||
add_first_install_middleware,
|
||||
add_ip_block_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
@@ -96,18 +99,22 @@ def create_app() -> FastAPI:
|
||||
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
|
||||
)
|
||||
|
||||
# required for SSO login
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
|
||||
|
||||
# order of these two middlewares is important
|
||||
app.add_middleware(InstalledExtensionMiddleware)
|
||||
app.add_middleware(ExtensionsRedirectMiddleware)
|
||||
|
||||
register_custom_extensions_path()
|
||||
|
||||
add_first_install_middleware(app)
|
||||
|
||||
# adds security middleware
|
||||
add_ip_block_middleware(app)
|
||||
add_ratelimit_middleware(app)
|
||||
|
||||
register_startup(app)
|
||||
register_routes(app)
|
||||
register_async_tasks(app)
|
||||
register_exception_handlers(app)
|
||||
register_shutdown(app)
|
||||
@@ -184,8 +191,7 @@ async def check_installed_extensions(app: FastAPI):
|
||||
persist state. Zips that are missing will be re-downloaded.
|
||||
"""
|
||||
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
|
||||
await load_disabled_extension_list()
|
||||
installed_extensions = await build_all_installed_extensions_list()
|
||||
installed_extensions = await build_all_installed_extensions_list(False)
|
||||
|
||||
for ext in installed_extensions:
|
||||
try:
|
||||
@@ -207,7 +213,9 @@ async def check_installed_extensions(app: FastAPI):
|
||||
logger.info(f"{ext.id} ({ext.installed_version})")
|
||||
|
||||
|
||||
async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
||||
async def build_all_installed_extensions_list(
|
||||
include_deactivated: Optional[bool] = True,
|
||||
) -> List[InstallableExtension]:
|
||||
"""
|
||||
Returns a list of all the installed extensions plus the extensions that
|
||||
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
||||
@@ -232,7 +240,17 @@ async def build_all_installed_extensions_list() -> List[InstallableExtension]:
|
||||
)
|
||||
installed_extensions.append(ext_info)
|
||||
|
||||
return installed_extensions
|
||||
if include_deactivated:
|
||||
return installed_extensions
|
||||
|
||||
if settings.lnbits_extensions_deactivate_all:
|
||||
return []
|
||||
|
||||
return [
|
||||
e
|
||||
for e in installed_extensions
|
||||
if e.id not in settings.lnbits_deactivated_extensions
|
||||
]
|
||||
|
||||
|
||||
def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||
@@ -255,7 +273,7 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
extension = Extension.from_installable_ext(ext)
|
||||
register_ext_routes(app, extension)
|
||||
|
||||
current_version = (await db_versions()).get(ext.id, 0)
|
||||
current_version = (await get_dbversions()).get(ext.id, 0)
|
||||
await migrate_extension_database(extension, current_version)
|
||||
|
||||
# mount routes for the new version
|
||||
@@ -268,7 +286,7 @@ def register_routes(app: FastAPI) -> None:
|
||||
"""Register FastAPI routes / LNbits extensions."""
|
||||
init_core_routers(app)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
for ext in get_valid_extensions(False):
|
||||
try:
|
||||
register_ext_routes(app, ext)
|
||||
except Exception as e:
|
||||
@@ -378,6 +396,9 @@ def register_startup(app: FastAPI):
|
||||
# check extensions after restart
|
||||
await check_installed_extensions(app)
|
||||
|
||||
# register core and extension routes
|
||||
register_routes(app)
|
||||
|
||||
if settings.lnbits_admin_ui:
|
||||
initialize_server_logger()
|
||||
|
||||
@@ -515,6 +536,15 @@ def register_exception_handlers(app: FastAPI):
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
if exc.headers and "token-expired" in exc.headers:
|
||||
response = RedirectResponse("/")
|
||||
response.delete_cookie("cookie_access_token")
|
||||
response.delete_cookie("is_lnbits_user_authorized")
|
||||
response.set_cookie(
|
||||
"is_access_token_expired", "true", samesite="none", secure=True
|
||||
)
|
||||
return response
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{
|
||||
|
||||
@@ -18,10 +18,13 @@ from lnbits.settings import settings
|
||||
from .core import db as core_db
|
||||
from .core import migrations as core_migrations
|
||||
from .core.crud import (
|
||||
delete_accounts_no_wallets,
|
||||
delete_unused_wallets,
|
||||
get_dbversions,
|
||||
get_inactive_extensions,
|
||||
get_installed_extension,
|
||||
get_installed_extensions,
|
||||
remove_deleted_wallets,
|
||||
)
|
||||
from .core.helpers import migrate_extension_database, run_migration
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
@@ -136,7 +139,11 @@ async def migrate_databases():
|
||||
core_version = current_versions.get("core", 0)
|
||||
await run_migration(conn, core_migrations, "core", core_version)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
# here is the first place we can be sure that the
|
||||
# `installed_extensions` table has been created
|
||||
await load_disabled_extension_list()
|
||||
|
||||
for ext in get_valid_extensions(False):
|
||||
current_version = current_versions.get(ext.code, 0)
|
||||
try:
|
||||
await migrate_extension_database(ext, current_version)
|
||||
@@ -147,16 +154,41 @@ async def migrate_databases():
|
||||
|
||||
|
||||
@db.command("versions")
|
||||
def database_versions():
|
||||
"""Show current database versions"""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(db_versions())
|
||||
|
||||
|
||||
@coro
|
||||
async def db_versions():
|
||||
"""Show current database versions"""
|
||||
async with core_db.connect() as conn:
|
||||
return await get_dbversions(conn)
|
||||
click.echo(await get_dbversions(conn))
|
||||
|
||||
|
||||
@db.command("cleanup-wallets")
|
||||
@click.argument("days", type=int, required=False)
|
||||
@coro
|
||||
async def database_cleanup_wallets(days: Optional[int] = None):
|
||||
"""Delete all wallets that never had any transaction"""
|
||||
async with core_db.connect() as conn:
|
||||
delta = days or settings.cleanup_wallets_days
|
||||
delta = delta * 24 * 60 * 60
|
||||
await delete_unused_wallets(delta, conn)
|
||||
|
||||
|
||||
@db.command("cleanup-deleted-wallets")
|
||||
@coro
|
||||
async def database_cleanup_deleted_wallets():
|
||||
"""Delete all wallets that has been marked deleted"""
|
||||
async with core_db.connect() as conn:
|
||||
await remove_deleted_wallets(conn)
|
||||
|
||||
|
||||
@db.command("cleanup-accounts")
|
||||
@click.argument("days", type=int, required=False)
|
||||
@coro
|
||||
async def database_cleanup_accounts(days: Optional[int] = None):
|
||||
"""Delete all accounts that have no wallets"""
|
||||
async with core_db.connect() as conn:
|
||||
delta = days or settings.cleanup_wallets_days
|
||||
delta = delta * 24 * 60 * 60
|
||||
await delete_accounts_no_wallets(delta, conn)
|
||||
|
||||
|
||||
async def load_disabled_extension_list() -> None:
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter
|
||||
from .db import core_app_extra, db
|
||||
from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
from .views.auth_api import auth_router
|
||||
|
||||
# this compat is needed for usermanager extension
|
||||
from .views.generic import generic_router, update_user_extension
|
||||
@@ -26,3 +27,4 @@ def init_core_routers(app):
|
||||
app.include_router(admin_router)
|
||||
app.include_router(tinyurl_router)
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import datetime
|
||||
import json
|
||||
from time import time
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import shortuuid
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import WalletType
|
||||
@@ -20,11 +22,14 @@ from lnbits.settings import (
|
||||
|
||||
from .models import (
|
||||
BalanceCheck,
|
||||
CreateUser,
|
||||
Payment,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
TinyURL,
|
||||
UpdateUserPassword,
|
||||
User,
|
||||
UserConfig,
|
||||
Wallet,
|
||||
WebPushSubscription,
|
||||
)
|
||||
@@ -33,8 +38,48 @@ from .models import (
|
||||
# --------
|
||||
|
||||
|
||||
async def create_user(
|
||||
data: CreateUser, user_config: Optional[UserConfig] = None
|
||||
) -> User:
|
||||
if not settings.new_accounts_allowed:
|
||||
raise ValueError("Account creation is disabled.")
|
||||
if await get_account_by_username(data.username):
|
||||
raise ValueError("Username already exists.")
|
||||
|
||||
if data.email and await get_account_by_email(data.email):
|
||||
raise ValueError("Email already exists.")
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
user_id = uuid4().hex
|
||||
tsph = db.timestamp_placeholder
|
||||
now = int(time())
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO accounts
|
||||
(id, email, username, pass, extra, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, {tsph}, {tsph})
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
data.email,
|
||||
data.username,
|
||||
pwd_context.hash(data.password),
|
||||
json.dumps(dict(user_config)) if user_config else "{}",
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
new_account = await get_account(user_id=user_id)
|
||||
assert new_account, "Newly created account couldn't be retrieved"
|
||||
return new_account
|
||||
|
||||
|
||||
async def create_account(
|
||||
conn: Optional[Connection] = None, user_id: Optional[str] = None
|
||||
conn: Optional[Connection] = None,
|
||||
user_id: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
user_config: Optional[UserConfig] = None,
|
||||
) -> User:
|
||||
if user_id:
|
||||
user_uuid4 = UUID(hex=user_id, version=4)
|
||||
@@ -42,7 +87,15 @@ async def create_account(
|
||||
else:
|
||||
user_id = uuid4().hex
|
||||
|
||||
await (conn or db).execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
|
||||
extra = json.dumps(dict(user_config)) if user_config else "{}"
|
||||
now = int(time())
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
INSERT INTO accounts (id, email, extra, created_at, updated_at)
|
||||
VALUES (?, ?, ?, {db.timestamp_placeholder}, {db.timestamp_placeholder})
|
||||
""",
|
||||
(user_id, email, extra, now, now),
|
||||
)
|
||||
|
||||
new_account = await get_account(user_id=user_id, conn=conn)
|
||||
assert new_account, "Newly created account couldn't be retrieved"
|
||||
@@ -50,19 +103,176 @@ async def create_account(
|
||||
return new_account
|
||||
|
||||
|
||||
async def update_account(
|
||||
user_id: str,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
user_config: Optional[UserConfig] = None,
|
||||
) -> Optional[User]:
|
||||
user = await get_account(user_id)
|
||||
assert user, "User not found"
|
||||
|
||||
if email:
|
||||
assert not user.email or email == user.email, "Cannot change email."
|
||||
account = await get_account_by_email(email)
|
||||
assert not account or account.id == user_id, "Email already in use."
|
||||
|
||||
if username:
|
||||
assert not user.username or username == user.username, "Cannot change username."
|
||||
account = await get_account_by_username(username)
|
||||
assert not account or account.id == user_id, "Username already in exists."
|
||||
|
||||
username = user.username or username
|
||||
email = user.email or email
|
||||
extra = user_config or user.config
|
||||
|
||||
now = int(time())
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE accounts SET (username, email, extra, updated_at) =
|
||||
(?, ?, ?, {db.timestamp_placeholder})
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
username,
|
||||
email,
|
||||
json.dumps(dict(extra)) if extra else "{}",
|
||||
now,
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
|
||||
user = await get_user(user_id)
|
||||
assert user, "Updated account couldn't be retrieved"
|
||||
return user
|
||||
|
||||
|
||||
async def get_account(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[User]:
|
||||
row = await (conn or db).fetchone(
|
||||
"SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,)
|
||||
"""
|
||||
SELECT id, email, username, created_at, updated_at, extra
|
||||
FROM accounts WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
user = User(**row) if row else None
|
||||
if user and row["extra"]:
|
||||
user.config = UserConfig(**json.loads(row["extra"]))
|
||||
return user
|
||||
|
||||
|
||||
async def delete_accounts_no_wallets(
|
||||
time_delta: int,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
DELETE FROM accounts
|
||||
WHERE NOT EXISTS (
|
||||
SELECT wallets.id FROM wallets WHERE wallets.user = accounts.id
|
||||
) AND updated_at < {db.timestamp_placeholder}
|
||||
""",
|
||||
(int(time()) - time_delta,),
|
||||
)
|
||||
|
||||
|
||||
async def get_user_password(user_id: str) -> Optional[str]:
|
||||
row = await db.fetchone(
|
||||
"SELECT pass FROM accounts WHERE id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return row[0]
|
||||
|
||||
|
||||
async def verify_user_password(user_id: str, password: str) -> bool:
|
||||
existing_password = await get_user_password(user_id)
|
||||
if not existing_password:
|
||||
return False
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
return pwd_context.verify(password, existing_password)
|
||||
|
||||
|
||||
# todo: , conn: Optional[Connection] = None ??
|
||||
async def update_user_password(data: UpdateUserPassword) -> Optional[User]:
|
||||
assert data.password == data.password_repeat, "Passwords do not match."
|
||||
|
||||
# old accounts do not have a pasword
|
||||
if await get_user_password(data.user_id):
|
||||
assert data.password_old, "Missing old password"
|
||||
old_pwd_ok = await verify_user_password(data.user_id, data.password_old)
|
||||
assert old_pwd_ok, "Invalid credentials."
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
now = int(time())
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE accounts SET pass = ?, updated_at = {db.timestamp_placeholder}
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
pwd_context.hash(data.password),
|
||||
now,
|
||||
data.user_id,
|
||||
),
|
||||
)
|
||||
|
||||
user = await get_user(data.user_id)
|
||||
assert user, "Updated account couldn't be retrieved"
|
||||
return user
|
||||
|
||||
|
||||
async def get_account_by_username(
|
||||
username: str, conn: Optional[Connection] = None
|
||||
) -> Optional[User]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT id, username, email, created_at, updated_at
|
||||
FROM accounts WHERE username = ?
|
||||
""",
|
||||
(username,),
|
||||
)
|
||||
|
||||
return User(**row) if row else None
|
||||
|
||||
|
||||
async def get_account_by_email(
|
||||
email: str, conn: Optional[Connection] = None
|
||||
) -> Optional[User]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT id, username, email, created_at, updated_at
|
||||
FROM accounts WHERE email = ?
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
|
||||
return User(**row) if row else None
|
||||
|
||||
|
||||
async def get_account_by_username_or_email(
|
||||
username_or_email: str, conn: Optional[Connection] = None
|
||||
) -> Optional[User]:
|
||||
user = await get_account_by_username(username_or_email, conn)
|
||||
if not user:
|
||||
user = await get_account_by_email(username_or_email, conn)
|
||||
return user
|
||||
|
||||
|
||||
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
|
||||
user = await (conn or db).fetchone(
|
||||
"SELECT id, email FROM accounts WHERE id = ?", (user_id,)
|
||||
"""
|
||||
SELECT id, email, username, pass, extra, created_at, updated_at
|
||||
FROM accounts WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
if user:
|
||||
@@ -86,6 +296,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||
return User(
|
||||
id=user["id"],
|
||||
email=user["email"],
|
||||
username=user["username"],
|
||||
extensions=[
|
||||
e[0] for e in extensions if User.is_extension_for_user(e[0], user["id"])
|
||||
],
|
||||
@@ -93,6 +304,8 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||
admin=user["id"] == settings.super_user
|
||||
or user["id"] in settings.lnbits_admin_users,
|
||||
super_user=user["id"] == settings.super_user,
|
||||
has_password=True if user["pass"] else False,
|
||||
config=UserConfig(**json.loads(user["extra"])) if user["extra"] else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -233,10 +446,11 @@ async def create_wallet(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Wallet:
|
||||
wallet_id = uuid4().hex
|
||||
now = int(time())
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO wallets (id, name, "user", adminkey, inkey)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
f"""
|
||||
INSERT INTO wallets (id, name, "user", adminkey, inkey, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, {db.timestamp_placeholder}, {db.timestamp_placeholder})
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
@@ -244,6 +458,8 @@ async def create_wallet(
|
||||
user_id,
|
||||
uuid4().hex,
|
||||
uuid4().hex,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -260,7 +476,10 @@ async def update_wallet(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[Wallet]:
|
||||
set_clause = []
|
||||
values = []
|
||||
values: list = []
|
||||
set_clause.append(f"updated_at = {db.timestamp_placeholder}")
|
||||
now = int(time())
|
||||
values.append(now)
|
||||
if name:
|
||||
set_clause.append("name = ?")
|
||||
values.append(name)
|
||||
@@ -282,13 +501,33 @@ async def update_wallet(
|
||||
async def delete_wallet(
|
||||
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
now = int(time())
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = true
|
||||
SET deleted = true, updated_at = {db.timestamp_placeholder}
|
||||
WHERE id = ? AND "user" = ?
|
||||
""",
|
||||
(wallet_id, user_id),
|
||||
(now, wallet_id, user_id),
|
||||
)
|
||||
|
||||
|
||||
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
|
||||
|
||||
|
||||
async def delete_unused_wallets(
|
||||
time_delta: int,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
DELETE FROM wallets
|
||||
WHERE (
|
||||
SELECT COUNT(*) FROM apipayments WHERE wallet = wallets.id
|
||||
) = 0 AND updated_at < {db.timestamp_placeholder}
|
||||
""",
|
||||
(int(time()) - time_delta,),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import importlib
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
@@ -48,24 +48,23 @@ async def run_migration(
|
||||
await update_migration_version(conn, db_name, version)
|
||||
|
||||
|
||||
async def stop_extension_background_work(ext_id: str, user: str):
|
||||
async def stop_extension_background_work(
|
||||
ext_id: str, user: str, access_token: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extensions SHOULD expose a DELETE enpoint at the root level of their API.
|
||||
This function tries first to call the endpoint using `http`
|
||||
and if it fails it tries using `https`.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
|
||||
await client.delete(url)
|
||||
headers = (
|
||||
{"Authorization": "Bearer " + access_token} if access_token else None
|
||||
)
|
||||
resp = await client.delete(url=url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
try:
|
||||
# try https
|
||||
url = f"https://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
|
||||
def to_valid_user_id(user_id: str) -> UUID:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
from time import time
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import OperationalError
|
||||
@@ -393,3 +394,80 @@ async def m015_create_push_notification_subscriptions_table(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m016_add_username_column_to_accounts(db):
|
||||
"""
|
||||
Adds username column to accounts.
|
||||
"""
|
||||
try:
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN username TEXT")
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN extra TEXT")
|
||||
except OperationalError:
|
||||
pass
|
||||
|
||||
|
||||
async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
||||
"""
|
||||
Adds created_at and updated_at column to accounts and wallets.
|
||||
"""
|
||||
try:
|
||||
await db.execute(
|
||||
"ALTER TABLE accounts "
|
||||
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE accounts "
|
||||
f"ADD COLUMN updated_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE wallets "
|
||||
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE wallets "
|
||||
f"ADD COLUMN updated_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
|
||||
)
|
||||
|
||||
# # set their wallets created_at with the first payment
|
||||
# await db.execute(
|
||||
# """
|
||||
# UPDATE wallets SET created_at = (
|
||||
# SELECT time FROM apipayments
|
||||
# WHERE apipayments.wallet = wallets.id
|
||||
# ORDER BY time ASC LIMIT 1
|
||||
# )
|
||||
# """
|
||||
# )
|
||||
|
||||
# # then set their accounts created_at with the wallet
|
||||
# await db.execute(
|
||||
# """
|
||||
# UPDATE accounts SET created_at = (
|
||||
# SELECT created_at FROM wallets
|
||||
# WHERE wallets.user = accounts.id
|
||||
# ORDER BY created_at ASC LIMIT 1
|
||||
# )
|
||||
# """
|
||||
# )
|
||||
|
||||
# set all to now where they are null
|
||||
now = int(time())
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE wallets SET created_at = {db.timestamp_placeholder}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
(now,),
|
||||
)
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE accounts SET created_at = {db.timestamp_placeholder}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
(now,),
|
||||
)
|
||||
|
||||
except OperationalError as exc:
|
||||
logger.error(f"Migration 17 failed: {exc}")
|
||||
pass
|
||||
|
||||
@@ -30,6 +30,8 @@ class Wallet(BaseModel):
|
||||
currency: Optional[str]
|
||||
balance_msat: int
|
||||
deleted: bool
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
@@ -79,14 +81,31 @@ class WalletTypeInfo:
|
||||
wallet: Wallet
|
||||
|
||||
|
||||
class UserConfig(BaseModel):
|
||||
email_verified: Optional[bool] = False
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
picture: Optional[str] = None
|
||||
# Auth provider, possible values:
|
||||
# - "env": the user was created automatically by the system
|
||||
# - "lnbits": the user was created via register form (username/pass or user_id only)
|
||||
# - "google | github | ...": the user was created using an SSO provider
|
||||
provider: Optional[str] = "lnbits" # auth provider
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
extensions: List[str] = []
|
||||
wallets: List[Wallet] = []
|
||||
password: Optional[str] = None
|
||||
admin: bool = False
|
||||
super_user: bool = False
|
||||
has_password: bool = False
|
||||
config: Optional[UserConfig] = None
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> List[str]:
|
||||
@@ -107,6 +126,43 @@ class User(BaseModel):
|
||||
return False
|
||||
|
||||
|
||||
class CreateUser(BaseModel):
|
||||
email: Optional[str] = Query(default=None)
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
|
||||
|
||||
class UpdateUser(BaseModel):
|
||||
user_id: str
|
||||
email: Optional[str] = Query(default=None)
|
||||
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
|
||||
config: Optional[UserConfig] = None
|
||||
|
||||
|
||||
class UpdateUserPassword(BaseModel):
|
||||
user_id: str
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
|
||||
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
|
||||
|
||||
|
||||
class UpdateSuperuserPassword(BaseModel):
|
||||
username: str = Query(default=..., min_length=2, max_length=20)
|
||||
password: str = Query(default=..., min_length=8, max_length=50)
|
||||
password_repeat: str = Query(default=..., min_length=8, max_length=50)
|
||||
|
||||
|
||||
class LoginUsr(BaseModel):
|
||||
usr: str
|
||||
|
||||
|
||||
class LoginUsernamePassword(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class Payment(FromRowModel):
|
||||
checking_id: str
|
||||
pending: bool
|
||||
@@ -323,6 +379,7 @@ class CreateLnurl(BaseModel):
|
||||
amount: int
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
|
||||
|
||||
class CreateInvoice(BaseModel):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, TypedDict
|
||||
@@ -41,6 +42,7 @@ from .crud import (
|
||||
create_wallet,
|
||||
delete_wallet_payment,
|
||||
get_account,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
get_super_settings,
|
||||
get_total_balance,
|
||||
@@ -52,7 +54,7 @@ from .crud import (
|
||||
update_super_user,
|
||||
)
|
||||
from .helpers import to_valid_user_id
|
||||
from .models import Payment, Wallet
|
||||
from .models import Payment, UserConfig, Wallet
|
||||
|
||||
|
||||
class PaymentFailure(Exception):
|
||||
@@ -118,8 +120,9 @@ async def create_invoice(
|
||||
if not amount > 0:
|
||||
raise InvoiceFailure("Amountless invoices not supported.")
|
||||
|
||||
if await get_wallet(wallet_id, conn=conn) is None:
|
||||
raise InvoiceFailure("Wallet does not exist.")
|
||||
user_wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not user_wallet:
|
||||
raise InvoiceFailure(f"Could not fetch wallet '{wallet_id}'.")
|
||||
|
||||
invoice_memo = None if description_hash else memo
|
||||
|
||||
@@ -130,6 +133,14 @@ async def create_invoice(
|
||||
amount, wallet_id, currency=currency, extra=extra, conn=conn
|
||||
)
|
||||
|
||||
if settings.is_wallet_max_balance_exceeded(
|
||||
user_wallet.balance_msat / 1000 + amount_sat
|
||||
):
|
||||
raise InvoiceFailure(
|
||||
f"Wallet balance cannot exceed "
|
||||
f"{settings.lnbits_wallet_limit_max_balance} sats."
|
||||
)
|
||||
|
||||
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
@@ -185,6 +196,8 @@ async def pay_invoice(
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
temp_id = invoice.payment_hash
|
||||
internal_id = f"internal_{invoice.payment_hash}"
|
||||
@@ -372,6 +385,58 @@ async def pay_invoice(
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
async def check_wallet_limits(wallet_id, conn, amount_msat):
|
||||
await check_time_limit_between_transactions(conn, wallet_id)
|
||||
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
|
||||
|
||||
|
||||
async def check_time_limit_between_transactions(conn, wallet_id):
|
||||
limit = settings.lnbits_wallet_limit_secs_between_trans
|
||||
if not limit or limit <= 0:
|
||||
return
|
||||
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - limit,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
if len(payments) == 0:
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"The time limit of {limit} seconds between payments has been reached."
|
||||
)
|
||||
|
||||
|
||||
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
|
||||
limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
||||
if not limit or limit <= 0:
|
||||
return
|
||||
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - 60 * 60 * 24,
|
||||
outgoing=True,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
if len(payments) == 0:
|
||||
return
|
||||
|
||||
total = 0
|
||||
for pay in payments:
|
||||
total += pay.amount
|
||||
total = total - amount_msat
|
||||
if limit * 1000 + total < 0:
|
||||
raise ValueError(
|
||||
"Daily withdrawal limit of "
|
||||
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
|
||||
+ " sats reached."
|
||||
)
|
||||
|
||||
|
||||
async def redeem_lnurl_withdraw(
|
||||
wallet_id: str,
|
||||
lnurl_request: str,
|
||||
@@ -611,6 +676,10 @@ async def check_admin_settings():
|
||||
):
|
||||
send_admin_user_to_saas()
|
||||
|
||||
account = await get_account(settings.super_user)
|
||||
if account and account.config and account.config.provider == "env":
|
||||
settings.first_install = True
|
||||
|
||||
logger.success(
|
||||
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
|
||||
"to get the superuser."
|
||||
@@ -656,7 +725,9 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
|
||||
if super_user:
|
||||
account = await get_account(super_user)
|
||||
if not account:
|
||||
account = await create_account(user_id=super_user)
|
||||
account = await create_account(
|
||||
user_id=super_user, user_config=UserConfig(provider="env")
|
||||
)
|
||||
if not account.wallets or len(account.wallets) == 0:
|
||||
await create_wallet(user_id=account.id)
|
||||
|
||||
|
||||
@@ -188,6 +188,8 @@ async def send_payment_push_notification(payment: Payment):
|
||||
body += f"\r\n{payment.memo}"
|
||||
|
||||
for subscription in subscriptions:
|
||||
# todo: review permissions when user-id-only not allowed
|
||||
# todo: replace all this logic with websockets?
|
||||
url = (
|
||||
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
|
||||
)
|
||||
|
||||
@@ -85,64 +85,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h6 class="q-mt-xl q-mb-md">Service Fees</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service Fee</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee"
|
||||
label="Service fee (%)"
|
||||
step="0.1"
|
||||
hint="Fee charged per tx (%)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service fee max</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee_max"
|
||||
label="Service fee max (sats)"
|
||||
hint="Max service fee to charge in (sats)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Fee Wallet</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_service_fee_wallet"
|
||||
label="Fee wallet (wallet ID)"
|
||||
hint="Wallet ID to send funds to"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Disable Service Fee for Internal Payments</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Fee</q-item-label>
|
||||
<q-item-label caption
|
||||
>Disable Service Fee for Internal Lightning
|
||||
Payments</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_service_fee_ignore_internal"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<lnbits-funding-sources
|
||||
:form-data="formData"
|
||||
|
||||
@@ -1,7 +1,87 @@
|
||||
<q-tab-panel name="security">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none" v-text="$t('security_tools')"></h6>
|
||||
<h6 class="q-my-none q-mb-sm">Authentication</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.auth_token_expire_minutes"
|
||||
type="number"
|
||||
label="Token expire minutes"
|
||||
hint="Time in minutes until the token expires"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.auth_allowed_methods"
|
||||
multiple
|
||||
hint="Allowed authorization methods"
|
||||
label="Select authorization methods"
|
||||
:options="formData.auth_all_methods"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="formData.auth_allowed_methods?.includes('google-auth')"
|
||||
class="q-pl-xl"
|
||||
>
|
||||
<strong class="q-my-none q-mb-sm">Google Auth</strong>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.google_client_id"
|
||||
label="Google Client ID"
|
||||
hint="Make sure thant the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.google_client_secret"
|
||||
type="password"
|
||||
label="Google Client Secret"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="formData.auth_allowed_methods?.includes('github-auth')"
|
||||
class="q-pl-xl"
|
||||
>
|
||||
<strong class="q-my-none q-mb-sm">GitHub Auth</strong>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.github_client_id"
|
||||
label="GitHub Client ID"
|
||||
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/github/token"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.github_client_secret"
|
||||
type="password"
|
||||
label="GitHub Client Secret"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="q-pa-none">
|
||||
<br />
|
||||
<h6 class="q-my-none" v-text="$t('security_tools')"></h6>
|
||||
<div>
|
||||
<div class="row">
|
||||
<div v-if="serverlogEnabled" class="column" style="width: 100%">
|
||||
@@ -100,6 +180,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('rate_limiter')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
@@ -121,6 +202,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('wallet_limiter')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wallet_limit_max_balance"
|
||||
:label="$t('wallet_max_ballance')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
|
||||
:label="$t('wallet_limit_max_withdraw_per_day')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
|
||||
:label="$t('wallet_limit_secs_between_trans')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
@@ -80,7 +80,67 @@
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<h6 class="q-my-none">Service Fee</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service Fee</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee"
|
||||
label="Service fee (%)"
|
||||
step="0.1"
|
||||
hint="Fee charged per tx (%)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service fee max</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee_max"
|
||||
label="Service fee max (sats)"
|
||||
hint="Max service fee to charge in (sats)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Fee Wallet</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_service_fee_wallet"
|
||||
label="Fee wallet (wallet ID)"
|
||||
hint="Wallet ID to send funds to"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Disable Service Fee for Internal Payments</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Fee</q-item-label>
|
||||
<q-item-label caption
|
||||
>Disable Service Fee for Internal Lightning
|
||||
Payments</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_service_fee_ignore_internal"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="q-my-none">Extensions</h6>
|
||||
<div>
|
||||
<p>Extension Sources</p>
|
||||
<q-input
|
||||
|
||||
@@ -1,62 +1,78 @@
|
||||
<q-tab-panel name="users">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">User Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<p>Admin Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddAdmin"
|
||||
@keydown.enter="addAdminUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Users with admin privileges"
|
||||
>
|
||||
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_admin_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
<h6 class="q-my-none q-mb-sm">User Management</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<p>Admin Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddAdmin"
|
||||
@keydown.enter="addAdminUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Users with admin privileges"
|
||||
>
|
||||
{{ user }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_admin_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="user"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div>
|
||||
<p>Allowed Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Only these users can use LNbits"
|
||||
>
|
||||
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{% raw %}
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_allowed_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<p>Allowed Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Only these users can use LNbits"
|
||||
>
|
||||
{{ user }}
|
||||
</q-chip>
|
||||
{% endraw %}
|
||||
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_allowed_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
v-text="user"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
<br />
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Allow creation of new users</q-item-label>
|
||||
<q-item-label caption
|
||||
>Allow creation of new users on the index page</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_allow_new_accounts"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
:label="$t('api_docs')"
|
||||
icon="vpn_key"
|
||||
:label="$t('api_keys_api_docs')"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
{% extends "base.html" %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/account.js') }}"></script>
|
||||
{% endblock %} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div v-if="user" class="col-12 col-md-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-tabs v-model="tab" align="justify">
|
||||
<q-tab
|
||||
name="user"
|
||||
:label="$t('account_settings')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="theme"
|
||||
:label="$t('look_and_feel')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
<q-tab-panels v-model="tab">
|
||||
<q-tab-panel name="user">
|
||||
<div v-if="passwordData.show">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h4 class="q-my-none">
|
||||
<span v-text="$t('password_config')"></span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-img
|
||||
v-if="user.config.picture"
|
||||
style="max-width: 100px"
|
||||
:src="user.config.picture"
|
||||
class="float-right"
|
||||
></q-img>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
v-if="user.has_password"
|
||||
v-model="passwordData.oldPassword"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
label="Old Password"
|
||||
filled
|
||||
dense
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-model="passwordData.newPassword"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
filled
|
||||
dense
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-model="passwordData.newPasswordRepeat"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="q-pb-lg">
|
||||
<q-btn
|
||||
@click="updatePassword"
|
||||
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="$t('change_password')"
|
||||
>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@click="passwordData.show = false"
|
||||
:label="$t('back')"
|
||||
outline
|
||||
unelevated
|
||||
color="grey"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-img
|
||||
v-if="user.config.picture"
|
||||
style="max-width: 100px"
|
||||
:src="user.config.picture"
|
||||
class="float-right"
|
||||
></q-img>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-section>
|
||||
<q-input
|
||||
v-model="user.id"
|
||||
:label="$t('user_id')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
:type="showUserId ? 'text': 'password'"
|
||||
class="q-mb-md"
|
||||
><q-btn
|
||||
@click="showUserId = !showUserId"
|
||||
dense
|
||||
flat
|
||||
:icon="showUserId ? 'visibility_off' : 'visibility'"
|
||||
color="grey"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.username"
|
||||
:label="$t('username')"
|
||||
filled
|
||||
dense
|
||||
:readonly="hasUsername"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.email"
|
||||
:label="$t('email')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<div v-if="!user.email" class="row"></div>
|
||||
<div v-if="!user.email" class="row">
|
||||
{% if "google-auth" in LNBITS_AUTH_METHODS or
|
||||
"github-auth" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col q-pa-sm text-h6">
|
||||
<span v-text="$t('verify_email')"></span>:
|
||||
</div>
|
||||
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col q-pa-sm">
|
||||
<q-btn
|
||||
:href="`/api/v1/auth/google?user_id=${user.id}`"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
rounded
|
||||
color="grey"
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div>Google</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
{%endif%} {% if "github-auth" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col q-pa-sm">
|
||||
<q-btn
|
||||
:href="`/api/v1/auth/github?user_id=${user.id}`"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
color="grey"
|
||||
rounded
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div>GitHub</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
{%endif%}
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="user.config">
|
||||
<q-input
|
||||
v-model="user.config.first_name"
|
||||
:label="$t('first_name')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.config.last_name"
|
||||
:label="$t('last_name')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.config.provider"
|
||||
:label="$t('auth_provider')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.config.picture"
|
||||
:label="$t('picture')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section>
|
||||
<q-btn @click="updateAccount" unelevated color="primary">
|
||||
<span v-text="$t('update_account')"></span>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@click="showChangePassword()"
|
||||
:label="user.has_password ? $t('change_password'): $t('set_password')"
|
||||
outline
|
||||
unelevated
|
||||
color="grey"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="theme">
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4"><span v-text="$t('language')"></span></div>
|
||||
<div class="col-8">
|
||||
<q-btn-dropdown
|
||||
dense
|
||||
flat
|
||||
round
|
||||
size="sm"
|
||||
icon="language"
|
||||
class="q-pl-md"
|
||||
>
|
||||
<q-list v-for="(lang, index) in g.langs" :key="index">
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
:active="activeLanguage(lang.value)"
|
||||
@click="changeLanguage(lang.value)"
|
||||
><q-item-section>
|
||||
<q-item-label
|
||||
v-text="lang.display ?? lang.value.toUpperCase()"
|
||||
></q-item-label>
|
||||
<q-tooltip
|
||||
><span v-text="lang.label"></span
|
||||
></q-tooltip>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('color_scheme')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('classic')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('classic')"
|
||||
icon="circle"
|
||||
color="deep-purple"
|
||||
size="md"
|
||||
><q-tooltip>classic</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('bitcoin')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('bitcoin')"
|
||||
icon="circle"
|
||||
color="orange"
|
||||
size="md"
|
||||
><q-tooltip>bitcoin</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('mint')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('mint')"
|
||||
icon="circle"
|
||||
color="green"
|
||||
size="md"
|
||||
><q-tooltip>mint</q-tooltip> </q-btn
|
||||
><q-btn
|
||||
v-if="g.allowedThemes.includes('autumn')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('autumn')"
|
||||
icon="circle"
|
||||
color="brown"
|
||||
size="md"
|
||||
><q-tooltip>autumn</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('monochrome')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('monochrome')"
|
||||
icon="circle"
|
||||
color="grey"
|
||||
size="md"
|
||||
><q-tooltip>monochrome</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('salvador')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('salvador')"
|
||||
icon="circle"
|
||||
color="blue-10"
|
||||
size="md"
|
||||
><q-tooltip>elSalvador</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('freedom')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('freedom')"
|
||||
icon="circle"
|
||||
color="pink-13"
|
||||
size="md"
|
||||
><q-tooltip>freedom</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('cyber')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('cyber')"
|
||||
icon="circle"
|
||||
color="light-green-9"
|
||||
size="md"
|
||||
><q-tooltip>cyber</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('flamingo')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('flamingo')"
|
||||
icon="circle"
|
||||
color="pink-3"
|
||||
size="md"
|
||||
><q-tooltip>flamingo</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('toggle_darkmode')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
@click="toggleDarkMode"
|
||||
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
|
||||
size="sm"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('toggle_darkmode')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">Notifications</div>
|
||||
<div class="col-8">
|
||||
<lnbits-notifications-btn
|
||||
v-if="g.user"
|
||||
pubkey="{{ WEBPUSH_PUBKEY }}"
|
||||
></lnbits-notifications-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('payment_reactions')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="reactionChoice"
|
||||
:options="reactionOptions"
|
||||
label="Reactions"
|
||||
@input="reactionChoiceFunc"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('payment_reactions')"></span
|
||||
></q-tooltip>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else class="col-12 col-md-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -185,7 +185,7 @@
|
||||
flat
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="[extension.id, '/?usr=', g.user.id].join('')"
|
||||
:href="extension.id + '/'"
|
||||
>{%raw%}{{ $t('open') }}{%endraw%}</q-btn
|
||||
>
|
||||
<q-btn
|
||||
@@ -195,7 +195,7 @@
|
||||
type="a"
|
||||
:href="['{{
|
||||
url_for('install.extensions')
|
||||
}}', '?usr=', g.user.id, '&disable=', extension.id].join('')"
|
||||
}}', '?disable=', extension.id].join('')"
|
||||
>
|
||||
{%raw%}{{ $t('disable') }}{%endraw%}</q-btn
|
||||
>
|
||||
@@ -209,7 +209,7 @@
|
||||
type="a"
|
||||
:href="['{{
|
||||
url_for('install.extensions')
|
||||
}}', '?usr=', g.user.id, '&enable=', extension.id].join('')"
|
||||
}}', '?enable=', extension.id].join('')"
|
||||
>
|
||||
{%raw%}{{ $t('enable') }}{%endraw%}
|
||||
<q-tooltip>
|
||||
@@ -511,7 +511,7 @@
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
`/api/v1/extension?usr=${this.g.user.id}`,
|
||||
`/api/v1/extension`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
ext_id: extension.id,
|
||||
@@ -542,7 +542,7 @@
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
`/api/v1/extension/${extension.id}?usr=${this.g.user.id}`,
|
||||
`/api/v1/extension/${extension.id}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
@@ -576,7 +576,7 @@
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
`/api/v1/extension/${extension.id}/db?usr=${this.g.user.id}`,
|
||||
`/api/v1/extension/${extension.id}/db`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
@@ -598,9 +598,8 @@
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
"{{ url_for('install.extensions') }}?usr=" +
|
||||
this.g.user.id +
|
||||
'&' +
|
||||
"{{ url_for('install.extensions') }}" +
|
||||
'?' +
|
||||
action +
|
||||
'=' +
|
||||
extension.id
|
||||
@@ -629,7 +628,7 @@
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/${extension.id}/releases?usr=${this.g.user.id}`,
|
||||
`/api/v1/extension/${extension.id}/releases`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
@@ -694,7 +693,7 @@
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/release/${org}/${repo}/${release.version}?usr=${this.g.user.id}`,
|
||||
`/api/v1/extension/release/${org}/${repo}/${release.version}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
release.loaded = true
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center main">
|
||||
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section class="grid">
|
||||
<div>
|
||||
<h6 class="q-my-none text-center">
|
||||
<strong>Welcome to LNbits</strong>
|
||||
<p>Set up the Superuser account below.</p>
|
||||
</h6>
|
||||
<br />
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
v-model="loginData.username"
|
||||
:label="$t('username')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.password"
|
||||
:type="loginData.isPwd ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwd = !loginData.isPwd"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.passwordRepeat"
|
||||
:type="loginData.isPwdRepeat ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || 'Passwords_dont_match']"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-btn
|
||||
@click="setPassword()"
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="$t('login')"
|
||||
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
|
||||
></q-btn>
|
||||
</q-form>
|
||||
</div>
|
||||
<div class="hero-wrapper">
|
||||
<div class="hero q-mx-auto"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.grid {
|
||||
display: block;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.hero {
|
||||
display: block;
|
||||
height: 100%;
|
||||
max-width: 250px;
|
||||
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
loginData: {
|
||||
isPwd: true,
|
||||
isPwdRepeat: true,
|
||||
username: '',
|
||||
password: '',
|
||||
passwordRepeat: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
|
||||
},
|
||||
computed: {
|
||||
checkPasswordsMatch() {
|
||||
return this.loginData.password !== this.loginData.passwordRepeat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async setPassword() {
|
||||
try {
|
||||
await LNbits.api.request('PUT', '/api/v1/auth/first_install', null, {
|
||||
username: this.loginData.username,
|
||||
password: this.loginData.password,
|
||||
password_repeat: this.loginData.passwordRepeat
|
||||
})
|
||||
|
||||
window.location.href = '/admin'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -2,77 +2,323 @@
|
||||
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
{% if lnurl or LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
{% if lnurl %}
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
></q-btn>
|
||||
{% elif LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||
<q-form @submit="createWallet" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="walletName"
|
||||
:label='$t("name_your_wallet", { name: "{{ SITE_TITLE }} *" })'
|
||||
></q-input>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="walletName == ''"
|
||||
type="submit"
|
||||
:label="$t('add_wallet')"
|
||||
></q-btn>
|
||||
</q-form>
|
||||
{% endif %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endif %}
|
||||
<div
|
||||
v-if="isUserAuthorized"
|
||||
class="col-12 col-md-6 col-lg-6 q-gutter-y-md"
|
||||
></div>
|
||||
<div v-else class="col-12 col-md-4 col-lg-4 q-gutter-y-md">
|
||||
<div class="gt-sm">
|
||||
<h3 class="q-my-none" v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
||||
{{SITE_TITLE}}
|
||||
</h3>
|
||||
<h5 class="q-my-md" v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
||||
{{SITE_TAGLINE}}
|
||||
</h5>
|
||||
</div>
|
||||
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only" in
|
||||
LNBITS_AUTH_METHODS)%}
|
||||
<div class="row q-mt-xl">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
v-text="$t('press_to_claim')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{%else%} {% endif %}
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
|
||||
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
|
||||
<div v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
||||
<p v-text="$t('lnbits_description')"></p>
|
||||
<div class="row q-mt-md q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://github.com/lnbits/lnbits"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('view_github')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('donate')"
|
||||
></q-btn>
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-12 col-md-10 col-lg-10 q-gutter-y-md">
|
||||
<q-badge v-if="isAccessTokenExpired" color="primary" rounded>
|
||||
<div class="text-h5">
|
||||
<span v-text="$t('session_has_expired')"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-html="formatDescription"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-badge>
|
||||
<q-card class="shadow-12">
|
||||
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('instant_access_question')"></span>
|
||||
<br />
|
||||
<q-badge
|
||||
@click="showLogin('user-id-only')"
|
||||
color="primary"
|
||||
class="cursor-pointer"
|
||||
rounded
|
||||
>
|
||||
<strong>
|
||||
<q-icon name="account_circle" size="xs"></q-icon>
|
||||
<span v-text="$t('login_with_user_id')"></span> </strong
|
||||
></q-badge>
|
||||
{% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||
<span v-text="$t('or')"></span>
|
||||
<q-badge
|
||||
@click="showRegister('user-id-only')"
|
||||
color="primary"
|
||||
class="cursor-pointer"
|
||||
rounded
|
||||
>
|
||||
<strong>
|
||||
<q-icon name="add" size="xs"></q-icon>
|
||||
<span v-text="$t('create_new_wallet')"></span>
|
||||
</strong>
|
||||
</q-badge>
|
||||
{%endif%}
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section
|
||||
v-if="authAction === 'login' && authMethod === 'user-id-only'"
|
||||
>
|
||||
<b> <span v-text="$t('login_with_user_id')"></span> </b><br /><br />
|
||||
<q-form @submit="loginUsr" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="usr"
|
||||
label="usr"
|
||||
type="password"
|
||||
></q-input>
|
||||
<div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="usr == ''"
|
||||
type="submit"
|
||||
label="Login"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
{%endif%} {% if "username-password" in LNBITS_AUTH_METHODS %}
|
||||
<q-card-section
|
||||
v-if="authAction === 'login' && authMethod === 'username-password'"
|
||||
>
|
||||
<div class="q-mb-lg">
|
||||
<strong>
|
||||
<span v-text="$t('login_to_account')"></span>
|
||||
</strong>
|
||||
</div>
|
||||
<q-form @submit="login" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="username"
|
||||
name="username"
|
||||
:label="$t('username_or_email') + ' *'"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="password"
|
||||
name="password"
|
||||
:label="$t('password') + ' *'"
|
||||
type="password"
|
||||
></q-input>
|
||||
<div>
|
||||
<q-btn
|
||||
:disable="!username || !password"
|
||||
color="primary"
|
||||
type="submit"
|
||||
:label="$t('login')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="authAction === 'register' && authMethod === 'username-password'"
|
||||
>
|
||||
<b> <span v-text="$t('create_account')"></span> </b><br /><br />
|
||||
<q-form @submit="register" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
required
|
||||
v-model="username"
|
||||
:label="$t('username') + ' *'"
|
||||
:rules="[(val) => validateUsername(val) || $t('invalid_username')]"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="password"
|
||||
:label="$t('password') + ' *'"
|
||||
type="password"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="passwordRepeat"
|
||||
:label="$t('password_repeat') + ' *'"
|
||||
type="password"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
></q-input>
|
||||
<div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!password || !passwordRepeat|| !username || (password !== passwordRepeat)"
|
||||
type="submit"
|
||||
class="full-width"
|
||||
:label="$t('create_account')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
{%endif%} {% if LNBITS_NEW_ACCOUNTS_ALLOWED %}
|
||||
<q-card-section
|
||||
v-if="authAction === 'register' && authMethod === 'user-id-only'"
|
||||
>
|
||||
<div>
|
||||
<q-form @submit="createWallet" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="walletName"
|
||||
:label='$t("name_your_wallet", { name: "{{ SITE_TITLE }} *" })'
|
||||
></q-input>
|
||||
<div>
|
||||
<q-btn
|
||||
color="primary"
|
||||
:disable="walletName == ''"
|
||||
type="submit"
|
||||
:label="$t('add_wallet')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</div>
|
||||
</q-card-section>
|
||||
{% if "username-password" in LNBITS_AUTH_METHODS %}
|
||||
<q-card-section
|
||||
v-if="authAction === 'login' && authMethod === 'username-password'"
|
||||
>
|
||||
<div>
|
||||
<q-btn
|
||||
color="grey"
|
||||
outline
|
||||
:label="$t('register')"
|
||||
class="full-width"
|
||||
@click="showRegister('username-password')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
{%endif%}
|
||||
<q-separator></q-separator>
|
||||
{% endif %}
|
||||
|
||||
<q-card-section
|
||||
v-if="authAction === 'login' && authMethod === 'username-password'"
|
||||
>
|
||||
<div class="row">
|
||||
{% if "google-auth" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col-12 full-width q-pa-sm">
|
||||
<q-btn
|
||||
href="/api/v1/auth/google"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
rounded
|
||||
color="grey"
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div>
|
||||
<span v-text="$t('signin_with_google')"></span>
|
||||
</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
{%endif%} {% if "github-auth" in LNBITS_AUTH_METHODS %}
|
||||
<div class="col-12 full-width q-pa-sm">
|
||||
<q-btn
|
||||
href="/api/v1/auth/github"
|
||||
type="a"
|
||||
outline
|
||||
no-caps
|
||||
color="grey"
|
||||
rounded
|
||||
class="full-width"
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div><span v-text="$t('signin_with_github')"></span></div>
|
||||
</q-btn>
|
||||
</div>
|
||||
{%endif%}
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section v-else>
|
||||
<div class="row">
|
||||
<div class="col q-pa-sm">
|
||||
<q-btn
|
||||
@click="showLogin('username-password')"
|
||||
:label="$t('back')"
|
||||
outline
|
||||
rounded
|
||||
color="grey"
|
||||
class="full-width"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ads -->
|
||||
<div class="col-12 col-md-3 col-lg-3" v-if="'{{SITE_TITLE}}' == 'LNbits'">
|
||||
<div
|
||||
v-if="'{{SITE_TITLE}}' != 'LNbits'"
|
||||
class="col-12 col-md-5 col-lg-5 q-pt-xl"
|
||||
>
|
||||
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
|
||||
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
|
||||
<div v-html="formatDescription"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-3 col-lg-3 gt-sm" v-else>
|
||||
<div class="row q-col-gutter-lg justify-center">
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://github.com/lnbits/lnbits"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('view_github')"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:label="$t('donate')"
|
||||
class="full-width q-mb-lg"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn
|
||||
flat
|
||||
@@ -228,24 +474,26 @@
|
||||
<div class="col q-pl-md"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn flat color="secondary" class="full-width q-mb-md"
|
||||
>{{ AD_SPACE_TITLE }}</q-btn
|
||||
>
|
||||
|
||||
<a href="{{ AD[0] }}" class="q-ma-md">
|
||||
<img
|
||||
v-if="($q.dark.isActive)"
|
||||
src="{{ AD[1] }}"
|
||||
style="max-width: 90%"
|
||||
/>
|
||||
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn flat color="secondary" class="full-width q-mb-md"
|
||||
>{{ AD_SPACE_TITLE }}</q-btn
|
||||
>
|
||||
|
||||
<a href="{{ AD[0] }}" class="q-ma-md">
|
||||
<img v-if="($q.dark.isActive)" src="{{ AD[1] }}" style="max-width: 90%" />
|
||||
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %} {% endif %}
|
||||
</div>
|
||||
<div v-if="'{{SITE_TITLE}}' == 'LNbits'" class="row gt-sm q-mt-xl">
|
||||
<div class="col-1"></div>
|
||||
<div class="col-10 q-pl-xl">
|
||||
<span v-text="$t('lnbits_description')"></span>
|
||||
</div>
|
||||
<div class="col-1"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,52 +15,57 @@
|
||||
{% elif HIDE_API %}
|
||||
<div class="col-12 q-gutter-y-md">
|
||||
{% else %}
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<div
|
||||
class="col-12 col-md-7 q-gutter-y-md"
|
||||
:style="$q.screen.lt.md ? {
|
||||
position: mobileSimple ? 'fixed !important': ''
|
||||
, top: mobileSimple ? '50% !important': ''
|
||||
, left: mobileSimple ? '50% !important': ''
|
||||
, transform: mobileSimple ? 'translate(-50%, -50%) !important': ''
|
||||
} : ''"
|
||||
>
|
||||
{% endif %}
|
||||
<q-card>
|
||||
<q-card
|
||||
:style="$q.screen.lt.md ? {
|
||||
background: $q.screen.lt.md ? 'none !important': ''
|
||||
, boxShadow: $q.screen.lt.md ? 'none !important': ''
|
||||
, margin: $q.screen.lt.md && mobileSimple ? 'auto !important': ''
|
||||
, width: $q.screen.lt.md && mobileSimple ? '90% !important': ''
|
||||
} : ''"
|
||||
>
|
||||
<q-card-section>
|
||||
<h3 class="q-my-none">
|
||||
<strong>{% raw %}{{ formattedBalance }} {% endraw %}</strong>
|
||||
{{LNBITS_DENOMINATION}}
|
||||
<q-btn
|
||||
<h3 class="q-my-none text-no-wrap">
|
||||
<strong v-text="formattedBalance"></strong>
|
||||
<small>{{LNBITS_DENOMINATION}}</small>
|
||||
<lnbits-update-balance
|
||||
v-if="'{{user.super_user}}' == 'True'"
|
||||
:wallet_id="this.g.wallet.id"
|
||||
flat
|
||||
:callback="updateBalanceCallback"
|
||||
round
|
||||
color="primary"
|
||||
icon="add"
|
||||
size="md"
|
||||
>
|
||||
<q-popup-edit
|
||||
class="bg-accent text-white"
|
||||
v-slot="scope"
|
||||
v-model="credit"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
:label='$t("credit_label", { denomination: "{{LNBITS_DENOMINATION}}"})'
|
||||
:hint="$t('credit_hint')"
|
||||
v-model="scope.value"
|
||||
dense
|
||||
autofocus
|
||||
:mask="'{{LNBITS_DENOMINATION}}' != 'sats' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="'{{LNBITS_DENOMINATION}}' != 'sats' ? '0.01' : '1'"
|
||||
@keyup.enter="updateBalance(scope.value)"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="edit" />
|
||||
</template>
|
||||
</q-input>
|
||||
</q-popup-edit>
|
||||
</q-btn>
|
||||
/>
|
||||
</h3>
|
||||
<div v-if="g.wallet.currency">
|
||||
<span
|
||||
class="text-h5 text-italic"
|
||||
v-text="formattedFiatBalance"
|
||||
style="opacity: 0.75"
|
||||
></span>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div v-if="g.wallet.currency">
|
||||
<span
|
||||
class="text-h5 text-italic"
|
||||
v-text="formattedFiatBalance"
|
||||
style="opacity: 0.75"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
@click="mobileSimple = !mobileSimple"
|
||||
color="primary"
|
||||
class="float-right lt-md"
|
||||
size="sm"
|
||||
flat
|
||||
:icon="mobileSimple ? 'unfold_more' : 'unfold_less'"
|
||||
:label="mobileSimple ? $t('more') : $t('less')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<div class="row q-pb-md q-px-md q-col-gutter-md gt-sm">
|
||||
@@ -98,7 +103,13 @@
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card
|
||||
:style="$q.screen.lt.md ? {
|
||||
background: $q.screen.lt.md ? 'none !important': ''
|
||||
, boxShadow: $q.screen.lt.md ? 'none !important': ''
|
||||
, marginTop: $q.screen.lt.md ? '0px !important': ''
|
||||
} : ''"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col">
|
||||
@@ -107,16 +118,13 @@
|
||||
:v-text="$t('transactions')"
|
||||
></h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="gt-sm col-auto">
|
||||
<q-btn
|
||||
flat
|
||||
color="grey"
|
||||
@click="exportCSV"
|
||||
:label="$t('export_csv')"
|
||||
></q-btn>
|
||||
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
|
||||
<q-tooltip>Check pending</q-tooltip>
|
||||
</q-btn>-->
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
@@ -132,10 +140,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
:style="$q.screen.lt.md ? {
|
||||
display: mobileSimple ? 'none !important': ''
|
||||
} : ''"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
v-model="paymentsTable.search"
|
||||
debounce="300"
|
||||
:placeholder="$t('search_by_tag_memo_amount')"
|
||||
class="q-mb-md"
|
||||
@@ -144,22 +155,26 @@
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="payments"
|
||||
:data="paymentsOmitter"
|
||||
:row-key="paymentTableRowKey"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
:no-data-label="$t('no_transactions')"
|
||||
:filter="paymentsTable.filter"
|
||||
:filter="paymentsTable.search"
|
||||
:loading="paymentsTable.loading"
|
||||
:hide-header="mobileSimple"
|
||||
:hide-bottom="mobileSimple"
|
||||
@request="fetchPayments"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
||||
>{{ col.label }}</q-th
|
||||
>
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
v-text="col.label"
|
||||
></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
@@ -178,7 +193,9 @@
|
||||
color="grey"
|
||||
@click="props.expand = !props.expand"
|
||||
>
|
||||
<q-tooltip>{{$t('pending')}}</q-tooltip>
|
||||
<q-tooltip
|
||||
><span v-text="$t('pending')"></span
|
||||
></q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td
|
||||
@@ -192,42 +209,42 @@
|
||||
text-color="black"
|
||||
>
|
||||
<a
|
||||
v-text="'#'+props.row.tag"
|
||||
class="inherit"
|
||||
:href="['/', props.row.tag, '/?usr=', user.id].join('')"
|
||||
>
|
||||
#{{ props.row.tag }}
|
||||
</a>
|
||||
:href="['/', props.row.tag].join('')"
|
||||
></a>
|
||||
</q-badge>
|
||||
{{ props.row.memo }}
|
||||
<span v-text="props.row.memo"></span>
|
||||
<br />
|
||||
|
||||
<i>
|
||||
{{ props.row.dateFrom }}<q-tooltip
|
||||
>{{ props.row.date }}</q-tooltip
|
||||
></i
|
||||
>
|
||||
<span v-text="props.row.dateFrom"></span>
|
||||
<q-tooltip
|
||||
><span v-text="props.row.date"></span
|
||||
></q-tooltip>
|
||||
</i>
|
||||
</q-td>
|
||||
|
||||
{% endraw %}
|
||||
<q-td
|
||||
auto-width
|
||||
key="amount"
|
||||
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
||||
:props="props"
|
||||
>{% raw %} {{
|
||||
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
|
||||
}}
|
||||
v-text="parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100"
|
||||
>
|
||||
</q-td>
|
||||
<q-td auto-width key="amount" v-else :props="props">
|
||||
{{ props.row.fsat }}<br />
|
||||
<span v-text="props.row.fsat"></span>
|
||||
<br />
|
||||
<i v-if="props.row.extra.wallet_fiat_currency">
|
||||
{{ formatFiat(props.row.extra.wallet_fiat_currency,
|
||||
props.row.extra.wallet_fiat_amount) }}
|
||||
<span
|
||||
v-text="formatFiat(props.row.extra.wallet_fiat_currency, props.row.extra.wallet_fiat_amount)"
|
||||
></span>
|
||||
<br />
|
||||
</i>
|
||||
<i v-if="props.row.extra.fiat_currency">
|
||||
{{ formatFiat(props.row.extra.fiat_currency,
|
||||
props.row.extra.fiat_amount) }}
|
||||
<span
|
||||
v-text="formatFiat(props.row.extra.fiat_currency, props.row.extra.fiat_amount)"
|
||||
></span>
|
||||
</i>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
@@ -302,30 +319,28 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% if HIDE_API %}
|
||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||
{% else %}
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<div
|
||||
v-if="!mobileSimple || $q.screen.gt.sm"
|
||||
class="col-12 col-md-5 q-gutter-y-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
{{ SITE_TITLE }} Wallet:
|
||||
<strong><em>{{ wallet.name }}</em></strong>
|
||||
<strong><em>{{wallet.name}}</em></strong>
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-list>
|
||||
{% include "core/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
|
||||
{% if wallet.lnurlwithdraw_full %}
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
@@ -355,7 +370,8 @@
|
||||
<q-card-section class="text-center">
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<qrcode
|
||||
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:value="'{{request.base_url}}' +'wallet?wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -437,6 +453,9 @@
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
{% include "core/_api_docs.html" %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -470,16 +489,15 @@
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="receive.show" position="top">
|
||||
{% raw %}
|
||||
<q-card
|
||||
v-if="!receive.paymentReq"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
<q-form @submit="createInvoice" class="q-gutter-md">
|
||||
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
|
||||
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
|
||||
<b v-text="receive.lnurl.domain"></b> is requesting an invoice:
|
||||
</p>
|
||||
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %}
|
||||
{% if LNBITS_DENOMINATION != 'sats' %}
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
@@ -523,7 +541,6 @@
|
||||
v-model.trim="receive.data.memo"
|
||||
:label="$t('memo')"
|
||||
></q-input>
|
||||
{% raw %}
|
||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
@@ -531,9 +548,10 @@
|
||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="receive.lnurl">
|
||||
{{$t('withdraw_from')}} {{receive.lnurl.domain}}
|
||||
</span>
|
||||
<span
|
||||
v-if="receive.lnurl"
|
||||
v-text="$t('withdraw_from') + receive.lnurl.domain"
|
||||
></span>
|
||||
<span v-else v-text="$t('create_invoice')"></span>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@@ -551,7 +569,10 @@
|
||||
></q-spinner>
|
||||
</q-form>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-card
|
||||
v-else-if="receive.paymentReq && receive.lnurl == null"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
@@ -577,28 +598,32 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
{% endraw %}
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="parse.show" @hide="closeParseDialog" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div v-if="parse.invoice">
|
||||
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
|
||||
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",",
|
||||
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
</h6>
|
||||
<h6 v-else class="q-my-none">
|
||||
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {%
|
||||
raw %}
|
||||
</h6>
|
||||
<h6
|
||||
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
||||
class="q-my-none"
|
||||
v-text="parseFloat(String(parse.invoice.fsat).replaceAll(',', '')) / 100 + '{{LNBITS_DENOMINATION}}'"
|
||||
></h6>
|
||||
<h6
|
||||
v-else
|
||||
class="q-my-none"
|
||||
v-text="parse.invoice.fsat + '{{LNBITS_DENOMINATION}}'"
|
||||
></h6>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p class="text-wrap">
|
||||
<strong v-text="$t('memo')">:</strong> {{
|
||||
parse.invoice.description }}<br />
|
||||
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
||||
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
||||
<strong v-text="$t('memo') + ': '"></strong>
|
||||
<span v-text="parse.invoice.description"></span>
|
||||
<br />
|
||||
<strong>Expire date: </strong>
|
||||
<span v-text="parse.invoice.expireDate"></span>
|
||||
<br />
|
||||
<strong>Hash: </strong>
|
||||
<span v-text="parse.invoice.hash"></span>
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div v-if="canPay" class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
@@ -632,24 +657,31 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlauth">
|
||||
{% raw %}
|
||||
<q-form @submit="authLnurl" class="q-gutter-md">
|
||||
<p class="q-my-none text-h6">
|
||||
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
|
||||
Authenticate with <b v-text="parse.lnurlauth.domain"></b>?
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p>
|
||||
For every website and for every LNbits wallet, a new keypair
|
||||
will be deterministically generated so your identity can't be
|
||||
tied to your LNbits wallet or linked across websites. No other
|
||||
data will be shared with {{ parse.lnurlauth.domain }}.
|
||||
data will be shared with
|
||||
<span v-text="parse.lnurlauth.domain"></span>.
|
||||
</p>
|
||||
<p>
|
||||
Your public key for <b v-text="parse.lnurlauth.domain"></b> is:
|
||||
</p>
|
||||
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
||||
<p class="q-mx-xl">
|
||||
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
|
||||
<code class="text-wrap" v-text="parse.lnurlauth.pubkey"></code>
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit">Login</q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
:label="$t('login')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
:label="$t('cancel')"
|
||||
v-close-popup
|
||||
@@ -659,55 +691,74 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlpay">
|
||||
{% raw %}
|
||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
||||
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
|
||||
parse.lnurlpay.maxSendable | msatoshiFormat }}
|
||||
{{LNBITS_DENOMINATION}}
|
||||
<b v-text="parse.lnurlpay.domain"></b> is requesting
|
||||
<span
|
||||
v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"
|
||||
></span>
|
||||
<span v-text="'{{LNBITS_DENOMINATION}}'"></span>
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
and a
|
||||
<span v-text="parse.lnurlpay.commentAllowed"></span>-char
|
||||
comment
|
||||
</span>
|
||||
</p>
|
||||
<p v-else class="q-my-none text-h6 text-center">
|
||||
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b>
|
||||
<b
|
||||
v-text="parse.lnurlpay.targetUser || parse.lnurlpay.domain"
|
||||
></b>
|
||||
is requesting <br />
|
||||
between
|
||||
<b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
|
||||
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
|
||||
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
<b v-text="msatoshiFormat(parse.lnurlpay.minSendable)"></b> and
|
||||
<b v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"></b>
|
||||
<span v-text="'{{LNBITS_DENOMINATION}}'"></span>
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
and a
|
||||
<span v-text="parse.lnurlpay.commentAllowed"></span>-char
|
||||
comment
|
||||
</span>
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<div class="row">
|
||||
<p class="col text-justify text-italic">
|
||||
{{ parse.lnurlpay.description }}
|
||||
</p>
|
||||
<p
|
||||
class="col text-justify text-italic"
|
||||
v-text="parse.lnurlpay.description"
|
||||
></p>
|
||||
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
|
||||
<q-img :src="parse.lnurlpay.image" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% endraw %}
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-if="!parse.lnurlpay.fixed"
|
||||
v-model="parse.data.unit"
|
||||
type="text"
|
||||
:label="$t('unit')"
|
||||
:options="receive.units"
|
||||
></q-select>
|
||||
<br />
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
dense
|
||||
v-model.number="parse.data.amount"
|
||||
type="number"
|
||||
label="Amount ({{LNBITS_DENOMINATION}}) *"
|
||||
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
|
||||
:mask="parse.data.unit != 'sat' ? '#.##' : '#'"
|
||||
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:min="parse.lnurlpay.minSendable / 1000"
|
||||
:max="parse.lnurlpay.maxSendable / 1000"
|
||||
:readonly="parse.lnurlpay.fixed"
|
||||
:readonly="parse.lnurlpay && parse.lnurlpay.fixed"
|
||||
></q-input>
|
||||
{% raw %}
|
||||
</div>
|
||||
<div
|
||||
class="col-8 q-pl-md"
|
||||
@@ -724,9 +775,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit"
|
||||
>Send {{LNBITS_DENOMINATION}}</q-btn
|
||||
>
|
||||
<q-btn unelevated color="primary" type="submit">Send</q-btn>
|
||||
<q-btn
|
||||
:label="$t('cancel')"
|
||||
v-close-popup
|
||||
@@ -736,7 +785,6 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-form
|
||||
@@ -880,8 +928,9 @@
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(disclaimerDialog.location.href)"
|
||||
:label="$t('copy_wallet_url')"
|
||||
type="a"
|
||||
href="/account"
|
||||
:label="$t('my_account')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
|
||||
@@ -249,7 +249,6 @@
|
||||
},
|
||||
api: function (method, url, options) {
|
||||
const params = new URLSearchParams(options?.query)
|
||||
params.set('usr', this.g.user.id)
|
||||
return LNbits.api
|
||||
.request(method, `/node/api/v1${url}?${params}`, {}, options?.data)
|
||||
.catch(error => {
|
||||
|
||||
@@ -17,9 +17,11 @@ from lnbits.core.services import (
|
||||
update_cached_settings,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.core.tasks import api_invoice_listeners
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, UpdateSettings, settings
|
||||
from lnbits.tasks import invoice_listeners
|
||||
|
||||
from .. import core_app_extra
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
@@ -48,6 +50,19 @@ async def api_auditor():
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/admin/api/v1/monitor",
|
||||
name="Monitor",
|
||||
description="show the current listeners and other monitoring data",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_monitor():
|
||||
return {
|
||||
"invoice_listeners": list(invoice_listeners.keys()),
|
||||
"api_invoice_listeners": list(api_invoice_listeners.keys()),
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
|
||||
async def api_get_settings(
|
||||
user: User = Depends(check_admin),
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from math import ceil
|
||||
from typing import Dict, List, Optional, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
@@ -48,6 +49,7 @@ from lnbits.core.models import (
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_access_token,
|
||||
check_admin,
|
||||
get_key_type,
|
||||
parse_filters,
|
||||
@@ -407,9 +409,15 @@ async def api_payments_pay_lnurl(
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
# no msat precision
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
r = await client.get(
|
||||
data.callback,
|
||||
params={"amount": data.amount, "comment": data.comment},
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
@@ -435,13 +443,13 @@ async def api_payments_pay_lnurl(
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != data.amount:
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {data.amount} msat, got {invoice.amount_msat}."
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -452,6 +460,9 @@ async def api_payments_pay_lnurl(
|
||||
extra["success_action"] = params["successAction"]
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
assert data.description is not None, "description is required"
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
@@ -780,7 +791,9 @@ async def websocket_update_get(item_id: str, data: str):
|
||||
|
||||
@api_router.post("/api/v1/extension")
|
||||
async def api_install_extension(
|
||||
data: CreateExtension, user: User = Depends(check_admin)
|
||||
data: CreateExtension,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive
|
||||
@@ -812,7 +825,7 @@ async def api_install_extension(
|
||||
await add_installed_extension(ext_info)
|
||||
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(data.ext_id, user.id)
|
||||
await stop_extension_background_work(data.ext_id, user.id, access_token)
|
||||
|
||||
if data.ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [data.ext_id]
|
||||
@@ -838,7 +851,11 @@ async def api_install_extension(
|
||||
|
||||
|
||||
@api_router.delete("/api/v1/extension/{ext_id}")
|
||||
async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)):
|
||||
async def api_uninstall_extension(
|
||||
ext_id: str,
|
||||
user: User = Depends(check_admin),
|
||||
access_token: Optional[str] = Depends(check_access_token),
|
||||
):
|
||||
installable_extensions = await InstallableExtension.get_installable_extensions()
|
||||
|
||||
extensions = [e for e in installable_extensions if e.id == ext_id]
|
||||
@@ -864,7 +881,7 @@ async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)
|
||||
|
||||
try:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_id, user.id)
|
||||
await stop_extension_background_work(ext_id, user.id, access_token)
|
||||
|
||||
if ext_id not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [ext_id]
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi_sso.sso.base import OpenID
|
||||
from fastapi_sso.sso.github import GithubSSO
|
||||
from fastapi_sso.sso.google import GoogleSSO
|
||||
from loguru import logger
|
||||
from starlette.status import (
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_403_FORBIDDEN,
|
||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.helpers import (
|
||||
create_access_token,
|
||||
decrypt_internal_message,
|
||||
encrypt_internal_message,
|
||||
is_valid_email_address,
|
||||
is_valid_username,
|
||||
)
|
||||
from lnbits.settings import AuthMethods, settings
|
||||
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_user,
|
||||
get_account,
|
||||
get_account_by_email,
|
||||
get_account_by_username_or_email,
|
||||
get_user,
|
||||
update_account,
|
||||
update_user_password,
|
||||
verify_user_password,
|
||||
)
|
||||
from ..models import (
|
||||
CreateUser,
|
||||
LoginUsernamePassword,
|
||||
LoginUsr,
|
||||
UpdateSuperuserPassword,
|
||||
UpdateUser,
|
||||
UpdateUserPassword,
|
||||
User,
|
||||
UserConfig,
|
||||
)
|
||||
|
||||
auth_router = APIRouter()
|
||||
|
||||
|
||||
@auth_router.get("/api/v1/auth", description="Get the authenticated user")
|
||||
async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth", description="Login via the username and password")
|
||||
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
HTTP_401_UNAUTHORIZED, "Login by 'Username and Password' not allowed."
|
||||
)
|
||||
|
||||
try:
|
||||
user = await get_account_by_username_or_email(data.username)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
|
||||
if not await verify_user_password(user.id, data.password):
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
return _auth_success_response(user.username, user.id)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth/usr", description="Login via the User ID")
|
||||
async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
|
||||
|
||||
try:
|
||||
user = await get_user(data.usr)
|
||||
if not user:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
|
||||
|
||||
return _auth_success_response(user.username or "", user.id)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
|
||||
|
||||
|
||||
@auth_router.get("/api/v1/auth/google", description="Google SSO")
|
||||
async def login_with_google(request: Request, user_id: Optional[str] = None):
|
||||
google_sso = _new_google_sso()
|
||||
if not google_sso:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'Google' not allowed.")
|
||||
|
||||
google_sso.redirect_uri = str(request.base_url) + "api/v1/auth/google/token"
|
||||
with google_sso:
|
||||
state = encrypt_internal_message(user_id)
|
||||
return await google_sso.get_login_redirect(state=state)
|
||||
|
||||
|
||||
@auth_router.get("/api/v1/auth/github", description="Github SSO")
|
||||
async def login_with_github(request: Request, user_id: Optional[str] = None):
|
||||
github_sso = _new_github_sso()
|
||||
if not github_sso:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'GitHub' not allowed.")
|
||||
|
||||
github_sso.redirect_uri = str(request.base_url) + "api/v1/auth/github/token"
|
||||
with github_sso:
|
||||
state = decrypt_internal_message(user_id)
|
||||
return await github_sso.get_login_redirect(state=state)
|
||||
|
||||
|
||||
@auth_router.get(
|
||||
"/api/v1/auth/google/token", description="Handle Google OAuth callback"
|
||||
)
|
||||
async def handle_google_token(request: Request) -> RedirectResponse:
|
||||
google_sso = _new_google_sso()
|
||||
if not google_sso:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'Google' not allowed.")
|
||||
|
||||
try:
|
||||
with google_sso:
|
||||
userinfo = await google_sso.verify_and_process(request)
|
||||
assert userinfo is not None
|
||||
user_id = decrypt_internal_message(google_sso.state)
|
||||
request.session.pop("user", None)
|
||||
return await _handle_sso_login(userinfo, user_id)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(
|
||||
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot authenticate user with Google Auth."
|
||||
)
|
||||
|
||||
|
||||
@auth_router.get(
|
||||
"/api/v1/auth/github/token", description="Handle Github OAuth callback"
|
||||
)
|
||||
async def handle_github_token(request: Request) -> RedirectResponse:
|
||||
github_sso = _new_github_sso()
|
||||
if not github_sso:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'GitHub' not allowed.")
|
||||
|
||||
try:
|
||||
with github_sso:
|
||||
userinfo = await github_sso.verify_and_process(request)
|
||||
assert userinfo is not None
|
||||
user_id = decrypt_internal_message(github_sso.state)
|
||||
request.session.pop("user", None)
|
||||
return await _handle_sso_login(userinfo, user_id)
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(
|
||||
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot authenticate user with GitHub Auth."
|
||||
)
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth/logout")
|
||||
async def logout() -> JSONResponse:
|
||||
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK)
|
||||
response.delete_cookie("cookie_access_token")
|
||||
response.delete_cookie("is_lnbits_user_authorized")
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
return response
|
||||
|
||||
|
||||
@auth_router.post("/api/v1/auth/register")
|
||||
async def register(data: CreateUser) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
HTTP_401_UNAUTHORIZED, "Register by 'Username and Password' not allowed."
|
||||
)
|
||||
|
||||
if data.password != data.password_repeat:
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Passwords do not match.")
|
||||
|
||||
if not data.username:
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Missing username.")
|
||||
if not is_valid_username(data.username):
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
|
||||
|
||||
if data.email and not is_valid_email_address(data.email):
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
|
||||
|
||||
try:
|
||||
user = await create_user(data)
|
||||
return _auth_success_response(user.username)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user.")
|
||||
|
||||
|
||||
@auth_router.put("/api/v1/auth/password")
|
||||
async def update_password(
|
||||
data: UpdateUserPassword, user: User = Depends(check_user_exists)
|
||||
) -> Optional[User]:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
|
||||
)
|
||||
if data.user_id != user.id:
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
|
||||
|
||||
try:
|
||||
return await update_user_password(data)
|
||||
except AssertionError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(
|
||||
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
|
||||
)
|
||||
|
||||
|
||||
@auth_router.put("/api/v1/auth/update")
|
||||
async def update(
|
||||
data: UpdateUser, user: User = Depends(check_user_exists)
|
||||
) -> Optional[User]:
|
||||
if data.user_id != user.id:
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
|
||||
if data.username and not is_valid_username(data.username):
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
|
||||
if data.email != user.email:
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Email mismatch.")
|
||||
|
||||
try:
|
||||
return await update_account(user.id, data.username, None, data.config)
|
||||
except AssertionError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user.")
|
||||
|
||||
|
||||
@auth_router.put("/api/v1/auth/first_install")
|
||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||
if not settings.first_install:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
|
||||
try:
|
||||
await update_account(
|
||||
user_id=settings.super_user,
|
||||
username=data.username,
|
||||
user_config=UserConfig(provider="lnbits"),
|
||||
)
|
||||
super_user = UpdateUserPassword(
|
||||
user_id=settings.super_user,
|
||||
password=data.password,
|
||||
password_repeat=data.password_repeat,
|
||||
username=data.username,
|
||||
)
|
||||
await update_user_password(super_user)
|
||||
settings.first_install = False
|
||||
return _auth_success_response(username=super_user.username)
|
||||
except AssertionError as e:
|
||||
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(
|
||||
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
|
||||
)
|
||||
|
||||
|
||||
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
|
||||
email = userinfo.email
|
||||
if not email or not is_valid_email_address(email):
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
|
||||
|
||||
redirect_path = "/wallet"
|
||||
user_config = UserConfig(**dict(userinfo))
|
||||
user_config.email_verified = True
|
||||
|
||||
account = await get_account_by_email(email)
|
||||
|
||||
if verified_user_id:
|
||||
if account:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Email already used.")
|
||||
account = await get_account(verified_user_id)
|
||||
if not account:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Cannot verify user email.")
|
||||
redirect_path = "/account"
|
||||
|
||||
if account:
|
||||
user = await update_account(account.id, email=email, user_config=user_config)
|
||||
else:
|
||||
if not settings.new_accounts_allowed:
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
|
||||
user = await create_account(email=email, user_config=user_config)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
|
||||
|
||||
return _auth_redirect_response(redirect_path, email)
|
||||
|
||||
|
||||
def _auth_success_response(
|
||||
username: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> JSONResponse:
|
||||
access_token = create_access_token(
|
||||
data={"sub": username or "", "usr": user_id, "email": email}
|
||||
)
|
||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized", "true", samesite="none", secure=True
|
||||
)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||
access_token = create_access_token(data={"sub": "" or "", "email": email})
|
||||
response = RedirectResponse(path)
|
||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||
response.set_cookie(
|
||||
"is_lnbits_user_authorized", "true", samesite="none", secure=True
|
||||
)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
return response
|
||||
|
||||
|
||||
def _new_google_sso() -> Optional[GoogleSSO]:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.google_auth):
|
||||
return None
|
||||
if not settings.is_google_auth_configured:
|
||||
logger.warning("Google Auth allowed but not configured.")
|
||||
return None
|
||||
return GoogleSSO(
|
||||
settings.google_client_id,
|
||||
settings.google_client_secret,
|
||||
None,
|
||||
allow_insecure_http=True,
|
||||
)
|
||||
|
||||
|
||||
def _new_github_sso() -> Optional[GithubSSO]:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.github_auth):
|
||||
return None
|
||||
if not settings.is_github_auth_configured:
|
||||
logger.warning("Github Auth allowed but not configured.")
|
||||
return None
|
||||
return GithubSSO(
|
||||
settings.github_client_id,
|
||||
settings.github_client_secret,
|
||||
None,
|
||||
allow_insecure_http=True,
|
||||
)
|
||||
@@ -1,17 +1,18 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import Annotated, List, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Depends, Query, Request, status
|
||||
from fastapi import Cookie, Depends, Query, Request, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.db import core_app_extra, db
|
||||
from lnbits.core.helpers import to_valid_user_id
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
@@ -52,6 +53,22 @@ async def home(request: Request, lightning: str = ""):
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/first_install", response_class=HTMLResponse)
|
||||
async def first_install(request: Request):
|
||||
if not settings.first_install:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{
|
||||
"request": request,
|
||||
"err": "Super user account has already been configured.",
|
||||
},
|
||||
)
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/first_install.html",
|
||||
{"request": request},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/robots.txt", response_class=HTMLResponse)
|
||||
async def robots():
|
||||
data = """
|
||||
@@ -74,9 +91,6 @@ async def extensions_install(
|
||||
):
|
||||
await toggle_extension(enable, disable, user.id)
|
||||
|
||||
# Update user as his extensions have been updated
|
||||
if enable or disable:
|
||||
user = await get_user(user.id) # type: ignore
|
||||
try:
|
||||
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
|
||||
installed_exts_ids = [e.id for e in installed_exts]
|
||||
@@ -103,20 +117,28 @@ async def extensions_install(
|
||||
|
||||
try:
|
||||
ext_id = activate or deactivate
|
||||
all_extensions = get_valid_extensions()
|
||||
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
||||
if ext_id and user.admin:
|
||||
if deactivate and deactivate not in settings.lnbits_deactivated_extensions:
|
||||
settings.lnbits_deactivated_extensions += [deactivate]
|
||||
elif activate:
|
||||
# if extension never loaded (was deactivated on server startup)
|
||||
if ext_id not in sys.modules.keys():
|
||||
# run extension start-up routine
|
||||
core_app_extra.register_new_ext_routes(ext)
|
||||
|
||||
settings.lnbits_deactivated_extensions = list(
|
||||
filter(
|
||||
lambda e: e != activate, settings.lnbits_deactivated_extensions
|
||||
)
|
||||
)
|
||||
|
||||
await update_installed_extension_state(
|
||||
ext_id=ext_id, active=activate is not None
|
||||
)
|
||||
|
||||
all_extensions = list(map(lambda e: e.code, get_valid_extensions()))
|
||||
all_ext_ids = list(map(lambda e: e.code, all_extensions))
|
||||
inactive_extensions = await get_inactive_extensions()
|
||||
db_version = await get_dbversions()
|
||||
extensions = list(
|
||||
@@ -131,7 +153,7 @@ async def extensions_install(
|
||||
"dependencies": ext.dependencies,
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": ext.id in db_version,
|
||||
"isAvailable": ext.id in all_extensions,
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
@@ -145,6 +167,9 @@ async def extensions_install(
|
||||
)
|
||||
)
|
||||
|
||||
# refresh user state. Eg: enabled extensions.
|
||||
user = await get_user(user.id) or user
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/extensions.html",
|
||||
{
|
||||
@@ -165,59 +190,60 @@ async def extensions_install(
|
||||
)
|
||||
async def wallet(
|
||||
request: Request,
|
||||
usr: UUID4 = Query(...),
|
||||
lnbits_last_active_wallet: Annotated[Union[str, None], Cookie()] = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
wal: Optional[UUID4] = Query(None),
|
||||
):
|
||||
user_id = usr.hex
|
||||
user = await get_user(user_id)
|
||||
|
||||
if not user:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User does not exist."}
|
||||
)
|
||||
|
||||
if not wal:
|
||||
if len(user.wallets) == 0:
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
return RedirectResponse(url=f"/wallet?usr={user_id}&wal={wallet.id}")
|
||||
return RedirectResponse(url=f"/wallet?usr={user_id}&wal={user.wallets[0].id}")
|
||||
else:
|
||||
if wal:
|
||||
wallet_id = wal.hex
|
||||
elif len(user.wallets) == 0:
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
user = await get_user(user_id=user.id) or user
|
||||
wallet_id = wallet.id
|
||||
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
|
||||
wallet_id = lnbits_last_active_wallet
|
||||
else:
|
||||
wallet_id = user.wallets[0].id
|
||||
|
||||
userwallet = user.get_wallet(wallet_id)
|
||||
if not userwallet or userwallet.deleted:
|
||||
user_wallet = user.get_wallet(wallet_id)
|
||||
if not user_wallet or user_wallet.deleted:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "Wallet not found"}
|
||||
)
|
||||
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and user_id not in settings.lnbits_allowed_users
|
||||
and user_id not in settings.lnbits_admin_users
|
||||
and user_id != settings.super_user
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User not authorized."}
|
||||
)
|
||||
|
||||
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
|
||||
user.admin = True
|
||||
if user_id == settings.super_user:
|
||||
user.super_user = True
|
||||
|
||||
logger.debug(f"Access user {user.id} wallet {userwallet.name}")
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
resp = template_renderer().TemplateResponse(
|
||||
"core/wallet.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"wallet": userwallet.dict(),
|
||||
"wallet": user_wallet.dict(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
},
|
||||
)
|
||||
resp.set_cookie(
|
||||
"lnbits_last_active_wallet", wallet_id, samesite="none", secure=True
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/account",
|
||||
response_class=HTMLResponse,
|
||||
description="show account page",
|
||||
)
|
||||
async def account(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/account.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/withdraw", response_class=JSONResponse)
|
||||
@@ -352,9 +378,44 @@ async def manifest(request: Request, usr: str):
|
||||
if settings.lnbits_custom_logo
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@main/docs/logos/lnbits.png"
|
||||
),
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"sizes": "900x900",
|
||||
}
|
||||
},
|
||||
{"src": "/static/favicon.ico", "sizes": "32x32", "type": "image/x-icon"},
|
||||
{
|
||||
"src": "/static/images/maskable_icon_x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": "/static/images/maskable_icon_x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": "/static/images/maskable_icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "1024x1024",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/static/images/screenshot_desktop.png",
|
||||
"sizes": "2394x1314",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "LNbits - Desktop screenshot",
|
||||
},
|
||||
{
|
||||
"src": "/static/images/screenshot_phone.png",
|
||||
"sizes": "1080x1739",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow",
|
||||
"label": "LNbits - Phone screenshot",
|
||||
},
|
||||
],
|
||||
"start_url": f"/wallet?usr={usr}&wal={user.wallets[0].id}",
|
||||
"background_color": "#1F2234",
|
||||
@@ -368,6 +429,13 @@ async def manifest(request: Request, usr: str):
|
||||
"short_name": wallet.name,
|
||||
"description": wallet.name,
|
||||
"url": f"/wallet?usr={usr}&wal={wallet.id}",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/images/maskable_icon_x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
}
|
||||
],
|
||||
}
|
||||
for wallet in user.wallets
|
||||
],
|
||||
|
||||
@@ -91,6 +91,12 @@ class Compat:
|
||||
return "(strftime('%s', 'now'))"
|
||||
return "<nothing>"
|
||||
|
||||
@property
|
||||
def timestamp_column_default(self) -> str:
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
return self.timestamp_now
|
||||
return "NULL"
|
||||
|
||||
@property
|
||||
def serial_primary_key(self) -> str:
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Literal, Optional, Type
|
||||
from typing import Annotated, Literal, Optional, Type, Union
|
||||
|
||||
from fastapi import Query, Request, Security
|
||||
from fastapi import Cookie, Depends, Query, Request, Security
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.openapi.models import APIKey, APIKeyIn
|
||||
from fastapi.security import APIKeyHeader, APIKeyQuery
|
||||
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
||||
from fastapi.security.base import SecurityBase
|
||||
from jose import ExpiredSignatureError, JWTError, jwt
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.core.crud import (
|
||||
get_account,
|
||||
get_account_by_email,
|
||||
get_account_by_username,
|
||||
get_user,
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from lnbits.core.models import User, WalletType, WalletTypeInfo
|
||||
from lnbits.db import Filter, Filters, TFilterModel
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
from lnbits.settings import AuthMethods, settings
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
|
||||
|
||||
|
||||
# TODO: fix type ignores
|
||||
@@ -220,44 +229,50 @@ async def require_invoice_key(
|
||||
return wallet
|
||||
|
||||
|
||||
async def check_user_exists(usr: UUID4) -> User:
|
||||
g().user = await get_user(usr.hex)
|
||||
|
||||
if not g().user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||
)
|
||||
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and g().user.id not in settings.lnbits_allowed_users
|
||||
and g().user.id not in settings.lnbits_admin_users
|
||||
and g().user.id != settings.super_user
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||
)
|
||||
|
||||
return g().user
|
||||
async def check_access_token(
|
||||
header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
|
||||
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
|
||||
) -> Optional[str]:
|
||||
return header_access_token or cookie_access_token
|
||||
|
||||
|
||||
async def check_admin(usr: UUID4) -> User:
|
||||
user = await check_user_exists(usr)
|
||||
async def check_user_exists(
|
||||
r: Request,
|
||||
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
||||
usr: Optional[UUID4] = None,
|
||||
) -> User:
|
||||
if access_token:
|
||||
account = await _get_account_from_token(access_token)
|
||||
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
account = await get_account(usr.hex)
|
||||
else:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
|
||||
|
||||
if not account or not settings.is_user_allowed(account.id):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
|
||||
|
||||
user = await get_user(account.id)
|
||||
assert user, "User not found for account."
|
||||
|
||||
if not user.admin and r["path"].split("/")[1] in settings.lnbits_admin_extensions:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not authorized for extension.")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="User not authorized. No admin privileges.",
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No admin privileges."
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def check_super_user(usr: UUID4) -> User:
|
||||
user = await check_admin(usr)
|
||||
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="User not authorized. No super user privileges.",
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No super user privileges."
|
||||
)
|
||||
return user
|
||||
|
||||
@@ -295,3 +310,23 @@ def parse_filters(model: Type[TFilterModel]):
|
||||
)
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
async def _get_account_from_token(access_token):
|
||||
try:
|
||||
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256")
|
||||
if "sub" in payload and payload.get("sub"):
|
||||
return await get_account_by_username(str(payload.get("sub")))
|
||||
if "usr" in payload and payload.get("usr"):
|
||||
return await get_account(str(payload.get("usr")))
|
||||
if "email" in payload and payload.get("email"):
|
||||
return await get_account_by_email(str(payload.get("email")))
|
||||
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
|
||||
except ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
|
||||
)
|
||||
except JWTError as e:
|
||||
logger.debug(e)
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.")
|
||||
|
||||
@@ -604,11 +604,23 @@ class CreateExtension(BaseModel):
|
||||
source_repo: str
|
||||
|
||||
|
||||
def get_valid_extensions() -> List[Extension]:
|
||||
return [
|
||||
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
||||
valid_extensions = [
|
||||
extension for extension in ExtensionManager().extensions if extension.is_valid
|
||||
]
|
||||
|
||||
if include_deactivated:
|
||||
return valid_extensions
|
||||
|
||||
if settings.lnbits_extensions_deactivate_all:
|
||||
return []
|
||||
|
||||
return [
|
||||
e
|
||||
for e in valid_extensions
|
||||
if e.code not in settings.lnbits_deactivated_extensions
|
||||
]
|
||||
|
||||
|
||||
def version_parse(v: str):
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Type
|
||||
|
||||
import jinja2
|
||||
import shortuuid
|
||||
from jose import jwt
|
||||
from pydantic import BaseModel
|
||||
from pydantic.schema import field_schema
|
||||
|
||||
@@ -11,6 +14,7 @@ from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
|
||||
from .db import FilterModel
|
||||
from .extension_manager import get_valid_extensions
|
||||
@@ -58,6 +62,7 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo
|
||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
||||
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
|
||||
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
|
||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
||||
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
||||
@@ -66,11 +71,7 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
||||
settings.lnbits_node_ui and get_node_class() is not None
|
||||
)
|
||||
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||
t.env.globals["EXTENSIONS"] = [
|
||||
e
|
||||
for e in get_valid_extensions()
|
||||
if e.code not in settings.lnbits_deactivated_extensions
|
||||
]
|
||||
t.env.globals["EXTENSIONS"] = get_valid_extensions(False)
|
||||
if settings.lnbits_custom_logo:
|
||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
||||
|
||||
@@ -166,3 +167,34 @@ def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?")
|
||||
"""
|
||||
query = ", ".join([f"{field} = ?" for field in model.dict().keys()])
|
||||
return f"UPDATE {table_name} SET {query} {where}"
|
||||
|
||||
|
||||
def is_valid_email_address(email: str) -> bool:
|
||||
email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}"
|
||||
return re.fullmatch(email_regex, email) is not None
|
||||
|
||||
|
||||
def is_valid_username(username: str) -> bool:
|
||||
username_regex = r"(?=[a-zA-Z0-9._]{2,20}$)(?!.*[_.]{2})[^_.].*[^_.]"
|
||||
return re.fullmatch(username_regex, username) is not None
|
||||
|
||||
|
||||
def create_access_token(data: dict):
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.auth_token_expire_minutes)
|
||||
to_encode = data.copy()
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
||||
|
||||
|
||||
def encrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
||||
"""Encrypt message with the internal secret key"""
|
||||
if not m:
|
||||
return None
|
||||
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode())
|
||||
|
||||
|
||||
def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
||||
"""Decrypt message with the internal secret key"""
|
||||
if not m:
|
||||
return None
|
||||
return AESCipher(key=settings.auth_secret_key).decrypt(m)
|
||||
|
||||
@@ -8,8 +8,8 @@ from starlette.templating import Jinja2Templates as SuperJinja2Templates
|
||||
|
||||
class Jinja2Templates(SuperJinja2Templates):
|
||||
def __init__(self, loader: BaseLoader) -> None:
|
||||
super().__init__("")
|
||||
self.env = self.get_environment(loader)
|
||||
super().__init__(env=self.env)
|
||||
|
||||
def get_environment(self, loader: BaseLoader) -> Environment:
|
||||
@pass_context
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Any, List, Tuple, Union
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
@@ -41,13 +40,6 @@ class InstalledExtensionMiddleware:
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
if not self._user_allowed_to_extension(top_path, scope):
|
||||
response = self._response_by_accepted_type(
|
||||
headers, "User not authorized.", HTTPStatus.FORBIDDEN
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
# static resources do not require redirect
|
||||
if rest[0:1] == ["static"]:
|
||||
await self.app(scope, receive, send)
|
||||
@@ -68,23 +60,6 @@ class InstalledExtensionMiddleware:
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
def _user_allowed_to_extension(self, ext_name: str, scope) -> bool:
|
||||
if ext_name not in settings.lnbits_admin_extensions:
|
||||
return True
|
||||
if "query_string" not in scope:
|
||||
return True
|
||||
|
||||
# parse the URL query string into a `dict`
|
||||
q = parse_qs(scope["query_string"].decode("UTF-8"))
|
||||
user = q.get("usr", [""])[0]
|
||||
if not user:
|
||||
return True
|
||||
|
||||
if user == settings.super_user or user in settings.lnbits_admin_users:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _response_by_accepted_type(
|
||||
self, headers: List[Any], msg: str, status_code: HTTPStatus
|
||||
) -> Union[HTMLResponse, JSONResponse]:
|
||||
@@ -212,7 +187,12 @@ class ExtensionsRedirectMiddleware:
|
||||
|
||||
def add_ratelimit_middleware(app: FastAPI):
|
||||
core_app_extra.register_new_ratelimiter()
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
# latest https://slowapi.readthedocs.io/en/latest/
|
||||
# shows this as a valid way to add the handler
|
||||
app.add_exception_handler(
|
||||
RateLimitExceeded,
|
||||
_rate_limit_exceeded_handler, # type: ignore
|
||||
)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
|
||||
@@ -235,3 +215,18 @@ def add_ip_block_middleware(app: FastAPI):
|
||||
return await call_next(request)
|
||||
|
||||
app.middleware("http")(block_allow_ip_middleware)
|
||||
|
||||
|
||||
def add_first_install_middleware(app: FastAPI):
|
||||
@app.middleware("http")
|
||||
async def first_install_middleware(request: Request, call_next):
|
||||
if (
|
||||
settings.first_install
|
||||
and request.url.path != "/api/v1/auth/first_install"
|
||||
and request.url.path != "/first_install"
|
||||
and not request.url.path.startswith("/static")
|
||||
):
|
||||
return RedirectResponse("/first_install")
|
||||
return await call_next(request)
|
||||
|
||||
app.middleware("http")(first_install_middleware)
|
||||
|
||||
@@ -312,8 +312,12 @@ class CoreLightningNode(Node):
|
||||
NodeInvoice(
|
||||
bolt11=invoice.get("bolt11") or invoice.get("bolt12"),
|
||||
amount=(
|
||||
# normal invoice
|
||||
invoice.get("amount_msat")
|
||||
# keysend or paid amountless invoice
|
||||
or invoice.get("amount_received_msat")
|
||||
# unpaid amountless invoice
|
||||
or 0
|
||||
),
|
||||
preimage=invoice.get("payment_preimage"),
|
||||
memo=invoice.get("description"),
|
||||
|
||||
@@ -4,6 +4,8 @@ import importlib
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import json
|
||||
from enum import Enum
|
||||
from hashlib import sha256
|
||||
from os import path
|
||||
from sqlite3 import Row
|
||||
from time import time
|
||||
@@ -114,6 +116,9 @@ class SecuritySettings(LNbitsSettings):
|
||||
lnbits_notifications: bool = Field(default=False)
|
||||
lnbits_killswitch: bool = Field(default=False)
|
||||
lnbits_killswitch_interval: int = Field(default=60)
|
||||
lnbits_wallet_limit_max_balance: int = Field(default=0)
|
||||
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0)
|
||||
lnbits_wallet_limit_secs_between_trans: int = Field(default=0)
|
||||
lnbits_watchdog: bool = Field(default=False)
|
||||
lnbits_watchdog_interval: int = Field(default=60)
|
||||
lnbits_watchdog_delta: int = Field(default=1_000_000)
|
||||
@@ -123,6 +128,13 @@ class SecuritySettings(LNbitsSettings):
|
||||
)
|
||||
)
|
||||
|
||||
def is_wallet_max_balance_exceeded(self, amount):
|
||||
return (
|
||||
self.lnbits_wallet_limit_max_balance
|
||||
and self.lnbits_wallet_limit_max_balance > 0
|
||||
and amount > self.lnbits_wallet_limit_max_balance
|
||||
)
|
||||
|
||||
|
||||
class FakeWalletFundingSource(LNbitsSettings):
|
||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||
@@ -245,6 +257,45 @@ class NodeUISettings(LNbitsSettings):
|
||||
lnbits_node_ui_transactions: bool = Field(default=False)
|
||||
|
||||
|
||||
class AuthMethods(Enum):
|
||||
user_id_only = "user-id-only"
|
||||
username_and_password = "username-password"
|
||||
google_auth = "google-auth"
|
||||
github_auth = "github-auth"
|
||||
|
||||
|
||||
class AuthSettings(LNbitsSettings):
|
||||
auth_token_expire_minutes: int = Field(default=525600)
|
||||
auth_all_methods = [a.value for a in AuthMethods]
|
||||
auth_allowed_methods: List[str] = Field(
|
||||
default=[
|
||||
AuthMethods.user_id_only.value,
|
||||
AuthMethods.username_and_password.value,
|
||||
]
|
||||
)
|
||||
|
||||
def is_auth_method_allowed(self, method: AuthMethods):
|
||||
return method.value in self.auth_allowed_methods
|
||||
|
||||
|
||||
class GoogleAuthSettings(LNbitsSettings):
|
||||
google_client_id: str = Field(default="")
|
||||
google_client_secret: str = Field(default="")
|
||||
|
||||
@property
|
||||
def is_google_auth_configured(self):
|
||||
return self.google_client_id != "" and self.google_client_secret != ""
|
||||
|
||||
|
||||
class GitHubAuthSettings(LNbitsSettings):
|
||||
github_client_id: str = Field(default="")
|
||||
github_client_secret: str = Field(default="")
|
||||
|
||||
@property
|
||||
def is_github_auth_configured(self):
|
||||
return self.github_client_id != "" and self.github_client_secret != ""
|
||||
|
||||
|
||||
class EditableSettings(
|
||||
UsersSettings,
|
||||
ExtensionsSettings,
|
||||
@@ -255,6 +306,9 @@ class EditableSettings(
|
||||
LightningSettings,
|
||||
WebPushSettings,
|
||||
NodeUISettings,
|
||||
AuthSettings,
|
||||
GoogleAuthSettings,
|
||||
GitHubAuthSettings,
|
||||
):
|
||||
@validator(
|
||||
"lnbits_admin_users",
|
||||
@@ -296,12 +350,15 @@ class EnvSettings(LNbitsSettings):
|
||||
lnbits_path: str = Field(default=".")
|
||||
lnbits_extensions_path: str = Field(default="lnbits")
|
||||
super_user: str = Field(default="")
|
||||
auth_secret_key: str = Field(default="")
|
||||
version: str = Field(default="0.0.0")
|
||||
user_agent: str = Field(default="")
|
||||
enable_log_to_file: bool = Field(default=True)
|
||||
log_rotation: str = Field(default="100 MB")
|
||||
log_retention: str = Field(default="3 months")
|
||||
server_startup_time: int = Field(default=time())
|
||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||
cleanup_wallets_days: int = Field(default=90)
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
@@ -344,6 +401,7 @@ class TransientSettings(InstalledExtensionsSettings):
|
||||
# - are not read from a file or from the `settings` table
|
||||
# - are not persisted in the `settings` table when the settings are updated
|
||||
# - are cleared on server restart
|
||||
first_install: bool = Field(default=False)
|
||||
|
||||
@classmethod
|
||||
def readonly_fields(cls):
|
||||
@@ -384,6 +442,14 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback
|
||||
|
||||
def is_user_allowed(self, user_id: str):
|
||||
return (
|
||||
len(self.lnbits_allowed_users) == 0
|
||||
or user_id in self.lnbits_allowed_users
|
||||
or user_id in self.lnbits_admin_users
|
||||
or user_id == self.super_user
|
||||
)
|
||||
|
||||
|
||||
class SuperSettings(EditableSettings):
|
||||
super_user: str
|
||||
@@ -432,6 +498,9 @@ settings = Settings()
|
||||
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
|
||||
|
||||
settings.version = importlib.metadata.version("lnbits")
|
||||
settings.auth_secret_key = (
|
||||
settings.auth_secret_key or sha256(settings.super_user.encode("utf-8")).hexdigest()
|
||||
)
|
||||
|
||||
if not settings.user_agent:
|
||||
settings.user_agent = f"LNbits/{settings.version}"
|
||||
|
||||
@@ -63,6 +63,7 @@ window.localisation.br = {
|
||||
toggle_darkmode: 'Alternar modo escuro',
|
||||
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
||||
api_docs: 'Documentação da API',
|
||||
api_keys_api_docs: 'Chaves de API e documentação da API',
|
||||
lnbits_version: 'Versão do LNbits',
|
||||
runs_on: 'Executa em',
|
||||
credit_hint: 'Pressione Enter para creditar a conta',
|
||||
@@ -177,6 +178,7 @@ window.localisation.br = {
|
||||
notification_source_label:
|
||||
'URL de origem (use apenas a fonte de status oficial do LNbits e fontes de confiança)',
|
||||
more: 'mais',
|
||||
less: 'menos',
|
||||
releases: 'Lançamentos',
|
||||
killswitch: 'Dispositivo de desativação',
|
||||
watchdog: 'Cão de guarda',
|
||||
@@ -195,5 +197,38 @@ window.localisation.br = {
|
||||
hour: 'hora',
|
||||
disable_server_log: 'Desativar Log do Servidor',
|
||||
enable_server_log: 'Ativar Registro do Servidor',
|
||||
coming_soon: 'Funcionalidade em breve'
|
||||
coming_soon: 'Funcionalidade em breve',
|
||||
session_has_expired: 'Sua sessão expirou. Por favor, faça login novamente.',
|
||||
instant_access_question: 'Quer acesso imediato?',
|
||||
login_with_user_id: 'Faça login com ID do usuário',
|
||||
or: 'ou',
|
||||
create_new_wallet: 'Criar Nova Carteira',
|
||||
login_to_account: 'Faça login na sua conta',
|
||||
create_account: 'Criar conta',
|
||||
account_settings: 'Configurações da Conta',
|
||||
signin_with_google: 'Entrar com o Google',
|
||||
signin_with_github: 'Entrar com GitHub',
|
||||
username_or_email: 'Nome de usuário ou E-mail',
|
||||
password: 'Senha',
|
||||
password_config: 'Configuração de Senha',
|
||||
password_repeat: 'Repetição de senha',
|
||||
change_password: 'Alterar Senha',
|
||||
set_password: 'Definir Senha',
|
||||
invalid_password: 'A senha deve ter pelo menos 8 caracteres',
|
||||
login: 'Entrar',
|
||||
register: 'Registrar',
|
||||
username: 'Nome de usuário',
|
||||
user_id: 'ID do Usuário',
|
||||
email: 'E-mail',
|
||||
first_name: 'Primeiro Nome',
|
||||
last_name: 'Sobrenome',
|
||||
picture: 'Foto',
|
||||
verify_email: 'Verifique o e-mail com',
|
||||
account: 'Conta',
|
||||
update_account: 'Atualizar Conta',
|
||||
invalid_username: 'Nome de usuário inválido',
|
||||
auth_provider: 'Provedor de Autenticação',
|
||||
my_account: 'Minha Conta',
|
||||
back: 'Voltar',
|
||||
logout: 'Sair'
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ window.localisation.cn = {
|
||||
toggle_darkmode: '切换暗黑模式',
|
||||
view_swagger_docs: '查看 LNbits Swagger API 文档',
|
||||
api_docs: 'API文档',
|
||||
api_keys_api_docs: 'API密钥和API文档',
|
||||
lnbits_version: 'LNbits版本',
|
||||
runs_on: '可运行在',
|
||||
credit_hint: '按 Enter 键充值账户',
|
||||
@@ -166,6 +167,7 @@ window.localisation.cn = {
|
||||
notification_source: '通知来源',
|
||||
notification_source_label: '来源 URL(仅使用官方LNbits状态源和您信任的源)',
|
||||
more: '更多',
|
||||
less: '少',
|
||||
releases: '版本',
|
||||
killswitch: '杀手锏',
|
||||
watchdog: '监控程序',
|
||||
@@ -184,5 +186,38 @@ window.localisation.cn = {
|
||||
hour: '小时',
|
||||
disable_server_log: '禁用服务器日志',
|
||||
enable_server_log: '启用服务器日志',
|
||||
coming_soon: '功能即将推出'
|
||||
coming_soon: '功能即将推出',
|
||||
session_has_expired: '您的会话已过期。请重新登录。',
|
||||
instant_access_question: '想要即时访问吗?',
|
||||
login_with_user_id: '使用用户ID登录',
|
||||
or: '或',
|
||||
create_new_wallet: '创建新钱包',
|
||||
login_to_account: '登录您的账户',
|
||||
create_account: '创建账户',
|
||||
account_settings: '账户设置',
|
||||
signin_with_google: '使用谷歌账号登录',
|
||||
signin_with_github: '使用 GitHub 登录',
|
||||
username_or_email: '用户名或电子邮箱',
|
||||
password: '密码',
|
||||
password_config: '密码配置',
|
||||
password_repeat: '密码重复',
|
||||
change_password: '修改密码',
|
||||
set_password: '设置密码',
|
||||
invalid_password: '密码至少需要有8个字符',
|
||||
login: '登录',
|
||||
register: '注册',
|
||||
username: '用户名',
|
||||
user_id: '用户ID',
|
||||
email: '电子邮件',
|
||||
first_name: '名字',
|
||||
last_name: '姓氏',
|
||||
picture: '图片',
|
||||
verify_email: '验证电子邮件与',
|
||||
account: '账户',
|
||||
update_account: '更新帐户',
|
||||
invalid_username: '无效用户名',
|
||||
auth_provider: '认证提供者',
|
||||
my_account: '我的账户',
|
||||
back: '返回',
|
||||
logout: '注销'
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ window.localisation.cs = {
|
||||
toggle_darkmode: 'Přepnout tmavý režim',
|
||||
view_swagger_docs: 'Zobrazit LNbits Swagger API dokumentaci',
|
||||
api_docs: 'API dokumentace',
|
||||
api_keys_api_docs: 'API klíče a API dokumentace',
|
||||
lnbits_version: 'Verze LNbits',
|
||||
runs_on: 'Běží na',
|
||||
credit_hint: 'Stiskněte Enter pro připsání na účet',
|
||||
@@ -174,6 +175,7 @@ window.localisation.cs = {
|
||||
notification_source_label:
|
||||
'URL zdroje (používejte pouze oficiální zdroj stavu LNbits a zdroje, kterým můžete věřit)',
|
||||
more: 'více',
|
||||
less: 'méně',
|
||||
releases: 'Vydání',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Watchdog',
|
||||
@@ -192,5 +194,38 @@ window.localisation.cs = {
|
||||
hour: 'hodina',
|
||||
disable_server_log: 'Zakázat log serveru',
|
||||
enable_server_log: 'Povolit log serveru',
|
||||
coming_soon: 'Funkce brzy dostupná'
|
||||
coming_soon: 'Funkce brzy dostupná',
|
||||
session_has_expired: 'Vaše relace vypršela. Prosím, přihlašte se znovu.',
|
||||
instant_access_question: 'Chcete okamžitý přístup?',
|
||||
login_with_user_id: 'Přihlásit se s uživatelským ID',
|
||||
or: 'nebo',
|
||||
create_new_wallet: 'Vytvořit novou peněženku',
|
||||
login_to_account: 'Přihlaste se ke svému účtu',
|
||||
create_account: 'Vytvořit účet',
|
||||
account_settings: 'Nastavení účtu',
|
||||
signin_with_google: 'Přihlásit se přes Google',
|
||||
signin_with_github: 'Přihlásit se přes GitHub',
|
||||
username_or_email: 'Uživatelské jméno nebo Email',
|
||||
password: 'Heslo',
|
||||
password_config: 'Konfigurace hesla',
|
||||
password_repeat: 'Opakujte heslo',
|
||||
change_password: 'Změnit heslo',
|
||||
set_password: 'Nastavit heslo',
|
||||
invalid_password: 'Heslo musí mít alespoň 8 znaků',
|
||||
login: 'Přihlášení',
|
||||
register: 'Registrovat',
|
||||
username: 'Uživatelské jméno',
|
||||
user_id: 'ID uživatele',
|
||||
email: 'Email',
|
||||
first_name: 'Křestní jméno',
|
||||
last_name: 'Příjmení',
|
||||
picture: 'Obrázek',
|
||||
verify_email: 'Ověřte e-mail s',
|
||||
account: 'Účet',
|
||||
update_account: 'Aktualizovat účet',
|
||||
invalid_username: 'Neplatné uživatelské jméno',
|
||||
auth_provider: 'Poskytovatel ověření',
|
||||
my_account: 'Můj účet',
|
||||
back: 'Zpět',
|
||||
logout: 'Odhlásit se'
|
||||
}
|
||||
|
||||
@@ -63,8 +63,9 @@ window.localisation.de = {
|
||||
service_fee_tooltip:
|
||||
'Bearbeitungsgebühr, die vom LNbits Server-Administrator pro ausgehender Transaktion berechnet wird',
|
||||
toggle_darkmode: 'Auf Dark Mode umschalten',
|
||||
view_swagger_docs: 'LNbits Swagger API-Dokumente',
|
||||
api_docs: 'API docs',
|
||||
view_swagger_docs: 'LNbits Swagger API-Dokumentation',
|
||||
api_docs: 'API-Dokumentation',
|
||||
api_keys_api_docs: 'API-Schlüssel und API-Dokumentation',
|
||||
lnbits_version: 'LNbits-Version',
|
||||
runs_on: 'Läuft auf',
|
||||
credit_hint: 'Klicke Enter, um das Konto zu belasten',
|
||||
@@ -179,6 +180,7 @@ window.localisation.de = {
|
||||
notification_source_label:
|
||||
'Quell-URL (verwenden Sie nur die offizielle LNbits-Statusquelle und Quellen, denen Sie vertrauen können)',
|
||||
more: 'mehr',
|
||||
less: 'weniger',
|
||||
releases: 'Veröffentlichungen',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Wachhund',
|
||||
@@ -197,5 +199,39 @@ window.localisation.de = {
|
||||
hour: 'Stunde',
|
||||
disable_server_log: 'Server-Log deaktivieren',
|
||||
enable_server_log: 'Serverprotokollierung aktivieren',
|
||||
coming_soon: 'Funktion demnächst verfügbar'
|
||||
coming_soon: 'Funktion demnächst verfügbar',
|
||||
session_has_expired:
|
||||
'Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.',
|
||||
instant_access_question: 'Möchten Sie sofortigen Zugang?',
|
||||
login_with_user_id: 'Mit Benutzer-ID anmelden',
|
||||
or: 'oder',
|
||||
create_new_wallet: 'Neue Geldbörse erstellen',
|
||||
login_to_account: 'Melden Sie sich bei Ihrem Konto an',
|
||||
create_account: 'Konto erstellen',
|
||||
account_settings: 'Kontoeinstellungen',
|
||||
signin_with_google: 'Mit Google anmelden',
|
||||
signin_with_github: 'Anmelden mit GitHub',
|
||||
username_or_email: 'Benutzername oder E-Mail',
|
||||
password: 'Passwort',
|
||||
password_config: 'Passwortkonfiguration',
|
||||
password_repeat: 'Passwortwiederholung',
|
||||
change_password: 'Passwort ändern',
|
||||
set_password: 'Passwort festlegen',
|
||||
invalid_password: 'Das Passwort muss mindestens 8 Zeichen haben.',
|
||||
login: 'Anmelden',
|
||||
register: 'Registrieren',
|
||||
username: 'Benutzername',
|
||||
user_id: 'Benutzer-ID',
|
||||
email: 'E-Mail',
|
||||
first_name: 'Vorname',
|
||||
last_name: 'Nachname',
|
||||
picture: 'Bild',
|
||||
verify_email: 'E-Mail verifizieren mit',
|
||||
account: 'Konto',
|
||||
update_account: 'Konto aktualisieren',
|
||||
invalid_username: 'Ungültiger Benutzername',
|
||||
auth_provider: 'Anbieter für Authentifizierung',
|
||||
my_account: 'Mein Konto',
|
||||
back: 'Zurück',
|
||||
logout: 'Abmelden'
|
||||
}
|
||||
|
||||
@@ -59,8 +59,10 @@ window.localisation.en = {
|
||||
service_fee_tooltip:
|
||||
'Service fee charged by the LNbits server admin per outgoing transaction',
|
||||
toggle_darkmode: 'Toggle Dark Mode',
|
||||
payment_reactions: 'Payment Reactions',
|
||||
view_swagger_docs: 'View LNbits Swagger API docs',
|
||||
api_docs: 'API docs',
|
||||
api_keys_api_docs: 'API keys and API docs',
|
||||
lnbits_version: 'LNbits version',
|
||||
runs_on: 'Runs on',
|
||||
credit_hint: 'Press Enter to credit account',
|
||||
@@ -96,7 +98,7 @@ window.localisation.en = {
|
||||
i_understand: 'I understand',
|
||||
copy_wallet_url: 'Copy wallet URL',
|
||||
disclaimer_dialog:
|
||||
'Login functionality to be released in a future update, for now, make sure you bookmark this page for future access to your wallet! This service is in BETA, and we hold no responsibility for people losing access to funds.',
|
||||
'To ensure continuous access to your wallets, please remember to securely store your login credentials! Please visit the "My Account" page. This service is in BETA, and we hold no responsibility for people losing access to funds.',
|
||||
no_transactions: 'No transactions made yet',
|
||||
manage: 'Manage',
|
||||
extensions: 'Extensions',
|
||||
@@ -171,6 +173,7 @@ window.localisation.en = {
|
||||
notification_source_label:
|
||||
'Source URL (only use the official LNbits status source, and sources you can trust)',
|
||||
more: 'more',
|
||||
less: 'less',
|
||||
releases: 'Releases',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Watchdog',
|
||||
@@ -182,6 +185,12 @@ window.localisation.en = {
|
||||
allow_access_hint: 'Allow access by IP (will override blocked IPs)',
|
||||
enter_ip: 'Enter IP and hit enter',
|
||||
rate_limiter: 'Rate Limiter',
|
||||
wallet_limiter: 'Wallet Limiter',
|
||||
wallet_limit_max_withdraw_per_day:
|
||||
'Max daily wallet withdrawal in sats (0 to disable)',
|
||||
wallet_max_ballance: 'Wallet max balance in sats (0 to disable)',
|
||||
wallet_limit_secs_between_trans:
|
||||
'Min secs between transactions per wallet (0 to disable)',
|
||||
number_of_requests: 'Number of requests',
|
||||
time_unit: 'Time unit',
|
||||
minute: 'minute',
|
||||
@@ -189,5 +198,41 @@ window.localisation.en = {
|
||||
hour: 'hour',
|
||||
disable_server_log: 'Disable Server Log',
|
||||
enable_server_log: 'Enable Server Log',
|
||||
coming_soon: 'Feature coming soon'
|
||||
coming_soon: 'Feature coming soon',
|
||||
session_has_expired: 'Your session has expired. Please login again.',
|
||||
instant_access_question: 'Want instant access?',
|
||||
login_with_user_id: 'Login with user ID',
|
||||
or: 'or',
|
||||
create_new_wallet: 'Create New Wallet',
|
||||
login_to_account: 'Login to your account',
|
||||
create_account: 'Create account',
|
||||
account_settings: 'Account Settings',
|
||||
signin_with_google: 'Sign in with Google',
|
||||
signin_with_github: 'Sign in with GitHub',
|
||||
username_or_email: 'Username or Email',
|
||||
password: 'Password',
|
||||
password_config: 'Password Config',
|
||||
password_repeat: 'Password repeat',
|
||||
change_password: 'Change Password',
|
||||
set_password: 'Set Password',
|
||||
invalid_password: 'Password must have at least 8 characters',
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
username: 'Username',
|
||||
user_id: 'User ID',
|
||||
email: 'Email',
|
||||
first_name: 'First Name',
|
||||
last_name: 'Last Name',
|
||||
picture: 'Picture',
|
||||
verify_email: 'Verify email with',
|
||||
account: 'Account',
|
||||
update_account: 'Update Account',
|
||||
invalid_username: 'Invalid Username',
|
||||
auth_provider: 'Auth Provider',
|
||||
my_account: 'My Account',
|
||||
back: 'Back',
|
||||
logout: 'Logout',
|
||||
look_and_feel: 'Look and Feel',
|
||||
language: 'Language',
|
||||
color_scheme: 'Color Scheme'
|
||||
}
|
||||
|
||||
@@ -61,8 +61,9 @@ window.localisation.es = {
|
||||
service_fee_tooltip:
|
||||
'Comisión de servicio cobrada por el administrador del servidor LNbits por cada transacción saliente',
|
||||
toggle_darkmode: 'Cambiar modo oscuro',
|
||||
view_swagger_docs: 'Ver documentos de API de LNbits Swagger',
|
||||
api_docs: 'Documentos de API',
|
||||
view_swagger_docs: 'Ver documentación de API de LNbits Swagger',
|
||||
api_docs: 'Documentación de API',
|
||||
api_keys_api_docs: 'Claves de API y documentación de API',
|
||||
lnbits_version: 'Versión de LNbits',
|
||||
runs_on: 'Corre en',
|
||||
credit_hint: 'Presione Enter para cargar la cuenta',
|
||||
@@ -177,6 +178,7 @@ window.localisation.es = {
|
||||
notification_source_label:
|
||||
'URL de origen (solo use la fuente oficial de estado de LNbits y fuentes en las que confíe)',
|
||||
more: 'más',
|
||||
less: 'menos',
|
||||
releases: 'Lanzamientos',
|
||||
killswitch: 'Interruptor de apagado',
|
||||
watchdog: 'Perro guardián',
|
||||
@@ -195,5 +197,39 @@ window.localisation.es = {
|
||||
hour: 'hora',
|
||||
disable_server_log: 'Desactivar registro del servidor',
|
||||
enable_server_log: 'Activar registro del servidor',
|
||||
coming_soon: 'Función próximamente disponible'
|
||||
coming_soon: 'Función próximamente disponible',
|
||||
session_has_expired:
|
||||
'Tu sesión ha expirado. Por favor, inicia sesión de nuevo.',
|
||||
instant_access_question: '¿Quieres acceso instantáneo?',
|
||||
login_with_user_id: 'Iniciar sesión con ID de usuario',
|
||||
or: 'o',
|
||||
create_new_wallet: 'Crear Nueva Cartera',
|
||||
login_to_account: 'Inicie sesión en su cuenta',
|
||||
create_account: 'Crear cuenta',
|
||||
account_settings: 'Configuración de la cuenta',
|
||||
signin_with_google: 'Inicia sesión con Google',
|
||||
signin_with_github: 'Inicia sesión con GitHub',
|
||||
username_or_email: 'Nombre de usuario o correo electrónico',
|
||||
password: 'Contraseña',
|
||||
password_config: 'Configuración de Contraseña',
|
||||
password_repeat: 'Repetición de contraseña',
|
||||
change_password: 'Cambiar contraseña',
|
||||
set_password: 'Establecer contraseña',
|
||||
invalid_password: 'La contraseña debe tener al menos 8 caracteres.',
|
||||
login: 'Iniciar sesión',
|
||||
register: 'Registrarse',
|
||||
username: 'Nombre de usuario',
|
||||
user_id: 'Identificación de usuario',
|
||||
email: 'Correo electrónico',
|
||||
first_name: 'Nombre de pila',
|
||||
last_name: 'Apellido',
|
||||
picture: 'Imagen',
|
||||
verify_email: 'Verifique el correo electrónico con',
|
||||
account: 'Cuenta',
|
||||
update_account: 'Actualizar cuenta',
|
||||
invalid_username: 'Nombre de usuario inválido',
|
||||
auth_provider: 'Proveedor de Autenticación',
|
||||
my_account: 'Mi cuenta',
|
||||
back: 'Atrás',
|
||||
logout: 'Cerrar sesión'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
window.localisation.fi = {
|
||||
confirm: 'Kyllä',
|
||||
server: 'Palvelin',
|
||||
theme: 'Teema',
|
||||
funding: 'Rahoitus',
|
||||
users: 'Käyttäjät',
|
||||
apps: 'Sovellukset',
|
||||
channels: 'Kanavat',
|
||||
transactions: 'Tapahtumat',
|
||||
dashboard: 'Ohjauspaneeli',
|
||||
node: 'Solmu',
|
||||
total_capacity: 'Kokonaiskapasiteetti',
|
||||
avg_channel_size: 'Keskimääräisen kanavan kapasiteetti',
|
||||
biggest_channel_size: 'Suurimman kanavan kapasiteetti',
|
||||
smallest_channel_size: 'Pienimmän kanavan kapasiteetti',
|
||||
number_of_channels: 'Kanavien lukumäärä',
|
||||
active_channels: 'Aktiivisia kanavia',
|
||||
connect_peer: 'Yhdistä naapuriin',
|
||||
connect: 'Yhdistä',
|
||||
open_channel: 'Avaa kanava',
|
||||
open: 'Avaa',
|
||||
close_channel: 'Sulje kanava',
|
||||
close: 'Sulje',
|
||||
restart: 'Palvelimen uudelleen käynnistys',
|
||||
save: 'Tallenna',
|
||||
save_tooltip: 'Tallenna muutokset',
|
||||
topup: 'Topup',
|
||||
topup_wallet: 'Lisää varoja lompakkoon',
|
||||
topup_hint: 'Lisää varoja lompakkoon sen ID:n perusteella',
|
||||
restart_tooltip: 'Uudelleenkäynnistä palvelu muutosten käyttöönottamiseksi',
|
||||
add_funds_tooltip: 'Lisää varoja lompakkoon',
|
||||
reset_defaults: 'Peruuta muutokset',
|
||||
reset_defaults_tooltip:
|
||||
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
|
||||
download_backup: 'Lataa tietokannan varmuuskopio',
|
||||
name_your_wallet: 'Anna %{name}-lompakollesi nimi',
|
||||
paste_invoice_label:
|
||||
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
|
||||
lnbits_description:
|
||||
'Kevyt ja helppokäyttöinen LNbits voi käyttää rahoituslähteinään erilaisia palveluita, kuten LND, Core Lightning, OpenNode, Alby, LNPay ja jopa itseään! Voit käyttää sitä itsenäisesti ja helposti tarjota erilaisia Lightning-palveluita. Pystyt luomaan sillä salamaverkkolompakoita eikä niiden määrää ole rajoitettu. Jokaiselle lompakolle saat yksilölliset API-avaimet. Varojen osittaminen tekee siitä erittäin kätevän varojen hallinnassa sekä myös ohjelmistokehityksen työkalun. Laajennukset lisäävät LNbits:in toiminnallisuuksia. Näinpä voit helposti testailla useita erilaisia ja viimeisimpiä salamaverkon teknologioita. Laajennuksien kehittämisen olemme pyrkineet tekemään mahdollisimman helpoksi pitämällä LNbits:in ilmaisena OpenSource-projektina. Kannustamme kaikkia kehittämään ja jakelemaan omia laajennuksia!',
|
||||
export_to_phone: 'Käytä puhelimessa lukemalla QR-koodi',
|
||||
export_to_phone_desc:
|
||||
'Tämä QR-koodi sisältää URL-osoitteen, jolla saa lompakkoosi täydet valtuudet. Voi lukea sen puhelimellasi ja avata sillä lompakkosi. Voit myös lisätä lompakkosi selaimella käytettäväksi PWA-sovellukseksi puhelimen aloitusruudulle. ',
|
||||
wallets: 'Lompakot',
|
||||
add_wallet: 'Lisää lompakko',
|
||||
delete_wallet: 'Poista lompakko',
|
||||
delete_wallet_desc:
|
||||
'Lompakko poistetaan pysyvästi. Siirrä lompakosta varat ennalta muualle, sillä tämä toiminto on PERUUTTAMATON!',
|
||||
rename_wallet: 'Nimeä lompakko uudelleen',
|
||||
update_name: 'Tallenna',
|
||||
fiat_tracking: 'Käytettävä valuutta',
|
||||
currency: 'Valuutta',
|
||||
update_currency: 'Tallenna',
|
||||
press_to_claim: 'Lunasta varat painamalla tästä',
|
||||
donate: 'Lahjoita',
|
||||
view_github: 'Näytä GitHub:ssa',
|
||||
voidwallet_active:
|
||||
'Maksutapahtumat ovat poissa käytöstä, koska VoidWallet on aktiivinen!',
|
||||
use_with_caution:
|
||||
'KÄYTÄ VAROEN - BETA-ohjelmisto on käytössä palvelussa: %{name}',
|
||||
service_fee: 'Palvelumaksu: %{amount} % tapahtumasta',
|
||||
service_fee_max:
|
||||
'Palvelumaksu: %{amount} % tapahtumasta (enintään %{max} sat)',
|
||||
service_fee_tooltip:
|
||||
'LNBits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
|
||||
toggle_darkmode: 'Tumma näkymä',
|
||||
toggle_reactions: 'Käytä tapahtuma efektejä',
|
||||
view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit',
|
||||
api_docs: 'API-dokumentaatio',
|
||||
api_keys_api_docs: 'API-avaimet ja -dokumentaatio',
|
||||
lnbits_version: 'LNbits versio',
|
||||
runs_on: 'Mukana menossa',
|
||||
credit_hint: 'Hyväksy painamalla Enter',
|
||||
credit_label: 'Lisää tilille varoja %{denomination}',
|
||||
paste: 'Liitä',
|
||||
paste_from_clipboard: 'Liitä leikepöydältä',
|
||||
paste_request: 'Liitä pyyntö',
|
||||
create_invoice: 'Laskuta',
|
||||
camera_tooltip: 'Kuvaa lasku tai QR-koodi',
|
||||
export_csv: 'Vie CSV-tiedostoon',
|
||||
chart_tooltip: 'Näytä kaaviokuva',
|
||||
pending: 'Odottaa',
|
||||
copy_invoice: 'Kopioi lasku',
|
||||
withdraw_from: 'Nosta kohteesta',
|
||||
cancel: 'Peruuta',
|
||||
scan: 'Scannaa',
|
||||
read: 'Lue',
|
||||
pay: 'Maksa',
|
||||
memo: 'Kuvaus',
|
||||
date: 'Päiväys',
|
||||
processing_payment: 'Maksua käsitellään...',
|
||||
not_enough_funds: 'Varat eivät riitä!',
|
||||
search_by_tag_memo_amount: 'Etsi tunnisteella, muistiolla tai määrällä',
|
||||
invoice_waiting: 'Lasku osottaa maksamista',
|
||||
payment_received: 'Maksu vastaanotettu',
|
||||
payment_sent: 'Maksu lähetetty',
|
||||
receive: 'vastaanota',
|
||||
send: 'lähetä',
|
||||
outgoing_payment_pending: 'Lähtevä maksu odottaa',
|
||||
drain_funds: 'Tyhjennä varat',
|
||||
drain_funds_desc:
|
||||
'Tämä LNURL-withdraw -tyyppinen QR-koodi on tarkoitettu kaikkien varojen imurointiin lompakosta. ÄLÄ JAA SITÄ KENELLEKÄÄN! Se on balanceCheck- ja balanceNotify-toimintojen kanssa yhteensopiva, joten sitä voi käyttää lompakon tyhjentämiseen ensimmäisen käytön jälleen jatkuvasti.',
|
||||
i_understand: 'Vakuutan ymmärtäväni',
|
||||
copy_wallet_url: 'Kopioi lompakon URL',
|
||||
disclaimer_dialog:
|
||||
'Muistathan tallettaa kirjautumistietosi turvallisesta ja helposti saataville, jotta pääset jatkossakin kirjautumaan lompakkoosi! Tutustu myös Tilin asetukset -sivuun. Tämä palvelu on kokeiluvaiheessa (eli BETA), ja niinpä kukaan ei ota mitään vastuuta varojen säilymisestä tai niiden käytettävyyden takaamisesta.',
|
||||
no_transactions: 'Lompakossa ei ole yhtään tapahtumaa',
|
||||
manage: 'Hallinnointi',
|
||||
extensions: 'Laajennukset',
|
||||
no_extensions: 'Laajennuksia ei ole asennettu :(',
|
||||
created: 'Luotu',
|
||||
search_extensions: 'Etsi laajennuksia',
|
||||
warning: 'Varoitus',
|
||||
repository: 'Laajennuksien lähde',
|
||||
confirm_continue: 'Haluatko varmasti jatkaa?',
|
||||
manage_extension_details: 'Asenna/Poista laajennus',
|
||||
install: 'Asenna',
|
||||
uninstall: 'Poista',
|
||||
drop_db: 'Poista tiedot',
|
||||
enable: 'Ota käyttöön',
|
||||
enable_extension_details: 'Ota laajennus käyttöön tälle käyttäjälle',
|
||||
disable: 'Poista käytöstä',
|
||||
installed: 'Asennettu',
|
||||
activated: 'Käytössä',
|
||||
deactivated: 'Poissa käytöstä',
|
||||
release_notes: 'Julkaisutiedot',
|
||||
activate_extension_details: 'Aseta/Poista laajennus käyttäjien saatavilta',
|
||||
featured: 'Esittelyssä',
|
||||
all: 'Kaikki',
|
||||
only_admins_can_install: '(Vain pääkäyttäjät voivat asentaa laajennuksia)',
|
||||
admin_only: 'Pääkäyttäjille',
|
||||
new_version: 'Uusi versio',
|
||||
extension_depends_on: 'Edellyttää:',
|
||||
extension_rating_soon: 'Arvostelut on tulossa pian',
|
||||
extension_installed_version: 'Nykyinen versio',
|
||||
extension_uninstall_warning:
|
||||
'Olet poistamassa laajennuksen kaikilta käyttäjiltä.',
|
||||
uninstall_confirm: 'Kyllä, poista asennus',
|
||||
extension_db_drop_info:
|
||||
'Kaikki laajennuksen tallettama tieto poistetaan pysyvästi. Poistoa ei voi jälkikäteen peruuttaa!',
|
||||
extension_db_drop_warning:
|
||||
'Olet tuhoamassa laajennuksen tallettamat tiedot. Vahvista poisto kirjoittamalla viivalle seuraavassa näkyvä laajennuksen nimi:',
|
||||
extension_min_lnbits_version: 'Tämä julkaisu vaatii vähintään LNbits-version',
|
||||
payment_hash: 'Maksun tiiviste',
|
||||
fee: 'Kulu',
|
||||
amount: 'Määrä',
|
||||
tag: 'Tunniste',
|
||||
unit: 'Yksikkö',
|
||||
description: 'Kuvaus',
|
||||
expiry: 'Vanheneminen',
|
||||
webhook: 'Webhook',
|
||||
payment_proof: 'Maksun varmenne',
|
||||
update_available: 'Saatavilla on päivitys versioon %{version}!',
|
||||
latest_update:
|
||||
'Käytössä oleva versio %{version}, on viimeisin saatavilla oleva.',
|
||||
notifications: 'Tiedotteet',
|
||||
no_notifications: 'Ei tiedotteita',
|
||||
notifications_disabled: 'LNbits-tilatiedotteet on poistettu käytöstä.',
|
||||
enable_notifications: 'Ota tiedotteet käyttöön',
|
||||
enable_notifications_desc:
|
||||
'Tämän ollessa valittuna, noudetaan LNbits-tilatiedotteet. Niitä ovat esimerkiksi turvallisuuteen liittyvät tapahtumatiedotteet ja tiedot tämän ohjelmiston päivityksistä.',
|
||||
enable_killswitch: 'Ota Killswitch käyttöön',
|
||||
enable_killswitch_desc:
|
||||
'Jos LNbits antaa killswitch-komennon, niin rahoituslähteeksi valitaan automaattisesti heti VoidWallet. Päivityksen jälkeen tämä asetus pitää tarkastaa uudelleen.',
|
||||
killswitch_interval: 'Killswitch-aikaväli',
|
||||
killswitch_interval_desc:
|
||||
'Tällä määritetään kuinka usein taustatoiminto tarkistaa killswitch-signaalin tilatiedotteiden lähteestä. Hakujen väli ilmoitetaan minuutteina.',
|
||||
enable_watchdog: 'Ota Watchdog käyttöön',
|
||||
enable_watchdog_desc:
|
||||
'Tämän ollessa käytössä, ja solmun varojen laskiessa alle LNbits-varojen määrän, otetaan automaattisesti käyttöön VoidWallet. Päivityksen jälkeen tämä asetus pitää tarkastaa uudelleen.',
|
||||
watchdog_interval: 'Watchdog-aikaväli',
|
||||
watchdog_interval_desc:
|
||||
'Tällä määritetään kuinka usein taustatoiminto tarkistaa varojen Delta-muutokset [node_balance - lnbits_balance] killswitch-signaalille. Hakujen väli ilmoitetaan minuutteina.',
|
||||
watchdog_delta: 'Watchdog Delta',
|
||||
watchdog_delta_desc:
|
||||
'Saldomuutoksen raja-arvo jolloin killswitch-muuttaa rahoituslähteeksi VoidWallet:in [lnbits_balance - node_balance > delta]',
|
||||
status: 'Tilanne',
|
||||
notification_source: 'Tiedotteiden lähde',
|
||||
notification_source_label:
|
||||
'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)',
|
||||
more: 'enemmän',
|
||||
less: 'vähemmän',
|
||||
releases: 'Julkaisut',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Watchdog',
|
||||
server_logs: 'Palvelimen lokit',
|
||||
ip_blocker: 'IP-suodatin',
|
||||
security: 'Turvallisuus',
|
||||
security_tools: 'Turvallisuus työkalut',
|
||||
block_access_hint: 'Estä pääsy IP-osoitteen perusteella',
|
||||
allow_access_hint: 'Salli pääsy IP-osoitteen perusteella (ohittaa estot)',
|
||||
enter_ip: 'Anna IP ja paina +',
|
||||
rate_limiter: 'Toiston rajoitin',
|
||||
number_of_requests: 'Pyyntöjen lukumäärä',
|
||||
time_unit: 'aikayksikkö',
|
||||
minute: 'minuutti',
|
||||
second: 'sekunti',
|
||||
hour: 'tunti',
|
||||
disable_server_log: 'Poista palvelimen loki käytöstä',
|
||||
enable_server_log: 'Ota palvelimen loki käyttöön',
|
||||
coming_soon: 'Ominaisuus on tulossa pian',
|
||||
session_has_expired: 'Käyttämätön sessio on vanhentunut. Kirjaudu uudelleen.',
|
||||
instant_access_question: 'Kirjaudu aikaisemmin luodulla tiedolla',
|
||||
login_with_user_id: 'Kirjaudu käyttäjä-ID:llä',
|
||||
or: 'tai',
|
||||
create_new_wallet: 'Avaa uusi lompakko',
|
||||
login_to_account: 'Kirjaudu käyttäjänimellä',
|
||||
create_account: 'Luo tili',
|
||||
account_settings: 'Tilin asetukset',
|
||||
signin_with_google: 'Kirjaudu Google-tunnuksella',
|
||||
signin_with_github: 'Kirjaudu GitHub-tunnuksella',
|
||||
username_or_email: 'Käyttäjänimi tai sähköposti',
|
||||
password: 'Anna uusi salasana',
|
||||
password_config: 'Salasanan määritys',
|
||||
password_repeat: 'Toista uusi salasana',
|
||||
change_password: 'Vaihda salasana',
|
||||
set_password: 'Aseta salasana',
|
||||
invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä',
|
||||
login: 'Kirjaudu',
|
||||
register: 'Rekisteröidy',
|
||||
username: 'Käyttäjänimi',
|
||||
user_id: 'Käyttäjä ID',
|
||||
email: 'Sähköposti',
|
||||
first_name: 'Etunimi',
|
||||
last_name: 'Sukunimi',
|
||||
picture: 'Kuva',
|
||||
verify_email: 'Vahvista sähköposti',
|
||||
account: 'Tili',
|
||||
update_account: 'Päivitä tiliä',
|
||||
invalid_username: 'Virheellinen käyttäjänimi',
|
||||
auth_provider: 'Tunnistamisen toimittaja',
|
||||
my_account: 'Tilini',
|
||||
back: 'Takaisin',
|
||||
logout: 'Poistu',
|
||||
look_and_feel: 'Kieli ja värit',
|
||||
language: 'Kieli',
|
||||
color_scheme: 'Väriteema'
|
||||
}
|
||||
@@ -65,8 +65,9 @@ window.localisation.fr = {
|
||||
service_fee_tooltip:
|
||||
"Frais de service facturés par l'administrateur du serveur LNbits pour chaque transaction sortante",
|
||||
toggle_darkmode: 'Basculer le mode sombre',
|
||||
view_swagger_docs: "Voir les documents de l'API Swagger de LNbits",
|
||||
api_docs: "Documents de l'API",
|
||||
view_swagger_docs: "Voir les documentation de l'API Swagger de LNbits",
|
||||
api_docs: "Documentation de l'API",
|
||||
api_keys_api_docs: "Clés API et documentation de l'API",
|
||||
lnbits_version: 'Version de LNbits',
|
||||
runs_on: 'Fonctionne sur',
|
||||
credit_hint: 'Appuyez sur Entrée pour créditer le compte',
|
||||
@@ -181,6 +182,7 @@ window.localisation.fr = {
|
||||
notification_source_label:
|
||||
'URL source (utilisez uniquement la source officielle de statut LNbits et des sources de confiance)',
|
||||
more: 'plus',
|
||||
less: 'moins',
|
||||
releases: 'Versions',
|
||||
killswitch: "Interrupteur d'arrêt",
|
||||
watchdog: 'Chien de garde',
|
||||
@@ -200,5 +202,38 @@ window.localisation.fr = {
|
||||
hour: 'heure',
|
||||
disable_server_log: 'Désactiver le journal du serveur',
|
||||
enable_server_log: 'Activer le journal du serveur',
|
||||
coming_soon: 'Fonctionnalité à venir bientôt'
|
||||
coming_soon: 'Fonctionnalité à venir bientôt',
|
||||
session_has_expired: 'Votre session a expiré. Veuillez vous reconnecter.',
|
||||
instant_access_question: 'Voulez-vous un accès instantané ?',
|
||||
login_with_user_id: "Connexion avec l'identifiant utilisateur",
|
||||
or: 'ou',
|
||||
create_new_wallet: 'Créer un nouveau portefeuille',
|
||||
login_to_account: 'Connectez-vous à votre compte',
|
||||
create_account: 'Créer un compte',
|
||||
account_settings: 'Paramètres du compte',
|
||||
signin_with_google: 'Connectez-vous avec Google',
|
||||
signin_with_github: 'Connectez-vous avec GitHub',
|
||||
username_or_email: "Nom d'utilisateur ou e-mail",
|
||||
password: 'Mot de passe',
|
||||
password_config: 'Configuration du mot de passe',
|
||||
password_repeat: 'Répétition du mot de passe',
|
||||
change_password: 'Changer le mot de passe',
|
||||
set_password: 'Définir le mot de passe',
|
||||
invalid_password: 'Le mot de passe doit comporter au moins 8 caractères',
|
||||
login: 'Connexion',
|
||||
register: 'Inscrire',
|
||||
username: "Nom d'utilisateur",
|
||||
user_id: 'Identifiant utilisateur',
|
||||
email: 'E-mail',
|
||||
first_name: 'Prénom',
|
||||
last_name: 'Nom de famille',
|
||||
picture: 'Image',
|
||||
verify_email: "Vérifiez l'e-mail avec",
|
||||
account: 'Compte',
|
||||
update_account: 'Mettre à jour le compte',
|
||||
invalid_username: "Nom d'utilisateur invalide",
|
||||
auth_provider: "Fournisseur d'authentification",
|
||||
my_account: 'Mon compte',
|
||||
back: 'Retour',
|
||||
logout: 'Déconnexion'
|
||||
}
|
||||
|
||||
@@ -62,8 +62,9 @@ window.localisation.it = {
|
||||
service_fee_tooltip:
|
||||
"Commissione di servizio addebitata dall'amministratore del server LNbits per ogni transazione in uscita",
|
||||
toggle_darkmode: 'Attiva la modalità notturna',
|
||||
view_swagger_docs: "Visualizza i documenti dell'API Swagger di LNbits",
|
||||
api_docs: 'Documenti API',
|
||||
view_swagger_docs: "Visualizza i documentazione dell'API Swagger di LNbits",
|
||||
api_docs: "Documentazione dell'API",
|
||||
api_keys_api_docs: "Chiavi API e documentazione dell'API",
|
||||
lnbits_version: 'Versione di LNbits',
|
||||
runs_on: 'Esegue su',
|
||||
credit_hint: 'Premere Invio per accreditare i fondi',
|
||||
@@ -177,6 +178,7 @@ window.localisation.it = {
|
||||
notification_source_label:
|
||||
'URL sorgente (utilizzare solo la fonte ufficiale di stato LNbits e fonti di cui ti puoi fidare)',
|
||||
more: 'più',
|
||||
less: 'meno',
|
||||
releases: 'Pubblicazioni',
|
||||
killswitch: 'Interruttore di spegnimento',
|
||||
watchdog: 'Cane da guardia',
|
||||
@@ -196,5 +198,39 @@ window.localisation.it = {
|
||||
hour: 'ora',
|
||||
disable_server_log: 'Disabilita Registro Server',
|
||||
enable_server_log: 'Attiva Registro Server',
|
||||
coming_soon: 'Caratteristica in arrivo prossimamente'
|
||||
coming_soon: 'Caratteristica in arrivo prossimamente',
|
||||
session_has_expired:
|
||||
'La tua sessione è scaduta. Per favore, effettua nuovamente il login.',
|
||||
instant_access_question: 'Vuoi accesso immediato?',
|
||||
login_with_user_id: 'Accedi con ID utente',
|
||||
or: 'oppure',
|
||||
create_new_wallet: 'Crea nuovo portafoglio',
|
||||
login_to_account: 'Accedi al tuo account',
|
||||
create_account: 'Crea un account',
|
||||
account_settings: "Impostazioni dell'account",
|
||||
signin_with_google: 'Accedi con Google',
|
||||
signin_with_github: 'Accedi con GitHub',
|
||||
username_or_email: 'Nome utente o Email',
|
||||
password: 'Password',
|
||||
password_config: 'Configurazione della password',
|
||||
password_repeat: 'Ripeti la password',
|
||||
change_password: 'Cambia Password',
|
||||
set_password: 'Imposta password',
|
||||
invalid_password: 'La password deve contenere almeno 8 caratteri',
|
||||
login: 'Accesso',
|
||||
register: 'Registrati',
|
||||
username: 'Nome utente',
|
||||
user_id: 'ID utente',
|
||||
email: 'Email',
|
||||
first_name: 'Nome',
|
||||
last_name: 'Cognome',
|
||||
picture: 'Immagine',
|
||||
verify_email: 'Verifica email con',
|
||||
account: 'Conto',
|
||||
update_account: 'Aggiorna Account',
|
||||
invalid_username: 'Nome utente non valido',
|
||||
auth_provider: 'Provider di Autenticazione',
|
||||
my_account: 'Il mio account',
|
||||
back: 'Indietro',
|
||||
logout: 'Esci'
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ window.localisation.jp = {
|
||||
toggle_darkmode: 'ダークモードを切り替える',
|
||||
view_swagger_docs: 'Swaggerドキュメントを表示',
|
||||
api_docs: 'APIドキュメント',
|
||||
api_keys_api_docs: 'APIキーとAPIドキュメント',
|
||||
lnbits_version: 'LNbits バージョン',
|
||||
runs_on: 'で実行',
|
||||
credit_hint:
|
||||
@@ -174,6 +175,7 @@ window.localisation.jp = {
|
||||
notification_source_label:
|
||||
'ソースURL(公式のLNbitsステータスソースのみを使用し、信頼できるソースのみを利用してください)',
|
||||
more: 'より多くの',
|
||||
less: '少ない',
|
||||
releases: 'リリース',
|
||||
killswitch: 'キルスイッチ',
|
||||
watchdog: 'ウォッチドッグ',
|
||||
@@ -193,5 +195,39 @@ window.localisation.jp = {
|
||||
hour: '時間',
|
||||
disable_server_log: 'サーバーログを無効にする',
|
||||
enable_server_log: 'サーバーログを有効にする',
|
||||
coming_soon: '機能は間もなく登場します'
|
||||
coming_soon: '機能は間もなく登場します',
|
||||
session_has_expired:
|
||||
'あなたのセッションは期限切れです。もう一度ログインしてください。',
|
||||
instant_access_question: '即時アクセスをご希望ですか?',
|
||||
login_with_user_id: 'ユーザーIDでログイン',
|
||||
or: 'または',
|
||||
create_new_wallet: '新しいウォレットを作成',
|
||||
login_to_account: 'アカウントにログインしてください',
|
||||
create_account: 'アカウントを作成',
|
||||
account_settings: 'アカウント設定',
|
||||
signin_with_google: 'Googleでサインイン',
|
||||
signin_with_github: 'GitHubでサインイン',
|
||||
username_or_email: 'ユーザー名またはメールアドレス',
|
||||
password: 'パスワード',
|
||||
password_config: 'パスワード設定',
|
||||
password_repeat: 'パスワードの再入力',
|
||||
change_password: 'パスワードを変更',
|
||||
set_password: 'パスワードを設定',
|
||||
invalid_password: 'パスワードは少なくとも8文字必要です',
|
||||
login: 'ログイン',
|
||||
register: '登録',
|
||||
username: 'ユーザー名',
|
||||
user_id: 'ユーザーID',
|
||||
email: 'メール',
|
||||
first_name: '名',
|
||||
last_name: '姓',
|
||||
picture: '写真',
|
||||
verify_email: 'メールアドレスの確認を行ってください',
|
||||
account: 'アカウント',
|
||||
update_account: 'アカウントを更新',
|
||||
invalid_username: '無効なユーザー名',
|
||||
auth_provider: '認証プロバイダ',
|
||||
my_account: 'マイアカウント',
|
||||
back: '戻る',
|
||||
logout: 'ログアウト'
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ window.localisation.kr = {
|
||||
toggle_darkmode: '다크 모드 전환',
|
||||
view_swagger_docs: 'LNbits Swagger API 문서를 봅니다',
|
||||
api_docs: 'API 문서',
|
||||
api_keys_api_docs: 'API 키와 API 문서',
|
||||
lnbits_version: 'LNbits 버전',
|
||||
runs_on: 'Runs on',
|
||||
credit_hint: '계정에 자금을 넣으려면 Enter를 눌러주세요',
|
||||
@@ -174,6 +175,7 @@ window.localisation.kr = {
|
||||
notification_source_label:
|
||||
'알림 메세지를 가져올 URL (공식 LNbits 상황판 출처나, 당신이 신뢰할 수 있는 출처만을 사용하세요)',
|
||||
more: '더 알아보기',
|
||||
less: '적게',
|
||||
releases: '배포 버전들',
|
||||
killswitch: '비상 정지',
|
||||
watchdog: '와치독',
|
||||
@@ -192,5 +194,38 @@ window.localisation.kr = {
|
||||
hour: '시간',
|
||||
disable_server_log: '서버 로깅 중단하기',
|
||||
enable_server_log: '서버 로깅 활성화하기',
|
||||
coming_soon: '곧 구현될 기능들입니다'
|
||||
coming_soon: '곧 구현될 기능들입니다',
|
||||
session_has_expired: '세션 유효 기간이 만료되었습니다. 다시 로그인해 주세요.',
|
||||
instant_access_question: '즉시 액세스하시겠습니까?',
|
||||
login_with_user_id: '사용자 ID로 로그인',
|
||||
or: '또는',
|
||||
create_new_wallet: '새 지갑 만들기',
|
||||
login_to_account: '계정에 로그인하세요.',
|
||||
create_account: '계정 생성',
|
||||
account_settings: '계정 설정',
|
||||
signin_with_google: 'Google으로 로그인',
|
||||
signin_with_github: 'GitHub으로 로그인',
|
||||
username_or_email: '사용자 이름 또는 이메일',
|
||||
password: '비밀번호',
|
||||
password_config: '비밀번호 설정',
|
||||
password_repeat: '비밀번호 재입력',
|
||||
change_password: '비밀번호 변경',
|
||||
set_password: '비밀번호 설정',
|
||||
invalid_password: '비밀번호는 최소 8자 이상이어야 합니다',
|
||||
login: '로그인',
|
||||
register: '등록',
|
||||
username: '사용자 이름',
|
||||
user_id: '사용자 ID',
|
||||
email: '이메일',
|
||||
first_name: '성명',
|
||||
last_name: '성',
|
||||
picture: '사진',
|
||||
verify_email: '이메일을 인증하려면',
|
||||
account: '계정',
|
||||
update_account: '계정 업데이트',
|
||||
invalid_username: '잘못된 사용자 이름',
|
||||
auth_provider: '인증 제공자',
|
||||
my_account: '내 계정',
|
||||
back: '뒤로',
|
||||
logout: '로그아웃'
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ window.localisation.nl = {
|
||||
toggle_darkmode: 'Donkere modus aan/uit',
|
||||
view_swagger_docs: 'Bekijk LNbits Swagger API-documentatie',
|
||||
api_docs: 'API-documentatie',
|
||||
api_keys_api_docs: 'API-sleutels en API-documentatie',
|
||||
lnbits_version: 'LNbits-versie',
|
||||
runs_on: 'Draait op',
|
||||
credit_hint: 'Druk op Enter om de rekening te crediteren',
|
||||
@@ -177,6 +178,7 @@ window.localisation.nl = {
|
||||
notification_source_label:
|
||||
'Bron-URL (gebruik alleen de officiële LNbits-statusbron en bronnen die u vertrouwt)',
|
||||
more: 'meer',
|
||||
less: 'minder',
|
||||
releases: 'Uitgaven',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Waakhond',
|
||||
@@ -196,5 +198,38 @@ window.localisation.nl = {
|
||||
hour: 'uur',
|
||||
disable_server_log: 'Serverlog uitschakelen',
|
||||
enable_server_log: 'Activeer Serverlog',
|
||||
coming_soon: 'Functie binnenkort beschikbaar'
|
||||
coming_soon: 'Functie binnenkort beschikbaar',
|
||||
session_has_expired: 'Uw sessie is verlopen. Log alstublieft opnieuw in.',
|
||||
instant_access_question: 'Wil je directe toegang?',
|
||||
login_with_user_id: 'Inloggen met gebruikers-ID',
|
||||
or: 'of',
|
||||
create_new_wallet: 'Nieuwe portemonnee aanmaken',
|
||||
login_to_account: 'Log in op je account',
|
||||
create_account: 'Account aanmaken',
|
||||
account_settings: 'Accountinstellingen',
|
||||
signin_with_google: 'Inloggen met Google',
|
||||
signin_with_github: 'Inloggen met GitHub',
|
||||
username_or_email: 'Gebruikersnaam of e-mail',
|
||||
password: 'Wachtwoord',
|
||||
password_config: 'Wachtwoordconfiguratie',
|
||||
password_repeat: 'Wachtwoord herhalen',
|
||||
change_password: 'Wachtwoord wijzigen',
|
||||
set_password: 'Wachtwoord instellen',
|
||||
invalid_password: 'Wachtwoord moet ten minste 8 tekens bevatten',
|
||||
login: 'Inloggen',
|
||||
register: 'Registreren',
|
||||
username: 'Gebruikersnaam',
|
||||
user_id: 'Gebruikers-ID',
|
||||
email: 'E-mail',
|
||||
first_name: 'Voornaam',
|
||||
last_name: 'Achternaam',
|
||||
picture: 'Foto',
|
||||
verify_email: 'E-mail verifiëren met',
|
||||
account: 'Account',
|
||||
update_account: 'Account bijwerken',
|
||||
invalid_username: 'Ongeldige gebruikersnaam',
|
||||
auth_provider: 'Auth Provider',
|
||||
my_account: 'Mijn Account',
|
||||
back: 'Terug',
|
||||
logout: 'Afmelden'
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ window.localisation.pi = {
|
||||
toggle_darkmode: 'Toggle Dark Mode, arr!',
|
||||
view_swagger_docs: 'View LNbits Swagger API docs and learn the secrets',
|
||||
api_docs: 'API docs for the scallywags',
|
||||
api_keys_api_docs: 'API keys and API docs',
|
||||
lnbits_version: 'LNbits version, arr!',
|
||||
runs_on: 'Runs on, matey',
|
||||
credit_hint: 'Press Enter to credit account and make it richer',
|
||||
@@ -176,6 +177,7 @@ window.localisation.pi = {
|
||||
notification_source_label:
|
||||
"Source URL (only use th' official LNbits status source, and sources ye can trust)",
|
||||
more: "Arr, 'tis more.",
|
||||
less: "Arr, 'tis more fewer.",
|
||||
releases: 'Releases',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Seadog',
|
||||
@@ -194,5 +196,38 @@ window.localisation.pi = {
|
||||
hour: 'hour',
|
||||
disable_server_log: "Disabl' %{Server} Log",
|
||||
enable_server_log: 'Enable Server Log',
|
||||
coming_soon: "Feature comin' soon"
|
||||
coming_soon: "Feature comin' soon",
|
||||
session_has_expired: 'Yer session has expired. Please login again.',
|
||||
instant_access_question: "Be wantin' quick entry, aye?",
|
||||
login_with_user_id: 'Login with user ID',
|
||||
or: 'arr',
|
||||
create_new_wallet: 'Create New Wallet',
|
||||
login_to_account: 'Log in to yer account',
|
||||
create_account: 'Create account',
|
||||
account_settings: "Account Settin's",
|
||||
signin_with_google: "Sign in wit' Google",
|
||||
signin_with_github: "Sign in wit' GitHub",
|
||||
username_or_email: 'Usarrrname or Email',
|
||||
password: 'Passwarrd',
|
||||
password_config: 'Passwarrd Config',
|
||||
password_repeat: 'Passwarrd repeat',
|
||||
change_password: 'Change Passwarrd',
|
||||
set_password: 'Set yer Secret Code',
|
||||
invalid_password: "Passwarrd must be havin' at leest 8 charrracters",
|
||||
login: 'Log in',
|
||||
register: 'Sign on',
|
||||
username: 'Username',
|
||||
user_id: 'User ID',
|
||||
email: 'Email',
|
||||
first_name: 'Firrrst Name',
|
||||
last_name: 'Surname',
|
||||
picture: "pictur'",
|
||||
verify_email: 'Verify email with',
|
||||
account: 'Arrrccount',
|
||||
update_account: "Updatin' Arrrccount",
|
||||
invalid_username: 'Username be not valid, matey!',
|
||||
auth_provider: 'Auth Provider becometh Auth Provider, ye see?',
|
||||
my_account: 'Me Arrrccount',
|
||||
back: 'Return',
|
||||
logout: 'Log out yer session'
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ window.localisation.pl = {
|
||||
toggle_darkmode: 'Tryb nocny',
|
||||
view_swagger_docs: 'Dokumentacja Swagger API',
|
||||
api_docs: 'Dokumentacja API',
|
||||
api_keys_api_docs: 'Klucze API i dokumentacja API',
|
||||
lnbits_version: 'Wersja LNbits',
|
||||
runs_on: 'Działa na',
|
||||
credit_hint: 'Naciśnij Enter aby doładować konto',
|
||||
@@ -174,6 +175,7 @@ window.localisation.pl = {
|
||||
notification_source_label:
|
||||
'Adres URL źródła (używaj tylko oficjalnego źródła statusu LNbits oraz źródeł, którym możesz zaufać)',
|
||||
more: 'więcej',
|
||||
less: 'mniej',
|
||||
releases: 'Wydania',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Pies gończy',
|
||||
@@ -193,5 +195,38 @@ window.localisation.pl = {
|
||||
hour: 'godzina',
|
||||
disable_server_log: 'Wyłącz log serwera',
|
||||
enable_server_log: 'Włącz dziennik serwera',
|
||||
coming_soon: 'Funkcja wkrótce będzie dostępna'
|
||||
coming_soon: 'Funkcja wkrótce będzie dostępna',
|
||||
session_has_expired: 'Twoja sesja wygasła. Proszę zaloguj się ponownie.',
|
||||
instant_access_question: 'Chcesz mieć natychmiastowy dostęp?',
|
||||
login_with_user_id: 'Zaloguj się za pomocą identyfikatora użytkownika',
|
||||
or: 'lub',
|
||||
create_new_wallet: 'Utwórz nowy portfel',
|
||||
login_to_account: 'Zaloguj się do swojego konta',
|
||||
create_account: 'Załóż konto',
|
||||
account_settings: 'Ustawienia konta',
|
||||
signin_with_google: 'Zaloguj się przez Google',
|
||||
signin_with_github: 'Zaloguj się przez GitHub',
|
||||
username_or_email: 'Nazwa użytkownika lub Email',
|
||||
password: 'Hasło',
|
||||
password_config: 'Konfiguracja Hasła',
|
||||
password_repeat: 'Powtórz hasło',
|
||||
change_password: 'Zmień hasło',
|
||||
set_password: 'Ustaw hasło',
|
||||
invalid_password: 'Hasło musi zawierać co najmniej 8 znaków',
|
||||
login: 'Logowanie',
|
||||
register: 'Zarejestruj',
|
||||
username: 'Nazwa użytkownika',
|
||||
user_id: 'Identyfikator użytkownika',
|
||||
email: 'Email',
|
||||
first_name: 'Imię',
|
||||
last_name: 'Nazwisko',
|
||||
picture: 'Zdjęcie',
|
||||
verify_email: 'Zweryfikuj email za pomocą',
|
||||
account: 'Konto',
|
||||
update_account: 'Aktualizuj konto',
|
||||
invalid_username: 'Nieprawidłowa nazwa użytkownika',
|
||||
auth_provider: 'Dostawca uwierzytelniania',
|
||||
my_account: 'Moje Konto',
|
||||
back: 'Wstecz',
|
||||
logout: 'Wyloguj'
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ window.localisation.pt = {
|
||||
toggle_darkmode: 'Alternar modo escuro',
|
||||
view_swagger_docs: 'Ver a documentação da API do LNbits Swagger',
|
||||
api_docs: 'Documentação da API',
|
||||
api_keys_api_docs: 'Chaves de API e documentação de API',
|
||||
lnbits_version: 'Versão do LNbits',
|
||||
runs_on: 'Executa em',
|
||||
credit_hint: 'Pressione Enter para creditar a conta',
|
||||
@@ -176,6 +177,7 @@ window.localisation.pt = {
|
||||
notification_source_label:
|
||||
'URL de Origem (use apenas a fonte oficial de status do LNbits e fontes em que confia)',
|
||||
more: 'mais',
|
||||
less: 'menos',
|
||||
releases: 'Lançamentos',
|
||||
killswitch: 'Interruptor de desativação',
|
||||
watchdog: 'Cão de guarda',
|
||||
@@ -194,5 +196,38 @@ window.localisation.pt = {
|
||||
hour: 'hora',
|
||||
disable_server_log: 'Desativar Log do Servidor',
|
||||
enable_server_log: 'Ativar Log do Servidor',
|
||||
coming_soon: 'Funcionalidade em breve'
|
||||
coming_soon: 'Funcionalidade em breve',
|
||||
session_has_expired: 'Sua sessão expirou. Por favor, faça login novamente.',
|
||||
instant_access_question: 'Quer acesso imediato?',
|
||||
login_with_user_id: 'Entrar com ID do usuário',
|
||||
or: 'ou',
|
||||
create_new_wallet: 'Criar Nova Carteira',
|
||||
login_to_account: 'Faça login na sua conta',
|
||||
create_account: 'Criar conta',
|
||||
account_settings: 'Configurações da Conta',
|
||||
signin_with_google: 'Entrar com o Google',
|
||||
signin_with_github: 'Entrar com o GitHub',
|
||||
username_or_email: 'Nome de usuário ou Email',
|
||||
password: 'Senha',
|
||||
password_config: 'Configuração de Senha',
|
||||
password_repeat: 'Repetição de senha',
|
||||
change_password: 'Alterar Senha',
|
||||
set_password: 'Definir Senha',
|
||||
invalid_password: 'A senha deve ter pelo menos 8 caracteres',
|
||||
login: 'Entrar',
|
||||
register: 'Registrar',
|
||||
username: 'Nome de usuário',
|
||||
user_id: 'ID do Usuário',
|
||||
email: 'E-mail',
|
||||
first_name: 'Nome próprio',
|
||||
last_name: 'Sobrenome',
|
||||
picture: 'Foto',
|
||||
verify_email: 'Verifique o e-mail com',
|
||||
account: 'Conta',
|
||||
update_account: 'Atualizar Conta',
|
||||
invalid_username: 'Nome de usuário inválido',
|
||||
auth_provider: 'Provedor de Autenticação',
|
||||
my_account: 'Minha Conta',
|
||||
back: 'Voltar',
|
||||
logout: 'Sair'
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ window.localisation.sk = {
|
||||
toggle_darkmode: 'Prepnúť Tmavý režim',
|
||||
view_swagger_docs: 'Zobraziť LNbits Swagger API dokumentáciu',
|
||||
api_docs: 'API dokumentácia',
|
||||
api_keys_api_docs: 'API kľúče a API dokumentácia',
|
||||
lnbits_version: 'Verzia LNbits',
|
||||
runs_on: 'Beží na',
|
||||
credit_hint: 'Stlačte Enter pre pripísanie na účet',
|
||||
@@ -174,6 +175,7 @@ window.localisation.sk = {
|
||||
notification_source_label:
|
||||
'URL zdroja (používajte len oficiálny LNbits zdroj stavu a zdroje, ktorým môžete dôverovať)',
|
||||
more: 'viac',
|
||||
less: 'menej',
|
||||
releases: 'Vydania',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Watchdog',
|
||||
@@ -192,5 +194,38 @@ window.localisation.sk = {
|
||||
hour: 'hodina',
|
||||
disable_server_log: 'Zakázať Log servera',
|
||||
enable_server_log: 'Povoliť Log servera',
|
||||
coming_soon: 'Funkcia bude čoskoro dostupná'
|
||||
coming_soon: 'Funkcia bude čoskoro dostupná',
|
||||
session_has_expired: 'Vaša relácia vypršala. Prosím, prihláste sa znova.',
|
||||
instant_access_question: 'Chcete okamžitý prístup?',
|
||||
login_with_user_id: 'Prihlásiť sa s používateľským ID',
|
||||
or: 'alebo',
|
||||
create_new_wallet: 'Vytvoriť novú peňaženku',
|
||||
login_to_account: 'Prihláste sa do vášho účtu',
|
||||
create_account: 'Vytvoriť účet',
|
||||
account_settings: 'Nastavenia účtu',
|
||||
signin_with_google: 'Prihlásiť sa cez Google',
|
||||
signin_with_github: 'Prihláste sa pomocou GitHub',
|
||||
username_or_email: 'Používateľské meno alebo email',
|
||||
password: 'Heslo',
|
||||
password_config: 'Konfigurácia hesla',
|
||||
password_repeat: 'Opakovanie hesla',
|
||||
change_password: 'Zmeniť heslo',
|
||||
set_password: 'Nastaviť heslo',
|
||||
invalid_password: 'Heslo musí mať aspoň 8 znakov',
|
||||
login: 'Prihlásenie',
|
||||
register: 'Registrovať',
|
||||
username: 'Používateľské meno',
|
||||
user_id: 'ID používateľa',
|
||||
email: 'Email',
|
||||
first_name: 'Meno',
|
||||
last_name: 'Priezvisko',
|
||||
picture: 'Obrázok',
|
||||
verify_email: 'Overiť e-mail s',
|
||||
account: 'Účet',
|
||||
update_account: 'Aktualizovať účet',
|
||||
invalid_username: 'Neplatné užívateľské meno',
|
||||
auth_provider: 'Poskytovateľ autentifikácie',
|
||||
my_account: 'Môj účet',
|
||||
back: 'Späť',
|
||||
logout: 'Odhlásiť sa'
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ window.localisation.we = {
|
||||
"Ffi gwasanaeth a godir gan weinyddwr gweinydd LNbits ym mhob trafodiad sy'n mynd allan",
|
||||
toggle_darkmode: 'Toglo Modd Tywyll',
|
||||
view_swagger_docs: 'Gweld dogfennau API LNbits Swagger',
|
||||
api_docs: 'API docs',
|
||||
api_docs: 'Dogfennau API',
|
||||
api_keys_api_docs: 'Allweddi API a dogfennau API',
|
||||
lnbits_version: 'Fersiwn LNbits',
|
||||
runs_on: 'Yn rhedeg ymlaen',
|
||||
credit_hint: 'Pwyswch Enter i gyfrif credyd',
|
||||
@@ -174,6 +175,7 @@ window.localisation.we = {
|
||||
notification_source_label:
|
||||
'URL Ffynhonnell (defnyddiwch yn unig ffynhonnell statws swyddogol LNbits, a ffynonellau y gallwch ymddiried ynddynt)',
|
||||
more: 'mwy',
|
||||
less: 'llai',
|
||||
releases: 'Rhyddhau',
|
||||
killswitch: 'Killswitch',
|
||||
watchdog: 'Gwyliwr',
|
||||
@@ -193,5 +195,38 @@ window.localisation.we = {
|
||||
hour: 'awr',
|
||||
disable_server_log: 'Analluogi Log Gweinydd',
|
||||
enable_server_log: 'Galluogi Log Gweinydd',
|
||||
coming_soon: 'Nodwedd yn dod yn fuan'
|
||||
coming_soon: 'Nodwedd yn dod yn fuan',
|
||||
session_has_expired: 'Mae eich sesiwn wedi dod i ben. Mewngofnodwch eto.',
|
||||
instant_access_question: 'Eisiau mynediad ar unwaith?',
|
||||
login_with_user_id: 'Mewngofnodi gyda ID y defnyddiwr',
|
||||
or: 'neu',
|
||||
create_new_wallet: 'Creu Waled Newydd',
|
||||
login_to_account: "Mewngofnodwch i'ch cyfrif",
|
||||
create_account: 'Creu cyfrif',
|
||||
account_settings: 'Gosodiadau Cyfrif',
|
||||
signin_with_google: 'Mewngofnodi gyda Google',
|
||||
signin_with_github: 'Mewngofnodi gyda GitHub',
|
||||
username_or_email: 'Defnyddiwr neu E-bost',
|
||||
password: 'Cyfrinair',
|
||||
password_config: 'Ffurfweddiad Cyfrinair',
|
||||
password_repeat: 'Ailadrodd cyfrinair',
|
||||
change_password: 'Newid Cyfrinair',
|
||||
set_password: 'Gosod Cyfrinair',
|
||||
invalid_password: "Rhaid i'r cyfrinair gynnwys o leiaf 8 nod.",
|
||||
login: 'Mewngofnodi',
|
||||
register: 'Cofrestru',
|
||||
username: 'Enw defnyddiwr',
|
||||
user_id: 'ID Defnyddiwr',
|
||||
email: 'E-bost',
|
||||
first_name: 'Enw Cyntaf',
|
||||
last_name: 'Cyfenw',
|
||||
picture: 'Llun',
|
||||
verify_email: 'Gwirio e-bost gyda',
|
||||
account: 'Cyfrif',
|
||||
update_account: 'Diweddaru Cyfrif',
|
||||
invalid_username: 'Enw Defnyddiwr Annilys',
|
||||
auth_provider: 'Darparwr Dilysiad',
|
||||
my_account: 'Fy Nghyfrif',
|
||||
back: 'Yn ôl',
|
||||
logout: 'Allgofnodi'
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -0,0 +1,122 @@
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
user: null,
|
||||
hasUsername: false,
|
||||
showUserId: false,
|
||||
reactionOptions: [
|
||||
'None',
|
||||
'confettiBothSides',
|
||||
'confettiFireworks',
|
||||
'confettiStars'
|
||||
],
|
||||
tab: 'user',
|
||||
passwordData: {
|
||||
show: false,
|
||||
oldPassword: null,
|
||||
newPassword: null,
|
||||
newPasswordRepeat: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
activeLanguage: function (lang) {
|
||||
return window.i18n.locale === lang
|
||||
},
|
||||
changeLanguage: function (newValue) {
|
||||
window.i18n.locale = newValue
|
||||
this.$q.localStorage.set('lnbits.lang', newValue)
|
||||
},
|
||||
toggleDarkMode: function () {
|
||||
this.$q.dark.toggle()
|
||||
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
|
||||
},
|
||||
reactionChoiceFunc: function () {
|
||||
this.$q.localStorage.set('lnbits.reactions', this.reactionChoice)
|
||||
},
|
||||
changeColor: function (newValue) {
|
||||
document.body.setAttribute('data-theme', newValue)
|
||||
this.$q.localStorage.set('lnbits.theme', newValue)
|
||||
},
|
||||
updateAccount: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/api/v1/auth/update',
|
||||
null,
|
||||
{
|
||||
user_id: this.user.id,
|
||||
username: this.user.username,
|
||||
email: this.user.email,
|
||||
config: this.user.config
|
||||
}
|
||||
)
|
||||
this.user = data
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Account updated.'
|
||||
})
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
},
|
||||
updatePassword: async function () {
|
||||
if (!this.user.username) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Please set a username first.'
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/api/v1/auth/password',
|
||||
null,
|
||||
{
|
||||
user_id: this.user.id,
|
||||
username: this.user.username,
|
||||
password_old: this.passwordData.oldPassword,
|
||||
password: this.passwordData.newPassword,
|
||||
password_repeat: this.passwordData.newPasswordRepeat
|
||||
}
|
||||
)
|
||||
this.user = data
|
||||
this.passwordData.show = false
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Password updated.'
|
||||
})
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
},
|
||||
showChangePassword: function () {
|
||||
if (!this.user.username) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Please set a username first.'
|
||||
})
|
||||
return
|
||||
}
|
||||
this.passwordData = {
|
||||
show: true,
|
||||
oldPassword: null,
|
||||
newPassword: null,
|
||||
newPasswordRepeat: null
|
||||
}
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.getAuthenticatedUser()
|
||||
this.user = data
|
||||
this.hasUsername = !!data.username
|
||||
if (!this.user.config) this.user.config = {}
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -175,7 +175,7 @@ new Vue({
|
||||
},
|
||||
restartServer() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id)
|
||||
.request('GET', '/admin/api/v1/restart/')
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
@@ -192,7 +192,7 @@ new Vue({
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/topup/?usr=' + this.g.user.id,
|
||||
'/admin/api/v1/topup/',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
this.wallet
|
||||
)
|
||||
@@ -229,11 +229,7 @@ new Vue({
|
||||
},
|
||||
getAudit() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/audit/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.request('GET', '/admin/api/v1/audit/', this.g.user.wallets[0].adminkey)
|
||||
.then(response => {
|
||||
this.auditData = response.data
|
||||
})
|
||||
@@ -245,7 +241,7 @@ new Vue({
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id,
|
||||
'/admin/api/v1/settings/',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
@@ -266,7 +262,7 @@ new Vue({
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id,
|
||||
'/admin/api/v1/settings/',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
data
|
||||
)
|
||||
@@ -294,7 +290,7 @@ new Vue({
|
||||
.confirmDialog('Are you sure you want to restore settings to default?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request('DELETE', '/admin/api/v1/settings/?usr=' + this.g.user.id)
|
||||
.request('DELETE', '/admin/api/v1/settings/')
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
@@ -310,7 +306,7 @@ new Vue({
|
||||
})
|
||||
},
|
||||
downloadBackup() {
|
||||
window.open('/admin/api/v1/backup/?usr=' + this.g.user.id, '_blank')
|
||||
window.open('/admin/api/v1/backup/', '_blank')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,14 +49,16 @@ window.LNbits = {
|
||||
description_hash,
|
||||
amount,
|
||||
description = '',
|
||||
comment = ''
|
||||
comment = '',
|
||||
unit = ''
|
||||
) {
|
||||
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
|
||||
callback,
|
||||
description_hash,
|
||||
amount,
|
||||
comment,
|
||||
description
|
||||
description,
|
||||
unit
|
||||
})
|
||||
},
|
||||
authLnurl: function (wallet, callback) {
|
||||
@@ -69,6 +71,41 @@ window.LNbits = {
|
||||
name: name
|
||||
})
|
||||
},
|
||||
register: function (username, email, password, password_repeat) {
|
||||
return axios({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/register',
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
password_repeat
|
||||
}
|
||||
})
|
||||
},
|
||||
login: function (username, password) {
|
||||
return axios({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth',
|
||||
data: {username, password}
|
||||
})
|
||||
},
|
||||
loginUsr: function (usr) {
|
||||
return axios({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/usr',
|
||||
data: {usr}
|
||||
})
|
||||
},
|
||||
logout: function () {
|
||||
return axios({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout'
|
||||
})
|
||||
},
|
||||
getAuthenticatedUser: function () {
|
||||
return this.request('get', '/api/v1/auth')
|
||||
},
|
||||
getWallet: function (wallet) {
|
||||
return this.request('get', '/api/v1/wallet', wallet.inkey)
|
||||
},
|
||||
@@ -76,7 +113,7 @@ window.LNbits = {
|
||||
return this.request('post', '/api/v1/wallet', wallet.adminkey, {
|
||||
name: name
|
||||
}).then(res => {
|
||||
window.location = '/wallet?usr=' + res.data.user + '&wal=' + res.data.id
|
||||
window.location = '/wallet?wal=' + res.data.id
|
||||
})
|
||||
},
|
||||
updateWallet: function (name, wallet) {
|
||||
@@ -93,8 +130,7 @@ window.LNbits = {
|
||||
}
|
||||
)
|
||||
},
|
||||
getPayments: function (wallet, query) {
|
||||
const params = new URLSearchParams(query)
|
||||
getPayments: function (wallet, params) {
|
||||
return this.request(
|
||||
'get',
|
||||
'/api/v1/payments/paginated?' + params,
|
||||
@@ -107,6 +143,24 @@ window.LNbits = {
|
||||
'/api/v1/payments/' + paymentHash,
|
||||
wallet.inkey
|
||||
)
|
||||
},
|
||||
updateBalance: function (credit, wallet_id) {
|
||||
return LNbits.api
|
||||
.request('PUT', '/admin/api/v1/topup/', null, {
|
||||
amount: credit,
|
||||
id: wallet_id
|
||||
})
|
||||
.then(_ => {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Success! Added ' + credit + ' sats to ' + wallet_id,
|
||||
icon: null
|
||||
})
|
||||
return parseInt(credit)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
events: {
|
||||
@@ -114,7 +168,6 @@ window.LNbits = {
|
||||
let listener = ev => {
|
||||
cb(JSON.parse(ev.data))
|
||||
}
|
||||
|
||||
this.listenersCount = this.listenersCount || {[wallet.inkey]: 0}
|
||||
this.listenersCount[wallet.inkey]++
|
||||
|
||||
@@ -200,7 +253,7 @@ window.LNbits = {
|
||||
newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format(
|
||||
newWallet.sat
|
||||
)
|
||||
newWallet.url = ['/wallet?usr=', data.user, '&wal=', data.id].join('')
|
||||
newWallet.url = `/wallet?&wal=${data.id}`
|
||||
return newWallet
|
||||
},
|
||||
payment: function (data) {
|
||||
@@ -311,6 +364,24 @@ window.LNbits = {
|
||||
return data
|
||||
}
|
||||
},
|
||||
prepareFilterQuery(tableConfig, props) {
|
||||
if (props) {
|
||||
tableConfig.pagination = props.pagination
|
||||
}
|
||||
let pagination = tableConfig.pagination
|
||||
tableConfig.loading = true
|
||||
const query = {
|
||||
limit: pagination.rowsPerPage,
|
||||
offset: (pagination.page - 1) * pagination.rowsPerPage,
|
||||
sortby: pagination.sortBy ?? '',
|
||||
direction: pagination.descending ? 'desc' : 'asc',
|
||||
...tableConfig.filter
|
||||
}
|
||||
if (tableConfig.search) {
|
||||
query.search = tableConfig.search
|
||||
}
|
||||
return new URLSearchParams(query)
|
||||
},
|
||||
exportCSV: function (columns, data, fileName) {
|
||||
var wrapCsvValue = function (val, formatFn) {
|
||||
var formatted = formatFn !== void 0 ? formatFn(val) : val
|
||||
@@ -372,6 +443,8 @@ window.windowMixin = {
|
||||
data: function () {
|
||||
return {
|
||||
toggleSubs: true,
|
||||
reactionChoice: 'confettiBothSides',
|
||||
isUserAuthorized: false,
|
||||
g: {
|
||||
offline: !navigator.onLine,
|
||||
visibleDrawer: false,
|
||||
@@ -386,21 +459,10 @@ window.windowMixin = {
|
||||
},
|
||||
|
||||
methods: {
|
||||
activeLanguage: function (lang) {
|
||||
return window.i18n.locale === lang
|
||||
},
|
||||
changeLanguage: function (newValue) {
|
||||
window.i18n.locale = newValue
|
||||
this.$q.localStorage.set('lnbits.lang', newValue)
|
||||
},
|
||||
changeColor: function (newValue) {
|
||||
document.body.setAttribute('data-theme', newValue)
|
||||
this.$q.localStorage.set('lnbits.theme', newValue)
|
||||
},
|
||||
toggleDarkMode: function () {
|
||||
this.$q.dark.toggle()
|
||||
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
|
||||
},
|
||||
copyText: function (text, message, position) {
|
||||
var notify = this.$q.notify
|
||||
Quasar.utils.copyToClipboard(text).then(function () {
|
||||
@@ -409,9 +471,50 @@ window.windowMixin = {
|
||||
position: position || 'bottom'
|
||||
})
|
||||
})
|
||||
},
|
||||
checkUsrInUrl: async function () {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const usr = params.get('usr')
|
||||
if (!usr) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.isUserAuthorized) {
|
||||
await LNbits.api.loginUsr(usr)
|
||||
}
|
||||
|
||||
params.delete('usr')
|
||||
const cleanQueryPrams = params.size ? `?${params.toString()}` : ''
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname + cleanQueryPrams
|
||||
)
|
||||
} finally {
|
||||
this.isUserAuthorized = !!this.$q.cookies.get(
|
||||
'is_lnbits_user_authorized'
|
||||
)
|
||||
}
|
||||
},
|
||||
logout: async function () {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Do you really want to logout?' +
|
||||
' Please visit "My Account" page to check your credentials!'
|
||||
)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.logout()
|
||||
window.location = '/'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
created: async function () {
|
||||
if (
|
||||
this.$q.localStorage.getItem('lnbits.darkMode') == true ||
|
||||
this.$q.localStorage.getItem('lnbits.darkMode') == false
|
||||
@@ -420,6 +523,9 @@ window.windowMixin = {
|
||||
} else {
|
||||
this.$q.dark.set(true)
|
||||
}
|
||||
this.reactionChoice =
|
||||
this.$q.localStorage.getItem('lnbits.reactions') || 'confettiBothSides'
|
||||
|
||||
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
|
||||
|
||||
let locale = this.$q.localStorage.getItem('lnbits.lang')
|
||||
@@ -495,6 +601,7 @@ window.windowMixin = {
|
||||
|
||||
this.g.extensions = extensions
|
||||
}
|
||||
await this.checkUsrInUrl()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ Vue.component('lnbits-extension-list', {
|
||||
<q-item v-for="extension in userExtensions" :key="extension.code"
|
||||
clickable
|
||||
:active="extension.isActive"
|
||||
tag="a" :href="[extension.url, '?usr=', user.id].join('')">
|
||||
tag="a" :href="extension.url">
|
||||
<q-item-section side>
|
||||
<q-avatar size="md">
|
||||
<q-img
|
||||
@@ -181,7 +181,7 @@ Vue.component('lnbits-manage', {
|
||||
<q-list v-if="user" dense class="lnbits-drawer__q-list">
|
||||
<q-item-label header v-text="$t('manage')"></q-item-label>
|
||||
<div v-if="user.admin">
|
||||
<q-item v-if='showAdmin' clickable tag="a" :href="['/admin?usr=', user.id].join('')">
|
||||
<q-item v-if='showAdmin' clickable tag="a" href="/admin">
|
||||
<q-item-section side>
|
||||
<q-icon name="admin_panel_settings" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
@@ -189,7 +189,7 @@ Vue.component('lnbits-manage', {
|
||||
<q-item-label lines="1" class="text-caption" v-text="$t('server')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if='showNode' clickable tag="a" :href="['/node?usr=', user.id].join('')">
|
||||
<q-item v-if='showNode' clickable tag="a" href="/node">
|
||||
<q-item-section side>
|
||||
<q-icon name="developer_board" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
@@ -198,7 +198,7 @@ Vue.component('lnbits-manage', {
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<q-item clickable tag="a" :href="['/extensions?usr=', user.id].join('')">
|
||||
<q-item clickable tag="a" href="/extensions">
|
||||
<q-item-section side>
|
||||
<q-icon name="extension" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
@@ -664,3 +664,58 @@ Vue.component('lnbits-dynamic-fields', {
|
||||
this.formData = this.buildData(this.options, this.value)
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('lnbits-update-balance', {
|
||||
mixins: [windowMixin],
|
||||
props: ['wallet_id', 'callback'],
|
||||
computed: {
|
||||
denomination() {
|
||||
return LNBITS_DENOMINATION
|
||||
},
|
||||
admin() {
|
||||
return this.g.user.admin
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
credit: 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateBalance: function (credit) {
|
||||
LNbits.api.updateBalance(credit, this.wallet_id).then(res => {
|
||||
this.callback({value: res, wallet_id: this.wallet_id})
|
||||
})
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<q-btn
|
||||
v-if="admin"
|
||||
round
|
||||
color="primary"
|
||||
icon="add"
|
||||
size="sm"
|
||||
>
|
||||
<q-popup-edit
|
||||
class="bg-accent text-white"
|
||||
v-slot="scope"
|
||||
v-model="credit"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
:label='$t("credit_label", { denomination: denomination })'
|
||||
:hint="$t('credit_hint')"
|
||||
v-model="scope.value"
|
||||
dense
|
||||
autofocus
|
||||
@keyup.enter="updateBalance(scope.value)"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="edit" />
|
||||
</template>
|
||||
</q-input>
|
||||
</q-popup-edit>
|
||||
<q-tooltip>Topup Wallet</q-tooltip>
|
||||
</q-btn>
|
||||
`
|
||||
})
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
function eventReactionWebocket(event_id) {
|
||||
localUrl = ''
|
||||
reaction = localStorage.getItem('lnbits.reactions')
|
||||
if (!reaction || reaction === 'None') {
|
||||
return
|
||||
}
|
||||
if (location.protocol !== 'http:') {
|
||||
localUrl = 'wss://' + location.host + '/api/v1/ws/' + event_id
|
||||
} else {
|
||||
localUrl = 'ws://' + location.host + '/api/v1/ws/' + event_id
|
||||
}
|
||||
connection = new WebSocket(localUrl)
|
||||
connection.onmessage = function (e) {
|
||||
try {
|
||||
const parsedData = JSON.parse(e.data)
|
||||
if (parsedData.payment.amount < 0) {
|
||||
return
|
||||
}
|
||||
reaction = localStorage.getItem('lnbits.reactions')
|
||||
if (reaction) {
|
||||
window[reaction.split('|')[1]]()
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
function confettiBothSides() {
|
||||
document.getElementById('vue').disabled = true
|
||||
var end = Date.now() + 2 * 1000
|
||||
var colors = ['#FFD700', '#ffffff']
|
||||
function frame() {
|
||||
confetti({
|
||||
particleCount: 2,
|
||||
angle: 60,
|
||||
spread: 55,
|
||||
origin: {x: 0},
|
||||
colors: colors,
|
||||
zIndex: 999999
|
||||
})
|
||||
confetti({
|
||||
particleCount: 2,
|
||||
angle: 120,
|
||||
spread: 55,
|
||||
origin: {x: 1},
|
||||
colors: colors,
|
||||
zIndex: 999999
|
||||
})
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame)
|
||||
} else {
|
||||
document.getElementById('vue').disabled = false
|
||||
}
|
||||
}
|
||||
frame()
|
||||
}
|
||||
function confettiFireworks() {
|
||||
var duration = 3 * 1000
|
||||
var animationEnd = Date.now() + duration
|
||||
var defaults = {startVelocity: 30, spread: 360, ticks: 60, zIndex: 0}
|
||||
|
||||
function randomInRange(min, max) {
|
||||
return Math.random() * (max - min) + min
|
||||
}
|
||||
|
||||
var interval = setInterval(function () {
|
||||
var timeLeft = animationEnd - Date.now()
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
return clearInterval(interval)
|
||||
}
|
||||
|
||||
var particleCount = 5 * (timeLeft / duration)
|
||||
// since particles fall down, start a bit higher than random
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: {x: randomInRange(0.1, 0.3), y: Math.random() - 0.2}
|
||||
})
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: {x: randomInRange(0.7, 0.9), y: Math.random() - 0.2}
|
||||
})
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function confettiStars() {
|
||||
var defaults = {
|
||||
spread: 360,
|
||||
ticks: 50,
|
||||
gravity: 0,
|
||||
decay: 0.94,
|
||||
startVelocity: 30,
|
||||
colors: ['FFE400', 'FFBD00', 'E89400', 'FFCA6C', 'FDFFB8']
|
||||
}
|
||||
|
||||
function shoot() {
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 40,
|
||||
scalar: 1.2,
|
||||
shapes: ['star']
|
||||
})
|
||||
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 10,
|
||||
scalar: 0.75,
|
||||
shapes: ['circle']
|
||||
})
|
||||
}
|
||||
|
||||
setTimeout(shoot, 0)
|
||||
setTimeout(shoot, 100)
|
||||
setTimeout(shoot, 200)
|
||||
setTimeout(shoot, 0)
|
||||
setTimeout(shoot, 100)
|
||||
setTimeout(shoot, 200)
|
||||
}
|
||||
!(function (t, e) {
|
||||
!(function t(e, n, a, i) {
|
||||
var o = !!(
|
||||
e.Worker &&
|
||||
e.Blob &&
|
||||
e.Promise &&
|
||||
e.OffscreenCanvas &&
|
||||
e.OffscreenCanvasRenderingContext2D &&
|
||||
e.HTMLCanvasElement &&
|
||||
e.HTMLCanvasElement.prototype.transferControlToOffscreen &&
|
||||
e.URL &&
|
||||
e.URL.createObjectURL
|
||||
)
|
||||
function r() {}
|
||||
function l(t) {
|
||||
var a = n.exports.Promise,
|
||||
i = void 0 !== a ? a : e.Promise
|
||||
return 'function' == typeof i ? new i(t) : (t(r, r), null)
|
||||
}
|
||||
var c,
|
||||
s,
|
||||
u,
|
||||
d,
|
||||
f,
|
||||
h,
|
||||
g,
|
||||
m,
|
||||
b =
|
||||
((u = Math.floor(1e3 / 60)),
|
||||
(d = {}),
|
||||
(f = 0),
|
||||
'function' == typeof requestAnimationFrame &&
|
||||
'function' == typeof cancelAnimationFrame
|
||||
? ((c = function (t) {
|
||||
var e = Math.random()
|
||||
return (
|
||||
(d[e] = requestAnimationFrame(function n(a) {
|
||||
f === a || f + u - 1 < a
|
||||
? ((f = a), delete d[e], t())
|
||||
: (d[e] = requestAnimationFrame(n))
|
||||
})),
|
||||
e
|
||||
)
|
||||
}),
|
||||
(s = function (t) {
|
||||
d[t] && cancelAnimationFrame(d[t])
|
||||
}))
|
||||
: ((c = function (t) {
|
||||
return setTimeout(t, u)
|
||||
}),
|
||||
(s = function (t) {
|
||||
return clearTimeout(t)
|
||||
})),
|
||||
{frame: c, cancel: s}),
|
||||
v =
|
||||
((m = {}),
|
||||
function () {
|
||||
if (h) return h
|
||||
if (!a && o) {
|
||||
var e = [
|
||||
'var CONFETTI, SIZE = {}, module = {};',
|
||||
'(' + t.toString() + ')(this, module, true, SIZE);',
|
||||
'onmessage = function(msg) {',
|
||||
' if (msg.data.options) {',
|
||||
' CONFETTI(msg.data.options).then(function () {',
|
||||
' if (msg.data.callback) {',
|
||||
' postMessage({ callback: msg.data.callback });',
|
||||
' }',
|
||||
' });',
|
||||
' } else if (msg.data.reset) {',
|
||||
' CONFETTI.reset();',
|
||||
' } else if (msg.data.resize) {',
|
||||
' SIZE.width = msg.data.resize.width;',
|
||||
' SIZE.height = msg.data.resize.height;',
|
||||
' } else if (msg.data.canvas) {',
|
||||
' SIZE.width = msg.data.canvas.width;',
|
||||
' SIZE.height = msg.data.canvas.height;',
|
||||
' CONFETTI = module.exports.create(msg.data.canvas);',
|
||||
' }',
|
||||
'}'
|
||||
].join('\n')
|
||||
try {
|
||||
h = new Worker(URL.createObjectURL(new Blob([e])))
|
||||
} catch (t) {
|
||||
return (
|
||||
void 0 !== typeof console &&
|
||||
'function' == typeof console.warn &&
|
||||
console.warn('🎊 Could not load worker', t),
|
||||
null
|
||||
)
|
||||
}
|
||||
!(function (t) {
|
||||
function e(e, n) {
|
||||
t.postMessage({options: e || {}, callback: n})
|
||||
}
|
||||
;(t.init = function (e) {
|
||||
var n = e.transferControlToOffscreen()
|
||||
t.postMessage({canvas: n}, [n])
|
||||
}),
|
||||
(t.fire = function (n, a, i) {
|
||||
if (g) return e(n, null), g
|
||||
var o = Math.random().toString(36).slice(2)
|
||||
return (g = l(function (a) {
|
||||
function r(e) {
|
||||
e.data.callback === o &&
|
||||
(delete m[o],
|
||||
t.removeEventListener('message', r),
|
||||
(g = null),
|
||||
i(),
|
||||
a())
|
||||
}
|
||||
t.addEventListener('message', r),
|
||||
e(n, o),
|
||||
(m[o] = r.bind(null, {data: {callback: o}}))
|
||||
}))
|
||||
}),
|
||||
(t.reset = function () {
|
||||
for (var e in (t.postMessage({reset: !0}), m))
|
||||
m[e](), delete m[e]
|
||||
})
|
||||
})(h)
|
||||
}
|
||||
return h
|
||||
}),
|
||||
y = {
|
||||
particleCount: 50,
|
||||
angle: 90,
|
||||
spread: 45,
|
||||
startVelocity: 45,
|
||||
decay: 0.9,
|
||||
gravity: 1,
|
||||
drift: 0,
|
||||
ticks: 200,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
shapes: ['square', 'circle'],
|
||||
zIndex: 100,
|
||||
colors: [
|
||||
'#26ccff',
|
||||
'#a25afd',
|
||||
'#ff5e7e',
|
||||
'#88ff5a',
|
||||
'#fcff42',
|
||||
'#ffa62d',
|
||||
'#ff36ff'
|
||||
],
|
||||
disableForReducedMotion: !1,
|
||||
scalar: 1
|
||||
}
|
||||
function p(t, e, n) {
|
||||
return (function (t, e) {
|
||||
return e ? e(t) : t
|
||||
})(t && null != t[e] ? t[e] : y[e], n)
|
||||
}
|
||||
function M(t) {
|
||||
return t < 0 ? 0 : Math.floor(t)
|
||||
}
|
||||
function w(t) {
|
||||
return parseInt(t, 16)
|
||||
}
|
||||
function x(t) {
|
||||
return t.map(C)
|
||||
}
|
||||
function C(t) {
|
||||
var e = String(t).replace(/[^0-9a-f]/gi, '')
|
||||
return (
|
||||
e.length < 6 && (e = e[0] + e[0] + e[1] + e[1] + e[2] + e[2]),
|
||||
{
|
||||
r: w(e.substring(0, 2)),
|
||||
g: w(e.substring(2, 4)),
|
||||
b: w(e.substring(4, 6))
|
||||
}
|
||||
)
|
||||
}
|
||||
function k(t) {
|
||||
;(t.width = document.documentElement.clientWidth),
|
||||
(t.height = document.documentElement.clientHeight)
|
||||
}
|
||||
function I(t) {
|
||||
var e = t.getBoundingClientRect()
|
||||
;(t.width = e.width), (t.height = e.height)
|
||||
}
|
||||
function T(t, e, n, o, r) {
|
||||
var c,
|
||||
s,
|
||||
u = e.slice(),
|
||||
d = t.getContext('2d'),
|
||||
f = l(function (e) {
|
||||
function l() {
|
||||
;(c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e()
|
||||
}
|
||||
;(c = b.frame(function e() {
|
||||
!a ||
|
||||
(o.width === i.width && o.height === i.height) ||
|
||||
((o.width = t.width = i.width), (o.height = t.height = i.height)),
|
||||
o.width ||
|
||||
o.height ||
|
||||
(n(t), (o.width = t.width), (o.height = t.height)),
|
||||
d.clearRect(0, 0, o.width, o.height),
|
||||
(u = u.filter(function (t) {
|
||||
return (function (t, e) {
|
||||
;(e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
|
||||
(e.y += Math.sin(e.angle2D) * e.velocity + e.gravity),
|
||||
(e.wobble += 0.1),
|
||||
(e.velocity *= e.decay),
|
||||
(e.tiltAngle += 0.1),
|
||||
(e.tiltSin = Math.sin(e.tiltAngle)),
|
||||
(e.tiltCos = Math.cos(e.tiltAngle)),
|
||||
(e.random = Math.random() + 5),
|
||||
(e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)),
|
||||
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble))
|
||||
var n = e.tick++ / e.totalTicks,
|
||||
a = e.x + e.random * e.tiltCos,
|
||||
i = e.y + e.random * e.tiltSin,
|
||||
o = e.wobbleX + e.random * e.tiltCos,
|
||||
r = e.wobbleY + e.random * e.tiltSin
|
||||
return (
|
||||
(t.fillStyle =
|
||||
'rgba(' +
|
||||
e.color.r +
|
||||
', ' +
|
||||
e.color.g +
|
||||
', ' +
|
||||
e.color.b +
|
||||
', ' +
|
||||
(1 - n) +
|
||||
')'),
|
||||
t.beginPath(),
|
||||
'circle' === e.shape
|
||||
? t.ellipse
|
||||
? t.ellipse(
|
||||
e.x,
|
||||
e.y,
|
||||
Math.abs(o - a) * e.ovalScalar,
|
||||
Math.abs(r - i) * e.ovalScalar,
|
||||
(Math.PI / 10) * e.wobble,
|
||||
0,
|
||||
2 * Math.PI
|
||||
)
|
||||
: (function (t, e, n, a, i, o, r, l, c) {
|
||||
t.save(),
|
||||
t.translate(e, n),
|
||||
t.rotate(o),
|
||||
t.scale(a, i),
|
||||
t.arc(0, 0, 1, r, l, c),
|
||||
t.restore()
|
||||
})(
|
||||
t,
|
||||
e.x,
|
||||
e.y,
|
||||
Math.abs(o - a) * e.ovalScalar,
|
||||
Math.abs(r - i) * e.ovalScalar,
|
||||
(Math.PI / 10) * e.wobble,
|
||||
0,
|
||||
2 * Math.PI
|
||||
)
|
||||
: (t.moveTo(Math.floor(e.x), Math.floor(e.y)),
|
||||
t.lineTo(Math.floor(e.wobbleX), Math.floor(i)),
|
||||
t.lineTo(Math.floor(o), Math.floor(r)),
|
||||
t.lineTo(Math.floor(a), Math.floor(e.wobbleY))),
|
||||
t.closePath(),
|
||||
t.fill(),
|
||||
e.tick < e.totalTicks
|
||||
)
|
||||
})(d, t)
|
||||
})).length
|
||||
? (c = b.frame(e))
|
||||
: l()
|
||||
})),
|
||||
(s = l)
|
||||
})
|
||||
return {
|
||||
addFettis: function (t) {
|
||||
return (u = u.concat(t)), f
|
||||
},
|
||||
canvas: t,
|
||||
promise: f,
|
||||
reset: function () {
|
||||
c && b.cancel(c), s && s()
|
||||
}
|
||||
}
|
||||
}
|
||||
function E(t, n) {
|
||||
var a,
|
||||
i = !t,
|
||||
r = !!p(n || {}, 'resize'),
|
||||
c = p(n, 'disableForReducedMotion', Boolean),
|
||||
s = o && !!p(n || {}, 'useWorker') ? v() : null,
|
||||
u = i ? k : I,
|
||||
d = !(!t || !s) && !!t.__confetti_initialized,
|
||||
f =
|
||||
'function' == typeof matchMedia &&
|
||||
matchMedia('(prefers-reduced-motion)').matches
|
||||
function h(e, n, i) {
|
||||
for (
|
||||
var o,
|
||||
r,
|
||||
l,
|
||||
c,
|
||||
s,
|
||||
d = p(e, 'particleCount', M),
|
||||
f = p(e, 'angle', Number),
|
||||
h = p(e, 'spread', Number),
|
||||
g = p(e, 'startVelocity', Number),
|
||||
m = p(e, 'decay', Number),
|
||||
b = p(e, 'gravity', Number),
|
||||
v = p(e, 'drift', Number),
|
||||
y = p(e, 'colors', x),
|
||||
w = p(e, 'ticks', Number),
|
||||
C = p(e, 'shapes'),
|
||||
k = p(e, 'scalar'),
|
||||
I = (function (t) {
|
||||
var e = p(t, 'origin', Object)
|
||||
return (e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e
|
||||
})(e),
|
||||
E = d,
|
||||
S = [],
|
||||
F = t.width * I.x,
|
||||
N = t.height * I.y;
|
||||
E--;
|
||||
|
||||
)
|
||||
S.push(
|
||||
((o = {
|
||||
x: F,
|
||||
y: N,
|
||||
angle: f,
|
||||
spread: h,
|
||||
startVelocity: g,
|
||||
color: y[E % y.length],
|
||||
shape:
|
||||
C[
|
||||
((c = 0),
|
||||
(s = C.length),
|
||||
Math.floor(Math.random() * (s - c)) + c)
|
||||
],
|
||||
ticks: w,
|
||||
decay: m,
|
||||
gravity: b,
|
||||
drift: v,
|
||||
scalar: k
|
||||
}),
|
||||
(r = void 0),
|
||||
(l = void 0),
|
||||
(r = o.angle * (Math.PI / 180)),
|
||||
(l = o.spread * (Math.PI / 180)),
|
||||
{
|
||||
x: o.x,
|
||||
y: o.y,
|
||||
wobble: 10 * Math.random(),
|
||||
velocity: 0.5 * o.startVelocity + Math.random() * o.startVelocity,
|
||||
angle2D: -r + (0.5 * l - Math.random() * l),
|
||||
tiltAngle: Math.random() * Math.PI,
|
||||
color: o.color,
|
||||
shape: o.shape,
|
||||
tick: 0,
|
||||
totalTicks: o.ticks,
|
||||
decay: o.decay,
|
||||
drift: o.drift,
|
||||
random: Math.random() + 5,
|
||||
tiltSin: 0,
|
||||
tiltCos: 0,
|
||||
wobbleX: 0,
|
||||
wobbleY: 0,
|
||||
gravity: 3 * o.gravity,
|
||||
ovalScalar: 0.6,
|
||||
scalar: o.scalar
|
||||
})
|
||||
)
|
||||
return a ? a.addFettis(S) : (a = T(t, S, u, n, i)).promise
|
||||
}
|
||||
function g(n) {
|
||||
var o = c || p(n, 'disableForReducedMotion', Boolean),
|
||||
g = p(n, 'zIndex', Number)
|
||||
if (o && f)
|
||||
return l(function (t) {
|
||||
t()
|
||||
})
|
||||
i && a
|
||||
? (t = a.canvas)
|
||||
: i &&
|
||||
!t &&
|
||||
((t = (function (t) {
|
||||
var e = document.createElement('canvas')
|
||||
return (
|
||||
(e.style.position = 'fixed'),
|
||||
(e.style.top = '0px'),
|
||||
(e.style.left = '0px'),
|
||||
(e.style.pointerEvents = 'none'),
|
||||
(e.style.zIndex = t),
|
||||
e
|
||||
)
|
||||
})(g)),
|
||||
document.body.appendChild(t)),
|
||||
r && !d && u(t)
|
||||
var m = {width: t.width, height: t.height}
|
||||
function b() {
|
||||
if (s) {
|
||||
var e = {
|
||||
getBoundingClientRect: function () {
|
||||
if (!i) return t.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
return (
|
||||
u(e),
|
||||
void s.postMessage({resize: {width: e.width, height: e.height}})
|
||||
)
|
||||
}
|
||||
m.width = m.height = null
|
||||
}
|
||||
function v() {
|
||||
;(a = null),
|
||||
r && e.removeEventListener('resize', b),
|
||||
i && t && (document.body.removeChild(t), (t = null), (d = !1))
|
||||
}
|
||||
return (
|
||||
s && !d && s.init(t),
|
||||
(d = !0),
|
||||
s && (t.__confetti_initialized = !0),
|
||||
r && e.addEventListener('resize', b, !1),
|
||||
s ? s.fire(n, m, v) : h(n, m, v)
|
||||
)
|
||||
}
|
||||
return (
|
||||
(g.reset = function () {
|
||||
s && s.reset(), a && a.reset()
|
||||
}),
|
||||
g
|
||||
)
|
||||
}
|
||||
;(n.exports = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E)
|
||||
})(
|
||||
(function () {
|
||||
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
|
||||
})(),
|
||||
e,
|
||||
!1
|
||||
),
|
||||
(t.confetti = e.exports)
|
||||
})(window, {})
|
||||
@@ -8,15 +8,70 @@ new Vue({
|
||||
data: {},
|
||||
description: ''
|
||||
},
|
||||
walletName: ''
|
||||
isUserAuthorized: false,
|
||||
authAction: 'login',
|
||||
authMethod: 'username-password',
|
||||
usr: '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
passwordRepeat: '',
|
||||
walletName: '',
|
||||
signup: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
formatDescription() {
|
||||
return LNbits.utils.convertMarkdown(this.description)
|
||||
},
|
||||
isAccessTokenExpired() {
|
||||
return this.$q.cookies.get('is_access_token_expired')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showLogin: function (authMethod) {
|
||||
this.authAction = 'login'
|
||||
this.authMethod = authMethod
|
||||
},
|
||||
showRegister: function (authMethod) {
|
||||
this.user = ''
|
||||
this.username = null
|
||||
this.password = null
|
||||
this.passwordRepeat = null
|
||||
|
||||
this.authAction = 'register'
|
||||
this.authMethod = authMethod
|
||||
},
|
||||
register: async function () {
|
||||
try {
|
||||
await LNbits.api.register(
|
||||
this.username,
|
||||
this.email,
|
||||
this.password,
|
||||
this.passwordRepeat
|
||||
)
|
||||
window.location.href = '/wallet'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
},
|
||||
login: async function () {
|
||||
try {
|
||||
await LNbits.api.login(this.username, this.password)
|
||||
window.location.href = '/wallet'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
},
|
||||
loginUsr: async function () {
|
||||
try {
|
||||
await LNbits.api.loginUsr(this.usr)
|
||||
this.usr = ''
|
||||
window.location.href = '/wallet'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
},
|
||||
createWallet: function () {
|
||||
LNbits.api.createAccount(this.walletName).then(res => {
|
||||
window.location = '/wallet?usr=' + res.data.user + '&wal=' + res.data.id
|
||||
@@ -28,9 +83,20 @@ new Vue({
|
||||
message: 'Processing...',
|
||||
icon: null
|
||||
})
|
||||
},
|
||||
validateUsername: function (val) {
|
||||
const usernameRegex = new RegExp(
|
||||
'^(?=[a-zA-Z0-9._]{2,20}$)(?!.*[_.]{2})[^_.].*[^_.]$'
|
||||
)
|
||||
return usernameRegex.test(val)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.description = SITE_DESCRIPTION
|
||||
|
||||
this.isUserAuthorized = !!this.$q.cookies.get('is_lnbits_user_authorized')
|
||||
if (this.isUserAuthorized) {
|
||||
window.location.href = '/wallet'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// update cache version every time there is a new deployment
|
||||
// so the service worker reinitializes the cache
|
||||
const CACHE_VERSION = 86
|
||||
const CACHE_VERSION = 114
|
||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||
|
||||
const getApiKey = request => {
|
||||
|
||||
@@ -113,7 +113,8 @@ new Vue({
|
||||
data: {
|
||||
request: '',
|
||||
amount: 0,
|
||||
comment: ''
|
||||
comment: '',
|
||||
unit: 'sat'
|
||||
},
|
||||
paymentChecker: null,
|
||||
copy: {
|
||||
@@ -149,7 +150,7 @@ new Vue({
|
||||
descending: true,
|
||||
rowsNumber: 10
|
||||
},
|
||||
filter: null,
|
||||
search: null,
|
||||
loading: false
|
||||
},
|
||||
paymentsCSV: {
|
||||
@@ -242,6 +243,7 @@ new Vue({
|
||||
},
|
||||
balance: 0,
|
||||
fiatBalance: 0,
|
||||
mobileSimple: false,
|
||||
credit: 0,
|
||||
update: {
|
||||
name: null,
|
||||
@@ -266,11 +268,17 @@ new Vue({
|
||||
}
|
||||
},
|
||||
filteredPayments: function () {
|
||||
var q = this.paymentsTable.filter
|
||||
var q = this.paymentsTable.search
|
||||
if (!q || q === '') return this.payments
|
||||
|
||||
return LNbits.utils.search(this.payments, q)
|
||||
},
|
||||
paymentsOmitter() {
|
||||
if (this.$q.screen.lt.md && this.mobileSimple) {
|
||||
return this.payments.length > 0 ? [this.payments[0]] : []
|
||||
}
|
||||
return this.payments
|
||||
},
|
||||
canPay: function () {
|
||||
if (!this.parse.invoice) return false
|
||||
return this.parse.invoice.sat <= this.balance
|
||||
@@ -279,12 +287,10 @@ new Vue({
|
||||
return this.payments.findIndex(payment => payment.pending) !== -1
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
methods: {
|
||||
msatoshiFormat: function (value) {
|
||||
return LNbits.utils.formatSat(value / 1000)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
},
|
||||
paymentTableRowKey: function (row) {
|
||||
return row.payment_hash + row.amount
|
||||
},
|
||||
@@ -346,33 +352,6 @@ new Vue({
|
||||
this.parse.camera.show = false
|
||||
this.focusInput('textArea')
|
||||
},
|
||||
updateBalance: function (credit) {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/topup/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
amount: credit,
|
||||
id: this.g.wallet.id
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Added ' +
|
||||
credit +
|
||||
' sats to ' +
|
||||
this.g.user.wallets[0].id,
|
||||
icon: null
|
||||
})
|
||||
this.balance += parseInt(credit)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeParseDialog: function () {
|
||||
setTimeout(() => {
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
@@ -632,7 +611,8 @@ new Vue({
|
||||
this.parse.lnurlpay.description_hash,
|
||||
this.parse.data.amount * 1000,
|
||||
this.parse.lnurlpay.description.slice(0, 120),
|
||||
this.parse.data.comment
|
||||
this.parse.data.comment,
|
||||
this.parse.data.unit
|
||||
)
|
||||
.then(response => {
|
||||
this.parse.show = false
|
||||
@@ -769,23 +749,9 @@ new Vue({
|
||||
})
|
||||
},
|
||||
fetchPayments: function (props) {
|
||||
// Props are passed by qasar when pagination or sorting changes
|
||||
if (props) {
|
||||
this.paymentsTable.pagination = props.pagination
|
||||
}
|
||||
let pagination = this.paymentsTable.pagination
|
||||
this.paymentsTable.loading = true
|
||||
const query = {
|
||||
limit: pagination.rowsPerPage,
|
||||
offset: (pagination.page - 1) * pagination.rowsPerPage,
|
||||
sortby: pagination.sortBy ?? 'time',
|
||||
direction: pagination.descending ? 'desc' : 'asc'
|
||||
}
|
||||
if (this.paymentsTable.filter) {
|
||||
query.search = this.paymentsTable.filter
|
||||
}
|
||||
const params = LNbits.utils.prepareFilterQuery(this.paymentsTable, props)
|
||||
return LNbits.api
|
||||
.getPayments(this.g.wallet, query)
|
||||
.getPayments(this.g.wallet, params)
|
||||
.then(response => {
|
||||
this.paymentsTable.loading = false
|
||||
this.paymentsTable.pagination.rowsNumber = response.data.total
|
||||
@@ -825,11 +791,20 @@ new Vue({
|
||||
formatFiat(currency, amount) {
|
||||
return LNbits.utils.formatCurrency(amount, currency)
|
||||
},
|
||||
updateBalanceCallback: function (res) {
|
||||
this.balance += res.value
|
||||
},
|
||||
exportCSV: function () {
|
||||
// status is important for export but it is not in paymentsTable
|
||||
// because it is manually added with payment detail link and icons
|
||||
// and would cause duplication in the list
|
||||
LNbits.api.getPayments(this.g.wallet, {}).then(response => {
|
||||
const pagination = this.paymentsTable.pagination
|
||||
const query = {
|
||||
sortby: pagination.sortBy ?? 'time',
|
||||
direction: pagination.descending ? 'desc' : 'asc'
|
||||
}
|
||||
const params = new URLSearchParams(query)
|
||||
LNbits.api.getPayments(this.g.wallet, params).then(response => {
|
||||
const payments = response.data.data.map(LNbits.map.payment)
|
||||
LNbits.utils.exportCSV(
|
||||
this.paymentsCSV.columns,
|
||||
@@ -861,6 +836,9 @@ new Vue({
|
||||
this.decodeRequest()
|
||||
this.parse.show = true
|
||||
}
|
||||
if (this.$q.screen.lt.md) {
|
||||
this.mobileSimple = true
|
||||
}
|
||||
this.fetchBalance()
|
||||
this.fetchPayments()
|
||||
|
||||
@@ -882,11 +860,11 @@ new Vue({
|
||||
this.disclaimerDialog.show = true
|
||||
this.$q.localStorage.set('lnbits.disclaimerShown', true)
|
||||
}
|
||||
|
||||
// listen to incoming payments
|
||||
LNbits.events.onInvoicePaid(this.g.wallet, payment =>
|
||||
this.onPaymentReceived(payment.payment_hash)
|
||||
)
|
||||
eventReactionWebocket(wallet.id)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -32,10 +32,12 @@
|
||||
"i18n/cs.js",
|
||||
"i18n/sk.js",
|
||||
"i18n/kr.js",
|
||||
"i18n/fi.js",
|
||||
"js/base.js",
|
||||
"js/components.js",
|
||||
"js/components/lnbits-funding-sources.js",
|
||||
"js/components/extension-settings.js",
|
||||
"js/event-reactions.js",
|
||||
"js/bolt11-decoder.js"
|
||||
],
|
||||
"css": ["vendor/quasar.css", "vendor/Chart.css", "css/base.css"]
|
||||
|
||||
@@ -14,11 +14,9 @@ from pywebpush import WebPushException, webpush
|
||||
from lnbits.core.crud import (
|
||||
delete_expired_invoices,
|
||||
delete_webpush_subscriptions,
|
||||
get_balance_checks,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
)
|
||||
from lnbits.core.services import redeem_lnurl_withdraw
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_wallet_class
|
||||
|
||||
@@ -186,14 +184,6 @@ async def check_pending_payments():
|
||||
await asyncio.sleep(60 * 30) # every 30 minutes
|
||||
|
||||
|
||||
async def perform_balance_checks():
|
||||
while True:
|
||||
for bc in await get_balance_checks():
|
||||
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
await asyncio.sleep(60 * 60 * 6) # every 6 hours
|
||||
|
||||
|
||||
async def invoice_callback_dispatcher(checking_id: str):
|
||||
"""
|
||||
Takes incoming payments, sets pending=False, and dispatches them to
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
v-text="$t('voidwallet_active')"
|
||||
color="red"
|
||||
text-color="black"
|
||||
class="q-mr-md"
|
||||
class="q-mr-md gt-md"
|
||||
>
|
||||
</q-badge>
|
||||
{%endif%}
|
||||
@@ -109,145 +109,54 @@
|
||||
>
|
||||
<span>OFFLINE</span>
|
||||
</q-badge>
|
||||
<lnbits-notifications-btn
|
||||
v-if="g.user"
|
||||
pubkey="{{ WEBPUSH_PUBKEY }}"
|
||||
></lnbits-notifications-btn>
|
||||
|
||||
<q-btn-dropdown
|
||||
v-if="isUserAuthorized"
|
||||
dense
|
||||
flat
|
||||
round
|
||||
size="sm"
|
||||
icon="language"
|
||||
class="q-pl-md"
|
||||
class="q-pl-sm"
|
||||
>
|
||||
<q-list v-for="(lang, index) in g.langs" :key="index">
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
:active="activeLanguage(lang.value)"
|
||||
@click="changeLanguage(lang.value)"
|
||||
<template v-slot:label>
|
||||
<div>
|
||||
{%if user and user.config and user.config.picture%}
|
||||
<img src="{{user.config.picture}}" style="max-width: 32px" />
|
||||
{%else%}
|
||||
<q-icon name="account_circle" />
|
||||
{%endif%}
|
||||
</div>
|
||||
</template>
|
||||
<q-list>
|
||||
<q-item tag="a" href="/account" clickable v-close-popup
|
||||
><q-item-section>
|
||||
<q-item-label
|
||||
v-text="lang.display ?? lang.value.toUpperCase()"
|
||||
></q-item-label>
|
||||
<q-tooltip><span v-text="lang.label"></span></q-tooltip>
|
||||
<q-icon name="person" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<span v-text="$t('my_account')"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label> </q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator></q-separator>
|
||||
<q-item clickable v-close-popup @click="logout"
|
||||
><q-item-section>
|
||||
<q-icon name="logout" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<span v-text="$t('logout')"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label> </q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
<q-btn-dropdown
|
||||
v-if="g.allowedThemes && g.allowedThemes.length > 1"
|
||||
dense
|
||||
flat
|
||||
round
|
||||
size="sm"
|
||||
icon="format_color_fill"
|
||||
class="q-pl-md"
|
||||
>
|
||||
<div class="row no-wrap q-pa-md">
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('classic')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('classic')"
|
||||
icon="circle"
|
||||
color="deep-purple"
|
||||
size="md"
|
||||
><q-tooltip>classic</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('bitcoin')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('bitcoin')"
|
||||
icon="circle"
|
||||
color="orange"
|
||||
size="md"
|
||||
><q-tooltip>bitcoin</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('mint')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('mint')"
|
||||
icon="circle"
|
||||
color="green"
|
||||
size="md"
|
||||
><q-tooltip>mint</q-tooltip> </q-btn
|
||||
><q-btn
|
||||
v-if="g.allowedThemes.includes('autumn')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('autumn')"
|
||||
icon="circle"
|
||||
color="brown"
|
||||
size="md"
|
||||
><q-tooltip>autumn</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('monochrome')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('monochrome')"
|
||||
icon="circle"
|
||||
color="grey"
|
||||
size="md"
|
||||
><q-tooltip>monochrome</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('salvador')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('salvador')"
|
||||
icon="circle"
|
||||
color="blue-10"
|
||||
size="md"
|
||||
><q-tooltip>elSalvador</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('freedom')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('freedom')"
|
||||
icon="circle"
|
||||
color="pink-13"
|
||||
size="md"
|
||||
><q-tooltip>freedom</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('cyber')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('cyber')"
|
||||
icon="circle"
|
||||
color="light-green-9"
|
||||
size="md"
|
||||
><q-tooltip>cyber</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('flamingo')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('flamingo')"
|
||||
icon="circle"
|
||||
color="pink-3"
|
||||
size="md"
|
||||
><q-tooltip>flamingo</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-btn-dropdown>
|
||||
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
@click="toggleDarkMode"
|
||||
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
|
||||
size="sm"
|
||||
>
|
||||
<q-tooltip><span v-text="$t('toggle_darkmode')"></span></q-tooltip>
|
||||
</q-btn>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
@@ -352,7 +261,8 @@
|
||||
{ value: 'br', label: 'Português do Brasil', display: '🇧🇷 BR' },
|
||||
{ value: 'cs', label: 'Česky', display: '🇨🇿 CS' },
|
||||
{ value: 'sk', label: 'Slovensky', display: '🇸🇰 SK' },
|
||||
{ value: 'kr', label: '한국어', display: '🇰🇷 KR' }
|
||||
{ value: 'kr', label: '한국어', display: '🇰🇷 KR' },
|
||||
{ value: 'fi', label: 'Suomi', display: '🇫🇮 FI' }
|
||||
]
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
https://t.me/lnbits
|
||||
</h4>
|
||||
<br />
|
||||
<q-btn
|
||||
@click="goBack"
|
||||
color="grey"
|
||||
outline
|
||||
label="Back"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -31,6 +38,11 @@
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
goBack: function () {
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% for url in INCLUDED_CSS %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ url }}" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="{{ static_url_for('static', url) }}"
|
||||
/>
|
||||
{% endfor %}
|
||||
<style>
|
||||
@page {
|
||||
@@ -34,7 +38,7 @@
|
||||
</q-layout>
|
||||
|
||||
{% for url in INCLUDED_JS %}
|
||||
<script src="{{ url }}"></script>
|
||||
<script src="{{ static_url_for('static', url) }}"></script>
|
||||
{% endfor %}
|
||||
<!---->
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import base64
|
||||
import getpass
|
||||
from hashlib import md5
|
||||
|
||||
from Cryptodome import Random
|
||||
from Cryptodome.Cipher import AES
|
||||
|
||||
BLOCK_SIZE = 16
|
||||
|
||||
|
||||
class AESCipher:
|
||||
"""This class is compatible with crypto-js/aes.js
|
||||
|
||||
Encrypt and decrypt in Javascript using:
|
||||
import AES from "crypto-js/aes.js";
|
||||
import Utf8 from "crypto-js/enc-utf8.js";
|
||||
AES.encrypt(decrypted, password).toString()
|
||||
AES.decrypt(encrypted, password).toString(Utf8);
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, key=None, description=""):
|
||||
self.key = key
|
||||
self.description = description + " "
|
||||
|
||||
def pad(self, data):
|
||||
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
|
||||
return data + (chr(length) * length).encode()
|
||||
|
||||
def unpad(self, data):
|
||||
return data[: -(data[-1] if isinstance(data[-1], int) else ord(data[-1]))]
|
||||
|
||||
@property
|
||||
def passphrase(self):
|
||||
passphrase = self.key if self.key is not None else None
|
||||
if passphrase is None:
|
||||
passphrase = getpass.getpass(f"Enter {self.description}password:")
|
||||
return passphrase
|
||||
|
||||
def bytes_to_key(self, data, salt, output=48):
|
||||
# extended from https://gist.github.com/gsakkis/4546068
|
||||
assert len(salt) == 8, len(salt)
|
||||
data += salt
|
||||
key = md5(data).digest()
|
||||
final_key = key
|
||||
while len(final_key) < output:
|
||||
key = md5(key + data).digest()
|
||||
final_key += key
|
||||
return final_key[:output]
|
||||
|
||||
def decrypt(self, encrypted: str) -> str: # type: ignore
|
||||
"""Decrypts a string using AES-256-CBC."""
|
||||
passphrase = self.passphrase
|
||||
encrypted = base64.b64decode(encrypted) # type: ignore
|
||||
assert encrypted[0:8] == b"Salted__"
|
||||
salt = encrypted[8:16]
|
||||
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
try:
|
||||
return self.unpad(aes.decrypt(encrypted[16:])).decode() # type: ignore
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError("Wrong passphrase")
|
||||
|
||||
def encrypt(self, message: bytes) -> str:
|
||||
passphrase = self.passphrase
|
||||
salt = Random.new().read(8)
|
||||
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
return base64.b64encode(
|
||||
b"Salted__" + salt + aes.encrypt(self.pad(message))
|
||||
).decode()
|
||||
@@ -20,12 +20,12 @@ class AlbyWallet(Wallet):
|
||||
"""https://guides.getalby.com/alby-wallet-api/reference/api-reference"""
|
||||
|
||||
def __init__(self):
|
||||
endpoint = settings.alby_api_endpoint
|
||||
if not settings.alby_api_endpoint:
|
||||
raise ValueError("cannot initialize AlbyWallet: missing alby_api_endpoint")
|
||||
if not settings.alby_access_token:
|
||||
raise ValueError("cannot initialize AlbyWallet: missing alby_access_token")
|
||||
|
||||
if not endpoint or not settings.alby_access_token:
|
||||
raise Exception("cannot initialize getalby")
|
||||
|
||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
self.endpoint = self.normalize_endpoint(settings.alby_api_endpoint)
|
||||
self.auth = {
|
||||
"Authorization": "Bearer " + settings.alby_access_token,
|
||||
"User-Agent": settings.user_agent,
|
||||
@@ -44,11 +44,14 @@ class AlbyWallet(Wallet):
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
return StatusResponse(f"Unable to connect to '{self.endpoint}'", 0)
|
||||
|
||||
data = r.json()["balance"]
|
||||
if r.is_error:
|
||||
return StatusResponse(data["error"], 0)
|
||||
error_message = r.json()["message"]
|
||||
return StatusResponse(error_message, 0)
|
||||
|
||||
return StatusResponse(None, data)
|
||||
data = r.json()
|
||||
assert data["unit"] == "sat"
|
||||
# multiply balance by 1000 to get msats balance
|
||||
return StatusResponse(None, data["balance"] * 1000)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
|
||||
@@ -95,6 +95,14 @@ class Wallet(ABC):
|
||||
def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
pass
|
||||
|
||||
def normalize_endpoint(self, endpoint: str, add_proto=True) -> str:
|
||||
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
if add_proto:
|
||||
endpoint = (
|
||||
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
|
||||
)
|
||||
return endpoint
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
pass
|
||||
|
||||
@@ -21,9 +21,10 @@ class ClicheWallet(Wallet):
|
||||
"""https://github.com/fiatjaf/cliche"""
|
||||
|
||||
def __init__(self):
|
||||
self.endpoint = settings.cliche_endpoint
|
||||
if not self.endpoint:
|
||||
raise Exception("cannot initialize cliche")
|
||||
if not settings.cliche_endpoint:
|
||||
raise ValueError("cannot initialize ClicheWallet: missing cliche_endpoint")
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.cliche_endpoint)
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
|
||||
@@ -29,9 +29,13 @@ class CoreLightningWallet(Wallet):
|
||||
__node_cls__ = CoreLightningNode
|
||||
|
||||
def __init__(self):
|
||||
self.rpc = settings.corelightning_rpc or settings.clightning_rpc
|
||||
self.ln = LightningRpc(self.rpc)
|
||||
rpc = settings.corelightning_rpc or settings.clightning_rpc
|
||||
if not rpc:
|
||||
raise ValueError(
|
||||
"cannot initialize CoreLightningWallet: missing corelightning_rpc"
|
||||
)
|
||||
|
||||
self.ln = LightningRpc(rpc)
|
||||
# check if description_hash is supported (from corelightning>=v0.11.0)
|
||||
command = self.ln.help("invoice")["help"][0]["command"] # type: ignore
|
||||
self.supports_description_hash = "deschashonly" in command
|
||||
|
||||
@@ -23,23 +23,26 @@ from .macaroon import load_macaroon
|
||||
|
||||
class CoreLightningRestWallet(Wallet):
|
||||
def __init__(self):
|
||||
macaroon = settings.corelightning_rest_macaroon
|
||||
assert macaroon, "missing cln-rest macaroon"
|
||||
|
||||
self.macaroon = load_macaroon(macaroon)
|
||||
|
||||
url = settings.corelightning_rest_url
|
||||
if not url:
|
||||
raise Exception("missing url for corelightning-rest")
|
||||
if not settings.corelightning_rest_url:
|
||||
raise ValueError(
|
||||
"cannot initialize CoreLightningRestWallet: "
|
||||
"missing corelightning_rest_url"
|
||||
)
|
||||
if not settings.corelightning_rest_macaroon:
|
||||
raise ValueError(
|
||||
"cannot initialize CoreLightningRestWallet: "
|
||||
"missing corelightning_rest_macaroon"
|
||||
)
|
||||
macaroon = load_macaroon(settings.corelightning_rest_macaroon)
|
||||
if not macaroon:
|
||||
raise Exception("missing macaroon for corelightning-rest")
|
||||
raise ValueError(
|
||||
"cannot initialize CoreLightningRestWallet: "
|
||||
"invalid corelightning_rest_macaroon provided"
|
||||
)
|
||||
|
||||
self.url = url[:-1] if url.endswith("/") else url
|
||||
self.url = (
|
||||
f"https://{self.url}" if not self.url.startswith("http") else self.url
|
||||
)
|
||||
self.url = self.normalize_endpoint(settings.corelightning_rest_url)
|
||||
headers = {
|
||||
"macaroon": self.macaroon,
|
||||
"macaroon": macaroon,
|
||||
"encodingtype": "hex",
|
||||
"accept": "application/json",
|
||||
"User-Agent": settings.user_agent,
|
||||
|
||||
@@ -30,15 +30,16 @@ class UnknownError(Exception):
|
||||
|
||||
class EclairWallet(Wallet):
|
||||
def __init__(self):
|
||||
url = settings.eclair_url
|
||||
passw = settings.eclair_pass
|
||||
if not url or not passw:
|
||||
raise Exception("cannot initialize eclair")
|
||||
if not settings.eclair_url:
|
||||
raise ValueError("cannot initialize EclairWallet: missing eclair_url")
|
||||
if not settings.eclair_pass:
|
||||
raise ValueError("cannot initialize EclairWallet: missing eclair_pass")
|
||||
|
||||
self.url = url[:-1] if url.endswith("/") else url
|
||||
self.url = self.normalize_endpoint(settings.eclair_url)
|
||||
self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws"
|
||||
|
||||
encodedAuth = base64.b64encode(f":{passw}".encode())
|
||||
password = settings.eclair_pass
|
||||
encodedAuth = base64.b64encode(f":{password}".encode())
|
||||
auth = str(encodedAuth, "utf-8")
|
||||
self.headers = {
|
||||
"Authorization": f"Basic {auth}",
|
||||
|
||||
@@ -20,14 +20,19 @@ class LNbitsWallet(Wallet):
|
||||
"""https://github.com/lnbits/lnbits"""
|
||||
|
||||
def __init__(self):
|
||||
self.endpoint = settings.lnbits_endpoint
|
||||
if not settings.lnbits_endpoint:
|
||||
raise ValueError("cannot initialize LNbitsWallet: missing lnbits_endpoint")
|
||||
key = (
|
||||
settings.lnbits_key
|
||||
or settings.lnbits_admin_key
|
||||
or settings.lnbits_invoice_key
|
||||
)
|
||||
if not self.endpoint or not key:
|
||||
raise Exception("cannot initialize lnbits wallet")
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"cannot initialize LNbitsWallet: "
|
||||
"missing lnbits_key or lnbits_admin_key or lnbits_invoice_key"
|
||||
)
|
||||
self.endpoint = self.normalize_endpoint(settings.lnbits_endpoint)
|
||||
self.headers = {"X-Api-Key": key, "User-Agent": settings.user_agent}
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2 as router
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
@@ -20,7 +21,7 @@ from .base import (
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
from .macaroon import AESCipher, load_macaroon
|
||||
from .macaroon import load_macaroon
|
||||
|
||||
|
||||
def b64_to_bytes(checking_id: str) -> bytes:
|
||||
@@ -57,7 +58,16 @@ environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA"
|
||||
|
||||
class LndWallet(Wallet):
|
||||
def __init__(self):
|
||||
endpoint = settings.lnd_grpc_endpoint
|
||||
if not settings.lnd_grpc_endpoint:
|
||||
raise ValueError("cannot initialize LndWallet: missing lnd_grpc_endpoint")
|
||||
if not settings.lnd_grpc_port:
|
||||
raise ValueError("cannot initialize LndWallet: missing lnd_grpc_port")
|
||||
|
||||
cert_path = settings.lnd_grpc_cert or settings.lnd_cert
|
||||
if not cert_path:
|
||||
raise ValueError(
|
||||
"cannot initialize LndWallet: missing lnd_grpc_cert or lnd_cert"
|
||||
)
|
||||
|
||||
macaroon = (
|
||||
settings.lnd_grpc_macaroon
|
||||
@@ -66,24 +76,25 @@ class LndWallet(Wallet):
|
||||
or settings.lnd_grpc_invoice_macaroon
|
||||
or settings.lnd_invoice_macaroon
|
||||
)
|
||||
|
||||
encrypted_macaroon = settings.lnd_grpc_macaroon_encrypted
|
||||
if encrypted_macaroon:
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(
|
||||
encrypted_macaroon
|
||||
)
|
||||
if not macaroon:
|
||||
raise ValueError(
|
||||
"cannot initialize LndWallet: "
|
||||
"missing lnd_grpc_macaroon or lnd_grpc_admin_macaroon or "
|
||||
"lnd_admin_macaroon or lnd_grpc_invoice_macaroon or "
|
||||
"lnd_invoice_macaroon or lnd_grpc_macaroon_encrypted"
|
||||
)
|
||||
|
||||
cert_path = settings.lnd_grpc_cert or settings.lnd_cert
|
||||
if not endpoint or not macaroon or not cert_path or not settings.lnd_grpc_port:
|
||||
raise Exception("cannot initialize lndrest")
|
||||
|
||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
self.endpoint = self.normalize_endpoint(
|
||||
settings.lnd_grpc_endpoint, add_proto=False
|
||||
)
|
||||
self.port = int(settings.lnd_grpc_port)
|
||||
self.cert_path = settings.lnd_grpc_cert or settings.lnd_cert
|
||||
|
||||
self.macaroon = load_macaroon(macaroon)
|
||||
self.cert_path = cert_path
|
||||
cert = open(self.cert_path, "rb").read()
|
||||
cert = open(cert_path, "rb").read()
|
||||
creds = grpc.ssl_channel_credentials(cert)
|
||||
auth_creds = grpc.metadata_call_credentials(self.metadata_callback)
|
||||
composite_creds = grpc.composite_channel_credentials(creds, auth_creds)
|
||||
|
||||
@@ -9,6 +9,7 @@ from loguru import logger
|
||||
|
||||
from lnbits.nodes.lndrest import LndRestNode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
@@ -17,7 +18,7 @@ from .base import (
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
from .macaroon import AESCipher, load_macaroon
|
||||
from .macaroon import load_macaroon
|
||||
|
||||
|
||||
class LndRestWallet(Wallet):
|
||||
@@ -26,8 +27,10 @@ class LndRestWallet(Wallet):
|
||||
__node_cls__ = LndRestNode
|
||||
|
||||
def __init__(self):
|
||||
endpoint = settings.lnd_rest_endpoint
|
||||
cert = settings.lnd_rest_cert
|
||||
if not settings.lnd_rest_endpoint:
|
||||
raise ValueError(
|
||||
"cannot initialize LndRestWallet: missing lnd_rest_endpoint"
|
||||
)
|
||||
|
||||
macaroon = (
|
||||
settings.lnd_rest_macaroon
|
||||
@@ -36,43 +39,39 @@ class LndRestWallet(Wallet):
|
||||
or settings.lnd_invoice_macaroon
|
||||
or settings.lnd_rest_invoice_macaroon
|
||||
)
|
||||
|
||||
encrypted_macaroon = settings.lnd_rest_macaroon_encrypted
|
||||
if encrypted_macaroon:
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(
|
||||
encrypted_macaroon
|
||||
)
|
||||
|
||||
if not endpoint:
|
||||
raise Exception("cannot initialize lndrest: no endpoint")
|
||||
|
||||
if not macaroon:
|
||||
raise Exception("cannot initialize lndrest: no macaroon")
|
||||
|
||||
if not cert:
|
||||
logger.warning(
|
||||
"no certificate for lndrest provided, this only works if you have a"
|
||||
" publicly issued certificate"
|
||||
raise ValueError(
|
||||
"cannot initialize LndRestWallet: "
|
||||
"missing lnd_rest_macaroon or lnd_admin_macaroon or "
|
||||
"lnd_rest_admin_macaroon or lnd_invoice_macaroon or "
|
||||
"lnd_rest_invoice_macaroon or lnd_rest_macaroon_encrypted"
|
||||
)
|
||||
|
||||
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
endpoint = (
|
||||
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
|
||||
)
|
||||
self.endpoint = endpoint
|
||||
self.macaroon = load_macaroon(macaroon)
|
||||
if not settings.lnd_rest_cert:
|
||||
logger.warning(
|
||||
"No certificate for LndRestWallet provided! "
|
||||
"This only works if you have a publicly issued certificate."
|
||||
)
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.lnd_rest_endpoint)
|
||||
|
||||
# if no cert provided it should be public so we set verify to True
|
||||
# and it will still check for validity of certificate and fail if its not valid
|
||||
# even on startup
|
||||
self.cert = cert or True
|
||||
cert = settings.lnd_rest_cert or True
|
||||
|
||||
macaroon = load_macaroon(macaroon)
|
||||
headers = {
|
||||
"Grpc-Metadata-macaroon": self.macaroon,
|
||||
"Grpc-Metadata-macaroon": macaroon,
|
||||
"User-Agent": settings.user_agent,
|
||||
}
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url=self.endpoint, headers=headers, verify=self.cert
|
||||
base_url=self.endpoint, headers=headers, verify=cert
|
||||
)
|
||||
|
||||
async def cleanup(self):
|
||||
@@ -139,14 +138,22 @@ class LndRestWallet(Wallet):
|
||||
lnrpcFeeLimit = dict()
|
||||
lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}"
|
||||
|
||||
r = await self.client.post(
|
||||
url="/v1/channels/transactions",
|
||||
json={"payment_request": bolt11, "fee_limit": lnrpcFeeLimit},
|
||||
timeout=None,
|
||||
)
|
||||
try:
|
||||
r = await self.client.post(
|
||||
url="/v1/channels/transactions",
|
||||
json={"payment_request": bolt11, "fee_limit": lnrpcFeeLimit},
|
||||
timeout=None,
|
||||
)
|
||||
r.raise_for_status()
|
||||
except Exception as exc:
|
||||
logger.warning(f"LndRestWallet pay_invoice POST error: {exc}.")
|
||||
return PaymentResponse(None, None, None, None, str(exc))
|
||||
|
||||
if r.is_error or r.json().get("payment_error"):
|
||||
data = r.json()
|
||||
|
||||
if data.get("payment_error"):
|
||||
error_message = r.json().get("payment_error") or r.text
|
||||
logger.warning(f"LndRestWallet pay_invoice payment_error: {error_message}.")
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
|
||||
data = r.json()
|
||||
|
||||
@@ -20,14 +20,23 @@ class LNPayWallet(Wallet):
|
||||
"""https://docs.lnpay.co/"""
|
||||
|
||||
def __init__(self):
|
||||
endpoint = settings.lnpay_api_endpoint
|
||||
if not settings.lnpay_api_endpoint:
|
||||
raise ValueError(
|
||||
"cannot initialize LNPayWallet: missing lnpay_api_endpoint"
|
||||
)
|
||||
if not settings.lnpay_api_key:
|
||||
raise ValueError("cannot initialize LNPayWallet: missing lnpay_api_key")
|
||||
|
||||
wallet_key = settings.lnpay_wallet_key or settings.lnpay_admin_key
|
||||
|
||||
if not endpoint or not wallet_key or not settings.lnpay_api_key:
|
||||
raise Exception("cannot initialize lnpay")
|
||||
|
||||
if not wallet_key:
|
||||
raise ValueError(
|
||||
"cannot initialize LNPayWallet: "
|
||||
"missing lnpay_wallet_key or lnpay_admin_key"
|
||||
)
|
||||
self.wallet_key = wallet_key
|
||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.lnpay_api_endpoint)
|
||||
|
||||
headers = {
|
||||
"X-Api-Key": settings.lnpay_api_key,
|
||||
"User-Agent": settings.user_agent,
|
||||
|
||||
@@ -20,15 +20,23 @@ from .base import (
|
||||
|
||||
class LnTipsWallet(Wallet):
|
||||
def __init__(self):
|
||||
endpoint = settings.lntips_api_endpoint
|
||||
if not settings.lntips_api_endpoint:
|
||||
raise ValueError(
|
||||
"cannot initialize LnTipsWallet: missing lntips_api_endpoint"
|
||||
)
|
||||
key = (
|
||||
settings.lntips_api_key
|
||||
or settings.lntips_admin_key
|
||||
or settings.lntips_invoice_key
|
||||
)
|
||||
if not endpoint or not key:
|
||||
raise Exception("cannot initialize lntxbod")
|
||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"cannot initialize LnTipsWallet: "
|
||||
"missing lntips_api_key or lntips_admin_key or lntips_invoice_key"
|
||||
)
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.lntips_api_endpoint)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Basic {key}",
|
||||
"User-Agent": settings.user_agent,
|
||||
|
||||
@@ -1 +1 @@
|
||||
from .macaroon import AESCipher, load_macaroon
|
||||
from .macaroon import load_macaroon
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import base64
|
||||
import getpass
|
||||
from hashlib import md5
|
||||
|
||||
from Cryptodome import Random
|
||||
from Cryptodome.Cipher import AES
|
||||
from loguru import logger
|
||||
|
||||
BLOCK_SIZE = 16
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
|
||||
|
||||
def load_macaroon(macaroon: str) -> str:
|
||||
@@ -39,73 +35,7 @@ def load_macaroon(macaroon: str) -> str:
|
||||
return macaroon
|
||||
|
||||
|
||||
class AESCipher:
|
||||
"""This class is compatible with crypto-js/aes.js
|
||||
|
||||
Encrypt and decrypt in Javascript using:
|
||||
import AES from "crypto-js/aes.js";
|
||||
import Utf8 from "crypto-js/enc-utf8.js";
|
||||
AES.encrypt(decrypted, password).toString()
|
||||
AES.decrypt(encrypted, password).toString(Utf8);
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, key=None, description=""):
|
||||
self.key = key
|
||||
self.description = description + " "
|
||||
|
||||
def pad(self, data):
|
||||
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
|
||||
return data + (chr(length) * length).encode()
|
||||
|
||||
def unpad(self, data):
|
||||
return data[: -(data[-1] if isinstance(data[-1], int) else ord(data[-1]))]
|
||||
|
||||
@property
|
||||
def passphrase(self):
|
||||
passphrase = self.key if self.key is not None else None
|
||||
if passphrase is None:
|
||||
passphrase = getpass.getpass(f"Enter {self.description}password:")
|
||||
return passphrase
|
||||
|
||||
def bytes_to_key(self, data, salt, output=48):
|
||||
# extended from https://gist.github.com/gsakkis/4546068
|
||||
assert len(salt) == 8, len(salt)
|
||||
data += salt
|
||||
key = md5(data).digest()
|
||||
final_key = key
|
||||
while len(final_key) < output:
|
||||
key = md5(key + data).digest()
|
||||
final_key += key
|
||||
return final_key[:output]
|
||||
|
||||
def decrypt(self, encrypted: str) -> str: # type: ignore
|
||||
"""Decrypts a string using AES-256-CBC."""
|
||||
passphrase = self.passphrase
|
||||
encrypted = base64.b64decode(encrypted) # type: ignore
|
||||
assert encrypted[0:8] == b"Salted__"
|
||||
salt = encrypted[8:16]
|
||||
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
try:
|
||||
return self.unpad(aes.decrypt(encrypted[16:])).decode() # type: ignore
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError("Wrong passphrase")
|
||||
|
||||
def encrypt(self, message: bytes) -> str:
|
||||
passphrase = self.passphrase
|
||||
salt = Random.new().read(8)
|
||||
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
return base64.b64encode(
|
||||
b"Salted__" + salt + aes.encrypt(self.pad(message))
|
||||
).decode()
|
||||
|
||||
|
||||
# todo: move to its own (crypto.py) file
|
||||
# if this file is executed directly, ask for a macaroon and encrypt it
|
||||
if __name__ == "__main__":
|
||||
macaroon = input("Enter macaroon: ")
|
||||
|
||||
@@ -20,16 +20,24 @@ class OpenNodeWallet(Wallet):
|
||||
"""https://developers.opennode.com/"""
|
||||
|
||||
def __init__(self):
|
||||
endpoint = settings.opennode_api_endpoint
|
||||
self.key = (
|
||||
if not settings.opennode_api_endpoint:
|
||||
raise ValueError(
|
||||
"cannot initialize OpenNodeWallet: missing opennode_api_endpoint"
|
||||
)
|
||||
key = (
|
||||
settings.opennode_key
|
||||
or settings.opennode_admin_key
|
||||
or settings.opennode_invoice_key
|
||||
)
|
||||
if not endpoint or not self.key:
|
||||
raise Exception("cannot initialize opennode")
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"cannot initialize OpenNodeWallet: "
|
||||
"missing opennode_key or opennode_admin_key or opennode_invoice_key"
|
||||
)
|
||||
self.key = key
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.opennode_api_endpoint)
|
||||
|
||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
headers = {
|
||||
"Authorization": self.key,
|
||||
"User-Agent": settings.user_agent,
|
||||
@@ -48,10 +56,12 @@ class OpenNodeWallet(Wallet):
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
return StatusResponse(f"Unable to connect to '{self.endpoint}'", 0)
|
||||
|
||||
data = r.json()["data"]
|
||||
if r.is_error:
|
||||
return StatusResponse(data["message"], 0)
|
||||
error_message = r.json()["message"]
|
||||
return StatusResponse(error_message, 0)
|
||||
|
||||
data = r.json()["data"]
|
||||
# multiply balance by 1000 to get msats balance
|
||||
return StatusResponse(None, data["balance"]["BTC"] * 1000)
|
||||
|
||||
async def create_invoice(
|
||||
|
||||
@@ -28,12 +28,17 @@ class UnknownError(Exception):
|
||||
|
||||
class SparkWallet(Wallet):
|
||||
def __init__(self):
|
||||
assert settings.spark_url, "spark url does not exist"
|
||||
self.url = settings.spark_url.replace("/rpc", "")
|
||||
if not settings.spark_url:
|
||||
raise ValueError("cannot initialize SparkWallet: missing spark_url")
|
||||
if not settings.spark_token:
|
||||
raise ValueError("cannot initialize SparkWallet: missing spark_token")
|
||||
|
||||
url = self.normalize_endpoint(settings.spark_url)
|
||||
url = url.replace("/rpc", "")
|
||||
self.token = settings.spark_token
|
||||
assert self.token, "spark wallet token does not exist"
|
||||
|
||||
headers = {"X-Access": self.token, "User-Agent": settings.user_agent}
|
||||
self.client = httpx.AsyncClient(base_url=self.url, headers=headers)
|
||||
self.client = httpx.AsyncClient(base_url=url, headers=headers)
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
|
||||
@@ -81,10 +81,12 @@
|
||||
"i18n/cs.js",
|
||||
"i18n/sk.js",
|
||||
"i18n/kr.js",
|
||||
"i18n/fi.js",
|
||||
"js/base.js",
|
||||
"js/components.js",
|
||||
"js/components/lnbits-funding-sources.js",
|
||||
"js/components/extension-settings.js",
|
||||
"js/event-reactions.js",
|
||||
"js/bolt11-decoder.js"
|
||||
],
|
||||
"css": [
|
||||
|
||||