From 4e0b7e1d168744438f0602735c4fee3970f3caf1 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Mon, 7 Sep 2026 05:05:27 +0000 Subject: [PATCH] fix(ui): rank Accept-Language tags by q-value instead of substring scan detect_ui_lang() (src/ui/http_server.c) picked the UI language with strstr(accept_language, "zh"), so any Chinese tag anywhere in the header served the Chinese UI regardless of position or q-value. A bilingual header whose top preference is English (e.g. en-US;q=0.9,zh;q=0.5) still got Chinese. Per RFC 9110 12.5.4, parse each tag's q-value (default 1.0), treat q=0 as explicitly unacceptable (12.5.1), and return the highest-ranked tag whose base language is zh or en, keeping the first occurrence on a tie. This mirrors the UI bundle's own JT() fallback parser, quoted in the issue, which already implements this correctly as a client-side fallback. Fixes #1829 Signed-off-by: Amir Fathi --- src/ui/http_server.c | 59 +++++++++++++++++++++++++++++++++++++++++--- tests/test_httpd.c | 42 +++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/ui/http_server.c b/src/ui/http_server.c index f47ac59be..66a8a0fdf 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -122,11 +122,64 @@ static void update_cors(const cbm_http_req_t *req, int port) { snprintf(g_cors_json, sizeof(g_cors_json), "%sContent-Type: application/json\r\n", g_cors); } +/* RFC 9110 §12.5.4: rank Accept-Language tags by q-value (default 1.0, + * q=0 excluded per §12.5.1) and return the top-ranked "zh"/"en" tag; ties + * keep whichever came first. */ static const char *detect_ui_lang(const char *accept_language) { - if (accept_language && (strstr(accept_language, "zh-CN") || strstr(accept_language, "zh"))) { - return "zh"; + if (!accept_language) { + return "en"; } - return "en"; + + char buf[CBM_SZ_256]; + snprintf(buf, sizeof(buf), "%s", accept_language); + + const char *best_tag = NULL; + double best_q = 0.0; /* only a strictly positive q is acceptable */ + char *cursor = buf; + + while (cursor) { + char *comma = strchr(cursor, ','); + if (comma) { + *comma = '\0'; + } + + char *entry = cursor; + while (*entry == ' ' || *entry == '\t') { + entry++; + } + + double q = 1.0; + char *semi = strchr(entry, ';'); + if (semi) { + *semi = '\0'; + char *qpos = strstr(semi + 1, "q="); + if (qpos) { + q = strtod(qpos + 2, NULL); + } + } + + size_t len = strlen(entry); + while (len > 0 && (entry[len - 1] == ' ' || entry[len - 1] == '\t')) { + entry[--len] = '\0'; + } + + if (len >= 2 && q > best_q) { + char base0 = (char)tolower((unsigned char)entry[0]); + char base1 = (char)tolower((unsigned char)entry[1]); + bool base_ends = len == 2 || entry[2] == '-'; + if (base_ends && base0 == 'z' && base1 == 'h') { + best_q = q; + best_tag = "zh"; + } else if (base_ends && base0 == 'e' && base1 == 'n') { + best_q = q; + best_tag = "en"; + } + } + + cursor = comma ? comma + 1 : NULL; + } + + return best_tag ? best_tag : "en"; } static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) { diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 8cc0c1772..b70355641 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -1637,6 +1637,46 @@ TEST(ui_server_ui_config_prefers_config_lang) { PASS(); } +TEST(ui_server_ui_config_ranks_by_qvalue_not_substring_issue1829) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + + /* English is ranked first (q=0.9 vs zh's q=0.5); a substring scan over + * the raw header would still match "zh" and serve Chinese. */ + char resp[4096]; + int n = th_http(cbm_http_server_port(ts.srv), + "GET /api/ui-config HTTP/1.1\r\n" + "Accept-Language: en-US,en;q=0.9,zh;q=0.5\r\n" + "\r\n", + resp, sizeof(resp)); + ASSERT_TRUE(n > 0); + ASSERT_EQ(th_status(resp), 200); + ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"en\"")); + + th_server_stop(&ts); + PASS(); +} + +TEST(ui_server_ui_config_treats_qzero_as_unacceptable_issue1829) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + + /* q=0 means "not acceptable" (RFC 9110 12.5.1), so English must win even + * though zh appears first in the header. */ + char resp[4096]; + int n = th_http(cbm_http_server_port(ts.srv), + "GET /api/ui-config HTTP/1.1\r\n" + "Accept-Language: zh;q=0, en\r\n" + "\r\n", + resp, sizeof(resp)); + ASSERT_TRUE(n > 0); + ASSERT_EQ(th_status(resp), 200); + ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"en\"")); + + th_server_stop(&ts); + PASS(); +} + TEST(ui_server_slow_request_hits_deadline) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); @@ -2426,6 +2466,8 @@ SUITE(httpd) { RUN_TEST(ui_server_ui_config_detects_zh_accept_language); RUN_TEST(ui_server_ui_config_includes_serving_version_issue1820); RUN_TEST(ui_server_ui_config_prefers_config_lang); + RUN_TEST(ui_server_ui_config_ranks_by_qvalue_not_substring_issue1829); + RUN_TEST(ui_server_ui_config_treats_qzero_as_unacceptable_issue1829); RUN_TEST(ui_server_slow_request_hits_deadline); RUN_TEST(ui_server_access_log_redacts_query); RUN_TEST(ui_server_stop_joins_cleanly);