Route: switch_lang

/op/switch_lang/<code> — the dedicated language-switch endpoint. Lives in src/routes/switch_lang.rs. Unlike the verb-mode routes, this is a real registered endpoint (not a dispatch branch), and it has its own URL shape.

Endpoint pattern

endpoint! {
    APP.url("/op/switch_lang/<lang>"),
    pub switch_lang <HTTP> { /* ... */ }
}

GET only — any other method returns 405 Method not allowed. There is no auth gate; switching language is a public action available to anonymous visitors.

Inputs

  • <code> path parameter — the target language. Run through normalize_lang, which lowercases, trims, and confirms membership in content::support_langs(). Unrecognized codes fall back to sfx::op::default_lang().
  • from query parameter (preferred) — the source URL the visitor was viewing.
  • Referer header (fallback) — used when from is absent or empty. Both referer and referrer spellings are accepted.

The source is resolved by switch_source(req):

if let Some(from) = req.query("from") { /* if non-empty, use it */ }
if let Some(referer) = req.header_str("referer").or_else(|| req.header_str("referrer")) {
    /* if non-empty, use it */
}
"/".to_string()

from wins because it is set explicitly by the language-switcher widget; Referer is the graceful fallback for bookmarklets, direct hits, and anything else that doesn’t pass from.

Algorithm

  1. Extract path. normalize_source_url accepts either an absolute URL or a path. If it starts with /, take it verbatim. If it has a ://, extract the path component after the host. Anything else collapses to /.

  2. Strip the current lang prefix. strip_supported_lang_prefix walks content::support_langs() and removes a leading /<lang>/ (or bare /<lang>) if present. This is what prevents prefix stacking — switching from /zh/policies/ to JA must produce /ja/policies/, not /ja/zh/policies/.

  3. Open-redirect hardening. After path extraction:

    if path.is_empty() || path.starts_with("//") {
        "/".to_string()
    } else {
        path
    }
    

    Both ?from=//evil.com/... and Referer: http://host//evil.com/... previously slipped through: the host-stripping step left //evil.com/... as the “path,” and emitting Location: //evil.com/... would let a browser navigate off-origin (a network-path reference, classic open-redirect vector). The final-step rejection catches both routes regardless of which input fed them.

  4. Apply target prefix. Empty ("") for the default language, /<code> otherwise:

    let target_path = if target_lang == sfx::op::default_lang() {
        stripped
    } else {
        format!("/{}{}", target_lang, stripped)
    };
    format!("{}{}", target_path, query)
    

    Query strings are preserved verbatim — switching language on /foo/?view=download lands the visitor on /zh/foo/?view=download.

Cookie

The response sets a lang cookie:

.add_cookie(
    "lang",
    Cookie::new(target_lang)
        .path("/")
        .http_only(true)
)
.add_header("Cache-Control", "no-store")

HttpOnly because no client JS needs to read it. Cache-Control: no-store because the response body is a 302 whose target depends on Set-Cookie — caching it (intermediate proxy or browser back/forward cache) would mean a subsequent visit replays the redirect without storing the cookie, defeating the whole switch.

Response

302 Found to the rewritten URL. No body of consequence; the browser follows the Location header and the next request carries both the new URL and the new cookie.

Why the cookie matters

Visitors on bare URLs (the default-lang URL scheme) have no /<code> segment for Route: view (dispatch) to read, so language resolution falls back to sfx::op::lang_or_none(req), which consults the cookie. Without it, every bare-URL hit serves the default language — pressing the JA switcher on /foo/ would land you on /foo/ again with the default content, because there’s no signal anywhere telling the renderer “this user wants Japanese.”

The cookie also makes language sticky across sessions: a visitor who picks JA once gets JA content on every later bare-URL visit until they switch again. See Language system for the full resolution order (prefix > cookie > default).

Why prefix-stripping matters

Without strip_supported_lang_prefix, switching from a non-default language to another non-default language would stack prefixes:

  • Visitor on /zh/policies/ clicks “JA”
  • Without stripping: target becomes /ja + /zh/policies/ = /ja/zh/policies/ (a 404, because no such route exists)
  • With stripping: target becomes /ja + /policies/ = /ja/policies/

The strip step normalizes the source path to its bare form before applying the new prefix, so the switch is symmetric regardless of where the visitor started.

Cross-links

For the full language resolution stack (prefix > cookie > default), the build-generated /<lang>/<**path> endpoints, and how lang_for(req) decides which .md slot to render: Language system.