Compare commits

..
Author SHA1 Message Date
Tiago Vasconcelos a55399794e WIP tests 2025-10-14 16:21:41 +01:00
Tiago Vasconcelos 10fc88f777 harden service worker 2025-10-14 16:21:25 +01:00
ArcandGitHub 266b16834b fix nix install doc (#3401) 2025-10-10 16:39:08 +01:00
ArcandGitHub ad606b2517 Fix: Glibc error by pegging ubuntu version, appimage include extras like breez_sdk (#3397) 2025-10-10 10:35:25 +01:00
ArcandGitHub 1ec28079d9 docs: jq needed for AppImage install (#3399) 2025-10-10 08:50:41 +02:00
Vlad StanandGitHub aeaddc1dc6 fix: donate link 2025-10-09 09:55:51 +03:00
Vlad StanandGitHub a9374d509c fix: hide payment details for failed payments (#3393) 2025-10-08 14:43:40 +02:00
Vlad StanandGitHub 3e72788fc8 chore: clean prin statement (#3391) 2025-10-07 14:39:24 +03:00
f48d24aca8 feat: show badge if an extension has paid releases (#3385)
Co-authored-by: dni  <office@dnilabs.com>
2025-10-06 17:06:18 +01:00
015262c9b3 chore: qr button tweak (quasar classes should always be used) (#3351)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-10-06 10:56:12 +02:00
e5e481f836 fix: correct timezone handling for transaction timestamps (#3373)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-10-06 10:53:03 +02:00
6c32ebf7e6 fix: remove uneven padding from Repo button (#3374)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-02 13:12:08 +03:00
21 changed files with 1022 additions and 424 deletions
+1 -1
View File
@@ -1 +1 @@
custom: https://demo.lnbits.com/lnurlp/link/fH59GD custom: https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg
+13 -17
View File
@@ -5,23 +5,19 @@ on:
types: [published] types: [published]
jobs: jobs:
build-linux-package: build-linux-package:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
# Step 1: Checkout the repository
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# Step 2: Set up Python (uv will still use this toolchain)
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
# Step 3: Install system deps (fuse) + uv
- name: Install system deps and uv - name: Install system deps and uv
run: | run: |
sudo apt-get update sudo apt-get update
@@ -30,7 +26,6 @@ jobs:
echo "$HOME/.local/bin" >> $GITHUB_PATH echo "$HOME/.local/bin" >> $GITHUB_PATH
shell: bash shell: bash
# Optional: Cache uv + venv to speed up CI
- name: Cache uv and venv - name: Cache uv and venv
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -39,7 +34,6 @@ jobs:
.venv .venv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }} key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
# Step 4: Prepare packaging tree and clone LNbits
- name: Prepare packaging & clone LNbits - name: Prepare packaging & clone LNbits
run: | run: |
mv .github/packaging packaging mv .github/packaging packaging
@@ -47,18 +41,11 @@ jobs:
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
shell: bash shell: bash
# Step 5: Build the LNbits binary with uv + PyInstaller
- name: Build LNbits binary (uv + PyInstaller) - name: Build LNbits binary (uv + PyInstaller)
run: | run: |
cd packaging/linux/AppDir/usr/lnbits cd packaging/linux/AppDir/usr/lnbits
uv sync --all-extras --no-dev
# Install project deps into .venv using uv
uv sync
# Install PyInstaller into the same environment
uv pip install pyinstaller uv pip install pyinstaller
# Build the LNbits binary
uv run pyinstaller \ uv run pyinstaller \
--onefile \ --onefile \
--name lnbits \ --name lnbits \
@@ -66,6 +53,10 @@ jobs:
--collect-all embit \ --collect-all embit \
--collect-all lnbits \ --collect-all lnbits \
--collect-all sqlalchemy \ --collect-all sqlalchemy \
--collect-all breez_sdk \
--collect-binaries breez_sdk \
--collect-all breez_sdk_liquid \
--collect-binaries breez_sdk_liquid \
--collect-all aiosqlite \ --collect-all aiosqlite \
--hidden-import=passlib.handlers.bcrypt \ --hidden-import=passlib.handlers.bcrypt \
"$(uv run which lnbits)" "$(uv run which lnbits)"
@@ -75,7 +66,7 @@ jobs:
chmod +x packaging/linux/AppDir/lnbits.desktop chmod +x packaging/linux/AppDir/lnbits.desktop
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
# Clean out non-dist content from the app dir to keep AppImage slim # keep AppDir slim
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \ find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
! -name 'dist' \ ! -name 'dist' \
! -name 'lnbits' \ ! -name 'lnbits' \
@@ -91,9 +82,14 @@ jobs:
packaging/linux/AppDir "$APPIMAGE_NAME" packaging/linux/AppDir "$APPIMAGE_NAME"
chmod +x "$APPIMAGE_NAME" chmod +x "$APPIMAGE_NAME"
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
# 🔎 quick audit: show glibc versions referenced by the binary
echo "Runner glibc:"
ldd --version | head -n1 || true
echo "Symbols needed by lnbits:"
strings "$APPIMAGE_NAME" | grep -o 'GLIBC_[0-9.]*' | sort -u || true
shell: bash shell: bash
# Step 6: Upload Linux Release Asset
- name: Upload Linux Release Asset - name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
with: with:
+19 -6
View File
@@ -15,7 +15,7 @@ Note that by default LNbits uses SQLite as its database, which is simple and eff
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or: Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
```sh ```sh
sudo apt-get install libfuse2 sudo apt-get install jq libfuse2
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
chmod +x LNbits-latest.AppImage chmod +x LNbits-latest.AppImage
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
@@ -132,7 +132,7 @@ Now visit `0.0.0.0:5000` to make a super-user account.
```sh ```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download) # Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon --yes
# Enable nix-command and flakes experimental features for nix: # Enable nix-command and flakes experimental features for nix:
grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \ grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \
@@ -145,16 +145,29 @@ echo "trusted-users = root $USER" | sudo tee -a /etc/nix/nix.conf
# Restart daemon so changes apply # Restart daemon so changes apply
sudo systemctl restart nix-daemon sudo systemctl restart nix-daemon
# Clone and build LNbits
git clone https://github.com/lnbits/lnbits.git
cd lnbits
# Make data directory and persist data/extension folders
mkdir data
PROJECT_DIR="$(pwd)"
{
echo "export PYTHONPATH=\"$PROJECT_DIR/ns:\$PYTHONPATH\""
echo "export LNBITS_DATA_FOLDER=\"$PROJECT_DIR/data\""
echo "export LNBITS_EXTENSIONS_PATH=\"$PROJECT_DIR\""
} >> ~/.bashrc
grep -qxF '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' ~/.bashrc || \
echo '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' >> ~/.bashrc
. ~/.bashrc
# Add cachix for cached binaries # Add cachix for cached binaries
nix-env -iA cachix -f https://cachix.org/api/v1/install nix-env -iA cachix -f https://cachix.org/api/v1/install
cachix use lnbits cachix use lnbits
# Clone and build LNbits # Build LNbits
git clone https://github.com/lnbits/lnbits.git
cd lnbits
nix build nix build
mkdir data
``` ```
#### Running the server #### Running the server
+67 -45
View File
@@ -1,53 +1,75 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail
# Check install has not already run # --- Config you might tweak ---
if [ ! -d lnbits/data ]; then REPO_URL="https://github.com/lnbits/lnbits.git"
BRANCH="main"
APP_DIR="${PWD}/lnbits"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-5000}"
ADMIN_UI="${LNBITS_ADMIN_UI:-true}"
# -------------------------------
# Update package list and install prerequisites non-interactively export DEBIAN_FRONTEND=noninteractive
sudo apt update -y
sudo apt install -y software-properties-common
# Add the deadsnakes PPA repository non-interactively # Ensure basic tooling
sudo add-apt-repository -y ppa:deadsnakes/ppa if ! command -v curl >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then
sudo apt-get update -y
# Install Python 3.10 and distutils non-interactively sudo apt-get install -y curl git
sudo apt install -y python3.10 python3.10-distutils
# Install UV
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
# Clone the LNbits repository
git clone https://github.com/lnbits/lnbits.git
if [ $? -ne 0 ]; then
echo "Failed to clone the repository ... FAIL"
exit 1
fi
# Ensure we are in the lnbits directory
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
git checkout main
# Make data folder
mkdir data
# Copy the .env.example to .env
cp .env.example .env
elif [ ! -d lnbits/wallets ]; then
# cd into lnbits
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi fi
# Install the dependencies using UV # System build deps and secp headers
uv sync --all-extras if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y
sudo apt-get install -y \
pkg-config \
build-essential \
libsecp256k1-dev \
automake \
autoconf \
libtool \
m4
fi
# Install uv (if missing)
if ! command -v uv >/dev/null 2>&1; then
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi
# Ensure PATH for current session
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
export PATH="$HOME/.local/bin:$PATH"
fi
# Set environment variables for LNbits # Clone or reuse repo
export LNBITS_ADMIN_UI=true if [[ ! -d "$APP_DIR/.git" ]]; then
export HOST=0.0.0.0 git clone "$REPO_URL" "$APP_DIR"
fi
# Run LNbits cd "$APP_DIR"
uv run lnbits git fetch --all --prune
git checkout "$BRANCH"
git pull --ff-only || true
# First-run setup
mkdir -p data
[[ -f .env ]] || cp .env.example .env || true
# Prefer system libsecp256k1 (avoid autotools path)
export SECP_BUNDLED=0
# Sync dependencies with Python 3.12
uv sync --python 3.12 --all-extras --no-dev
# Environment
export LNBITS_ADMIN_UI="$ADMIN_UI"
export HOST="$HOST"
export PORT="$PORT"
# Open firewall (optional)
if command -v ufw >/dev/null 2>&1; then
sudo ufw allow "$PORT"/tcp || true
fi
# Run LNbits with Python 3.12 via uv
exec uv run --python 3.12 lnbits
+37 -11
View File
@@ -39,6 +39,7 @@ class ExplicitRelease(BaseModel):
info_notification: str | None info_notification: str | None
critical_notification: str | None critical_notification: str | None
details_link: str | None details_link: str | None
paid_features: str | None
pay_link: str | None pay_link: str | None
def is_version_compatible(self): def is_version_compatible(self):
@@ -187,6 +188,7 @@ class ExtensionRelease(BaseModel):
icon: str | None = None icon: str | None = None
details_link: str | None = None details_link: str | None = None
paid_features: str | None = None
pay_link: str | None = None pay_link: str | None = None
cost_sats: int | None = None cost_sats: int | None = None
paid_sats: int | None = 0 paid_sats: int | None = 0
@@ -256,6 +258,7 @@ class ExtensionRelease(BaseModel):
html_url=e.html_url, html_url=e.html_url,
details_link=e.details_link, details_link=e.details_link,
pay_link=e.pay_link, pay_link=e.pay_link,
paid_features=e.paid_features,
repo=e.repo, repo=e.repo,
icon=e.icon, icon=e.icon,
) )
@@ -308,6 +311,9 @@ class ExtensionMeta(BaseModel):
dependencies: list[str] = [] dependencies: list[str] = []
archive: str | None = None archive: str | None = None
featured: bool = False featured: bool = False
paid_features: str | None = None
has_paid_release: bool = False
has_free_release: bool = False
class InstallableExtension(BaseModel): class InstallableExtension(BaseModel):
@@ -451,9 +457,23 @@ class InstallableExtension(BaseModel):
shutil.rmtree(self.ext_upgrade_dir, True) shutil.rmtree(self.ext_upgrade_dir, True)
def check_latest_version(self, release: ExtensionRelease | None): def check_release_updates(self, release: ExtensionRelease | None):
self._check_latest_version(release)
self._check_payment_link(release)
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
if not pay_link or not self.meta or not self.meta.payments:
return None
return next(
(p for p in self.meta.payments if p.pay_link == pay_link),
None,
)
def _check_latest_version(self, release: ExtensionRelease | None):
if not release: if not release:
return return
if not release.is_version_compatible:
return
if not self.meta or not self.meta.latest_release: if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta() meta = self.meta or ExtensionMeta()
meta.latest_release = release meta.latest_release = release
@@ -464,13 +484,19 @@ class InstallableExtension(BaseModel):
): ):
self.meta.latest_release = release self.meta.latest_release = release
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None: def _check_payment_link(self, release: ExtensionRelease | None):
if not pay_link or not self.meta or not self.meta.payments: if not release:
return None return
return next( if not release.is_version_compatible:
(p for p in self.meta.payments if p.pay_link == pay_link), return
None, if not self.meta:
) self.meta = ExtensionMeta()
if release.pay_link:
self.meta.has_paid_release = True
else:
self.meta.has_free_release = True
if release.paid_features:
self.meta.paid_features = release.paid_features
def _restore_payment_info(self): def _restore_payment_info(self):
if ( if (
@@ -596,7 +622,7 @@ class InstallableExtension(BaseModel):
(ee for ee in extension_list if ee.id == r.id), None (ee for ee in extension_list if ee.id == r.id), None
) )
if existing_ext and ext.meta: if existing_ext and ext.meta:
existing_ext.check_latest_version(ext.meta.latest_release) existing_ext.check_release_updates(ext.meta.latest_release)
continue continue
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
@@ -610,10 +636,10 @@ class InstallableExtension(BaseModel):
(ee for ee in extension_list if ee.id == e.id), None (ee for ee in extension_list if ee.id == e.id), None
) )
if existing_ext: if existing_ext:
existing_ext.check_latest_version(release) existing_ext.check_release_updates(release)
continue continue
ext = InstallableExtension.from_explicit_release(e) ext = InstallableExtension.from_explicit_release(e)
ext.check_latest_version(release) ext.check_release_updates(release)
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured meta.featured = ext.id in manifest.featured
ext.meta = meta ext.meta = meta
+33 -2
View File
@@ -137,8 +137,40 @@
@click="showExtensionDetails(extension.id, extension.details_link)" @click="showExtensionDetails(extension.id, extension.details_link)"
v-text="extension.name" v-text="extension.name"
></div> ></div>
<div> <div style="justify-content: space-between; display: flex">
<lnbits-extension-rating :rating="0" /> <lnbits-extension-rating :rating="0" />
<q-btn-group size="xs" style="margin: 5px 0">
<q-btn
v-if="extension.hasFreeRelease"
color="green"
size="xs"
:label="$t('free')"
>
<q-tooltip>
<span v-text="$t('extension_has_free_release')"></span>
</q-tooltip>
</q-btn>
<q-btn
v-if="extension.hasPaidRelease || extension.paidFeatures"
color="primary"
size="xs"
:label="$t('paid')"
>
<q-tooltip>
<span
v-if="extension.hasPaidRelease"
v-text="$t('extension_has_paid_release')"
></span>
<br
v-if="extension.hasPaidRelease && extension.paidFeatures"
/>
<span
v-if="extension.paidFeatures"
v-text="extension.paidFeatures"
></span>
</q-tooltip>
</q-btn>
</q-btn-group>
</div> </div>
<div style="justify-content: space-between; display: flex"> <div style="justify-content: space-between; display: flex">
<q-toggle <q-toggle
@@ -916,7 +948,6 @@
:href="selectedExtensionDetails.repo" :href="selectedExtensionDetails.repo"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="q-pr-xs"
><q-tooltip>repository</q-tooltip></q-btn ><q-tooltip>repository</q-tooltip></q-btn
> >
</div> </div>
+454 -61
View File
@@ -1,82 +1,475 @@
// update cache version every time there is a new deployment /**
// so the service worker reinitializes the cache * LNbits Service Worker - Hardened Production PWA
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-' *
* Features:
* - Forced update on deploy via skipWaiting + clients.claim()
* - Network timeout (10s) with cache fallback
* - Hashed API key cache keying for security
* - TTL-based lazy cache expiry (7 days)
* - In-memory metadata cache for performance
* - Structured logging with diagnostics
* - Open redirect prevention
*/
const getApiKey = request => { // ============================================================================
let api_key = request.headers.get('X-Api-Key') // Configuration
if (!api_key || api_key == 'undefined') { // ============================================================================
api_key = 'no_api_key'
} const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
return api_key const NETWORK_TIMEOUT_MS = 10000 // 10s timeout for network requests
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7-day TTL for cache entries
const CACHE_METADATA_KEY = '__lnbits_meta__'
const CACHE_SYNC_INTERVAL_MS = 60000 // Persist metadata every 60s
// In-memory metadata cache (TTL map: url -> timestamp)
let metadataCache = new Map()
let metadataSyncTimer = null
// ============================================================================
// Logging Utilities
// ============================================================================
/**
* Structured logging with level and context.
* @param {string} level - 'info', 'warn', or 'error'
* @param {string} message - Log message
* @param {Object} context - Optional context fields (url, error, etc.)
*/
const log = (level, message, context = {}) => {
const timestamp = new Date().toISOString()
const prefix = `[SW:${level.toUpperCase()}]`
const fields = Object.entries(context)
.map(([k, v]) => `${k}=${v}`)
.join(' ')
console.log(`${prefix} ${timestamp} ${message} ${fields}`.trim())
} }
// on activation we clean up the previously registered service workers // ============================================================================
self.addEventListener('activate', evt => // Hashing and API Key Handling
evt.waitUntil( // ============================================================================
caches.keys().then(cacheNames => {
return Promise.all( /**
cacheNames.map(cacheName => { * Compute SHA-256 hash of input string; falls back to simple prefix hash.
if (!cacheName.startsWith(CURRENT_CACHE)) { * @param {string} str - Input string
return caches.delete(cacheName) * @returns {Promise<string>} - Hex digest or fallback hash
} */
}) const hashString = async str => {
) try {
if (!crypto?.subtle?.digest) {
throw new Error('crypto.subtle.digest not available')
}
const encoder = new TextEncoder()
const data = encoder.encode(str)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
} catch (err) {
// Fallback: simple prefix hash (not cryptographic, but safe for cache keying)
log('warn', 'crypto.subtle.digest unavailable; using fallback hash', {
error: err.message
}) })
) return btoa(str.substring(0, 32))
) .replace(/[^a-z0-9]/gi, '')
.substring(0, 32)
// The fetch handler serves responses for same-origin resources from a cache.
// If no response is found, it populates the runtime cache with the response
// from the network before returning it to the page.
self.addEventListener('fetch', event => {
if (
event.request.url.startsWith(self.location.origin) &&
event.request.method == 'GET'
) {
// Open the cache
event.respondWith(
caches.open(CURRENT_CACHE + getApiKey(event.request)).then(cache => {
// Go to the network first
return fetch(event.request)
.then(fetchedResponse => {
cache.put(event.request, fetchedResponse.clone())
return fetchedResponse
})
.catch(() => {
// If the network is unavailable, get
return cache.match(event.request.url)
})
})
)
} }
}
/**
* Extract and normalize API key from request headers.
* @param {Request} request - Fetch API request object
* @returns {Promise<string>} - SHA-256 hash of API key or 'no_api_key'
*/
const getApiKeyHash = async request => {
let apiKey = request.headers.get('X-Api-Key') || 'no_api_key'
if (apiKey === 'undefined' || apiKey === '') {
apiKey = 'no_api_key'
}
return hashString(apiKey)
}
// ============================================================================
// Timeout Utility
// ============================================================================
/**
* Reject a promise after specified delay.
* @param {number} ms - Milliseconds
* @returns {Promise<never>}
*/
const timeout = ms => {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Network timeout after ${ms}ms`)), ms)
)
}
// ============================================================================
// Cache Metadata Management
// ============================================================================
/**
* Load TTL metadata for a cache store.
* Tries in-memory cache first, then persistent cache.
* @param {string} cacheName - Cache store name
* @returns {Promise<Object>} - Map of URL -> timestamp
*/
const loadCacheMetadata = async cacheName => {
// Check in-memory cache first
if (metadataCache.has(cacheName)) {
return metadataCache.get(cacheName)
}
// Load from persistent cache
try {
const cache = await caches.open(cacheName)
const metaResp = await cache.match(CACHE_METADATA_KEY)
if (metaResp) {
const text = await metaResp.text()
const metadata = JSON.parse(text)
metadataCache.set(cacheName, metadata)
return metadata
}
} catch (err) {
log('warn', 'Failed to load cache metadata', {
cache: cacheName,
error: err.message
})
}
return {}
}
/**
* Save TTL metadata back to persistent cache.
* Updates in-memory cache and schedules sync.
* @param {string} cacheName - Cache store name
* @param {Object} metadata - Map of URL -> timestamp
* @returns {Promise<void>}
*/
const saveCacheMetadata = async (cacheName, metadata) => {
// Update in-memory cache
metadataCache.set(cacheName, metadata)
// Schedule persistent sync (debounced every 60s)
if (!metadataSyncTimer) {
metadataSyncTimer = setTimeout(async () => {
for (const [name, meta] of metadataCache.entries()) {
try {
const cache = await caches.open(name)
const resp = new Response(JSON.stringify(meta), {
headers: {'Content-Type': 'application/json'}
})
await cache.put(CACHE_METADATA_KEY, resp)
} catch (err) {
log('warn', 'Failed to persist cache metadata', {
cache: name,
error: err.message
})
}
}
metadataSyncTimer = null
}, CACHE_SYNC_INTERVAL_MS)
}
}
/**
* Check if a cached response has expired based on TTL.
* @param {string} cacheName - Cache store name
* @param {string} url - Request URL
* @returns {Promise<boolean>} - True if entry is expired
*/
const isCacheEntryExpired = async (cacheName, url) => {
const metadata = await loadCacheMetadata(cacheName)
const timestamp = metadata[url]
if (!timestamp) return true // No metadata = expired
return Date.now() - timestamp > CACHE_TTL_MS
}
/**
* Prune expired entries from a cache store.
* @param {string} cacheName - Cache store name
* @returns {Promise<void>}
*/
const pruneExpiredEntries = async cacheName => {
try {
const cache = await caches.open(cacheName)
const metadata = await loadCacheMetadata(cacheName)
const now = Date.now()
const entriesToDelete = []
for (const [url, timestamp] of Object.entries(metadata)) {
if (url === CACHE_METADATA_KEY) continue
if (now - timestamp > CACHE_TTL_MS) {
entriesToDelete.push(url)
}
}
for (const url of entriesToDelete) {
await cache.delete(url)
delete metadata[url]
}
if (entriesToDelete.length > 0) {
await saveCacheMetadata(cacheName, metadata)
log('info', `Pruned expired cache entries`, {
cache: cacheName,
count: entriesToDelete.length
})
}
} catch (err) {
log('error', `Error pruning cache`, {cache: cacheName, error: err.message})
}
}
/**
* Delete all caches from previous service worker versions.
* @returns {Promise<void>}
*/
const deleteOldCaches = async () => {
try {
const cacheNames = await caches.keys()
const deletions = cacheNames
.filter(name => !name.startsWith(CURRENT_CACHE))
.map(async cacheName => {
log('info', `Deleting old cache`, {cache: cacheName})
await caches.delete(cacheName)
})
await Promise.all(deletions)
} catch (err) {
log('error', `Error deleting old caches`, {error: err.message})
}
}
/**
* Prune expired entries from all current cache stores.
* @returns {Promise<void>}
*/
const pruneAllCurrentCaches = async () => {
try {
const cacheNames = await caches.keys()
const currentCaches = cacheNames.filter(c => c.startsWith(CURRENT_CACHE))
await Promise.all(currentCaches.map(pruneExpiredEntries))
} catch (err) {
log('error', `Error pruning all caches`, {error: err.message})
}
}
// ============================================================================
// URL Validation
// ============================================================================
/**
* Validate and normalize notification URL to prevent open redirects.
* @param {string} url - URL to validate
* @returns {string} - Safe URL or fallback
*/
const validateNotificationUrl = url => {
if (!url) return '/'
try {
const parsed = new URL(url, self.location.origin)
// Only allow same-origin URLs
if (parsed.origin === self.location.origin) {
return parsed.pathname + parsed.search + parsed.hash
}
} catch (err) {
log('warn', `Invalid notification URL`, {url, error: err.message})
}
return '/'
}
// ============================================================================
// Service Worker Lifecycle Events
// ============================================================================
/**
* Install event: declare intent to take over clients immediately.
*/
self.addEventListener('install', event => {
log('info', 'Service worker installing')
self.skipWaiting()
}) })
// Handle and show incoming push notifications /**
self.addEventListener('push', function (event) { * Activate event: take control of all clients and clean up old caches.
if (!(self.Notification && self.Notification.permission === 'granted')) { */
self.addEventListener('activate', event => {
log('info', 'Service worker activating')
event.waitUntil(
(async () => {
// Claim all clients immediately
await self.clients.claim()
log('info', 'Claimed all clients')
// Delete old version caches
await deleteOldCaches()
// Prune expired entries from current caches
await pruneAllCurrentCaches()
log('info', 'Activation complete; controlling all clients')
})()
)
})
// ============================================================================
// Fetch Event Handler
// ============================================================================
/**
* Fetch event: network-first strategy with timeout and cache fallback.
* Applies only to same-origin GET requests.
*/
self.addEventListener('fetch', event => {
const {request} = event
const isGetRequest = request.method === 'GET'
const isSameOrigin = request.url.startsWith(self.location.origin)
if (!isSameOrigin || !isGetRequest) {
// Let browser handle it
return return
} }
let data = event.data.json() event.respondWith(
const title = data.title (async () => {
const body = data.body const apiKeyHash = await getApiKeyHash(request)
const url = data.url const cacheName = CURRENT_CACHE + apiKeyHash
try {
// Open cache for this API key
const cache = await caches.open(cacheName)
try {
// Network-first: attempt fetch with timeout
const fetchPromise = fetch(request)
const response = await Promise.race([
fetchPromise,
timeout(NETWORK_TIMEOUT_MS)
])
// Cache only successful responses (2xx status)
if (response.ok) {
try {
const responseToCache = response.clone()
await cache.put(request, responseToCache)
const metadata = await loadCacheMetadata(cacheName)
metadata[request.url] = Date.now()
await saveCacheMetadata(cacheName, metadata)
} catch (err) {
log('warn', 'Failed to cache response', {
url: request.url,
error: err.message
})
}
} else {
// Do not cache error responses (4xx, 5xx)
log('debug', 'Not caching error response', {
url: request.url,
status: response.status
})
}
return response
} catch (fetchErr) {
// Network failed or timed out; try cache
const expired = await isCacheEntryExpired(cacheName, request.url)
const cachedResponse = await cache.match(request.url)
if (cachedResponse && !expired) {
log('info', 'Serving from cache', {
url: request.url,
reason: 'network_error'
})
return cachedResponse
}
if (cachedResponse && expired) {
log('warn', 'Serving stale cache (expired)', {url: request.url})
// Return stale but remove from cache soon
return cachedResponse
}
log('error', 'Network error and no cache', {
url: request.url,
error: fetchErr.message
})
return new Response('Offline and no cached response available', {
status: 503,
statusText: 'Service Unavailable',
headers: {'Content-Type': 'text/plain'}
})
}
} catch (err) {
log('error', 'Unexpected error in fetch handler', {
url: request.url,
error: err.message
})
return new Response('Internal service worker error', {
status: 500,
statusText: 'Internal Server Error',
headers: {'Content-Type': 'text/plain'}
})
}
})()
)
})
// ============================================================================
// Push Notification Events
// ============================================================================
/**
* Push event: handle incoming push notifications.
*/
self.addEventListener('push', event => {
if (!(self.Notification && self.Notification.permission === 'granted')) {
log('warn', 'Push received but notifications not permitted')
return
}
let data
try {
data = event.data.json()
} catch (err) {
log('error', 'Failed to parse push event data', {error: err.message})
return
}
const {title = 'LNbits', body = '', url = '/'} = data
event.waitUntil( event.waitUntil(
self.registration.showNotification(title, { self.registration.showNotification(title, {
body: body, body: body,
icon: '/favicon.ico', icon: '/favicon.ico',
data: { data: {url},
url: url badge: '/favicon.ico',
} tag: 'lnbits-notification'
}) })
) )
log('info', 'Notification shown', {title})
}) })
// User can click on the notification message to open wallet /**
// Installed app will open when `url_handlers` in web app manifest is supported * Notification click: open or focus the app window.
self.addEventListener('notificationclick', function (event) { */
self.addEventListener('notificationclick', event => {
event.notification.close() event.notification.close()
event.waitUntil(clients.openWindow(event.notification.data.url)) event.waitUntil(
(async () => {
const url = validateNotificationUrl(event.notification.data.url)
// Check if app is already open in a tab
const clients = await self.clients.matchAll({type: 'window'})
for (const client of clients) {
if (client.url.startsWith(url) && 'focus' in client) {
log('info', 'Focused existing client window', {url})
return client.focus()
}
}
// App not open; open a new window/tab
if (self.clients.openWindow) {
log('info', 'Opening new client window', {url})
return self.clients.openWindow(url)
}
})()
)
}) })
+4 -3
View File
@@ -61,9 +61,10 @@ async def api_install_extension(data: CreateExtension):
data.ext_id, data.source_repo, data.archive, data.version data.ext_id, data.source_repo, data.archive, data.version
) )
if not release: if not release:
raise HTTPException( raise HTTPException(HTTPStatus.NOT_FOUND, "Release not found")
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
) if not release.is_version_compatible:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
release.payment_hash = data.payment_hash release.payment_hash = data.payment_hash
ext_meta = ExtensionMeta(installed_release=release) ext_meta = ExtensionMeta(installed_release=release)
+3
View File
@@ -126,6 +126,9 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
if ext.meta and ext.meta.latest_release if ext.meta and ext.meta.latest_release
else None else None
), ),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": ( "installedRelease": (
dict(ext.meta.installed_release) dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release if ext.meta and ext.meta.installed_release
+3 -1
View File
@@ -296,7 +296,9 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
if payment.failed: if payment.failed:
return {"paid": False, "status": "failed", "details": payment} if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "status": "failed", "details": payment}
return {"paid": False, "status": "failed"}
try: try:
status = await payment.check_status() status = await payment.check_status()
+13 -236
View File
@@ -6,7 +6,6 @@ from typing import Any, Literal
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx import httpx
from httpx import HTTPStatusError
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
@@ -26,34 +25,6 @@ from .base import (
FiatMethod = Literal["checkout", "terminal"] FiatMethod = Literal["checkout", "terminal"]
# ---- NEW: normalized subscription status type ----
StripeStatus = Literal[
"active",
"trialing",
"past_due",
"unpaid",
"canceled",
"incomplete",
"incomplete_expired",
"paused",
"not_found",
"pending",
"error",
"unknown",
]
# Typed map to ensure mypy sees return values as StripeStatus (not plain str)
_STRIPE_STATUS_MAP: dict[str, StripeStatus] = {
"active": "active",
"trialing": "trialing",
"past_due": "past_due",
"unpaid": "unpaid",
"canceled": "canceled",
"incomplete": "incomplete",
"incomplete_expired": "incomplete_expired",
"paused": "paused",
}
class StripeTerminalOptions(BaseModel): class StripeTerminalOptions(BaseModel):
class Config: class Config:
@@ -72,22 +43,6 @@ class StripeCheckoutOptions(BaseModel):
line_item_name: str | None = None line_item_name: str | None = None
# === Direct-debit subscription options ===
class StripeRecurringOptions(BaseModel):
class Config:
extra = "ignore"
price_id: str | None = None
price_lookup_key: str | None = None
payment_method_types: list[str] = Field(default_factory=lambda: ["bacs_debit"])
success_url: str | None = None
cancel_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
customer_email: str | None = None
trial_days: int | None = None
class StripeCreateInvoiceOptions(BaseModel): class StripeCreateInvoiceOptions(BaseModel):
class Config: class Config:
extra = "ignore" extra = "ignore"
@@ -95,7 +50,6 @@ class StripeCreateInvoiceOptions(BaseModel):
fiat_method: FiatMethod = "checkout" fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None terminal: StripeTerminalOptions | None = None
checkout: StripeCheckoutOptions | None = None checkout: StripeCheckoutOptions | None = None
recurring: StripeRecurringOptions | None = None
class StripeWallet(FiatProvider): class StripeWallet(FiatProvider):
@@ -135,10 +89,12 @@ class StripeWallet(FiatProvider):
r = await self.client.get(url="/v1/balance", timeout=15) r = await self.client.get(url="/v1/balance", timeout=15)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
available = data.get("available") or [] available = data.get("available") or []
available_balance = 0 available_balance = 0
if available and isinstance(available, list): if available and isinstance(available, list):
available_balance = int(available[0].get("amount", 0)) available_balance = int(available[0].get("amount", 0))
return FiatStatusResponse(balance=available_balance) return FiatStatusResponse(balance=available_balance)
except json.JSONDecodeError: except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0) return FiatStatusResponse("Server error: 'invalid json response'", 0)
@@ -160,11 +116,6 @@ class StripeWallet(FiatProvider):
if not opts: if not opts:
return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options") return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options")
if opts.recurring is not None:
return await self._create_subscription_checkout_session(
payment_hash, memo, opts
)
if opts.fiat_method == "checkout": if opts.fiat_method == "checkout":
return await self._create_checkout_invoice( return await self._create_checkout_invoice(
amount_cents, currency, payment_hash, memo, opts amount_cents, currency, payment_hash, memo, opts
@@ -219,7 +170,6 @@ class StripeWallet(FiatProvider):
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
# ---------- One-off Checkout ----------
async def _create_checkout_invoice( async def _create_checkout_invoice(
self, self,
amount_cents: int, amount_cents: int,
@@ -273,7 +223,6 @@ class StripeWallet(FiatProvider):
ok=False, error_message=f"Unable to connect to {self.endpoint}." ok=False, error_message=f"Unable to connect to {self.endpoint}."
) )
# ---------- Terminal ----------
async def _create_terminal_invoice( async def _create_terminal_invoice(
self, self,
amount_cents: int, amount_cents: int,
@@ -316,189 +265,8 @@ class StripeWallet(FiatProvider):
ok=False, error_message=f"Unable to connect to {self.endpoint}." ok=False, error_message=f"Unable to connect to {self.endpoint}."
) )
# ---------- Subscription Checkout ----------
async def _create_subscription_checkout_session(
self,
payment_hash: str,
memo: str | None,
opts: StripeCreateInvoiceOptions,
) -> FiatInvoiceResponse:
rc = opts.recurring or StripeRecurringOptions()
try:
price_id = rc.price_id
if not price_id and rc.price_lookup_key:
price_id = await self._get_price_id_by_lookup_key(rc.price_lookup_key)
if not price_id:
return FiatInvoiceResponse(
ok=False,
error_message="Stripe: missing price_id or price_lookup_key",
)
success_url = (
rc.success_url
or (opts.checkout.success_url if opts.checkout else None)
or settings.stripe_payment_success_url
or "https://lnbits.com"
)
cancel_url = rc.cancel_url or success_url
form_data: list[tuple[str, str]] = [
("mode", "subscription"),
("success_url", success_url),
("cancel_url", cancel_url),
("payment_method_collection", "always"),
("metadata[payment_hash]", payment_hash),
("line_items[0][price]", price_id),
("line_items[0][quantity]", "1"),
]
if rc.trial_days:
form_data.append(
("subscription_data[trial_period_days]", str(rc.trial_days))
)
if rc.customer_email:
form_data.append(("customer_email", rc.customer_email))
form_data += self._encode_metadata("metadata", rc.metadata)
r = await self.client.post(
"/v1/checkout/sessions",
headers=self._build_headers_form(),
content=urlencode(form_data),
)
r.raise_for_status()
data = r.json()
session_id, url = data.get("id"), data.get("url")
if not session_id or not url:
return FiatInvoiceResponse(
ok=False,
error_message="Server error: missing id or url (subscription)",
)
return FiatInvoiceResponse(
ok=True, checking_id=session_id, payment_request=url
)
except HTTPStatusError as e:
body = e.response.text if e.response is not None else "<no body>"
logger.warning(f"Stripe subscription 400: {body}")
return FiatInvoiceResponse(ok=False, error_message=body)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
# ---------- Subscription status helpers (NEW) ----------
async def get_subscription_status(self, sub_or_session_id: str) -> StripeStatus:
"""
Accepts either a 'sub_...' or 'cs_...' id. If it's a 'cs_...',
returns 'pending' until the subscription exists; once it does,
returns the mapped subscription status.
"""
sid = self._normalize_stripe_id(sub_or_session_id)
try:
if sid.startswith("sub_"):
r = await self.client.get(f"/v1/subscriptions/{sid}")
if r.status_code == 404:
return "not_found"
r.raise_for_status()
return self._status_from_subscription(r.json())
if sid.startswith("cs_"):
r = await self.client.get(f"/v1/checkout/sessions/{sid}")
if r.status_code == 404:
return "not_found"
r.raise_for_status()
data = r.json()
subscription_id = data.get("subscription")
if not subscription_id:
return "pending"
r2 = await self.client.get(f"/v1/subscriptions/{subscription_id}")
if r2.status_code == 404:
return "not_found"
r2.raise_for_status()
return self._status_from_subscription(r2.json())
return "unknown"
except httpx.HTTPStatusError:
return "error"
except Exception:
return "error"
async def get_subscription_status_and_promote(
self, sub_or_session_id: str
) -> tuple[StripeStatus, str]:
"""
Returns (status, effective_id). If given a 'cs_...' and the Checkout
Session has created a subscription, returns the subscription status
AND the promoted 'sub_...' id so you can persist it. If given a 'sub_...',
returns its status and the same id.
"""
sid = self._normalize_stripe_id(sub_or_session_id)
try:
if sid.startswith("sub_"):
r = await self.client.get(f"/v1/subscriptions/{sid}")
if r.status_code == 404:
return ("not_found", sid)
r.raise_for_status()
return (self._status_from_subscription(r.json()), sid)
if sid.startswith("cs_"):
r = await self.client.get(f"/v1/checkout/sessions/{sid}")
if r.status_code == 404:
return ("not_found", sid)
r.raise_for_status()
data = r.json()
subscription_id = data.get("subscription")
if not subscription_id:
return ("pending", sid)
# Promote to the subscription id
r2 = await self.client.get(f"/v1/subscriptions/{subscription_id}")
if r2.status_code == 404:
return ("not_found", subscription_id)
r2.raise_for_status()
return (self._status_from_subscription(r2.json()), subscription_id)
return ("unknown", sid)
except httpx.HTTPStatusError:
return ("error", sid)
except Exception:
return ("error", sid)
def _status_from_subscription(self, sub: dict) -> StripeStatus:
status = (sub or {}).get("status")
if not status:
return "unknown"
return _STRIPE_STATUS_MAP.get(str(status).lower().strip(), "unknown")
# ---------- Helpers ----------
async def _get_price_id_by_lookup_key(self, lookup_key: str) -> str | None:
params = {"active": "true", "expand[]": "data.product", "limit": "1"}
qs = urlencode(params) + f"&lookup_keys[]={lookup_key}"
r = await self.client.get(f"/v1/prices?{qs}")
r.raise_for_status()
data = r.json()
items = (data or {}).get("data") or []
if not items:
return None
return items[0].get("id")
async def list_prices_for_product(self, product_id: str) -> list[dict]:
qs = urlencode({"product": product_id, "active": "true", "limit": "100"})
r = await self.client.get(f"/v1/prices?{qs}")
r.raise_for_status()
data = r.json()
return (data or {}).get("data") or []
def _normalize_stripe_id(self, checking_id: str) -> str: def _normalize_stripe_id(self, checking_id: str) -> str:
"""Remove our internal prefix so Stripe sees a real id."""
return ( return (
checking_id.replace("fiat_stripe_", "", 1) checking_id.replace("fiat_stripe_", "", 1)
if checking_id.startswith("fiat_stripe_") if checking_id.startswith("fiat_stripe_")
@@ -506,9 +274,11 @@ class StripeWallet(FiatProvider):
) )
def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus: def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus:
"""Map a Checkout Session to LNbits fiat status."""
if data.get("payment_status") == "paid": if data.get("payment_status") == "paid":
return FiatPaymentSuccessStatus() return FiatPaymentSuccessStatus()
# Consider an expired session a fail (existing 24h rule).
expires_at = data.get("expires_at") expires_at = data.get("expires_at")
_24h_ago = datetime.now(timezone.utc) - timedelta(hours=24) _24h_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and float(expires_at) < _24h_ago.timestamp(): if expires_at and float(expires_at) < _24h_ago.timestamp():
@@ -517,18 +287,25 @@ class StripeWallet(FiatProvider):
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus: def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus:
"""Map a PaymentIntent to LNbits fiat status (card_present friendly)."""
status = pi.get("status") status = pi.get("status")
if status == "succeeded": if status == "succeeded":
return FiatPaymentSuccessStatus() return FiatPaymentSuccessStatus()
if status in ("canceled", "payment_failed"): if status in ("canceled", "payment_failed"):
return FiatPaymentFailedStatus() return FiatPaymentFailedStatus()
if status == "requires_payment_method": if status == "requires_payment_method":
if pi.get("last_payment_error"): if pi.get("last_payment_error"):
return FiatPaymentFailedStatus() return FiatPaymentFailedStatus()
now_ts = datetime.now(timezone.utc).timestamp() now_ts = datetime.now(timezone.utc).timestamp()
created_ts = float(pi.get("created") or now_ts) created_ts = float(pi.get("created") or now_ts)
if (now_ts - created_ts) > 300: is_stale = (now_ts - created_ts) > 300
if is_stale:
return FiatPaymentFailedStatus() return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
def _build_headers_form(self) -> dict[str, str]: def _build_headers_form(self) -> dict[str, str]:
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -168,6 +168,8 @@ window.localisation.en = {
'Only admin accounts can create extensions', 'Only admin accounts can create extensions',
admin_only: 'Admin Only', admin_only: 'Admin Only',
new_version: 'New Version', new_version: 'New Version',
extension_has_free_release: 'Has free releases',
extension_has_paid_release: 'Has paid releases',
extension_depends_on: 'Depends on:', extension_depends_on: 'Depends on:',
extension_rating_soon: 'Ratings coming soon', extension_rating_soon: 'Ratings coming soon',
extension_installed_version: 'Installed version', extension_installed_version: 'Installed version',
@@ -663,5 +665,7 @@ window.localisation.en = {
callback_success_url_hint: callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful', 'The user will be redirected to this URL after the payment is successful',
connected: 'Connected', connected: 'Connected',
not_connected: 'Not Connected' not_connected: 'Not Connected',
free: 'Free',
paid: 'Paid'
} }
+4 -1
View File
@@ -468,7 +468,10 @@ window.AdminPageLogic = {
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
formatDate(date) { formatDate(date) {
return moment.utc(date * 1000).fromNow() return moment
.utc(date * 1000)
.local()
.fromNow()
}, },
sendTestEmail() { sendTestEmail() {
LNbits.api LNbits.api
+2 -2
View File
@@ -267,10 +267,10 @@ window.LNbits = {
} }
obj.date = moment.utc(data.created_at).local().format(window.dateFormat) obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
obj.dateFrom = moment.utc(data.created_at).fromNow() obj.dateFrom = moment.utc(data.created_at).local().fromNow()
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat) obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
obj.expirydateFrom = moment.utc(obj.expiry).fromNow() obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
obj.msat = obj.amount obj.msat = obj.amount
obj.sat = obj.msat / 1000 obj.sat = obj.msat / 1000
obj.tag = obj.extra?.tag obj.tag = obj.extra?.tag
+30 -29
View File
@@ -154,7 +154,7 @@ function confettiStars() {
setTimeout(shoot, 200) setTimeout(shoot, 200)
} }
!(function (t, e) { !(function (t, e) {
!(function t(e, n, a, i) { ;(!(function t(e, n, a, i) {
var o = !!( var o = !!(
e.Worker && e.Worker &&
e.Blob && e.Blob &&
@@ -248,12 +248,12 @@ function confettiStars() {
function e(e, n) { function e(e, n) {
t.postMessage({options: e || {}, callback: n}) t.postMessage({options: e || {}, callback: n})
} }
;(t.init = function (e) { ;((t.init = function (e) {
var n = e.transferControlToOffscreen() var n = e.transferControlToOffscreen()
t.postMessage({canvas: n}, [n]) t.postMessage({canvas: n}, [n])
}), }),
(t.fire = function (n, a, i) { (t.fire = function (n, a, i) {
if (g) return e(n, null), g if (g) return (e(n, null), g)
var o = Math.random().toString(36).slice(2) var o = Math.random().toString(36).slice(2)
return (g = l(function (a) { return (g = l(function (a) {
function r(e) { function r(e) {
@@ -264,15 +264,15 @@ function confettiStars() {
i(), i(),
a()) a())
} }
t.addEventListener('message', r), ;(t.addEventListener('message', r),
e(n, o), e(n, o),
(m[o] = r.bind(null, {data: {callback: o}})) (m[o] = r.bind(null, {data: {callback: o}})))
})) }))
}), }),
(t.reset = function () { (t.reset = function () {
for (var e in (t.postMessage({reset: !0}), m)) for (var e in (t.postMessage({reset: !0}), m))
m[e](), delete m[e] (m[e](), delete m[e])
}) }))
})(h) })(h)
} }
return h return h
@@ -328,12 +328,12 @@ function confettiStars() {
) )
} }
function k(t) { function k(t) {
;(t.width = document.documentElement.clientWidth), ;((t.width = document.documentElement.clientWidth),
(t.height = document.documentElement.clientHeight) (t.height = document.documentElement.clientHeight))
} }
function I(t) { function I(t) {
var e = t.getBoundingClientRect() var e = t.getBoundingClientRect()
;(t.width = e.width), (t.height = e.height) ;((t.width = e.width), (t.height = e.height))
} }
function T(t, e, n, o, r) { function T(t, e, n, o, r) {
var c, var c,
@@ -342,10 +342,10 @@ function confettiStars() {
d = t.getContext('2d'), d = t.getContext('2d'),
f = l(function (e) { f = l(function (e) {
function l() { function l() {
;(c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e() ;((c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e())
} }
;(c = b.frame(function e() { ;((c = b.frame(function e() {
!a || ;(!a ||
(o.width === i.width && o.height === i.height) || (o.width === i.width && o.height === i.height) ||
((o.width = t.width = i.width), (o.height = t.height = i.height)), ((o.width = t.width = i.width), (o.height = t.height = i.height)),
o.width || o.width ||
@@ -354,7 +354,7 @@ function confettiStars() {
d.clearRect(0, 0, o.width, o.height), d.clearRect(0, 0, o.width, o.height),
(u = u.filter(function (t) { (u = u.filter(function (t) {
return (function (t, e) { return (function (t, e) {
;(e.x += Math.cos(e.angle2D) * e.velocity + e.drift), ;((e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
(e.y += Math.sin(e.angle2D) * e.velocity + e.gravity), (e.y += Math.sin(e.angle2D) * e.velocity + e.gravity),
(e.wobble += 0.1), (e.wobble += 0.1),
(e.velocity *= e.decay), (e.velocity *= e.decay),
@@ -363,7 +363,7 @@ function confettiStars() {
(e.tiltCos = Math.cos(e.tiltAngle)), (e.tiltCos = Math.cos(e.tiltAngle)),
(e.random = Math.random() + 5), (e.random = Math.random() + 5),
(e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)), (e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)),
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble)) (e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble)))
var n = e.tick++ / e.totalTicks, var n = e.tick++ / e.totalTicks,
a = e.x + e.random * e.tiltCos, a = e.x + e.random * e.tiltCos,
i = e.y + e.random * e.tiltSin, i = e.y + e.random * e.tiltSin,
@@ -393,12 +393,12 @@ function confettiStars() {
2 * Math.PI 2 * Math.PI
) )
: (function (t, e, n, a, i, o, r, l, c) { : (function (t, e, n, a, i, o, r, l, c) {
t.save(), ;(t.save(),
t.translate(e, n), t.translate(e, n),
t.rotate(o), t.rotate(o),
t.scale(a, i), t.scale(a, i),
t.arc(0, 0, 1, r, l, c), t.arc(0, 0, 1, r, l, c),
t.restore() t.restore())
})( })(
t, t,
e.x, e.x,
@@ -420,18 +420,18 @@ function confettiStars() {
})(d, t) })(d, t)
})).length })).length
? (c = b.frame(e)) ? (c = b.frame(e))
: l() : l())
})), })),
(s = l) (s = l))
}) })
return { return {
addFettis: function (t) { addFettis: function (t) {
return (u = u.concat(t)), f return ((u = u.concat(t)), f)
}, },
canvas: t, canvas: t,
promise: f, promise: f,
reset: function () { reset: function () {
c && b.cancel(c), s && s() ;(c && b.cancel(c), s && s())
} }
} }
} }
@@ -466,7 +466,7 @@ function confettiStars() {
k = p(e, 'scalar'), k = p(e, 'scalar'),
I = (function (t) { I = (function (t) {
var e = p(t, 'origin', Object) var e = p(t, 'origin', Object)
return (e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e return ((e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e)
})(e), })(e),
E = d, E = d,
S = [], S = [],
@@ -531,7 +531,7 @@ function confettiStars() {
return l(function (t) { return l(function (t) {
t() t()
}) })
i && a ;(i && a
? (t = a.canvas) ? (t = a.canvas)
: i && : i &&
!t && !t &&
@@ -547,7 +547,7 @@ function confettiStars() {
) )
})(g)), })(g)),
document.body.appendChild(t)), document.body.appendChild(t)),
r && !d && u(t) r && !d && u(t))
var m = {width: t.width, height: t.height} var m = {width: t.width, height: t.height}
function b() { function b() {
if (s) { if (s) {
@@ -564,9 +564,9 @@ function confettiStars() {
m.width = m.height = null m.width = m.height = null
} }
function v() { function v() {
;(a = null), ;((a = null),
r && e.removeEventListener('resize', b), r && e.removeEventListener('resize', b),
i && t && (document.body.removeChild(t), (t = null), (d = !1)) i && t && (document.body.removeChild(t), (t = null), (d = !1)))
} }
return ( return (
s && !d && s.init(t), s && !d && s.init(t),
@@ -578,12 +578,13 @@ function confettiStars() {
} }
return ( return (
(g.reset = function () { (g.reset = function () {
s && s.reset(), a && a.reset() ;(s && s.reset(), a && a.reset())
}), }),
g g
) )
} }
;(n.exports = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E) ;((n.exports = E(null, {useWorker: !0, resize: !0})),
(n.exports.create = E))
})( })(
(function () { (function () {
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {} return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
@@ -591,5 +592,5 @@ function confettiStars() {
e, e,
!1 !1
), ),
(t.confetti = e.exports) (t.confetti = e.exports))
})(window, {}) })(window, {})
+1 -1
View File
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
return LNbits.utils.formatDate(this.ts) return LNbits.utils.formatDate(this.ts)
}, },
dateFrom() { dateFrom() {
return moment.utc(this.date).fromNow() return moment.utc(this.date).local().fromNow()
} }
}, },
template: ` template: `
+1 -1
View File
@@ -164,7 +164,7 @@ window.PaymentsPageLogic = {
if (p.extra && p.extra.tag) { if (p.extra && p.extra.tag) {
p.tag = p.extra.tag p.tag = p.extra.tag
} }
p.timeFrom = moment.utc(p.created_at).fromNow() p.timeFrom = moment.utc(p.created_at).local().fromNow()
p.outgoing = p.amount < 0 p.outgoing = p.amount < 0
p.amount = p.amount =
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) + new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
+9 -3
View File
@@ -179,7 +179,7 @@ window.WalletPageLogic = {
methods: { methods: {
dateFromNow(unix) { dateFromNow(unix) {
const date = new Date(unix * 1000) const date = new Date(unix * 1000)
return moment.utc(date).fromNow() return moment.utc(date).local().fromNow()
}, },
formatFiatAmount(amount, currency) { formatFiatAmount(amount, currency) {
this.update.currency = currency this.update.currency = currency
@@ -476,8 +476,14 @@ window.WalletPageLogic = {
createdDate, createdDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ' 'YYYY-MM-DDTHH:mm:ss.SSSZ'
) )
cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow() cleanInvoice.expireDateFrom = moment
cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow() .utc(expireDate)
.local()
.fromNow()
cleanInvoice.createdDateFrom = moment
.utc(createdDate)
.local()
.fromNow()
cleanInvoice.expired = false // TODO cleanInvoice.expired = false // TODO
} }
+1 -2
View File
@@ -649,8 +649,7 @@
</div> </div>
<div <div
v-if="showButtons" v-if="showButtons"
class="qrcode__buttons row q-gutter-x-sm" class="qrcode__buttons row q-gutter-x-sm items-center justify-end no-wrap full-width"
style="justify-content: flex-end"
> >
<q-btn <q-btn
v-if="nfc && nfcSupported" v-if="nfc && nfcSupported"
+321
View File
@@ -0,0 +1,321 @@
"""
Service Worker Integration Tests
Tests core functionality: cache management, TTL expiry, API key hashing,
timeout behavior, and URL validation.
Run with: pytest tests/test_service_worker.py -v
"""
import asyncio
import hashlib
import json
import time
import pytest
# Mock service worker helpers (simulate JS crypto and cache behavior)
class MockCache:
"""Simulates browser Cache API."""
def __init__(self):
self.store = {}
self.metadata = {}
async def put(self, url, response):
self.store[url] = response
self.metadata[url] = time.time() # Use time.time() for timestamps
async def match(self, url):
return self.store.get(url)
async def delete(self, url):
if url in self.store:
del self.store[url]
del self.metadata[url]
async def keys(self):
return list(self.store.keys())
# Service Worker helper functions (Python equivalent of JS code)
async def hash_api_key(api_key: str = None) -> str:
"""Hash API key using SHA-256."""
if not api_key:
api_key = "no_api_key"
return hashlib.sha256(api_key.encode()).hexdigest()
def validate_notification_url(url: str, origin: str = "https://example.com") -> str:
"""Validate notification URL; prevent open redirects."""
if not url:
return "/"
try:
if url.startswith(origin):
return url
if url.startswith("/"):
return url
except Exception:
pass
return "/"
def is_cache_entry_expired(
timestamp: float, ttl_ms: int = 7 * 24 * 60 * 60 * 1000
) -> bool:
"""Check if cache entry has exceeded TTL."""
# timestamp is in seconds, convert to ms for comparison
now_ms = time.time() * 1000
entry_ms = timestamp * 1000
return (now_ms - entry_ms) > ttl_ms
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def mock_cache():
"""Provide a mock cache store."""
return MockCache()
@pytest.fixture
def cache_config():
"""Cache configuration parameters."""
return {
"NETWORK_TIMEOUT_MS": 10000,
"CACHE_TTL_MS": 7 * 24 * 60 * 60 * 1000,
"CURRENT_CACHE": "lnbits-test-",
}
# ============================================================================
# Tests: Core Functionality
# ============================================================================
@pytest.mark.anyio
async def test_hash_api_key():
"""API keys should be hashed, not stored plaintext."""
api_key = "test-secret-key-12345"
hash1 = await hash_api_key(api_key)
hash2 = await hash_api_key(api_key)
# Consistent hash
assert hash1 == hash2
assert len(hash1) == 64 # SHA-256 hex digest
# Not plaintext
assert api_key not in hash1
@pytest.mark.anyio
async def test_hash_different_keys():
"""Different API keys should produce different hashes."""
hash1 = await hash_api_key("key1")
hash2 = await hash_api_key("key2")
assert hash1 != hash2
@pytest.mark.anyio
async def test_cache_put_and_match(mock_cache):
"""Cache should store and retrieve responses."""
url = "https://example.com/api/data"
response = {"status": 200, "body": "test"}
await mock_cache.put(url, response)
result = await mock_cache.match(url)
assert result == response
@pytest.mark.anyio
async def test_cache_metadata_tracking(mock_cache):
"""Cache should track timestamps for TTL expiry."""
url = "https://example.com/api/data"
response = {"status": 200}
await mock_cache.put(url, response)
assert url in mock_cache.metadata
assert mock_cache.metadata[url] > 0
# ============================================================================
# Tests: TTL and Expiry (Security & Correctness)
# ============================================================================
@pytest.mark.anyio
async def test_cache_entry_not_expired_fresh(cache_config):
"""Fresh cache entries should not be expired."""
now_ms = time.time() * 1000
ttl = cache_config["CACHE_TTL_MS"]
# Entry created just now
expired = is_cache_entry_expired(now_ms / 1000, ttl)
assert not expired
@pytest.mark.anyio
async def test_cache_entry_expired_old(cache_config):
"""Cache entries exceeding TTL should be expired."""
now_ms = time.time() * 1000
ttl = cache_config["CACHE_TTL_MS"]
# Entry created 8 days ago (exceeds 7-day TTL)
old_timestamp = (now_ms - ttl - 1000) / 1000
expired = is_cache_entry_expired(old_timestamp, ttl)
assert expired
# ============================================================================
# Tests: URL Validation (Security: CWE-601 Open Redirect)
# ============================================================================
@pytest.mark.anyio
async def test_validate_same_origin_url():
"""Same-origin URLs should be allowed."""
url = "/wallet?id=123"
result = validate_notification_url(url)
assert result == url
@pytest.mark.anyio
async def test_validate_same_origin_absolute():
"""Absolute same-origin URLs should be allowed."""
url = "https://example.com/wallet"
result = validate_notification_url(url, origin="https://example.com")
assert result == url
@pytest.mark.anyio
async def test_prevent_open_redirect_external():
"""External URLs should be rejected; redirect to safe default."""
malicious_url = "https://evil.com/phish"
result = validate_notification_url(malicious_url, origin="https://example.com")
assert result == "/"
@pytest.mark.anyio
async def test_prevent_open_redirect_protocol_switch():
"""Protocol switches should be prevented."""
malicious_url = "javascript:alert('xss')"
result = validate_notification_url(malicious_url)
assert result == "/"
@pytest.mark.anyio
async def test_validate_none_url():
"""Missing or null URLs should fallback to safe default."""
assert validate_notification_url(None) == "/"
assert validate_notification_url("") == "/"
# ============================================================================
# Tests: Network Timeout (Performance & UX)
# ============================================================================
@pytest.mark.anyio
async def test_network_timeout_enforced():
"""Fetch requests should timeout after configured delay."""
timeout_ms = 100
async def slow_fetch():
await asyncio.sleep(timeout_ms / 1000 + 0.5)
return {"status": 200}
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(slow_fetch(), timeout=timeout_ms / 1000)
@pytest.mark.anyio
async def test_timeout_fallback_to_cache(mock_cache):
"""On timeout, should fallback to cached response."""
url = "https://example.com/api/data"
cached_response = {"status": 200, "cached": True}
# Pre-populate cache
await mock_cache.put(url, cached_response)
result = await mock_cache.match(url)
assert result == cached_response
# ============================================================================
# Tests: Worker Lifecycle (Deployment Update)
# ============================================================================
@pytest.mark.anyio
async def test_cache_versioning():
"""Cache should use version prefix to isolate deployments."""
version1 = "lnbits-v1.0-"
version2 = "lnbits-v1.1-"
api_key_hash = await hash_api_key("key1")
cache_key_v1 = version1 + api_key_hash
cache_key_v2 = version2 + api_key_hash
assert cache_key_v1 != cache_key_v2
assert cache_key_v1.startswith("lnbits-v1.0-")
assert cache_key_v2.startswith("lnbits-v1.1-")
@pytest.mark.anyio
async def test_old_caches_should_be_cleaned(mock_cache):
"""Old cache versions should be deleted on activation."""
old_cache = "lnbits-old-version-"
current_cache = "lnbits-current-"
# Simulate old and new caches
await mock_cache.put(f"{old_cache}key1", {"data": "old"})
await mock_cache.put(f"{current_cache}key1", {"data": "new"})
# Cleanup: delete old
await mock_cache.delete(f"{old_cache}key1")
old_result = await mock_cache.match(f"{old_cache}key1")
current_result = await mock_cache.match(f"{current_cache}key1")
assert old_result is None
assert current_result == {"data": "new"}
# ============================================================================
# Tests: Edge Cases & Error Handling
# ============================================================================
@pytest.mark.anyio
async def test_malformed_push_notification():
"""Malformed push data should not crash handler."""
malformed_data = "not-json"
with pytest.raises(json.JSONDecodeError):
json.loads(malformed_data)
@pytest.mark.anyio
async def test_missing_api_key_defaults():
"""Missing API key should hash as 'no_api_key'."""
hash_with_key = await hash_api_key("actual-key")
hash_without_key = await hash_api_key(None)
assert hash_with_key != hash_without_key
@pytest.mark.anyio
async def test_cache_entry_collision_prevention():
"""Different API keys should use separate cache stores."""
key1 = "user-api-key-1"
key2 = "user-api-key-2"
hash1 = await hash_api_key(key1)
hash2 = await hash_api_key(key2)
cache1 = f"lnbits-{hash1}"
cache2 = f"lnbits-{hash2}"
assert cache1 != cache2