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