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.
This commit is contained in:
archipelago
2026-10-10 10:52:45 -04:00
parent 1102566ce4
commit dfc4c659a6
3 changed files with 57 additions and 17 deletions
+32 -16
View File
@@ -74,10 +74,15 @@ async fn local_podman_image_exists(image: &str) -> Result<bool> {
}
}
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 '</head>'")))
})
.collect::<Vec<_>>()
.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 '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v4\"></script></head>';",
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v5\"></script></head>';",
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]
+2 -1
View File
@@ -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 });
@@ -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<unknown>();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)
})
})