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]
jobs:
build-linux-package:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
# Step 1: Checkout the repository
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
# Step 2: Set up Python (uv will still use this toolchain)
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# Step 3: Install system deps (fuse) + uv
- name: Install system deps and uv
run: |
sudo apt-get update
@@ -30,7 +26,6 @@ jobs:
echo "$HOME/.local/bin" >> $GITHUB_PATH
shell: bash
# Optional: Cache uv + venv to speed up CI
- name: Cache uv and venv
uses: actions/cache@v4
with:
@@ -39,7 +34,6 @@ jobs:
.venv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
# Step 4: Prepare packaging tree and clone LNbits
- name: Prepare packaging & clone LNbits
run: |
mv .github/packaging packaging
@@ -47,18 +41,11 @@ jobs:
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
shell: bash
# Step 5: Build the LNbits binary with uv + PyInstaller
- name: Build LNbits binary (uv + PyInstaller)
run: |
cd packaging/linux/AppDir/usr/lnbits
# Install project deps into .venv using uv
uv sync
# Install PyInstaller into the same environment
uv sync --all-extras --no-dev
uv pip install pyinstaller
# Build the LNbits binary
uv run pyinstaller \
--onefile \
--name lnbits \
@@ -66,6 +53,10 @@ jobs:
--collect-all embit \
--collect-all lnbits \
--collect-all sqlalchemy \
--collect-all breez_sdk \
--collect-binaries breez_sdk \
--collect-all breez_sdk_liquid \
--collect-binaries breez_sdk_liquid \
--collect-all aiosqlite \
--hidden-import=passlib.handlers.bcrypt \
"$(uv run which lnbits)"
@@ -75,7 +66,7 @@ jobs:
chmod +x packaging/linux/AppDir/lnbits.desktop
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 \
! -name 'dist' \
! -name 'lnbits' \
@@ -91,9 +82,14 @@ jobs:
packaging/linux/AppDir "$APPIMAGE_NAME"
chmod +x "$APPIMAGE_NAME"
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
# Step 6: Upload Linux Release Asset
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
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:
```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
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
@@ -132,7 +132,7 @@ Now visit `0.0.0.0:5000` to make a super-user account.
```sh
# 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:
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
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
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
# Build LNbits
nix build
mkdir data
```
#### 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
if [ ! -d lnbits/data ]; then
# --- Config you might tweak ---
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
sudo apt update -y
sudo apt install -y software-properties-common
export DEBIAN_FRONTEND=noninteractive
# Add the deadsnakes PPA repository non-interactively
sudo add-apt-repository -y ppa:deadsnakes/ppa
# Install Python 3.10 and distutils non-interactively
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; }
# Ensure basic tooling
if ! command -v curl >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then
sudo apt-get update -y
sudo apt-get install -y curl git
fi
# Install the dependencies using UV
uv sync --all-extras
# System build deps and secp headers
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
export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0
# Clone or reuse repo
if [[ ! -d "$APP_DIR/.git" ]]; then
git clone "$REPO_URL" "$APP_DIR"
fi
# Run LNbits
uv run lnbits
cd "$APP_DIR"
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
critical_notification: str | None
details_link: str | None
paid_features: str | None
pay_link: str | None
def is_version_compatible(self):
@@ -187,6 +188,7 @@ class ExtensionRelease(BaseModel):
icon: str | None = None
details_link: str | None = None
paid_features: str | None = None
pay_link: str | None = None
cost_sats: int | None = None
paid_sats: int | None = 0
@@ -256,6 +258,7 @@ class ExtensionRelease(BaseModel):
html_url=e.html_url,
details_link=e.details_link,
pay_link=e.pay_link,
paid_features=e.paid_features,
repo=e.repo,
icon=e.icon,
)
@@ -308,6 +311,9 @@ class ExtensionMeta(BaseModel):
dependencies: list[str] = []
archive: str | None = None
featured: bool = False
paid_features: str | None = None
has_paid_release: bool = False
has_free_release: bool = False
class InstallableExtension(BaseModel):
@@ -451,9 +457,23 @@ class InstallableExtension(BaseModel):
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:
return
if not release.is_version_compatible:
return
if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta()
meta.latest_release = release
@@ -464,13 +484,19 @@ class InstallableExtension(BaseModel):
):
self.meta.latest_release = 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_payment_link(self, release: ExtensionRelease | None):
if not release:
return
if not release.is_version_compatible:
return
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):
if (
@@ -596,7 +622,7 @@ class InstallableExtension(BaseModel):
(ee for ee in extension_list if ee.id == r.id), None
)
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
meta = ext.meta or ExtensionMeta()
@@ -610,10 +636,10 @@ class InstallableExtension(BaseModel):
(ee for ee in extension_list if ee.id == e.id), None
)
if existing_ext:
existing_ext.check_latest_version(release)
existing_ext.check_release_updates(release)
continue
ext = InstallableExtension.from_explicit_release(e)
ext.check_latest_version(release)
ext.check_release_updates(release)
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
+33 -2
View File
@@ -137,8 +137,40 @@
@click="showExtensionDetails(extension.id, extension.details_link)"
v-text="extension.name"
></div>
<div>
<div style="justify-content: space-between; display: flex">
<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 style="justify-content: space-between; display: flex">
<q-toggle
@@ -916,7 +948,6 @@
:href="selectedExtensionDetails.repo"
target="_blank"
rel="noopener noreferrer"
class="q-pr-xs"
><q-tooltip>repository</q-tooltip></q-btn
>
</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
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
/**
* LNbits Service Worker - Hardened Production PWA
*
* 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')
if (!api_key || api_key == 'undefined') {
api_key = 'no_api_key'
}
return api_key
// ============================================================================
// Configuration
// ============================================================================
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
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 =>
evt.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (!cacheName.startsWith(CURRENT_CACHE)) {
return caches.delete(cacheName)
}
})
)
// ============================================================================
// Hashing and API Key Handling
// ============================================================================
/**
* Compute SHA-256 hash of input string; falls back to simple prefix hash.
* @param {string} str - Input string
* @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
})
)
)
// 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)
})
})
)
return btoa(str.substring(0, 32))
.replace(/[^a-z0-9]/gi, '')
.substring(0, 32)
}
}
/**
* 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) {
if (!(self.Notification && self.Notification.permission === 'granted')) {
/**
* Activate event: take control of all clients and clean up old caches.
*/
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
}
let data = event.data.json()
const title = data.title
const body = data.body
const url = data.url
event.respondWith(
(async () => {
const apiKeyHash = await getApiKeyHash(request)
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(
self.registration.showNotification(title, {
body: body,
icon: '/favicon.ico',
data: {
url: url
}
data: {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
self.addEventListener('notificationclick', function (event) {
/**
* Notification click: open or focus the app window.
*/
self.addEventListener('notificationclick', event => {
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
)
if not release:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
)
raise HTTPException(HTTPStatus.NOT_FOUND, "Release not found")
if not release.is_version_compatible:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
release.payment_hash = data.payment_hash
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
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": (
dict(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}
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:
status = await payment.check_status()
+13 -236
View File
@@ -6,7 +6,6 @@ from typing import Any, Literal
from urllib.parse import urlencode
import httpx
from httpx import HTTPStatusError
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
@@ -26,34 +25,6 @@ from .base import (
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 Config:
@@ -72,22 +43,6 @@ class StripeCheckoutOptions(BaseModel):
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 Config:
extra = "ignore"
@@ -95,7 +50,6 @@ class StripeCreateInvoiceOptions(BaseModel):
fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None
checkout: StripeCheckoutOptions | None = None
recurring: StripeRecurringOptions | None = None
class StripeWallet(FiatProvider):
@@ -135,10 +89,12 @@ class StripeWallet(FiatProvider):
r = await self.client.get(url="/v1/balance", timeout=15)
r.raise_for_status()
data = r.json()
available = data.get("available") or []
available_balance = 0
if available and isinstance(available, list):
available_balance = int(available[0].get("amount", 0))
return FiatStatusResponse(balance=available_balance)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
@@ -160,11 +116,6 @@ class StripeWallet(FiatProvider):
if not opts:
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":
return await self._create_checkout_invoice(
amount_cents, currency, payment_hash, memo, opts
@@ -219,7 +170,6 @@ class StripeWallet(FiatProvider):
r.raise_for_status()
return r.json()
# ---------- One-off Checkout ----------
async def _create_checkout_invoice(
self,
amount_cents: int,
@@ -273,7 +223,6 @@ class StripeWallet(FiatProvider):
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
# ---------- Terminal ----------
async def _create_terminal_invoice(
self,
amount_cents: int,
@@ -316,189 +265,8 @@ class StripeWallet(FiatProvider):
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:
"""Remove our internal prefix so Stripe sees a real id."""
return (
checking_id.replace("fiat_stripe_", "", 1)
if checking_id.startswith("fiat_stripe_")
@@ -506,9 +274,11 @@ class StripeWallet(FiatProvider):
)
def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus:
"""Map a Checkout Session to LNbits fiat status."""
if data.get("payment_status") == "paid":
return FiatPaymentSuccessStatus()
# Consider an expired session a fail (existing 24h rule).
expires_at = data.get("expires_at")
_24h_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and float(expires_at) < _24h_ago.timestamp():
@@ -517,18 +287,25 @@ class StripeWallet(FiatProvider):
return FiatPaymentPendingStatus()
def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus:
"""Map a PaymentIntent to LNbits fiat status (card_present friendly)."""
status = pi.get("status")
if status == "succeeded":
return FiatPaymentSuccessStatus()
if status in ("canceled", "payment_failed"):
return FiatPaymentFailedStatus()
if status == "requires_payment_method":
if pi.get("last_payment_error"):
return FiatPaymentFailedStatus()
now_ts = datetime.now(timezone.utc).timestamp()
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 FiatPaymentPendingStatus()
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',
admin_only: 'Admin Only',
new_version: 'New Version',
extension_has_free_release: 'Has free releases',
extension_has_paid_release: 'Has paid releases',
extension_depends_on: 'Depends on:',
extension_rating_soon: 'Ratings coming soon',
extension_installed_version: 'Installed version',
@@ -663,5 +665,7 @@ window.localisation.en = {
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful',
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)
},
formatDate(date) {
return moment.utc(date * 1000).fromNow()
return moment
.utc(date * 1000)
.local()
.fromNow()
},
sendTestEmail() {
LNbits.api
+2 -2
View File
@@ -267,10 +267,10 @@ window.LNbits = {
}
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.expirydateFrom = moment.utc(obj.expiry).fromNow()
obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
obj.msat = obj.amount
obj.sat = obj.msat / 1000
obj.tag = obj.extra?.tag
+30 -29
View File
@@ -154,7 +154,7 @@ function confettiStars() {
setTimeout(shoot, 200)
}
!(function (t, e) {
!(function t(e, n, a, i) {
;(!(function t(e, n, a, i) {
var o = !!(
e.Worker &&
e.Blob &&
@@ -248,12 +248,12 @@ function confettiStars() {
function e(e, n) {
t.postMessage({options: e || {}, callback: n})
}
;(t.init = function (e) {
;((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
if (g) return (e(n, null), g)
var o = Math.random().toString(36).slice(2)
return (g = l(function (a) {
function r(e) {
@@ -264,15 +264,15 @@ function confettiStars() {
i(),
a())
}
t.addEventListener('message', r),
;(t.addEventListener('message', r),
e(n, o),
(m[o] = r.bind(null, {data: {callback: 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]
})
(m[e](), delete m[e])
}))
})(h)
}
return h
@@ -328,12 +328,12 @@ function confettiStars() {
)
}
function k(t) {
;(t.width = document.documentElement.clientWidth),
(t.height = document.documentElement.clientHeight)
;((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)
;((t.width = e.width), (t.height = e.height))
}
function T(t, e, n, o, r) {
var c,
@@ -342,10 +342,10 @@ function confettiStars() {
d = t.getContext('2d'),
f = l(function (e) {
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() {
!a ||
;((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 ||
@@ -354,7 +354,7 @@ function confettiStars() {
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.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),
@@ -363,7 +363,7 @@ function confettiStars() {
(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))
(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,
@@ -393,12 +393,12 @@ function confettiStars() {
2 * Math.PI
)
: (function (t, e, n, a, i, o, r, l, c) {
t.save(),
;(t.save(),
t.translate(e, n),
t.rotate(o),
t.scale(a, i),
t.arc(0, 0, 1, r, l, c),
t.restore()
t.restore())
})(
t,
e.x,
@@ -420,18 +420,18 @@ function confettiStars() {
})(d, t)
})).length
? (c = b.frame(e))
: l()
: l())
})),
(s = l)
(s = l))
})
return {
addFettis: function (t) {
return (u = u.concat(t)), f
return ((u = u.concat(t)), f)
},
canvas: t,
promise: f,
reset: function () {
c && b.cancel(c), s && s()
;(c && b.cancel(c), s && s())
}
}
}
@@ -466,7 +466,7 @@ function confettiStars() {
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
return ((e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e)
})(e),
E = d,
S = [],
@@ -531,7 +531,7 @@ function confettiStars() {
return l(function (t) {
t()
})
i && a
;(i && a
? (t = a.canvas)
: i &&
!t &&
@@ -547,7 +547,7 @@ function confettiStars() {
)
})(g)),
document.body.appendChild(t)),
r && !d && u(t)
r && !d && u(t))
var m = {width: t.width, height: t.height}
function b() {
if (s) {
@@ -564,9 +564,9 @@ function confettiStars() {
m.width = m.height = null
}
function v() {
;(a = null),
;((a = null),
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 (
s && !d && s.init(t),
@@ -578,12 +578,13 @@ function confettiStars() {
}
return (
(g.reset = function () {
s && s.reset(), a && a.reset()
;(s && s.reset(), a && a.reset())
}),
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 () {
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
@@ -591,5 +592,5 @@ function confettiStars() {
e,
!1
),
(t.confetti = e.exports)
(t.confetti = e.exports))
})(window, {})
+1 -1
View File
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
return LNbits.utils.formatDate(this.ts)
},
dateFrom() {
return moment.utc(this.date).fromNow()
return moment.utc(this.date).local().fromNow()
}
},
template: `
+1 -1
View File
@@ -164,7 +164,7 @@ window.PaymentsPageLogic = {
if (p.extra && 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.amount =
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
+9 -3
View File
@@ -179,7 +179,7 @@ window.WalletPageLogic = {
methods: {
dateFromNow(unix) {
const date = new Date(unix * 1000)
return moment.utc(date).fromNow()
return moment.utc(date).local().fromNow()
},
formatFiatAmount(amount, currency) {
this.update.currency = currency
@@ -476,8 +476,14 @@ window.WalletPageLogic = {
createdDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow()
cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow()
cleanInvoice.expireDateFrom = moment
.utc(expireDate)
.local()
.fromNow()
cleanInvoice.createdDateFrom = moment
.utc(createdDate)
.local()
.fromNow()
cleanInvoice.expired = false // TODO
}
+1 -2
View File
@@ -649,8 +649,7 @@
</div>
<div
v-if="showButtons"
class="qrcode__buttons row q-gutter-x-sm"
style="justify-content: flex-end"
class="qrcode__buttons row q-gutter-x-sm items-center justify-end no-wrap full-width"
>
<q-btn
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