use axum::{ Router, extract::{DefaultBodyLimit, Request}, handler::Handler, http::{HeaderValue, header}, middleware, middleware::Next, response::Response, routing::{get, patch, post}, }; use crate::{ auth::require_bearer_auth, editor_agent::api::editor_agent_message, editor_agent::{ cancel_editor_agent_tool_call, confirm_editor_agent_tool_call, create_editor_agent_conversation, delete_editor_agent_conversation, get_editor_agent_conversation, list_editor_agent_conversations, }, editor_project::{ EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES, create_editor_asset, create_editor_asset_folder, create_editor_project, create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder, delete_editor_project, edit_editor_image, extract_editor_ui_design_assets, generate_editor_image, generate_editor_scene, get_editor_asset_library, get_editor_generation_pricing, get_editor_project, list_editor_projects, list_public_editor_project_resources, load_recent_editor_project, remove_editor_image_background, rename_editor_project, save_editor_project_layout, snap_editor_image_to_pixel_art, submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder, update_editor_project_resource_showcase, }, editor_project_icon::{ generate_editor_icon_spritesheet, generate_icon_spec, split_editor_icon_spritesheet, }, llm::icon_specs::{refine_art_style, refine_game_play}, state::AppState, }; const EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024; async fn add_showcase_private_response_headers(request: Request, next: Next) -> Response { let mut response = next.run(request).await; response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static("private, no-store"), ); response .headers_mut() .append(header::VARY, HeaderValue::from_static("Authorization")); response } pub fn router(state: AppState) -> Router { let auth = middleware::from_fn_with_state(state, require_bearer_auth); let protected_routes = [ ( "/api/editor/projects/recent", get(load_recent_editor_project), ), ( "/api/editor/projects", get(list_editor_projects).post(create_editor_project), ), ( "/api/editor/projects/{project_id}", get(get_editor_project) .patch( save_editor_project_layout .layer(DefaultBodyLimit::max(EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES)), ) .delete(delete_editor_project), ), ( "/api/editor/projects/{project_id}/metadata", patch(rename_editor_project), ), ( "/api/editor/projects/{project_id}/resources", post(create_editor_project_resource), ), ( "/api/editor/projects/{project_id}/agent-conversations", get(list_editor_agent_conversations).post(create_editor_agent_conversation), ), ( "/api/editor/agent-conversations/{conversation_id}", get(get_editor_agent_conversation).delete(delete_editor_agent_conversation), ), ( "/api/editor/agent-conversations/{conversation_id}/messages", post(editor_agent_message) .layer(DefaultBodyLimit::max(EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES)), ), ( "/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/confirm", post(confirm_editor_agent_tool_call), ), ( "/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/cancel", post(cancel_editor_agent_tool_call), ), ( "/api/editor/project-resources/{resource_id}/showcase", patch(update_editor_project_resource_showcase), ), ("/api/editor/assets/library", get(get_editor_asset_library)), ( "/api/editor/assets/folders", post(create_editor_asset_folder), ), ( "/api/editor/assets/folders/{folder_id}", patch(update_editor_asset_folder).delete(delete_editor_asset_folder), ), ("/api/editor/assets", post(create_editor_asset)), ( "/api/editor/assets/{asset_id}", patch(update_editor_asset).delete(delete_editor_asset), ), ( "/api/editor/assets/{asset_id}/showcase-submissions", post(submit_editor_asset_showcase), ), ( "/api/editor/images/generations", post(generate_editor_image), ), ( "/api/editor/scenes/generations", post(generate_editor_scene), ), ( "/api/editor/llm/icon-specs/refine-game-play", post(refine_game_play), ), ( "/api/editor/llm/icon-specs/refine-art-style", post(refine_art_style), ), ( "/api/editor/icon-specs/generations", post(generate_icon_spec), ), ("/api/editor/images/edits", post(edit_editor_image)), ( "/api/editor/images/background-removals", post(remove_editor_image_background), ), ( "/api/editor/images/pixel-art-snaps", post(snap_editor_image_to_pixel_art), ), ( "/api/editor/icon-spritesheets/generations", post(generate_editor_icon_spritesheet), ), ( "/api/editor/icon-spritesheets/slices", post(split_editor_icon_spritesheet), ), ( "/api/editor/ui-designs/assets/extractions", post(extract_editor_ui_design_assets), ), ]; // 在方法层统一鉴权,未支持的方法仍由 Axum 返回 405。 let protected_router = protected_routes .into_iter() .fold(Router::new(), |router, (path, methods)| { router.route(path, methods.route_layer(auth.clone())) }); // 点赞拒绝响应也必须带私有缓存头,保持该层位于鉴权外侧。 let showcase_likes_router = Router::new().route( "/api/editor/showcase/assets/{showcase_id}/likes", post(toggle_editor_showcase_asset_like) .route_layer(auth) .route_layer(middleware::from_fn(add_showcase_private_response_headers)), ); Router::new() .route( "/api/editor/generation-pricing", get(get_editor_generation_pricing), ) .route( "/api/editor/showcase/resources", get(list_public_editor_project_resources) .route_layer(middleware::from_fn(add_showcase_private_response_headers)), ) .merge(protected_router) .merge(showcase_likes_router) } #[cfg(test)] mod tests { use axum::{ Router, body::Body, http::{Request, header}, middleware, response::{IntoResponse, Response}, routing::get, }; use tower::ServiceExt; use super::add_showcase_private_response_headers; async fn response_with_existing_vary() -> Response { let mut response = "ok".into_response(); response .headers_mut() .insert(header::VARY, "Origin".parse().expect("valid Vary value")); response } #[tokio::test] async fn showcase_private_headers_preserve_existing_vary_fields() { let app = Router::new().route( "/", get(response_with_existing_vary) .route_layer(middleware::from_fn(add_showcase_private_response_headers)), ); let response = app .oneshot( Request::builder() .uri("/") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should succeed"); let vary_values = response .headers() .get_all(header::VARY) .iter() .map(|value| value.to_str().expect("Vary should be ASCII")) .collect::>(); assert_eq!(vary_values, vec!["Origin", "Authorization"]); } } #[cfg(test)] mod route_contract_tests { use axum::{ body::Body, http::{Request, StatusCode, header}, middleware, }; use http_body_util::BodyExt; use tower::ServiceExt; use super::router; use crate::{config::AppConfig, request_context::attach_request_context, state::AppState}; const PROTECTED_ROUTES: &[(&str, &[&str])] = &[ ("/api/editor/projects/recent", &["GET"]), ("/api/editor/projects", &["GET", "POST"]), ( "/api/editor/projects/{project_id}", &["GET", "PATCH", "DELETE"], ), ("/api/editor/projects/{project_id}/metadata", &["PATCH"]), ("/api/editor/projects/{project_id}/resources", &["POST"]), ( "/api/editor/projects/{project_id}/agent-conversations", &["GET", "POST"], ), ( "/api/editor/agent-conversations/{conversation_id}", &["GET", "DELETE"], ), ( "/api/editor/agent-conversations/{conversation_id}/messages", &["POST"], ), ( "/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/confirm", &["POST"], ), ( "/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/cancel", &["POST"], ), ( "/api/editor/project-resources/{resource_id}/showcase", &["PATCH"], ), ("/api/editor/assets/library", &["GET"]), ("/api/editor/assets/folders", &["POST"]), ( "/api/editor/assets/folders/{folder_id}", &["PATCH", "DELETE"], ), ("/api/editor/assets", &["POST"]), ("/api/editor/assets/{asset_id}", &["PATCH", "DELETE"]), ( "/api/editor/assets/{asset_id}/showcase-submissions", &["POST"], ), ("/api/editor/showcase/assets/{showcase_id}/likes", &["POST"]), ("/api/editor/images/generations", &["POST"]), ("/api/editor/scenes/generations", &["POST"]), ("/api/editor/llm/icon-specs/refine-game-play", &["POST"]), ("/api/editor/llm/icon-specs/refine-art-style", &["POST"]), ("/api/editor/icon-specs/generations", &["POST"]), ("/api/editor/images/edits", &["POST"]), ("/api/editor/images/background-removals", &["POST"]), ("/api/editor/images/pixel-art-snaps", &["POST"]), ("/api/editor/icon-spritesheets/generations", &["POST"]), ("/api/editor/icon-spritesheets/slices", &["POST"]), ("/api/editor/ui-designs/assets/extractions", &["POST"]), ]; fn app() -> axum::Router { let state = AppState::new(AppConfig::default()).expect("state should build"); router(state.clone()) .layer(middleware::from_fn(attach_request_context)) .with_state(state) } #[tokio::test] async fn protected_route_matrix_keeps_auth_methods_and_head_behavior() { let app = app(); for (template, supported) in PROTECTED_ROUTES { let path = template .split('/') .map(|segment| { if segment.starts_with('{') { "fixture" } else { segment } }) .collect::>() .join("/"); let mut allowed = supported.to_vec(); if allowed.contains(&"GET") { allowed.push("HEAD"); } allowed.sort_unstable(); for credential in [None, Some("Bearer invalid-token")] { for method in [ "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", ] { let mut request = Request::builder().method(method).uri(&path); if let Some(credential) = credential { request = request.header(header::AUTHORIZATION, credential); } let response = app .clone() .oneshot(request.body(Body::empty()).expect("request should build")) .await .expect("request should complete"); let expected = if allowed.contains(&method) { StatusCode::UNAUTHORIZED } else { StatusCode::METHOD_NOT_ALLOWED }; assert_eq!( response.status(), expected, "{method} {path}, credential={credential:?}" ); if expected == StatusCode::METHOD_NOT_ALLOWED { let mut actual = response.headers()[header::ALLOW] .to_str() .expect("Allow should be ASCII") .split(',') .map(str::trim) .collect::>(); actual.sort_unstable(); assert_eq!(actual, allowed, "Allow for {method} {path}"); } if method == "HEAD" { assert!( response .into_body() .collect() .await .expect("HEAD body should collect") .to_bytes() .is_empty(), "HEAD {path} must not return a body" ); } } } } } #[tokio::test] async fn unknown_paths_remain_not_found_before_authentication() { for credential in [None, Some("Bearer invalid-token")] { let mut request = Request::builder().uri("/api/editor/missing/unknown/route"); if let Some(credential) = credential { request = request.header(header::AUTHORIZATION, credential); } let response = app() .oneshot(request.body(Body::empty()).expect("request should build")) .await .expect("request should complete"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } } #[tokio::test] async fn public_reads_and_showcase_rejections_keep_cache_boundaries() { use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, }; use std::time::Duration; // 精选读取会访问正式 facade;隔离上游返回 503,明确验证匿名请求已进入 handler。 let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("fixture should bind"); let address = listener .local_addr() .expect("fixture should have an address"); let calls = Arc::new(AtomicUsize::new(0)); let fixture_calls = calls.clone(); let fixture = axum::Router::new().fallback(move || { fixture_calls.fetch_add(1, Ordering::SeqCst); async { StatusCode::SERVICE_UNAVAILABLE } }); let server = tokio::spawn(async move { axum::serve(listener, fixture) .await .expect("fixture should serve"); }); let state = AppState::new(AppConfig { spacetime_server_url: format!("http://{address}"), spacetime_database: "route-showcase-unavailable-fixture".to_string(), spacetime_procedure_timeout: Duration::from_secs(2), ..AppConfig::default() }) .expect("state should build"); let app = router(state.clone()) .layer(middleware::from_fn(attach_request_context)) .with_state(state); let pricing = app .clone() .oneshot( Request::builder() .uri("/api/editor/generation-pricing") .body(Body::empty()) .expect("pricing request should build"), ) .await .expect("pricing request should complete"); assert_eq!(pricing.status(), StatusCode::OK); assert_eq!(calls.load(Ordering::SeqCst), 0); let response = tokio::time::timeout( Duration::from_secs(5), app.clone().oneshot( Request::builder() .uri("/api/editor/showcase/resources") .body(Body::empty()) .expect("showcase request should build"), ), ) .await .expect("showcase request should remain bounded") .expect("showcase request should complete"); server.abort(); let _ = server.await; assert_eq!(response.status(), StatusCode::BAD_GATEWAY); assert!( calls.load(Ordering::SeqCst) > 0, "anonymous request must reach the database facade" ); assert_eq!( response.headers()[header::CACHE_CONTROL], "private, no-store" ); assert!( response .headers() .get_all(header::VARY) .iter() .any(|value| value == "Authorization") ); let body = response .into_body() .collect() .await .expect("error body should collect") .to_bytes(); let payload: serde_json::Value = serde_json::from_slice(&body).expect("error should be JSON"); assert_eq!(payload["error"]["code"], "UPSTREAM_ERROR"); assert_eq!(payload["error"]["details"]["provider"], "spacetimedb"); assert!( payload["error"]["details"]["message"] .as_str() .is_some_and(|message| message.contains("503")) ); for (method, path) in [ ("GET", "/api/editor/showcase/resources"), ("POST", "/api/editor/showcase/assets/fixture/likes"), ] { let response = app .clone() .oneshot( Request::builder() .method(method) .uri(path) .header(header::AUTHORIZATION, "Bearer invalid-token") .body(Body::empty()) .expect("showcase request should build"), ) .await .expect("showcase request should complete"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!( response.headers()[header::CACHE_CONTROL], "private, no-store" ); assert!( response .headers() .get_all(header::VARY) .iter() .any(|value| value == "Authorization") ); } } #[tokio::test] async fn authentication_precedes_layout_and_agent_body_limits() { let app = app(); for (method, path, limit) in [ ( "PATCH", "/api/editor/projects/fixture", super::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES, ), ( "POST", "/api/editor/agent-conversations/fixture/messages", super::EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES, ), ] { let response = app .clone() .oneshot( Request::builder() .method(method) .uri(path) .header(header::CONTENT_TYPE, "application/json") .body(Body::from("x".repeat(limit + 1))) .expect("oversized request should build"), ) .await .expect("oversized request should complete"); assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "{method} {path}" ); } } #[tokio::test] async fn authenticated_layout_and_agent_requests_keep_distinct_body_limits() { use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; let state = AppState::new(AppConfig::default()).expect("state should build"); let user = state .seed_test_phone_user_with_password("13800138991", "secret123") .await; let session_id = state.seed_test_refresh_session_for_user(&user, "session-route-body-limits"); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: user.id.clone(), session_id, provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: user.token_version, phone_verified: false, binding_status: BindingStatus::Active, display_name: Some(user.display_name.clone()), }, state.auth_jwt_config(), time::OffsetDateTime::now_utc(), ) .expect("claims should build"); let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign"); let app = router(state.clone()) .layer(middleware::from_fn(attach_request_context)) .with_state(state); for (method, path, size, expected) in [ ( "PATCH", "/api/editor/projects/fixture", 2 * 1024 * 1024 + 1, StatusCode::UNPROCESSABLE_ENTITY, ), ( "PATCH", "/api/editor/projects/fixture", super::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES + 1, StatusCode::PAYLOAD_TOO_LARGE, ), ( "POST", "/api/editor/agent-conversations/fixture/messages", super::EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES + 1, StatusCode::PAYLOAD_TOO_LARGE, ), ] { // 用有效 JSON 字符串触发类型校验,避免进入业务 handler 或产生持久化副作用。 let body = format!("\"{}\"", "x".repeat(size)); let response = app .clone() .oneshot( Request::builder() .method(method) .uri(path) .header(header::AUTHORIZATION, format!("Bearer {token}")) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(body)) .expect("body limit request should build"), ) .await .expect("body limit request should complete"); assert_eq!(response.status(), expected, "{method} {path}, size={size}"); } } #[tokio::test] async fn method_auth_layer_leaves_405_outside_authentication() { async fn reject(_: Request, _: axum::middleware::Next) -> StatusCode { StatusCode::UNAUTHORIZED } let method_layer = axum::Router::new().route( "/only-get", axum::routing::get(|| async { "ok" }).route_layer(middleware::from_fn(reject)), ); let router_layer = axum::Router::new() .route("/only-get", axum::routing::get(|| async { "ok" })) .route_layer(middleware::from_fn(reject)); for (app, expected) in [ (method_layer, StatusCode::METHOD_NOT_ALLOWED), (router_layer, StatusCode::UNAUTHORIZED), ] { let response = app .oneshot( Request::builder() .method("OPTIONS") .uri("/only-get") .body(Body::empty()) .expect("request should build"), ) .await .expect("request should complete"); assert_eq!(response.status(), expected); } } }