From 03c55584314e1587cf891a3b0f77d01208dd680e Mon Sep 17 00:00:00 2001 From: Jiang Chaoqun Date: Wed, 7 Oct 2026 17:29:49 +0800 Subject: [PATCH] feat: add home virtual sub administration --- .github/workflows/go.yml | 2 +- README.md | 1 + api/accounts/home_virtual_sub_test.go | 267 ++++++++++++++++++ api/handler.go | 2 + api/homevirtualsub/handlers.go | 207 ++++++++++++++ api/homevirtualsub/routes.go | 37 +++ config/config_example.yaml | 6 + config/global.go | 16 +- config/home_virtual_sub.go | 50 ++++ config/home_virtual_sub_test.go | 27 ++ deploy/pi/README.md | 2 + docs/specs/home-virtual-sub/design.md | 84 ++++++ docs/specs/home-virtual-sub/validation.md | 27 ++ front_vue/README.md | 3 + front_vue/src/api/home_virtual_sub.cjs | 21 ++ front_vue/src/router/index.js | 1 + .../src/utils/home_virtual_sub_behavior.cjs | 52 ++++ front_vue/src/views/AdminAudit.vue | 6 +- front_vue/src/views/AdminHomeVirtualSub.vue | 180 ++++++++++++ front_vue/src/views/AdminLayout.vue | 2 +- front_vue/test/home_virtual_sub.test.cjs | 57 ++++ .../test/home_virtual_sub_pages.test.cjs | 113 ++++++++ go.mod | 7 + go.sum | 9 + rpc/homevirtualsub/client.go | 200 +++++++++++++ rpc/homevirtualsub/client_test.go | 122 ++++++++ rpc/homevirtualsub/types.go | 70 +++++ 27 files changed, 1560 insertions(+), 11 deletions(-) create mode 100644 api/accounts/home_virtual_sub_test.go create mode 100644 api/homevirtualsub/handlers.go create mode 100644 api/homevirtualsub/routes.go create mode 100644 config/home_virtual_sub.go create mode 100644 config/home_virtual_sub_test.go create mode 100644 docs/specs/home-virtual-sub/design.md create mode 100644 docs/specs/home-virtual-sub/validation.md create mode 100644 front_vue/src/api/home_virtual_sub.cjs create mode 100644 front_vue/src/utils/home_virtual_sub_behavior.cjs create mode 100644 front_vue/src/views/AdminHomeVirtualSub.vue create mode 100644 front_vue/test/home_virtual_sub.test.cjs create mode 100644 front_vue/test/home_virtual_sub_pages.test.cjs create mode 100644 rpc/homevirtualsub/client.go create mode 100644 rpc/homevirtualsub/client_test.go create mode 100644 rpc/homevirtualsub/types.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e019243..e1a1f5e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -80,7 +80,7 @@ jobs: HOME_SERVER_READ_CACHE_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_readcache_test_ci?parseTime=true' MANUALS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_manuals_test_ci?parseTime=true' FILE_SHARING_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_file_sharing_test_ci?parseTime=true' - run: go test -v ./config ./api/accounts ./api/webprojects ./api/manuals ./api/fileshare ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/manuals ./domain/service/fileshare ./domain/service/resourcepasswords ./domain/service/webprojects ./domain/service/webcomments ./domain/service/applications ./domain/service/passport ./domain/service/rsa ./internal/accountsmigrate ./cmd/accounts-migrate ./cmd/accounts-maintain ./utils ./utils/md ./utils/log ./utils/ginfmt ./utils/cache ./domain/service/webanalytics + run: go test -v ./config ./rpc/homevirtualsub ./api/accounts ./api/webprojects ./api/manuals ./api/fileshare ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/manuals ./domain/service/fileshare ./domain/service/resourcepasswords ./domain/service/webprojects ./domain/service/webcomments ./domain/service/applications ./domain/service/passport ./domain/service/rsa ./internal/accountsmigrate ./cmd/accounts-migrate ./cmd/accounts-maintain ./utils ./utils/md ./utils/log ./utils/ginfmt ./utils/cache ./domain/service/webanalytics - name: Test isolated DAL cache bindings env: diff --git a/README.md b/README.md index 7c40c7d..90bccf5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - 家庭图书管理能力: 以 isbn 为基础,快速管理家庭中的纸质书及电子书; - WebDAV: 使用同一套账号体系,通过 WebDAV 协议实现外部文档访问,地址为 [https://www.server.com:port/webdav](https://www.server.com:port/webdav); - 网页托管:动态管理 HTML 文档和纯前端工具,通过同域 `/p/{slug}/` 访问,由服务端控制公开或指定账号可见。 +- home virtual sub:管理员在管理中心查看、增改删订阅配置,查看运行状态及刷新、启停订阅。默认关闭,部署及接口约定见 [管理说明](docs/specs/home-virtual-sub/design.md)。 ## 安装说明 diff --git a/api/accounts/home_virtual_sub_test.go b/api/accounts/home_virtual_sub_test.go new file mode 100644 index 0000000..4bbba4a --- /dev/null +++ b/api/accounts/home_virtual_sub_test.go @@ -0,0 +1,267 @@ +package accounts_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" + virtualapi "github.com/mcoder2014/home_server/api/homevirtualsub" + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/data" +) + +const virtualRevision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +const virtualPrefix = "/api/admin/home-virtual-sub" + +func installVirtualRoutes(t *testing.T, f *httpFixture, address string) { + t.Helper() + if address != "" { + path := filepath.Join(t.TempDir(), "service.token") + if err := os.WriteFile(path, []byte("synthetic-home-virtual-sub-service-token"), 0600); err != nil { + t.Fatal(err) + } + f.conf.HomeVirtualSub = config.HomeVirtualSubConfig{Enabled: true, BaseURL: address, TokenFile: path} + } + // Adding a bootstrap connection must retain the already loaded DB snapshot. + snapshot := config.Runtime() + config.SetGlobalConfig(f.conf) + if err := config.StoreRuntimeSnapshot(snapshot); err != nil { + t.Fatal(err) + } + if err := virtualapi.InitRouter(); err != nil { + t.Fatal(err) + } + data.ForRange(func(method, path string, handlers ...gin.HandlerFunc) { + if strings.HasPrefix(path, virtualPrefix) { + f.router.Handle(method, path, handlers...) + } + }) +} + +func TestHTTPHomeVirtualSubAuthorizationAndSafeAudit(t *testing.T) { + f := newHTTPFixture(t) + var calls int32 + var unauthorized int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + if r.Header.Get("Authorization") != "Bearer synthetic-home-virtual-sub-service-token" { + t.Error("service credential missing") + } + if atomic.LoadInt32(&unauthorized) == 1 { + w.WriteHeader(401) + w.Write([]byte(`{"error":{"code":"unauthorized","message":"secret-provider credential"}}`)) + return + } + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body["current_password"] != nil || body["reason"] != nil { + t.Error("confirmation leaked to service") + } + if r.Header.Get("If-Match") != `"`+virtualRevision+`"` { + t.Error("revision not forwarded") + } + json.NewEncoder(w).Encode(map[string]interface{}{"saved": true, "revision": virtualRevision, "apply_state": "pending", "message": "https://secret.example/sub?token=hidden"}) + })) + defer upstream.Close() + installVirtualRoutes(t, f, upstream.URL) + owner := f.login(f.owner, integrationPassword) + member := f.login(f.member, integrationPassword) + application := f.applicationToken(owner, []string{"web-projects:read"}) + input := map[string]interface{}{"name": "测试/订阅", "urls": []string{"https://secret.example/sub?token=hidden"}, "http_port": 18301, "socket_port": 18302, "reason": "验证管理权限", "current_password": integrationPassword} + headers := map[string]string{"If-Match": virtualRevision} + for _, session := range []*browserSession{nil, member} { + requireDenied(t, f.request("POST", virtualPrefix+"/subscriptions", session, input, headers)) + requireDenied(t, f.request("GET", virtualPrefix+"/status", session, nil, nil)) + } + requireDenied(t, f.request("POST", virtualPrefix+"/subscriptions", owner, input, map[string]string{"If-Match": virtualRevision, "Authorization": "Bearer " + application})) + requireDenied(t, f.request("POST", virtualPrefix+"/subscriptions", owner, input, map[string]string{"If-Match": virtualRevision, "X-CSRF-Token": ""})) + requireDenied(t, f.request("POST", virtualPrefix+"/subscriptions", owner, input, map[string]string{"If-Match": virtualRevision, "Origin": "https://foreign.example.com"})) + requireDenied(t, f.request("POST", virtualPrefix+"/subscriptions", owner, input, nil)) + input["current_password"] = "wrong-password" + requireDenied(t, f.request("POST", virtualPrefix+"/subscriptions", owner, input, headers)) + if atomic.LoadInt32(&calls) != 0 { + t.Fatal("denied request reached service") + } + input["current_password"] = integrationPassword + for _, invalid := range []map[string]interface{}{{"urls": []string{}}, {"urls": nil}, {"http_port": nil}, {"bind_ip": nil}, {"outbound_server_count": nil}} { + invalid["reason"], invalid["current_password"] = "验证空字段", integrationPassword + requireDenied(t, f.request("PATCH", virtualPrefix+"/subscriptions?name=demo", owner, invalid, headers)) + } + if atomic.LoadInt32(&calls) != 0 { + t.Fatal("explicit empty values reached service") + } + response := f.request("POST", virtualPrefix+"/subscriptions", owner, input, headers) + result := requireSuccess(t, response) + if result["saved"] != true || result["apply_state"] != "pending" || result["audit_recorded"] != true { + t.Fatalf("unexpected mutation result: %s", response.Raw) + } + if strings.Contains(string(response.Raw), "secret.example") || response.Header.Get("Cache-Control") != "no-store" { + t.Fatal("mutation leaked service text or was cacheable") + } + var summary, reason, outcome string + if err := f.database.QueryRow("SELECT after_summary, reason, result FROM admin_audit_log WHERE target_type = 'home_virtual_sub'").Scan(&summary, &reason, &outcome); err != nil { + t.Fatal(err) + } + if strings.Contains(summary, "secret.example") || strings.Contains(summary, integrationPassword) || reason != "验证管理权限" || outcome != "pending" { + t.Fatal("audit must retain only safe metadata and actual outcome") + } + atomic.StoreInt32(&unauthorized, 1) + response = f.request("POST", virtualPrefix+"/subscriptions", owner, input, headers) + if response.Status != 503 || strings.Contains(string(response.Raw), "credential") { + t.Fatalf("unsafe dependency failure: %s", response.Raw) + } + // A dependency credential failure must not revoke the browser session. + requireSuccess(t, f.request("GET", "/api/auth/me", owner, nil, nil)) + // An unavailable intent log must block the external write. + previous := atomic.LoadInt32(&calls) + if _, err := f.database.Exec("DROP TABLE admin_audit_log"); err != nil { + t.Fatal(err) + } + response = f.request("POST", virtualPrefix+"/subscriptions", owner, input, headers) + if response.Status != 503 || atomic.LoadInt32(&calls) != previous { + t.Fatal("unrecorded external write was allowed") + } +} + +func TestHTTPHomeVirtualSubDisabledByDefault(t *testing.T) { + f := newHTTPFixture(t) + installVirtualRoutes(t, f, "") + owner := f.login(f.owner, integrationPassword) + status := requireSuccess(t, f.request("GET", virtualPrefix+"/status", owner, nil, nil)) + if status["enabled"] != false { + t.Fatal("legacy config unexpectedly enabled management") + } + requireDenied(t, f.request("GET", virtualPrefix+"/subscriptions", owner, nil, nil)) +} + +func TestHTTPHomeVirtualSubAuditCompletionFailureKeepsServiceResult(t *testing.T) { + f := newHTTPFixture(t) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate an intent disappearing after the external operation succeeded. + if _, err := f.database.Exec("DELETE FROM admin_audit_log WHERE target_type = 'home_virtual_sub'"); err != nil { + t.Error(err) + } + json.NewEncoder(w).Encode(map[string]interface{}{"saved": true, "revision": virtualRevision, "apply_state": "applied"}) + })) + defer upstream.Close() + installVirtualRoutes(t, f, upstream.URL) + owner := f.login(f.owner, integrationPassword) + input := map[string]interface{}{"current_password": integrationPassword, "reason": "验证审计结果异常"} + response := f.request("DELETE", virtualPrefix+"/subscriptions?name=demo", owner, input, map[string]string{"If-Match": virtualRevision}) + result := requireSuccess(t, response) + if result["saved"] != true || result["apply_state"] != "applied" || result["audit_recorded"] != false { + t.Fatalf("external operation was misreported as rollback: %s", response.Raw) + } +} + +func TestHTTPHomeVirtualSubRechecksRevokedActingSession(t *testing.T) { + f := newHTTPFixture(t) + var calls int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + json.NewEncoder(w).Encode(map[string]interface{}{"saved": false, "revision": virtualRevision, "apply_state": "applied"}) + })) + defer upstream.Close() + installVirtualRoutes(t, f, upstream.URL) + acting := f.login(f.owner, integrationPassword) + surviving := f.login(f.owner, integrationPassword) + page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", acting, nil, nil)) + actingID := object(page["current_session"])["id"].(string) + payload, err := json.Marshal(map[string]interface{}{"reason": "验证写入前会话复核", "current_password": integrationPassword}) + if err != nil { + t.Fatal(err) + } + // Pause after authentication, then revoke only the session performing the write. + blocked, done := f.beginPausedAccountRequest(virtualPrefix+"/subscriptions/start?name=demo", acting, payload, map[string]string{"If-Match": virtualRevision}) + result := requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke", surviving, map[string]interface{}{"session_ids": []string{actingID}}, nil)) + if number(result["revoked_count"]) != 1 { + t.Fatal("acting session was not selectively revoked") + } + response := f.finishPausedRequest(blocked, done) + var audits int64 + if err := f.database.QueryRow("SELECT COUNT(*) FROM admin_audit_log WHERE target_type = 'home_virtual_sub'").Scan(&audits); err != nil { + t.Fatal(err) + } + if response.Status != http.StatusUnauthorized || atomic.LoadInt32(&calls) != 0 || audits != 0 { + t.Fatalf("revoked acting session reached the external write: status=%d calls=%d audits=%d", response.Status, atomic.LoadInt32(&calls), audits) + } + f.me(surviving) +} + +// This opt-in acceptance test connects the real administrator handlers to an +// isolated management service with a synthetic feed and core. CI without that +// independent service still runs the authorization and RPC contract tests. +func TestHTTPHomeVirtualSubConnectedService(t *testing.T) { + address, tokenFile, feed := os.Getenv("HOME_VIRTUAL_SUB_TEST_URL"), os.Getenv("HOME_VIRTUAL_SUB_TEST_TOKEN_FILE"), os.Getenv("HOME_VIRTUAL_SUB_TEST_FEED_URL") + if address == "" || tokenFile == "" || feed == "" { + t.Skip("requires an isolated home virtual sub service and synthetic feed") + } + if address != "http://127.0.0.1:19190" || !strings.HasPrefix(tokenFile, "/tmp/home-virtual-sub-validation-") || feed != "http://127.0.0.1:19191/subscription" { + t.Fatal("unsafe connected-service fixture") + } + f := newHTTPFixture(t) + f.conf.HomeVirtualSub = config.HomeVirtualSubConfig{Enabled: true, BaseURL: address, TokenFile: tokenFile} + installVirtualRoutes(t, f, "") + owner := f.login(f.owner, integrationPassword) + status := requireSuccess(t, f.request("GET", virtualPrefix+"/status", owner, nil, nil)) + if number(status["total"]) != 0 { + t.Fatal("connected service must start with an empty synthetic config") + } + getRevision := func() string { + return requireSuccess(t, f.request("GET", virtualPrefix+"/subscriptions", owner, nil, nil))["revision"].(string) + } + call := func(method, path string, values map[string]interface{}) map[string]interface{} { + values["current_password"], values["reason"] = integrationPassword, "独立双服务联调" + return requireSuccess(t, f.request(method, virtualPrefix+path, owner, values, map[string]string{"If-Match": getRevision()})) + } + nameQuery := "?name=%E8%81%94%E8%B0%83%2F%E8%AE%A2%E9%98%85" + created := call("POST", "/subscriptions", map[string]interface{}{"name": "联调/订阅", "urls": []string{feed}, "bind_ip": "127.0.0.1", "http_port": 19192, "socket_port": 19193, "outbound_server_count": 1}) + if created["saved"] != true { + t.Fatal("create did not persist configuration") + } + deadline := time.Now().Add(10 * time.Second) + for { + status = requireSuccess(t, f.request("GET", virtualPrefix+"/status", owner, nil, nil)) + if number(status["running"]) == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("synthetic core did not start") + } + time.Sleep(50 * time.Millisecond) + } + detail := requireSuccess(t, f.request("GET", virtualPrefix+"/subscriptions/detail"+nameQuery, owner, nil, nil)) + if object(detail["subscription"])["name"] != "联调/订阅" { + t.Fatal("query name did not survive the gateway") + } + call("PATCH", "/subscriptions"+nameQuery, map[string]interface{}{"http_port": 19194, "socket_port": 19195}) + call("POST", "/subscriptions/stop"+nameQuery, map[string]interface{}{}) + status = requireSuccess(t, f.request("GET", virtualPrefix+"/status", owner, nil, nil)) + if number(status["running"]) != 0 { + t.Fatal("stop did not wait for the core to exit") + } + call("POST", "/subscriptions/start"+nameQuery, map[string]interface{}{}) + call("POST", "/subscriptions/refresh"+nameQuery, map[string]interface{}{}) + deleted := call("DELETE", "/subscriptions"+nameQuery, map[string]interface{}{}) + if deleted["saved"] != true { + t.Fatal("delete did not persist configuration") + } + status = requireSuccess(t, f.request("GET", virtualPrefix+"/status", owner, nil, nil)) + if number(status["total"]) != 0 || number(status["running"]) != 0 { + t.Fatal("last delete retained a worker") + } + logs := requireSuccess(t, f.request("GET", "/api/admin/audit-logs?target_type=home_virtual_sub", owner, nil, nil)) + encoded, _ := json.Marshal(logs) + if strings.Contains(string(encoded), feed) || strings.Contains(string(encoded), integrationPassword) { + t.Fatal("connected-service audit exposed sensitive input") + } +} diff --git a/api/handler.go b/api/handler.go index 33084af..ad261cd 100644 --- a/api/handler.go +++ b/api/handler.go @@ -6,6 +6,7 @@ import ( "github.com/mcoder2014/home_server/api/applications" "github.com/mcoder2014/home_server/api/auth" "github.com/mcoder2014/home_server/api/fileshare" + "github.com/mcoder2014/home_server/api/homevirtualsub" "github.com/mcoder2014/home_server/api/library" "github.com/mcoder2014/home_server/api/manuals" "github.com/mcoder2014/home_server/api/middleware" @@ -41,6 +42,7 @@ func InitRouter() error { fileshare.InitRouter, auth.InitRouter, applications.InitRouter, + homevirtualsub.InitRouter, } { err = initFunc() if err != nil { diff --git a/api/homevirtualsub/handlers.go b/api/homevirtualsub/handlers.go new file mode 100644 index 0000000..7ddb777 --- /dev/null +++ b/api/homevirtualsub/handlers.go @@ -0,0 +1,207 @@ +package homevirtualsub + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "strings" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" + "github.com/mcoder2014/home_server/api/middleware" + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/db" + "github.com/mcoder2014/home_server/domain/model" + accountservice "github.com/mcoder2014/home_server/domain/service/accounts" + apperrors "github.com/mcoder2014/home_server/errors" + virtual "github.com/mcoder2014/home_server/rpc/homevirtualsub" + "github.com/mcoder2014/home_server/utils" + "github.com/mcoder2014/home_server/utils/ginfmt" + "github.com/mcoder2014/home_server/utils/log" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func read(c *gin.Context, client *virtual.Client, view string) { + if client == nil { + if view == "status" { + ginfmt.Success(c, http.StatusOK, virtual.Status{Workers: []virtual.Worker{}}) + } else { + ginfmt.Fail(c, apperrors.WithMessage(apperrors.ErrNotFound, "home virtual sub 管理尚未启用")) + } + return + } + var result interface{} + var err error + switch view { + case "status": + result, err = client.Status(c.Request.Context()) + case "subscriptions": + result, err = client.List(c.Request.Context()) + case "subscriptions/detail": + result, err = client.Detail(c.Request.Context(), c.Query("name")) + } + if err != nil { + ginfmt.Fail(c, err) + return + } + ginfmt.Success(c, http.StatusOK, result) +} + +type mutationInput struct { + virtual.SubscriptionInput + Reason string `json:"reason"` + CurrentPassword string `json:"current_password"` +} + +// mutate confirms the administrator and records intent before contacting the +// service. No database transaction spans the network call. Service results and +// audit failures are reported independently; an external change is never +// described as rolled back because local audit finalization failed. +func mutate(c *gin.Context, client *virtual.Client, action string) { + if client == nil { + ginfmt.Fail(c, apperrors.WithMessage(apperrors.ErrNotFound, "home virtual sub 管理尚未启用")) + return + } + revision := c.GetHeader("If-Match") + if strings.HasPrefix(revision, `"`) && strings.HasSuffix(revision, `"`) { + revision = strings.TrimSuffix(strings.TrimPrefix(revision, `"`), `"`) + } + if len(c.Request.Header.Values("If-Match")) != 1 || !virtual.ValidRevision(revision) { + ginfmt.Fail(c, apperrors.WithMessage(apperrors.ErrInvalid, "请刷新配置并携带当前版本")) + return + } + var input mutationInput + if err := decodeInput(c, &input); err != nil { + ginfmt.Fail(c, err) + return + } + name := c.Query("name") + if action == "create" { + name = input.Name + } + if name == "" || len(name) > 256 || !utf8.ValidString(name) || strings.ContainsAny(name, "\r\n\x00") { + ginfmt.Fail(c, apperrors.ErrInvalid) + return + } + if action != "create" && input.Name != "" && input.Name != name { + ginfmt.Fail(c, apperrors.WithMessage(apperrors.ErrInvalid, "编辑时不能修改订阅名称")) + return + } + if action == "create" && len(input.URLs) == 0 { + ginfmt.Fail(c, apperrors.WithMessage(apperrors.ErrInvalid, "请填写订阅地址")) + return + } + audit, err := beginAudit(c, action, name, revision, input) + if err != nil { + ginfmt.Fail(c, err) + return + } + result, callErr := client.Mutate(c.Request.Context(), action, c.Query("name"), revision, input.SubscriptionInput) + // Finalize with a short independent context even if the browser disconnected. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + outcome, after := "unknown", "{}" + if callErr == nil { + outcome = result.ApplyState + content, _ := json.Marshal(map[string]interface{}{"name": name, "saved": result.Saved, "revision": result.Revision, "apply_state": result.ApplyState}) + after = string(content) + } else if errors.Is(callErr, apperrors.ErrInvalid) || errors.Is(callErr, apperrors.ErrConflict) || errors.Is(callErr, apperrors.ErrNotFound) { + outcome = "rejected" + } + finished := db.MasterDB().WithContext(ctx).Session(&gorm.Session{Logger: logger.Discard}).Table(dal.AdminAuditTable).Where("id = ?", audit.ID).Updates(map[string]interface{}{"result": outcome, "after_summary": after}) + if finished.Error != nil || finished.RowsAffected != 1 { + log.Ctx(ctx).Error("home virtual sub audit result could not be recorded") + } + if callErr != nil { + ginfmt.Fail(c, callErr) + return + } + result.AuditRecorded = finished.Error == nil && finished.RowsAffected == 1 + if !result.AuditRecorded { + result.Message += ";操作记录结果待核对" + } + ginfmt.Success(c, http.StatusOK, result) +} + +func decodeInput(c *gin.Context, input *mutationInput) error { + media, _, err := mime.ParseMediaType(c.GetHeader("Content-Type")) + if err != nil || media != "application/json" { + return apperrors.ErrUnsupported + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + decoder := json.NewDecoder(c.Request.Body) + var raw map[string]json.RawMessage + if err = decoder.Decode(&raw); err != nil { + var limit *http.MaxBytesError + if errors.As(err, &limit) { + return apperrors.ErrTooLarge + } + return apperrors.ErrInvalid + } + if decoder.Decode(&struct{}{}) != io.EOF { + return apperrors.ErrInvalid + } + if raw == nil { + return apperrors.ErrInvalid + } + // Explicit null must not turn into an omitted field in the service PATCH. + for _, value := range raw { + if strings.TrimSpace(string(value)) == "null" { + return apperrors.ErrInvalid + } + } + content, err := json.Marshal(raw) + if err != nil { + return apperrors.ErrInvalid + } + fields := json.NewDecoder(bytes.NewReader(content)) + fields.DisallowUnknownFields() + if fields.Decode(input) != nil || (input.URLs != nil && len(input.URLs) == 0) { + return apperrors.ErrInvalid + } + input.Reason = strings.TrimSpace(input.Reason) + if input.Reason == "" || len(input.Reason) > 512 || !utf8.ValidString(input.Reason) || input.CurrentPassword == "" || len(input.CurrentPassword) > 1024 { + return apperrors.ErrInvalid + } + return nil +} + +// beginAudit rechecks the exact acting session and the account password version +// in a short transaction. Intent must be durable before the remote side effect. +func beginAudit(c *gin.Context, action, name, revision string, input mutationInput) (*model.AdminAuditLog, error) { + value, _ := c.Get(middleware.AccountContextKey) + actor, ok := value.(*model.UserAccount) + if !ok || actor == nil { + return nil, apperrors.ErrUnauthorized + } + verified, err := accountservice.VerifyAdminPassword(ginfmt.RPCContext(c), actor.ID, input.CurrentPassword) + if err != nil { + return nil, err + } + database := db.MasterDB() + if database == nil { + return nil, apperrors.ErrDependency + } + content, _ := json.Marshal(map[string]interface{}{"name": name, "revision": revision, "http_port": input.HTTPPort, "socket_port": input.SocketPort, "url_count": len(input.URLs)}) + audit := &model.AdminAuditLog{ActorUserID: actor.ID, Action: "home_virtual_sub." + action, TargetType: "home_virtual_sub", BeforeSummary: string(content), AfterSummary: "{}", Reason: input.Reason, Result: "in_progress", RequestID: c.GetString(log.LogIDKey), CreateTime: time.Now()} + err = database.WithContext(c.Request.Context()).Session(&gorm.Session{Logger: logger.Discard}).Transaction(func(tx *gorm.DB) error { + fresh, e := accountservice.RequireUserSessionTx(tx, c.GetString(utils.CtxKeyLoginToken), actor.ID) + if e != nil { + return e + } + if fresh.Role != model.RoleAdmin || fresh.AuthVersion != verified.AuthVersion || fresh.PasswordHash != verified.PasswordHash { + return apperrors.ErrForbidden + } + return tx.Table(dal.AdminAuditTable).Create(audit).Error + }) + if err != nil { + return nil, err + } + return audit, nil +} diff --git a/api/homevirtualsub/routes.go b/api/homevirtualsub/routes.go new file mode 100644 index 0000000..7b99476 --- /dev/null +++ b/api/homevirtualsub/routes.go @@ -0,0 +1,37 @@ +package homevirtualsub + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mcoder2014/home_server/api/middleware" + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/data" + virtual "github.com/mcoder2014/home_server/rpc/homevirtualsub" +) + +const prefix = "/api/admin/home-virtual-sub" + +func InitRouter() error { + client, err := virtual.NewClient(config.Global().HomeVirtualSub) + if err != nil { + return err + } + admin, write := middleware.RequireAccount(true, false), middleware.BrowserWrite() + for _, view := range []string{"status", "subscriptions", "subscriptions/detail"} { + current := view + data.AddRoute(http.MethodGet, prefix+"/"+current, admin, func(c *gin.Context) { read(c, client, current) }) + } + for _, route := range []struct{ method, path, action string }{ + {http.MethodPost, "/subscriptions", "create"}, + {http.MethodPatch, "/subscriptions", "update"}, + {http.MethodDelete, "/subscriptions", "delete"}, + {http.MethodPost, "/subscriptions/refresh", "refresh"}, + {http.MethodPost, "/subscriptions/start", "start"}, + {http.MethodPost, "/subscriptions/stop", "stop"}, + } { + action := route.action + data.AddRoute(route.method, prefix+route.path, admin, write, func(c *gin.Context) { mutate(c, client, action) }) + } + return nil +} diff --git a/config/config_example.yaml b/config/config_example.yaml index d867dc8..e2dcd82 100644 --- a/config/config_example.yaml +++ b/config/config_example.yaml @@ -4,6 +4,12 @@ identity_source: config config_source: file # Verified hard ceiling of every proxy upload path; 50 MiB in this example. upload_hard_limit_bytes: 52428800 +# Optional administrator integration. Database accounts and HTTPS are required. +home_virtual_sub: + enabled: false + base_url: http://127.0.0.1:18190 + token_file: /etc/home_server/home_virtual_sub.token + timeout_ms: 5000 server: # http server 的服务端口 port: 8080 diff --git a/config/global.go b/config/global.go index 0f96273..d0b16e3 100644 --- a/config/global.go +++ b/config/global.go @@ -7,12 +7,13 @@ import ( ) type Config struct { - Redis RedisConfig `json:"-" yaml:"redis"` - Cache CacheConfig `json:"-" yaml:"cache"` - Analytics AnalyticsConfig `json:"-" yaml:"analytics"` - IdentitySource string `json:"identity_source" yaml:"identity_source"` - ConfigSource string `json:"config_source" yaml:"config_source"` - UploadHardLimitBytes int64 `json:"upload_hard_limit_bytes" yaml:"upload_hard_limit_bytes"` + Redis RedisConfig `json:"-" yaml:"redis"` + Cache CacheConfig `json:"-" yaml:"cache"` + Analytics AnalyticsConfig `json:"-" yaml:"analytics"` + HomeVirtualSub HomeVirtualSubConfig `json:"-" yaml:"home_virtual_sub"` + IdentitySource string `json:"identity_source" yaml:"identity_source"` + ConfigSource string `json:"config_source" yaml:"config_source"` + UploadHardLimitBytes int64 `json:"upload_hard_limit_bytes" yaml:"upload_hard_limit_bytes"` // 服务相关配置 Server struct { // http 服务端口号 @@ -169,6 +170,9 @@ func InitGlobalConfig(filepath string) error { if err := NormalizeInfrastructure(&loaded); err != nil { return err } + if err := NormalizeHomeVirtualSub(&loaded.HomeVirtualSub); err != nil { + return err + } SetGlobalConfig(loaded) logrus.Infof("InitGlobalConfig config file path: %v", filepath) return nil diff --git a/config/home_virtual_sub.go b/config/home_virtual_sub.go new file mode 100644 index 0000000..9dbc25e --- /dev/null +++ b/config/home_virtual_sub.go @@ -0,0 +1,50 @@ +package config + +import ( + "fmt" + "net" + "net/url" + "path/filepath" + "strconv" + "strings" +) + +// HomeVirtualSubConfig is a deployment-only connection. It is never part of +// browser bootstrap or the editable runtime configuration registry. +type HomeVirtualSubConfig struct { + Enabled bool `yaml:"enabled"` + BaseURL string `yaml:"base_url"` + TokenFile string `yaml:"token_file"` + TimeoutMS int `yaml:"timeout_ms"` +} + +func NormalizeHomeVirtualSub(c *HomeVirtualSubConfig) error { + if !c.Enabled { + return nil + } + if c.BaseURL == "" { + c.BaseURL = "http://127.0.0.1:18190" + } + if c.TimeoutMS == 0 { + c.TimeoutMS = 5000 + } + u, err := url.Parse(c.BaseURL) + if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return fmt.Errorf("home virtual sub requires a loopback HTTP address without credentials or a path") + } + ip := net.ParseIP(u.Hostname()) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("home virtual sub requires a literal loopback address") + } + if u.Port() != "" { + port, e := strconv.Atoi(u.Port()) + if e != nil || port < 1 || port > 65535 { + return fmt.Errorf("invalid home virtual sub port") + } + } + if !filepath.IsAbs(c.TokenFile) || c.TimeoutMS < 100 || c.TimeoutMS > 30000 { + return fmt.Errorf("home virtual sub requires an absolute token_file and timeout_ms between 100 and 30000") + } + c.BaseURL = strings.TrimSuffix(c.BaseURL, "/") + return nil +} diff --git a/config/home_virtual_sub_test.go b/config/home_virtual_sub_test.go new file mode 100644 index 0000000..e5e2526 --- /dev/null +++ b/config/home_virtual_sub_test.go @@ -0,0 +1,27 @@ +package config + +import ( + "testing" + + "github.com/smartystreets/goconvey/convey" +) + +func TestHomeVirtualSubConfig(t *testing.T) { + convey.Convey("旧配置保持关闭,启用后限制私有连接与凭证路径", t, func() { + var legacy HomeVirtualSubConfig + convey.So(NormalizeHomeVirtualSub(&legacy), convey.ShouldBeNil) + convey.So(legacy.Enabled, convey.ShouldBeFalse) + + active := HomeVirtualSubConfig{Enabled: true, TokenFile: "/etc/home_server/home_virtual_sub.token"} + convey.So(NormalizeHomeVirtualSub(&active), convey.ShouldBeNil) + convey.So(active.BaseURL, convey.ShouldEqual, "http://127.0.0.1:18190") + convey.So(active.TimeoutMS, convey.ShouldEqual, 5000) + for _, address := range []string{"http://example.com", "http://user:secret@127.0.0.1", "http://127.0.0.1/other", "http://127.0.0.1?token=secret", "file:///tmp/service"} { + candidate := active + candidate.BaseURL = address + convey.So(NormalizeHomeVirtualSub(&candidate), convey.ShouldNotBeNil) + } + active.TokenFile = "relative.token" + convey.So(NormalizeHomeVirtualSub(&active), convey.ShouldNotBeNil) + }) +} diff --git a/deploy/pi/README.md b/deploy/pi/README.md index 0d0c492..8198e6c 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -4,6 +4,8 @@ 实际 IP、域名、系统账号、证书路径和数据库配置应保存在服务器或被 Git 忽略的 `deploy/local/`,不要回填到公开模板。复制示例并替换所有环境值后,再执行配置校验与部署。 +可选的 home virtual sub 管理入口位于 `/admin/home-virtual-sub`。先部署支持 `/management/v1` 的同机管理服务,准备两个服务内容一致的私有凭证文件,再启用后端 `home_virtual_sub` 启动配置;数据库管理员会话、HTTPS 和现有 `/api/admin/` 代理规则必须可用。该能力无需新增 DDL或公网管理端口。配置、权限、结果未知与关闭集成的步骤见 [home virtual sub 管理说明](../../docs/specs/home-virtual-sub/design.md#部署连接)。 + | 组件 | 路径与职责 | | --- | --- | | 后端 release | `/opt/home_server/releases//bin/home_server`;`current` 符号链接选择当前版本。 | diff --git a/docs/specs/home-virtual-sub/design.md b/docs/specs/home-virtual-sub/design.md new file mode 100644 index 0000000..0106fbe --- /dev/null +++ b/docs/specs/home-virtual-sub/design.md @@ -0,0 +1,84 @@ +# home virtual sub 管理 + +## 目标与边界 + +管理员通过管理中心查看、新增、编辑、删除订阅,并查看运行状态及刷新、启停单个订阅。home virtual sub 独立运行,订阅配置和代理进程由其自身管理;Home Server 负责管理员鉴权、界面和操作记录。 + +管理连接为可选启动配置,默认关闭。首版用于同机部署,管理服务监听回环地址。保留已有账号、业务模块、代理监听地址和订阅启动行为。 + +```mermaid +flowchart LR + A["管理员浏览器"] -->|"HTTPS 与管理员会话"| B["Home Server"] + B -->|"回环连接与私有服务凭证"| C["home virtual sub"] + C --> D["订阅配置文件"] + C --> E["订阅调度与代理子进程"] +``` + +## 管理接口 + +浏览器接口前缀为 `/api/admin/home-virtual-sub`,服务接口前缀为 `/management/v1`。接口只允许固定路径和动作,订阅名称通过 `name` 查询参数传递,支持原有非 ASCII 名称。 + +| 方法与相对路径 | 行为 | +| --- | --- | +| `GET /status` | 返回服务启动时间、核心类型、检查间隔、配置版本、运行数量及订阅状态 | +| `GET /subscriptions` | 返回脱敏配置列表;不包含订阅 URL 或代理密码 | +| `GET /subscriptions/detail?name=…` | 返回管理员编辑需要的订阅 URL 和普通字段;代理链只标记存在 | +| `POST /subscriptions` | 新增订阅并触发生效 | +| `PATCH /subscriptions?name=…` | 编辑原订阅;名称保持不变 | +| `DELETE /subscriptions?name=…` | 停止并删除订阅 | +| `POST /subscriptions/refresh?name=…` | 请求刷新订阅 | +| `POST /subscriptions/start?name=…` | 启用并启动订阅 | +| `POST /subscriptions/stop?name=…` | 停止订阅,定时刷新不会重新启动 | + +服务成功响应为直接 JSON;浏览器响应沿用 Home Server 的 `code`、`message`、`data` 结构。状态响应包含 `api_version: 1`、`started_at`、`core_type`、`interval_second`、`revision`、`total`、`running`、`stopped`、`workers`。每个 worker 包含 `name`、`bind_ip`、`http_port`、`socket_port`、`url_count`、`enabled`、`running`、`state`、`last_error`、`last_refresh_at`、`last_success_at`。 + +配置列表为 `{revision, subscriptions}`;浏览器可见字段为 `name`、`bind_ip`、`http_port`、`socket_port`、`outbound_server_count`、`url_count`、`has_proxy_chain`,详情额外返回 `urls`。服务接口中的生成文件名不会转发给浏览器。新增与编辑仅接受名称、URL、监听地址、两个端口和出站节点数量(1–5);修改保留生成文件名、代理链和其他未展示字段。两种监听端口必须不同,且在 1–65535 之间;服务检查与其他订阅及实际监听端口的冲突。 + +写请求携带 `If-Match` 中的配置摘要版本。Home Server 额外要求 `current_password` 和 `reason`,这些字段不转发给服务。服务写结果为 `{saved, revision, apply_state, message}`,`apply_state` 为 `pending`、`applied` 或 `error`。服务错误为 `{error: {code, message}}`,错误码限定为 `invalid`、`conflict`、`not_found`、`unauthorized`、`unavailable`,不得返回原始订阅响应、运行配置或带凭证的错误。 + +## 持久化与进程生命周期 + +订阅配置只有服务端一个写入者,不复制到 Home Server 数据库。保存按完整配置摘要检测并发修改,以同目录临时文件和原子替换持久化,保留未修改字段。管理模式允许零订阅。 + +配置保存与生效分别报告;刷新及启动由服务生命周期管理,HTTP 请求结束不能终止代理进程。操作有界,状态查询不等待外部订阅拉取。编辑只调整目标订阅;改变端口等配置会使对应代理短暂中断。 + +运行中复用当前端口并扩大监听范围(例如 `127.0.0.1` 改为 `0.0.0.0` 或 `::1` 改为 `::`)时,先停止订阅,再修改并启动。扩大范围需要检查新增地址上的端口占用;保留旧核心时无法完成该检查,因此服务拒绝直接在线扩大范围,不保存该次编辑。 + +删除先停止调度并确认子进程退出,再持久化删除和移除 worker。保存失败恢复原启用状态并报告实际结果。成功删除后,定时刷新及重启均不能复活订阅。临时停止不持久化,服务重启仍按原有启动规则启用已配置订阅。 + +## 权限、界面与异常 + +复用管理员会话、HTTPS、Origin、CSRF 和当前密码校验,应用凭证与普通用户不能管理订阅。连接地址和服务凭证只存在受限启动配置中,响应采用 `Cache-Control: no-store`。管理操作沿用审计结构,摘要仅记录名称、端口、动作和结果。 + +写操作先持久化 `in_progress` 审计意图,再调用服务;不在数据库事务中等待网络。返回的保存和生效结果独立于审计更新结果,浏览器结果包含 `audit_recorded`。服务超时或返回不可用时记录 `unknown`,需重新读取配置及状态核对;进程中断后仍为 `in_progress` 的记录也按结果未知处理。审计更新失败不会把已经完成的服务操作描述为回滚。 + +界面位于管理中心,使用状态摘要、订阅列表、编辑表单和操作确认。每 5 秒读取状态,隐藏页面暂停刷新;请求失败清除在线状态并显示当前状态未知。服务可连接、代理运行和最近订阅刷新结果分别展示。写入超时提示结果待核对,不自动重发。 + +## 验收 + +| 场景 | 通过条件 | +| --- | --- | +| 权限 | 未登录、普通用户、应用身份、错误密码或 CSRF 均不能触发服务写入 | +| 配置 | 新增重启保留;编辑保留未展示字段;版本冲突与保存失败不覆盖配置 | +| 进程 | 请求结束后子进程继续运行;目标订阅变更不停止其他订阅 | +| 删除 | 删除最后一个订阅后管理服务仍可用;重启不会恢复已删除订阅 | +| 异常 | 服务断连不显示旧在线状态;保存和生效分别报告;错误和审计没有凭证 | +| 兼容 | 默认关闭时旧配置、登录、页面和业务 API 保持原行为 | + +验证在 `ssh pi` 的独立临时目录、合成配置与空闲端口进行,不使用运行配置或停止现有服务。 + +## 部署连接 + +Home Server 需要已完成数据库账号迁移,使用管理员浏览器会话及同源 HTTPS。连接只接受字面量回环 IP,不接受用户信息、查询参数或附加路径;不经过环境代理,不跟随重定向。 + +```yaml +home_virtual_sub: + enabled: true + base_url: http://127.0.0.1:18190 + token_file: /etc/home_server/home_virtual_sub.token + timeout_ms: 5000 +``` + +管理服务的凭证与此文件内容一致。凭证为 32–256 个可见 ASCII 字符,文件须为运行用户可读取的普通文件,且组与其他用户没有权限,建议使用 `0600`;两个服务使用不同系统账号时,各持有内容一致的私有文件。凭证不进入 Git、浏览器、审计摘要或日志。连接配置属于启动参数,修改后重启 Home Server;管理员在页面上维护订阅,无需日常 SSH。 + +已有 `/api/admin/` Nginx 规则覆盖全部新增接口,不增加公开管理服务端口。升级先部署支持 v1 契约的管理服务,再部署 Home Server 并启用连接;旧配置缺少 `home_virtual_sub` 时仍关闭。关闭集成可设 `enabled: false` 后重启 Home Server,已有订阅代理继续按服务自身配置运行。新接口无需数据库 DDL。 diff --git a/docs/specs/home-virtual-sub/validation.md b/docs/specs/home-virtual-sub/validation.md new file mode 100644 index 0000000..ee955e1 --- /dev/null +++ b/docs/specs/home-virtual-sub/validation.md @@ -0,0 +1,27 @@ +# home virtual sub 验证记录 + +验证日期:2026-10-07。构建与测试均通过 `ssh pi` 在临时目录执行,使用合成订阅、模拟代理子进程、独立 MariaDB 数据目录和测试数据库。验证没有读取运行配置、修改生产数据或重启现有服务。 + +## 已通过的验证 + +| 范围 | 检查内容 | 结果 | +| --- | --- | --- | +| 启动配置与服务客户端 | 默认关闭、回环地址、私有凭证文件、固定路径、版本转发、重定向拒绝、超时不重试、错误脱敏 | Go 测试通过 | +| 管理接口 | 未登录、普通用户、应用凭证、错误密码、CSRF、Origin、版本与空字段校验;认证后撤销操作会话仍禁止写入;确认字段不转发 | HTTP 集成测试通过 | +| 审计 | 调用前保存意图;意图保存失败禁止调用;结果更新失败仍保留真实操作结果并提示审计异常 | HTTP 集成测试通过 | +| 现有账号能力 | `api/accounts`、`api/auth`、`api/middleware`、`api/applications`、`domain/service/accounts` | 回归测试通过 | +| 并发 | 新服务客户端与管理 HTTP 用例 | `go test -race` 通过 | +| 前端 | 请求契约、表单校验、版本冲突保留草稿、迟到响应失效、未知写结果核对、旧页面用例 | 125 个用例通过 | +| 构建 | 服务端 `./build.sh`;前端 `npm run build` | 产物生成成功 | +| 双服务联调 | 管理员实际处理链连接独立管理服务,新增、详情、编辑、启停、刷新、删除最后一个订阅 | 通过 | +| 重启持久化 | 删除后重启独立管理服务 | 配置仍为零订阅,运行数量为零 | + +测试数据库使用 UTC 时间以匹配现有 DSN 的时间解析约定;并行验证使用不同数据库,避免账号 fixture 的清表操作互相影响。 + +## 验证限制 + +pi 上没有可用的浏览器测试工具,尚未完成真实浏览器中的页面布局、交互和 HTTPS 部署验收;当前依据为页面行为测试与 Vue 构建结果。 + +pi 上没有 Mermaid 渲染器,远程 SVG 渲染请求返回 HTTP 403;[设计文档](design.md)中的架构图尚未完成渲染验证。 + +联调使用模拟代理子进程和合成节点,没有验证真实代理核心的网络转发能力。已有线上服务保持运行;实际启用集成仍需配置本机连接及凭证,并按设计文档的部署顺序操作。 diff --git a/front_vue/README.md b/front_vue/README.md index 33df166..9c44657 100644 --- a/front_vue/README.md +++ b/front_vue/README.md @@ -14,6 +14,7 @@ Vue 3 与 Element Plus 页面。浏览器使用同源 HttpOnly Cookie 登录, | `/admin/users` | 搜索用户、有效会话统计与详情;创建、封禁、恢复、删除、重置密码、昵称/头像、退出全部会话;管理员、藏书和 WebDAV 授权 | `/api/admin/users`、`/:id`、`/:id/sessions`、`/:id/reset-profile` 及原管理动作 | | `/admin/web-share` | 按所有者、名称、发布状态、可见范围和审核状态筛选全站网页;版本预览、下架、删除、恢复、解除审核锁 | `/api/admin/web-share`、`/:id`、`/:id/:action`、管理员版本预览路径 | | `/admin/config` | 根据 schema 生成分组表单;校验、查看差异、发布、查看历史、回滚、检查运行生效状态 | `/api/admin/config/schema`、`config`、`status`、`/:namespace`、`validate`、`history`、`rollback` | +| `/admin/home-virtual-sub` | 查看订阅和运行状态;新增、编辑、删除、刷新和启停订阅 | `/api/admin/home-virtual-sub/status`、`subscriptions`、`subscriptions/detail`、`subscriptions/refresh`、`start`、`stop` | | `/admin/audit-logs` | 按目标类型与数字 ID 查询操作记录及脱敏变化 | `/api/admin/audit-logs` | 原 `/applications`、`/web-share`、`/web-projects` 兼容入口、图书与扫码页面保留。网页托管与应用管理请求也使用 Cookie 和 CSRF;应用 scope 选择器包含网页托管、网页评论、藏书和 WebDAV 权限,并按当前用户能力限制可授予范围。网页编辑页可在增强容器与原始页面之间切换;新项目默认增强,旧项目缺少字段时按原始页面显示。应用有效期表单读取 `/me.application_policy` 的默认值及上限,随当前站点策略变化。应用统计区分有效与未吊销数量,网页统计区分发布与未删除数量。 @@ -36,6 +37,8 @@ Vue 3 与 Element Plus 页面。浏览器使用同源 HttpOnly Cookie 登录, ## 动态配置表单 +home virtual sub 的连接由部署配置启用,凭证保存在后端。订阅列表不包含 URL;打开编辑窗口后读取地址,关闭时清除草稿。所有写操作要求原因、当前密码和配置版本。每 5 秒读取运行状态,页面隐藏时暂停,断连立即清除在线状态;配置轮询不覆盖编辑草稿。版本冲突需重新读取并确认,写请求超时后核对配置和状态,不自动重发。停止是临时操作,服务重启后恢复原有启动规则。 + 配置字段由服务端 schema 定义。布尔值使用开关、整数使用带范围的输入框、文本使用普通文本表单;固定邀请规则只读且不进入发布请求。发布前显示字段差异和生效影响,回滚创建新版本。数据库中已保存的版本与运行中已加载的版本分别展示,页面每 15 秒刷新运行状态。 关闭注册会使旧的未使用邀请码永久失效,重新开启或版本回滚不复活旧码。配置页的发布和回滚使用 `request_id`、`If-Match`、原因及管理员当前密码。密码不进入配置值或历史。 diff --git a/front_vue/src/api/home_virtual_sub.cjs b/front_vue/src/api/home_virtual_sub.cjs new file mode 100644 index 0000000..c957043 --- /dev/null +++ b/front_vue/src/api/home_virtual_sub.cjs @@ -0,0 +1,21 @@ +const {createBrowserRequest} = require('./browser_client.cjs') +const prefix = '/api/admin/home-virtual-sub' + +function createHomeVirtualSubApi(transport, getCSRF) { + const request = createBrowserRequest(transport, getCSRF) + return { + status: () => request('get', prefix + '/status'), + list: () => request('get', prefix + '/subscriptions'), + detail: name => request('get', prefix + '/subscriptions/detail', undefined, undefined, {name}), + create: (revision, data) => request('post', prefix + '/subscriptions', data, revision), + update: (name, revision, data) => request('patch', prefix + '/subscriptions', data, revision, {name}), + remove: (name, revision, data) => request('delete', prefix + '/subscriptions', data, revision, {name}), + action: async (action, name, revision, data) => { + if (!['refresh', 'start', 'stop'].includes(action)) throw new Error('未知订阅操作') + return request('post', prefix + '/subscriptions/' + action, data, revision, {name}) + }, + } +} + +const homeVirtualSubApi = createHomeVirtualSubApi() +module.exports = {createHomeVirtualSubApi, homeVirtualSubApi} diff --git a/front_vue/src/router/index.js b/front_vue/src/router/index.js index 2d4d67d..b9b2353 100644 --- a/front_vue/src/router/index.js +++ b/front_vue/src/router/index.js @@ -29,6 +29,7 @@ const routes = [ {path: 'users', component: () => import('@/views/AdminUsers.vue'), meta: {title: '用户管理'}}, {path: 'web-share', component: () => import('@/views/AdminProjects.vue'), meta: {title: '网页审核'}}, {path: 'config', component: () => import('@/views/AdminConfig.vue'), meta: {title: '站点设置'}}, + {path: 'home-virtual-sub', component: () => import('@/views/AdminHomeVirtualSub.vue'), meta: {title: 'home virtual sub'}}, {path: 'audit-logs', component: () => import('@/views/AdminAudit.vue'), meta: {title: '操作记录'}}, ]}, { diff --git a/front_vue/src/utils/home_virtual_sub_behavior.cjs b/front_vue/src/utils/home_virtual_sub_behavior.cjs new file mode 100644 index 0000000..a9ee791 --- /dev/null +++ b/front_vue/src/utils/home_virtual_sub_behavior.cjs @@ -0,0 +1,52 @@ +function subscriptionInput(draft) { + const name = String(draft.name || '') + if (!name.trim() || new TextEncoder().encode(name).length > 256 || /[\r\n\0]/.test(name)) throw new Error('订阅名称不能为空,且不能超过 256 个 UTF-8 字节') + const urls = String(draft.urls_text || '').split(/\r?\n/).map(value => value.trim()).filter(Boolean) + if (!urls.length || urls.length > 64) throw new Error('请填写 1–64 个订阅地址,每行一个') + for (const value of urls) { + let url + try { url = new URL(value) } catch (_) { throw new Error('订阅地址必须是完整的 HTTP 或 HTTPS 地址') } + if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) throw new Error('订阅地址必须是完整的 HTTP 或 HTTPS 地址') + } + const httpPort = Number(draft.http_port), socketPort = Number(draft.socket_port), outbound = Number(draft.outbound_server_count) + if (![httpPort, socketPort].every(value => Number.isInteger(value) && value >= 1 && value <= 65535) || httpPort === socketPort) throw new Error('HTTP 和 SOCKS 端口必须不同,且在 1–65535 之间') + if (!Number.isInteger(outbound) || outbound < 1 || outbound > 5) throw new Error('出站节点数量必须在 1–5 之间') + const bind = String(draft.bind_ip || '').trim() + if (!bind) throw new Error('请填写监听 IP 地址') + return {name, urls, bind_ip: bind, http_port: httpPort, socket_port: socketPort, outbound_server_count: outbound} +} + +// Each visibility change invalidates older requests. Failed reads immediately +// discard the previous online state, while the editor remains independent. +function createStatusLoader(fetchStatus, onStatus, onError) { + let active = false, epoch = 0, pendingEpoch = -1 + return { + activate() { if (!active) { active = true; epoch++ } }, + deactivate() { active = false; epoch++; onStatus(null) }, + invalidate() { epoch++; onStatus(null) }, + async refresh() { + if (!active || pendingEpoch === epoch) return + const requestEpoch = epoch + pendingEpoch = requestEpoch + try { + const status = await fetchStatus() + if (active && epoch === requestEpoch) { onStatus(status); onError(null) } + } catch (error) { + if (active && epoch === requestEpoch) { onStatus(null); onError(error) } + } finally { + if (pendingEpoch === requestEpoch) pendingEpoch = -1 + } + }, + } +} + +function workerPresentation(worker) { + if (!worker) return {label: '状态未知', type: 'info'} + if (worker.state === 'pending') return {label: '正在生效', type: 'warning'} + if (worker.state === 'error') return {label: '运行失败', type: 'danger'} + if (worker.running) return {label: '运行中', type: 'success'} + if (worker.state === 'stopped') return {label: '已停止', type: 'info'} + return {label: '状态未知', type: 'info'} +} + +module.exports = {subscriptionInput, createStatusLoader, workerPresentation} diff --git a/front_vue/src/views/AdminAudit.vue b/front_vue/src/views/AdminAudit.vue index 04bd842..cbaac8b 100644 --- a/front_vue/src/views/AdminAudit.vue +++ b/front_vue/src/views/AdminAudit.vue @@ -1,9 +1,9 @@ @@ -13,7 +13,7 @@ const {accountsApi} = require('@/api/accounts.cjs') const {dateTime} = require('@/utils/accounts_behavior.cjs') export default { name: 'AdminAudit', components: {UserIdentity}, - data() { return {actionText: {'reset-profile': '重置昵称 / 头像', avatar: '更新头像', 'remove-avatar': '移除头像', ban: '封禁账号', unban: '恢复账号', delete: '删除账号', 'reset-password': '重置密码', 'logout-all': '退出全部会话', role: '变更管理员身份', 'library-permission': '变更家庭藏书授权', 'webdav-permission': '变更 WebDAV 授权', profile: '更新个人资料', 'change-password': '修改密码', register: '邀请注册', create: '创建账号', 'revoke-invitation': '撤销邀请码', 'config.publish': '发布配置', 'config.rollback': '回滚配置'}, targetText: {user: '用户', web_project: '网页', site_config: '站点配置', invitation: '邀请码'}, items: [], cursor: '', hasMore: false, loading: false, error: '', filters: {target_type: this.$route.query.target_type || '', target_id: this.$route.query.target_id || ''}} }, + data() { return {actionText: {'home_virtual_sub.create': '新增订阅', 'home_virtual_sub.update': '编辑订阅', 'home_virtual_sub.delete': '删除订阅', 'home_virtual_sub.refresh': '刷新订阅', 'home_virtual_sub.start': '启动订阅', 'home_virtual_sub.stop': '停止订阅', 'reset-profile': '重置昵称 / 头像', avatar: '更新头像', 'remove-avatar': '移除头像', ban: '封禁账号', unban: '恢复账号', delete: '删除账号', 'reset-password': '重置密码', 'logout-all': '退出全部会话', role: '变更管理员身份', 'library-permission': '变更家庭藏书授权', 'webdav-permission': '变更 WebDAV 授权', profile: '更新个人资料', 'change-password': '修改密码', register: '邀请注册', create: '创建账号', 'revoke-invitation': '撤销邀请码', 'config.publish': '发布配置', 'config.rollback': '回滚配置'}, targetText: {home_virtual_sub: 'home virtual sub', user: '用户', web_project: '网页', site_config: '站点配置', invitation: '邀请码'}, items: [], cursor: '', hasMore: false, loading: false, error: '', filters: {target_type: this.$route.query.target_type || '', target_id: this.$route.query.target_id || ''}} }, created() { this.load(true) }, methods: { dateTime, diff --git a/front_vue/src/views/AdminHomeVirtualSub.vue b/front_vue/src/views/AdminHomeVirtualSub.vue new file mode 100644 index 0000000..04e82ba --- /dev/null +++ b/front_vue/src/views/AdminHomeVirtualSub.vue @@ -0,0 +1,180 @@ + + + diff --git a/front_vue/src/views/AdminLayout.vue b/front_vue/src/views/AdminLayout.vue index fdbe684..35afc0b 100644 --- a/front_vue/src/views/AdminLayout.vue +++ b/front_vue/src/views/AdminLayout.vue @@ -1,7 +1,7 @@ diff --git a/front_vue/test/home_virtual_sub.test.cjs b/front_vue/test/home_virtual_sub.test.cjs new file mode 100644 index 0000000..509902c --- /dev/null +++ b/front_vue/test/home_virtual_sub.test.cjs @@ -0,0 +1,57 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const {createHomeVirtualSubApi} = require('../src/api/home_virtual_sub.cjs') +const {subscriptionInput, createStatusLoader, workerPresentation} = require('../src/utils/home_virtual_sub_behavior.cjs') + +test('management requests use fixed paths, query names and current revision', async () => { + const requests = [] + const api = createHomeVirtualSubApi({request: async request => {requests.push(request); return {data: {code: 0, data: {saved: true}}}}}, () => 'synthetic-csrf') + await api.detail('测试/订阅') + await api.update('测试/订阅', 'a'.repeat(64), {urls: ['https://example.com/sub'], current_password: 'synthetic', reason: '测试'}) + await api.action('stop', '测试/订阅', 'a'.repeat(64), {current_password: 'synthetic', reason: '测试'}) + assert.equal(requests[0].url, '/api/admin/home-virtual-sub/subscriptions/detail') + assert.equal(requests[0].params.name, '测试/订阅') + assert.equal(requests[1].method, 'patch') + assert.equal(requests[1].headers['If-Match'], 'a'.repeat(64)) + assert.equal(requests[1].headers['X-CSRF-Token'], 'synthetic-csrf') + assert.equal(requests[2].url, '/api/admin/home-virtual-sub/subscriptions/stop') + await assert.rejects(() => api.action('../../other', 'x', 'a'.repeat(64), {})) + assert.equal(requests.length, 3) +}) + +test('network failure sends each mutation only once', async () => { + let count = 0 + const api = createHomeVirtualSubApi({request: async () => {count++; throw new Error('offline')}}, () => 'csrf') + await assert.rejects(() => api.remove('demo', 'a'.repeat(64), {})) + assert.equal(count, 1) +}) + +test('editor validates fields without sending advanced credentials', () => { + const input = subscriptionInput({name: '测试/订阅', urls_text: 'https://example.com/a\n https://example.com/b ', bind_ip: '127.0.0.1', http_port: 18301, socket_port: 18302, outbound_server_count: 3, http_proxy_chain: {password: 'secret'}}) + assert.deepEqual(input.urls, ['https://example.com/a', 'https://example.com/b']) + assert.equal(input.http_proxy_chain, undefined) + assert.throws(() => subscriptionInput({...input, urls_text: 'file:///etc/passwd'})) + assert.throws(() => subscriptionInput({...input, urls_text: 'https://example.com', http_port: 18302})) + assert.throws(() => subscriptionInput({...input, urls_text: ''})) +}) + +test('status loss clears online state and late requests cannot restore it', async () => { + let status = null, failure = '' + let resolve + const loader = createStatusLoader(() => new Promise(done => {resolve = done}), value => {status = value}, error => {failure = error.message}) + loader.activate() + const pending = loader.refresh() + loader.deactivate() + resolve({enabled: true, running: 1}) + await pending + assert.equal(status, null) + const offline = createStatusLoader(async () => {throw new Error('offline')}, value => {status = value}, error => {failure = error.message}) + offline.activate() + status = {running: 1} + await offline.refresh() + assert.equal(status, null) + assert.equal(failure, 'offline') + assert.equal(workerPresentation(null).label, '状态未知') + assert.equal(workerPresentation({state: 'running', running: true}).label, '运行中') + assert.equal(workerPresentation({state: 'error', running: false}).label, '运行失败') +}) diff --git a/front_vue/test/home_virtual_sub_pages.test.cjs b/front_vue/test/home_virtual_sub_pages.test.cjs new file mode 100644 index 0000000..c3c34bc --- /dev/null +++ b/front_vue/test/home_virtual_sub_pages.test.cjs @@ -0,0 +1,113 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const vm = require('node:vm') +const path = require('node:path') + +function component(api) { + const source = fs.readFileSync(path.join(__dirname, '../src/views/AdminHomeVirtualSub.vue'), 'utf8') + const script = source.match(/