feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): bibliothèque d'icônes projet personnalisées importées par l'utilisateur — #358 (QA verte)
domain: variante ProjectIconSet::Custom, métadonnées CustomProjectIconAsset (id opaque, media type png/jpeg/webp, byte_size, dimensions décodées, sha256, nom de fichier original sans chemin, created_at), CustomProjectIconContent et port CustomProjectIconAssetStore (list/read/exists/create/delete) avec CustomProjectIconStoreError dédiée. application: use case project_icon_assets — import validé png/jpeg/webp avec rejet du contenu SVG, dimensionnement et digest sha256, bibliothèque partagée à l'instance indépendante de tout projet, suppression refusée tant que l'asset est référencé par un projet. infrastructure: store fichier store/project_icon_asset.rs — index canonique + octets, création atomique sous extension validée. backend/web-server: contrat HTTP /api/project-icon-assets (GET liste, POST import, GET lecture, GET /usage, DELETE) avec prévol OPTIONS et tests contrat incluant le rejet SVG. app-tauri: commandes import/list/read/get_usage/delete_custom_project_icon_asset exposées au frontend. frontend: port ProjectIconAssetGateway (list/import/delete/getUrl sans exposer de chemin filesystem), gateways http+mock avec tests, DI, section Icônes personnalisées du ProjectIconPicker (import fichier accept png/jpeg/webp, sélection role=option, suppression avec retrait de sélection, erreurs lisibles i18n FR/EN), tests projectIcons étendus. QA verte: contrats HTTP et Tauri identiques, suites cargo et npm ciblées vertes.
This commit is contained in:
@ -111,6 +111,7 @@ pub use sandbox::{default_enforcer, NoopSandbox};
|
||||
pub use scheduler::TokioScheduler;
|
||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||
pub use sprints::FsSprintStore;
|
||||
pub use store::FsCustomProjectIconAssetStore;
|
||||
pub use store::FsProjectPluginSettingsStore;
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
pub use store::OnnxEmbedder;
|
||||
|
||||
@ -15,6 +15,7 @@ mod open_project_restore;
|
||||
mod permission;
|
||||
mod profile;
|
||||
mod project;
|
||||
mod project_icon_asset;
|
||||
mod project_plugin;
|
||||
mod scheduled_task;
|
||||
mod secrets;
|
||||
@ -44,6 +45,7 @@ pub use open_project_restore::FsOpenProjectRestoreStore;
|
||||
pub use permission::FsPermissionStore;
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||
pub use project::FsProjectStore;
|
||||
pub use project_icon_asset::FsCustomProjectIconAssetStore;
|
||||
pub use project_plugin::FsProjectPluginSettingsStore;
|
||||
pub use scheduled_task::FsScheduledProjectTaskStore;
|
||||
pub use secrets::FsSecretStore;
|
||||
|
||||
277
crates/infrastructure/src/store/project_icon_asset.rs
Normal file
277
crates/infrastructure/src/store/project_icon_asset.rs
Normal file
@ -0,0 +1,277 @@
|
||||
//! Filesystem adapter for the shared user-imported project icon library.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
CustomProjectIconAsset, CustomProjectIconAssetStore, CustomProjectIconContent,
|
||||
CustomProjectIconStoreError,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const INDEX_VERSION: u32 = 1;
|
||||
|
||||
/// File-backed library rooted below app data at `.ideai/icon-assets`.
|
||||
pub struct FsCustomProjectIconAssetStore {
|
||||
root: PathBuf,
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AssetIndex {
|
||||
#[serde(default = "index_version")]
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
assets: Vec<AssetRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AssetRecord {
|
||||
#[serde(flatten)]
|
||||
asset: CustomProjectIconAsset,
|
||||
extension: String,
|
||||
}
|
||||
|
||||
const fn index_version() -> u32 {
|
||||
INDEX_VERSION
|
||||
}
|
||||
|
||||
impl FsCustomProjectIconAssetStore {
|
||||
/// Builds an adapter rooted in the instance-local application data directory.
|
||||
#[must_use]
|
||||
pub fn new(app_data_dir: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
root: app_data_dir.as_ref().join(".ideai").join("icon-assets"),
|
||||
lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn index_path(&self) -> PathBuf {
|
||||
self.root.join("index.json")
|
||||
}
|
||||
fn icons_dir(&self) -> PathBuf {
|
||||
self.root.join("icons")
|
||||
}
|
||||
|
||||
async fn load_index(&self) -> Result<AssetIndex, CustomProjectIconStoreError> {
|
||||
match tokio::fs::read(self.index_path()).await {
|
||||
Ok(bytes) => {
|
||||
let index: AssetIndex = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CustomProjectIconStoreError::Format(error.to_string()))?;
|
||||
if index.version != INDEX_VERSION {
|
||||
return Err(CustomProjectIconStoreError::Format(format!(
|
||||
"unsupported index version {}",
|
||||
index.version
|
||||
)));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(AssetIndex {
|
||||
version: INDEX_VERSION,
|
||||
assets: Vec::new(),
|
||||
}),
|
||||
Err(error) => Err(io_error(&self.index_path(), error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_index(&self, index: &AssetIndex) -> Result<(), CustomProjectIconStoreError> {
|
||||
tokio::fs::create_dir_all(&self.root)
|
||||
.await
|
||||
.map_err(|error| io_error(&self.root, error))?;
|
||||
let mut bytes = serde_json::to_vec_pretty(index)
|
||||
.map_err(|error| CustomProjectIconStoreError::Format(error.to_string()))?;
|
||||
bytes.push(b'\n');
|
||||
let temporary = self.root.join("index.json.tmp");
|
||||
tokio::fs::write(&temporary, bytes)
|
||||
.await
|
||||
.map_err(|error| io_error(&temporary, error))?;
|
||||
tokio::fs::rename(&temporary, self.index_path())
|
||||
.await
|
||||
.map_err(|error| io_error(&self.index_path(), error))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CustomProjectIconAssetStore for FsCustomProjectIconAssetStore {
|
||||
async fn list(&self) -> Result<Vec<CustomProjectIconAsset>, CustomProjectIconStoreError> {
|
||||
let _guard = self.lock.lock().await;
|
||||
Ok(self
|
||||
.load_index()
|
||||
.await?
|
||||
.assets
|
||||
.into_iter()
|
||||
.map(|record| record.asset)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn read(
|
||||
&self,
|
||||
asset_id: &str,
|
||||
) -> Result<CustomProjectIconContent, CustomProjectIconStoreError> {
|
||||
validate_component(asset_id)?;
|
||||
let _guard = self.lock.lock().await;
|
||||
let index = self.load_index().await?;
|
||||
let record = index
|
||||
.assets
|
||||
.into_iter()
|
||||
.find(|record| record.asset.id == asset_id)
|
||||
.ok_or(CustomProjectIconStoreError::NotFound)?;
|
||||
validate_extension(&record.extension)?;
|
||||
let path = self
|
||||
.icons_dir()
|
||||
.join(format!("{}.{}", record.asset.id, record.extension));
|
||||
let bytes = tokio::fs::read(&path).await.map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
CustomProjectIconStoreError::Format(format!(
|
||||
"indexed asset file is missing: {}",
|
||||
record.asset.id
|
||||
))
|
||||
} else {
|
||||
io_error(&path, error)
|
||||
}
|
||||
})?;
|
||||
Ok(CustomProjectIconContent {
|
||||
asset: record.asset,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exists(&self, asset_id: &str) -> Result<bool, CustomProjectIconStoreError> {
|
||||
validate_component(asset_id)?;
|
||||
let _guard = self.lock.lock().await;
|
||||
Ok(self
|
||||
.load_index()
|
||||
.await?
|
||||
.assets
|
||||
.iter()
|
||||
.any(|record| record.asset.id == asset_id))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
asset: &CustomProjectIconAsset,
|
||||
extension: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), CustomProjectIconStoreError> {
|
||||
validate_component(&asset.id)?;
|
||||
validate_extension(extension)?;
|
||||
let _guard = self.lock.lock().await;
|
||||
let mut index = self.load_index().await?;
|
||||
if index
|
||||
.assets
|
||||
.iter()
|
||||
.any(|record| record.asset.id == asset.id)
|
||||
{
|
||||
return Err(CustomProjectIconStoreError::Format(format!(
|
||||
"duplicate asset id {}",
|
||||
asset.id
|
||||
)));
|
||||
}
|
||||
let icons = self.icons_dir();
|
||||
tokio::fs::create_dir_all(&icons)
|
||||
.await
|
||||
.map_err(|error| io_error(&icons, error))?;
|
||||
let path = icons.join(format!("{}.{}", asset.id, extension));
|
||||
tokio::fs::write(&path, bytes)
|
||||
.await
|
||||
.map_err(|error| io_error(&path, error))?;
|
||||
index.assets.push(AssetRecord {
|
||||
asset: asset.clone(),
|
||||
extension: extension.to_owned(),
|
||||
});
|
||||
if let Err(error) = self.save_index(&index).await {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, asset_id: &str) -> Result<(), CustomProjectIconStoreError> {
|
||||
validate_component(asset_id)?;
|
||||
let _guard = self.lock.lock().await;
|
||||
let mut index = self.load_index().await?;
|
||||
let position = index
|
||||
.assets
|
||||
.iter()
|
||||
.position(|record| record.asset.id == asset_id)
|
||||
.ok_or(CustomProjectIconStoreError::NotFound)?;
|
||||
let record = index.assets.remove(position);
|
||||
validate_extension(&record.extension)?;
|
||||
self.save_index(&index).await?;
|
||||
let path = self
|
||||
.icons_dir()
|
||||
.join(format!("{}.{}", asset_id, record.extension));
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_error(&path, error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_component(value: &str) -> Result<(), CustomProjectIconStoreError> {
|
||||
(!value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')))
|
||||
.then_some(())
|
||||
.ok_or_else(|| CustomProjectIconStoreError::Format("unsafe asset id".to_owned()))
|
||||
}
|
||||
|
||||
fn validate_extension(value: &str) -> Result<(), CustomProjectIconStoreError> {
|
||||
matches!(value, "png" | "jpg" | "webp")
|
||||
.then_some(())
|
||||
.ok_or_else(|| {
|
||||
CustomProjectIconStoreError::Format("unsupported stored extension".to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
fn io_error(path: &Path, error: std::io::Error) -> CustomProjectIconStoreError {
|
||||
CustomProjectIconStoreError::Io(format!("{}: {error}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn asset(id: &str) -> CustomProjectIconAsset {
|
||||
CustomProjectIconAsset {
|
||||
id: id.to_owned(),
|
||||
media_type: "image/png".to_owned(),
|
||||
byte_size: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
sha256: "abc".to_owned(),
|
||||
original_filename: "icon.png".to_owned(),
|
||||
created_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persists_lists_reads_and_deletes_inside_confined_root() {
|
||||
let root = std::env::temp_dir().join(format!("idea-icon-assets-{}", uuid::Uuid::new_v4()));
|
||||
let store = FsCustomProjectIconAssetStore::new(&root);
|
||||
store
|
||||
.create(&asset("stable_id"), "png", b"png")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.list().await.unwrap(), vec![asset("stable_id")]);
|
||||
assert_eq!(store.read("stable_id").await.unwrap().bytes, b"png");
|
||||
assert!(root
|
||||
.join(".ideai/icon-assets/icons/stable_id.png")
|
||||
.is_file());
|
||||
store.delete("stable_id").await.unwrap();
|
||||
assert!(!store.exists("stable_id").await.unwrap());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_path_like_ids_and_extensions() {
|
||||
let store = FsCustomProjectIconAssetStore::new(std::env::temp_dir());
|
||||
assert!(store.read("../escape").await.is_err());
|
||||
assert!(store.create(&asset("safe"), "svg", b"x").await.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user