Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- Internationalization (i18n) support and initial German translation.


## 3.6.2

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/wastebin_server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ hex = "0.4"
hostname = "0.4.0"
http = "1.3"
mime = "0.3"
phf = { version = "0.11", features = ["macros"] }
qrcodegen = "1"
sha2 = "0.11"
serde = { workspace = true }
Expand Down
4 changes: 3 additions & 1 deletion crates/wastebin_server/src/handlers/delete/form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use axum::response::Redirect;

use crate::handlers::extract::{Theme, Uid};
use crate::handlers::html::{ErrorResponse, make_error};
use crate::i18n::Lang;
use crate::{Database, Page};

pub async fn delete(
Expand All @@ -11,14 +12,15 @@ pub async fn delete(
State(page): State<Page>,
Uid(uid): Uid,
theme: Option<Theme>,
lang: Lang,
) -> Result<Redirect, ErrorResponse> {
async {
let id = id.parse()?;
db.delete_for(id, uid).await?;
Ok(Redirect::to("/"))
}
.await
.map_err(|err| make_error(err, page.clone(), theme))
.map_err(|err| make_error(err, page.clone(), theme, lang))
}

#[cfg(test)]
Expand Down
5 changes: 4 additions & 1 deletion crates/wastebin_server/src/handlers/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::Page;
use crate::cache::Key;
use crate::handlers::extract::{Password, Theme};
use crate::handlers::html::{ErrorResponse, PasswordInput, make_error};
use crate::i18n::Lang;
use wastebin_core::db::read::{Data, Entry};
use wastebin_core::db::{self, Database};

Expand All @@ -18,6 +19,7 @@ pub async fn get(
State(db): State<Database>,
State(page): State<Page>,
theme: Option<Theme>,
lang: Lang,
password: Option<Password>,
) -> Result<Response, ErrorResponse> {
async {
Expand All @@ -31,14 +33,15 @@ pub async fn get(
Err(db::Error::NoPassword) => Ok(PasswordInput {
page: page.clone(),
theme: theme.clone(),
lang,
id: key.id.to_string(),
}
.into_response()),
Err(err) => Err(err.into()),
}
}
.await
.map_err(|err| make_error(err, page, theme))
.map_err(|err| make_error(err, page, theme, lang))
}

fn make_content_disposition(filename: &str) -> HeaderValue {
Expand Down
83 changes: 83 additions & 0 deletions crates/wastebin_server/src/handlers/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use serde::Deserialize;

use wastebin_core::crypto;

use crate::i18n::Lang;

/// A safe redirect back to the referer.
///
/// Extracts the `Referer` header and strips it down to just the path (and query string),
Expand Down Expand Up @@ -192,3 +194,84 @@ where
.map(|data| Password(data.password.as_bytes().to_vec().into())))
}
}

/// Map a single language tag (e.g. `en`, `de-AT`) to a supported [`Lang`].
fn lang_from_tag(tag: &str) -> Option<Lang> {
match tag.split('-').next()?.trim() {
"en" | "eN" | "En" | "EN" => Some(Lang::En),
"de" | "dE" | "De" | "DE" => Some(Lang::De),
_ => None,
}
}

/// Pick the best supported language from an `Accept-Language` header value,
/// honoring `q=` weights. Falls back to the default language if nothing
/// matches.
fn lang_from_accept_language(header: &str) -> Lang {
header
.split(',')
.enumerate()
.filter_map(|(idx, entry)| {
let mut parts = entry.split(';');
let tag = parts.next().map(str::trim).filter(|t| !t.is_empty())?;
let lang = lang_from_tag(tag)?;

let q = parts
.find_map(|p| {
let p = p.trim();
p.strip_prefix("q=").or_else(|| p.strip_prefix("Q="))
})
.and_then(|s| s.parse::<f32>().ok())
.unwrap_or(1.0);

// Use position as a tie-breaker so the first listed entry wins
// when weights are equal.
#[expect(clippy::cast_precision_loss)]
let weighted = q - (idx as f32) * 1e-6;
Some((weighted, lang))
})
.max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
.map_or(Lang::default(), |(_, l)| l)
}

impl<S> FromRequestParts<S> for Lang
where
S: Send + Sync,
{
type Rejection = Infallible;

async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(parts
.headers
.get(http::header::ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok())
.map_or_else(Lang::default, lang_from_accept_language))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn picks_highest_q() {
assert_eq!(lang_from_accept_language("en;q=0.5,de;q=0.9"), Lang::De);
}

#[test]
fn defaults_to_english_when_unsupported() {
assert_eq!(lang_from_accept_language("ja,fr;q=0.7"), Lang::En);
}

#[test]
fn handles_region_subtags() {
assert_eq!(lang_from_accept_language("de-AT"), Lang::De);
}

#[test]
fn first_listed_wins_on_tie() {
// Both implicit q=1.0; first listed should win.
assert_eq!(lang_from_accept_language("de,en"), Lang::De);
assert_eq!(lang_from_accept_language("en,de"), Lang::En);
}
}
6 changes: 5 additions & 1 deletion crates/wastebin_server/src/handlers/html/burn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ use crate::cache::Key;
use crate::handlers::extract::Theme;
use crate::handlers::html::qr::{code_from, dark_modules};
use crate::handlers::html::{ErrorResponse, make_error};
use crate::i18n::Lang;
use crate::{Error, Page};

/// GET handler for the burn page.
pub async fn get(
Path(id): Path<String>,
State(page): State<Page>,
theme: Option<Theme>,
lang: Lang,
) -> Result<Burn, ErrorResponse> {
async {
let key: Key = id.parse()?;
Expand All @@ -29,10 +31,11 @@ pub async fn get(
key,
code,
theme: theme.clone(),
lang,
})
}
.await
.map_err(|err| make_error(err, page, theme))
.map_err(|err| make_error(err, page, theme, lang))
}

/// Burn page shown if "burn-after-reading" was selected during insertion.
Expand All @@ -43,6 +46,7 @@ pub(crate) struct Burn {
key: Key,
code: qrcodegen::QrCode,
theme: Option<Theme>,
lang: Lang,
}

impl Burn {
Expand Down
4 changes: 4 additions & 0 deletions crates/wastebin_server/src/handlers/html/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ use askama::Template;
use askama_web::WebTemplate;
use axum::extract::State;

use crate::i18n::Lang;
use crate::{Highlighter, Page, handlers::extract::Theme};

/// GET handler for the index page.
pub async fn get(
State(page): State<Page>,
State(highlighter): State<Highlighter>,
theme: Option<Theme>,
lang: Lang,
) -> Index {
Index {
page,
theme,
lang,
highlighter,
}
}
Expand All @@ -23,5 +26,6 @@ pub async fn get(
pub(crate) struct Index {
page: Page,
theme: Option<Theme>,
lang: Lang,
highlighter: Highlighter,
}
12 changes: 11 additions & 1 deletion crates/wastebin_server/src/handlers/html/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ use axum::http::StatusCode;

use crate::Page;
use crate::handlers::extract::Theme;
use crate::i18n::Lang;

/// Error page showing a message.
#[derive(Template, WebTemplate)]
#[template(path = "error.html")]
pub(crate) struct Error {
pub page: Page,
pub theme: Option<Theme>,
pub lang: Lang,
pub description: String,
}

Expand All @@ -26,6 +28,7 @@ pub(crate) struct Error {
pub(crate) struct PasswordInput {
pub page: Page,
pub theme: Option<Theme>,
pub lang: Lang,
pub id: String,
}

Expand All @@ -35,6 +38,7 @@ pub(crate) struct PasswordInput {
pub(crate) struct BurnConfirmation {
pub page: Page,
pub theme: Option<Theme>,
pub lang: Lang,
pub id: String,
pub title: Option<String>,
}
Expand All @@ -44,13 +48,19 @@ pub(crate) type ErrorResponse = (StatusCode, Error);

/// Create an error response from `error` consisting of [`StatusCode`] derive from `error` as well
/// as a rendered page with a description.
pub fn make_error(error: crate::Error, page: Page, theme: Option<Theme>) -> ErrorResponse {
pub fn make_error(
error: crate::Error,
page: Page,
theme: Option<Theme>,
lang: Lang,
) -> ErrorResponse {
let description = error.to_string();
(
error.into(),
Error {
page,
theme,
lang,
description,
},
)
Expand Down
8 changes: 7 additions & 1 deletion crates/wastebin_server/src/handlers/html/paste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use serde::Deserialize;
use crate::cache::{Key, Mode};
use crate::handlers::extract::{Theme, Uid};
use crate::handlers::html::{BurnConfirmation, ErrorResponse, PasswordInput, make_error};
use crate::i18n::Lang;
use crate::{Cache, Database, Highlighter, Page};
use wastebin_core::crypto::Password;
use wastebin_core::db;
Expand All @@ -33,6 +34,7 @@ pub(crate) struct Paste {
page: Page,
key: Key,
theme: Option<Theme>,
lang: Lang,
can_delete: bool,
/// If the paste still in the database and can be fetched with another request.
is_available: bool,
Expand All @@ -58,6 +60,7 @@ pub async fn get<E>(
Path(id): Path<String>,
uid: Option<Uid>,
theme: Option<Theme>,
lang: Lang,
form: Result<Form<PasteForm>, E>,
) -> Result<Response, ErrorResponse> {
async {
Expand All @@ -80,6 +83,7 @@ pub async fn get<E>(
return Ok(BurnConfirmation {
page: page.clone(),
theme: theme.clone(),
lang,
id,
title: metadata.title.clone(),
}
Expand All @@ -93,6 +97,7 @@ pub async fn get<E>(
return Ok(PasswordInput {
page: page.clone(),
theme: theme.clone(),
lang,
id,
}
.into_response());
Expand Down Expand Up @@ -134,6 +139,7 @@ pub async fn get<E>(
page: page.clone(),
key,
theme: theme.clone(),
lang,
can_delete,
is_available,
expiration,
Expand All @@ -145,7 +151,7 @@ pub async fn get<E>(
Ok(paste.into_response())
}
.await
.map_err(|err| make_error(err, page, theme))
.map_err(|err| make_error(err, page, theme, lang))
}

#[cfg(test)]
Expand Down
6 changes: 5 additions & 1 deletion crates/wastebin_server/src/handlers/html/qr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::cache::Key;
use crate::handlers::extract::{Theme, Uid};
use crate::handlers::html::paste::is_markdown_ext;
use crate::handlers::html::{ErrorResponse, make_error};
use crate::i18n::Lang;
use crate::{Error, Page};
use wastebin_core::db::Database;
use wastebin_core::db::read::Metadata;
Expand All @@ -20,6 +21,7 @@ pub async fn get(
State(db): State<Database>,
uid: Option<Uid>,
theme: Option<Theme>,
lang: Lang,
) -> Result<Qr, ErrorResponse> {
async {
let key: Key = id.parse()?;
Expand Down Expand Up @@ -48,6 +50,7 @@ pub async fn get(
Ok(Qr {
page: page.clone(),
theme: theme.clone(),
lang,
key,
can_delete,
is_available: true,
Expand All @@ -58,7 +61,7 @@ pub async fn get(
})
}
.await
.map_err(|err| make_error(err, page, theme))
.map_err(|err| make_error(err, page, theme, lang))
}

/// Paste view showing the formatted paste as well as a bunch of links.
Expand All @@ -67,6 +70,7 @@ pub async fn get(
pub(crate) struct Qr {
page: Page,
theme: Option<Theme>,
lang: Lang,
key: Key,
can_delete: bool,
is_available: bool,
Expand Down
Loading
Loading