Skip to content
Open
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
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.

6 changes: 5 additions & 1 deletion crates/aionui-app/src/router/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,11 @@ pub fn build_file_state(services: &AppServices) -> FileRouterState {
let broadcaster = services.event_bus.clone();
let allowed_roots = default_allowed_roots(Some(services.work_dir.as_path()));
let browse_roots = aionui_file::browse::default_browse_roots();
let file_service = Arc::new(FileService::new(broadcaster.clone(), allowed_roots.clone()));
let upload_settings_repo = Arc::new(SqliteSettingsRepository::new(services.database.pool().clone()));
let file_service = Arc::new(
FileService::new(broadcaster.clone(), allowed_roots.clone())
.with_upload_workspace_context(upload_settings_repo, services.conversation_repo.clone()),
);
let watch_service = Arc::new(FileWatchService::new(broadcaster).expect("file watch service initialization"));
let snapshot_service = Arc::new(SnapshotService::new());
FileRouterState {
Expand Down
54 changes: 54 additions & 0 deletions crates/aionui-app/tests/file_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,60 @@ async fn upload_accepts_small_png_and_returns_readable_path() {
let _ = std::fs::remove_dir(p.parent().unwrap());
}

#[tokio::test]
async fn upload_saves_to_conversation_workspace_when_setting_enabled() {
let (mut app, services) = build_app().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;

let req = json_with_token(
"PATCH",
"/api/settings",
json!({ "save_upload_to_workspace": true }),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);

let workspace = tempfile::tempdir().unwrap();
let req = json_with_token(
"POST",
"/api/conversations",
json!({
"type": "acp",
"name": "Upload workspace target",
"extra": { "workspace": workspace.path().to_str().unwrap() }
}),
&token,
&csrf,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let created = body_json(resp).await;
let conversation_id = created["data"]["id"].as_str().unwrap();

let bytes = br#"{"uploaded":true}"#.to_vec();
let (content_type, body) = UploadMultipart::new()
.add_file("file", "workspace-upload.json", "application/json", &bytes)
.add_text("conversation_id", conversation_id)
.build();

let req = upload_request(&content_type, body, &token, &csrf);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);

let json = body_json(resp).await;
let path = json["data"].as_str().expect("data should be a string path");
let path = std::path::Path::new(path);
let workspace_root = std::fs::canonicalize(workspace.path()).unwrap();

assert_eq!(path.parent().unwrap(), workspace_root);
assert_eq!(path.file_name().unwrap().to_string_lossy(), "workspace-upload.json");
assert_eq!(std::fs::read(path).unwrap(), bytes);

let _ = std::fs::remove_file(path);
}

#[tokio::test]
async fn upload_uses_content_disposition_filename_when_file_name_missing() {
let (mut app, services) = build_app().await;
Expand Down
1 change: 1 addition & 0 deletions crates/aionui-file/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ edition.workspace = true
[dependencies]
aionui-api-types.workspace = true
aionui-common.workspace = true
aionui-db.workspace = true
aionui-realtime.workspace = true
async-trait.workspace = true
axum.workspace = true
Expand Down
212 changes: 204 additions & 8 deletions crates/aionui-file/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use tracing::warn;

use aionui_api_types::WebSocketMessage;
use aionui_common::AppError;
use aionui_db::{IConversationRepository, ISettingsRepository};
use aionui_realtime::EventBroadcaster;

use crate::path_safety::{has_traversal, validate_path, validate_path_for_write, validate_path_with_extra_root};
Expand Down Expand Up @@ -59,6 +60,7 @@ const PLACEHOLDER_SVG: &str = concat!(
/// A concrete implementation of [`crate::traits::IFileService`].
pub struct FileService {
broadcaster: Arc<dyn EventBroadcaster>,
upload_workspace_context: Option<UploadWorkspaceContext>,
/// Allowed root directories for path safety validation.
allowed_roots: Vec<std::path::PathBuf>,
/// In-memory cache for `list_workspace_files`, keyed by canonical root.
Expand All @@ -67,16 +69,34 @@ pub struct FileService {
zip_cancellations: DashMap<String, Arc<AtomicBool>>,
}

struct UploadWorkspaceContext {
settings_repo: Arc<dyn ISettingsRepository>,
conversation_repo: Arc<dyn IConversationRepository>,
}

impl FileService {
pub fn new(broadcaster: Arc<dyn EventBroadcaster>, allowed_roots: Vec<std::path::PathBuf>) -> Self {
Self {
broadcaster,
upload_workspace_context: None,
allowed_roots,
workspace_files_cache: DashMap::new(),
zip_cancellations: DashMap::new(),
}
}

pub fn with_upload_workspace_context(
mut self,
settings_repo: Arc<dyn ISettingsRepository>,
conversation_repo: Arc<dyn IConversationRepository>,
) -> Self {
self.upload_workspace_context = Some(UploadWorkspaceContext {
settings_repo,
conversation_repo,
});
self
}

/// Invalidate the workspace files cache for a given root.
/// Called when file changes are detected.
pub fn invalidate_cache(&self, root: &str) {
Expand Down Expand Up @@ -114,6 +134,57 @@ impl FileService {
.any(|root| candidate.starts_with(root))
}

async fn resolve_upload_directory(&self, conversation_id: Option<&str>) -> Result<PathBuf, AppError> {
let temp_dir = upload_temp_dir(conversation_id);
let Some(conversation_id) = conversation_id else {
return Ok(temp_dir);
};
let Some(context) = self.upload_workspace_context.as_ref() else {
return Ok(temp_dir);
};

let settings = context
.settings_repo
.get_settings()
.await
.map_err(|e| AppError::Internal(format!("Failed to get upload setting: {e}")))?;
if !settings.as_ref().map(|s| s.save_upload_to_workspace).unwrap_or(false) {
return Ok(temp_dir);
}

let Some(conversation) = context
.conversation_repo
.get(conversation_id)
.await
.map_err(|e| AppError::Internal(format!("Failed to get conversation for upload workspace: {e}")))?
else {
warn!(
conversation_id,
"upload workspace save requested but conversation was not found; falling back to temp upload directory"
);
return Ok(temp_dir);
};

match workspace_from_conversation_extra(&conversation.extra) {
Ok(Some(workspace)) => Ok(workspace),
Ok(None) => {
warn!(
conversation_id,
"upload workspace save requested but conversation has no workspace; falling back to temp upload directory"
);
Ok(temp_dir)
}
Err(e) => {
warn!(
conversation_id,
error = %e,
"upload workspace save requested but conversation extra is invalid; falling back to temp upload directory"
);
Ok(temp_dir)
}
}
}

/// List immediate children of `dir`, building a single-level tree.
/// Each child directory also lists *its* children (depth = 2 from `dir`).
async fn build_dir_tree(&self, dir: &Path, root: &Path) -> Result<Vec<DirOrFile>, AppError> {
Expand Down Expand Up @@ -352,6 +423,26 @@ fn split_base_ext(name: &str) -> (&str, &str) {
}
}

fn upload_temp_dir(conversation_id: Option<&str>) -> PathBuf {
let mut dir = std::env::temp_dir().join("aionui");
if let Some(conversation_id) = conversation_id {
dir = dir.join(conversation_id);
} else {
dir = dir.join("general");
}
dir
}

fn workspace_from_conversation_extra(extra: &str) -> Result<Option<PathBuf>, serde_json::Error> {
let value: serde_json::Value = serde_json::from_str(extra)?;
Ok(value
.get("workspace")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|workspace| !workspace.is_empty())
.map(PathBuf::from))
}

/// Get file metadata synchronously.
fn get_file_metadata_sync(path: &Path) -> Result<FileMetadata, AppError> {
let metadata = std::fs::metadata(path)
Expand Down Expand Up @@ -924,16 +1015,13 @@ impl crate::traits::IFileService for FileService {

let name = file_name.to_owned();
let bytes = data.to_vec();
let dir = self.resolve_upload_directory(conv_id.as_deref()).await?;

tokio::task::spawn_blocking(move || {
let mut dir = std::env::temp_dir().join("aionui");
if let Some(conv_id) = conv_id.as_deref() {
dir = dir.join(conv_id);
} else {
dir = dir.join("general");
}
let uploaded_path = tokio::task::spawn_blocking(move || {
std::fs::create_dir_all(&dir)
.map_err(|e| AppError::Internal(format!("cannot create upload directory: {e}")))?;
let dir = std::fs::canonicalize(&dir)
.map_err(|e| AppError::Internal(format!("cannot resolve upload directory: {e}")))?;

let (base, ext) = split_base_ext(&name);
let mut candidate = name.clone();
Expand Down Expand Up @@ -971,7 +1059,14 @@ impl crate::traits::IFileService for FileService {
}
})
.await
.map_err(|e| AppError::Internal(format!("create upload file task failed: {e}")))?
.map_err(|e| AppError::Internal(format!("create upload file task failed: {e}")))??;

// Uploads can create files inside a workspace after the file mention
// list has already cached an empty result. Clear all roots because an
// upload can affect both the exact workspace cache and ancestor roots.
self.workspace_files_cache.clear();

Ok(uploaded_path)
}

async fn get_image_base64(&self, path: &str, extra_root: Option<&Path>) -> Result<String, AppError> {
Expand Down Expand Up @@ -1857,6 +1952,44 @@ mod tests {
crate::service::FileService::new(Arc::new(NullBroadcaster), vec![])
}

async fn make_service_with_upload_context(
save_upload_to_workspace: bool,
conversation_id: &str,
workspace: &std::path::Path,
) -> (crate::service::FileService, aionui_db::Database) {
use aionui_db::{IConversationRepository, ISettingsRepository};

let db = aionui_db::init_database_memory().await.unwrap();
let settings_repo = Arc::new(aionui_db::SqliteSettingsRepository::new(db.pool().clone()));
settings_repo
.upsert_settings("en-US", true, false, false, save_upload_to_workspace)
.await
.unwrap();

let conversation_repo = Arc::new(aionui_db::SqliteConversationRepository::new(db.pool().clone()));
let now = aionui_common::now_ms();
let row = aionui_db::models::ConversationRow {
id: conversation_id.to_owned(),
user_id: "system_default_user".to_owned(),
name: "Upload target".to_owned(),
r#type: "acp".to_owned(),
extra: serde_json::json!({ "workspace": workspace.to_string_lossy() }).to_string(),
model: None,
status: Some("pending".to_owned()),
source: Some("aionui".to_owned()),
channel_chat_id: None,
pinned: false,
pinned_at: None,
created_at: now,
updated_at: now,
};
conversation_repo.create(&row).await.unwrap();

let service = crate::service::FileService::new(Arc::new(NullBroadcaster), vec![workspace.to_path_buf()])
.with_upload_workspace_context(settings_repo, conversation_repo);
(service, db)
}

#[tokio::test]
async fn create_upload_file_writes_bytes_and_returns_path() {
use crate::traits::IFileService;
Expand Down Expand Up @@ -1906,6 +2039,69 @@ mod tests {
let _ = std::fs::remove_dir(parent);
}

#[tokio::test]
async fn create_upload_file_uses_conversation_workspace_when_setting_enabled() {
use crate::traits::IFileService;

let workspace = tempfile::tempdir().unwrap();
let conv = unique_conv_id("workspace");
let (svc, _db) = make_service_with_upload_context(true, &conv, workspace.path()).await;

let path_str = svc
.create_upload_file("project.json", br#"{"ok":true}"#, Some(&conv))
.await
.unwrap();
let path = std::path::Path::new(&path_str);
let workspace_root = std::fs::canonicalize(workspace.path()).unwrap();

assert_eq!(path.parent().unwrap(), workspace_root);
assert_eq!(path.file_name().unwrap().to_string_lossy(), "project.json");
assert_eq!(std::fs::read(path).unwrap(), br#"{"ok":true}"#);

let _ = std::fs::remove_file(path);
}

#[tokio::test]
async fn create_upload_file_invalidates_workspace_file_list_cache() {
use crate::traits::IFileService;

let workspace = tempfile::tempdir().unwrap();
let conv = unique_conv_id("workspace-cache");
let (svc, _db) = make_service_with_upload_context(true, &conv, workspace.path()).await;
let workspace_str = workspace.path().to_string_lossy().into_owned();

let before = svc.list_workspace_files(&workspace_str).await.unwrap();
assert!(before.is_empty());

let path_str = svc
.create_upload_file("cached.md", b"# cached", Some(&conv))
.await
.unwrap();
let after = svc.list_workspace_files(&workspace_str).await.unwrap();

assert!(after.iter().any(|file| file.name == "cached.md"));
let _ = std::fs::remove_file(path_str);
}

#[tokio::test]
async fn create_upload_file_uses_temp_directory_when_setting_disabled() {
use crate::traits::IFileService;

let workspace = tempfile::tempdir().unwrap();
let conv = unique_conv_id("workspace-disabled");
let (svc, _db) = make_service_with_upload_context(false, &conv, workspace.path()).await;

let path_str = svc.create_upload_file("note.txt", b"temp", Some(&conv)).await.unwrap();
let path = std::path::Path::new(&path_str);
let parent = path.parent().unwrap();

assert_eq!(parent.file_name().unwrap().to_string_lossy(), conv);
assert_ne!(parent, std::fs::canonicalize(workspace.path()).unwrap());
assert_eq!(std::fs::read(path).unwrap(), b"temp");

let _ = std::fs::remove_dir_all(parent);
}

#[tokio::test]
async fn create_upload_file_rejects_path_separators() {
use crate::traits::IFileService;
Expand Down
11 changes: 6 additions & 5 deletions crates/aionui-file/src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,13 @@ pub trait IFileService: Send + Sync {
/// Create an empty temporary file and return its absolute path.
async fn create_temp_file(&self, file_name: &str) -> Result<String, AppError>;

/// Write `data` to a temporary file and return its absolute path.
/// Write `data` to an upload file and return its absolute path.
///
/// When `conversation_id` is provided, the file is placed under a
/// per-conversation sub-directory (`<tmp>/aionui/<conversation_id>/`);
/// otherwise the shared `<tmp>/aionui/` directory is used (same as
/// [`create_temp_file`](Self::create_temp_file)).
/// When `conversation_id` is provided and upload-to-workspace is enabled,
/// the file is placed in that conversation's workspace. Otherwise it is
/// placed under a per-conversation temp directory
/// (`<tmp>/aionui/<conversation_id>/`) or `<tmp>/aionui/general/` when no
/// conversation is provided.
///
/// `file_name` must not contain path separators or traversal patterns.
async fn create_upload_file(
Expand Down