mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
fix(extensions): restore URL globals for provider link resolution
This commit is contained in:
@@ -8,6 +8,8 @@ the Rust backend. Retain this notice in source and binary distributions.
|
||||
The artwork resampling in `crates/core/src/cover.rs` also follows the
|
||||
ApproxBiLinear pixel-center and premultiplied-alpha calculations from
|
||||
`golang.org/x/image/draw`, under the same Go Authors BSD license below.
|
||||
URL reference parsing and serialization in `crates/network/src/url.rs` follow
|
||||
`net/url`, also under that license.
|
||||
|
||||
Copyright 2009 The Go Authors.
|
||||
Copyright 2014 The Go Authors. All rights reserved.
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(crate) fn register<'js>(
|
||||
services: &ExtensionServices,
|
||||
) -> rquickjs::Result<Object<'js>> {
|
||||
let host = Object::new(ctx.clone())?;
|
||||
crate::url_host::register(ctx, &host)?;
|
||||
crate::utility_host::register(ctx, &host, Arc::clone(&control), services)?;
|
||||
crate::legacy_host::register(ctx, &host, Arc::clone(&control), services)?;
|
||||
let item_control = Arc::clone(&control);
|
||||
|
||||
@@ -30,6 +30,7 @@ mod session_host;
|
||||
pub mod signed_session;
|
||||
pub mod storage;
|
||||
pub mod transfer_policy;
|
||||
mod url_host;
|
||||
mod utility_host;
|
||||
|
||||
pub use runtime::{ExtensionError, ExtensionRuntime, ExtensionServices, RuntimeLimits};
|
||||
|
||||
@@ -67,6 +67,46 @@
|
||||
}
|
||||
return goString(value);
|
||||
}
|
||||
function queryMethods(values, mutable) {
|
||||
const result = {};
|
||||
for (const method of mutable ? ["append", "delete", "get", "getAll", "has", "set"] : ["get", "getAll", "has"]) {
|
||||
if (method === "append" || method === "set" || method === "delete") {
|
||||
result[method] = function(key, value) {
|
||||
if (arguments.length >= (method === "delete" ? 1 : 2)) {
|
||||
values.write(method, goString(key), method === "delete" ? "" : goString(value));
|
||||
}
|
||||
};
|
||||
} else {
|
||||
result[method] = function(key) {
|
||||
if (!arguments.length) return method === "getAll" ? [] : method === "has" ? false : null;
|
||||
return values.read(method, goString(key));
|
||||
};
|
||||
}
|
||||
}
|
||||
result.toString = function() { return values.encode(); };
|
||||
return result;
|
||||
}
|
||||
globalThis.URL = function URL(input, base) {
|
||||
if (!new.target) throw new TypeError("URL requires new");
|
||||
if (!arguments.length) { this.href = ""; return; }
|
||||
const parsed = host.parseURL(goString(input), base === undefined ? undefined : goString(base));
|
||||
Object.assign(this, parsed);
|
||||
if (parsed.searchParams) {
|
||||
this.searchParams = queryMethods(parsed.searchParams, false);
|
||||
this.toString = this.toJSON = function() { return parsed.href; };
|
||||
}
|
||||
};
|
||||
globalThis.URLSearchParams = function URLSearchParams(init) {
|
||||
if (!new.target) throw new TypeError("URLSearchParams requires new");
|
||||
const values = host.parseQuery(typeof init === "string" ? goString(init).replace(/^\?/, "") : "");
|
||||
const boxed = [boxedStringValue, boxedNumberValue, boxedBooleanValue].some(valueOf => {
|
||||
try { stringApply(valueOf, init, []); return true; } catch (_) { return false; }
|
||||
});
|
||||
if (isMap(init) && !boxed) {
|
||||
for (const key of Object.keys(init)) values.write("set", key.toWellFormed(), formatGo(init[key]));
|
||||
}
|
||||
Object.assign(this, queryMethods(values, true));
|
||||
};
|
||||
const byteArrays = new WeakSet();
|
||||
const responseByteArrays = new WeakMap();
|
||||
function emptyBytes() {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
use crate::host::decode_go_utf8;
|
||||
use rquickjs::{Ctx, Function, IntoJs, Object, Value};
|
||||
use spotiflac_network::{
|
||||
query,
|
||||
url::{UrlParts, unescape_path},
|
||||
};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
pub(crate) fn register<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> {
|
||||
host.set("parseURL", Function::new(ctx.clone(), parse_url)?)?;
|
||||
host.set("parseQuery", Function::new(ctx.clone(), query_object)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_url<'js>(
|
||||
ctx: Ctx<'js>,
|
||||
input: String,
|
||||
base: Option<String>,
|
||||
) -> rquickjs::Result<Object<'js>> {
|
||||
let object = Object::new(ctx.clone())?;
|
||||
let parsed = base
|
||||
.as_deref()
|
||||
.and_then(UrlParts::parse)
|
||||
.and_then(|base| base.resolve_reference(&input))
|
||||
.or_else(|| UrlParts::parse(&input));
|
||||
let Some(parsed) = parsed else {
|
||||
object.set("href", input)?;
|
||||
return Ok(object);
|
||||
};
|
||||
object.set("href", parsed.reference_string())?;
|
||||
object.set("protocol", format!("{}:", parsed.scheme))?;
|
||||
object.set("host", decode_go_utf8(&parsed.host))?;
|
||||
let host_end = parsed.host.len() - parsed.port.as_ref().map_or(0, |port| port.len() + 1);
|
||||
let hostname = &parsed.host[..host_end];
|
||||
let hostname = hostname
|
||||
.strip_prefix(b"[")
|
||||
.and_then(|host| host.strip_suffix(b"]"))
|
||||
.unwrap_or(hostname);
|
||||
object.set("hostname", decode_go_utf8(hostname))?;
|
||||
object.set("port", parsed.port.as_deref().unwrap_or_default())?;
|
||||
object.set(
|
||||
"pathname",
|
||||
if parsed.raw_path.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
decode_go_utf8(&parsed.path)
|
||||
},
|
||||
)?;
|
||||
object.set(
|
||||
"search",
|
||||
if parsed.raw_query.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("?{}", parsed.raw_query)
|
||||
},
|
||||
)?;
|
||||
let fragment = decode_go_utf8(&unescape_path(&parsed.fragment).unwrap_or_default());
|
||||
object.set(
|
||||
"hash",
|
||||
if fragment.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("#{fragment}")
|
||||
},
|
||||
)?;
|
||||
object.set(
|
||||
"origin",
|
||||
format!("{}://{}", parsed.scheme, decode_go_utf8(&parsed.host)),
|
||||
)?;
|
||||
object.set("username", decode_go_utf8(&parsed.username))?;
|
||||
object.set(
|
||||
"password",
|
||||
decode_go_utf8(parsed.password.as_deref().unwrap_or_default()),
|
||||
)?;
|
||||
object.set("searchParams", query_object(ctx, parsed.raw_query)?)?;
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
fn query_object<'js>(ctx: Ctx<'js>, input: String) -> rquickjs::Result<Object<'js>> {
|
||||
let object = Object::new(ctx.clone())?;
|
||||
let values = Rc::new(RefCell::new(query::parse(&input)));
|
||||
let read = Rc::clone(&values);
|
||||
object.set(
|
||||
"read",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |ctx: Ctx<'js>, method: String, key: String| {
|
||||
let values = read.borrow();
|
||||
let found = values.get(key.as_bytes());
|
||||
match method.as_str() {
|
||||
"has" => found.is_some().into_js(&ctx),
|
||||
"getAll" => match found {
|
||||
Some(values) => values
|
||||
.iter()
|
||||
.map(|value| decode_go_utf8(value))
|
||||
.collect::<Vec<_>>()
|
||||
.into_js(&ctx),
|
||||
None => Ok(Value::new_null(ctx)),
|
||||
},
|
||||
_ => match found
|
||||
.and_then(|values| values.first())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(value) => decode_go_utf8(value).into_js(&ctx),
|
||||
None => Ok(Value::new_null(ctx)),
|
||||
},
|
||||
}
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
let write = Rc::clone(&values);
|
||||
object.set(
|
||||
"write",
|
||||
Function::new(
|
||||
ctx.clone(),
|
||||
move |method: String, key: String, value: String| {
|
||||
let mut values = write.borrow_mut();
|
||||
match method.as_str() {
|
||||
"append" => values
|
||||
.entry(key.into_bytes())
|
||||
.or_default()
|
||||
.push(value.into_bytes()),
|
||||
"set" => query::set(&mut values, &key, &value),
|
||||
_ => {
|
||||
values.remove(key.as_bytes());
|
||||
}
|
||||
}
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
object.set(
|
||||
"encode",
|
||||
Function::new(ctx, move || query::encode(&values.borrow()))?,
|
||||
)?;
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{ExtensionRuntime, RuntimeLimits};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn url_globals_resolve_track_paths_and_album_query_ids() {
|
||||
let runtime = ExtensionRuntime::load(
|
||||
r#"
|
||||
registerExtension({probe() {
|
||||
return [typeof URL, typeof URLSearchParams, ...[
|
||||
"https://music.example.test/tracks/TRACK123/",
|
||||
"https://music.example.test/albums/ALBUM123?trackAsin=TRACK456",
|
||||
"https://music.example.test/artists/ARTIST123"
|
||||
].map(link => {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
if (url.hostname !== "music.example.test") return null;
|
||||
const track = url.searchParams.get("trackAsin");
|
||||
if (track) return track;
|
||||
const match = url.pathname.match(/^\/tracks\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
} catch (_) { return null; }
|
||||
})];
|
||||
}});
|
||||
"#,
|
||||
"{}",
|
||||
RuntimeLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let result: serde_json::Value =
|
||||
serde_json::from_str(&runtime.call("probe", "[]", None, 1000).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
json!(["function", "function", "TRACK123", "TRACK456", null])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_and_query_objects_preserve_legacy_constructor_contracts() {
|
||||
let runtime = ExtensionRuntime::load(r#"
|
||||
registerExtension({probe() {
|
||||
const url = new URL("../a%2Fb?z=2&z=3&empty&bad=%Q&semi=a;b#hi%20there", "https://u:p@EXAMPLE.test:443/x/y");
|
||||
const original = url.toString();
|
||||
url.href = "changed";
|
||||
const params = new URLSearchParams("?z=first&empty=&z=second&raw=%FF&bad=%Q&semi=a;b");
|
||||
const before = [params.get("z"), params.getAll("z"), params.get("empty"), params.has("empty"), params.getAll("missing"), params.getAll(), params.toString()];
|
||||
params.append("z", "last"); params.set("empty", "a b"); params.delete("raw"); params.set("ignored");
|
||||
const object = new URLSearchParams({z: [1, 2], nil: null, ok: true});
|
||||
return [original, url.protocol, url.host, url.hostname, url.port, url.pathname, url.search, url.hash, url.origin, url.username, url.password,
|
||||
url.toString(), JSON.stringify(url), url.searchParams.toString(), typeof url.searchParams.set,
|
||||
before, params.toString(), object.toString(),
|
||||
Object.keys(new URL()), Object.keys(new URL("http://[bad]")),
|
||||
new URL("https://example.test?").toString(), new URL("mailto:a@example.test").toString(),
|
||||
new URL("/a", "broken base").toString(), new URL("https://[fe80::1%25en0]:80/a").host,
|
||||
Object.keys(params), new URLSearchParams(new String("?x=y")).toString(),
|
||||
new URL("https://%FF%E0%A4.test/").hostname,
|
||||
new URLSearchParams("x=\ud800&\udfff=v").toString()];
|
||||
}});
|
||||
"#, "{}", RuntimeLimits::default()).unwrap();
|
||||
let result: serde_json::Value =
|
||||
serde_json::from_str(&runtime.call("probe", "[]", None, 1000).unwrap()).unwrap();
|
||||
let href = "https://u:p@EXAMPLE.test:443/a%2Fb?z=2&z=3&empty&bad=%Q&semi=a;b#hi%20there";
|
||||
assert_eq!(
|
||||
result,
|
||||
json!([
|
||||
href,
|
||||
"https:",
|
||||
"EXAMPLE.test:443",
|
||||
"EXAMPLE.test",
|
||||
"443",
|
||||
"/a/b",
|
||||
"?z=2&z=3&empty&bad=%Q&semi=a;b",
|
||||
"#hi there",
|
||||
"https://EXAMPLE.test:443",
|
||||
"u",
|
||||
"p",
|
||||
href,
|
||||
serde_json::to_string(href).unwrap(),
|
||||
"empty=&z=2&z=3",
|
||||
"undefined",
|
||||
[
|
||||
"first",
|
||||
["first", "second"],
|
||||
null,
|
||||
true,
|
||||
null,
|
||||
[],
|
||||
"empty=&raw=%FF&z=first&z=second"
|
||||
],
|
||||
"empty=a+b&z=first&z=second&z=last",
|
||||
"nil=%3Cnil%3E&ok=true&z=%5B1+2%5D",
|
||||
["href"],
|
||||
["href"],
|
||||
"https://example.test?",
|
||||
"mailto:a@example.test",
|
||||
"/a",
|
||||
"[fe80::1%en0]:80",
|
||||
[
|
||||
"append", "delete", "get", "getAll", "has", "set", "toString"
|
||||
],
|
||||
"",
|
||||
"���.test",
|
||||
"x=%EF%BF%BD&%EF%BF%BD=v"
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct UrlParts {
|
||||
pub scheme: String,
|
||||
pub hostname: String,
|
||||
@@ -14,6 +14,11 @@ pub struct UrlParts {
|
||||
pub fragment: String,
|
||||
pub port: Option<String>,
|
||||
pub has_credentials: bool,
|
||||
pub host: Vec<u8>,
|
||||
pub username: Vec<u8>,
|
||||
pub password: Option<Vec<u8>>,
|
||||
pub opaque: String,
|
||||
pub omit_host: bool,
|
||||
}
|
||||
|
||||
impl UrlParts {
|
||||
@@ -46,6 +51,9 @@ impl UrlParts {
|
||||
let mut hostname = String::new();
|
||||
let mut port = None;
|
||||
let mut has_credentials = false;
|
||||
let mut raw_host = Vec::new();
|
||||
let mut username = Vec::new();
|
||||
let mut password = None;
|
||||
if !rest.starts_with('/') {
|
||||
if !scheme.is_empty() {
|
||||
return Some(Self {
|
||||
@@ -58,6 +66,8 @@ impl UrlParts {
|
||||
fragment: fragment.to_owned(),
|
||||
port,
|
||||
has_credentials,
|
||||
opaque: rest.to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if rest.split('/').next()?.contains(':') {
|
||||
@@ -79,11 +89,20 @@ impl UrlParts {
|
||||
return None;
|
||||
}
|
||||
decode(user, Escape::Path)?;
|
||||
let (name, secret) = user
|
||||
.split_once(':')
|
||||
.map_or((user, None), |(name, secret)| (name, Some(secret)));
|
||||
username = decode(name, Escape::Path)?;
|
||||
password = match secret {
|
||||
Some(secret) => Some(decode(secret, Escape::Path)?),
|
||||
None => None,
|
||||
};
|
||||
host
|
||||
} else {
|
||||
authority
|
||||
};
|
||||
hostname = parse_host(host, scheme)?;
|
||||
raw_host = decode(host, Escape::Path)?;
|
||||
let port_part = if host.starts_with('[') {
|
||||
&host[host.rfind(']')? + 1..]
|
||||
} else {
|
||||
@@ -91,6 +110,7 @@ impl UrlParts {
|
||||
};
|
||||
port = port_part.strip_prefix(':').map(str::to_owned);
|
||||
}
|
||||
let omit_host = !scheme.is_empty() && path.starts_with('/') && !path.starts_with("//");
|
||||
let path = if rest.is_empty() {
|
||||
b"/".to_vec()
|
||||
} else {
|
||||
@@ -106,9 +126,111 @@ impl UrlParts {
|
||||
fragment: fragment.to_owned(),
|
||||
port,
|
||||
has_credentials,
|
||||
host: raw_host,
|
||||
username,
|
||||
password,
|
||||
opaque: String::new(),
|
||||
omit_host,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize a parsed reference without the HTTP layer's credential removal
|
||||
/// or empty-path normalization.
|
||||
pub fn reference_string(&self) -> String {
|
||||
let mut result = String::new();
|
||||
if !self.scheme.is_empty() {
|
||||
result.push_str(&self.scheme);
|
||||
result.push(':');
|
||||
}
|
||||
if !self.opaque.is_empty() {
|
||||
result.push_str(&self.opaque);
|
||||
} else {
|
||||
if (!self.scheme.is_empty() || !self.host.is_empty() || self.has_credentials)
|
||||
&& !(self.omit_host && self.host.is_empty() && !self.has_credentials)
|
||||
{
|
||||
if !self.host.is_empty() || !self.raw_path.is_empty() || self.has_credentials {
|
||||
result.push_str("//");
|
||||
}
|
||||
if self.has_credentials {
|
||||
result.push_str(&escape_component(&self.username, b"$&+,;="));
|
||||
if let Some(password) = &self.password {
|
||||
result.push(':');
|
||||
result.push_str(&escape_component(password, b"$&+,;="));
|
||||
}
|
||||
result.push('@');
|
||||
}
|
||||
result.push_str(&escape_component(&self.host, b"!$&'()*+,;=:[]<>\""));
|
||||
}
|
||||
let path = self.escaped_path();
|
||||
if !path.is_empty() && !path.starts_with('/') && !self.host.is_empty() {
|
||||
result.push('/');
|
||||
}
|
||||
if result.is_empty() && path.split('/').next().is_some_and(|p| p.contains(':')) {
|
||||
result.push_str("./");
|
||||
}
|
||||
result.push_str(&path);
|
||||
}
|
||||
if self.force_query || !self.raw_query.is_empty() {
|
||||
result.push('?');
|
||||
result.push_str(&self.raw_query);
|
||||
}
|
||||
if !self.fragment.is_empty() {
|
||||
result.push('#');
|
||||
if self.fragment.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:[]/%@?".contains(&byte)
|
||||
}) {
|
||||
result.push_str(&self.fragment);
|
||||
} else {
|
||||
result.push_str(&escape_component(
|
||||
&unescape_path(&self.fragment).unwrap_or_default(),
|
||||
b"!$&()*+,/:;=?@",
|
||||
));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Go ResolveReference semantics for JavaScript URL construction, including
|
||||
/// credentials and opaque schemes that cannot be used as HTTP requests.
|
||||
pub fn resolve_reference(&self, reference: &str) -> Option<Self> {
|
||||
let mut target = Self::parse(reference)?;
|
||||
let absolute =
|
||||
!target.scheme.is_empty() || !target.host.is_empty() || target.has_credentials;
|
||||
if target.scheme.is_empty() {
|
||||
target.scheme.clone_from(&self.scheme);
|
||||
}
|
||||
if absolute {
|
||||
if target.opaque.is_empty() {
|
||||
target.raw_path = resolve_path(&target.escaped_path(), "");
|
||||
}
|
||||
} else {
|
||||
if target.raw_path.is_empty() && !target.force_query && target.raw_query.is_empty() {
|
||||
target.raw_query.clone_from(&self.raw_query);
|
||||
if target.fragment.is_empty() {
|
||||
target.fragment.clone_from(&self.fragment);
|
||||
}
|
||||
}
|
||||
if target.raw_path.is_empty() && !self.opaque.is_empty() {
|
||||
target.opaque.clone_from(&self.opaque);
|
||||
} else {
|
||||
target.host.clone_from(&self.host);
|
||||
target.hostname.clone_from(&self.hostname);
|
||||
target.port.clone_from(&self.port);
|
||||
target.has_credentials = self.has_credentials;
|
||||
target.username.clone_from(&self.username);
|
||||
target.password.clone_from(&self.password);
|
||||
let base = if self.opaque.is_empty() {
|
||||
self.escaped_path()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
target.raw_path = resolve_path(&base, &target.escaped_path());
|
||||
}
|
||||
}
|
||||
// URL(base) reparses the resolved string in the legacy runtime.
|
||||
Self::parse(&target.reference_string())
|
||||
}
|
||||
|
||||
pub fn authority(&self) -> String {
|
||||
let host = if self.hostname.contains(':') {
|
||||
format!("[{}]", self.hostname)
|
||||
@@ -222,6 +344,23 @@ impl UrlParts {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unescape_path(input: &str) -> Option<Vec<u8>> {
|
||||
decode(input, Escape::Path)
|
||||
}
|
||||
|
||||
fn escape_component(value: &[u8], reserved: &[u8]) -> String {
|
||||
let mut result = String::with_capacity(value.len());
|
||||
for &byte in value {
|
||||
if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) || reserved.contains(&byte) {
|
||||
result.push(char::from(byte));
|
||||
} else {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(result, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// RFC 3986 dot segments apply to escaped paths. In particular, %2e%2e and
|
||||
// %2f remain escaped rather than becoming browser-style traversal segments.
|
||||
fn resolve_path(base: &str, reference: &str) -> String {
|
||||
|
||||
Reference in New Issue
Block a user