diff --git a/CUSTOM_FRONTEND_URL_SIMPLE.md b/CUSTOM_FRONTEND_URL_SIMPLE.md new file mode 100644 index 00000000..96c2d052 --- /dev/null +++ b/CUSTOM_FRONTEND_URL_SIMPLE.md @@ -0,0 +1,213 @@ +# Custom Frontend URL - Simple Redirect Approach + +## Overview + +This is the **simplest** approach to integrate LNbits with a custom frontend. LNbits handles all authentication (login, register, password reset) and then redirects users to your custom frontend URL instead of `/wallet`. + +## How It Works + +### Password Reset Flow + +1. **Admin generates reset link** → User receives: `https://lnbits.com/?reset_key=...` +2. **User clicks link** → LNbits shows password reset form +3. **User submits new password** → LNbits sets auth cookies +4. **LNbits redirects** → `https://myapp.com/` (your custom frontend) +5. **Web-app loads** → `checkAuth()` sees valid LNbits cookies → ✅ User is logged in! + +### Login Flow + +1. **User visits** → `https://lnbits.com/` +2. **User logs in** → LNbits validates credentials and sets cookies +3. **LNbits redirects** → `https://myapp.com/` +4. **Web-app loads** → ✅ User is logged in! + +### Register Flow + +1. **User visits** → `https://lnbits.com/` +2. **User registers** → LNbits creates account and sets cookies +3. **LNbits redirects** → `https://myapp.com/` +4. **Web-app loads** → ✅ User is logged in! + +## Configuration + +### Environment Variable + +```bash +# In .env or environment +export LNBITS_CUSTOM_FRONTEND_URL=https://myapp.com +``` + +Or configure through LNbits admin UI: **Settings → Operations → Custom Frontend URL** + +### Default Behavior + +- **If not set**: Redirects to `/wallet` (default LNbits behavior) +- **If set**: Redirects to your custom frontend URL + +## Implementation + +### Changes Made + +1. **Added setting** (`lnbits/settings.py:282-285`): + ```python + class OpsSettings(LNbitsSettings): + lnbits_custom_frontend_url: str | None = Field( + default=None, + description="Custom frontend URL for post-auth redirects" + ) + ``` + +2. **Exposed to frontend** (`lnbits/helpers.py:88`): + ```python + window_settings = { + # ... + "LNBITS_CUSTOM_FRONTEND_URL": settings.lnbits_custom_frontend_url, + # ... + } + ``` + +3. **Updated redirects** (`lnbits/static/js/index.js`): + - `login()` - line 78 + - `register()` - line 56 + - `reset()` - line 68 + - `loginUsr()` - line 88 + + All now use: + ```javascript + window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL || '/wallet' + ``` + +## Advantages + +### ✅ Zero Changes to Web-App +Your custom frontend doesn't need to: +- Parse `?reset_key=` from URLs +- Build password reset UI +- Handle password reset API calls +- Manage error states +- Implement validation + +### ✅ Auth Cookies Work Automatically +LNbits sets httponly cookies that your web-app automatically sees: +- `cookie_access_token` +- `is_lnbits_user_authorized` + +Your existing `auth.checkAuth()` will detect these and log the user in. + +### ✅ Simple & Elegant +Only 3 files changed in LNbits, zero changes in web-app. + +### ✅ Backwards Compatible +Existing LNbits installations continue to work. Setting is optional. + +## Disadvantages + +### ⚠️ Brief Branding Inconsistency +Users see LNbits UI briefly during: +- Login form +- Registration form +- Password reset form + +Then get redirected to your branded web-app. + +**This is usually acceptable** for most use cases, especially for password reset which is infrequent. + +## Testing + +1. **Set the environment variable**: + ```bash + export LNBITS_CUSTOM_FRONTEND_URL=http://localhost:5173 + ``` + +2. **Restart LNbits**: + ```bash + poetry run lnbits + ``` + +3. **Test Login**: + - Visit `http://localhost:5000/` + - Log in with credentials + - Verify redirect to `http://localhost:5173` + - Verify web-app shows you as logged in + +4. **Test Password Reset**: + - Admin generates reset link in users panel + - User clicks link with `?reset_key=...` + - User enters new password + - Verify redirect to custom frontend + - Verify web-app shows you as logged in + +## Security + +### ✅ Secure +- Auth cookies are httponly (can't be accessed by JavaScript) +- LNbits handles all auth logic +- No sensitive data in URLs except one-time reset keys +- Reset keys expire based on `auth_token_expire_minutes` + +### 🔒 HTTPS Required +Always use HTTPS for custom frontend URLs in production: +```bash +export LNBITS_CUSTOM_FRONTEND_URL=https://myapp.com +``` + +## Migration + +**No database migration required!** + +Settings are stored as JSON in `system_settings` table. New fields are automatically included. + +## Alternative: Full Custom Frontend Approach + +If you need **complete branding consistency** (no LNbits UI shown), you would need to: + +1. Build password reset form in web-app +2. Parse `?reset_key=` from URL +3. Add API method to call `/api/v1/auth/reset` +4. Handle validation, errors, loading states +5. Update admin UI to generate links pointing to web-app + +This is **significantly more work** for marginal benefit (users see LNbits UI for ~5 seconds during password reset). + +## Recommendation + +**Use this simple approach** unless you have specific requirements for complete UI consistency. The brief LNbits UI is a small trade-off for the simplicity gained. + +## Related Files + +- `lnbits/settings.py` - Setting definition +- `lnbits/helpers.py` - Expose to frontend +- `lnbits/static/js/index.js` - Redirect logic + +## Example `.env` + +```bash +# LNbits Configuration +LNBITS_DATA_FOLDER=./data +LNBITS_DATABASE_URL=sqlite:///./data/database.sqlite3 + +# Custom Frontend Integration +LNBITS_CUSTOM_FRONTEND_URL=https://myapp.com + +# Other settings... +``` + +## How Web-App Benefits + +Your web-app at `https://myapp.com` can now: + +1. **Receive logged-in users** from LNbits without any code changes +2. **Use existing `auth.checkAuth()`** - it just works +3. **Focus on your features** - don't rebuild auth UI +4. **Trust LNbits security** - it's battle-tested + +The auth cookies LNbits sets are valid for your domain if LNbits is on a subdomain (e.g., `api.myapp.com`) or you're using proper CORS configuration. + +## Future Enhancement + +If you later need the full custom UI approach, all the groundwork is there: +- Setting exists and is configurable +- Just add the web-app UI components +- Update admin panel to generate web-app links + +But start with this simple approach first! 🚀 diff --git a/extensions.json b/extensions.json new file mode 100644 index 00000000..aca0811d --- /dev/null +++ b/extensions.json @@ -0,0 +1,113 @@ +{ + "featured": [ + "satmachineclient", + "castle" + ], + "extensions": [ + { + "id": "satmachineadmin", + "repo": "https://git.aiolabs.dev/lnbits-exts/satmachineadmin", + "name": "Satoshi Machine Admin", + "version": "0.0.2", + "short_description": "Admin Dashboard for Satoshi Machine", + "icon": "https://git.aiolabs.dev/lnbits-exts/satmachineadmin/raw/branch/main/static/image/aio.png", + "archive": "https://git.aiolabs.dev/lnbits-exts/satmachineadmin/archive/v0.0.2.zip", + "hash": "e46863d3c1133897d9fe9cc4afe149804df25b2aeee1716de4da4332ae9789b1" + }, + { + "id": "satmachineclient", + "repo": "https://git.aiolabs.dev/lnbits-exts/satmachineclient", + "name": "Satoshi Machine Client", + "version": "0.0.1", + "short_description": "Client Dashboard for Satoshi Machine", + "icon": "https://git.aiolabs.dev/lnbits-exts/satmachineclient/raw/branch/main/static/image/aio.png", + "archive": "https://git.aiolabs.dev/lnbits-exts/satmachineclient/archive/v0.0.1.zip", + "hash": "6d93e1a537ca3565fcf4dc4811497de10660a14639046854c1006b06d9a045e5" + }, + { + "id": "events", + "repo": "https://git.aiolabs.dev/lnbits-exts/events", + "name": "AIO Events", + "version": "0.0.1", + "short_description": "AIO fork of lnbits-exts/events", + "icon": "https://git.aiolabs.dev/lnbits-exts/events/raw/branch/main/static/image/aio.png", + "archive": "https://git.aiolabs.dev/lnbits-exts/events/archive/v0.0.1.zip", + "hash": "681408f3f5f5b0773f0aa5fd7fbdefcd47ed9190b44409b80d2b119cc5516335" + }, + { + "id": "nostrrelay", + "repo": "https://git.aiolabs.dev/lnbits-exts/nostrrelay", + "name": "AIO Nostrrelay", + "version": "0.0.2", + "short_description": "AIO fork of lnbits-exts/nostrrelay", + "icon": "https://git.aiolabs.dev/lnbits-exts/nostrrelay/raw/branch/main/static/image/aio.png", + "archive": "https://git.aiolabs.dev/lnbits-exts/nostrrelay/archive/v0.0.2.zip", + "hash": "a46475f076557f5c5a426a9e3cbb680af855b833a9f66ba32b6f36778336302f" + }, + { + "id": "nostrclient", + "repo": "https://git.aiolabs.dev/lnbits-exts/nostrclient", + "name": "AIO nostrclient", + "version": "0.0.1", + "short_description": "AIO fork of lnbits-exts/nostrclient", + "icon": "https://git.aiolabs.dev/lnbits-exts/nostrclient/raw/branch/main/static/image/aio.png", + "archive": "https://git.aiolabs.dev/lnbits-exts/nostrclient/archive/v0.0.1.zip", + "hash": "c71c6bd2105e299a2ff342fb9d94d619570f28f231a3025f52f29757f9b31d04" + }, + { + "id": "nostrmarket", + "repo": "https://git.aiolabs.dev/lnbits-exts/nostrmarket", + "name": "AIO nostrmarket", + "version": "0.0.1", + "short_description": "AIO fork of lnbits-exts/nostrmarket", + "icon": "https://git.aiolabs.dev/lnbits-exts/nostrmarket/raw/branch/main/static/image/aio.png", + "archive": "https://git.aiolabs.dev/lnbits-exts/nostrmarket/archive/v0.0.1.zip", + "hash": "5177dfdae62554c75ba8244d5a26e371de6e512a8b6dc7bb6f90e8b05916ed35" + }, + { + "id": "castle", + "repo": "https://git.aiolabs.dev/lnbits-exts/castle", + "name": "Castle", + "version": "0.0.1", + "short_description": "Castle Accounting", + "icon": "https://git.aiolabs.dev/lnbits-exts/castle/raw/branch/main/static/image/castle.png", + "archive": "https://git.aiolabs.dev/lnbits-exts/castle/archive/v0.0.1.zip", + "hash": "0b152190902e7377bfba9feb4c82998a9400a69413ee5482343f15da8308b89b" + }, + { + "id": "castle", + "repo": "https://git.aiolabs.dev/lnbits-exts/castle", + "name": "Castle", + "version": "0.0.2", + "short_description": "Castle Accounting", + "icon": "https://git.aiolabs.dev/lnbits-exts/castle/raw/branch/main/static/image/castle.png", + "archive": "https://git.aiolabs.dev/lnbits-exts/castle/archive/v0.0.2.zip", + "hash": "0f742deb4777480a32ad239f6eae42c57583bf7a8ca9bd131903b6bb7282f069" + }, + { + "id": "lnurlp", + "repo": "https://github.com/lnbits/lnurlp", + "name": "Pay Links", + "version": "1.1.3", + "min_lnbits_version": "1.3.0", + "short_description": "Make reusable LNURL pay links", + "icon": "https://github.com/lnbits/lnurlp/raw/main/static/image/lnurl-pay.png", + "details_link": "https://raw.githubusercontent.com/lnbits/lnurlp/main/config.json", + "archive": "https://github.com/lnbits/lnurlp/archive/refs/tags/v1.1.3.zip", + "hash": "913b6880fd824e801f05ae50ed6f57817c9a580f1ed1b02b435823d5754e1b98", + "max_lnbits_version": "1.4.0" + }, + { + "id": "lnurlp", + "repo": "https://github.com/lnbits/lnurlp", + "name": "Pay Links", + "version": "1.2.0", + "min_lnbits_version": "1.4.0", + "short_description": "Make reusable LNURL pay links", + "icon": "https://github.com/lnbits/lnurlp/raw/main/static/image/lnurl-pay.png", + "details_link": "https://raw.githubusercontent.com/lnbits/lnurlp/main/config.json", + "archive": "https://github.com/lnbits/lnurlp/archive/refs/tags/v1.2.0.zip", + "hash": "d3872eb5f65b9e962fc201d6784f94f3fd576284582b980805142a2b3f62d8e8" + } + ] +} diff --git a/lnbits/core/services/users.py b/lnbits/core/services/users.py index a3c9b800..d21a8aff 100644 --- a/lnbits/core/services/users.py +++ b/lnbits/core/services/users.py @@ -1,3 +1,5 @@ +import json +import time from pathlib import Path from uuid import uuid4 @@ -72,7 +74,7 @@ async def create_user_account_no_ckeck( account.id = uuid4().hex account = await create_account(account, conn=conn) - await create_wallet( + wallet = await create_wallet( user_id=account.id, wallet_name=wallet_name or settings.lnbits_default_wallet_name, conn=conn, @@ -86,6 +88,22 @@ async def create_user_account_no_ckeck( except Exception as e: logger.error(f"Error enabeling default extension {ext_id}: {e}") + # Create default pay link for users with username + if account.username and "lnurlp" in user_extensions: + try: + await _create_default_pay_link(account, wallet) + logger.info(f"Created default pay link for user {account.username}") + except Exception as e: + logger.error(f"Failed to create default pay link for user {account.username}: {e}") + + # Publish Nostr kind 0 metadata event if user has username and Nostr keys + if account.username and account.pubkey and account.prvkey: + try: + await _publish_nostr_metadata_event(account) + logger.info(f"Published Nostr metadata event for user {account.username}") + except Exception as e: + logger.error(f"Failed to publish Nostr metadata for user {account.username}: {e}") + user = await get_user_from_account(account, conn=conn) if not user: raise ValueError("Cannot find user for account.") @@ -192,3 +210,160 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings: editable_settings = EditableSettings.from_dict(settings.dict()) return await create_admin_settings(account.id, editable_settings.dict()) + + +async def _create_default_pay_link(account: Account, wallet) -> None: + """Create a default pay link for new users with username (Bitcoinmat receiving address)""" + try: + # Try dynamic import that works with extensions in different locations + import importlib + import sys + + # First try the standard import path + try: + lnurlp_crud = importlib.import_module("lnbits.extensions.lnurlp.crud") + lnurlp_models = importlib.import_module("lnbits.extensions.lnurlp.models") + except ImportError: + # If that fails, try importing from external extensions path + # This handles cases where extensions are in /var/lib/lnbits/extensions + try: + # Add extensions path to sys.path if not already there + extensions_path = ( + settings.lnbits_extensions_path or "/var/lib/lnbits/extensions" + ) + if extensions_path not in sys.path: + sys.path.insert(0, extensions_path) + + lnurlp_crud = importlib.import_module("lnurlp.crud") + lnurlp_models = importlib.import_module("lnurlp.models") + except ImportError as e: + logger.warning(f"lnurlp extension not found in any location: {e}") + return + + create_pay_link = lnurlp_crud.create_pay_link + CreatePayLinkData = lnurlp_models.CreatePayLinkData + + pay_link_data = CreatePayLinkData( + description="Bitcoinmat Receiving Address", + wallet=wallet.id, + # Note default `currency` is satoshis when set as NULL in db + min=1, # minimum 1 sat + max=500000, # maximum 500,000 sats + comment_chars=140, + username=account.username, # use the username as lightning address + zaps=True, + disposable=True, + ) + + await create_pay_link(pay_link_data) + + logger.info( + f"Successfully created default pay link for user {account.username}" + ) + + except Exception as e: + logger.error(f"Failed to create default pay link: {e}") + # Don't raise - we don't want user creation to fail if pay link creation fails + + +async def _publish_nostr_metadata_event(account: Account) -> None: + """Publish a Nostr kind 0 metadata event for a new user""" + import coincurve + + from lnbits.utils.nostr import sign_event + + # Create Nostr kind 0 metadata event + metadata = { + "name": account.username, + "display_name": account.username, + "about": f"LNbits user: {account.username}", + } + + event = { + "kind": 0, + "created_at": int(time.time()), + "tags": [], + "content": json.dumps(metadata), + } + + # Convert hex private key to coincurve PrivateKey + private_key = coincurve.PrivateKey(bytes.fromhex(account.prvkey)) + + # Sign the event + signed_event = sign_event(event, account.pubkey, private_key) + + # Publish directly to nostrrelay database (hacky but works around WebSocket issues) + await _insert_event_into_nostrrelay(signed_event, account.username) + + +async def _insert_event_into_nostrrelay(event: dict, username: str) -> None: + """Directly insert Nostr event into nostrrelay database (hacky workaround)""" + try: + import importlib + import sys + + # Try to import nostrrelay from various possible locations + nostrrelay_crud = None + NostrEvent = None + try: + nostrrelay_crud = importlib.import_module( + "lnbits.extensions.nostrrelay.crud" + ) + NostrEvent = importlib.import_module( + "lnbits.extensions.nostrrelay.relay.event" + ).NostrEvent + except ImportError: + try: + # Check if nostrrelay is in external extensions path + extensions_path = ( + settings.lnbits_extensions_path or "/var/lib/lnbits/extensions" + ) + if extensions_path not in sys.path: + sys.path.insert(0, extensions_path) + nostrrelay_crud = importlib.import_module("nostrrelay.crud") + NostrEvent = importlib.import_module( + "nostrrelay.relay.event" + ).NostrEvent + except ImportError: + # Try from the lnbits-nostrmarket project path + nostrmarket_path = "/home/padreug/Projects/lnbits-nostrmarket" + if nostrmarket_path not in sys.path: + sys.path.insert(0, nostrmarket_path) + nostrrelay_crud = importlib.import_module("nostrrelay.crud") + NostrEvent = importlib.import_module( + "nostrrelay.relay.event" + ).NostrEvent + + if not nostrrelay_crud or not NostrEvent: + logger.warning( + "Could not import nostrrelay - skipping direct database insert" + ) + return + + # Use a default relay_id for the proof of concept + relay_id = "test1" + logger.debug(f"Using relay_id: {relay_id} for nostrrelay event") + + # Create NostrEvent object for nostrrelay + nostr_event = NostrEvent( + id=event["id"], + relay_id=relay_id, + publisher=event["pubkey"], + pubkey=event["pubkey"], + created_at=event["created_at"], + kind=event["kind"], + tags=event.get("tags", []), + content=event["content"], + sig=event["sig"], + ) + + # Insert directly into nostrrelay database + await nostrrelay_crud.create_event(nostr_event) + + logger.info( + f"Successfully inserted Nostr metadata event for {username} into nostrrelay database" + ) + + except Exception as e: + logger.error(f"Failed to insert event into nostrrelay database: {e}") + logger.debug(f"Exception details: {type(e).__name__}: {str(e)}") diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 123776d7..5d31423c 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -100,6 +100,16 @@ async def api_list_currencies_available() -> list[str]: return allowed_currencies() +@api_router.get("/api/v1/default-currency") +async def api_get_default_currency() -> dict[str, str | None]: + """ + Get the default accounting currency for this LNbits instance. + Returns the configured default, or None if not set. + """ + default_currency = settings.lnbits_default_accounting_currency + return {"default_currency": default_currency} + + @api_router.post("/api/v1/conversion") async def api_fiat_as_sats(data: ConversionData): output = {} diff --git a/lnbits/core/views/user_api.py b/lnbits/core/views/user_api.py index 1b0301e8..fc0fd2d1 100644 --- a/lnbits/core/views/user_api.py +++ b/lnbits/core/views/user_api.py @@ -96,6 +96,9 @@ async def api_create_user(data: CreateUser) -> CreateUser: data.extra = data.extra or UserExtra() data.extra.provider = data.extra.provider or "lnbits" + if data.pubkey: + data.pubkey = normalize_public_key(data.pubkey) + account = Account( id=uuid4().hex, username=data.username, diff --git a/lnbits/helpers.py b/lnbits/helpers.py index 8780f0cc..45d0424f 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -87,6 +87,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates "LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image, "LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge, "LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color, + "LNBITS_CUSTOM_FRONTEND_URL": settings.lnbits_custom_frontend_url, "LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all, "LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed, "LNBITS_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager, diff --git a/lnbits/settings.py b/lnbits/settings.py index 2f469223..4c4ba6f9 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -960,6 +960,10 @@ class EnvSettings(LNbitsSettings): lnbits_title: str = Field(default="LNbits API") lnbits_path: str = Field(default=".") lnbits_extensions_path: str = Field(default="lnbits") + lnbits_custom_frontend_url: str | None = Field( + default=None, + description="Custom frontend URL for redirects after auth (e.g., https://myapp.com). If not set, redirects to /wallet. This is read-only and must be set via environment variable." + ) super_user: str = Field(default="") auth_secret_key: str = Field(default="") version: str = Field(default="0.0.0") diff --git a/lnbits/static/js/pages/home.js b/lnbits/static/js/pages/home.js index d3958d64..26d3105e 100644 --- a/lnbits/static/js/pages/home.js +++ b/lnbits/static/js/pages/home.js @@ -79,7 +79,12 @@ window.PageHome = { this.password, this.passwordRepeat ) - this.refreshAuthUser() + // Redirect to custom frontend URL if configured, otherwise use router + if (this.LNBITS_CUSTOM_FRONTEND_URL) { + window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL + } else { + this.refreshAuthUser() + } } catch (e) { LNbits.utils.notifyApiError(e) } @@ -91,7 +96,12 @@ window.PageHome = { this.password, this.passwordRepeat ) - this.refreshAuthUser() + // Redirect to custom frontend URL if configured, otherwise use router + if (this.LNBITS_CUSTOM_FRONTEND_URL) { + window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL + } else { + this.refreshAuthUser() + } } catch (e) { LNbits.utils.notifyApiError(e) } @@ -99,7 +109,12 @@ window.PageHome = { async login() { try { await LNbits.api.login(this.username, this.password) - this.refreshAuthUser() + // Redirect to custom frontend URL if configured, otherwise use router + if (this.LNBITS_CUSTOM_FRONTEND_URL) { + window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL + } else { + this.refreshAuthUser() + } } catch (e) { LNbits.utils.notifyApiError(e) } @@ -107,7 +122,13 @@ window.PageHome = { async loginUsr() { try { await LNbits.api.loginUsr(this.usr) - this.refreshAuthUser() + this.usr = '' + // Redirect to custom frontend URL if configured, otherwise use router + if (this.LNBITS_CUSTOM_FRONTEND_URL) { + window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL + } else { + this.refreshAuthUser() + } } catch (e) { console.warn(e) LNbits.utils.notifyApiError(e) @@ -138,14 +159,28 @@ window.PageHome = { } }, created() { - if (this.g.isUserAuthorized) { - return this.refreshAuthUser() - } const urlParams = new URLSearchParams(window.location.search) + + // Check for reset_key FIRST - password reset should work even if user is logged in this.reset_key = urlParams.get('reset_key') if (this.reset_key) { this.authAction = 'reset' + // Clear existing auth cookies to allow password reset to work + this.$q.cookies.remove('is_lnbits_user_authorized') + this.$q.cookies.remove('cookie_access_token') + return // Don't redirect to /wallet } + + // Redirect authorized users + if (this.g.isUserAuthorized) { + // Redirect to custom frontend URL if configured, otherwise use router + if (this.LNBITS_CUSTOM_FRONTEND_URL) { + window.location.href = this.LNBITS_CUSTOM_FRONTEND_URL + } else { + return this.refreshAuthUser() + } + } + // check if lightning parameters are present in the URL if (urlParams.has('lightning')) { this.lnurl = urlParams.get('lightning') diff --git a/misc-aio/AUTO_CREDIT_CHANGES.md b/misc-aio/AUTO_CREDIT_CHANGES.md new file mode 100644 index 00000000..408f6987 --- /dev/null +++ b/misc-aio/AUTO_CREDIT_CHANGES.md @@ -0,0 +1,88 @@ +# LNBits Auto-Credit Changes + +## Overview +Modified LNBits server to automatically credit new accounts with 1 million satoshis (1,000,000 sats) when they are created. + +## Changes Made + +### 1. Modified `lnbits/core/services/users.py` + +**Added imports:** +- `get_wallet` from `..crud` +- `update_wallet_balance` from `.payments` + +**Modified `create_user_account_no_ckeck` function:** +- Changed `create_wallet` call to capture the returned wallet object +- Added automatic credit of 1,000,000 sats after wallet creation +- Added error handling and logging for the credit operation + +**Code changes:** +```python +# Before: +await create_wallet( + user_id=account.id, + wallet_name=wallet_name or settings.lnbits_default_wallet_name, +) + +# After: +wallet = await create_wallet( + user_id=account.id, + wallet_name=wallet_name or settings.lnbits_default_wallet_name, +) + +# Credit new account with 1 million satoshis +try: + await update_wallet_balance(wallet, 1_000_000) + logger.info(f"Credited new account {account.id} with 1,000,000 sats") +except Exception as e: + logger.error(f"Failed to credit new account {account.id} with 1,000,000 sats: {e}") +``` + +### 2. Updated Tests in `tests/api/test_auth.py` + +**Modified test functions:** +- `test_register_ok`: Added balance verification for regular user registration +- `test_register_nostr_ok`: Added balance verification for Nostr authentication + +**Added assertions:** +```python +# Check that the wallet has 1 million satoshis +wallet = user.wallets[0] +assert wallet.balance == 1_000_000, f"Expected 1,000,000 sats balance, got {wallet.balance} sats" +``` + +## Affected Account Creation Paths + +The automatic credit will be applied to all new accounts created through: + +1. **Regular user registration** (`/api/v1/auth/register`) +2. **Nostr authentication** (`/api/v1/auth/nostr`) +3. **SSO login** (when new account is created) +4. **API account creation** (`/api/v1/account`) +5. **Admin user creation** (via admin interface) + +## Excluded Paths + +- **Superuser/Admin account creation** (`init_admin_settings`): This function creates the admin account directly and bypasses the user creation flow, so it won't receive the automatic credit. + +## Testing + +To test the changes: + +1. Install dependencies: `poetry install` +2. Run the modified tests: `poetry run pytest tests/api/test_auth.py::test_register_ok -v` +3. Run Nostr test: `poetry run pytest tests/api/test_auth.py::test_register_nostr_ok -v` + +## Logging + +The system will log: +- Success: `"Credited new account {account.id} with 1,000,000 sats"` +- Failure: `"Failed to credit new account {account.id} with 1,000,000 sats: {error}"` + +## Notes + +- The credit uses the existing `update_wallet_balance` function which creates an internal payment record +- The credit is applied after wallet creation but before user extensions are set up +- Error handling ensures that account creation continues even if the credit fails +- The credit amount is hardcoded to 1,000,000 sats (1MM sats) + diff --git a/misc-aio/lnbits-websocket-guide.md b/misc-aio/lnbits-websocket-guide.md new file mode 100644 index 00000000..d6662b1d --- /dev/null +++ b/misc-aio/lnbits-websocket-guide.md @@ -0,0 +1,460 @@ +# LNbits WebSocket Implementation Guide + +## Overview + +LNbits provides real-time WebSocket connections for monitoring wallet status, payment confirmations, and transaction updates. This guide covers how to implement and use these WebSocket connections in your applications. + +## WebSocket Endpoints + +### 1. Payment Monitoring WebSocket +- **URL**: `ws://localhost:5006/api/v1/ws/{wallet_inkey}` +- **HTTPS**: `wss://your-domain.com/api/v1/ws/{wallet_inkey}` +- **Purpose**: Real-time payment notifications and wallet updates + +### 2. Generic WebSocket Communication +- **URL**: `ws://localhost:5006/api/v1/ws/{item_id}` +- **Purpose**: Custom real-time communication channels + +## Client-Side Implementation + +### JavaScript/Browser Implementation + +#### Basic WebSocket Connection +```javascript +// Construct WebSocket URL +const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' +const websocketUrl = `${protocol}//${window.location.host}/api/v1/ws` + +// Connect to payment monitoring +const ws = new WebSocket(`${websocketUrl}/${wallet.inkey}`) + +// Handle incoming messages +ws.onmessage = (event) => { + const data = JSON.parse(event.data) + console.log('Received:', data) + + if (data.payment) { + handlePaymentReceived(data.payment) + } +} + +// Handle connection events +ws.onopen = () => { + console.log('WebSocket connected') +} + +ws.onclose = () => { + console.log('WebSocket disconnected') +} + +ws.onerror = (error) => { + console.error('WebSocket error:', error) +} +``` + +#### Using LNbits Built-in Event System +```javascript +// Using the built-in LNbits event system +LNbits.events.onInvoicePaid(wallet, (data) => { + if (data.payment) { + console.log('Payment confirmed:', data.payment) + + // Update UI + updateWalletBalance(data.payment.amount) + showPaymentNotification(data.payment) + } +}) +``` + +#### Vue.js Implementation Example +```javascript +// Vue component method +initWebSocket() { + const protocol = location.protocol === 'http:' ? 'ws://' : 'wss://' + const wsUrl = `${protocol}${document.domain}:${location.port}/api/v1/ws/${this.wallet.inkey}` + + this.ws = new WebSocket(wsUrl) + + this.ws.addEventListener('message', async ({ data }) => { + const response = JSON.parse(data.toString()) + + if (response.payment) { + // Handle payment update + await this.handlePaymentUpdate(response.payment) + } + }) + + this.ws.addEventListener('open', () => { + this.connectionStatus = 'connected' + }) + + this.ws.addEventListener('close', () => { + this.connectionStatus = 'disconnected' + // Implement reconnection logic + setTimeout(() => this.initWebSocket(), 5000) + }) +} +``` + +### Python Client Implementation + +```python +import asyncio +import websockets +import json + +async def listen_to_wallet(wallet_inkey, base_url="ws://localhost:5006"): + uri = f"{base_url}/api/v1/ws/{wallet_inkey}" + + try: + async with websockets.connect(uri) as websocket: + print(f"Connected to WebSocket: {uri}") + + async for message in websocket: + data = json.loads(message) + + if 'payment' in data: + payment = data['payment'] + print(f"Payment received: {payment['amount']} sat") + print(f"Payment hash: {payment['payment_hash']}") + + # Process payment + await handle_payment_received(payment) + + except websockets.exceptions.ConnectionClosed: + print("WebSocket connection closed") + except Exception as e: + print(f"WebSocket error: {e}") + +async def handle_payment_received(payment): + """Process incoming payment""" + # Update database + # Send notifications + # Update application state + pass + +# Run the WebSocket listener +if __name__ == "__main__": + wallet_inkey = "your_wallet_inkey_here" + asyncio.run(listen_to_wallet(wallet_inkey)) +``` + +### Node.js Client Implementation + +```javascript +const WebSocket = require('ws') + +class LNbitsWebSocketClient { + constructor(walletInkey, baseUrl = 'ws://localhost:5006') { + this.walletInkey = walletInkey + this.baseUrl = baseUrl + this.ws = null + this.reconnectInterval = 5000 + } + + connect() { + const url = `${this.baseUrl}/api/v1/ws/${this.walletInkey}` + this.ws = new WebSocket(url) + + this.ws.on('open', () => { + console.log(`Connected to LNbits WebSocket: ${url}`) + }) + + this.ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()) + this.handleMessage(message) + } catch (error) { + console.error('Error parsing WebSocket message:', error) + } + }) + + this.ws.on('close', () => { + console.log('WebSocket connection closed. Reconnecting...') + setTimeout(() => this.connect(), this.reconnectInterval) + }) + + this.ws.on('error', (error) => { + console.error('WebSocket error:', error) + }) + } + + handleMessage(message) { + if (message.payment) { + console.log('Payment received:', message.payment) + this.onPaymentReceived(message.payment) + } + } + + onPaymentReceived(payment) { + // Override this method to handle payments + console.log(`Received ${payment.amount} sat`) + } + + disconnect() { + if (this.ws) { + this.ws.close() + } + } +} + +// Usage +const client = new LNbitsWebSocketClient('your_wallet_inkey_here') +client.onPaymentReceived = (payment) => { + // Custom payment handling + console.log(`Processing payment: ${payment.payment_hash}`) +} +client.connect() +``` + +## Server-Side Implementation (LNbits Extensions) + +### Sending WebSocket Updates + +```python +from lnbits.core.services import websocket_manager + +async def notify_wallet_update(wallet_inkey: str, payment_data: dict): + """Send payment update to connected WebSocket clients""" + message = { + "payment": payment_data, + "timestamp": int(time.time()) + } + + await websocket_manager.send(wallet_inkey, json.dumps(message)) + +# Example usage in payment processing +async def process_payment_confirmation(payment_hash: str): + payment = await get_payment(payment_hash) + + if payment.wallet: + await notify_wallet_update(payment.wallet, { + "payment_hash": payment.payment_hash, + "amount": payment.amount, + "memo": payment.memo, + "status": "confirmed" + }) +``` + +### HTTP Endpoints for WebSocket Updates + +```python +# Send data via GET request +@router.get("/notify/{wallet_inkey}/{message}") +async def notify_wallet_get(wallet_inkey: str, message: str): + await websocket_manager.send(wallet_inkey, message) + return {"sent": True, "message": message} + +# Send data via POST request +@router.post("/notify/{wallet_inkey}") +async def notify_wallet_post(wallet_inkey: str, data: str): + await websocket_manager.send(wallet_inkey, data) + return {"sent": True, "data": data} +``` + +## Message Format + +### Payment Notification Message +```json +{ + "payment": { + "payment_hash": "abc123...", + "amount": 1000, + "memo": "Test payment", + "status": "confirmed", + "timestamp": 1640995200, + "fee": 1, + "wallet_id": "wallet_uuid" + } +} +``` + +### Custom Message Format +```json +{ + "type": "balance_update", + "wallet_id": "wallet_uuid", + "balance": 50000, + "timestamp": 1640995200 +} +``` + +## Best Practices + +### 1. Connection Management +- Implement automatic reconnection logic +- Handle connection timeouts gracefully +- Use exponential backoff for reconnection attempts + +### 2. Error Handling +```javascript +class WebSocketManager { + constructor(walletInkey) { + this.walletInkey = walletInkey + this.maxReconnectAttempts = 10 + this.reconnectAttempts = 0 + this.reconnectDelay = 1000 + } + + connect() { + try { + this.ws = new WebSocket(this.getWebSocketUrl()) + this.setupEventHandlers() + } catch (error) { + this.handleConnectionError(error) + } + } + + handleConnectionError(error) { + console.error('WebSocket connection error:', error) + + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++ + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1) + + setTimeout(() => { + console.log(`Reconnection attempt ${this.reconnectAttempts}`) + this.connect() + }, delay) + } else { + console.error('Max reconnection attempts reached') + } + } +} +``` + +### 3. Message Validation +```javascript +function validatePaymentMessage(data) { + if (!data.payment) return false + + const payment = data.payment + return ( + typeof payment.payment_hash === 'string' && + typeof payment.amount === 'number' && + payment.amount > 0 && + ['pending', 'confirmed', 'failed'].includes(payment.status) + ) +} +``` + +### 4. Security Considerations +- Use HTTPS/WSS in production +- Validate wallet permissions before connecting +- Implement rate limiting for WebSocket connections +- Never expose admin keys through WebSocket messages + +## Testing WebSocket Connections + +### Using wscat (Command Line Tool) +```bash +# Install wscat +npm install -g wscat + +# Connect to WebSocket +wscat -c ws://localhost:5006/api/v1/ws/your_wallet_inkey + +# Test with SSL +wscat -c wss://your-domain.com/api/v1/ws/your_wallet_inkey +``` + +### Browser Console Testing +```javascript +// Open browser console and run: +const ws = new WebSocket('ws://localhost:5006/api/v1/ws/your_wallet_inkey') +ws.onmessage = (e) => console.log('Received:', JSON.parse(e.data)) +ws.onopen = () => console.log('Connected') +ws.onclose = () => console.log('Disconnected') +``` + +### Sending Test Messages +```bash +# Using curl to trigger WebSocket message +curl "http://localhost:5006/api/v1/ws/your_wallet_inkey/test_message" + +# Using POST +curl -X POST "http://localhost:5006/api/v1/ws/your_wallet_inkey" \ + -H "Content-Type: application/json" \ + -d '"test message data"' +``` + +## Troubleshooting + +### Common Issues + +1. **Connection Refused** + - Verify LNbits server is running on correct port + - Check firewall settings + - Ensure WebSocket endpoint is enabled + +2. **Authentication Errors** + - Verify wallet inkey is correct + - Check wallet permissions + - Ensure wallet exists and is active + +3. **Message Not Received** + - Check WebSocket connection status + - Verify message format + - Test with browser dev tools + +4. **Frequent Disconnections** + - Implement proper reconnection logic + - Check network stability + - Monitor server logs for errors + +### Debug Logging +```javascript +// Enable verbose WebSocket logging +const ws = new WebSocket(wsUrl) +ws.addEventListener('open', (event) => { + console.log('WebSocket opened:', event) +}) +ws.addEventListener('close', (event) => { + console.log('WebSocket closed:', event.code, event.reason) +}) +ws.addEventListener('error', (event) => { + console.error('WebSocket error:', event) +}) +``` + +## Production Deployment + +### Nginx Configuration +```nginx +location /api/v1/ws/ { + proxy_pass http://localhost:5006; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; +} +``` + +### SSL/TLS Configuration +```nginx +server { + listen 443 ssl http2; + server_name your-domain.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location /api/v1/ws/ { + proxy_pass http://localhost:5006; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + # ... other headers + } +} +``` + +## Conclusion + +LNbits WebSocket implementation provides a robust foundation for real-time wallet monitoring and payment processing. By following this guide, you can implement reliable WebSocket connections that enhance user experience with instant payment notifications and live wallet updates. + +Remember to implement proper error handling, reconnection logic, and security measures when deploying to production environments. \ No newline at end of file diff --git a/misc-aio/lnbits-websocket-guide.pdf b/misc-aio/lnbits-websocket-guide.pdf new file mode 100644 index 00000000..38fb0666 Binary files /dev/null and b/misc-aio/lnbits-websocket-guide.pdf differ diff --git a/misc-aio/publish_profiles_from_csv.py b/misc-aio/publish_profiles_from_csv.py new file mode 100644 index 00000000..b33ab666 --- /dev/null +++ b/misc-aio/publish_profiles_from_csv.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Bulk publish Nostr profiles from CSV file +Usage: python publish_profiles_from_csv.py +""" +import sys +import csv +import json +import time +import hashlib +import secp256k1 +from websocket import create_connection + +def sign_event(event, public_key_hex, private_key): + """Sign a Nostr event""" + # Create the signature data + signature_data = json.dumps([ + 0, + public_key_hex, + event["created_at"], + event["kind"], + event["tags"], + event["content"] + ], separators=(',', ':'), ensure_ascii=False) + + # Calculate event ID + event_id = hashlib.sha256(signature_data.encode()).hexdigest() + event["id"] = event_id + event["pubkey"] = public_key_hex + + # Sign the event + signature = private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True).hex() + event["sig"] = signature + + return event + +def publish_profile_metadata(private_key_hex, profile_name, relay_url): + """Publish a Nostr kind 0 metadata event""" + try: + # Convert hex private key to secp256k1 PrivateKey and get public key + private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex)) + public_key_hex = private_key.pubkey.serialize()[1:].hex() # Remove the 0x02 prefix + + print(f"Publishing profile for: {profile_name}") + print(f" Public key: {public_key_hex}") + + # Create Nostr kind 0 metadata event + metadata = { + "name": profile_name, + "display_name": profile_name, + "about": f"Profile for {profile_name}" + } + + event = { + "kind": 0, + "created_at": int(time.time()), + "tags": [], + "content": json.dumps(metadata, separators=(',', ':')) + } + + # Sign the event + signed_event = sign_event(event, public_key_hex, private_key) + + # Connect to relay and publish + ws = create_connection(relay_url, timeout=15) + + # Send the event + event_message = f'["EVENT",{json.dumps(signed_event)}]' + ws.send(event_message) + + # Wait for response + try: + response = ws.recv() + print(f" ✅ Published successfully: {response}") + except Exception as e: + print(f" ⚠️ No immediate response: {e}") + + # Close connection + ws.close() + return True + + except Exception as e: + print(f" ❌ Failed to publish: {e}") + return False + +def main(): + if len(sys.argv) != 3: + print("Usage: python publish_profiles_from_csv.py ") + print("Example: python publish_profiles_from_csv.py publish-these.csv wss://relay.example.com") + sys.exit(1) + + csv_file = sys.argv[1] + relay_url = sys.argv[2] + + print(f"Publishing profiles from {csv_file} to {relay_url}") + print("=" * 60) + + published_count = 0 + failed_count = 0 + + try: + with open(csv_file, 'r') as file: + csv_reader = csv.DictReader(file) + + for row_num, row in enumerate(csv_reader, start=2): # start=2 because header is line 1 + username = row['username'].strip() + private_key_hex = row['prvkey'].strip() + + if not username or not private_key_hex: + print(f"Row {row_num}: Skipping empty row") + continue + + print(f"\nRow {row_num}: Processing {username}") + + success = publish_profile_metadata(private_key_hex, username, relay_url) + + if success: + published_count += 1 + else: + failed_count += 1 + + # Small delay between publishes to be nice to the relay + time.sleep(1) + + except FileNotFoundError: + print(f"❌ File not found: {csv_file}") + sys.exit(1) + except Exception as e: + print(f"❌ Error processing CSV: {e}") + sys.exit(1) + + print("\n" + "=" * 60) + print(f"Publishing complete!") + print(f"✅ Successfully published: {published_count}") + print(f"❌ Failed: {failed_count}") + print(f"📊 Total processed: {published_count + failed_count}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/misc-aio/test_nostr_connection.py b/misc-aio/test_nostr_connection.py new file mode 100644 index 00000000..d00b75b7 --- /dev/null +++ b/misc-aio/test_nostr_connection.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Manual Nostr profile metadata publisher +Usage: python test_nostr_connection.py +""" +import sys +import json +import time +import hashlib +import secp256k1 +from websocket import create_connection + +def sign_event(event, public_key_hex, private_key): + """Sign a Nostr event""" + # Create the signature data + signature_data = json.dumps([ + 0, + public_key_hex, + event["created_at"], + event["kind"], + event["tags"], + event["content"] + ], separators=(',', ':'), ensure_ascii=False) + + # Calculate event ID + event_id = hashlib.sha256(signature_data.encode()).hexdigest() + event["id"] = event_id + event["pubkey"] = public_key_hex + + # Sign the event + signature = private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True).hex() + event["sig"] = signature + + return event + +def publish_profile_metadata(private_key_hex, profile_name, relay_url): + """Publish a Nostr kind 0 metadata event""" + try: + # Convert hex private key to secp256k1 PrivateKey and get public key + private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex)) + public_key_hex = private_key.pubkey.serialize()[1:].hex() # Remove the 0x02 prefix + + print(f"Private key: {private_key_hex}") + print(f"Public key: {public_key_hex}") + print(f"Profile name: {profile_name}") + print(f"Relay URL: {relay_url}") + print() + + # Create Nostr kind 0 metadata event + metadata = { + "name": profile_name, + "display_name": profile_name, + "about": f"Manual profile update for {profile_name}" + } + + event = { + "kind": 0, + "created_at": int(time.time()), + "tags": [], + "content": json.dumps(metadata, separators=(',', ':')) + } + + # Sign the event + signed_event = sign_event(event, public_key_hex, private_key) + + print(f"Signed event: {json.dumps(signed_event, indent=2)}") + print() + + # Connect to relay and publish + print(f"Connecting to relay: {relay_url}") + ws = create_connection(relay_url, timeout=15) + print("✅ Connected successfully!") + + # Send the event + event_message = f'["EVENT",{json.dumps(signed_event)}]' + print(f"Sending EVENT: {event_message}") + ws.send(event_message) + + # Wait for response + try: + response = ws.recv() + print(f"✅ Relay response: {response}") + except Exception as e: + print(f"⚠️ No immediate response: {e}") + + # Close connection + ws.close() + print("✅ Connection closed successfully") + print(f"Profile metadata for '{profile_name}' published successfully!") + + except Exception as e: + print(f"❌ Failed to publish profile: {e}") + raise + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: python test_nostr_connection.py ") + print("Example: python test_nostr_connection.py abc123... 'My Name' wss://relay.example.com") + sys.exit(1) + + private_key_hex = sys.argv[1] + profile_name = sys.argv[2] + relay_url = sys.argv[3] + + publish_profile_metadata(private_key_hex, profile_name, relay_url) \ No newline at end of file