Compare commits

..
Author SHA1 Message Date
Arc 1f46545dea Reapply "Merge remote-tracking branch 'origin/dev' into stripe_recurring"
This reverts commit 37d2a695a7.
2025-09-26 11:49:42 +01:00
Arc 37d2a695a7 Revert "Merge remote-tracking branch 'origin/dev' into stripe_recurring"
This reverts commit 63458ced1e, reversing
changes made to cb3e340a78.
2025-09-26 11:47:50 +01:00
Arc 63458ced1e Merge remote-tracking branch 'origin/dev' into stripe_recurring 2025-09-26 11:44:04 +01:00
Arc cb3e340a78 mypy fix 2025-09-26 11:42:18 +01:00
arcbtc ae24f7e43c Merge branch 'or_stripe' into stripe_recurring 2025-09-25 05:18:26 +01:00
arcbtc 609808f6a2 added helper for getting subscription id 2025-09-25 05:17:08 +01:00
arcbtc c760e6f63d added a helper to get the subscription id
if returned that can be used for the checking id
2025-09-25 05:13:04 +01:00
ArcandArc 991ac4d7fe make 2025-09-25 03:58:14 +01:00
ArcandArc c1c622524e Working 2025-09-25 03:58:14 +01:00
arcbtcandArc ccc784c8fc recuuring payments 2025-09-25 03:58:14 +01:00
Arc 7c72766bbd make 2025-09-16 22:49:55 +01:00
Arc 416d170996 Working 2025-09-16 22:45:55 +01:00
arcbtc 94ebc22dcc recuuring payments 2025-09-15 21:13:28 +01:00
21 changed files with 415 additions and 1013 deletions
+1 -1
View File
@@ -1 +1 @@
custom: https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg custom: https://demo.lnbits.com/lnurlp/link/fH59GD
+17 -13
View File
@@ -5,19 +5,23 @@ on:
types: [published] types: [published]
jobs: jobs:
build-linux-package: build-linux-package:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
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
@@ -26,6 +30,7 @@ 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:
@@ -34,6 +39,7 @@ 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
@@ -41,11 +47,18 @@ 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 \
@@ -53,10 +66,6 @@ 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)"
@@ -66,7 +75,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
# keep AppDir slim # Clean out non-dist content from the app dir to keep AppImage 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' \
@@ -82,14 +91,9 @@ 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:
+6 -19
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 jq libfuse2 sudo apt-get install 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 --yes sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon
# 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,29 +145,16 @@ 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
# Build LNbits # Clone and build LNbits
git clone https://github.com/lnbits/lnbits.git
cd lnbits
nix build nix build
mkdir data
``` ```
#### Running the server #### Running the server
+42 -64
View File
@@ -1,75 +1,53 @@
#!/usr/bin/env bash #!/bin/bash
set -euo pipefail
# --- Config you might tweak --- # Check install has not already run
REPO_URL="https://github.com/lnbits/lnbits.git" if [ ! -d lnbits/data ]; then
BRANCH="main"
APP_DIR="${PWD}/lnbits"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-5000}"
ADMIN_UI="${LNBITS_ADMIN_UI:-true}"
# -------------------------------
export DEBIAN_FRONTEND=noninteractive # Update package list and install prerequisites non-interactively
sudo apt update -y
sudo apt install -y software-properties-common
# Ensure basic tooling # Add the deadsnakes PPA repository non-interactively
if ! command -v curl >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt-get update -y
sudo apt-get install -y curl git
fi
# System build deps and secp headers # Install Python 3.10 and distutils non-interactively
if command -v apt-get >/dev/null 2>&1; then sudo apt install -y python3.10 python3.10-distutils
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) # Install UV
if ! command -v uv >/dev/null 2>&1; then
curl -LsSf https://astral.sh/uv/install.sh | sh curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi export PATH="/home/$USER/.local/bin:$PATH"
# Ensure PATH for current session
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then if [ ! -d lnbits/wallets ]; then
export PATH="$HOME/.local/bin:$PATH" # 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
# Clone or reuse repo # Install the dependencies using UV
if [[ ! -d "$APP_DIR/.git" ]]; then uv sync --all-extras
git clone "$REPO_URL" "$APP_DIR"
fi
cd "$APP_DIR"
git fetch --all --prune
git checkout "$BRANCH"
git pull --ff-only || true
# First-run setup # Set environment variables for LNbits
mkdir -p data export LNBITS_ADMIN_UI=true
[[ -f .env ]] || cp .env.example .env || true export HOST=0.0.0.0
# Prefer system libsecp256k1 (avoid autotools path) # Run LNbits
export SECP_BUNDLED=0 uv run lnbits
# 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
+11 -37
View File
@@ -39,7 +39,6 @@ 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):
@@ -188,7 +187,6 @@ 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
@@ -258,7 +256,6 @@ 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,
) )
@@ -311,9 +308,6 @@ 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):
@@ -457,23 +451,9 @@ class InstallableExtension(BaseModel):
shutil.rmtree(self.ext_upgrade_dir, True) shutil.rmtree(self.ext_upgrade_dir, True)
def check_release_updates(self, release: ExtensionRelease | None): def check_latest_version(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
@@ -484,19 +464,13 @@ class InstallableExtension(BaseModel):
): ):
self.meta.latest_release = release self.meta.latest_release = release
def _check_payment_link(self, release: ExtensionRelease | None): def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
if not release: if not pay_link or not self.meta or not self.meta.payments:
return return None
if not release.is_version_compatible: return next(
return (p for p in self.meta.payments if p.pay_link == pay_link),
if not self.meta: None,
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 (
@@ -622,7 +596,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_release_updates(ext.meta.latest_release) existing_ext.check_latest_version(ext.meta.latest_release)
continue continue
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
@@ -636,10 +610,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_release_updates(release) existing_ext.check_latest_version(release)
continue continue
ext = InstallableExtension.from_explicit_release(e) ext = InstallableExtension.from_explicit_release(e)
ext.check_release_updates(release) ext.check_latest_version(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
+2 -33
View File
@@ -137,40 +137,8 @@
@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 style="justify-content: space-between; display: flex"> <div>
<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
@@ -948,6 +916,7 @@
: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>
+55 -448
View File
@@ -1,475 +1,82 @@
/** // update cache version every time there is a new deployment
* LNbits Service Worker - Hardened Production PWA // so the service worker reinitializes the cache
*
* 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
*/
// ============================================================================
// Configuration
// ============================================================================
const CURRENT_CACHE = 'lnbits-{{ cache_version }}-' 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) const getApiKey = request => {
let metadataCache = new Map() let api_key = request.headers.get('X-Api-Key')
let metadataSyncTimer = null if (!api_key || api_key == 'undefined') {
api_key = 'no_api_key'
// ============================================================================ }
// Logging Utilities return api_key
// ============================================================================
/**
* 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
// Hashing and API Key Handling self.addEventListener('activate', evt =>
// ============================================================================ evt.waitUntil(
caches.keys().then(cacheNames => {
/** return Promise.all(
* Compute SHA-256 hash of input string; falls back to simple prefix hash. cacheNames.map(cacheName => {
* @param {string} str - Input string if (!cacheName.startsWith(CURRENT_CACHE)) {
* @returns {Promise<string>} - Hex digest or fallback hash return caches.delete(cacheName)
*/ }
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)
}
}
/**
* 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)
) )
} )
// ============================================================================ // The fetch handler serves responses for same-origin resources from a cache.
// Cache Metadata Management // If no response is found, it populates the runtime cache with the response
// ============================================================================ // from the network before returning it to the page.
/**
* 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()
})
/**
* 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 => { self.addEventListener('fetch', event => {
const {request} = event if (
const isGetRequest = request.method === 'GET' event.request.url.startsWith(self.location.origin) &&
const isSameOrigin = 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())
if (!isSameOrigin || !isGetRequest) { return fetchedResponse
// Let browser handle it })
return .catch(() => {
// If the network is unavailable, get
return cache.match(event.request.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'}
})
}
})()
)
}) })
// ============================================================================ // Handle and show incoming push notifications
// Push Notification Events self.addEventListener('push', function (event) {
// ============================================================================
/**
* Push event: handle incoming push notifications.
*/
self.addEventListener('push', event => {
if (!(self.Notification && self.Notification.permission === 'granted')) { if (!(self.Notification && self.Notification.permission === 'granted')) {
log('warn', 'Push received but notifications not permitted')
return return
} }
let data let data = event.data.json()
try { const title = data.title
data = event.data.json() const body = data.body
} catch (err) { const url = data.url
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: {url}, data: {
badge: '/favicon.ico', url: url
tag: 'lnbits-notification' }
}) })
) )
log('info', 'Notification shown', {title})
}) })
/** // User can click on the notification message to open wallet
* Notification click: open or focus the app window. // Installed app will open when `url_handlers` in web app manifest is supported
*/ self.addEventListener('notificationclick', function (event) {
self.addEventListener('notificationclick', event => {
event.notification.close() event.notification.close()
event.waitUntil( event.waitUntil(clients.openWindow(event.notification.data.url))
(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)
}
})()
)
}) })
+3 -4
View File
@@ -61,10 +61,9 @@ 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(HTTPStatus.NOT_FOUND, "Release not found") raise HTTPException(
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,9 +126,6 @@ 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
+1 -3
View File
@@ -296,9 +296,7 @@ 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:
if wallet and wallet.id == payment.wallet_id: return {"paid": False, "status": "failed", "details": payment}
return {"paid": False, "status": "failed", "details": payment}
return {"paid": False, "status": "failed"}
try: try:
status = await payment.check_status() status = await payment.check_status()
+236 -13
View File
@@ -6,6 +6,7 @@ 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
@@ -25,6 +26,34 @@ 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:
@@ -43,6 +72,22 @@ 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"
@@ -50,6 +95,7 @@ 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):
@@ -89,12 +135,10 @@ 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)
@@ -116,6 +160,11 @@ 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
@@ -170,6 +219,7 @@ 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,
@@ -223,6 +273,7 @@ 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,
@@ -265,8 +316,189 @@ 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_")
@@ -274,11 +506,9 @@ 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():
@@ -287,25 +517,18 @@ 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)
is_stale = (now_ts - created_ts) > 300 if (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
+1 -5
View File
@@ -168,8 +168,6 @@ 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',
@@ -665,7 +663,5 @@ 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'
} }
+1 -4
View File
@@ -468,10 +468,7 @@ window.AdminPageLogic = {
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
formatDate(date) { formatDate(date) {
return moment return moment.utc(date * 1000).fromNow()
.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).local().fromNow() obj.dateFrom = moment.utc(data.created_at).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).local().fromNow() obj.expirydateFrom = moment.utc(obj.expiry).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
+29 -30
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,13 +578,12 @@ 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 = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E)
(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 || {}
@@ -592,5 +591,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).local().fromNow() return moment.utc(this.date).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).local().fromNow() p.timeFrom = moment.utc(p.created_at).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) +
+3 -9
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).local().fromNow() return moment.utc(date).fromNow()
}, },
formatFiatAmount(amount, currency) { formatFiatAmount(amount, currency) {
this.update.currency = currency this.update.currency = currency
@@ -476,14 +476,8 @@ window.WalletPageLogic = {
createdDate, createdDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ' 'YYYY-MM-DDTHH:mm:ss.SSSZ'
) )
cleanInvoice.expireDateFrom = moment cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow()
.utc(expireDate) cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow()
.local()
.fromNow()
cleanInvoice.createdDateFrom = moment
.utc(createdDate)
.local()
.fromNow()
cleanInvoice.expired = false // TODO cleanInvoice.expired = false // TODO
} }
+2 -1
View File
@@ -649,7 +649,8 @@
</div> </div>
<div <div
v-if="showButtons" v-if="showButtons"
class="qrcode__buttons row q-gutter-x-sm items-center justify-end no-wrap full-width" class="qrcode__buttons row q-gutter-x-sm"
style="justify-content: flex-end"
> >
<q-btn <q-btn
v-if="nfc && nfcSupported" v-if="nfc && nfcSupported"
-321
View File
@@ -1,321 +0,0 @@
"""
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