feat: init

This commit is contained in:
zhenyi
2026-06-07 11:30:56 +08:00
commit 563381c1ca
361 changed files with 41327 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
use crate::config::AppConfig;
use crate::error::AppResult;
impl AppConfig {
pub fn session_cookie_name(&self) -> AppResult<String> {
self.get_env_or("APP_SESSION_COOKIE_NAME", "sid".to_string())
}
pub fn session_cookie_secure(&self) -> AppResult<bool> {
self.get_env_or("APP_SESSION_COOKIE_SECURE", true)
}
pub fn session_cookie_http_only(&self) -> AppResult<bool> {
self.get_env_or("APP_SESSION_COOKIE_HTTP_ONLY", true)
}
pub fn session_cookie_same_site(&self) -> AppResult<String> {
self.get_env_or("APP_SESSION_COOKIE_SAME_SITE", "Lax".to_string())
}
pub fn session_cookie_path(&self) -> AppResult<String> {
self.get_env_or("APP_SESSION_COOKIE_PATH", "/".to_string())
}
pub fn session_cookie_domain(&self) -> AppResult<Option<String>> {
self.get_env::<String>("APP_SESSION_COOKIE_DOMAIN")
}
pub fn session_ttl_secs(&self) -> AppResult<u64> {
self.get_env_or("APP_SESSION_TTL_SECS", 86400)
}
pub fn session_max_age_secs(&self) -> AppResult<Option<u64>> {
self.get_env::<u64>("APP_SESSION_MAX_AGE_SECS")
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod config;
#[allow(clippy::module_inception)]
pub mod session;
pub mod storage;
pub use self::{
session::{Session, SessionState, SessionStatus, SessionUser},
storage::{RedisSessionStore, SessionKey, SessionStore, generate_session_key},
};
+195
View File
@@ -0,0 +1,195 @@
use std::cell::{Ref, RefCell};
use std::rc::Rc;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use uuid::Uuid;
use crate::error::AppError;
const SESSION_USER_KEY: &str = "session:user_uid";
#[derive(Clone)]
pub struct Session(Rc<RefCell<SessionInner>>);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SessionStatus {
Changed,
Purged,
Renewed,
#[default]
Unchanged,
}
#[derive(Default)]
struct SessionInner {
state: Map<String, Value>,
status: SessionStatus,
}
impl Session {
pub fn new() -> Self {
Self::default()
}
pub fn from_state(state: Map<String, Value>) -> Self {
Self(Rc::new(RefCell::new(SessionInner {
state,
status: SessionStatus::Unchanged,
})))
}
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, AppError> {
if let Some(value) = self.0.borrow().state.get(key) {
Ok(Some(serde_json::from_value(value.clone())?))
} else {
Ok(None)
}
}
pub fn contains_key(&self, key: &str) -> bool {
self.0.borrow().state.contains_key(key)
}
pub fn entries(&self) -> Ref<'_, Map<String, Value>> {
Ref::map(self.0.borrow(), |inner| &inner.state)
}
pub fn status(&self) -> SessionStatus {
Ref::map(self.0.borrow(), |inner| &inner.status).clone()
}
pub fn insert<T: Serialize>(&self, key: impl Into<String>, value: T) -> Result<(), AppError> {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
if inner.status != SessionStatus::Renewed {
inner.status = SessionStatus::Changed;
}
let val = serde_json::to_value(&value)?;
inner.state.insert(key.into(), val);
}
Ok(())
}
pub fn update<T: Serialize + DeserializeOwned, F>(
&self,
key: impl Into<String>,
updater: F,
) -> Result<(), AppError>
where
F: FnOnce(T) -> T,
{
let mut inner = self.0.borrow_mut();
let key_str = key.into();
if let Some(val) = inner.state.get(&key_str) {
if inner.status == SessionStatus::Purged {
return Ok(());
}
let value: T = serde_json::from_value(val.clone())?;
let updated = serde_json::to_value(updater(value))?;
if inner.status != SessionStatus::Renewed {
inner.status = SessionStatus::Changed;
}
inner.state.insert(key_str, updated);
}
Ok(())
}
pub fn update_or<T: Serialize + DeserializeOwned, F>(
&self,
key: &str,
default: T,
updater: F,
) -> Result<(), AppError>
where
F: FnOnce(T) -> T,
{
if self.contains_key(key) {
self.update(key, updater)
} else {
self.insert(key, default)
}
}
pub fn remove(&self, key: &str) -> Option<Value> {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
if inner.status != SessionStatus::Renewed {
inner.status = SessionStatus::Changed;
}
return inner.state.remove(key);
}
None
}
pub fn remove_as<T: DeserializeOwned>(&self, key: &str) -> Option<Result<T, AppError>> {
self.remove(key)
.map(|value| serde_json::from_value(value).map_err(AppError::Json))
}
pub fn clear(&self) {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
if inner.status != SessionStatus::Renewed {
inner.status = SessionStatus::Changed;
}
inner.state.clear();
}
}
pub fn purge(&self) {
let mut inner = self.0.borrow_mut();
inner.status = SessionStatus::Purged;
inner.state.clear();
}
pub fn renew(&self) {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
inner.status = SessionStatus::Renewed;
}
}
pub fn user(&self) -> Option<Uuid> {
self.get::<Uuid>(SESSION_USER_KEY).ok().flatten()
}
pub fn set_user(&self, uid: Uuid) {
let _ = self.insert(SESSION_USER_KEY, uid);
}
pub fn clear_user(&self) {
let _ = self.remove(SESSION_USER_KEY);
}
pub fn take_state(&self) -> SessionState {
let mut inner = self.0.borrow_mut();
std::mem::take(&mut inner.state)
}
pub fn mark_unchanged(&self) {
self.0.borrow_mut().status = SessionStatus::Unchanged;
}
}
impl Default for Session {
fn default() -> Self {
Self(Rc::new(RefCell::new(SessionInner::default())))
}
}
pub type SessionState = Map<String, Value>;
#[derive(Debug, Clone, Copy)]
pub struct SessionUser(pub Uuid);
+60
View File
@@ -0,0 +1,60 @@
use serde::ser::{Serialize, SerializeMap, Serializer};
use serde_json::Value;
use crate::error::AppError;
use super::interface::SessionState;
const SESSION_STATE_FORMAT_VERSION: u8 = 1;
struct StoredSessionStateRef<'a> {
state: &'a SessionState,
}
impl Serialize for StoredSessionStateRef<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("v", &SESSION_STATE_FORMAT_VERSION)?;
map.serialize_entry("state", self.state)?;
map.end()
}
}
pub fn serialize_session_state(session_state: &SessionState) -> Result<String, AppError> {
let stored = StoredSessionStateRef {
state: session_state,
};
serde_json::to_string(&stored).map_err(AppError::Json)
}
pub fn deserialize_session_state(value: &str) -> Result<SessionState, AppError> {
let value: Value = serde_json::from_str(value)?;
let Value::Object(mut obj) = value else {
return Err(AppError::Config("invalid session state format".into()));
};
if let Some(Value::Object(_)) = obj.get("state")
&& let Some(Value::Number(v)) = obj.get("v")
{
let version = v
.as_u64()
.and_then(|n| u8::try_from(n).ok())
.ok_or_else(|| AppError::Config("invalid session state format version".into()))?;
if version != SESSION_STATE_FORMAT_VERSION {
return Err(AppError::Config(format!(
"unsupported session state format version: {version}"
)));
}
let Some(Value::Object(state)) = obj.remove("state") else {
return Err(AppError::Config("missing session state".into()));
};
return Ok(state);
}
Ok(obj)
}
+36
View File
@@ -0,0 +1,36 @@
use std::future::Future;
use serde_json::{Map, Value};
use super::SessionKey;
use crate::error::AppError;
pub type SessionState = Map<String, Value>;
pub trait SessionStore {
fn load(
&self,
session_key: &SessionKey,
) -> impl Future<Output = Result<Option<SessionState>, AppError>>;
fn save(
&self,
session_state: SessionState,
ttl_secs: u64,
) -> impl Future<Output = Result<SessionKey, AppError>>;
fn update(
&self,
session_key: SessionKey,
session_state: SessionState,
ttl_secs: u64,
) -> impl Future<Output = Result<SessionKey, AppError>>;
fn update_ttl(
&self,
session_key: &SessionKey,
ttl_secs: u64,
) -> impl Future<Output = Result<(), AppError>>;
fn delete(&self, session_key: &SessionKey) -> impl Future<Output = Result<(), AppError>>;
}
+12
View File
@@ -0,0 +1,12 @@
mod format;
mod interface;
mod redis;
mod session_key;
mod utils;
pub use self::{
interface::{SessionState, SessionStore},
redis::RedisSessionStore,
session_key::SessionKey,
utils::generate_session_key,
};
+82
View File
@@ -0,0 +1,82 @@
use crate::cache::redis::AppRedis;
use crate::error::AppError;
use super::SessionKey;
use super::format::{deserialize_session_state, serialize_session_state};
use super::interface::{SessionState, SessionStore};
use super::utils::generate_session_key;
pub struct RedisSessionStore {
redis: AppRedis,
}
impl RedisSessionStore {
pub fn new(redis: AppRedis) -> Self {
Self { redis }
}
}
impl SessionStore for RedisSessionStore {
async fn load(&self, session_key: &SessionKey) -> Result<Option<SessionState>, AppError> {
let mut conn = self.redis.get_connection()?;
let value: Option<String> = redis::cmd("GET")
.arg(session_key.as_ref())
.query(&mut *conn.inner_mut())
.ok()
.flatten();
match value {
None => Ok(None),
Some(v) => Ok(Some(deserialize_session_state(&v)?)),
}
}
async fn save(
&self,
session_state: SessionState,
ttl_secs: u64,
) -> Result<SessionKey, AppError> {
let body = serialize_session_state(&session_state)?;
let session_key = generate_session_key();
let mut conn = self.redis.get_connection()?;
redis::cmd("SETEX")
.arg(session_key.as_ref())
.arg(ttl_secs)
.arg(&body)
.query::<()>(&mut *conn.inner_mut())?;
Ok(session_key)
}
async fn update(
&self,
session_key: SessionKey,
session_state: SessionState,
ttl_secs: u64,
) -> Result<SessionKey, AppError> {
let body = serialize_session_state(&session_state)?;
let mut conn = self.redis.get_connection()?;
redis::cmd("SETEX")
.arg(session_key.as_ref())
.arg(ttl_secs)
.arg(&body)
.query::<()>(&mut *conn.inner_mut())?;
Ok(session_key)
}
async fn update_ttl(&self, session_key: &SessionKey, ttl_secs: u64) -> Result<(), AppError> {
let mut conn = self.redis.get_connection()?;
redis::cmd("EXPIRE")
.arg(session_key.as_ref())
.arg(ttl_secs)
.query::<()>(&mut *conn.inner_mut())?;
Ok(())
}
async fn delete(&self, session_key: &SessionKey) -> Result<(), AppError> {
let mut conn = self.redis.get_connection()?;
redis::cmd("DEL")
.arg(session_key.as_ref())
.query::<()>(&mut *conn.inner_mut())?;
Ok(())
}
}
+28
View File
@@ -0,0 +1,28 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionKey(pub String);
impl TryFrom<String> for SessionKey {
type Error = &'static str;
fn try_from(val: String) -> Result<Self, Self::Error> {
if val.len() > 4064 {
return Err("session key exceeds 4064 bytes");
}
if val.contains('\0') {
return Err("session key contains null bytes");
}
Ok(SessionKey(val))
}
}
impl AsRef<str> for SessionKey {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<SessionKey> for String {
fn from(key: SessionKey) -> Self {
key.0
}
}
+7
View File
@@ -0,0 +1,7 @@
use uuid::Uuid;
use super::SessionKey;
pub fn generate_session_key() -> SessionKey {
SessionKey(Uuid::now_v7().to_string())
}