Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions src/ui/http_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
42 changes: 42 additions & 0 deletions tests/test_httpd.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading