feat: add public account overview visibility
This commit is contained in:
+104
-32
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user