diff --git a/changes-entries/negotiation-qs-content-type.txt b/changes-entries/negotiation-qs-content-type.txt new file mode 100644 index 00000000000..03630352a5b --- /dev/null +++ b/changes-entries/negotiation-qs-content-type.txt @@ -0,0 +1,2 @@ + *) mod_negotiation: Do not send the "qs" source quality parameter of the selected + variant in the Content-Type of a response negotiated by MultiViews. PR 53595 diff --git a/modules/mappers/mod_negotiation.c b/modules/mappers/mod_negotiation.c index af96b263867..63cd7f239e6 100644 --- a/modules/mappers/mod_negotiation.c +++ b/modules/mappers/mod_negotiation.c @@ -3068,6 +3068,41 @@ static int handle_map_file(request_rec *r) return OK; } +/* The "qs" parameter of a variant's media type is only its source quality + * for the selection, see get_entry(), and does not belong in the + * Content-Type of the response. Return content_type without it, all + * other parameters are kept as they are. + */ +static const char *strip_source_quality(apr_pool_t *p, + const char *content_type) +{ + const char *line = content_type; + char *stripped; + int found = 0; + + if (!ap_strcasestr(content_type, "qs")) { + return content_type; + } + + stripped = ap_get_token(p, &line, 0); + while (*line == ';') { + const char *param = line++; + const char *name = ap_get_token(p, &line, 1); + apr_size_t len = strcspn(name, "= \t"); + + if (len == 2 && name[len] && !ap_cstr_casecmpn(name, "qs", 2)) { + found = 1; + } + else { + stripped = apr_pstrcat(p, stripped, + apr_pstrmemdup(p, param, line - param), + NULL); + } + } + + return found ? apr_pstrcat(p, stripped, line, NULL) : content_type; +} + static int handle_multi(request_rec *r) { negotiation_state *neg; @@ -3124,6 +3159,14 @@ static int handle_multi(request_rec *r) /* now do a "fast redirect" ... promotes the sub_req into the main req */ ap_internal_fast_redirect(sub_req, r); + if (r->content_type) { + const char *type = strip_source_quality(r->pool, r->content_type); + + if (type != r->content_type) { + ap_set_content_type_ex(r, type, AP_REQUEST_IS_TRUSTED_CT(r)); + } + } + /* give no advise for time on this subrequest. Perhaps we * should tally the last mtime among all variants, and date * the most recent, but that could confuse the proxies. diff --git a/test/pytest_suite/t/conf/extra.conf.in b/test/pytest_suite/t/conf/extra.conf.in index cb3acce9336..4581e41f005 100644 --- a/test/pytest_suite/t/conf/extra.conf.in +++ b/test/pytest_suite/t/conf/extra.conf.in @@ -135,6 +135,15 @@ LanguagePriority zh-TW fr fu en de + + Options +MultiViews + AddType text/html;qs=0.9 .html + AddType application/xhtml+xml;qs=1.0 .xhtml + AddType "text/html;charset=utf-8;qs=0.8" .qsafter + AddType "text/html;QS=0.8;charset=utf-8" .qsbefore + AddType "application/example;foo=\"a;b\";q=0.7;qs=0.5;bar=\"c, d\"" .quoted + + diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/after.qsafter b/test/pytest_suite/t/htdocs/modules/negotiation/qs/after.qsafter new file mode 100644 index 00000000000..294186e497a --- /dev/null +++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/after.qsafter @@ -0,0 +1 @@ +after diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/before.qsbefore b/test/pytest_suite/t/htdocs/modules/negotiation/qs/before.qsbefore new file mode 100644 index 00000000000..90be1f3056c --- /dev/null +++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/before.qsbefore @@ -0,0 +1 @@ +before diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.html b/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.html new file mode 100644 index 00000000000..1936cc1d441 --- /dev/null +++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.html @@ -0,0 +1 @@ +html diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.xhtml b/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.xhtml new file mode 100644 index 00000000000..f723f596fdc --- /dev/null +++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.xhtml @@ -0,0 +1 @@ +xhtml diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/quoted.quoted b/test/pytest_suite/t/htdocs/modules/negotiation/qs/quoted.quoted new file mode 100644 index 00000000000..b04fedbe6c3 --- /dev/null +++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/quoted.quoted @@ -0,0 +1 @@ +quoted diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm1.htm b/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm1.htm new file mode 100644 index 00000000000..1936cc1d441 --- /dev/null +++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm1.htm @@ -0,0 +1 @@ +html diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm2.xht b/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm2.xht new file mode 100644 index 00000000000..f31fc40aa5f --- /dev/null +++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm2.xht @@ -0,0 +1 @@ +xht diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/typemap.var b/test/pytest_suite/t/htdocs/modules/negotiation/qs/typemap.var new file mode 100644 index 00000000000..b8464c88f0c --- /dev/null +++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/typemap.var @@ -0,0 +1,5 @@ +URI: tm1.htm +Content-Type: text/html; qs=0.9 + +URI: tm2.xht +Content-Type: application/xhtml+xml; qs=1.0 diff --git a/test/pytest_suite/tests/t/modules/test_negotiation.py b/test/pytest_suite/tests/t/modules/test_negotiation.py index b56b6218083..69144282aa5 100644 --- a/test/pytest_suite/tests/t/modules/test_negotiation.py +++ b/test/pytest_suite/tests/t/modules/test_negotiation.py @@ -3,12 +3,15 @@ Covers default-language selection, explicit variant requests, Accept-Language obedience (plain + compressed + typemap), quality-rating preferences, a non-existent highest-quality fallback, a typemap query-string case, and -Accept content-type negotiation (with 406 cases). +Accept content-type negotiation (with 406 cases), and the source quality +("qs") of a variant not showing up in the Content-Type of the response. Perl original used ``need_module('negotiation') && need_cgi && need_module('mime')``. """ +import re + import pytest from apache_pytest import need_cgi, need_module, t_cmp @@ -153,3 +156,39 @@ def test_content_type(http, accept, expected): else: assert t_cmp(r.status_code, 406), \ f"expect Not Acceptable for Accept: {accept}" + + +XML = "application/xhtml+xml" + +# "qs" only decides between variants, the other parameters are kept +QS_PARAMS = [ + ("after", "text/html;charset=utf-8"), + ("before", "text/html;charset=utf-8"), + ("quoted", 'application/example;foo="a;b";q=0.7;bar="c, d"'), +] + + +def _content_type(resp): + assert resp.status_code == 200 + return re.sub(r";\s+", ";", resp.headers["content-type"]) + + +@need_module("negotiation", "mime") +@pytest.mark.parametrize("path", ["doc", "typemap.var"]) +@pytest.mark.parametrize("accept,expected", [ + (f"text/html, {XML}", XML), + ("text/html", "text/html"), + (f"text/html, {XML};q=0.5", "text/html"), +]) +def test_source_quality(http, path, accept, expected): + r = http.GET(f"/modules/negotiation/qs/{path}", headers={"Accept": accept}) + assert t_cmp(_content_type(r), expected), \ + "qs selects the variant but is not sent in Content-Type" + + +@need_module("negotiation", "mime") +@pytest.mark.parametrize("name,expected", QS_PARAMS, ids=[n for n, _ in QS_PARAMS]) +def test_source_quality_parameters(http, name, expected): + r = http.GET(f"/modules/negotiation/qs/{name}", headers={"Accept": "*/*"}) + assert t_cmp(_content_type(r), expected), \ + "only qs is removed from the Content-Type"