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:
2026-09-15 07:57:51 +02:00
parent a082b55c75
commit 0f01d1f9b1
32 changed files with 2002 additions and 151 deletions

View File

@ -86,17 +86,18 @@ use backend::dto::{
ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DeleteLayoutRequestDto, DeleteLayoutResultDto, DetectProfilesRequestDto,
DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto,
GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectSettingsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
CustomProjectIconAssetDto, CustomProjectIconUsageDto, DeleteLayoutRequestDto,
DeleteLayoutResultDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto,
EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto,
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
ImportCustomProjectIconAssetRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto,
LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenProjectRestoreStateDto,
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, RecallMemoryRequestDto,
RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto,
RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
@ -138,6 +139,7 @@ const WS_PATH: &str = "/api/ws";
const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const WS_MAX_PAYLOAD: usize = 64 * 1024;
const WS_OUTPUT_BUFFER: usize = 512;
const HTTP_MAX_REQUEST_BODY_BYTES: usize = 3 * 1024 * 1024;
#[cfg(test)]
type RequestBody = Full<Bytes>;
@ -1078,7 +1080,7 @@ async fn handle_tcp_connection(
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.len() > 1024 * 1024 {
if buffer.len() > HTTP_MAX_REQUEST_BODY_BYTES {
return Err("request too large".to_owned());
}
}
@ -2299,7 +2301,7 @@ async fn dispatch_http(
return cors_response(StatusCode::NO_CONTENT, origin.as_deref());
}
match (method, uri.path()) {
match (method.clone(), uri.path()) {
(Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await,
(Method::POST, "/api/pairing-code") => {
let Some(token) = session_cookie(&headers) else {
@ -2366,6 +2368,25 @@ async fn dispatch_http(
refresh_session_cookie(&mut response, &state, &token);
response
}
(_, path)
if path == "/api/project-icon-assets"
|| path.starts_with("/api/project-icon-assets/") =>
{
let (token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let mut response = project_icon_asset_response(
&method,
path,
body,
Arc::clone(&state),
origin.as_deref(),
)
.await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::POST, "/api/devices/revoke-all") => {
let (_token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
@ -2498,6 +2519,135 @@ async fn dispatch_http(
}
}
async fn project_icon_asset_response(
method: &Method,
path: &str,
body: Bytes,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
if method == Method::GET && path == "/api/project-icon-assets" {
return match state.app.list_custom_project_icon_assets.execute().await {
Ok(assets) => json_response(
StatusCode::OK,
&serde_json::to_value(
assets
.into_iter()
.map(CustomProjectIconAssetDto::from)
.collect::<Vec<_>>(),
)
.unwrap(),
origin,
),
Err(error) => app_error_response(error, origin),
};
}
if method == Method::POST && path == "/api/project-icon-assets" {
let request = match serde_json::from_slice::<ImportCustomProjectIconAssetRequestDto>(&body)
{
Ok(request) => request,
Err(error) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
format!("invalid import request: {error}"),
origin,
)
}
};
let bytes = match base64_decode(&request.data_base64) {
Ok(bytes) => bytes,
Err(error) => return error_dto_response(StatusCode::BAD_REQUEST, error, origin),
};
return match state
.app
.import_custom_project_icon_asset
.execute(request.original_filename, request.media_type, bytes)
.await
{
Ok(asset) => json_response(
StatusCode::CREATED,
&serde_json::to_value(CustomProjectIconAssetDto::from(asset)).unwrap(),
origin,
),
Err(error) => app_error_response(error, origin),
};
}
let Some(asset_path) = path.strip_prefix("/api/project-icon-assets/") else {
return error_response(
StatusCode::METHOD_NOT_ALLOWED,
"METHOD_NOT_ALLOWED",
"method not allowed",
origin,
);
};
if method == Method::GET && asset_path.ends_with("/usage") {
let asset_id = asset_path.trim_end_matches("/usage").trim_end_matches('/');
return match state
.app
.get_custom_project_icon_asset_usage
.execute(asset_id)
.await
{
Ok(usage) => json_response(
StatusCode::OK,
&serde_json::to_value(CustomProjectIconUsageDto::from(usage)).unwrap(),
origin,
),
Err(error) => app_error_response(error, origin),
};
}
if method == Method::GET {
return match state
.app
.read_custom_project_icon_asset
.execute(asset_path)
.await
{
Ok(output) => {
let mut response = Response::new(ResponseBody::bytes(Bytes::from(output.bytes)));
*response.status_mut() = StatusCode::OK;
if let Ok(value) = HeaderValue::from_str(&output.metadata.asset.media_type) {
response.headers_mut().insert(CONTENT_TYPE, value);
}
response.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
add_cors_headers(&mut response, origin);
response
}
Err(error) => app_error_response(error, origin),
};
}
if method == Method::DELETE {
return match state
.app
.delete_custom_project_icon_asset
.execute(asset_path)
.await
{
Ok(usage) => json_response(
StatusCode::OK,
&serde_json::to_value(CustomProjectIconUsageDto::from(usage)).unwrap(),
origin,
),
Err(error) => app_error_response(error, origin),
};
}
error_response(
StatusCode::METHOD_NOT_ALLOWED,
"METHOD_NOT_ALLOWED",
"method not allowed",
origin,
)
}
fn app_error_response(error: AppError, origin: Option<&str>) -> Response<ResponseBody> {
let error = ErrorDto::from(error);
error_dto_response(error_status(&error), error, origin)
}
fn is_api_path(path: &str) -> bool {
path == "/api" || path.starts_with("/api/")
}
@ -5886,6 +6036,9 @@ fn api_route_allows_missing_origin(method: &Method, path: &str) -> bool {
| "/api/project-files/download-file"
| "/api/project-files/download-directory"
)
|| (method == Method::GET
&& (path == "/api/project-icon-assets"
|| path.starts_with("/api/project-icon-assets/")))
}
fn validate_reverse_proxy(
@ -6098,7 +6251,7 @@ fn error_status(error: &ErrorDto) -> StatusCode {
match error.code.as_str() {
"NOT_FOUND" => StatusCode::NOT_FOUND,
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
"CONFLICT" => StatusCode::CONFLICT,
"CONFLICT" | "CUSTOM_ICON_IN_USE" => StatusCode::CONFLICT,
"INVALID" => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
@ -6127,7 +6280,7 @@ fn add_cors_headers(response: &mut Response<ResponseBody>, origin: Option<&str>)
);
response.headers_mut().insert(
"access-control-allow-methods",
HeaderValue::from_static("POST, OPTIONS"),
HeaderValue::from_static("GET, POST, DELETE, OPTIONS"),
);
}
}
@ -6138,7 +6291,7 @@ fn status_for_error(error: &ErrorDto) -> StatusCode {
"UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST,
"INVALID" => StatusCode::BAD_REQUEST,
"NOT_FOUND" => StatusCode::NOT_FOUND,
"CONFLICT" => StatusCode::CONFLICT,
"CONFLICT" | "CUSTOM_ICON_IN_USE" => StatusCode::CONFLICT,
"FORBIDDEN" => StatusCode::FORBIDDEN,
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
_ => StatusCode::INTERNAL_SERVER_ERROR,
@ -6738,6 +6891,7 @@ mod tests {
StructuredSessionDescriptor,
};
use async_trait::async_trait;
use base64::engine::general_purpose::STANDARD;
use domain::ports::{AgentSession, AgentSessionError, EventBus, ReplyStream};
use domain::{
AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskWakePolicy,
@ -10280,6 +10434,152 @@ mod tests {
assert!(!entries.iter().any(|entry| entry["name"] == ".ideai"));
}
#[tokio::test]
async fn custom_project_icon_routes_import_list_read_usage_and_delete() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
let (status, imported, _) = response_json(
request(
Arc::clone(&state),
Method::POST,
"/api/project-icon-assets",
json!({
"originalFilename": "/home/user/avatar.png",
"mediaType": "image/png",
"dataBase64": png_base64
}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(imported["mediaType"], "image/png");
assert_eq!(imported["originalFilename"], "avatar.png");
assert_eq!(imported["width"], 1);
assert_eq!(imported["height"], 1);
assert_eq!(imported["usedByProjectCount"], 0);
let asset_id = imported["id"].as_str().unwrap().to_owned();
let (status, listed, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
"/api/project-icon-assets",
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(listed.as_array().unwrap().len(), 1);
let (status, bytes, headers) = response_bytes(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-icon-assets/{asset_id}"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers.get(CONTENT_TYPE).unwrap(), "image/png");
assert_eq!(STANDARD.decode(png_base64).unwrap(), bytes);
let project_id = create_project_for_test(&state, "Custom Icon User").await;
let (update_status, _) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"update_project_settings",
json!({
"projectId": project_id,
"icon": { "set": "custom", "name": asset_id.clone() }
}),
)
.await;
assert_eq!(update_status, StatusCode::OK);
let (status, usage, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-icon-assets/{asset_id}/usage"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(usage["usedByProjectCount"], 1);
let (status, conflict, _) = response_json(
request(
Arc::clone(&state),
Method::DELETE,
&format!("/api/project-icon-assets/{asset_id}"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(conflict["code"], "CUSTOM_ICON_IN_USE");
let (clear_status, _) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"update_project_settings",
json!({ "projectId": project_id, "icon": null }),
)
.await;
assert_eq!(clear_status, StatusCode::OK);
let (status, deleted, _) = response_json(
request(
Arc::clone(&state),
Method::DELETE,
&format!("/api/project-icon-assets/{asset_id}"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(deleted["assetId"], asset_id);
}
#[tokio::test]
async fn custom_project_icon_import_rejects_svg_content() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, error, _) = response_json(
request(
state,
Method::POST,
"/api/project-icon-assets",
json!({
"originalFilename": "unsafe.svg",
"mediaType": "image/svg+xml",
"dataBase64": STANDARD.encode(b"<svg xmlns='http://www.w3.org/2000/svg'/>")
}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(error["code"], "INVALID");
}
#[tokio::test]
async fn authorized_project_file_gets_allow_missing_origin_header() {
let state = state();