Skip to content

Commit 863ed15

Browse files
committed
chore: Implement handling of relative URLs
1 parent 6bfb510 commit 863ed15

File tree

8 files changed

+114
-30
lines changed

8 files changed

+114
-30
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ categories = ["web-programming"]
1717
cssparser = "0"
1818
kuchiki = "0.8"
1919
attohttpc = "0"
20+
url = "2"
2021

2122
[dev-dependencies]
2223
criterion = ">= 0.1"

python/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Changed
1111

1212
- Skip selectors, that can't be parsed.
13+
- Validate `base_url` to be a valid URL.
1314

1415
### Fixed
1516

python/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ built = { version = "0.4", features = ["chrono"] }
1515

1616
[dependencies]
1717
css-inline = { path = "..", version = "= 0.1.0" }
18+
url = "2"
1819
rayon = "1"
1920
pyo3 = { version = ">= 0.10", features = ["extension-module"] }
2021
pyo3-built = "0.4"

python/src/lib.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@ fn to_pyerr(error: rust_inline::InlineError) -> PyErr {
1515
}
1616
}
1717

18+
struct UrlError(url::ParseError);
19+
20+
impl From<UrlError> for PyErr {
21+
fn from(error: UrlError) -> Self {
22+
exceptions::ValueError::py_err(format!("{}", error.0))
23+
}
24+
}
25+
26+
fn parse_url(url: Option<String>) -> PyResult<Option<url::Url>> {
27+
Ok(if let Some(url) = url {
28+
Some(url::Url::parse(url.as_str()).map_err(UrlError)?)
29+
} else {
30+
None
31+
})
32+
}
33+
1834
/// Customizable CSS inliner.
1935
#[pyclass]
2036
#[text_signature = "(remove_style_tags=False, base_url=None, load_remote_stylesheets=True)"]
@@ -29,15 +45,15 @@ impl CSSInliner {
2945
remove_style_tags: Option<bool>,
3046
base_url: Option<String>,
3147
load_remote_stylesheets: Option<bool>,
32-
) -> Self {
48+
) -> PyResult<Self> {
3349
let options = rust_inline::InlineOptions {
3450
remove_style_tags: remove_style_tags.unwrap_or(false),
35-
base_url,
51+
base_url: parse_url(base_url)?,
3652
load_remote_stylesheets: load_remote_stylesheets.unwrap_or(true),
3753
};
38-
CSSInliner {
54+
Ok(CSSInliner {
3955
inner: rust_inline::CSSInliner::new(options),
40-
}
56+
})
4157
}
4258

4359
/// inline(html)
@@ -70,7 +86,7 @@ fn inline(
7086
) -> PyResult<String> {
7187
let options = rust_inline::InlineOptions {
7288
remove_style_tags: remove_style_tags.unwrap_or(false),
73-
base_url,
89+
base_url: parse_url(base_url)?,
7490
load_remote_stylesheets: load_remote_stylesheets.unwrap_or(true),
7591
};
7692
let inliner = rust_inline::CSSInliner::new(options);
@@ -90,7 +106,7 @@ fn inline_many(
90106
) -> PyResult<Vec<String>> {
91107
let options = rust_inline::InlineOptions {
92108
remove_style_tags: remove_style_tags.unwrap_or(false),
93-
base_url,
109+
base_url: parse_url(base_url)?,
94110
load_remote_stylesheets: load_remote_stylesheets.unwrap_or(true),
95111
};
96112
let inliner = rust_inline::CSSInliner::new(options);

python/tests-py/test_inlining.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,8 @@ def test_no_existing_style(func, kwargs, expected):
5353
def test_inline_many_wrong_type():
5454
with pytest.raises(TypeError):
5555
css_inline.inline_many([1])
56+
57+
58+
def test_invalid_base_url():
59+
with pytest.raises(ValueError):
60+
css_inline.CSSInliner(base_url="foo")

src/lib.rs

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,11 @@ pub mod error;
133133
mod parser;
134134

135135
pub use error::InlineError;
136+
use std::borrow::Cow;
136137
use std::collections::HashMap;
137138
use std::fs::File;
138139
use std::io::{Read, Write};
140+
pub use url::{ParseError, Url};
139141

140142
#[derive(Debug)]
141143
struct Rule<'i> {
@@ -161,7 +163,7 @@ pub struct InlineOptions {
161163
/// Remove "style" tags after inlining
162164
pub remove_style_tags: bool,
163165
/// Used for loading external stylesheets via relative URLs
164-
pub base_url: Option<String>,
166+
pub base_url: Option<Url>,
165167
/// Whether remote stylesheets should be loaded or not
166168
pub load_remote_stylesheets: bool,
167169
}
@@ -245,7 +247,7 @@ impl CSSInliner {
245247
{
246248
if let Some(href) = &link_tag.attributes.borrow().get("href") {
247249
let url = self.get_full_url(href);
248-
let css = self.load_external(url.as_str())?;
250+
let css = self.load_external(url.as_ref())?;
249251
process_css(&document, css.as_str())?;
250252
}
251253
}
@@ -254,20 +256,24 @@ impl CSSInliner {
254256
Ok(())
255257
}
256258

257-
fn get_full_url(&self, href: &str) -> String {
258-
if href.starts_with("//") {
259-
if let Some(base_url) = &self.options.base_url {
260-
if base_url.starts_with("https://") {
261-
format!("https:{}", href)
262-
} else {
263-
format!("http:{}", href)
264-
}
259+
fn get_full_url<'u>(&self, href: &'u str) -> Cow<'u, str> {
260+
// Valid absolute URL
261+
if Url::parse(href).is_ok() {
262+
return Cow::Borrowed(href);
263+
};
264+
if let Some(base_url) = &self.options.base_url {
265+
// Use the same scheme as the base URL
266+
if href.starts_with("//") {
267+
return Cow::Owned(format!("{}:{}", base_url.scheme(), href));
265268
} else {
266-
format!("http:{}", href)
269+
// Not a URL, then it is a relative URL
270+
if let Ok(new_url) = base_url.join(href) {
271+
return Cow::Owned(new_url.to_string());
272+
}
267273
}
268-
} else {
269-
href.to_string()
270-
}
274+
};
275+
// If it is not a valid URL and there is no base URL specified, we assume a local path
276+
Cow::Borrowed(href)
271277
}
272278

273279
fn load_external(&self, url: &str) -> Result<String, InlineError> {

tests/server.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@
22

33
app = Flask(__name__)
44

5-
with open("tests/external.css") as fd:
6-
STYLESHEET = fd.read()
7-
85

96
@app.route("/external.css")
107
def stylesheet():
11-
return STYLESHEET
8+
return "h1 { color: blue; }"
129

1310

1411
if __name__ == "__main__":

tests/test_inlining.rs

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use css_inline::{inline, CSSInliner, InlineOptions};
1+
use css_inline::{inline, CSSInliner, InlineOptions, Url};
22

33
macro_rules! html {
44
($style: expr, $body: expr) => {
@@ -151,11 +151,6 @@ h2 { color: red; }
151151
<h2>Smaller Text</h2>
152152
</body>
153153
</html>"#;
154-
let options = InlineOptions {
155-
remove_style_tags: false,
156-
base_url: None,
157-
load_remote_stylesheets: false,
158-
};
159154
let result = inline(&html).unwrap();
160155
assert!(result.ends_with(
161156
r#"<body>
@@ -192,6 +187,68 @@ h2 { color: red; }
192187
))
193188
}
194189

190+
#[test]
191+
fn remote_network_stylesheet_same_scheme() {
192+
let html = r#"
193+
<html>
194+
<head>
195+
<link href="//127.0.0.1:5000/external.css" rel="stylesheet" type="text/css">
196+
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml">
197+
<style type="text/css">
198+
h2 { color: red; }
199+
</style>
200+
</head>
201+
<body>
202+
<h1>Big Text</h1>
203+
<h2>Smaller Text</h2>
204+
</body>
205+
</html>"#;
206+
let options = InlineOptions {
207+
base_url: Some(Url::parse("http://127.0.0.1:5000").unwrap()),
208+
..Default::default()
209+
};
210+
let inliner = CSSInliner::new(options);
211+
let result = inliner.inline(&html).unwrap();
212+
assert!(result.ends_with(
213+
r#"<body>
214+
<h1 style="color: blue;">Big Text</h1>
215+
<h2 style="color: red;">Smaller Text</h2>
216+
217+
</body></html>"#
218+
))
219+
}
220+
221+
#[test]
222+
fn remote_network_relative_stylesheet() {
223+
let html = r#"
224+
<html>
225+
<head>
226+
<link href="external.css" rel="stylesheet" type="text/css">
227+
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml">
228+
<style type="text/css">
229+
h2 { color: red; }
230+
</style>
231+
</head>
232+
<body>
233+
<h1>Big Text</h1>
234+
<h2>Smaller Text</h2>
235+
</body>
236+
</html>"#;
237+
let options = InlineOptions {
238+
base_url: Some(Url::parse("http://127.0.0.1:5000").unwrap()),
239+
..Default::default()
240+
};
241+
let inliner = CSSInliner::new(options);
242+
let result = inliner.inline(&html).unwrap();
243+
assert!(result.ends_with(
244+
r#"<body>
245+
<h1 style="color: blue;">Big Text</h1>
246+
<h2 style="color: red;">Smaller Text</h2>
247+
248+
</body></html>"#
249+
))
250+
}
251+
195252
#[test]
196253
fn customize_inliner() {
197254
let options = InlineOptions {

0 commit comments

Comments
 (0)