From dfc4c659a694652959d711ecc8586589413303f5 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 10 Oct 2026 10:52:45 -0400 Subject: [PATCH] fix: keep IndeeHub provider injection idempotent for bundled frontends When the IndeeHub container's own index.html already bundles nostr-provider.js, the managed nginx patch no longer injects a sub_filter provider and strips any existing one, staying idempotent either way; the provider cache-bust marker moves to tab-signer-v5. The rental request API now accepts an explicit boolean renewExpired choice, validates it, and forwards it to the dashboard bridge, with a focused provider regression. --- .../src/api/rpc/package/install.rs | 48 ++++++++++++------- neode-ui/public/nostr-provider.js | 3 +- .../__tests__/nativeProviderRequests.test.ts | 23 +++++++++ 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 1609c037..e0f4b7e2 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -74,10 +74,15 @@ async fn local_podman_image_exists(image: &str) -> Result { } } -fn patched_indeedhub_nginx_config(original: &str) -> String { +fn patched_indeedhub_nginx_config(original: &str, inject_provider: bool) -> String { let mut conf = original .lines() - .filter(|line| !line.contains("X-Frame-Options")) + .filter(|line| { + !line.contains("X-Frame-Options") + && (inject_provider + || (!line.contains("sub_filter_once") + && !line.contains("sub_filter ''"))) + }) .collect::>() .join("\n"); conf.push('\n'); @@ -91,21 +96,22 @@ fn patched_indeedhub_nginx_config(original: &str) -> String { location = /sw.js {", ); } - if conf.contains("try_files") && !conf.contains("sub_filter") { + if inject_provider && conf.contains("try_files") && !conf.contains("sub_filter") { conf = conf.replacen( "try_files $uri $uri/ /index.html;", "try_files $uri $uri/ /index.html;\n\ sub_filter_once on;\n\ - sub_filter '' '';", + sub_filter '' '';", 1, ); } conf = conf.replace( "src=\"/nostr-provider.js\"", - "src=\"/nostr-provider.js?v=tab-signer-v4\"", + "src=\"/nostr-provider.js?v=tab-signer-v5\"", ); - conf = conf.replace("tab-signer-v2", "tab-signer-v4"); - conf = conf.replace("tab-signer-v3", "tab-signer-v4"); + conf = conf.replace("tab-signer-v2", "tab-signer-v5"); + conf = conf.replace("tab-signer-v3", "tab-signer-v5"); + conf = conf.replace("tab-signer-v4", "tab-signer-v5"); conf.replace( "proxy_set_header X-Forwarded-Prefix /api;", "proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;", @@ -165,6 +171,11 @@ pub(crate) async fn patch_indeedhub_nostr_provider() { let provider_src = "/opt/archipelago/web-ui/nostr-provider.js"; let provider_dest = format!("{container_root}/usr/share/nginx/html/nostr-provider.js"); + let provider_is_bundled = + tokio::fs::read_to_string(format!("{container_root}/usr/share/nginx/html/index.html")) + .await + .map(|html| html.contains("src=\"/nostr-provider.js")) + .unwrap_or(false); let provider_copied = tokio::fs::metadata(provider_src).await.is_ok() && tokio::process::Command::new("podman") .args([ @@ -189,7 +200,7 @@ pub(crate) async fn patch_indeedhub_nostr_provider() { if let Ok(out) = copy_out { if out.status.success() { if let Ok(original) = tokio::fs::read_to_string(&tmp_path).await { - let conf = patched_indeedhub_nginx_config(&original); + let conf = patched_indeedhub_nginx_config(&original, !provider_is_bundled); if conf != original && tokio::fs::write(&tmp_path, &conf).await.is_ok() { config_copied = tokio::process::Command::new("podman") .args([ @@ -219,7 +230,8 @@ pub(crate) async fn patch_indeedhub_nostr_provider() { } } else if conf == original && conf.contains("location = /nostr-provider.js {") - && conf.contains("src=\"/nostr-provider.js?v=tab-signer-v4\"") + && (provider_is_bundled + || conf.contains("src=\"/nostr-provider.js?v=tab-signer-v5\"")) { config_copied = true; } @@ -2618,19 +2630,23 @@ mod tests { } } "#; - let patched = patched_indeedhub_nginx_config(original); + let patched = patched_indeedhub_nginx_config(original, true); assert!(!patched.contains("X-Frame-Options")); assert!(patched.contains("location = /nostr-provider.js {")); assert!(patched.contains("Cache-Control \"no-cache, no-store, must-revalidate\"")); - assert!(patched.contains("src=\"/nostr-provider.js?v=tab-signer-v4\"")); + assert!(patched.contains("src=\"/nostr-provider.js?v=tab-signer-v5\"")); assert!(patched.contains("X-Forwarded-Prefix $http_x_forwarded_prefix/api")); - assert_eq!(patched_indeedhub_nginx_config(&patched), patched); + assert_eq!(patched_indeedhub_nginx_config(&patched, true), patched); - let previous_broker = patched.replace("tab-signer-v4", "tab-signer-v3"); - let migrated = patched_indeedhub_nginx_config(&previous_broker); - assert!(migrated.contains("tab-signer-v4")); + let previous_broker = patched.replace("tab-signer-v5", "tab-signer-v3"); + let migrated = patched_indeedhub_nginx_config(&previous_broker, true); + assert!(migrated.contains("tab-signer-v5")); assert!(!migrated.contains("tab-signer-v3")); - assert_eq!(patched_indeedhub_nginx_config(&migrated), migrated); + assert_eq!(patched_indeedhub_nginx_config(&migrated, true), migrated); + + let bundled = patched_indeedhub_nginx_config(&patched, false); + assert!(!bundled.contains("sub_filter")); + assert_eq!(patched_indeedhub_nginx_config(&bundled, false), bundled); } #[test] diff --git a/neode-ui/public/nostr-provider.js b/neode-ui/public/nostr-provider.js index bc75fad7..4bc9d937 100644 --- a/neode-ui/public/nostr-provider.js +++ b/neode-ui/public/nostr-provider.js @@ -439,7 +439,8 @@ start: function (handle, readyId) { return rentalRequest('start', { handle: handle, ready_id: readyId }, 35000); }, request: function (offer, options) { if (!options || options.playbackProtocol !== 2) return Promise.reject(new Error('Playback protocol 2 is required before requesting a rental.')); - return rentalRequest('request', { offer: offer }, 600000, options.signal); + if (options.renewExpired !== undefined && typeof options.renewExpired !== 'boolean') return Promise.reject(new Error('Invalid rental renewal request.')); + return rentalRequest('request', { offer: offer, renewExpired: options.renewExpired === true }, 600000, options.signal); } }; Object.defineProperty(window.archipelagoRental, 'playbackProtocol', { value: 2, writable: false, configurable: false, enumerable: true }); diff --git a/neode-ui/src/composables/__tests__/nativeProviderRequests.test.ts b/neode-ui/src/composables/__tests__/nativeProviderRequests.test.ts index bc8f6114..b2dea100 100644 --- a/neode-ui/src/composables/__tests__/nativeProviderRequests.test.ts +++ b/neode-ui/src/composables/__tests__/nativeProviderRequests.test.ts @@ -38,4 +38,27 @@ describe('native provider on ordinary HTTP node origins',()=>{ await expect(window.archipelagoRental.request({title:'Already closed'},{playbackProtocol:2,signal:stop.signal})).rejects.toThrow('closed') expect(parent.postMessage).not.toHaveBeenCalled() }) + it('validates and forwards the explicit rental renewal choice',async()=>{ + const listeners:((event:unknown)=>void)[]=[] + const parent={postMessage:vi.fn()} + const window:any={top:{},parent,location:{href:'http://node.local:7778/browse',pathname:'/browse',port:'7778',origin:'http://node.local:7778'},addEventListener:(_name:string,handler:(event:unknown)=>void)=>listeners.push(handler)} + const timers=new Set();let count=0 + runInNewContext(source,{window,document:{currentScript:{hasAttribute:()=>true},readyState:'complete'},crypto:{getRandomValues:(bytes:Uint8Array)=>{bytes.fill(++count);return bytes}},Uint8Array,URL,console, + setTimeout:(fn:unknown)=>{timers.add(fn);return fn},clearTimeout:(fn:unknown)=>timers.delete(fn)}) + await expect(window.archipelagoRental.request({title:'Bad'},{playbackProtocol:2,renewExpired:'yes' as unknown as boolean})).rejects.toThrow('Invalid rental renewal request.') + expect(parent.postMessage).not.toHaveBeenCalled() + const renewal=window.archipelagoRental.request({title:'Expired'},{playbackProtocol:2,renewExpired:true}) + const fresh=window.archipelagoRental.request({title:'Fresh'},{playbackProtocol:2,renewExpired:false}) + const omitted=window.archipelagoRental.request({title:'Omitted'},{playbackProtocol:2}) + const messages=parent.postMessage.mock.calls.map(([message])=>message) + expect(messages).toHaveLength(3) + expect(messages[0]).toMatchObject({type:'archipelago-rental-request',action:'request',playbackProtocol:2,renewExpired:true}) + expect(messages[1]).toMatchObject({type:'archipelago-rental-request',action:'request',playbackProtocol:2,renewExpired:false}) + expect(messages[2]).toMatchObject({type:'archipelago-rental-request',action:'request',playbackProtocol:2,renewExpired:false}) + for(const message of messages) for(const receive of listeners) receive({source:parent,origin:'http://node.local',data:{type:'archipelago-rental-response',id:message.id,result:{ok:true}}}) + await expect(renewal).resolves.toEqual({ok:true}) + await expect(fresh).resolves.toEqual({ok:true}) + await expect(omitted).resolves.toEqual({ok:true}) + expect(timers.size).toBe(0) + }) })