From 1161fab8053fce1c86c38572f6591d4945aa1d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Thu, 29 Jan 2026 13:07:54 +0100 Subject: [PATCH] feat: adding root path functionality - add --root-path to lnbits application start to use in uvicorn - utils.urlFor is aware of root-path - api.request is aware of root-path TODO: dynamic component loading kinda works, but older extension loading is curently broken because of path issues on `normalize_path` unhardcode api fixup nocache unhardcode feat: adding root path functionality - add --root-path to lnbits application start to use in uvicorn - utils.urlFor is aware of root-path - api.request is aware of root-path TODO: dynamic component loading kinda works, but older extension loading is curently broken because of path issues on `normalize_path` --- lnbits/core/views/generic.py | 2 +- lnbits/decorators.py | 2 + lnbits/helpers.py | 21 ++++-- lnbits/middleware.py | 4 +- lnbits/server.py | 14 +++- lnbits/settings.py | 1 + lnbits/static/js/api.js | 70 +++++-------------- lnbits/static/js/globals.js | 10 +-- lnbits/static/js/init-app.js | 25 +++++-- lnbits/static/js/utils.js | 14 ++-- lnbits/templates/base.html | 12 +++- lnbits/templates/components/lnbits-header.vue | 2 +- .../components/lnbits-home-logos.vue | 8 ++- .../lnbits-manage-extension-list.vue | 7 +- lnbits/templates/pages/account.vue | 8 ++- lnbits/templates/pages/wallet.vue | 8 ++- 16 files changed, 123 insertions(+), 85 deletions(-) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index e073a5c4f..19676077c 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -32,7 +32,7 @@ generic_router = APIRouter( @generic_router.get("/favicon.ico", response_class=FileResponse) async def favicon(): - return RedirectResponse(settings.lnbits_qr_logo) + return RedirectResponse(settings.root_path + settings.lnbits_qr_logo) @generic_router.get("/robots.txt", response_class=HTMLResponse) diff --git a/lnbits/decorators.py b/lnbits/decorators.py index 1673b860f..7c5cfaf9e 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -517,6 +517,7 @@ async def _check_account_api_access( raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.") +# TODO: this messes up my extension urls def url_for_interceptor(original_method): def normalize_url(self, *args, **kwargs): url = original_method(self, *args, **kwargs) @@ -527,6 +528,7 @@ def url_for_interceptor(original_method): # Upgraded extensions modify the path. # This interceptor ensures that the path is normalized. +# TODO: this messes up my extension urls Request.url_for = url_for_interceptor(Request.url_for) # type: ignore[method-assign] diff --git a/lnbits/helpers.py b/lnbits/helpers.py index 88a001b34..a416f8005 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -48,8 +48,11 @@ def url_for(endpoint: str, external: bool | None = False, **params: Any) -> str: return url -def static_url_for(static: str, path: str) -> str: - return f"/{static}/{path}?v={settings.server_startup_time}" +def static_url_for(static: str, path: str, no_cache: bool = False) -> str: + url = f"{settings.root_path}{static}/{path}" + if no_cache: + url += f"?v={settings.server_startup_time}" + return url def template_renderer(additional_folders: list | None = None) -> Jinja2Templates: @@ -57,7 +60,6 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates "lnbits/templates", settings.extension_builder_working_dir_path.as_posix(), ] - if additional_folders: additional_folders += [ Path(settings.lnbits_extensions_path, "extensions", f) @@ -69,6 +71,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates t.env.globals["normalize_path"] = normalize_path # used in base.html + t.env.globals["ROOT_PATH"] = settings.root_path t.env.globals["SITE_TITLE"] = settings.lnbits_site_title t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True) @@ -310,6 +313,8 @@ def get_api_routes(routes: list) -> dict[str, str]: def path_segments(path: str) -> list[str]: path = path.strip("/") + # Remove empty segments caused by '//' in the path + # segments = [s for s in path.split("/") if s] segments = path.split("/") if len(segments) < 2: return segments @@ -319,8 +324,16 @@ def path_segments(path: str) -> list[str]: def normalize_path(path: str | None) -> str: + print(path) path = path or "" - return "/" + "/".join(path_segments(path)) + segments = path_segments(path) + print(segments) + joined = "/".join(segments) + print("!!!!!!!!!!") + print(joined) + return joined + + # return "/" + "/".join(path_segments(path)) def normalize_endpoint(endpoint: str, add_proto=True) -> str: diff --git a/lnbits/middleware.py b/lnbits/middleware.py index dfa077885..60464be09 100644 --- a/lnbits/middleware.py +++ b/lnbits/middleware.py @@ -20,6 +20,7 @@ from lnbits.helpers import normalize_path, template_renderer from lnbits.settings import settings +# TODO: root path should be considered here? class InstalledExtensionMiddleware: # This middleware class intercepts calls made to the extensions API and: # - it blocks the calls if the extension has been disabled or uninstalled. @@ -51,7 +52,7 @@ class InstalledExtensionMiddleware: await self.app(scope, receive, send) return - # re-route all trafic if the extension has been upgraded + # re-route all traffic if the extension has been upgraded if top_path in settings.lnbits_upgraded_extensions: upgrade_path = ( f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}""" @@ -241,6 +242,7 @@ def add_first_install_middleware(app: FastAPI): async def first_install_middleware(request: Request, call_next): if ( settings.first_install + # TODO: root path should be considered here? and request.url.path != "/api/v1/auth/first_install" and request.url.path != "/first_install" and not request.url.path.startswith("/static") diff --git a/lnbits/server.py b/lnbits/server.py index 51a26d3eb..240461968 100644 --- a/lnbits/server.py +++ b/lnbits/server.py @@ -17,6 +17,11 @@ from lnbits.settings import set_cli_settings, settings ) @click.option("--port", default=settings.port, help="Port to listen on") @click.option("--host", default=settings.host, help="Host to run LNbits on") +@click.option( + "--root-path", + default=settings.root_path, + help="Root path of proxy, my.lnbits.com/rootpath ", +) @click.option( "--forwarded-allow-ips", default=settings.forwarded_allow_ips, @@ -30,6 +35,7 @@ from lnbits.settings import set_cli_settings, settings def main( port: int, host: str, + root_path: str, forwarded_allow_ips: str, ssl_keyfile: str, ssl_certfile: str, @@ -46,7 +52,12 @@ def main( parents=True, exist_ok=True ) - set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips) + set_cli_settings( + host=host, + port=port, + forwarded_allow_ips=forwarded_allow_ips, + root_path=root_path, + ) while True: config = uvicorn.Config( @@ -54,6 +65,7 @@ def main( loop="uvloop", port=port, host=host, + root_path=root_path, forwarded_allow_ips=forwarded_allow_ips, ssl_keyfile=ssl_keyfile, ssl_certfile=ssl_certfile, diff --git a/lnbits/settings.py b/lnbits/settings.py index 49e9eb5c6..52a158a62 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -1060,6 +1060,7 @@ class EnvSettings(LNbitsSettings): auth_https_only: bool = Field(default=True) host: str = Field(default="127.0.0.1") port: int = Field(default=5000, gt=0) + root_path: str = Field(default="/") forwarded_allow_ips: str = Field(default="*") lnbits_title: str = Field(default="LNbits API") lnbits_path: str = Field(default=".") diff --git a/lnbits/static/js/api.js b/lnbits/static/js/api.js index ba1936790..11f2a1d00 100644 --- a/lnbits/static/js/api.js +++ b/lnbits/static/js/api.js @@ -1,5 +1,7 @@ window._lnbitsApi = { request(method, url, apiKey, data, options = {}) { + url = ROOT_PATH + url.replace(/^\/+/, '') // Ensure single slash after rootPath + console.log(`API Request: ${method.toUpperCase()} ${url}`) return axios({ method: method, url: url, @@ -67,75 +69,41 @@ window._lnbitsApi = { }) }, register(username, email, password, password_repeat, invitation_code) { - return axios({ - method: 'POST', - url: '/api/v1/auth/register', - data: { - username, - email, - password, - password_repeat, - invitation_code - } + return this.request('post', '/api/v1/auth/register', null, { + username, + email, + password, + password_repeat, + invitation_code }) }, reset(reset_key, password, password_repeat) { - return axios({ - method: 'PUT', - url: '/api/v1/auth/reset', - data: { - reset_key, - password, - password_repeat - } + return this.request('put', '/api/v1/auth/reset', null, { + reset_key, + password, + password_repeat }) }, getAuthUser() { - return axios({ - method: 'GET', - url: '/api/v1/auth' - }) + return this.request('get', '/api/v1/auth') }, login(username, password) { - return axios({ - method: 'POST', - url: '/api/v1/auth', - data: {username, password} - }) + return this.request('post', '/api/v1/auth', null, {username, password}) }, loginByProvider(provider, headers, data) { - return axios({ - method: 'POST', - url: `/api/v1/auth/${provider}`, - headers: headers, - data - }) + return this.request('post', `/api/v1/auth/${provider}`, null, data) }, loginUsr(usr) { - return axios({ - method: 'POST', - url: '/api/v1/auth/usr', - data: {usr} - }) + return this.request('post', '/api/v1/auth/usr', null, {usr}) }, logout() { - return axios({ - method: 'POST', - url: '/api/v1/auth/logout' - }) + return this.request('post', '/api/v1/auth/logout') }, impersonateUser(usr) { - return axios({ - method: 'POST', - url: '/api/v1/auth/impersonate', - data: {usr} - }) + return this.request('POST', '/api/v1/auth/impersonate', null, {usr}) }, stopImpersonation() { - return axios({ - method: 'DELETE', - url: '/api/v1/auth/impersonate' - }) + return this.request('DELETE', '/api/v1/auth/impersonate') }, getAuthenticatedUser() { return this.request('get', '/api/v1/auth') diff --git a/lnbits/static/js/globals.js b/lnbits/static/js/globals.js index 990eb32ee..6ad851812 100644 --- a/lnbits/static/js/globals.js +++ b/lnbits/static/js/globals.js @@ -72,7 +72,7 @@ window.dateFormat = 'YYYY-MM-DD HH:mm' const websocketPrefix = window.location.protocol === 'http:' ? 'ws://' : 'wss://' -const websocketUrl = `${websocketPrefix}${window.location.host}/api/v1/ws` +const websocketUrl = `${websocketPrefix}${window.location.host}${ROOT_PATH}api/v1/ws` const _access_cookies_for_safari_refresh_do_not_delete = document.cookie @@ -87,9 +87,11 @@ addEventListener('online', event => { }) if (navigator.serviceWorker != null) { - navigator.serviceWorker.register('/service-worker.js').then(registration => { - console.log('Registered events at scope: ', registration.scope) - }) + navigator.serviceWorker + .register(ROOT_PATH + 'service-worker.js') + .then(registration => { + console.log('Registered events at scope: ', registration.scope) + }) } if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) { diff --git a/lnbits/static/js/init-app.js b/lnbits/static/js/init-app.js index 457b016bc..f98b58b64 100644 --- a/lnbits/static/js/init-app.js +++ b/lnbits/static/js/init-app.js @@ -11,11 +11,16 @@ const quasarConfig = { const DynamicComponent = { async created() { + // no trailing / + const rootPath = ROOT_PATH.replace(/\/+$/, '') const name = this.$route.path.split('/')[1] - const path = `/${name}/` - const routesPath = `/${name}/static/routes.json` + const path = `${rootPath}/${name}/` + const routesPath = `${rootPath}/${name}/static/routes.json` if (this.$router.getRoutes().some(r => r.path === path)) return - if (this.$route.fullPath.startsWith('/extensions/builder/preview')) return + if ( + this.$route.fullPath.startsWith(rootPath + '/extensions/builder/preview') + ) + return fetch(routesPath) .then(async res => { if (!res.ok) throw new Error('No dynamic routes found') @@ -38,9 +43,17 @@ const DynamicComponent = { let route = RENDERED_ROUTE // append trailing slash only on the root path `/path` -> `/path/` if (route.split('/').length === 2) route += '/' + console.log('ROUTE', route) + + console.log('path / fullpath', this.$route.path, this.$route.fullPath) + if (route !== this.$route.path) { - console.log('Redirecting to non-vue route:', this.$route.fullPath) - window.location = this.$route.fullPath + const rootPath = ROOT_PATH.replace(/\/+$/, '') + console.log( + 'Redirecting to non-vue route:', + rootPath + this.$route.fullPath + ) + // window.location = rootPath + this.$route.fullPath return } }) @@ -139,7 +152,7 @@ const routes = [ ] window.router = VueRouter.createRouter({ - history: VueRouter.createWebHistory(), + history: VueRouter.createWebHistory(ROOT_PATH), routes }) diff --git a/lnbits/static/js/utils.js b/lnbits/static/js/utils.js index 54315337f..63d395b58 100644 --- a/lnbits/static/js/utils.js +++ b/lnbits/static/js/utils.js @@ -1,13 +1,17 @@ window._lnbitsUtils = { - url_for(url) { - const _url = new URL(url, window.location.origin) - _url.searchParams.set('v', window.g.settings.cacheKey) + urlFor(url, noCache = false) { + if (url.startsWith('http://') || url.startsWith('https://')) { + return url + } + const rootPath = ROOT_PATH.replace(/\/+$/, '') + const _url = new URL(rootPath + url, window.location.origin) + if (!noCache) _url.searchParams.set('v', window.g.settings.cacheKey) return _url.toString() }, loadScript(src) { return new Promise((resolve, reject) => { const script = document.createElement('script') - script.src = this.url_for(src) + script.src = this.urlFor(src) script.onload = () => { resolve() } @@ -18,7 +22,7 @@ window._lnbitsUtils = { }) }, async loadTemplate(url) { - return fetch(this.url_for(url)) + return fetch(this.urlFor(url)) .then(response => { if (!response.ok) { throw new Error(`Failed to load template from ${url}`) diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index c81271b2e..17247d020 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -23,6 +23,7 @@ name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no" /> + {% endif %} {% block head_scripts %}{% endblock %} + @@ -52,9 +60,7 @@ v-if="g.user && !g.isPublicPage" > -
+
{% block page %}{% endblock %}
diff --git a/lnbits/templates/components/lnbits-header.vue b/lnbits/templates/components/lnbits-header.vue index 8daa89a3c..d789b8d65 100644 --- a/lnbits/templates/components/lnbits-header.vue +++ b/lnbits/templates/components/lnbits-header.vue @@ -26,7 +26,7 @@ @click="g.visibleDrawer = !g.visibleDrawer" > - + Logo diff --git a/lnbits/templates/components/lnbits-home-logos.vue b/lnbits/templates/components/lnbits-home-logos.vue index 73246846a..e4831c722 100644 --- a/lnbits/templates/components/lnbits-home-logos.vue +++ b/lnbits/templates/components/lnbits-home-logos.vue @@ -7,7 +7,9 @@
@@ -18,7 +20,9 @@ diff --git a/lnbits/templates/components/lnbits-manage-extension-list.vue b/lnbits/templates/components/lnbits-manage-extension-list.vue index 6705d6dc3..ef8271596 100644 --- a/lnbits/templates/components/lnbits-manage-extension-list.vue +++ b/lnbits/templates/components/lnbits-manage-extension-list.vue @@ -20,11 +20,14 @@ clickable :active="$route.path.startsWith('/' + extension.code)" tag="a" - :to="'/' + extension.code + '/'" + :to="`/${extension.code}/`" > - + diff --git a/lnbits/templates/pages/account.vue b/lnbits/templates/pages/account.vue index 2373062b3..67f77788f 100644 --- a/lnbits/templates/pages/account.vue +++ b/lnbits/templates/pages/account.vue @@ -285,7 +285,9 @@ >
Google
@@ -306,7 +308,9 @@ >
GitHub
diff --git a/lnbits/templates/pages/wallet.vue b/lnbits/templates/pages/wallet.vue index 2c4d4cb03..78752e946 100644 --- a/lnbits/templates/pages/wallet.vue +++ b/lnbits/templates/pages/wallet.vue @@ -216,8 +216,12 @@ - - + +