@@ -109,4 +109,33 @@ pub trait ForestAuth: Send + Sync {
|
||||
access_token: &str,
|
||||
token_id: &str,
|
||||
) -> Result<(), AuthError>;
|
||||
|
||||
async fn update_username(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
new_username: &str,
|
||||
) -> Result<User, AuthError>;
|
||||
|
||||
async fn change_password(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
current_password: &str,
|
||||
new_password: &str,
|
||||
) -> Result<(), AuthError>;
|
||||
|
||||
async fn add_email(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
email: &str,
|
||||
) -> Result<UserEmail, AuthError>;
|
||||
|
||||
async fn remove_email(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
email: &str,
|
||||
) -> Result<(), AuthError>;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ pub struct Artifact {
|
||||
pub struct ArtifactContext {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub web: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pr: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -59,6 +63,12 @@ pub struct ArtifactRef {
|
||||
pub struct ArtifactDestination {
|
||||
pub name: String,
|
||||
pub environment: String,
|
||||
#[serde(default)]
|
||||
pub type_organisation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub type_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub type_version: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -69,6 +79,24 @@ pub struct OrgMember {
|
||||
pub joined_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Destination {
|
||||
pub name: String,
|
||||
pub environment: String,
|
||||
pub organisation: String,
|
||||
#[serde(default)]
|
||||
pub metadata: std::collections::HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub dest_type: Option<DestinationType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DestinationType {
|
||||
pub organisation: String,
|
||||
pub name: String,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum PlatformError {
|
||||
#[error("not authenticated")]
|
||||
@@ -140,6 +168,18 @@ pub trait ForestPlatform: Send + Sync {
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<OrgMember, PlatformError>;
|
||||
|
||||
async fn get_artifact_by_slug(
|
||||
&self,
|
||||
access_token: &str,
|
||||
slug: &str,
|
||||
) -> Result<Artifact, PlatformError>;
|
||||
|
||||
async fn list_destinations(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
) -> Result<Vec<Destination>, PlatformError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
115
crates/forage-core/src/session/file_store.rs
Normal file
115
crates/forage-core/src/session/file_store.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use super::{SessionData, SessionError, SessionId, SessionStore};
|
||||
|
||||
/// File-based session store. Each session is a JSON file in a directory.
|
||||
/// Suitable for local development — sessions survive server restarts.
|
||||
pub struct FileSessionStore {
|
||||
dir: PathBuf,
|
||||
max_inactive: Duration,
|
||||
}
|
||||
|
||||
impl FileSessionStore {
|
||||
pub fn new(dir: impl AsRef<Path>) -> Result<Self, SessionError> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| SessionError::Store(format!("failed to create session dir: {e}")))?;
|
||||
Ok(Self {
|
||||
dir,
|
||||
max_inactive: Duration::days(30),
|
||||
})
|
||||
}
|
||||
|
||||
fn session_path(&self, id: &SessionId) -> PathBuf {
|
||||
// Use a safe filename: replace any non-alphanumeric chars
|
||||
let safe_name: String = id
|
||||
.as_str()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
self.dir.join(format!("{safe_name}.json"))
|
||||
}
|
||||
|
||||
/// Remove sessions inactive for longer than `max_inactive`.
|
||||
pub fn reap_expired(&self) {
|
||||
let cutoff = Utc::now() - self.max_inactive;
|
||||
let entries = match std::fs::read_dir(&self.dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
if let Ok(data) = serde_json::from_str::<SessionData>(&contents) {
|
||||
if data.last_seen_at < cutoff {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
std::fs::read_dir(&self.dir)
|
||||
.map(|e| {
|
||||
e.flatten()
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
== Some("json")
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SessionStore for FileSessionStore {
|
||||
async fn create(&self, data: SessionData) -> Result<SessionId, SessionError> {
|
||||
let id = SessionId::generate();
|
||||
let path = self.session_path(&id);
|
||||
let json = serde_json::to_string_pretty(&data)
|
||||
.map_err(|e| SessionError::Store(format!("serialize error: {e}")))?;
|
||||
std::fs::write(&path, json)
|
||||
.map_err(|e| SessionError::Store(format!("write error: {e}")))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get(&self, id: &SessionId) -> Result<Option<SessionData>, SessionError> {
|
||||
let path = self.session_path(id);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => {
|
||||
let data: SessionData = serde_json::from_str(&contents)
|
||||
.map_err(|e| SessionError::Store(format!("deserialize error: {e}")))?;
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(SessionError::Store(format!("read error: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError> {
|
||||
let path = self.session_path(id);
|
||||
let json = serde_json::to_string_pretty(&data)
|
||||
.map_err(|e| SessionError::Store(format!("serialize error: {e}")))?;
|
||||
std::fs::write(&path, json)
|
||||
.map_err(|e| SessionError::Store(format!("write error: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &SessionId) -> Result<(), SessionError> {
|
||||
let path = self.session_path(id);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(SessionError::Store(format!("delete error: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod file_store;
|
||||
mod store;
|
||||
|
||||
pub use file_store::FileSessionStore;
|
||||
pub use store::InMemorySessionStore;
|
||||
|
||||
use crate::auth::UserEmail;
|
||||
@@ -85,7 +87,7 @@ pub fn generate_csrf_token() -> String {
|
||||
}
|
||||
|
||||
/// Server-side session data. Never exposed to the browser.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionData {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
|
||||
Reference in New Issue
Block a user