diff --git a/README.md b/README.md index 597c719..ed1e211 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - 在本地 SQLite 中保留历史用量和限额快照 - 在限额重置前、重置后发送 Telegram 或邮件提醒 - 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息 -- 所有运行期配置均可通过前端完成 +- 初始化后无需登录即可查看公开只读账号总览;只有登录管理员后才能添加账号、同步用量和修改运行期配置 - 前端适配 320px 起的手机浏览器,支持移动底部导航、iOS 安全区与深浅主题 - React 前端、Go 后端、Codex CLI 和 SQLite 运行在同一个容器中 @@ -46,6 +46,8 @@ http://服务器地址:8180 首次打开页面时创建管理员账号,并设置所在时区。用户名至少 3 位,密码至少 10 位。 +初始化完成后,首页会显示已勾选“公开显示到未登录总览”的账号;点击“登录后配置”并使用管理员账号登录,才能进入设置中心添加账号、同步数据、修改账号公开状态或配置提醒。未勾选公开的账号只会在登录后的总览中显示。 + > 首次初始化没有额外安装码。创建管理员之前,不要将端口直接暴露到不可信网络。公网部署应使用 HTTPS 反向代理,并限制初始化阶段的访问来源。 ## 连接 Codex 账户 diff --git a/backend/CONTRACT.md b/backend/CONTRACT.md index babca90..c5e5767 100644 --- a/backend/CONTRACT.md +++ b/backend/CONTRACT.md @@ -8,7 +8,7 @@ - `GET /health/live` 始终返回 `200 {"status":"ok"}`;`GET /health/ready` 在 SQLite 可用时返回 `200 {"status":"ok","appServer":bool}`,数据库不可用时返回 `503 {"error":string}`。`appServer` 表示至少一个账号的 app-server 已完成初始化。 - JSON 请求体最多读取 1 MiB,拒绝未知字段;业务错误统一为 `{"error":string}`。未匹配 API 返回 `404 {"error":"接口不存在"}`。 - 所有响应带 `X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy: same-origin` 和同源 CSP。 -- `GET /api/v1/system/status`、`POST /api/v1/setup`、`POST /api/v1/auth/login` 匿名可用。status 的其他方法返回 405;其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。当前 dispatcher 只对部分路由显式限制 HTTP method;下文使用“任意方法”或“非 `GET`”的地方是对实际兼容行为的记录。 +- `GET /api/v1/system/status`、`POST /api/v1/setup`、`POST /api/v1/auth/login` 匿名可用;初始化完成后,`GET`/`HEAD /api/v1/accounts` 和 `GET`/`HEAD /api/v1/dashboard` 也提供已标记为公开账号的匿名只读总览。匿名总览会隐藏邮箱、认证方式、账号配置校验和内部错误字段;未公开账号对匿名请求按不存在处理。其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。当前 dispatcher 只对部分路由显式限制 HTTP method;下文使用“任意方法”或“非 `GET`”的地方是对实际兼容行为的记录。 - session 有效期七天,cookie 为 `HttpOnly`、`SameSite=Strict`、`Path=/`;数据库只保存 token 摘要。登录失败按 `RemoteAddr` 在进程内限制为 15 分钟最多 10 次,超限返回 429。 - 未命中静态文件的非 API GET 路径返回嵌入的 `index.html`,供前端路由回退。 @@ -18,7 +18,7 @@ ```text {id, displayName, email:string|null, planType:string|null, - expectedKind:"any"|"personal"|"team", + expectedKind:"any"|"personal"|"team", publicVisible:bool, actualKind:"unknown"|"personal"|"team", validationStatus:"pending"|"matched"|"mismatch"|"unknown", possibleDuplicate:bool, connected:bool, createdAt, updatedAt} @@ -57,14 +57,15 @@ | 方法与路径 | 请求与响应 | | --- | --- | -| `GET /api/v1/accounts` | 返回 `200 Account[]`,按 ID 升序。 | -| `POST /api/v1/accounts` | body `{displayName,expectedKind}`;空名称默认为 `新账号`,空类型默认为 `any`;成功返回 `201 Account`。 | -| `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?}`;名称不能为空,省略类型时保留旧值;成功返回 `200 {ok:true}`。 | +| `GET /api/v1/accounts` | 初始化后匿名可读;匿名只返回 `publicVisible=true` 的账号,按 ID 升序;匿名响应隐藏 `email`、`expectedKind`、`actualKind`、`validationStatus`、`possibleDuplicate` 和创建/更新时间。登录后返回全部账号及完整字段。 | +| `POST /api/v1/accounts` | body `{displayName,expectedKind,publicVisible}`;空名称默认为 `新账号`,空类型默认为 `any`,`publicVisible` 省略时默认为 `false`;成功返回 `201 Account`。 | +| `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?,publicVisible?}`;名称不能为空,省略类型或 `publicVisible` 时分别保留旧值;成功返回 `200 {ok:true}`。 | | `DELETE /api/v1/accounts/{id}` | 停止该账号进程,删除账号及级联历史,再删除对应凭据目录;成功返回 `200 {ok:true}`。 | | `POST /api/v1/accounts/{id}/login/device` | 启动并初始化 app-server,调用 `account/login/start` 的 `chatgptDeviceCode` 流程;返回含 `verificationUrl`、`userCode` 和 `loginId` 的结果。 | | `POST /api/v1/accounts/{id}/logout` | 调用 `account/logout` 并将连接状态置为 false;返回 `200 {ok:true}`。 | | `POST /api/v1/accounts/{id}/sync` | 同步指定账号;成功 `200 {ok:true}`,上游失败 502。 | -| `任意方法 /api/v1/dashboard?accountId={id}` | 返回内存中的 `Dashboard`;非 `GET`/`HEAD` 还需来源头。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 | +| `GET`/`HEAD /api/v1/dashboard?accountId={id}` | 初始化后匿名可读公开账号,返回内存中的 `Dashboard`;匿名访问未公开账号返回 404,匿名响应隐藏邮箱、认证方式和内部错误字段。登录后可读取全部账号。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 | +| `任意非读方法 /api/v1/dashboard?accountId={id}` | 要求 session;非 `GET`/`HEAD` 还需来源头。保持兼容的读取行为,省略或无效的零值 ID 使用账号 1。 | | `POST /api/v1/sync?accountId={id}` | 旧兼容入口,同步指定账号;省略或零值 ID 使用账号 1。 | 账号不存在返回 404 `账号不存在`;非法路径 ID 返回 400 `账号 ID 无效`;无效 `expectedKind` 返回 400 `连接类型无效`。未知账号套餐不得猜测为个人或团队。 diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index e33f8c9..6ec06d6 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -43,6 +43,14 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { a.login(w, r) return } + if p == "accounts" && readOnlyMethod(r.Method) { + a.accountsAPI(w, r) + return + } + if p == "dashboard" && readOnlyMethod(r.Method) { + a.dashboardAPI(w, r) + return + } if !a.require(w, r) { return } @@ -53,17 +61,11 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]string{"username": username}) case p == "auth/logout" && r.Method == "POST": a.logout(w, r) - case p == "accounts" && r.Method == "GET": - x, e := a.store.Accounts() - if e != nil { - jsonOut(w, 500, map[string]string{"error": e.Error()}) - } else { - jsonOut(w, 200, x) - } case p == "accounts" && r.Method == "POST": var in struct { - DisplayName string `json:"displayName"` - ExpectedKind string `json:"expectedKind"` + DisplayName string `json:"displayName"` + ExpectedKind string `json:"expectedKind"` + PublicVisible bool `json:"publicVisible"` } if decode(r, &in) != nil { jsonOut(w, 400, map[string]string{"error": "请求格式错误"}) @@ -80,7 +82,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { jsonOut(w, 400, map[string]string{"error": "连接类型无效"}) break } - x, e := a.store.CreateAccount(in.DisplayName, in.ExpectedKind) + x, e := a.store.CreateAccountWithVisibility(in.DisplayName, in.ExpectedKind, in.PublicVisible) if e == nil { a.addRuntime(x.ID) jsonOut(w, 201, x) @@ -90,25 +92,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { case strings.HasPrefix(p, "accounts/"): a.accountAPI(w, r, p) case p == "dashboard": - id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64) - if id == 0 { - id = 1 - } - rt := a.runtime(id) - if rt == nil { - jsonOut(w, 404, map[string]string{"error": "账号不存在"}) - } else { - rt.syncing.Lock() - d := rt.dash - rt.syncing.Unlock() - if d.Limits == nil { - d.Limits = []LimitBucket{} - } - if d.Usage == nil { - d.Usage = []UsagePoint{} - } - jsonOut(w, 200, d) - } + a.dashboardAPI(w, r) case p == "sync" && r.Method == "POST": id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64) if id == 0 { @@ -162,6 +146,93 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { } } +func readOnlyMethod(method string) bool { + return method == http.MethodGet || method == http.MethodHead +} + +func (a *App) accountsAPI(w http.ResponseWriter, r *http.Request) { + if !a.store.Initialized() { + jsonOut(w, http.StatusConflict, map[string]string{"error": "请先初始化"}) + return + } + x, e := a.store.Accounts() + if e != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": e.Error()}) + return + } + if !a.authed(r) { + visible := make([]store.Account, 0, len(x)) + for _, account := range x { + if !account.PublicVisible { + continue + } + visible = append(visible, publicAccount(account)) + } + x = visible + } + jsonOut(w, http.StatusOK, x) +} + +func publicAccount(account store.Account) store.Account { + account.Email = nil + account.ExpectedKind = "any" + account.ActualKind = "unknown" + account.ValidationStatus = "unknown" + account.PossibleDuplicate = false + account.CreatedAt = 0 + account.UpdatedAt = 0 + return account +} + +func (a *App) dashboardAPI(w http.ResponseWriter, r *http.Request) { + if !a.store.Initialized() { + jsonOut(w, http.StatusConflict, map[string]string{"error": "请先初始化"}) + return + } + id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64) + if id == 0 { + id = 1 + } + account, accountErr := a.store.Account(id) + if accountErr != nil { + if accountErr == sql.ErrNoRows { + jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"}) + } else { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": accountErr.Error()}) + } + return + } + if !a.authed(r) && !account.PublicVisible { + jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"}) + return + } + rt := a.runtime(id) + if rt == nil { + jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"}) + return + } + rt.syncing.Lock() + d := rt.dash + rt.syncing.Unlock() + if d.Limits == nil { + d.Limits = []LimitBucket{} + } + if d.Usage == nil { + d.Usage = []UsagePoint{} + } + if !a.authed(r) { + d = publicDashboard(d) + } + jsonOut(w, http.StatusOK, d) +} + +func publicDashboard(d Dashboard) Dashboard { + d.Account.Email = nil + d.Account.AuthMode = nil + d.LastError = "" + return d +} + func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) { parts := strings.Split(p, "/") if len(parts) < 2 { @@ -185,8 +256,9 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) { switch { case action == "" && r.Method == "PUT": var in struct { - DisplayName string `json:"displayName"` - ExpectedKind string `json:"expectedKind"` + DisplayName string `json:"displayName"` + ExpectedKind string `json:"expectedKind"` + PublicVisible *bool `json:"publicVisible"` } if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" { jsonOut(w, 400, map[string]string{"error": "名称不能为空"}) @@ -205,7 +277,7 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) { jsonOut(w, 400, map[string]string{"error": "连接类型无效"}) return } - e = a.store.UpdateAccountSettings(id, strings.TrimSpace(in.DisplayName), in.ExpectedKind) + e = a.store.UpdateAccountSettingsWithVisibility(id, strings.TrimSpace(in.DisplayName), in.ExpectedKind, in.PublicVisible) if e == nil { jsonOut(w, 200, map[string]bool{"ok": true}) } diff --git a/backend/internal/app/runtime_test.go b/backend/internal/app/runtime_test.go index 81b0619..0971fbc 100644 --- a/backend/internal/app/runtime_test.go +++ b/backend/internal/app/runtime_test.go @@ -6,11 +6,14 @@ import ( "errors" "net/http" "net/http/httptest" + "strconv" + "strings" "sync" "testing" "time" "codex-helper/internal/security" + "codex-helper/internal/store" ) func TestSystemStatusRejectsNonGETMethods(t *testing.T) { @@ -47,6 +50,9 @@ func TestSystemStatusReturnsBuildVersion(t *testing.T) { func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) { a := newReminderTestApp(t) + if err := a.store.Set("initialized", "true"); err != nil { + t.Fatal(err) + } a.runtimes[1] = &accountRuntime{} _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken("test-session"), time.Now().Add(time.Hour).Unix(), time.Now().Unix()) if err != nil { @@ -71,6 +77,208 @@ func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) { } } +func TestAnonymousOverviewIsReadOnly(t *testing.T) { + a := newReminderTestApp(t) + if err := a.store.Set("initialized", "true"); err != nil { + t.Fatal(err) + } + email := "owner@example.com" + plan := "plus" + if err := a.store.UpdateAccount(1, &email, &plan, true); err != nil { + t.Fatal(err) + } + publicVisible := true + if err := a.store.UpdateAccountSettingsWithVisibility(1, "默认账号", "any", &publicVisible); err != nil { + t.Fatal(err) + } + a.runtimes[1] = &accountRuntime{ + dash: Dashboard{ + AccountID: 1, + DisplayName: "默认账号", + Account: AccountView{Email: &email, PlanType: &plan, Connected: true}, + Limits: []LimitBucket{}, + Usage: []UsagePoint{}, + FetchedAt: time.Now().Unix(), + }, + } + + accountsRecorder := httptest.NewRecorder() + a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil)) + if accountsRecorder.Code != http.StatusOK { + t.Fatalf("anonymous accounts status = %d, body = %s", accountsRecorder.Code, accountsRecorder.Body.String()) + } + var accounts []struct { + Email *string `json:"email"` + ExpectedKind string `json:"expectedKind"` + PublicVisible bool `json:"publicVisible"` + ValidationState string `json:"validationStatus"` + } + if err := json.Unmarshal(accountsRecorder.Body.Bytes(), &accounts); err != nil { + t.Fatal(err) + } + if len(accounts) != 1 || accounts[0].Email != nil || !accounts[0].PublicVisible || accounts[0].ExpectedKind != "any" || accounts[0].ValidationState != "unknown" { + t.Fatalf("anonymous account data = %#v; sensitive account fields were not redacted", accounts) + } + + dashboardRecorder := httptest.NewRecorder() + a.api(dashboardRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil)) + if dashboardRecorder.Code != http.StatusOK { + t.Fatalf("anonymous dashboard status = %d, body = %s", dashboardRecorder.Code, dashboardRecorder.Body.String()) + } + var publicDashboardBody Dashboard + if err := json.Unmarshal(dashboardRecorder.Body.Bytes(), &publicDashboardBody); err != nil { + t.Fatal(err) + } + if publicDashboardBody.Account.Email != nil || publicDashboardBody.Account.AuthMode != nil { + t.Fatalf("anonymous dashboard account = %#v; identity fields were not redacted", publicDashboardBody.Account) + } + + for _, path := range []string{"/api/v1/settings/general", "/api/v1/accounts", "/api/v1/accounts/1/sync"} { + recorder := httptest.NewRecorder() + method := http.MethodGet + if path == "/api/v1/accounts" || strings.HasSuffix(path, "/sync") { + method = http.MethodPost + } + a.api(recorder, httptest.NewRequest(method, path, nil)) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("anonymous %s status = %d, body = %s; configuration must require login", path, recorder.Code, recorder.Body.String()) + } + } + + session := "test-session" + if _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil { + t.Fatal(err) + } + privateRecorder := httptest.NewRecorder() + privateRequest := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil) + privateRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) + a.api(privateRecorder, privateRequest) + if privateRecorder.Code != http.StatusOK { + t.Fatalf("authenticated dashboard status = %d, body = %s", privateRecorder.Code, privateRecorder.Body.String()) + } + var privateDashboardBody Dashboard + if err := json.Unmarshal(privateRecorder.Body.Bytes(), &privateDashboardBody); err != nil { + t.Fatal(err) + } + if privateDashboardBody.Account.Email == nil || *privateDashboardBody.Account.Email != email { + t.Fatalf("authenticated dashboard email = %v; want %q", privateDashboardBody.Account.Email, email) + } + configRecorder := httptest.NewRecorder() + configRequest := httptest.NewRequest(http.MethodPut, "/api/v1/settings/general", strings.NewReader(`{"timezone":"UTC","theme":"system","syncMinutes":5,"retentionDays":90,"beforeMinutes":30,"notifyBefore":true,"notifyAfter":true}`)) + configRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) + configRequest.Header.Set("X-Requested-With", "codex-helper") + a.api(configRecorder, configRequest) + if configRecorder.Code != http.StatusOK { + t.Fatalf("authenticated settings status = %d, body = %s", configRecorder.Code, configRecorder.Body.String()) + } +} + +func TestAccountVisibilityFiltersAnonymousOverviewAndCanBeUpdated(t *testing.T) { + a := newReminderTestApp(t) + if err := a.store.Set("initialized", "true"); err != nil { + t.Fatal(err) + } + publicAccount, err := a.store.CreateAccountWithVisibility("公开账号", "team", true) + if err != nil { + t.Fatal(err) + } + privateAccount, err := a.store.CreateAccount("私有账号", "personal") + if err != nil { + t.Fatal(err) + } + a.runtimes[publicAccount.ID] = &accountRuntime{} + a.runtimes[privateAccount.ID] = &accountRuntime{} + + accountsRecorder := httptest.NewRecorder() + a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil)) + if accountsRecorder.Code != http.StatusOK { + t.Fatalf("anonymous accounts status = %d, body = %s", accountsRecorder.Code, accountsRecorder.Body.String()) + } + var visible []struct { + ID int64 `json:"id"` + PublicVisible bool `json:"publicVisible"` + } + if err := json.Unmarshal(accountsRecorder.Body.Bytes(), &visible); err != nil { + t.Fatal(err) + } + if len(visible) != 1 || visible[0].ID != publicAccount.ID || !visible[0].PublicVisible { + t.Fatalf("anonymous accounts = %#v; want only public account %d", visible, publicAccount.ID) + } + + privateDashboard := httptest.NewRecorder() + privatePath := "/api/v1/dashboard?accountId=" + strconv.FormatInt(privateAccount.ID, 10) + a.api(privateDashboard, httptest.NewRequest(http.MethodGet, privatePath, nil)) + if privateDashboard.Code != http.StatusNotFound { + t.Fatalf("anonymous private dashboard status = %d, body = %s", privateDashboard.Code, privateDashboard.Body.String()) + } + + publicDashboard := httptest.NewRecorder() + publicPath := "/api/v1/dashboard?accountId=" + strconv.FormatInt(publicAccount.ID, 10) + a.api(publicDashboard, httptest.NewRequest(http.MethodGet, publicPath, nil)) + if publicDashboard.Code != http.StatusOK { + t.Fatalf("anonymous public dashboard status = %d, body = %s", publicDashboard.Code, publicDashboard.Body.String()) + } + + session := "visibility-session" + if _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil { + t.Fatal(err) + } + createRecorder := httptest.NewRecorder() + createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/accounts", strings.NewReader(`{"displayName":"接口公开账号","expectedKind":"team","publicVisible":true}`)) + createRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) + createRequest.Header.Set("X-Requested-With", "codex-helper") + a.api(createRecorder, createRequest) + if createRecorder.Code != http.StatusCreated { + t.Fatalf("authenticated account creation status = %d, body = %s", createRecorder.Code, createRecorder.Body.String()) + } + var created store.Account + if err := json.Unmarshal(createRecorder.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + if !created.PublicVisible { + t.Fatalf("created account = %#v; want publicVisible=true", created) + } + authenticatedAccounts := httptest.NewRecorder() + authenticatedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil) + authenticatedRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) + a.api(authenticatedAccounts, authenticatedRequest) + if authenticatedAccounts.Code != http.StatusOK { + t.Fatalf("authenticated accounts status = %d, body = %s", authenticatedAccounts.Code, authenticatedAccounts.Body.String()) + } + var all []struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(authenticatedAccounts.Body.Bytes(), &all); err != nil { + t.Fatal(err) + } + if len(all) != 4 { + t.Fatalf("authenticated accounts = %#v; want default, public, private, and newly created accounts", all) + } + + anonymousUpdate := httptest.NewRecorder() + anonymousUpdateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatInt(privateAccount.ID, 10), strings.NewReader(`{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`)) + a.api(anonymousUpdate, anonymousUpdateRequest) + if anonymousUpdate.Code != http.StatusUnauthorized { + t.Fatalf("anonymous visibility update status = %d, body = %s", anonymousUpdate.Code, anonymousUpdate.Body.String()) + } + + authenticatedUpdate := httptest.NewRecorder() + authenticatedUpdateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatInt(privateAccount.ID, 10), strings.NewReader(`{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`)) + authenticatedUpdateRequest.AddCookie(&http.Cookie{Name: "session", Value: session}) + authenticatedUpdateRequest.Header.Set("X-Requested-With", "codex-helper") + a.api(authenticatedUpdate, authenticatedUpdateRequest) + if authenticatedUpdate.Code != http.StatusOK { + t.Fatalf("authenticated visibility update status = %d, body = %s", authenticatedUpdate.Code, authenticatedUpdate.Body.String()) + } + updated, err := a.store.Account(privateAccount.ID) + if err != nil { + t.Fatal(err) + } + if !updated.PublicVisible { + t.Fatalf("updated account = %#v; want publicVisible=true", updated) + } +} + func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) { now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC) reset := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC) diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 5ec10a8..52a0812 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -95,13 +95,14 @@ func (s *Store) migrateAccounts() error { email TEXT, plan_type TEXT, expected_kind TEXT NOT NULL DEFAULT 'any', + public_visible INTEGER NOT NULL DEFAULT 0, connected INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )`); err != nil { return err } - var hasExpectedKind bool + var hasExpectedKind, hasPublicVisible bool rows, qerr := tx.Query("PRAGMA table_info(accounts)") if qerr != nil { return qerr @@ -112,6 +113,7 @@ func (s *Store) migrateAccounts() error { var def any _ = rows.Scan(&cid, &name, &typ, ¬null, &def, &pk) hasExpectedKind = hasExpectedKind || name == "expected_kind" + hasPublicVisible = hasPublicVisible || name == "public_visible" } rows.Close() if !hasExpectedKind { @@ -119,6 +121,11 @@ func (s *Store) migrateAccounts() error { return err } } + if !hasPublicVisible { + if _, err = tx.Exec("ALTER TABLE accounts ADD COLUMN public_visible INTEGER NOT NULL DEFAULT 0"); err != nil { + return err + } + } var count int if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil { return err @@ -181,6 +188,7 @@ type Account struct { Email *string `json:"email"` PlanType *string `json:"planType"` ExpectedKind string `json:"expectedKind"` + PublicVisible bool `json:"publicVisible"` ActualKind string `json:"actualKind"` ValidationStatus string `json:"validationStatus"` PossibleDuplicate bool `json:"possibleDuplicate"` @@ -190,7 +198,7 @@ type Account struct { } func (s *Store) Accounts() ([]Account, error) { - rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,expected_kind,connected,created_at,updated_at FROM accounts ORDER BY id") + rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,expected_kind,public_visible,connected,created_at,updated_at FROM accounts ORDER BY id") if e != nil { return nil, e } @@ -198,7 +206,7 @@ func (s *Store) Accounts() ([]Account, error) { out := []Account{} for rows.Next() { var a Account - if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil { + if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil { return nil, e } a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType) @@ -217,21 +225,51 @@ func (s *Store) Accounts() ([]Account, error) { } return out, rows.Err() } + +func (s *Store) Account(id int64) (Account, error) { + var a Account + err := s.DB.QueryRow("SELECT id,display_name,email,plan_type,expected_kind,public_visible,connected,created_at,updated_at FROM accounts WHERE id=?", id). + Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt) + if err != nil { + return Account{}, err + } + a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType) + return a, nil +} + func (s *Store) CreateAccount(name string, kinds ...string) (Account, error) { + return s.createAccount(name, false, kinds...) +} + +func (s *Store) CreateAccountWithVisibility(name, expectedKind string, publicVisible bool) (Account, error) { + return s.createAccount(name, publicVisible, expectedKind) +} + +func (s *Store) createAccount(name string, publicVisible bool, kinds ...string) (Account, error) { expectedKind := "any" if len(kinds) > 0 { expectedKind = kinds[0] } now := time.Now().Unix() - r, e := s.DB.Exec("INSERT INTO accounts(display_name,expected_kind,created_at,updated_at) VALUES(?,?,?,?)", name, expectedKind, now, now) + r, e := s.DB.Exec("INSERT INTO accounts(display_name,expected_kind,public_visible,created_at,updated_at) VALUES(?,?,?,?,?)", name, expectedKind, publicVisible, now, now) if e != nil { return Account{}, e } id, _ := r.LastInsertId() - return Account{ID: id, DisplayName: name, ExpectedKind: expectedKind, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil + return Account{ID: id, DisplayName: name, ExpectedKind: expectedKind, PublicVisible: publicVisible, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil } func (s *Store) UpdateAccountSettings(id int64, name, expectedKind string) error { - r, e := s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,updated_at=? WHERE id=?", name, expectedKind, time.Now().Unix(), id) + return s.UpdateAccountSettingsWithVisibility(id, name, expectedKind, nil) +} + +func (s *Store) UpdateAccountSettingsWithVisibility(id int64, name, expectedKind string, publicVisible *bool) error { + var r sql.Result + var e error + if publicVisible == nil { + r, e = s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,updated_at=? WHERE id=?", name, expectedKind, time.Now().Unix(), id) + } else { + r, e = s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,public_visible=?,updated_at=? WHERE id=?", name, expectedKind, *publicVisible, time.Now().Unix(), id) + } if e != nil { return e } diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 5459c13..39ca447 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -33,7 +33,7 @@ func TestAccountsAndPerAccountUsage(t *testing.T) { } defer s.DB.Close() accounts, err := s.Accounts() - if err != nil || len(accounts) != 1 || accounts[0].ID != 1 { + if err != nil || len(accounts) != 1 || accounts[0].ID != 1 || accounts[0].PublicVisible { t.Fatalf("default accounts = %#v, %v", accounts, err) } second, err := s.CreateAccount("Team workspace") @@ -97,11 +97,52 @@ func TestExistingAccountsGainExpectedKind(t *testing.T) { if err != nil || len(accounts) != 1 { t.Fatalf("accounts = %#v, %v", accounts, err) } - if accounts[0].ExpectedKind != "any" || accounts[0].ActualKind != "team" || accounts[0].ValidationStatus != "matched" { + if accounts[0].ExpectedKind != "any" || accounts[0].PublicVisible || accounts[0].ActualKind != "team" || accounts[0].ValidationStatus != "matched" { t.Fatalf("migrated account = %#v", accounts[0]) } } +func TestAccountVisibilitySettings(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer s.DB.Close() + + private, err := s.CreateAccount("私有账号") + if err != nil { + t.Fatal(err) + } + public, err := s.CreateAccountWithVisibility("公开账号", "team", true) + if err != nil { + t.Fatal(err) + } + if private.PublicVisible || !public.PublicVisible { + t.Fatalf("created accounts = %#v, %#v", private, public) + } + if err := s.UpdateAccountSettings(public.ID, "公开账号重命名", "team"); err != nil { + t.Fatal(err) + } + unchanged, err := s.Account(public.ID) + if err != nil { + t.Fatal(err) + } + if !unchanged.PublicVisible { + t.Fatal("legacy settings update unexpectedly changed public visibility") + } + visible := false + if err := s.UpdateAccountSettingsWithVisibility(public.ID, "公开账号重命名", "team", &visible); err != nil { + t.Fatal(err) + } + updated, err := s.Account(public.ID) + if err != nil { + t.Fatal(err) + } + if updated.PublicVisible { + t.Fatal("explicit false visibility update was not persisted") + } +} + func ptr(value string) *string { return &value } func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) { diff --git a/docs/backend/authentication-and-security.md b/docs/backend/authentication-and-security.md index 7e6a800..223dc78 100644 --- a/docs/backend/authentication-and-security.md +++ b/docs/backend/authentication-and-security.md @@ -22,4 +22,6 @@ Codex OAuth 凭据由 app-server 写入各账号隔离的 `CODEX_HOME`,不经 ## HTTP 边界 +初始化完成后,标记为公开的账号列表和 Dashboard 以匿名只读方式开放,供公开总览加载;未标记账号对匿名请求不可见。匿名响应不返回邮箱、Codex 认证方式、账号配置校验字段或内部错误。新增账号、设备码登录、同步、删除、公开状态修改、提醒和所有设置接口仍必须经过 `require` 的 session 与来源校验。 + JSON 解码限制为 1 MiB 并拒绝未知字段。统一安全头包括限制性 CSP、`nosniff`、禁止 iframe 和 same-origin referrer。前端路由、按钮禁用和邮箱掩码均不是服务端授权边界;所有新敏感端点必须在后端经过 `require`,改变 API 方法时还要核对来源头逻辑。 diff --git a/docs/backend/data-notifications-and-backup.md b/docs/backend/data-notifications-and-backup.md index 7f2a17f..037cbe1 100644 --- a/docs/backend/data-notifications-and-backup.md +++ b/docs/backend/data-notifications-and-backup.md @@ -6,7 +6,7 @@ - `settings` 保存通用、SMTP、Telegram、绑定码及安装标记;秘密单独以密文 key 保存。 - `admin` 与 `sessions` 保存唯一管理员和登录会话。 -- `accounts` 保存 Codex 连接元数据与期望套餐类型。 +- `accounts` 保存 Codex 连接元数据、期望套餐类型和 `public_visible`;该字段默认 `0`,旧账号迁移后保持私有,只有管理员明确开启后才进入匿名总览。 - `daily_usage` 和 `limit_snapshots` 按 `account_id` 保存历史,删除账号时级联删除。 - `notifications` 保存稳定去重键、调度时间、结构化消息、状态、次数和错误。 - `telegram_updates` 保存 Bot API offset。 diff --git a/docs/frontend/application.md b/docs/frontend/application.md index a542482..2c61438 100644 --- a/docs/frontend/application.md +++ b/docs/frontend/application.md @@ -4,7 +4,7 @@ ## 状态与路由 -应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v` 徽标显示在安装页、登录页和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。 +应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;初始化后未登录渲染公开只读 `/` 总览和 `/login` 登录页,登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未登录访问 `/settings` 回到公开总览,未知路径回到 `/`。状态响应中的构建版本以 `v` 徽标显示在安装页、登录页、公开总览和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。 主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后刷新;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图;顶部“刷新全部”和卡片内的账号级刷新都会先调用对应的 sync,再重新读取 Dashboard。 diff --git a/docs/reference/engineering-invariants.md b/docs/reference/engineering-invariants.md index e1cb3d8..0e32620 100644 --- a/docs/reference/engineering-invariants.md +++ b/docs/reference/engineering-invariants.md @@ -4,7 +4,7 @@ ## API 与认证 -- [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 是路径、状态码、响应字段和兼容文案的契约。匿名入口只能是当前 status、setup 和 login。 +- [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 是路径、状态码、响应字段和兼容文案的契约。匿名入口包括 status、setup、login,以及初始化完成后已标记公开账号的列表和 Dashboard 只读总览;新增或修改配置、账号、同步和凭据接口仍必须要求 session。 - 所有受保护端点必须回查 session;非只读请求还必须验证 `X-Requested-With`。前端路由与按钮不能代替后端门禁。 - session 原 token 只进入 cookie,SQLite 只保存摘要;密码保持 argon2id。错误和日志不得包含密码、cookie、Bot Token、SMTP 密码或 Codex token。 - 初始化是事务性单管理员创建。新增自动初始化能力前必须保留并发与首次公网暴露的安全边界。 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6ce0b42..d570a79 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import React, { lazy, Suspense, useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter, + Link, Navigate, Route, Routes, @@ -18,6 +19,7 @@ import { Flame, Github, Gauge, + LogIn, Mail, LogOut, Moon, @@ -83,7 +85,13 @@ function App() { {authenticated ? ( } /> ) : ( - } /> + <> + } /> + } + /> + )} @@ -206,10 +214,32 @@ function Login({ version }: { version: string }) { {e &&

{e}

} +

+ 查看公开总览 +

); } +function PublicShell({ version }: { version: string }) { + const nav = useNavigate(); + return ( +
+
+ + +
+
+ + } /> + } /> + +
+
+ ); +} function Shell({ version }: { version: string }) { const { logout } = useAuth(); const nav = useNavigate(); @@ -358,7 +388,7 @@ const emptyDashboardCardState = (): DashboardCardState => ({ refresh: "idle", }); -function Dashboard() { +function Dashboard({ publicView = false }: { publicView?: boolean }) { const [accounts, setAccounts] = useState(null), [cards, setCards] = useState>({}), [expanded, setExpanded] = useState>({}), @@ -539,7 +569,12 @@ function Dashboard() { if (!accounts.length) return ( <> -
+
尚未添加 Codex 账号
); @@ -569,6 +604,7 @@ function Dashboard() { key={account.id} account={account} state={cards[account.id] || emptyDashboardCardState()} + publicView={publicView} expanded={Boolean(expanded[account.id])} onToggle={() => setExpanded((current) => ({ @@ -587,12 +623,14 @@ function Dashboard() { function AccountOverviewCard({ account, state, + publicView, expanded, onToggle, onRefresh, }: { account: Account; state: DashboardCardState; + publicView: boolean; expanded: boolean; onToggle: () => void; onRefresh: () => void; @@ -619,7 +657,7 @@ function AccountOverviewCard({ {title} - {email ? maskEmail(email) : "尚未登录"} ·{" "} + {publicView ? "公开只读" : email ? maskEmail(email) : "尚未登录"} ·{" "} {connected ? "已连接" : "未连接"} @@ -638,6 +676,7 @@ function AccountOverviewCard({ dashboard={dashboard} state={state} onRefresh={onRefresh} + publicView={publicView} /> ) : (
@@ -701,10 +740,12 @@ function AccountDashboardDetails({ dashboard, state, onRefresh, + publicView, }: { dashboard: Dash; state: DashboardCardState; onRefresh: () => void; + publicView: boolean; }) { return ( <> @@ -747,9 +788,11 @@ function AccountDashboardDetails({ icon={} label="登录邮箱" value={ - dashboard.account.email - ? maskEmail(dashboard.account.email) - : "尚未连接" + publicView + ? "登录后查看" + : dashboard.account.email + ? maskEmail(dashboard.account.email) + : "尚未连接" } /> (null), [active, setActive] = useState(0), [newKind, setNewKind] = useState<"personal" | "team">("team"), + [newPublicVisible, setNewPublicVisible] = useState(false), [busy, setBusy] = useState(false), [err, setErr] = useState(""); const load = async (signal?: AbortSignal) => { @@ -1195,6 +1239,7 @@ function CodexSettings() { const x = await post("accounts", (value) => decodeAccounts([value])[0], { displayName: `账号 ${xs.length + 1}`, expectedKind: newKind, + publicVisible: newPublicVisible, }); await load(); setActive(x.id); @@ -1238,6 +1283,14 @@ function CodexSettings() { + @@ -1263,6 +1316,7 @@ function CodexSettings() { await put(`accounts/${x.id}`, decodeOK, { displayName: name, expectedKind: x.expectedKind, + publicVisible: x.publicVisible, }); void load().catch((error) => setErr(toErrorMessage(error)), @@ -1296,6 +1350,7 @@ function CodexSettings() { await put(`accounts/${x.id}`, decodeOK, { displayName: x.displayName, expectedKind: e.target.value, + publicVisible: x.publicVisible, }); void load().catch((error) => setErr(toErrorMessage(error))); }} @@ -1305,6 +1360,26 @@ function CodexSettings() { +