fix: disable HTTP keep-alive and update nginx proxy config
- Set http1_keep_alive(false) on hyper server to prevent connection reuse issues with nginx reverse proxy - Clean up nginx proxy config: remove upstream block, use direct proxy_pass to 127.0.0.1:5678 - Update AppLauncherOverlay and appLauncher store with UI fixes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0cf71c4115
commit
0fb373273a
@ -163,6 +163,7 @@ impl Server {
|
||||
});
|
||||
|
||||
if let Err(e) = Http::new()
|
||||
.http1_keep_alive(false)
|
||||
.serve_connection(stream, service)
|
||||
.with_upgrades()
|
||||
.await
|
||||
|
||||
@ -11,7 +11,7 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self' http://127.0.0.1:* http://localhost:*" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always;
|
||||
|
||||
# AIUI SPA (Chat mode iframe)
|
||||
location /aiui/ {
|
||||
@ -28,7 +28,7 @@ server {
|
||||
proxy_pass http://127.0.0.1:3141/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Connection "";
|
||||
# Connection header managed by nginx default
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
@ -45,7 +45,7 @@ server {
|
||||
proxy_pass https://openrouter.ai/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host openrouter.ai;
|
||||
proxy_set_header Connection "";
|
||||
# Connection header managed by nginx default
|
||||
proxy_ssl_server_name on;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
@ -65,7 +65,7 @@ server {
|
||||
proxy_cache off;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Connection "";
|
||||
# Connection header managed by nginx default
|
||||
}
|
||||
|
||||
# AIUI web search proxy — SearXNG on port 8888
|
||||
@ -108,6 +108,7 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# Connection header managed by nginx default
|
||||
|
||||
# Increase timeout for long-running operations (e.g., Docker image pulls)
|
||||
proxy_connect_timeout 600s;
|
||||
@ -421,7 +422,7 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self' http://127.0.0.1:* http://localhost:*" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src *" always;
|
||||
|
||||
# AIUI SPA (Chat mode iframe)
|
||||
location /aiui/ {
|
||||
@ -436,7 +437,7 @@ server {
|
||||
proxy_pass http://127.0.0.1:3141/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Connection "";
|
||||
# Connection header managed by nginx default
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
@ -456,7 +457,7 @@ server {
|
||||
proxy_cache off;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Connection "";
|
||||
# Connection header managed by nginx default
|
||||
}
|
||||
location /aiui/api/openrouter/ {
|
||||
if ($cookie_session = "") {
|
||||
@ -465,7 +466,7 @@ server {
|
||||
proxy_pass https://openrouter.ai/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host openrouter.ai;
|
||||
proxy_set_header Connection "";
|
||||
# Connection header managed by nginx default
|
||||
proxy_ssl_server_name on;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
@ -506,6 +507,7 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# Connection header managed by nginx default
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
|
||||
@ -77,14 +77,39 @@
|
||||
</Transition>
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
v-if="store.url"
|
||||
v-if="store.url && !iframeBlocked"
|
||||
:key="iframeRefreshKey"
|
||||
:src="store.url"
|
||||
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
|
||||
title="App content"
|
||||
@load="onIframeLoad"
|
||||
@error="onIframeError"
|
||||
/>
|
||||
|
||||
<!-- Iframe blocked fallback -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeBlocked && !iframeLoading" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2v-2m0-8h18M3 8v8m18-8v8" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Can't display in frame</h3>
|
||||
<p class="text-white/50 text-sm mb-6">This app doesn't support embedded viewing.<br>Please open it in a new tab instead.</p>
|
||||
<button
|
||||
@click="openInNewTabAndClose"
|
||||
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open in new tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Payment Confirmation Dialog -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="pendingPayment" class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
@ -158,6 +183,16 @@ const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
const iframeRefreshKey = ref(0)
|
||||
const isRefreshing = ref(false)
|
||||
const iframeLoading = ref(true)
|
||||
const iframeBlocked = ref(false)
|
||||
|
||||
// Timers for iframe load detection
|
||||
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let contentCheckId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
if (contentCheckId) { clearTimeout(contentCheckId); contentCheckId = null }
|
||||
}
|
||||
|
||||
// Wallet connect — payment request state
|
||||
const pendingPayment = ref<PaymentRequest | null>(null)
|
||||
@ -168,7 +203,15 @@ const paymentOrigin = ref('')
|
||||
function refreshIframe() {
|
||||
isRefreshing.value = true
|
||||
iframeLoading.value = true
|
||||
iframeBlocked.value = false
|
||||
clearTimers()
|
||||
iframeRefreshKey.value++
|
||||
loadTimeoutId = setTimeout(() => {
|
||||
if (iframeLoading.value) {
|
||||
iframeLoading.value = false
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
function openInNewTab() {
|
||||
@ -177,11 +220,44 @@ function openInNewTab() {
|
||||
}
|
||||
}
|
||||
|
||||
function openInNewTabAndClose() {
|
||||
openInNewTab()
|
||||
store.close()
|
||||
}
|
||||
|
||||
function onIframeLoad() {
|
||||
injectScrollbarHideIfSameOrigin()
|
||||
isRefreshing.value = false
|
||||
iframeLoading.value = false
|
||||
sendIdentityIfSupported()
|
||||
|
||||
// Clear the load timeout
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
|
||||
// Check iframe content after a brief delay to let the app render
|
||||
contentCheckId = setTimeout(checkIframeContent, 2000)
|
||||
}
|
||||
|
||||
function onIframeError() {
|
||||
clearTimers()
|
||||
iframeLoading.value = false
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
|
||||
/** Check if the iframe loaded meaningful content (same-origin only) */
|
||||
function checkIframeContent() {
|
||||
try {
|
||||
const iframe = iframeRef.value
|
||||
if (!iframe) return
|
||||
const doc = iframe.contentDocument
|
||||
if (!doc) return // Cross-origin — can't check, assume OK
|
||||
const body = doc.body
|
||||
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin: can't access, assume working
|
||||
}
|
||||
}
|
||||
|
||||
/** Apps that support the Archipelago identity protocol (postMessage) */
|
||||
@ -379,10 +455,21 @@ watch(
|
||||
(open) => {
|
||||
if (open) {
|
||||
iframeLoading.value = true
|
||||
iframeBlocked.value = false
|
||||
clearTimers()
|
||||
// Set max load timeout — if iframe never fires load, show fallback
|
||||
loadTimeoutId = setTimeout(() => {
|
||||
if (iframeLoading.value) {
|
||||
iframeLoading.value = false
|
||||
iframeBlocked.value = true
|
||||
}
|
||||
}, 15000)
|
||||
closeBtnRef.value?.focus()
|
||||
} else {
|
||||
isRefreshing.value = false
|
||||
iframeLoading.value = true
|
||||
iframeBlocked.value = false
|
||||
clearTimers()
|
||||
// Clear any pending payment when closing
|
||||
if (pendingPayment.value) {
|
||||
rejectPayment()
|
||||
@ -397,6 +484,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimers()
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('message', onMessage)
|
||||
})
|
||||
|
||||
@ -1,41 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** Apps that must open in new tab instead of iframe.
|
||||
* - DENY apps: always new tab (X-Frame-Options: DENY)
|
||||
* - Redirect apps: new tab on HTTPS (absolute redirects break subpath proxy in iframe)
|
||||
* On HTTP, these load via direct port URL so iframe works fine.
|
||||
/** Apps that set X-Frame-Options or CSP frame-ancestors, blocking iframe embedding.
|
||||
* Verified by checking response headers from each app container.
|
||||
* These always open in a new tab. Other apps load in the iframe overlay.
|
||||
*/
|
||||
function mustOpenInNewTab(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
// Always new tab: X-Frame-Options DENY or subpath fundamentally breaks the app
|
||||
// External sites — third-party cookie/iframe restrictions
|
||||
if (u.hostname.includes('indeehub')) return true
|
||||
// Local apps with X-Frame-Options or CSP frame-ancestors blocking iframes
|
||||
if (
|
||||
u.port === '23000' || // BTCPay (X-Frame-Options: DENY)
|
||||
u.port === '8123' || // Home Assistant (subpath breaks routing)
|
||||
u.port === '8085' || // Nextcloud (subpath breaks CSS/assets)
|
||||
u.port === '2283' // Immich (subpath breaks SPA)
|
||||
u.port === '23000' || // BTCPay — X-Frame-Options: DENY
|
||||
u.port === '3000' || // Grafana — X-Frame-Options: deny
|
||||
u.port === '8082' || // Vaultwarden — X-Frame-Options: SAMEORIGIN + CSP frame-ancestors
|
||||
u.port === '2342' || // PhotoPrism — X-Frame-Options: DENY + CSP frame-ancestors: 'none'
|
||||
u.port === '8085' || // Nextcloud — X-Frame-Options: SAMEORIGIN
|
||||
u.port === '3001' || // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
||||
u.port === '8123' // Home Assistant — X-Frame-Options: SAMEORIGIN
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// On HTTPS, apps with absolute-path redirects break in iframe via proxy
|
||||
if (window.location.protocol === 'https:') {
|
||||
return (
|
||||
u.port === '8096' || // Jellyfin (redirects to /web/index.html)
|
||||
u.port === '9000' || // Portainer (redirects to /timeout.html)
|
||||
u.port === '2342' || // PhotoPrism (redirects to /library/login)
|
||||
u.port === '9980' || // OnlyOffice (redirects to /welcome/)
|
||||
u.port === '3001' || // Uptime Kuma (redirects to /dashboard)
|
||||
u.port === '8175' // Fedimint (redirects to /login)
|
||||
)
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Port → proxy path for apps (nginx strips X-Frame-Options) */
|
||||
/** Port → proxy path for apps (nginx strips X-Frame-Options + avoids mixed content) */
|
||||
const PORT_TO_PROXY: Record<string, string> = {
|
||||
'81': '/app/nginx-proxy-manager/',
|
||||
'3000': '/app/grafana/',
|
||||
@ -65,7 +58,11 @@ const PORT_TO_PROXY: Record<string, string> = {
|
||||
'18081': '/app/nostr-rs-relay/',
|
||||
}
|
||||
|
||||
/** Rewrite to same-origin proxy so iframe can embed (avoids mixed content on HTTPS) */
|
||||
/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content.
|
||||
* On HTTP, direct port URLs are used — they avoid subpath routing issues
|
||||
* (apps' root-relative asset paths like /static/main.js break under /app/xxx/).
|
||||
* On HTTPS, must proxy to avoid mixed-content blocks; nginx also strips X-Frame-Options.
|
||||
*/
|
||||
function toEmbeddableUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
@ -73,8 +70,7 @@ function toEmbeddableUrl(url: string): string {
|
||||
const proxyPath = PORT_TO_PROXY[u.port]
|
||||
const sameHost = u.hostname === window.location.hostname
|
||||
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
|
||||
// Use proxy when: (a) mixed content, or (b) vaultwarden/penpot always (subpath required)
|
||||
if (proxyPath && sameHost && (needsProxy || u.port === '8082' || u.port === '9001')) {
|
||||
if (proxyPath && sameHost && needsProxy) {
|
||||
return `${origin}${proxyPath}`
|
||||
}
|
||||
} catch {
|
||||
@ -90,11 +86,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
||||
const embeddableUrl = toEmbeddableUrl(payload.url)
|
||||
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
||||
window.open(embeddableUrl, '_blank', 'noopener,noreferrer')
|
||||
// New tab: always use direct port URL so app assets load correctly
|
||||
window.open(payload.url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
const embeddableUrl = toEmbeddableUrl(payload.url)
|
||||
previousActiveElement = (document.activeElement as HTMLElement) || null
|
||||
url.value = embeddableUrl
|
||||
title.value = payload.title
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user