use super::{Error, InvalidHeaderNameSnafu, InvalidHeaderValueSnafu, is_development};
use axum::http::{HeaderMap, HeaderValue, header, header::HeaderName};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use cookie::{CookieBuilder, SameSite};
use nanoid::nanoid;
use snafu::ResultExt;
use std::time::Duration;
type Result<T> = std::result::Result<T, Error>;
pub fn insert_headers<K, V>(
headers: &mut HeaderMap<HeaderValue>,
values: impl IntoIterator<Item = (K, V)>,
) -> Result<()>
where
K: AsRef<str>,
V: AsRef<str>,
{
for (name, value) in values {
let name = name.as_ref();
let value = value.as_ref();
if name.is_empty() || value.is_empty() {
continue;
}
let name = HeaderName::try_from(name).context(InvalidHeaderNameSnafu)?;
let value = HeaderValue::try_from(value).context(InvalidHeaderValueSnafu)?;
headers.insert(name, value);
}
Ok(())
}
pub fn set_header_if_not_exist(
headers: &mut HeaderMap<HeaderValue>,
name: &str,
value: &str,
) -> Result<()> {
if headers.contains_key(name) {
return Ok(());
}
insert_headers(headers, [(name, value)])
}
pub fn set_no_cache_if_not_exist(headers: &mut HeaderMap<HeaderValue>) {
headers
.entry(header::CACHE_CONTROL)
.or_insert(HeaderValue::from_static("no-cache"));
}
pub fn get_header_value<'a>(headers: &'a HeaderMap<HeaderValue>, key: &str) -> Option<&'a str> {
headers.get(key).and_then(|value| value.to_str().ok())
}
const DEVICE_ID_NAME: &str = "device";
const DEVICE_ID_LIFETIME: Duration = Duration::from_secs(365 * 24 * 60 * 60);
const DEVICE_ID_LEN: usize = 16;
fn is_valid_device_id(value: &str) -> bool {
value.len() == DEVICE_ID_LEN
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
pub fn get_device_id_from_cookie(jar: &CookieJar) -> Option<&str> {
jar.get(DEVICE_ID_NAME)
.map(|cookie| cookie.value())
.filter(|value| is_valid_device_id(value))
}
pub fn generate_device_id_cookie() -> CookieBuilder<'static> {
let expires = cookie::time::OffsetDateTime::now_utc()
.saturating_add(cookie::time::Duration::try_from(DEVICE_ID_LIFETIME).unwrap_or_default());
Cookie::build((DEVICE_ID_NAME, nanoid!(DEVICE_ID_LEN)))
.http_only(true)
.same_site(SameSite::Lax)
.secure(!is_development())
.expires(expires)
.path("/")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn no_cache_is_added_only_when_absent() {
let mut headers = HeaderMap::new();
set_no_cache_if_not_exist(&mut headers);
assert_eq!(headers[header::CACHE_CONTROL], "no-cache");
let mut headers = HeaderMap::new();
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("max-age=60"),
);
set_no_cache_if_not_exist(&mut headers);
assert_eq!(headers[header::CACHE_CONTROL], "max-age=60");
}
#[test]
fn set_header_if_not_exist_respects_existing_value() {
let mut headers = HeaderMap::new();
set_header_if_not_exist(&mut headers, "X-Trace-Id", "a").expect("合法头");
set_header_if_not_exist(&mut headers, "X-Trace-Id", "b").expect("合法头");
assert_eq!(headers["x-trace-id"], "a");
assert!(set_header_if_not_exist(&mut headers, "bad name", "v").is_err());
assert_eq!(headers.len(), 1);
}
fn jar_with_device(value: &str) -> CookieJar {
CookieJar::new().add(Cookie::new(DEVICE_ID_NAME, value.to_string()))
}
#[test]
fn generated_device_id_round_trips() {
let cookie = generate_device_id_cookie().build();
let jar = jar_with_device(cookie.value());
assert_eq!(get_device_id_from_cookie(&jar), Some(cookie.value()));
}
#[test]
fn foreign_device_ids_are_rejected() {
for bad in [
"",
"short",
&"a".repeat(DEVICE_ID_LEN + 1),
&"a".repeat(4096),
"abcdefgh ijklmno", "abcdefgh.ijklmno", "设备设备设备设", ] {
assert_eq!(
get_device_id_from_cookie(&jar_with_device(bad)),
None,
"{bad:?}"
);
}
assert_eq!(get_device_id_from_cookie(&CookieJar::new()), None);
}
}