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:
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user