Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55399794e | ||
|
|
10fc88f777 | ||
|
|
266b16834b | ||
|
|
ad606b2517 | ||
|
|
1ec28079d9 | ||
|
|
aeaddc1dc6 |
+1
-1
@@ -1 +1 @@
|
||||
custom: https://demo.lnbits.com/lnurlp/link/fH59GD
|
||||
custom: https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user