diff --git a/deploy/miniapp-market/README.md b/deploy/miniapp-market/README.md index a4f6371cdf..9380520fee 100644 --- a/deploy/miniapp-market/README.md +++ b/deploy/miniapp-market/README.md @@ -274,10 +274,19 @@ curl -fsS https://market.openbitfun.com/miniapp/api/v1/config curl -fsS https://market.openbitfun.com/miniapp/ >/dev/null ``` +生产 `/config` 应返回 `"webSubmissionsEnabled":false`。此开关关闭时,Web +投稿写请求由后端拒绝,网页仅保留“我的投稿”历史;BitFun Desktop 的 Bearer +投稿和 Web 管理员审核继续可用。未来重新开放网页投稿时,必须先完成对应安全 +回归,再显式修改 root-only `market.env` 并仅 recreate 市场容器。 +环境变量缺失时后端也默认关闭,但生产 `market.env` 应显式保留 +`MARKET_WEB_SUBMISSIONS_ENABLED=false`,避免后续运维人员误判当前策略。 + 再用浏览器人工检查: - `/miniapp/` 能加载,刷新子页面不会 404; - `MARKET_PUBLIC_BROWSE=false` 时匿名目录按预期关闭; +- `MARKET_WEB_SUBMISSIONS_ENABLED=false` 时投稿/更新/撤回按钮不可见,直接访问 + `/miniapp/submit` 提示改用 BitFun Desktop,“我的投稿”仍可读取; - OAuth 已配置时,GitHub 登录 callback 正常; - 与本次改动有关的浏览、下载、投稿、审核、安装或更新流程正常; - Relay、New API 和官网仍可用,且它们的容器/vhost 没被重启或改写。 diff --git a/deploy/miniapp-market/market.env.example b/deploy/miniapp-market/market.env.example index 066260d199..3ba6d236f6 100644 --- a/deploy/miniapp-market/market.env.example +++ b/deploy/miniapp-market/market.env.example @@ -1,5 +1,7 @@ MARKET_PUBLIC_BASE_URL=https://market.openbitfun.com/miniapp MARKET_PUBLIC_BROWSE=false +# Keep browser-based submission writes disabled. BitFun Desktop uses Bearer auth and remains enabled. +MARKET_WEB_SUBMISSIONS_ENABLED=false MARKET_GITHUB_CLIENT_ID= MARKET_GITHUB_CLIENT_SECRET= MARKET_SESSION_SECRET=replace-with-at-least-32-random-bytes diff --git a/src/apps/miniapp-market-server/README.md b/src/apps/miniapp-market-server/README.md index 3cb4d79d54..053679b527 100644 --- a/src/apps/miniapp-market-server/README.md +++ b/src/apps/miniapp-market-server/README.md @@ -87,8 +87,14 @@ cargo run -p bitfun-miniapp-market-server | `MARKET_SESSION_SECRET` | 会话签名 secret,生产至少 24 字符并应使用强随机值 | | `MARKET_ADMIN_GITHUB_IDS` | 逗号分隔的 GitHub 数字 ID | | `MARKET_PUBLIC_BROWSE` | 是否向匿名用户开放目录 | +| `MARKET_WEB_SUBMISSIONS_ENABLED` | 是否允许 Web Cookie 会话投稿;默认及生产为 `false`,Desktop Bearer 投稿不受影响 | | `RUST_LOG` | 英文 JSON 日志过滤器 | +未设置 `MARKET_WEB_SUBMISSIONS_ENABLED` 时服务按 `false` 处理;它与 +`MARKET_PUBLIC_BROWSE` 相互独立。生产修改该值需要仅 recreate 市场容器,并通过 +`GET /miniapp/api/v1/config` 核对实际的 `webSubmissionsEnabled`,不能只根据 +env 文件内容判断已经生效。 + 固定生产 OAuth callback 是: `https://market.openbitfun.com/miniapp/api/v1/auth/github/callback` diff --git a/src/crates/services/miniapp-market-service/README.md b/src/crates/services/miniapp-market-service/README.md index 7e20af2b79..7adde41afc 100644 --- a/src/crates/services/miniapp-market-service/README.md +++ b/src/crates/services/miniapp-market-service/README.md @@ -51,6 +51,26 @@ - GitHub token 只用于读取公开 `{id,login,avatar_url}`,随后丢弃,不能下发给 Web 或桌面客户端。 - 管理员身份每次请求按 GitHub 数字 ID 计算,不能依赖客户端声明。 +- `MARKET_WEB_SUBMISSIONS_ENABLED=false` 时,所有投稿写路由会在读取请求体前 + 拒绝 Web Cookie 会话;Desktop Bearer 投稿、投稿历史读取和 Web 管理员审核 + 保持可用。UI 隐藏不是这一边界的替代品。 + +## 当前投稿入口与鉴权矩阵 + +生产默认 `MARKET_WEB_SUBMISSIONS_ENABLED=false`。该开关只控制普通用户的投稿 +写入,不控制目录、评分收藏、只读投稿历史或管理员审核: + +| 请求面 | Web Cookie | Desktop Bearer | 开关关闭时 | +| --- | --- | --- | --- | +| 公开目录、详情、下载 | 可选登录 | 可选登录 | 不变 | +| 评分与收藏 | 登录并校验 CSRF | 登录 | 不变 | +| `GET /submissions`、`GET /submissions/{id}` | 登录 | 登录 | 保持可读 | +| `POST /submissions`、包/截图 PUT/DELETE、submit、withdraw | 登录并校验 CSRF | 登录 | Web 在读取 body 前返回 `403 web_submissions_disabled`;Desktop 保持可写 | +| `/admin/*` 审核和下架 | 管理员登录并校验 CSRF | 管理员登录 | 不受该开关影响 | + +匿名或无效凭据的投稿写请求仍先返回 `401 unauthorized`。未来重新开放 Web 投稿时, +必须显式设置环境变量、recreate 容器,并重新验证 CSRF、上传大小、恶意包拒绝和 +所有投稿状态转换;不得仅修改前端显示条件。 ## 本地验证 diff --git a/src/crates/services/miniapp-market-service/src/auth.rs b/src/crates/services/miniapp-market-service/src/auth.rs index dd78b58034..72451ebaa8 100644 --- a/src/crates/services/miniapp-market-service/src/auth.rs +++ b/src/crates/services/miniapp-market-service/src/auth.rs @@ -634,6 +634,7 @@ mod tests { session_secret: "test-session-secret-at-least-24".to_string(), admin_github_ids: HashSet::from([24753352]), public_browse: false, + web_submissions_enabled: false, } } diff --git a/src/crates/services/miniapp-market-service/src/config.rs b/src/crates/services/miniapp-market-service/src/config.rs index 9e19e1023b..7377ddd42a 100644 --- a/src/crates/services/miniapp-market-service/src/config.rs +++ b/src/crates/services/miniapp-market-service/src/config.rs @@ -16,6 +16,7 @@ pub struct MarketConfig { pub session_secret: String, pub admin_github_ids: HashSet, pub public_browse: bool, + pub web_submissions_enabled: bool, } impl MarketConfig { @@ -57,6 +58,9 @@ impl MarketConfig { let public_browse = env::var("MARKET_PUBLIC_BROWSE") .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes")) .unwrap_or(true); + let web_submissions_enabled = env::var("MARKET_WEB_SUBMISSIONS_ENABLED") + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes")) + .unwrap_or(false); Ok(Self { bind, @@ -69,6 +73,7 @@ impl MarketConfig { session_secret, admin_github_ids, public_browse, + web_submissions_enabled, }) } diff --git a/src/crates/services/miniapp-market-service/src/lib.rs b/src/crates/services/miniapp-market-service/src/lib.rs index 7321e6eb8f..9df559c431 100644 --- a/src/crates/services/miniapp-market-service/src/lib.rs +++ b/src/crates/services/miniapp-market-service/src/lib.rs @@ -132,6 +132,7 @@ mod tests { session_secret: "test-session-secret-at-least-24".to_string(), admin_github_ids: [24753352].into_iter().collect(), public_browse: true, + web_submissions_enabled: false, }; tokio::fs::create_dir_all(&config.web_dir).await.unwrap(); tokio::fs::write(config.web_dir.join("index.html"), "") diff --git a/src/crates/services/miniapp-market-service/src/routes.rs b/src/crates/services/miniapp-market-service/src/routes.rs index 9addcf14e2..008feb6acb 100644 --- a/src/crates/services/miniapp-market-service/src/routes.rs +++ b/src/crates/services/miniapp-market-service/src/routes.rs @@ -1,14 +1,16 @@ use crate::artifacts::ArtifactStore; use crate::auth::{ AuthService, CompletedOAuth, DesktopAuthPollRequest, RefreshTokenRequest, RequestAuth, + RequestAuthKind, }; use crate::config::MarketConfig; use crate::db::{AuthenticatedUser, Database}; use crate::error::{MarketError, MarketResult}; use crate::package::{validate_market_package, validate_min_bitfun_version, validate_screenshot}; use axum::body::{Body, Bytes}; -use axum::extract::{DefaultBodyLimit, Path, Query, State}; -use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::extract::{DefaultBodyLimit, Path, Query, Request, State}; +use axum::http::{header, HeaderMap, HeaderValue, Method, StatusCode}; +use axum::middleware::Next; use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{any, get, post, put}; use axum::{Json, Router}; @@ -99,6 +101,7 @@ struct ModerationReason { struct MarketConfigResponse { github_auth_configured: bool, public_browse: bool, + web_submissions_enabled: bool, categories: &'static [&'static str], } @@ -135,6 +138,7 @@ struct AdminSubmissionDetail { } pub(crate) fn api_router(state: Arc) -> Router { + let submission_policy_state = state.clone(); Router::new() .route("/health", get(health)) .route("/config", get(config)) @@ -200,6 +204,10 @@ pub(crate) fn api_router(state: Arc) -> Router { // to be an explicit wildcard route that outranks `/miniapp/{*rest}`. .route("/{*rest}", any(api_not_found)) .layer(DefaultBodyLimit::max(21 * 1024 * 1024)) + .layer(axum::middleware::from_fn_with_state( + submission_policy_state, + enforce_submission_write_policy, + )) .with_state(state) } @@ -207,6 +215,35 @@ async fn api_not_found() -> MarketError { MarketError::not_found("Unknown API route.") } +async fn enforce_submission_write_policy( + State(state): State>, + request: Request, + next: Next, +) -> MarketResult { + if is_submission_write_request(request.method(), request.uri().path()) { + // Authenticate before Axum reads a JSON/package/screenshot body. This keeps the + // disabled Web surface from becoming an unauthenticated upload sink. + require_submission_write_auth(&state, request.headers()).await?; + } + Ok(next.run(request).await) +} + +fn is_submission_write_request(method: &Method, path: &str) -> bool { + let path = path + .strip_prefix("/miniapp/api/v1") + .unwrap_or(path) + .trim_matches('/'); + let segments = path.split('/').collect::>(); + matches!( + (method.as_str(), segments.as_slice()), + ("POST", ["submissions"]) + | ("DELETE", ["submissions", _]) + | ("PUT", ["submissions", _, "package"]) + | ("POST", ["submissions", _, "submit"]) + | ("PUT" | "DELETE", ["submissions", _, "screenshots", _]) + ) +} + async fn health(State(state): State>) -> impl IntoResponse { let database_ready = sqlx::query_scalar::<_, i64>("SELECT 1") .fetch_one(state.db.pool()) @@ -230,6 +267,7 @@ async fn config(State(state): State>) -> Json, ) -> MarketResult> { - let auth = require_write_auth(&state, &headers).await?; + let auth = require_submission_write_auth(&state, &headers).await?; validate_submission_request(&request)?; let listing_id = validate_listing_ownership_and_release( &state, @@ -575,7 +613,7 @@ async fn upload_submission_package( Path(submission_id): Path, body: Bytes, ) -> MarketResult> { - let auth = require_write_auth(&state, &headers).await?; + let auth = require_submission_write_auth(&state, &headers).await?; let submission = submission_by_id(&state, &submission_id, auth.user.internal_id, false).await?; if submission.status != MarketSubmissionStatus::Draft { return Err(MarketError::conflict( @@ -618,7 +656,7 @@ async fn upload_submission_screenshot( Path((submission_id, position)): Path<(String, u32)>, body: Bytes, ) -> MarketResult> { - let auth = require_write_auth(&state, &headers).await?; + let auth = require_submission_write_auth(&state, &headers).await?; if position as usize >= MARKET_MAX_SCREENSHOTS { return Err(MarketError::bad_request( "invalid_screenshot_position", @@ -667,7 +705,7 @@ async fn delete_submission_screenshot( headers: HeaderMap, Path((submission_id, position)): Path<(String, u32)>, ) -> MarketResult { - let auth = require_write_auth(&state, &headers).await?; + let auth = require_submission_write_auth(&state, &headers).await?; let submission = submission_by_id(&state, &submission_id, auth.user.internal_id, false).await?; if submission.status != MarketSubmissionStatus::Draft { return Err(MarketError::conflict( @@ -689,7 +727,7 @@ async fn submit_submission( headers: HeaderMap, Path(submission_id): Path, ) -> MarketResult> { - let auth = require_write_auth(&state, &headers).await?; + let auth = require_submission_write_auth(&state, &headers).await?; let submission = submission_by_id(&state, &submission_id, auth.user.internal_id, false).await?; if submission.status != MarketSubmissionStatus::Draft { return Err(MarketError::conflict( @@ -745,7 +783,7 @@ async fn withdraw_submission( headers: HeaderMap, Path(submission_id): Path, ) -> MarketResult> { - let auth = require_write_auth(&state, &headers).await?; + let auth = require_submission_write_auth(&state, &headers).await?; let now = Utc::now().timestamp(); let updated = sqlx::query( "UPDATE submissions SET status = 'withdrawn', updated_at = ? @@ -1716,6 +1754,22 @@ async fn require_write_auth(state: &MarketState, headers: &HeaderMap) -> MarketR Ok(auth) } +async fn require_submission_write_auth( + state: &MarketState, + headers: &HeaderMap, +) -> MarketResult { + let auth = state.auth.require_auth(headers).await?; + if !state.config.web_submissions_enabled && matches!(&auth.kind, RequestAuthKind::Web { .. }) { + return Err(MarketError::new( + StatusCode::FORBIDDEN, + "web_submissions_disabled", + "Web submissions are disabled. Use BitFun Desktop to submit MiniApps.", + )); + } + state.auth.require_csrf(headers, &auth)?; + Ok(auth) +} + async fn require_admin(state: &MarketState, headers: &HeaderMap) -> MarketResult { let auth = state.auth.require_auth(headers).await?; if !state.auth.is_admin(&auth.user) { @@ -2059,6 +2113,7 @@ mod tests { session_secret: "test-session-secret-at-least-24".to_string(), admin_github_ids: HashSet::from([24753352]), public_browse: true, + web_submissions_enabled: false, }; let db = Database::open(&config.database_path).await.unwrap(); let artifacts = ArtifactStore::open(config.artifact_dir.clone()) @@ -2214,4 +2269,128 @@ mod tests { .unwrap(); assert_eq!(audit_count, 2); } + + #[test] + fn submission_write_policy_excludes_reads_and_admin_review_routes() { + assert!(is_submission_write_request(&Method::POST, "/submissions")); + assert!(is_submission_write_request( + &Method::PUT, + "/submissions/submission-id/package" + )); + assert!(is_submission_write_request( + &Method::DELETE, + "/miniapp/api/v1/submissions/submission-id/screenshots/0" + )); + assert!(!is_submission_write_request(&Method::GET, "/submissions")); + assert!(!is_submission_write_request( + &Method::POST, + "/admin/submissions/submission-id/decision" + )); + assert!(!is_submission_write_request( + &Method::PUT, + "/listings/example/favorite" + )); + } + + #[tokio::test] + async fn disabled_web_submission_writes_are_rejected_before_body_parsing() { + use tower::ServiceExt; + + let temporary = tempfile::tempdir().unwrap(); + let config = MarketConfig { + bind: "127.0.0.1:0".parse().unwrap(), + public_base_url: "https://market.openbitfun.com/miniapp".to_string(), + database_path: temporary.path().join("market.sqlite"), + artifact_dir: temporary.path().join("artifacts"), + web_dir: temporary.path().join("web"), + github_client_id: Some("client-id".to_string()), + github_client_secret: Some("client-secret".to_string()), + session_secret: "test-session-secret-at-least-24".to_string(), + admin_github_ids: HashSet::from([24753352]), + public_browse: true, + web_submissions_enabled: false, + }; + let db = Database::open(&config.database_path).await.unwrap(); + let artifacts = ArtifactStore::open(config.artifact_dir.clone()) + .await + .unwrap(); + let auth = AuthService::new(config.clone(), db.clone()).unwrap(); + let user = db + .upsert_github_user( + 24753352, + "bobleer", + "https://avatars.githubusercontent.com/u/24753352", + ) + .await + .unwrap(); + db.create_web_session( + user.internal_id, + "web-session-token", + "csrf-token", + (Utc::now() + chrono::Duration::hours(1)).timestamp(), + ) + .await + .unwrap(); + db.create_api_token( + user.internal_id, + "desktop-access-token", + "access", + "desktop-token-family", + (Utc::now() + chrono::Duration::hours(1)).timestamp(), + ) + .await + .unwrap(); + let state = Arc::new(MarketState { + config, + db, + artifacts, + auth, + }); + let app = api_router(state.clone()); + let cookie = "bitfun_market_session=web-session-token; bitfun_market_csrf=csrf-token"; + + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/submissions") + .header(header::COOKIE, cookie) + .header("x-csrf-token", "csrf-token") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("not valid json")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = axum::body::to_bytes(response.into_body(), 8 * 1024) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["error"]["code"], "web_submissions_disabled"); + + let history = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/submissions") + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(history.status(), StatusCode::OK); + + let mut desktop_headers = HeaderMap::new(); + desktop_headers.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer desktop-access-token"), + ); + let desktop_auth = require_submission_write_auth(&state, &desktop_headers) + .await + .unwrap(); + assert!(matches!(desktop_auth.kind, RequestAuthKind::Bearer { .. })); + } } diff --git a/src/miniapp-market-web/README.md b/src/miniapp-market-web/README.md index b830003fb8..713ac609d3 100644 --- a/src/miniapp-market-web/README.md +++ b/src/miniapp-market-web/README.md @@ -24,12 +24,15 @@ `.env` 写进源码、日志、截图或提交记录。 7. 未完成本文件中的最小验证、没有明确 Git commit,或生产健康检查未通过 时,不得声称已经发布。 +8. `webSubmissionsEnabled=false` 时网页投稿必须是只读模式:隐藏新建、上传、 + 提交新版本和撤回操作,只保留“我的投稿”历史。不要仅靠隐藏按钮保护接口; + 后端还会按认证来源拒绝 Web Cookie 投稿写请求。 ## 源码地图 | 位置 | 作用 | | --- | --- | -| `src/App.tsx` | 目录、详情、投稿、我的投稿和管理员审核页面 | +| `src/App.tsx` | 目录、详情、受开关控制的投稿、只读“我的投稿”和管理员审核页面 | | `src/api.ts` | `/miniapp/api/v1` 客户端、CSRF、登录和下载 URL | | `src/types.ts` | 网页使用的 API DTO | | `src/i18n.ts` | `zh-CN`、`zh-TW`、`en-US` 文案与 fallback | @@ -75,6 +78,15 @@ MARKET_DEV_API=http://127.0.0.1:19710 pnpm run dev:miniapp-market 本地未配置 GitHub OAuth 时,浏览和无登录页面仍可开发,登录按钮会处于不可 用状态。不要为了本地调试复制生产 secret。 +网页投稿默认关闭。只有在本地专门验证网页投稿旧流程时,才给本地 Rust 服务设置 +`MARKET_WEB_SUBMISSIONS_ENABLED=true`;生产保持 `false`。桌面客户端使用 +Bearer token 投稿,不受这个网页开关影响。 + +`src/api.ts` 和 `SubmitPage` 暂时保留未来可能重新启用的 Web 投稿实现;它们存在 +不代表生产能力已开放。所有入口必须只根据后端 `/config` 返回的 +`webSubmissionsEnabled` 显示,配置加载失败时按关闭处理。不要增加仅由前端常量、 +URL 参数或本地存储绕过的开关。 + ## 修改后的最小验证 在仓库根目录运行: @@ -104,7 +116,9 @@ pnpm run theme:color-audit:all - `/miniapp/apps/` 详情可打开; - 三种语言可切换,窄屏和宽屏没有明显溢出; - API 失败会显示可理解的错误,不在控制台泄露凭据; -- 登录、投稿或审核相关改动使用测试账号走完对应流程。 +- 网页投稿关闭时看不到投稿/更新/撤回按钮,直接访问 `/miniapp/submit` 会提示 + 改用 BitFun Desktop,“我的投稿”仍可查看; +- 登录、桌面投稿或审核相关改动使用测试账号走完对应流程。 ## API 类型变更 diff --git a/src/miniapp-market-web/src/App.tsx b/src/miniapp-market-web/src/App.tsx index 3e9c8609a9..53bc6f3701 100644 --- a/src/miniapp-market-web/src/App.tsx +++ b/src/miniapp-market-web/src/App.tsx @@ -86,6 +86,7 @@ function App() { const { theme, toggleTheme } = useTheme(); const [route, setRoute] = useState(currentRoute); const [config, setConfig] = useState(); + const [configResolved, setConfigResolved] = useState(false); const [me, setMe] = useState(); const [authResolved, setAuthResolved] = useState(false); @@ -109,7 +110,11 @@ function App() { }, []); useEffect(() => { - void marketApi.config().then(setConfig).catch(() => undefined); + void marketApi + .config() + .then(setConfig) + .catch(() => undefined) + .finally(() => setConfigResolved(true)); void refreshIdentity(); }, [refreshIdentity]); @@ -117,6 +122,8 @@ function App() { if (route.path === '/submit') { return ( ; + return ( + + ); } if (route.path === '/admin') { return ; @@ -136,7 +150,15 @@ function App() { } const detailMatch = route.path.match(/^\/apps\/([a-z0-9-]+)$/); if (detailMatch) { - return ; + return ( + + ); } return ; })(); @@ -250,13 +272,15 @@ function Header({ > {t('discover')} - + {config?.webSubmissionsEnabled && ( + + )} {me && ( + {config?.webSubmissionsEnabled ? ( + + ) : ( + {t('desktopSubmissionHint')} + )} )} {loading && items.length === 0 && @@ -632,11 +660,13 @@ function AppCardSkeleton() { function DetailPage({ slug, + webSubmissionsEnabled, me, locale, t, }: { slug: string; + webSubmissionsEnabled: boolean; me?: Me; locale: Locale; t: (key: MessageKey) => string; @@ -715,7 +745,7 @@ function DetailPage({