@@ -12,6 +12,8 @@ use serde::Deserialize;
1212
1313use wastebin_core:: crypto;
1414
15+ use crate :: i18n:: Lang ;
16+
1517/// A safe redirect back to the referer.
1618///
1719/// Extracts the `Referer` header and strips it down to just the path (and query string),
@@ -192,3 +194,84 @@ where
192194 . map ( |data| Password ( data. password . as_bytes ( ) . to_vec ( ) . into ( ) ) ) )
193195 }
194196}
197+
198+ /// Map a single language tag (e.g. `en`, `de-AT`) to a supported [`Lang`].
199+ fn lang_from_tag ( tag : & str ) -> Option < Lang > {
200+ match tag. split ( '-' ) . next ( ) ?. trim ( ) {
201+ "en" | "eN" | "En" | "EN" => Some ( Lang :: En ) ,
202+ "de" | "dE" | "De" | "DE" => Some ( Lang :: De ) ,
203+ _ => None ,
204+ }
205+ }
206+
207+ /// Pick the best supported language from an `Accept-Language` header value,
208+ /// honoring `q=` weights. Falls back to the default language if nothing
209+ /// matches.
210+ fn lang_from_accept_language ( header : & str ) -> Lang {
211+ header
212+ . split ( ',' )
213+ . enumerate ( )
214+ . filter_map ( |( idx, entry) | {
215+ let mut parts = entry. split ( ';' ) ;
216+ let tag = parts. next ( ) . map ( str:: trim) . filter ( |t| !t. is_empty ( ) ) ?;
217+ let lang = lang_from_tag ( tag) ?;
218+
219+ let q = parts
220+ . find_map ( |p| {
221+ let p = p. trim ( ) ;
222+ p. strip_prefix ( "q=" ) . or_else ( || p. strip_prefix ( "Q=" ) )
223+ } )
224+ . and_then ( |s| s. parse :: < f32 > ( ) . ok ( ) )
225+ . unwrap_or ( 1.0 ) ;
226+
227+ // Use position as a tie-breaker so the first listed entry wins
228+ // when weights are equal.
229+ #[ expect( clippy:: cast_precision_loss) ]
230+ let weighted = q - ( idx as f32 ) * 1e-6 ;
231+ Some ( ( weighted, lang) )
232+ } )
233+ . max_by ( |a, b| a. 0 . partial_cmp ( & b. 0 ) . unwrap_or ( std:: cmp:: Ordering :: Equal ) )
234+ . map_or ( Lang :: default ( ) , |( _, l) | l)
235+ }
236+
237+ impl < S > FromRequestParts < S > for Lang
238+ where
239+ S : Send + Sync ,
240+ {
241+ type Rejection = Infallible ;
242+
243+ async fn from_request_parts ( parts : & mut Parts , _state : & S ) -> Result < Self , Self :: Rejection > {
244+ Ok ( parts
245+ . headers
246+ . get ( http:: header:: ACCEPT_LANGUAGE )
247+ . and_then ( |v| v. to_str ( ) . ok ( ) )
248+ . map_or_else ( Lang :: default, lang_from_accept_language) )
249+ }
250+ }
251+
252+ #[ cfg( test) ]
253+ mod tests {
254+ use super :: * ;
255+
256+ #[ test]
257+ fn picks_highest_q ( ) {
258+ assert_eq ! ( lang_from_accept_language( "en;q=0.5,de;q=0.9" ) , Lang :: De ) ;
259+ }
260+
261+ #[ test]
262+ fn defaults_to_english_when_unsupported ( ) {
263+ assert_eq ! ( lang_from_accept_language( "ja,fr;q=0.7" ) , Lang :: En ) ;
264+ }
265+
266+ #[ test]
267+ fn handles_region_subtags ( ) {
268+ assert_eq ! ( lang_from_accept_language( "de-AT" ) , Lang :: De ) ;
269+ }
270+
271+ #[ test]
272+ fn first_listed_wins_on_tie ( ) {
273+ // Both implicit q=1.0; first listed should win.
274+ assert_eq ! ( lang_from_accept_language( "de,en" ) , Lang :: De ) ;
275+ assert_eq ! ( lang_from_accept_language( "en,de" ) , Lang :: En ) ;
276+ }
277+ }
0 commit comments