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`
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
+17
-4
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+13
-1
@@ -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,
|
||||
|
||||
@@ -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=".")
|
||||
|
||||
+19
-51
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<link rel="icon" type="image/x-icon" href="{{ ROOT_PATH }}favicon.ico" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<link
|
||||
@@ -32,6 +33,13 @@
|
||||
{% if web_manifest %}
|
||||
<link async="async" rel="manifest" href="{{ web_manifest }}" />
|
||||
{% endif %} {% block head_scripts %}{% endblock %}
|
||||
<script type="text/javascript">
|
||||
const ROOT_PATH = '{{ ROOT_PATH }}'
|
||||
const RENDERED_ROUTE = '{{ normalize_path(request.path) }}'.replace(
|
||||
ROOT_PATH.replace(/\/+$/, '') || '/',
|
||||
''
|
||||
)
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body data-theme="bitcoin">
|
||||
@@ -52,9 +60,7 @@
|
||||
v-if="g.user && !g.isPublicPage"
|
||||
></lnbits-header-wallets>
|
||||
<!-- block page content from static extensions -->
|
||||
<div
|
||||
v-if="$route.path.startsWith('{{ normalize_path(request.path) }}')"
|
||||
>
|
||||
<div v-if="$route.path.startsWith(RENDERED_ROUTE)">
|
||||
{% block page %}{% endblock %}
|
||||
</div>
|
||||
<!-- vue router-view -->
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
@click="g.visibleDrawer = !g.visibleDrawer"
|
||||
></q-btn>
|
||||
<q-toolbar-title>
|
||||
<q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" href="/">
|
||||
<q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" :href="utils.urlFor('/', true)">
|
||||
<q-avatar v-if="g.settings.customLogo" height="30px">
|
||||
<img alt="Logo" :src="g.settings.customLogo" />
|
||||
</q-avatar>
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
<a :href="logo.url" target="_blank" rel="noopener noreferrer">
|
||||
<q-img
|
||||
contain
|
||||
:src="$q.dark.isActive ? logo.darkSrc : logo.lightSrc"
|
||||
:src="
|
||||
utils.urlFor($q.dark.isActive ? logo.darkSrc : logo.lightSrc)
|
||||
"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
@@ -18,7 +20,9 @@
|
||||
<a :href="logo.url" target="_blank" rel="noopener noreferrer">
|
||||
<q-img
|
||||
contain
|
||||
:src="$q.dark.isActive ? logo.darkSrc : logo.lightSrc"
|
||||
:src="
|
||||
utils.urlFor($q.dark.isActive ? logo.darkSrc : logo.lightSrc)
|
||||
"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -20,11 +20,14 @@
|
||||
clickable
|
||||
:active="$route.path.startsWith('/' + extension.code)"
|
||||
tag="a"
|
||||
:to="'/' + extension.code + '/'"
|
||||
:to="`/${extension.code}/`"
|
||||
>
|
||||
<q-item-section side>
|
||||
<q-avatar size="md">
|
||||
<q-img :src="extension.tile" style="max-width: 20px"></q-img>
|
||||
<q-img
|
||||
:src="utils.urlFor(extension.tile)"
|
||||
style="max-width: 20px"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
|
||||
@@ -285,7 +285,9 @@
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
|
||||
:src="
|
||||
utils.urlFor('/static/images/google-logo.png')
|
||||
"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div>Google</div>
|
||||
@@ -306,7 +308,9 @@
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
|
||||
:src="
|
||||
utils.urlFor('/static/images/github-logo.png')
|
||||
"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div>GitHub</div>
|
||||
|
||||
@@ -216,8 +216,12 @@
|
||||
<q-card-section class="text-subtitle1">
|
||||
<span v-text="g.settings.adSpaceTitle"></span>
|
||||
<a :href="ad[0]" class="lnbits-ad" v-for="ad in g.settings.adSpace">
|
||||
<q-img class="q-mb-xs" v-if="$q.dark.isActive" :src="ad[1]"></q-img>
|
||||
<q-img class="q-mb-xs" v-else :src="ad[2]"></q-img>
|
||||
<q-img
|
||||
class="q-mb-xs"
|
||||
v-if="$q.dark.isActive"
|
||||
:src="utils.urlFor(ad[1])"
|
||||
></q-img>
|
||||
<q-img class="q-mb-xs" v-else :src="utils.urlFor(ad[2])"></q-img>
|
||||
</a>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
Reference in New Issue
Block a user