Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55399794e | ||
|
|
10fc88f777 | ||
|
|
266b16834b | ||
|
|
ad606b2517 | ||
|
|
1ec28079d9 | ||
|
|
aeaddc1dc6 | ||
|
|
a9374d509c | ||
|
|
3e72788fc8 | ||
|
|
f48d24aca8 | ||
|
|
015262c9b3 | ||
|
|
e5e481f836 | ||
|
|
6c32ebf7e6 |
+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
|
||||
|
||||
@@ -39,6 +39,7 @@ class ExplicitRelease(BaseModel):
|
||||
info_notification: str | None
|
||||
critical_notification: str | None
|
||||
details_link: str | None
|
||||
paid_features: str | None
|
||||
pay_link: str | None
|
||||
|
||||
def is_version_compatible(self):
|
||||
@@ -187,6 +188,7 @@ class ExtensionRelease(BaseModel):
|
||||
icon: str | None = None
|
||||
details_link: str | None = None
|
||||
|
||||
paid_features: str | None = None
|
||||
pay_link: str | None = None
|
||||
cost_sats: int | None = None
|
||||
paid_sats: int | None = 0
|
||||
@@ -256,6 +258,7 @@ class ExtensionRelease(BaseModel):
|
||||
html_url=e.html_url,
|
||||
details_link=e.details_link,
|
||||
pay_link=e.pay_link,
|
||||
paid_features=e.paid_features,
|
||||
repo=e.repo,
|
||||
icon=e.icon,
|
||||
)
|
||||
@@ -308,6 +311,9 @@ class ExtensionMeta(BaseModel):
|
||||
dependencies: list[str] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
paid_features: str | None = None
|
||||
has_paid_release: bool = False
|
||||
has_free_release: bool = False
|
||||
|
||||
|
||||
class InstallableExtension(BaseModel):
|
||||
@@ -451,9 +457,23 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
|
||||
def check_latest_version(self, release: ExtensionRelease | None):
|
||||
def check_release_updates(self, release: ExtensionRelease | None):
|
||||
self._check_latest_version(release)
|
||||
self._check_payment_link(release)
|
||||
|
||||
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
||||
if not pay_link or not self.meta or not self.meta.payments:
|
||||
return None
|
||||
return next(
|
||||
(p for p in self.meta.payments if p.pay_link == pay_link),
|
||||
None,
|
||||
)
|
||||
|
||||
def _check_latest_version(self, release: ExtensionRelease | None):
|
||||
if not release:
|
||||
return
|
||||
if not release.is_version_compatible:
|
||||
return
|
||||
if not self.meta or not self.meta.latest_release:
|
||||
meta = self.meta or ExtensionMeta()
|
||||
meta.latest_release = release
|
||||
@@ -464,13 +484,19 @@ class InstallableExtension(BaseModel):
|
||||
):
|
||||
self.meta.latest_release = release
|
||||
|
||||
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
|
||||
if not pay_link or not self.meta or not self.meta.payments:
|
||||
return None
|
||||
return next(
|
||||
(p for p in self.meta.payments if p.pay_link == pay_link),
|
||||
None,
|
||||
)
|
||||
def _check_payment_link(self, release: ExtensionRelease | None):
|
||||
if not release:
|
||||
return
|
||||
if not release.is_version_compatible:
|
||||
return
|
||||
if not self.meta:
|
||||
self.meta = ExtensionMeta()
|
||||
if release.pay_link:
|
||||
self.meta.has_paid_release = True
|
||||
else:
|
||||
self.meta.has_free_release = True
|
||||
if release.paid_features:
|
||||
self.meta.paid_features = release.paid_features
|
||||
|
||||
def _restore_payment_info(self):
|
||||
if (
|
||||
@@ -596,7 +622,7 @@ class InstallableExtension(BaseModel):
|
||||
(ee for ee in extension_list if ee.id == r.id), None
|
||||
)
|
||||
if existing_ext and ext.meta:
|
||||
existing_ext.check_latest_version(ext.meta.latest_release)
|
||||
existing_ext.check_release_updates(ext.meta.latest_release)
|
||||
continue
|
||||
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
@@ -610,10 +636,10 @@ class InstallableExtension(BaseModel):
|
||||
(ee for ee in extension_list if ee.id == e.id), None
|
||||
)
|
||||
if existing_ext:
|
||||
existing_ext.check_latest_version(release)
|
||||
existing_ext.check_release_updates(release)
|
||||
continue
|
||||
ext = InstallableExtension.from_explicit_release(e)
|
||||
ext.check_latest_version(release)
|
||||
ext.check_release_updates(release)
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
ext.meta = meta
|
||||
|
||||
@@ -137,8 +137,40 @@
|
||||
@click="showExtensionDetails(extension.id, extension.details_link)"
|
||||
v-text="extension.name"
|
||||
></div>
|
||||
<div>
|
||||
<div style="justify-content: space-between; display: flex">
|
||||
<lnbits-extension-rating :rating="0" />
|
||||
<q-btn-group size="xs" style="margin: 5px 0">
|
||||
<q-btn
|
||||
v-if="extension.hasFreeRelease"
|
||||
color="green"
|
||||
size="xs"
|
||||
:label="$t('free')"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('extension_has_free_release')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="extension.hasPaidRelease || extension.paidFeatures"
|
||||
color="primary"
|
||||
size="xs"
|
||||
:label="$t('paid')"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span
|
||||
v-if="extension.hasPaidRelease"
|
||||
v-text="$t('extension_has_paid_release')"
|
||||
></span>
|
||||
<br
|
||||
v-if="extension.hasPaidRelease && extension.paidFeatures"
|
||||
/>
|
||||
<span
|
||||
v-if="extension.paidFeatures"
|
||||
v-text="extension.paidFeatures"
|
||||
></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</q-btn-group>
|
||||
</div>
|
||||
<div style="justify-content: space-between; display: flex">
|
||||
<q-toggle
|
||||
@@ -916,7 +948,6 @@
|
||||
:href="selectedExtensionDetails.repo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="q-pr-xs"
|
||||
><q-tooltip>repository</q-tooltip></q-btn
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
)
|
||||
})
|
||||
|
||||
@@ -61,9 +61,10 @@ async def api_install_extension(data: CreateExtension):
|
||||
data.ext_id, data.source_repo, data.archive, data.version
|
||||
)
|
||||
if not release:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||
)
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Release not found")
|
||||
|
||||
if not release.is_version_compatible:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
|
||||
|
||||
release.payment_hash = data.payment_hash
|
||||
ext_meta = ExtensionMeta(installed_release=release)
|
||||
|
||||
@@ -126,6 +126,9 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||
if ext.meta and ext.meta.latest_release
|
||||
else None
|
||||
),
|
||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
||||
"installedRelease": (
|
||||
dict(ext.meta.installed_release)
|
||||
if ext.meta and ext.meta.installed_release
|
||||
|
||||
@@ -296,7 +296,9 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
|
||||
return {"paid": True, "preimage": payment.preimage}
|
||||
|
||||
if payment.failed:
|
||||
return {"paid": False, "status": "failed", "details": payment}
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": False, "status": "failed", "details": payment}
|
||||
return {"paid": False, "status": "failed"}
|
||||
|
||||
try:
|
||||
status = await payment.check_status()
|
||||
|
||||
+13
-236
@@ -6,7 +6,6 @@ from typing import Any, Literal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from httpx import HTTPStatusError
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
@@ -26,34 +25,6 @@ from .base import (
|
||||
|
||||
FiatMethod = Literal["checkout", "terminal"]
|
||||
|
||||
# ---- NEW: normalized subscription status type ----
|
||||
StripeStatus = Literal[
|
||||
"active",
|
||||
"trialing",
|
||||
"past_due",
|
||||
"unpaid",
|
||||
"canceled",
|
||||
"incomplete",
|
||||
"incomplete_expired",
|
||||
"paused",
|
||||
"not_found",
|
||||
"pending",
|
||||
"error",
|
||||
"unknown",
|
||||
]
|
||||
|
||||
# Typed map to ensure mypy sees return values as StripeStatus (not plain str)
|
||||
_STRIPE_STATUS_MAP: dict[str, StripeStatus] = {
|
||||
"active": "active",
|
||||
"trialing": "trialing",
|
||||
"past_due": "past_due",
|
||||
"unpaid": "unpaid",
|
||||
"canceled": "canceled",
|
||||
"incomplete": "incomplete",
|
||||
"incomplete_expired": "incomplete_expired",
|
||||
"paused": "paused",
|
||||
}
|
||||
|
||||
|
||||
class StripeTerminalOptions(BaseModel):
|
||||
class Config:
|
||||
@@ -72,22 +43,6 @@ class StripeCheckoutOptions(BaseModel):
|
||||
line_item_name: str | None = None
|
||||
|
||||
|
||||
# === Direct-debit subscription options ===
|
||||
class StripeRecurringOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
price_id: str | None = None
|
||||
price_lookup_key: str | None = None
|
||||
payment_method_types: list[str] = Field(default_factory=lambda: ["bacs_debit"])
|
||||
|
||||
success_url: str | None = None
|
||||
cancel_url: str | None = None
|
||||
metadata: dict[str, str] = Field(default_factory=dict)
|
||||
customer_email: str | None = None
|
||||
trial_days: int | None = None
|
||||
|
||||
|
||||
class StripeCreateInvoiceOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
@@ -95,7 +50,6 @@ class StripeCreateInvoiceOptions(BaseModel):
|
||||
fiat_method: FiatMethod = "checkout"
|
||||
terminal: StripeTerminalOptions | None = None
|
||||
checkout: StripeCheckoutOptions | None = None
|
||||
recurring: StripeRecurringOptions | None = None
|
||||
|
||||
|
||||
class StripeWallet(FiatProvider):
|
||||
@@ -135,10 +89,12 @@ class StripeWallet(FiatProvider):
|
||||
r = await self.client.get(url="/v1/balance", timeout=15)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
available = data.get("available") or []
|
||||
available_balance = 0
|
||||
if available and isinstance(available, list):
|
||||
available_balance = int(available[0].get("amount", 0))
|
||||
|
||||
return FiatStatusResponse(balance=available_balance)
|
||||
except json.JSONDecodeError:
|
||||
return FiatStatusResponse("Server error: 'invalid json response'", 0)
|
||||
@@ -160,11 +116,6 @@ class StripeWallet(FiatProvider):
|
||||
if not opts:
|
||||
return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options")
|
||||
|
||||
if opts.recurring is not None:
|
||||
return await self._create_subscription_checkout_session(
|
||||
payment_hash, memo, opts
|
||||
)
|
||||
|
||||
if opts.fiat_method == "checkout":
|
||||
return await self._create_checkout_invoice(
|
||||
amount_cents, currency, payment_hash, memo, opts
|
||||
@@ -219,7 +170,6 @@ class StripeWallet(FiatProvider):
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
# ---------- One-off Checkout ----------
|
||||
async def _create_checkout_invoice(
|
||||
self,
|
||||
amount_cents: int,
|
||||
@@ -273,7 +223,6 @@ class StripeWallet(FiatProvider):
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
# ---------- Terminal ----------
|
||||
async def _create_terminal_invoice(
|
||||
self,
|
||||
amount_cents: int,
|
||||
@@ -316,189 +265,8 @@ class StripeWallet(FiatProvider):
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
# ---------- Subscription Checkout ----------
|
||||
async def _create_subscription_checkout_session(
|
||||
self,
|
||||
payment_hash: str,
|
||||
memo: str | None,
|
||||
opts: StripeCreateInvoiceOptions,
|
||||
) -> FiatInvoiceResponse:
|
||||
rc = opts.recurring or StripeRecurringOptions()
|
||||
try:
|
||||
price_id = rc.price_id
|
||||
if not price_id and rc.price_lookup_key:
|
||||
price_id = await self._get_price_id_by_lookup_key(rc.price_lookup_key)
|
||||
if not price_id:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False,
|
||||
error_message="Stripe: missing price_id or price_lookup_key",
|
||||
)
|
||||
|
||||
success_url = (
|
||||
rc.success_url
|
||||
or (opts.checkout.success_url if opts.checkout else None)
|
||||
or settings.stripe_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
)
|
||||
cancel_url = rc.cancel_url or success_url
|
||||
|
||||
form_data: list[tuple[str, str]] = [
|
||||
("mode", "subscription"),
|
||||
("success_url", success_url),
|
||||
("cancel_url", cancel_url),
|
||||
("payment_method_collection", "always"),
|
||||
("metadata[payment_hash]", payment_hash),
|
||||
("line_items[0][price]", price_id),
|
||||
("line_items[0][quantity]", "1"),
|
||||
]
|
||||
|
||||
if rc.trial_days:
|
||||
form_data.append(
|
||||
("subscription_data[trial_period_days]", str(rc.trial_days))
|
||||
)
|
||||
|
||||
if rc.customer_email:
|
||||
form_data.append(("customer_email", rc.customer_email))
|
||||
|
||||
form_data += self._encode_metadata("metadata", rc.metadata)
|
||||
|
||||
r = await self.client.post(
|
||||
"/v1/checkout/sessions",
|
||||
headers=self._build_headers_form(),
|
||||
content=urlencode(form_data),
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
session_id, url = data.get("id"), data.get("url")
|
||||
if not session_id or not url:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False,
|
||||
error_message="Server error: missing id or url (subscription)",
|
||||
)
|
||||
return FiatInvoiceResponse(
|
||||
ok=True, checking_id=session_id, payment_request=url
|
||||
)
|
||||
|
||||
except HTTPStatusError as e:
|
||||
body = e.response.text if e.response is not None else "<no body>"
|
||||
logger.warning(f"Stripe subscription 400: {body}")
|
||||
return FiatInvoiceResponse(ok=False, error_message=body)
|
||||
except json.JSONDecodeError:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: invalid json response"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
# ---------- Subscription status helpers (NEW) ----------
|
||||
async def get_subscription_status(self, sub_or_session_id: str) -> StripeStatus:
|
||||
"""
|
||||
Accepts either a 'sub_...' or 'cs_...' id. If it's a 'cs_...',
|
||||
returns 'pending' until the subscription exists; once it does,
|
||||
returns the mapped subscription status.
|
||||
"""
|
||||
sid = self._normalize_stripe_id(sub_or_session_id)
|
||||
try:
|
||||
if sid.startswith("sub_"):
|
||||
r = await self.client.get(f"/v1/subscriptions/{sid}")
|
||||
if r.status_code == 404:
|
||||
return "not_found"
|
||||
r.raise_for_status()
|
||||
return self._status_from_subscription(r.json())
|
||||
|
||||
if sid.startswith("cs_"):
|
||||
r = await self.client.get(f"/v1/checkout/sessions/{sid}")
|
||||
if r.status_code == 404:
|
||||
return "not_found"
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
subscription_id = data.get("subscription")
|
||||
if not subscription_id:
|
||||
return "pending"
|
||||
r2 = await self.client.get(f"/v1/subscriptions/{subscription_id}")
|
||||
if r2.status_code == 404:
|
||||
return "not_found"
|
||||
r2.raise_for_status()
|
||||
return self._status_from_subscription(r2.json())
|
||||
|
||||
return "unknown"
|
||||
|
||||
except httpx.HTTPStatusError:
|
||||
return "error"
|
||||
except Exception:
|
||||
return "error"
|
||||
|
||||
async def get_subscription_status_and_promote(
|
||||
self, sub_or_session_id: str
|
||||
) -> tuple[StripeStatus, str]:
|
||||
"""
|
||||
Returns (status, effective_id). If given a 'cs_...' and the Checkout
|
||||
Session has created a subscription, returns the subscription status
|
||||
AND the promoted 'sub_...' id so you can persist it. If given a 'sub_...',
|
||||
returns its status and the same id.
|
||||
"""
|
||||
sid = self._normalize_stripe_id(sub_or_session_id)
|
||||
try:
|
||||
if sid.startswith("sub_"):
|
||||
r = await self.client.get(f"/v1/subscriptions/{sid}")
|
||||
if r.status_code == 404:
|
||||
return ("not_found", sid)
|
||||
r.raise_for_status()
|
||||
return (self._status_from_subscription(r.json()), sid)
|
||||
|
||||
if sid.startswith("cs_"):
|
||||
r = await self.client.get(f"/v1/checkout/sessions/{sid}")
|
||||
if r.status_code == 404:
|
||||
return ("not_found", sid)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
subscription_id = data.get("subscription")
|
||||
if not subscription_id:
|
||||
return ("pending", sid)
|
||||
|
||||
# Promote to the subscription id
|
||||
r2 = await self.client.get(f"/v1/subscriptions/{subscription_id}")
|
||||
if r2.status_code == 404:
|
||||
return ("not_found", subscription_id)
|
||||
r2.raise_for_status()
|
||||
return (self._status_from_subscription(r2.json()), subscription_id)
|
||||
|
||||
return ("unknown", sid)
|
||||
|
||||
except httpx.HTTPStatusError:
|
||||
return ("error", sid)
|
||||
except Exception:
|
||||
return ("error", sid)
|
||||
|
||||
def _status_from_subscription(self, sub: dict) -> StripeStatus:
|
||||
status = (sub or {}).get("status")
|
||||
if not status:
|
||||
return "unknown"
|
||||
return _STRIPE_STATUS_MAP.get(str(status).lower().strip(), "unknown")
|
||||
|
||||
# ---------- Helpers ----------
|
||||
async def _get_price_id_by_lookup_key(self, lookup_key: str) -> str | None:
|
||||
params = {"active": "true", "expand[]": "data.product", "limit": "1"}
|
||||
qs = urlencode(params) + f"&lookup_keys[]={lookup_key}"
|
||||
r = await self.client.get(f"/v1/prices?{qs}")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
items = (data or {}).get("data") or []
|
||||
if not items:
|
||||
return None
|
||||
return items[0].get("id")
|
||||
|
||||
async def list_prices_for_product(self, product_id: str) -> list[dict]:
|
||||
qs = urlencode({"product": product_id, "active": "true", "limit": "100"})
|
||||
r = await self.client.get(f"/v1/prices?{qs}")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return (data or {}).get("data") or []
|
||||
|
||||
def _normalize_stripe_id(self, checking_id: str) -> str:
|
||||
"""Remove our internal prefix so Stripe sees a real id."""
|
||||
return (
|
||||
checking_id.replace("fiat_stripe_", "", 1)
|
||||
if checking_id.startswith("fiat_stripe_")
|
||||
@@ -506,9 +274,11 @@ class StripeWallet(FiatProvider):
|
||||
)
|
||||
|
||||
def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus:
|
||||
"""Map a Checkout Session to LNbits fiat status."""
|
||||
if data.get("payment_status") == "paid":
|
||||
return FiatPaymentSuccessStatus()
|
||||
|
||||
# Consider an expired session a fail (existing 24h rule).
|
||||
expires_at = data.get("expires_at")
|
||||
_24h_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
if expires_at and float(expires_at) < _24h_ago.timestamp():
|
||||
@@ -517,18 +287,25 @@ class StripeWallet(FiatProvider):
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus:
|
||||
"""Map a PaymentIntent to LNbits fiat status (card_present friendly)."""
|
||||
status = pi.get("status")
|
||||
|
||||
if status == "succeeded":
|
||||
return FiatPaymentSuccessStatus()
|
||||
|
||||
if status in ("canceled", "payment_failed"):
|
||||
return FiatPaymentFailedStatus()
|
||||
|
||||
if status == "requires_payment_method":
|
||||
if pi.get("last_payment_error"):
|
||||
return FiatPaymentFailedStatus()
|
||||
|
||||
now_ts = datetime.now(timezone.utc).timestamp()
|
||||
created_ts = float(pi.get("created") or now_ts)
|
||||
if (now_ts - created_ts) > 300:
|
||||
is_stale = (now_ts - created_ts) > 300
|
||||
if is_stale:
|
||||
return FiatPaymentFailedStatus()
|
||||
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
def _build_headers_form(self) -> dict[str, str]:
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -168,6 +168,8 @@ window.localisation.en = {
|
||||
'Only admin accounts can create extensions',
|
||||
admin_only: 'Admin Only',
|
||||
new_version: 'New Version',
|
||||
extension_has_free_release: 'Has free releases',
|
||||
extension_has_paid_release: 'Has paid releases',
|
||||
extension_depends_on: 'Depends on:',
|
||||
extension_rating_soon: 'Ratings coming soon',
|
||||
extension_installed_version: 'Installed version',
|
||||
@@ -663,5 +665,7 @@ window.localisation.en = {
|
||||
callback_success_url_hint:
|
||||
'The user will be redirected to this URL after the payment is successful',
|
||||
connected: 'Connected',
|
||||
not_connected: 'Not Connected'
|
||||
not_connected: 'Not Connected',
|
||||
free: 'Free',
|
||||
paid: 'Paid'
|
||||
}
|
||||
|
||||
@@ -468,7 +468,10 @@ window.AdminPageLogic = {
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
formatDate(date) {
|
||||
return moment.utc(date * 1000).fromNow()
|
||||
return moment
|
||||
.utc(date * 1000)
|
||||
.local()
|
||||
.fromNow()
|
||||
},
|
||||
sendTestEmail() {
|
||||
LNbits.api
|
||||
|
||||
@@ -267,10 +267,10 @@ window.LNbits = {
|
||||
}
|
||||
|
||||
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
|
||||
obj.dateFrom = moment.utc(data.created_at).fromNow()
|
||||
obj.dateFrom = moment.utc(data.created_at).local().fromNow()
|
||||
|
||||
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
|
||||
obj.expirydateFrom = moment.utc(obj.expiry).fromNow()
|
||||
obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
|
||||
obj.msat = obj.amount
|
||||
obj.sat = obj.msat / 1000
|
||||
obj.tag = obj.extra?.tag
|
||||
|
||||
@@ -154,7 +154,7 @@ function confettiStars() {
|
||||
setTimeout(shoot, 200)
|
||||
}
|
||||
!(function (t, e) {
|
||||
!(function t(e, n, a, i) {
|
||||
;(!(function t(e, n, a, i) {
|
||||
var o = !!(
|
||||
e.Worker &&
|
||||
e.Blob &&
|
||||
@@ -248,12 +248,12 @@ function confettiStars() {
|
||||
function e(e, n) {
|
||||
t.postMessage({options: e || {}, callback: n})
|
||||
}
|
||||
;(t.init = function (e) {
|
||||
;((t.init = function (e) {
|
||||
var n = e.transferControlToOffscreen()
|
||||
t.postMessage({canvas: n}, [n])
|
||||
}),
|
||||
(t.fire = function (n, a, i) {
|
||||
if (g) return e(n, null), g
|
||||
if (g) return (e(n, null), g)
|
||||
var o = Math.random().toString(36).slice(2)
|
||||
return (g = l(function (a) {
|
||||
function r(e) {
|
||||
@@ -264,15 +264,15 @@ function confettiStars() {
|
||||
i(),
|
||||
a())
|
||||
}
|
||||
t.addEventListener('message', r),
|
||||
;(t.addEventListener('message', r),
|
||||
e(n, o),
|
||||
(m[o] = r.bind(null, {data: {callback: o}}))
|
||||
(m[o] = r.bind(null, {data: {callback: o}})))
|
||||
}))
|
||||
}),
|
||||
(t.reset = function () {
|
||||
for (var e in (t.postMessage({reset: !0}), m))
|
||||
m[e](), delete m[e]
|
||||
})
|
||||
(m[e](), delete m[e])
|
||||
}))
|
||||
})(h)
|
||||
}
|
||||
return h
|
||||
@@ -328,12 +328,12 @@ function confettiStars() {
|
||||
)
|
||||
}
|
||||
function k(t) {
|
||||
;(t.width = document.documentElement.clientWidth),
|
||||
(t.height = document.documentElement.clientHeight)
|
||||
;((t.width = document.documentElement.clientWidth),
|
||||
(t.height = document.documentElement.clientHeight))
|
||||
}
|
||||
function I(t) {
|
||||
var e = t.getBoundingClientRect()
|
||||
;(t.width = e.width), (t.height = e.height)
|
||||
;((t.width = e.width), (t.height = e.height))
|
||||
}
|
||||
function T(t, e, n, o, r) {
|
||||
var c,
|
||||
@@ -342,10 +342,10 @@ function confettiStars() {
|
||||
d = t.getContext('2d'),
|
||||
f = l(function (e) {
|
||||
function l() {
|
||||
;(c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e()
|
||||
;((c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e())
|
||||
}
|
||||
;(c = b.frame(function e() {
|
||||
!a ||
|
||||
;((c = b.frame(function e() {
|
||||
;(!a ||
|
||||
(o.width === i.width && o.height === i.height) ||
|
||||
((o.width = t.width = i.width), (o.height = t.height = i.height)),
|
||||
o.width ||
|
||||
@@ -354,7 +354,7 @@ function confettiStars() {
|
||||
d.clearRect(0, 0, o.width, o.height),
|
||||
(u = u.filter(function (t) {
|
||||
return (function (t, e) {
|
||||
;(e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
|
||||
;((e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
|
||||
(e.y += Math.sin(e.angle2D) * e.velocity + e.gravity),
|
||||
(e.wobble += 0.1),
|
||||
(e.velocity *= e.decay),
|
||||
@@ -363,7 +363,7 @@ function confettiStars() {
|
||||
(e.tiltCos = Math.cos(e.tiltAngle)),
|
||||
(e.random = Math.random() + 5),
|
||||
(e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)),
|
||||
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble))
|
||||
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble)))
|
||||
var n = e.tick++ / e.totalTicks,
|
||||
a = e.x + e.random * e.tiltCos,
|
||||
i = e.y + e.random * e.tiltSin,
|
||||
@@ -393,12 +393,12 @@ function confettiStars() {
|
||||
2 * Math.PI
|
||||
)
|
||||
: (function (t, e, n, a, i, o, r, l, c) {
|
||||
t.save(),
|
||||
;(t.save(),
|
||||
t.translate(e, n),
|
||||
t.rotate(o),
|
||||
t.scale(a, i),
|
||||
t.arc(0, 0, 1, r, l, c),
|
||||
t.restore()
|
||||
t.restore())
|
||||
})(
|
||||
t,
|
||||
e.x,
|
||||
@@ -420,18 +420,18 @@ function confettiStars() {
|
||||
})(d, t)
|
||||
})).length
|
||||
? (c = b.frame(e))
|
||||
: l()
|
||||
: l())
|
||||
})),
|
||||
(s = l)
|
||||
(s = l))
|
||||
})
|
||||
return {
|
||||
addFettis: function (t) {
|
||||
return (u = u.concat(t)), f
|
||||
return ((u = u.concat(t)), f)
|
||||
},
|
||||
canvas: t,
|
||||
promise: f,
|
||||
reset: function () {
|
||||
c && b.cancel(c), s && s()
|
||||
;(c && b.cancel(c), s && s())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,7 +466,7 @@ function confettiStars() {
|
||||
k = p(e, 'scalar'),
|
||||
I = (function (t) {
|
||||
var e = p(t, 'origin', Object)
|
||||
return (e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e
|
||||
return ((e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e)
|
||||
})(e),
|
||||
E = d,
|
||||
S = [],
|
||||
@@ -531,7 +531,7 @@ function confettiStars() {
|
||||
return l(function (t) {
|
||||
t()
|
||||
})
|
||||
i && a
|
||||
;(i && a
|
||||
? (t = a.canvas)
|
||||
: i &&
|
||||
!t &&
|
||||
@@ -547,7 +547,7 @@ function confettiStars() {
|
||||
)
|
||||
})(g)),
|
||||
document.body.appendChild(t)),
|
||||
r && !d && u(t)
|
||||
r && !d && u(t))
|
||||
var m = {width: t.width, height: t.height}
|
||||
function b() {
|
||||
if (s) {
|
||||
@@ -564,9 +564,9 @@ function confettiStars() {
|
||||
m.width = m.height = null
|
||||
}
|
||||
function v() {
|
||||
;(a = null),
|
||||
;((a = null),
|
||||
r && e.removeEventListener('resize', b),
|
||||
i && t && (document.body.removeChild(t), (t = null), (d = !1))
|
||||
i && t && (document.body.removeChild(t), (t = null), (d = !1)))
|
||||
}
|
||||
return (
|
||||
s && !d && s.init(t),
|
||||
@@ -578,12 +578,13 @@ function confettiStars() {
|
||||
}
|
||||
return (
|
||||
(g.reset = function () {
|
||||
s && s.reset(), a && a.reset()
|
||||
;(s && s.reset(), a && a.reset())
|
||||
}),
|
||||
g
|
||||
)
|
||||
}
|
||||
;(n.exports = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E)
|
||||
;((n.exports = E(null, {useWorker: !0, resize: !0})),
|
||||
(n.exports.create = E))
|
||||
})(
|
||||
(function () {
|
||||
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
|
||||
@@ -591,5 +592,5 @@ function confettiStars() {
|
||||
e,
|
||||
!1
|
||||
),
|
||||
(t.confetti = e.exports)
|
||||
(t.confetti = e.exports))
|
||||
})(window, {})
|
||||
|
||||
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
|
||||
return LNbits.utils.formatDate(this.ts)
|
||||
},
|
||||
dateFrom() {
|
||||
return moment.utc(this.date).fromNow()
|
||||
return moment.utc(this.date).local().fromNow()
|
||||
}
|
||||
},
|
||||
template: `
|
||||
|
||||
@@ -164,7 +164,7 @@ window.PaymentsPageLogic = {
|
||||
if (p.extra && p.extra.tag) {
|
||||
p.tag = p.extra.tag
|
||||
}
|
||||
p.timeFrom = moment.utc(p.created_at).fromNow()
|
||||
p.timeFrom = moment.utc(p.created_at).local().fromNow()
|
||||
p.outgoing = p.amount < 0
|
||||
p.amount =
|
||||
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
||||
|
||||
@@ -179,7 +179,7 @@ window.WalletPageLogic = {
|
||||
methods: {
|
||||
dateFromNow(unix) {
|
||||
const date = new Date(unix * 1000)
|
||||
return moment.utc(date).fromNow()
|
||||
return moment.utc(date).local().fromNow()
|
||||
},
|
||||
formatFiatAmount(amount, currency) {
|
||||
this.update.currency = currency
|
||||
@@ -476,8 +476,14 @@ window.WalletPageLogic = {
|
||||
createdDate,
|
||||
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||
)
|
||||
cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow()
|
||||
cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow()
|
||||
cleanInvoice.expireDateFrom = moment
|
||||
.utc(expireDate)
|
||||
.local()
|
||||
.fromNow()
|
||||
cleanInvoice.createdDateFrom = moment
|
||||
.utc(createdDate)
|
||||
.local()
|
||||
.fromNow()
|
||||
|
||||
cleanInvoice.expired = false // TODO
|
||||
}
|
||||
|
||||
@@ -649,8 +649,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="showButtons"
|
||||
class="qrcode__buttons row q-gutter-x-sm"
|
||||
style="justify-content: flex-end"
|
||||
class="qrcode__buttons row q-gutter-x-sm items-center justify-end no-wrap full-width"
|
||||
>
|
||||
<q-btn
|
||||
v-if="nfc && nfcSupported"
|
||||
|
||||
@@ -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