Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df8e69218e |
@@ -32,7 +32,7 @@ generic_router = APIRouter(
|
|||||||
|
|
||||||
@generic_router.get("/favicon.ico", response_class=FileResponse)
|
@generic_router.get("/favicon.ico", response_class=FileResponse)
|
||||||
async def favicon():
|
async def favicon():
|
||||||
return RedirectResponse(settings.root_path + settings.lnbits_qr_logo)
|
return RedirectResponse(settings.lnbits_qr_logo)
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get("/robots.txt", response_class=HTMLResponse)
|
@generic_router.get("/robots.txt", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -517,7 +517,6 @@ async def _check_account_api_access(
|
|||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.")
|
||||||
|
|
||||||
|
|
||||||
# TODO: this messes up my extension urls
|
|
||||||
def url_for_interceptor(original_method):
|
def url_for_interceptor(original_method):
|
||||||
def normalize_url(self, *args, **kwargs):
|
def normalize_url(self, *args, **kwargs):
|
||||||
url = original_method(self, *args, **kwargs)
|
url = original_method(self, *args, **kwargs)
|
||||||
@@ -528,7 +527,6 @@ def url_for_interceptor(original_method):
|
|||||||
|
|
||||||
# Upgraded extensions modify the path.
|
# Upgraded extensions modify the path.
|
||||||
# This interceptor ensures that the path is normalized.
|
# 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]
|
Request.url_for = url_for_interceptor(Request.url_for) # type: ignore[method-assign]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-17
@@ -48,11 +48,8 @@ def url_for(endpoint: str, external: bool | None = False, **params: Any) -> str:
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
def static_url_for(static: str, path: str, no_cache: bool = False) -> str:
|
def static_url_for(static: str, path: str) -> str:
|
||||||
url = f"{settings.root_path}{static}/{path}"
|
return f"/{static}/{path}?v={settings.server_startup_time}"
|
||||||
if no_cache:
|
|
||||||
url += f"?v={settings.server_startup_time}"
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
|
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
|
||||||
@@ -60,6 +57,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
|||||||
"lnbits/templates",
|
"lnbits/templates",
|
||||||
settings.extension_builder_working_dir_path.as_posix(),
|
settings.extension_builder_working_dir_path.as_posix(),
|
||||||
]
|
]
|
||||||
|
|
||||||
if additional_folders:
|
if additional_folders:
|
||||||
additional_folders += [
|
additional_folders += [
|
||||||
Path(settings.lnbits_extensions_path, "extensions", f)
|
Path(settings.lnbits_extensions_path, "extensions", f)
|
||||||
@@ -71,7 +69,6 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
|||||||
t.env.globals["normalize_path"] = normalize_path
|
t.env.globals["normalize_path"] = normalize_path
|
||||||
|
|
||||||
# used in base.html
|
# used in base.html
|
||||||
t.env.globals["ROOT_PATH"] = settings.root_path
|
|
||||||
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
|
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
|
||||||
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon
|
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon
|
||||||
t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True)
|
t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True)
|
||||||
@@ -313,8 +310,6 @@ def get_api_routes(routes: list) -> dict[str, str]:
|
|||||||
|
|
||||||
def path_segments(path: str) -> list[str]:
|
def path_segments(path: str) -> list[str]:
|
||||||
path = path.strip("/")
|
path = path.strip("/")
|
||||||
# Remove empty segments caused by '//' in the path
|
|
||||||
# segments = [s for s in path.split("/") if s]
|
|
||||||
segments = path.split("/")
|
segments = path.split("/")
|
||||||
if len(segments) < 2:
|
if len(segments) < 2:
|
||||||
return segments
|
return segments
|
||||||
@@ -324,16 +319,8 @@ def path_segments(path: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_path(path: str | None) -> str:
|
def normalize_path(path: str | None) -> str:
|
||||||
print(path)
|
|
||||||
path = path or ""
|
path = path or ""
|
||||||
segments = path_segments(path)
|
return "/" + "/".join(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:
|
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from lnbits.helpers import normalize_path, template_renderer
|
|||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|
||||||
# TODO: root path should be considered here?
|
|
||||||
class InstalledExtensionMiddleware:
|
class InstalledExtensionMiddleware:
|
||||||
# This middleware class intercepts calls made to the extensions API and:
|
# This middleware class intercepts calls made to the extensions API and:
|
||||||
# - it blocks the calls if the extension has been disabled or uninstalled.
|
# - it blocks the calls if the extension has been disabled or uninstalled.
|
||||||
@@ -52,7 +51,7 @@ class InstalledExtensionMiddleware:
|
|||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
return
|
return
|
||||||
|
|
||||||
# re-route all traffic if the extension has been upgraded
|
# re-route all trafic if the extension has been upgraded
|
||||||
if top_path in settings.lnbits_upgraded_extensions:
|
if top_path in settings.lnbits_upgraded_extensions:
|
||||||
upgrade_path = (
|
upgrade_path = (
|
||||||
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
|
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
|
||||||
@@ -242,7 +241,6 @@ def add_first_install_middleware(app: FastAPI):
|
|||||||
async def first_install_middleware(request: Request, call_next):
|
async def first_install_middleware(request: Request, call_next):
|
||||||
if (
|
if (
|
||||||
settings.first_install
|
settings.first_install
|
||||||
# TODO: root path should be considered here?
|
|
||||||
and request.url.path != "/api/v1/auth/first_install"
|
and request.url.path != "/api/v1/auth/first_install"
|
||||||
and request.url.path != "/first_install"
|
and request.url.path != "/first_install"
|
||||||
and not request.url.path.startswith("/static")
|
and not request.url.path.startswith("/static")
|
||||||
|
|||||||
+1
-13
@@ -17,11 +17,6 @@ from lnbits.settings import set_cli_settings, settings
|
|||||||
)
|
)
|
||||||
@click.option("--port", default=settings.port, help="Port to listen on")
|
@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("--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(
|
@click.option(
|
||||||
"--forwarded-allow-ips",
|
"--forwarded-allow-ips",
|
||||||
default=settings.forwarded_allow_ips,
|
default=settings.forwarded_allow_ips,
|
||||||
@@ -35,7 +30,6 @@ from lnbits.settings import set_cli_settings, settings
|
|||||||
def main(
|
def main(
|
||||||
port: int,
|
port: int,
|
||||||
host: str,
|
host: str,
|
||||||
root_path: str,
|
|
||||||
forwarded_allow_ips: str,
|
forwarded_allow_ips: str,
|
||||||
ssl_keyfile: str,
|
ssl_keyfile: str,
|
||||||
ssl_certfile: str,
|
ssl_certfile: str,
|
||||||
@@ -52,12 +46,7 @@ def main(
|
|||||||
parents=True, exist_ok=True
|
parents=True, exist_ok=True
|
||||||
)
|
)
|
||||||
|
|
||||||
set_cli_settings(
|
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
forwarded_allow_ips=forwarded_allow_ips,
|
|
||||||
root_path=root_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
@@ -65,7 +54,6 @@ def main(
|
|||||||
loop="uvloop",
|
loop="uvloop",
|
||||||
port=port,
|
port=port,
|
||||||
host=host,
|
host=host,
|
||||||
root_path=root_path,
|
|
||||||
forwarded_allow_ips=forwarded_allow_ips,
|
forwarded_allow_ips=forwarded_allow_ips,
|
||||||
ssl_keyfile=ssl_keyfile,
|
ssl_keyfile=ssl_keyfile,
|
||||||
ssl_certfile=ssl_certfile,
|
ssl_certfile=ssl_certfile,
|
||||||
|
|||||||
@@ -1060,7 +1060,6 @@ class EnvSettings(LNbitsSettings):
|
|||||||
auth_https_only: bool = Field(default=True)
|
auth_https_only: bool = Field(default=True)
|
||||||
host: str = Field(default="127.0.0.1")
|
host: str = Field(default="127.0.0.1")
|
||||||
port: int = Field(default=5000, gt=0)
|
port: int = Field(default=5000, gt=0)
|
||||||
root_path: str = Field(default="/")
|
|
||||||
forwarded_allow_ips: str = Field(default="*")
|
forwarded_allow_ips: str = Field(default="*")
|
||||||
lnbits_title: str = Field(default="LNbits API")
|
lnbits_title: str = Field(default="LNbits API")
|
||||||
lnbits_path: str = Field(default=".")
|
lnbits_path: str = Field(default=".")
|
||||||
|
|||||||
+43
-11
@@ -1,7 +1,5 @@
|
|||||||
window._lnbitsApi = {
|
window._lnbitsApi = {
|
||||||
request(method, url, apiKey, data, options = {}) {
|
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({
|
return axios({
|
||||||
method: method,
|
method: method,
|
||||||
url: url,
|
url: url,
|
||||||
@@ -69,41 +67,75 @@ window._lnbitsApi = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
register(username, email, password, password_repeat, invitation_code) {
|
register(username, email, password, password_repeat, invitation_code) {
|
||||||
return this.request('post', '/api/v1/auth/register', null, {
|
return axios({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/register',
|
||||||
|
data: {
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
password_repeat,
|
password_repeat,
|
||||||
invitation_code
|
invitation_code
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
reset(reset_key, password, password_repeat) {
|
reset(reset_key, password, password_repeat) {
|
||||||
return this.request('put', '/api/v1/auth/reset', null, {
|
return axios({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/auth/reset',
|
||||||
|
data: {
|
||||||
reset_key,
|
reset_key,
|
||||||
password,
|
password,
|
||||||
password_repeat
|
password_repeat
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getAuthUser() {
|
getAuthUser() {
|
||||||
return this.request('get', '/api/v1/auth')
|
return axios({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth'
|
||||||
|
})
|
||||||
},
|
},
|
||||||
login(username, password) {
|
login(username, password) {
|
||||||
return this.request('post', '/api/v1/auth', null, {username, password})
|
return axios({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth',
|
||||||
|
data: {username, password}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
loginByProvider(provider, headers, data) {
|
loginByProvider(provider, headers, data) {
|
||||||
return this.request('post', `/api/v1/auth/${provider}`, null, data)
|
return axios({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/auth/${provider}`,
|
||||||
|
headers: headers,
|
||||||
|
data
|
||||||
|
})
|
||||||
},
|
},
|
||||||
loginUsr(usr) {
|
loginUsr(usr) {
|
||||||
return this.request('post', '/api/v1/auth/usr', null, {usr})
|
return axios({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/usr',
|
||||||
|
data: {usr}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
return this.request('post', '/api/v1/auth/logout')
|
return axios({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/logout'
|
||||||
|
})
|
||||||
},
|
},
|
||||||
impersonateUser(usr) {
|
impersonateUser(usr) {
|
||||||
return this.request('POST', '/api/v1/auth/impersonate', null, {usr})
|
return axios({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/impersonate',
|
||||||
|
data: {usr}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
stopImpersonation() {
|
stopImpersonation() {
|
||||||
return this.request('DELETE', '/api/v1/auth/impersonate')
|
return axios({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/auth/impersonate'
|
||||||
|
})
|
||||||
},
|
},
|
||||||
getAuthenticatedUser() {
|
getAuthenticatedUser() {
|
||||||
return this.request('get', '/api/v1/auth')
|
return this.request('get', '/api/v1/auth')
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ window.dateFormat = 'YYYY-MM-DD HH:mm'
|
|||||||
|
|
||||||
const websocketPrefix =
|
const websocketPrefix =
|
||||||
window.location.protocol === 'http:' ? 'ws://' : 'wss://'
|
window.location.protocol === 'http:' ? 'ws://' : 'wss://'
|
||||||
const websocketUrl = `${websocketPrefix}${window.location.host}${ROOT_PATH}api/v1/ws`
|
const websocketUrl = `${websocketPrefix}${window.location.host}/api/v1/ws`
|
||||||
|
|
||||||
const _access_cookies_for_safari_refresh_do_not_delete = document.cookie
|
const _access_cookies_for_safari_refresh_do_not_delete = document.cookie
|
||||||
|
|
||||||
@@ -87,9 +87,7 @@ addEventListener('online', event => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (navigator.serviceWorker != null) {
|
if (navigator.serviceWorker != null) {
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker.register('/service-worker.js').then(registration => {
|
||||||
.register(ROOT_PATH + 'service-worker.js')
|
|
||||||
.then(registration => {
|
|
||||||
console.log('Registered events at scope: ', registration.scope)
|
console.log('Registered events at scope: ', registration.scope)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,11 @@ const quasarConfig = {
|
|||||||
|
|
||||||
const DynamicComponent = {
|
const DynamicComponent = {
|
||||||
async created() {
|
async created() {
|
||||||
// no trailing /
|
|
||||||
const rootPath = ROOT_PATH.replace(/\/+$/, '')
|
|
||||||
const name = this.$route.path.split('/')[1]
|
const name = this.$route.path.split('/')[1]
|
||||||
const path = `${rootPath}/${name}/`
|
const path = `/${name}/`
|
||||||
const routesPath = `${rootPath}/${name}/static/routes.json`
|
const routesPath = `/${name}/static/routes.json`
|
||||||
if (this.$router.getRoutes().some(r => r.path === path)) return
|
if (this.$router.getRoutes().some(r => r.path === path)) return
|
||||||
if (
|
if (this.$route.fullPath.startsWith('/extensions/builder/preview')) return
|
||||||
this.$route.fullPath.startsWith(rootPath + '/extensions/builder/preview')
|
|
||||||
)
|
|
||||||
return
|
|
||||||
fetch(routesPath)
|
fetch(routesPath)
|
||||||
.then(async res => {
|
.then(async res => {
|
||||||
if (!res.ok) throw new Error('No dynamic routes found')
|
if (!res.ok) throw new Error('No dynamic routes found')
|
||||||
@@ -43,17 +38,9 @@ const DynamicComponent = {
|
|||||||
let route = RENDERED_ROUTE
|
let route = RENDERED_ROUTE
|
||||||
// append trailing slash only on the root path `/path` -> `/path/`
|
// append trailing slash only on the root path `/path` -> `/path/`
|
||||||
if (route.split('/').length === 2) route += '/'
|
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) {
|
if (route !== this.$route.path) {
|
||||||
const rootPath = ROOT_PATH.replace(/\/+$/, '')
|
console.log('Redirecting to non-vue route:', this.$route.fullPath)
|
||||||
console.log(
|
window.location = this.$route.fullPath
|
||||||
'Redirecting to non-vue route:',
|
|
||||||
rootPath + this.$route.fullPath
|
|
||||||
)
|
|
||||||
// window.location = rootPath + this.$route.fullPath
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -152,7 +139,7 @@ const routes = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
window.router = VueRouter.createRouter({
|
window.router = VueRouter.createRouter({
|
||||||
history: VueRouter.createWebHistory(ROOT_PATH),
|
history: VueRouter.createWebHistory(),
|
||||||
routes
|
routes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
window._lnbitsUtils = {
|
window._lnbitsUtils = {
|
||||||
urlFor(url, noCache = false) {
|
url_for(url) {
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
const _url = new URL(url, window.location.origin)
|
||||||
return url
|
_url.searchParams.set('v', window.g.settings.cacheKey)
|
||||||
}
|
|
||||||
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()
|
return _url.toString()
|
||||||
},
|
},
|
||||||
loadScript(src) {
|
loadScript(src) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const script = document.createElement('script')
|
const script = document.createElement('script')
|
||||||
script.src = this.urlFor(src)
|
script.src = this.url_for(src)
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
@@ -22,7 +18,7 @@ window._lnbitsUtils = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async loadTemplate(url) {
|
async loadTemplate(url) {
|
||||||
return fetch(this.urlFor(url))
|
return fetch(this.url_for(url))
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load template from ${url}`)
|
throw new Error(`Failed to load template from ${url}`)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"
|
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="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<link
|
<link
|
||||||
@@ -33,13 +32,6 @@
|
|||||||
{% if web_manifest %}
|
{% if web_manifest %}
|
||||||
<link async="async" rel="manifest" href="{{ web_manifest }}" />
|
<link async="async" rel="manifest" href="{{ web_manifest }}" />
|
||||||
{% endif %} {% block head_scripts %}{% endblock %}
|
{% 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>
|
</head>
|
||||||
|
|
||||||
<body data-theme="bitcoin">
|
<body data-theme="bitcoin">
|
||||||
@@ -60,7 +52,9 @@
|
|||||||
v-if="g.user && !g.isPublicPage"
|
v-if="g.user && !g.isPublicPage"
|
||||||
></lnbits-header-wallets>
|
></lnbits-header-wallets>
|
||||||
<!-- block page content from static extensions -->
|
<!-- block page content from static extensions -->
|
||||||
<div v-if="$route.path.startsWith(RENDERED_ROUTE)">
|
<div
|
||||||
|
v-if="$route.path.startsWith('{{ normalize_path(request.path) }}')"
|
||||||
|
>
|
||||||
{% block page %}{% endblock %}
|
{% block page %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<!-- vue router-view -->
|
<!-- vue router-view -->
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
@click="g.visibleDrawer = !g.visibleDrawer"
|
@click="g.visibleDrawer = !g.visibleDrawer"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
<q-toolbar-title>
|
<q-toolbar-title>
|
||||||
<q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" :href="utils.urlFor('/', true)">
|
<q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" href="/">
|
||||||
<q-avatar v-if="g.settings.customLogo" height="30px">
|
<q-avatar v-if="g.settings.customLogo" height="30px">
|
||||||
<img alt="Logo" :src="g.settings.customLogo" />
|
<img alt="Logo" :src="g.settings.customLogo" />
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
<a :href="logo.url" target="_blank" rel="noopener noreferrer">
|
<a :href="logo.url" target="_blank" rel="noopener noreferrer">
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
contain
|
||||||
:src="
|
:src="$q.dark.isActive ? logo.darkSrc : logo.lightSrc"
|
||||||
utils.urlFor($q.dark.isActive ? logo.darkSrc : logo.lightSrc)
|
|
||||||
"
|
|
||||||
></q-img>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -20,9 +18,7 @@
|
|||||||
<a :href="logo.url" target="_blank" rel="noopener noreferrer">
|
<a :href="logo.url" target="_blank" rel="noopener noreferrer">
|
||||||
<q-img
|
<q-img
|
||||||
contain
|
contain
|
||||||
:src="
|
:src="$q.dark.isActive ? logo.darkSrc : logo.lightSrc"
|
||||||
utils.urlFor($q.dark.isActive ? logo.darkSrc : logo.lightSrc)
|
|
||||||
"
|
|
||||||
></q-img>
|
></q-img>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,14 +20,11 @@
|
|||||||
clickable
|
clickable
|
||||||
:active="$route.path.startsWith('/' + extension.code)"
|
:active="$route.path.startsWith('/' + extension.code)"
|
||||||
tag="a"
|
tag="a"
|
||||||
:to="`/${extension.code}/`"
|
:to="'/' + extension.code + '/'"
|
||||||
>
|
>
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-avatar size="md">
|
<q-avatar size="md">
|
||||||
<q-img
|
<q-img :src="extension.tile" style="max-width: 20px"></q-img>
|
||||||
:src="utils.urlFor(extension.tile)"
|
|
||||||
style="max-width: 20px"
|
|
||||||
></q-img>
|
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
|
|||||||
@@ -285,9 +285,7 @@
|
|||||||
>
|
>
|
||||||
<q-avatar size="32px" class="q-mr-md">
|
<q-avatar size="32px" class="q-mr-md">
|
||||||
<q-img
|
<q-img
|
||||||
:src="
|
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
|
||||||
utils.urlFor('/static/images/google-logo.png')
|
|
||||||
"
|
|
||||||
></q-img>
|
></q-img>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<div>Google</div>
|
<div>Google</div>
|
||||||
@@ -308,9 +306,7 @@
|
|||||||
>
|
>
|
||||||
<q-avatar size="32px" class="q-mr-md">
|
<q-avatar size="32px" class="q-mr-md">
|
||||||
<q-img
|
<q-img
|
||||||
:src="
|
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
|
||||||
utils.urlFor('/static/images/github-logo.png')
|
|
||||||
"
|
|
||||||
></q-img>
|
></q-img>
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<div>GitHub</div>
|
<div>GitHub</div>
|
||||||
|
|||||||
@@ -216,12 +216,8 @@
|
|||||||
<q-card-section class="text-subtitle1">
|
<q-card-section class="text-subtitle1">
|
||||||
<span v-text="g.settings.adSpaceTitle"></span>
|
<span v-text="g.settings.adSpaceTitle"></span>
|
||||||
<a :href="ad[0]" class="lnbits-ad" v-for="ad in g.settings.adSpace">
|
<a :href="ad[0]" class="lnbits-ad" v-for="ad in g.settings.adSpace">
|
||||||
<q-img
|
<q-img class="q-mb-xs" v-if="$q.dark.isActive" :src="ad[1]"></q-img>
|
||||||
class="q-mb-xs"
|
<q-img class="q-mb-xs" v-else :src="ad[2]"></q-img>
|
||||||
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>
|
</a>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|||||||
+27
-5
@@ -25,6 +25,8 @@ class Cache:
|
|||||||
def __init__(self, interval: float = 10) -> None:
|
def __init__(self, interval: float = 10) -> None:
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
self._values: dict[Any, Cached] = {}
|
self._values: dict[Any, Cached] = {}
|
||||||
|
self._refreshing: set[str] = set()
|
||||||
|
self._tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
def value(self, key: str) -> Cached | None:
|
def value(self, key: str) -> Cached | None:
|
||||||
return self._values.get(key)
|
return self._values.get(key)
|
||||||
@@ -49,16 +51,36 @@ class Cache:
|
|||||||
|
|
||||||
async def save_result(self, coro, key: str, expiry: float = 10):
|
async def save_result(self, coro, key: str, expiry: float = 10):
|
||||||
"""
|
"""
|
||||||
If `key` exists, return its value, otherwise call coro and cache its result
|
Stale-while-revalidate: return stale value immediately and refresh in
|
||||||
|
the background. Only blocks on a true cold start (no prior value).
|
||||||
"""
|
"""
|
||||||
cached = self.get(key)
|
cached = self._values.get(key)
|
||||||
if cached:
|
if cached is not None:
|
||||||
return cached
|
if cached.expiry > time():
|
||||||
else:
|
return cached.value
|
||||||
|
# stale: serve old value and refresh in background (one task at a time)
|
||||||
|
if key not in self._refreshing:
|
||||||
|
self._refreshing.add(key)
|
||||||
|
# extend expiry now to prevent a stampede of background tasks
|
||||||
|
self._values[key] = Cached(cached.value, time() + expiry)
|
||||||
|
task = asyncio.create_task(self._refresh(coro, key, expiry))
|
||||||
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
return cached.value
|
||||||
|
# cold start: must wait for the first value
|
||||||
value = await coro()
|
value = await coro()
|
||||||
self.set(key, value, expiry=expiry)
|
self.set(key, value, expiry=expiry)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
async def _refresh(self, coro, key: str, expiry: float):
|
||||||
|
try:
|
||||||
|
value = await coro()
|
||||||
|
self.set(key, value, expiry=expiry)
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"Error refreshing cache key {key}")
|
||||||
|
finally:
|
||||||
|
self._refreshing.discard(key)
|
||||||
|
|
||||||
async def invalidate_forever(self):
|
async def invalidate_forever(self):
|
||||||
while settings.lnbits_running:
|
while settings.lnbits_running:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -192,8 +192,6 @@ class LndWallet(Wallet):
|
|||||||
fee_limit_msat=fee_limit_msat,
|
fee_limit_msat=fee_limit_msat,
|
||||||
timeout_seconds=30,
|
timeout_seconds=30,
|
||||||
no_inflight_updates=True,
|
no_inflight_updates=True,
|
||||||
max_parts=16,
|
|
||||||
time_pref=0.9,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
res: Payment = await self.router_rpc.SendPaymentV2(req).read()
|
res: Payment = await self.router_rpc.SendPaymentV2(req).read()
|
||||||
|
|||||||
@@ -90,6 +90,61 @@ async def test_cache_pop_expired_returns_default(cache):
|
|||||||
assert cache.pop(key, default="fallback") == "fallback"
|
assert cache.pop(key, default="fallback") == "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cache_coro_stale_returns_immediately(cache):
|
||||||
|
"""Stale entry is served immediately; background refresh updates the value."""
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def test():
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return calls
|
||||||
|
|
||||||
|
# cold start
|
||||||
|
result = await cache.save_result(test, key="test", expiry=0.01)
|
||||||
|
assert result == 1
|
||||||
|
|
||||||
|
# let the entry expire
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
# stale-while-revalidate: returns old value immediately
|
||||||
|
result = await cache.save_result(test, key="test", expiry=0.5)
|
||||||
|
assert result == 1 # stale value returned, not the new one
|
||||||
|
|
||||||
|
# allow background refresh to complete
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert calls == 2
|
||||||
|
# now the cache has the fresh value
|
||||||
|
result = await cache.save_result(test, key="test", expiry=0.5)
|
||||||
|
assert result == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cache_coro_no_stampede(cache):
|
||||||
|
"""Multiple concurrent requests on a stale entry spawn only one refresh."""
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def slow_fetch():
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
await cache.save_result(slow_fetch, key="test", expiry=0.01)
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
# fire multiple concurrent requests while stale
|
||||||
|
results = await asyncio.gather(
|
||||||
|
cache.save_result(slow_fetch, key="test", expiry=0.5),
|
||||||
|
cache.save_result(slow_fetch, key="test", expiry=0.5),
|
||||||
|
cache.save_result(slow_fetch, key="test", expiry=0.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1) # let the single background task finish
|
||||||
|
assert all(r == 1 for r in results) # all got stale value
|
||||||
|
assert calls == 2 # cold start + exactly one background refresh
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_invalidate_forever_logs_and_recovers_from_errors(
|
async def test_invalidate_forever_logs_and_recovers_from_errors(
|
||||||
settings: Settings, mocker: MockerFixture
|
settings: Settings, mocker: MockerFixture
|
||||||
|
|||||||
Reference in New Issue
Block a user