Skip to content

Commit

Permalink
Moved language preference cookie into general purpose preference cookie
Browse files Browse the repository at this point in the history
  • Loading branch information
kellpossible committed Jan 16, 2024
1 parent 8c45591 commit 7b6d7a8
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 92 deletions.
64 changes: 9 additions & 55 deletions src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use time::OffsetDateTime;
use crate::{
error::{map_eyre_error, map_std_error},
state::AppState,
user_preferences::UserPreferences,
};

#[derive(RustEmbed)]
Expand Down Expand Up @@ -75,12 +76,6 @@ pub fn negotiate_translated_string<'a>(

pub type I18nLoader = Arc<FluentLanguageLoader>;

// impl FromRef<AppState> for I18nLoader {
// fn from_ref(state: &AppState) -> Self {
// state.i18n
// }
// }

pub fn initialize() -> I18nLoader {
Arc::new(fluent_language_loader!())
}
Expand All @@ -92,64 +87,23 @@ pub fn load_languages(loader: &I18nLoader) -> eyre::Result<()> {
Ok(())
}

#[derive(Deserialize)]
pub struct Query {
lang: unic_langid::LanguageIdentifier,
}

/// The name of the cookie used to store the user selected language.
const LANG_COOKIE_NAME: &str = "lang";
/// The Max-Age property for the cookie (in seconds).
const LANG_COOKIE_MAX_AGE_SECONDS: u64 = 365 * 24 * 60 * 60;

/// Handler to select a language by setting the [`LANG_COOKIE_NAME`] cookie.
pub async fn handler(
axum::extract::Query(query): axum::extract::Query<Query>,
headers: HeaderMap,
) -> axum::response::Result<impl IntoResponse> {
let referer_str = headers
.get("Referer")
.wrap_err("No referer headers")
.map_err(map_eyre_error)?
.to_str()
.wrap_err("Referer is not a valid string")
.map_err(map_eyre_error)?;
let mut response = Redirect::to(referer_str).into_response();
let lang = query.lang;
let value = HeaderValue::from_str(&format!(
"{LANG_COOKIE_NAME}={lang}; Max-Age={LANG_COOKIE_MAX_AGE_SECONDS}"
))
.map_err(map_std_error)?;
response.headers_mut().insert(SET_COOKIE, value);
Ok(response)
}

pub async fn middleware<B>(
State(state): State<AppState>,
headers: HeaderMap,
mut request: Request<B>,
next: Next<B>,
) -> Response {
let cookies = CookieJar::from_headers(request.headers());
let cookie_lang = match Option::transpose(
cookies
.get(LANG_COOKIE_NAME)
.map(|cookie| unic_langid::LanguageIdentifier::from_str(cookie.value())),
) {
Ok(cookie_lang) => cookie_lang,
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unable to parse lang cookie: {error}"),
)
.into_response()
}
};
let preferences: &UserPreferences = request
.extensions()
.get()
.expect("Expected user_preferences middleware to be installed before this middleware");

let accept_language = headers.get("Accept-Language").map(parse_accept_language);
let requested_languages = cookie_lang
let requested_languages = preferences
.lang
.as_ref()
.map(|lang| {
let mut requested_languages = RequestedLanguages(vec![lang]);
let mut requested_languages = RequestedLanguages(vec![lang.clone()]);
if let Some(accept_language) = &accept_language {
requested_languages
.0
Expand Down
5 changes: 4 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ mod serde;
mod state;
mod templates;
mod types;
mod user_preferences;

#[tokio::main]
async fn main() -> eyre::Result<()> {
Expand Down Expand Up @@ -108,7 +109,8 @@ async fn main() -> eyre::Result<()> {

// build our application with a route
let app = Router::new()
.route("/i18n", get(i18n::handler))
// Using a GET request because this supports a redirect.
.route("/user-preferences", get(user_preferences::handler))
.route("/disclaimer", post(disclaimer::handler))
// These routes expose public forecast information and thus have the disclaimer middleware
// applied to them.
Expand Down Expand Up @@ -137,6 +139,7 @@ async fn main() -> eyre::Result<()> {
state.clone(),
i18n::middleware,
))
.layer(middleware::from_fn(user_preferences::middleware))
.layer(middleware::from_fn_with_state(
state.clone(),
analytics::middleware,
Expand Down
37 changes: 2 additions & 35 deletions src/templates/forecast.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{% from "macros/elements.html" import divider %}
{% from "macros/language_select.html" import language_select %}
{% from "macros/forecast_intro.html" import forecast_intro %}
{% from "macros/weather.html" import weather %}
{% macro hazard_rating_number(hazard_value) -%}
{%- if not hazard_value -%}
?
Expand Down Expand Up @@ -211,41 +212,7 @@ <h2 class="text-4xl text-center py-2">
<div class="pb-2 prose max-w-full leading-normal text-black">
{{ translated_string(weather_forecast) | md }}
</div>
{% if is_current %}
{% if "Windy" in weather_maps %}
{% with weather_map = weather_maps.Windy %}
<h3 class="text-3xl text-center py-1">
windy.com
</h3>
<iframe width="100%"
height="450"
src="https://embed.windy.com/embed2.html?lat={{ weather_map.latitude }}&lon={{ weather_map.longitude }}&detailLat={{ weather_map.latitude }}&detailLon={{ weather_map.longitude }}&width=650&height=450&zoom=11&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=true&metricWind=m%2Fs&metricTemp=%C2%B0C&radarRange=-1"
frameborder="0"></iframe>
{% endwith %}
{% endif %}
{% if "Meteoblue" in weather_maps %}
{% with weather_map = weather_maps.Meteoblue %}
<h3 class="text-3xl text-center py-1 pt-2">
<!-- DO NOT REMOVE THIS LINK --><a class="text-blue-600 hover:text-blue-800"
href="https://www.meteoblue.com/en/weather/week/{{ weather_map.location_id }}?utm_source=weather_widget&utm_medium=linkus&utm_content=daily&utm_campaign=Weather%2BWidget"
target="_blank"
rel="noopener">meteoblue</a>
</h3>
<div class="flex items-center justify-center">
<div class="w-full">
<iframe src="https://www.meteoblue.com/en/weather/widget/daily/{{ weather_map.location_id }}?geoloc=fixed&days=7&tempunit=CELSIUS&windunit=METER_PER_SECOND&precipunit=MILLIMETER&coloured=coloured&pictoicon=0&pictoicon=1&maxtemperature=0&maxtemperature=1&mintemperature=0&mintemperature=1&windspeed=0&windspeed=1&windgust=0&winddirection=0&winddirection=1&uv=0&humidity=0&precipitation=0&precipitation=1&precipitationprobability=0&precipitationprobability=1&spot=0&spot=1&pressure=0&layout=light"
frameborder="0"
scrolling="yes"
allowtransparency="true"
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox"
class="w-full md:aspect-[17/10] aspect-[10/10]"></iframe>
<div>
</div>
</div>
</div>
{% endwith %}
{% endif %}
{% endif %}
{% if is_current %}{{ weather(weather_maps) }}{% endif %}
</div>
<div class="pt-4">
<h2 class="text-4xl text-center">
Expand Down
2 changes: 1 addition & 1 deletion src/templates/macros/language_select.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
name="lang"
autocomplete="off"
class="p-2 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
onchange="window.location.replace(`/i18n?lang=${this.value}`)">
onchange="window.location.replace(`/user-preferences?lang=${this.value}`)">
<option value="en-US"
{% if LANGUAGE == "en-US" %}selected="selected"{% endif %}>
🌍 English
Expand Down
32 changes: 32 additions & 0 deletions src/templates/macros/weather.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{% macro weather(weather_maps) %}
{% if "Windy" in weather_maps %}
{% with weather_map = weather_maps.Windy %}
<h3 class="text-3xl text-center py-1">windy.com</h3>
<iframe width="100%"
height="450"
src="https://embed.windy.com/embed2.html?lat={{ weather_map.latitude }}&lon={{ weather_map.longitude }}&detailLat={{ weather_map.latitude }}&detailLon={{ weather_map.longitude }}&width=650&height=450&zoom=11&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=true&metricWind=m%2Fs&metricTemp=%C2%B0C&radarRange=-1"
frameborder="0"></iframe>
{% endwith %}
{% endif %}
{% if "Meteoblue" in weather_maps %}
{% with weather_map = weather_maps.Meteoblue %}
<h3 class="text-3xl text-center py-1 pt-2">
<!-- DO NOT REMOVE THIS LINK --><a class="text-blue-600 hover:text-blue-800"
href="https://www.meteoblue.com/en/weather/week/{{ weather_map.location_id }}?utm_source=weather_widget&utm_medium=linkus&utm_content=daily&utm_campaign=Weather%2BWidget"
target="_blank"
rel="noopener">meteoblue</a>
</h3>
<div class="flex items-center justify-center">
<div class="w-full">
<iframe src="https://www.meteoblue.com/en/weather/widget/daily/{{ weather_map.location_id }}?geoloc=fixed&days=7&tempunit=CELSIUS&windunit=METER_PER_SECOND&precipunit=MILLIMETER&coloured=coloured&pictoicon=0&pictoicon=1&maxtemperature=0&maxtemperature=1&mintemperature=0&mintemperature=1&windspeed=0&windspeed=1&windgust=0&winddirection=0&winddirection=1&uv=0&humidity=0&precipitation=0&precipitation=1&precipitationprobability=0&precipitationprobability=1&spot=0&spot=1&pressure=0&layout=light"
frameborder="0"
scrolling="yes"
allowtransparency="true"
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox"
class="w-full md:aspect-[17/10] aspect-[10/10]"></iframe>
<div></div>
</div>
</div>
{% endwith %}
{% endif %}
{% endmacro %}
83 changes: 83 additions & 0 deletions src/user_preferences.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use axum::{
extract::Query,
middleware::Next,
response::{IntoResponse, Redirect, Response},
Extension,
};
use axum_extra::extract::CookieJar;
use base64::Engine;
use eyre::{Context, ContextCompat};
use http::{header::SET_COOKIE, HeaderMap, HeaderValue, Request, StatusCode};
use serde::{Deserialize, Serialize};

use crate::error::{map_eyre_error, map_std_error};

#[derive(Serialize, Deserialize, Default, Clone)]
#[serde(default)]
pub struct UserPreferences {
pub lang: Option<unic_langid::LanguageIdentifier>,
}

impl UserPreferences {
/// Merge right into left, skipping any fields that are `None` on right.
fn merge(mut left: Self, right: Self) -> Self {
if right.lang.is_some() {
left.lang = right.lang;
}

left
}
}

const COOKIE_NAME: &str = "preferences";
/// The Max-Age property for the cookie (in seconds).
const COOKIE_MAX_AGE_SECONDS: u64 = 365 * 24 * 60 * 60;

/// Handler for setting user preferences using a query, and redirecting to the referrer URL
/// provided in the request. This merges with what has currently been set.
pub async fn handler(
Query(set_preferences): Query<UserPreferences>,
Extension(current_preferences): Extension<UserPreferences>,
headers: HeaderMap,
) -> axum::response::Result<impl IntoResponse> {
let referer_str = headers
.get("Referer")
.wrap_err("No referer headers")
.map_err(map_eyre_error)?
.to_str()
.wrap_err("Referer is not a valid string")
.map_err(map_eyre_error)?;
let mut response = Redirect::to(referer_str).into_response();

let preferences = UserPreferences::merge(current_preferences, set_preferences);
let preferences_data = serde_urlencoded::to_string(preferences)
.context("Error serializing preferences")
.map_err(map_eyre_error)?;
let value = HeaderValue::from_str(&format!(
"{COOKIE_NAME}={preferences_data}; Max-Age={COOKIE_MAX_AGE_SECONDS}"
))
.map_err(map_std_error)?;
response.headers_mut().insert(SET_COOKIE, value);
Ok(response)
}

/// Middleware for extracting user preferences from cookie that was set using [`set_handler`].
pub async fn middleware<B>(mut request: Request<B>, next: Next<B>) -> Response {
let cookies = CookieJar::from_headers(request.headers());
let preferences: UserPreferences =
match Option::transpose(cookies.get(COOKIE_NAME).map(|cookie| {
serde_urlencoded::from_str(cookie.value()).context("Error deserializing preferences")
})) {
Ok(preferences) => preferences.unwrap_or_default(),
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unable to parse preferences cookie: {error}"),
)
.into_response()
}
};

request.extensions_mut().insert(preferences);
next.run(request).await
}

0 comments on commit 7b6d7a8

Please sign in to comment.