Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m20s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m3s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / Backend tests (pull_request) Successful in 7m36s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m26s
Project CI / Repository checks (pull_request) Successful in 6m25s
Project CI / Native shell tests (pull_request) Successful in 9m34s

# Conflicts:
#	deploy/container/nginx.conf
#	deploy/nginx/genarrative-dev-http.conf
#	deploy/nginx/genarrative.conf
#	server-rs/crates/api-server/src/modules/game_distribution.rs
#	src/components/game-distribution/GameDetailPage.tsx
#	src/components/game-distribution/GameDistributionPages.test.tsx
#	src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
This commit is contained in:
2026-10-06 00:19:40 +08:00
131 changed files with 15727 additions and 5835 deletions
+2 -1
View File
@@ -12,6 +12,7 @@ base64 = { workspace = true }
cbc = { workspace = true }
bytes = { workspace = true }
dotenvy = { workspace = true }
flate2 = { workspace = true }
hex = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
http-body-util = { workspace = true }
@@ -21,7 +22,7 @@ rmcp = { workspace = true, features = ["server", "transport-streamable-http-serv
webp = { workspace = true }
module-ai = { workspace = true }
module-assets = { workspace = true, features = ["server-service"] }
module-auth = { workspace = true }
module-auth = { workspace = true, features = ["services"] }
module-editor-agent = { workspace = true }
module-game-distribution = { workspace = true }
module-runtime = { workspace = true }
+1
View File
@@ -50,6 +50,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::external_api::router(state.clone()))
.merge(modules::frontend_runtime_config::router(state.clone()))
.merge(modules::game_distribution::router(state.clone()))
.merge(modules::creator::router(state.clone()))
.merge(modules::assets::router(state.clone()))
.merge(modules::editor_project::router(state.clone()))
.merge(modules::platform::router(state.clone()))
@@ -2,6 +2,7 @@ pub mod admin;
pub mod assets;
pub mod auth;
pub mod client_downloads;
pub mod creator;
pub mod editor_project;
pub mod external_api;
pub mod external_generation;
@@ -0,0 +1,343 @@
use axum::{
Router,
extract::{Extension, Path, Query, Request, State, rejection::QueryRejection},
http::{HeaderValue, StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{delete, get, put},
};
use module_auth::creator::{
ConnectionCursor, ConnectionKind, CreatorAction, normalize_user_id, parse_list_limit,
};
use serde::Deserialize;
use spacetime_client::{SpacetimeClientError, creator::CreatorListInput};
use crate::{
api_response::json_success_body, auth::optional_access_token_from_headers,
http_error::AppError, request_context::RequestContext, state::AppState,
};
#[derive(Clone)]
struct CreatorViewer(Option<String>);
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConnectionsQuery {
limit: Option<String>,
cursor: Option<String>,
}
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route("/api/creators/{user_id}", get(profile))
.route("/api/creators/{user_id}/following", get(following))
.route("/api/creators/{user_id}/followers", get(followers))
.route("/api/creators/{user_id}/relationship", get(relationship))
.route(
"/api/creators/{user_id}/follow",
put(follow).delete(unfollow),
)
.route(
"/api/creators/me/followers/{user_id}",
delete(remove_follower),
)
.route_layer(middleware::from_fn_with_state(state, creator_auth))
.route_layer(middleware::from_fn(no_store))
}
async fn no_store(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
}
async fn creator_auth(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let ctx = request
.extensions()
.get::<RequestContext>()
.ok_or_else(|| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?;
let authenticated = optional_access_token_from_headers(
&state,
request.uri().path().into(),
request.headers().clone(),
ctx.request_id().into(),
)
.await?;
request.extensions_mut().insert(CreatorViewer(
authenticated.map(|auth| auth.claims().user_id().to_owned()),
));
Ok(next.run(request).await)
}
fn input_error(error: impl std::fmt::Display) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
fn actor(viewer: CreatorViewer) -> Result<String, AppError> {
viewer
.0
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))
}
fn database_error(error: SpacetimeClientError) -> AppError {
let status = match &error {
SpacetimeClientError::Procedure(code) => match code.as_str() {
"invalid_input" => StatusCode::BAD_REQUEST,
"user_not_found" => StatusCode::NOT_FOUND,
"forbidden" => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR,
},
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
// 不把底层连接信息或内部身份送到公开响应。
AppError::from_status(status)
}
async fn profile(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let id = normalize_user_id(&id).map_err(input_error)?;
let profile = state
.spacetime_client()
.creator_profile(id)
.await
.map_err(database_error)?;
Ok(json_success_body(Some(&ctx), profile).into_response())
}
async fn relationship(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(viewer): Extension<CreatorViewer>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let actor_id = actor(viewer)?;
let id = normalize_user_id(&id).map_err(input_error)?;
let relation = state
.spacetime_client()
.creator_relationship(actor_id, id)
.await
.map_err(database_error)?;
Ok(json_success_body(Some(&ctx), relation).into_response())
}
fn list_input(
id: &str,
viewer: CreatorViewer,
query: Result<Query<ConnectionsQuery>, QueryRejection>,
kind: ConnectionKind,
) -> Result<CreatorListInput, AppError> {
let id = normalize_user_id(id).map_err(input_error)?;
let Query(query) = query.map_err(|_| input_error("列表查询参数不合法"))?;
let limit = parse_list_limit(query.limit.as_deref()).map_err(input_error)? as u32;
if let Some(cursor) = &query.cursor {
ConnectionCursor::decode(cursor, &id, kind).map_err(input_error)?;
}
Ok(CreatorListInput {
owner_id: id,
viewer_id: viewer.0,
kind,
limit,
cursor: query.cursor,
})
}
async fn following(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(viewer): Extension<CreatorViewer>,
Path(id): Path<String>,
query: Result<Query<ConnectionsQuery>, QueryRejection>,
) -> Result<Response, AppError> {
connections(
state,
ctx,
list_input(&id, viewer, query, ConnectionKind::Following)?,
)
.await
}
async fn followers(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(viewer): Extension<CreatorViewer>,
Path(id): Path<String>,
query: Result<Query<ConnectionsQuery>, QueryRejection>,
) -> Result<Response, AppError> {
connections(
state,
ctx,
list_input(&id, viewer, query, ConnectionKind::Followers)?,
)
.await
}
async fn connections(
state: AppState,
ctx: RequestContext,
input: CreatorListInput,
) -> Result<Response, AppError> {
let list = state
.spacetime_client()
.creator_connections(input)
.await
.map_err(database_error)?;
Ok(json_success_body(Some(&ctx), list).into_response())
}
async fn mutate(
state: AppState,
ctx: RequestContext,
viewer: CreatorViewer,
id: String,
action: CreatorAction,
) -> Result<Response, AppError> {
let actor_id = actor(viewer)?;
let command =
module_auth::creator::prepare_mutation(&actor_id, &id, action).map_err(input_error)?;
let response = state
.spacetime_client()
.mutate_creator_relationship(command.actor_id, command.target_id, action)
.await
.map_err(database_error)?;
Ok(json_success_body(Some(&ctx), response).into_response())
}
async fn follow(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(viewer): Extension<CreatorViewer>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
mutate(state, ctx, viewer, id, CreatorAction::Follow).await
}
async fn unfollow(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(viewer): Extension<CreatorViewer>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
mutate(state, ctx, viewer, id, CreatorAction::Unfollow).await
}
async fn remove_follower(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(viewer): Extension<CreatorViewer>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
mutate(state, ctx, viewer, id, CreatorAction::RemoveFollower).await
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, http::Request};
use tower::ServiceExt;
#[tokio::test]
async fn creator_private_routes_reject_anonymous_and_forged_actor_headers() {
let app =
crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap());
for (method, path) in [
("GET", "/api/creators/b/relationship"),
("PUT", "/api/creators/b/follow"),
("DELETE", "/api/creators/b/follow"),
("DELETE", "/api/creators/me/followers/b"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(path)
.header("x-genarrative-authenticated-user-id", "a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"{method} {path}"
);
assert_eq!(
response.headers()[header::CACHE_CONTROL],
"private, no-store"
);
}
}
#[tokio::test]
async fn creator_public_routes_reject_invalid_bearer_instead_of_downgrading() {
let app =
crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap());
for path in [
"/api/creators/b",
"/api/creators/b/following",
"/api/creators/b/followers",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(path)
.header(header::AUTHORIZATION, "Bearer invalid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
}
#[tokio::test]
async fn creator_lists_reject_invalid_limit_cursor_and_viewer_override_before_database() {
let app =
crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap());
for query in [
"limit=0",
"limit=51",
"limit=1.5",
"cursor=invalid",
"viewerId=b",
"limit=1&limit=2",
] {
for kind in ["following", "followers"] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/creators/b/{kind}?{query}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{kind} {query}");
}
}
}
#[test]
fn creator_error_mapping_is_explicit_and_hides_internal_failures() {
for (code, status) in [
("user_not_found", StatusCode::NOT_FOUND),
("forbidden", StatusCode::FORBIDDEN),
("invalid_input", StatusCode::BAD_REQUEST),
(
"secret connection details",
StatusCode::INTERNAL_SERVER_ERROR,
),
] {
let error = database_error(SpacetimeClientError::Procedure(code.into()));
assert_eq!(error.status_code(), status);
assert!(!error.message().contains("secret"));
}
}
}
File diff suppressed because it is too large Load Diff