From 98ce47354c545ae408a3b72d1629b96f9238e612 Mon Sep 17 00:00:00 2001 From: Jiang Chaoqun Date: Sat, 19 Sep 2026 23:52:44 +0800 Subject: [PATCH 1/3] feat: add private file sharing and resource reading passwords --- .github/workflows/go.yml | 8 +- api/accounts/handlers.go | 2 +- api/accounts/integration_test.go | 2 +- .../resource_password_integration_test.go | 120 +++ api/fileshare/handlers.go | 275 ++++++ api/fileshare/integration_test.go | 782 ++++++++++++++++++ api/fileshare/route.go | 42 +- api/fileshare/share.go | 8 - api/handler.go | 2 + api/manuals/content.go | 3 +- api/manuals/handlers.go | 3 +- api/manuals/integration_test.go | 2 +- api/manuals/password.go | 75 ++ api/manuals/password_integration_test.go | 102 +++ api/manuals/route.go | 4 + api/middleware/account.go | 2 + api/webprojects/comments.go | 34 +- api/webprojects/content.go | 59 +- api/webprojects/password.go | 119 +++ api/webprojects/route.go | 3 + app/fileshare/access.go | 219 +++++ app/fileshare/application.go | 107 +++ app/fileshare/download.go | 182 ++++ app/fileshare/files.go | 258 ++++++ app/fileshare/policy.go | 113 +++ app/fileshare/shares.go | 189 +++++ app/manuals/password.go | 65 ++ app/manuals/read.go | 68 +- app/manuals/resource.go | 19 +- app/webprojects/password.go | 37 + config/config_example.yaml | 11 + config/global.go | 15 + config/nginx/file_sharing_locations.conf | 48 ++ config/runtime.go | 3 +- deploy/pi/README.md | 18 + deploy/pi/frontend-nginx.conf | 1 + deploy/pi/gateway-api.conf | 57 ++ deploy/pi/gateway-frontend.conf | 57 ++ docs/specs/file-sharing/design.md | 92 +++ docs/specs/file-sharing/flow.mmd | 9 + docs/specs/file-sharing/flow.svg | 1 + docs/specs/file-sharing/implementation.md | 22 + docs/specs/file-sharing/index.html | 173 ++++ docs/specs/file-sharing/verification.md | 40 + domain/dal/fileshare.go | 206 +++++ domain/dal/fileshare_integration_test.go | 131 +++ .../dal/migrations/20260919_file_sharing.sql | 43 + .../20260919_resource_passwords.sql | 9 + domain/dal/resource_password.go | 50 ++ domain/model/fileshare.go | 104 +++ domain/model/resource_password.go | 15 + domain/repository/webprojects/password.go | 62 ++ domain/service/accounts/service.go | 14 +- domain/service/applications/service.go | 5 + domain/service/fileshare/storage.go | 259 ++++++ domain/service/fileshare/storage_test.go | 65 ++ domain/service/fileshare/types.go | 308 +++++++ domain/service/fileshare/validation_test.go | 81 ++ domain/service/manuals/types.go | 31 +- domain/service/resourcepasswords/service.go | 402 +++++++++ .../service/resourcepasswords/service_test.go | 84 ++ domain/service/service.go | 4 + front_vue/package.json | 2 + front_vue/public/index.html | 3 +- front_vue/src/App.vue | 2 +- front_vue/src/api/files.cjs | 67 ++ front_vue/src/api/manuals.cjs | 9 + front_vue/src/api/web_projects.cjs | 9 + front_vue/src/assets/global.css | 37 +- front_vue/src/components/MyHeader.vue | 14 +- front_vue/src/permission.js | 3 +- front_vue/src/router/index.js | 14 + front_vue/src/store/index.js | 16 +- front_vue/src/utils/accounts_behavior.cjs | 5 +- front_vue/src/utils/file_sharing_behavior.cjs | 90 ++ .../src/utils/web_projects_navigation.cjs | 4 +- front_vue/src/views/FileShareManager.vue | 280 +++++++ front_vue/src/views/FileShareReceive.vue | 89 ++ front_vue/src/views/Index.vue | 18 +- front_vue/src/views/ManualDetail.vue | 34 +- front_vue/src/views/ManualEditor.vue | 42 +- front_vue/src/views/ManualList.vue | 4 +- front_vue/src/views/WebProjectEditor.vue | 64 +- front_vue/src/views/WebProjectList.vue | 9 +- front_vue/src/views/WebProjectOpen.vue | 47 ++ front_vue/test/account-center-browser.cjs | 5 + front_vue/test/accounts-behavior.test.cjs | 7 + front_vue/test/file-sharing-api.test.cjs | 89 ++ front_vue/test/file-sharing-behavior.test.cjs | 71 ++ front_vue/test/file-sharing-browser.cjs | 250 ++++++ front_vue/test/file-sharing-shell.test.cjs | 9 + front_vue/test/manuals-api.test.cjs | 20 + front_vue/test/manuals-browser.cjs | 70 +- front_vue/test/site-visual-browser.cjs | 134 +++ front_vue/test/web-project-open.test.cjs | 58 ++ front_vue/test/web-projects-api.test.cjs | 20 + .../test/web-projects-navigation.test.cjs | 7 + utils/log/request_uri.go | 6 + utils/log/request_uri_test.go | 1 + 99 files changed, 6802 insertions(+), 131 deletions(-) create mode 100644 api/accounts/resource_password_integration_test.go create mode 100644 api/fileshare/handlers.go create mode 100644 api/fileshare/integration_test.go delete mode 100644 api/fileshare/share.go create mode 100644 api/manuals/password.go create mode 100644 api/manuals/password_integration_test.go create mode 100644 api/webprojects/password.go create mode 100644 app/fileshare/access.go create mode 100644 app/fileshare/application.go create mode 100644 app/fileshare/download.go create mode 100644 app/fileshare/files.go create mode 100644 app/fileshare/policy.go create mode 100644 app/fileshare/shares.go create mode 100644 app/manuals/password.go create mode 100644 app/webprojects/password.go create mode 100644 config/nginx/file_sharing_locations.conf create mode 100644 docs/specs/file-sharing/design.md create mode 100644 docs/specs/file-sharing/flow.mmd create mode 100644 docs/specs/file-sharing/flow.svg create mode 100644 docs/specs/file-sharing/implementation.md create mode 100644 docs/specs/file-sharing/index.html create mode 100644 docs/specs/file-sharing/verification.md create mode 100644 domain/dal/fileshare.go create mode 100644 domain/dal/fileshare_integration_test.go create mode 100644 domain/dal/migrations/20260919_file_sharing.sql create mode 100644 domain/dal/migrations/20260919_resource_passwords.sql create mode 100644 domain/dal/resource_password.go create mode 100644 domain/model/fileshare.go create mode 100644 domain/model/resource_password.go create mode 100644 domain/repository/webprojects/password.go create mode 100644 domain/service/fileshare/storage.go create mode 100644 domain/service/fileshare/storage_test.go create mode 100644 domain/service/fileshare/types.go create mode 100644 domain/service/fileshare/validation_test.go create mode 100644 domain/service/resourcepasswords/service.go create mode 100644 domain/service/resourcepasswords/service_test.go create mode 100644 front_vue/src/api/files.cjs create mode 100644 front_vue/src/utils/file_sharing_behavior.cjs create mode 100644 front_vue/src/views/FileShareManager.vue create mode 100644 front_vue/src/views/FileShareReceive.vue create mode 100644 front_vue/test/file-sharing-api.test.cjs create mode 100644 front_vue/test/file-sharing-behavior.test.cjs create mode 100644 front_vue/test/file-sharing-browser.cjs create mode 100644 front_vue/test/file-sharing-shell.test.cjs create mode 100644 front_vue/test/site-visual-browser.cjs create mode 100644 front_vue/test/web-project-open.test.cjs diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 235fece..0889994 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -43,6 +43,7 @@ jobs: CREATE DATABASE home_server_analytics_test_ci; CREATE DATABASE home_server_readcache_test_ci; CREATE DATABASE home_server_manuals_test_ci CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + CREATE DATABASE home_server_file_sharing_test_ci CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'home_server_ci'@'%' IDENTIFIED BY 'ci-isolated-tests'; GRANT ALL ON home_server_accounts_http_test_ci.* TO 'home_server_ci'@'%'; GRANT ALL ON home_server_accounts_migration_test_ci.* TO 'home_server_ci'@'%'; @@ -51,6 +52,7 @@ jobs: GRANT ALL ON home_server_analytics_test_ci.* TO 'home_server_ci'@'%'; GRANT ALL ON home_server_readcache_test_ci.* TO 'home_server_ci'@'%'; GRANT ALL ON home_server_manuals_test_ci.* TO 'home_server_ci'@'%'; + GRANT ALL ON home_server_file_sharing_test_ci.* TO 'home_server_ci'@'%'; SQL - name: Exercise availability-first Redis policy @@ -75,14 +77,16 @@ jobs: HOME_SERVER_ANALYTICS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_analytics_test_ci?parseTime=true' 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' - run: go test -v ./config ./api/accounts ./api/webprojects ./api/manuals ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/manuals ./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 + 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 - name: Test isolated DAL cache bindings env: MANUALS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_manuals_test_ci?parseTime=true' HOME_SERVER_TEST_REDIS_ADDR: '127.0.0.1:16389' 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' - run: go test -v ./domain/dal -run 'Test(ReadCache|Manual)' -count=1 + 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 ./domain/dal -run 'Test(ReadCache|Manual|FileShare)' -count=1 - name: Test AI API client run: python3 script/test_home_server_api.py -v diff --git a/api/accounts/handlers.go b/api/accounts/handlers.go index 9646ffb..1f3ceed 100644 --- a/api/accounts/handlers.go +++ b/api/accounts/handlers.go @@ -247,7 +247,7 @@ func publicBootstrap(c *gin.Context) { return } runtime := config.Runtime() - respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value, "modules": map[string]bool{"manuals": runtime.Manuals.Enabled}}, nil) + respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value, "modules": map[string]bool{"manuals": runtime.Manuals.Enabled, "file_sharing": runtime.FileSharing.Enabled}}, nil) } // validateInvitation 处理 POST /api/auth/invitations/validate:在来源限流后校验邀请码,仅返回有效状态与到期时间,不消费邀请码。 diff --git a/api/accounts/integration_test.go b/api/accounts/integration_test.go index decf2c2..31accd1 100644 --- a/api/accounts/integration_test.go +++ b/api/accounts/integration_test.go @@ -122,7 +122,7 @@ func newHTTPFixture(t *testing.T, initialApplications ...bool) *httpFixture { t.Fatal(err) } baseline := []string{strings.Replace(string(base), "use home_server;", "", 1)} - for _, name := range []string{"20260909_web_projects.sql", "20260912_applications.sql", "20260916_web_comments.sql"} { + for _, name := range []string{"20260909_web_projects.sql", "20260912_applications.sql", "20260916_web_comments.sql", "20260919_resource_passwords.sql"} { raw, err := migrations.SQL.ReadFile(name) if err != nil { t.Fatal(err) diff --git a/api/accounts/resource_password_integration_test.go b/api/accounts/resource_password_integration_test.go new file mode 100644 index 0000000..558e65f --- /dev/null +++ b/api/accounts/resource_password_integration_test.go @@ -0,0 +1,120 @@ +package accounts_test + +import ( + "net/http" + "strconv" + "strings" + "testing" + + "github.com/mcoder2014/home_server/domain/model" +) + +func TestHTTPWebProjectPasswordProtectsContentAndComments(t *testing.T) { + f := newHTTPFixture(t) + owner := f.login(f.owner, integrationPassword) + guest := f.login(f.guest, integrationPassword) + project, release := f.privatePage(owner) + id := project["id"].(string) + base := "/api/web-share/" + id + project = requireSuccess(t, f.request(http.MethodPatch, base, owner, map[string]interface{}{"access_mode": "public", "container_mode": "raw"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)})) + + status := requireSuccess(t, f.request(http.MethodGet, base+"/password", owner, nil, nil)) + if status["password_protected"] != false || number(status["version"]) != 0 { + t.Fatalf("unexpected initial password state: %v", status) + } + if response := f.request(http.MethodPut, base+"/password", owner, map[string]interface{}{"password": "short", "version": 0}, nil); response.Status != http.StatusBadRequest { + t.Fatalf("short password accepted: status=%d code=%d", response.Status, response.Code) + } + status = requireSuccess(t, f.request(http.MethodPut, "/api/web-projects/"+id+"/password", owner, map[string]interface{}{"password": "correct horse battery staple", "version": 0}, nil)) + if status["password_protected"] != true || number(status["version"]) != 1 { + t.Fatalf("password was not enabled: %v", status) + } + if response := f.request(http.MethodPut, base+"/password", owner, map[string]interface{}{"password": "another valid password", "version": 0}, nil); response.Status != http.StatusConflict || response.Code != 5 { + t.Fatalf("stale password version accepted: status=%d code=%d", response.Status, response.Code) + } + + pagePath := "/p/http-private-fixture/index.html" + locked := f.request(http.MethodGet, pagePath, nil, nil, map[string]string{"Accept": "text/html"}) + if locked.Status != http.StatusForbidden || locked.Header.Get("X-Resource-Password") != "required" || locked.Header.Get("X-Resource-ID") != id || !strings.Contains(string(locked.Raw), "type=\"password\"") { + t.Fatalf("direct HTML did not return the native password gate: status=%d headers=%v body=%s", locked.Status, locked.Header, locked.Raw) + } + if ownerPage := f.request(http.MethodGet, pagePath, owner, nil, nil); ownerPage.Status != http.StatusOK { + t.Fatalf("owner did not bypass password after ACL checks: %d", ownerPage.Status) + } + if _, err := f.database.Exec("UPDATE web_project SET status = ? WHERE id = ?", model.WebProjectStatusDisabled, id); err != nil { + t.Fatal(err) + } + if ownerPage := f.request(http.MethodGet, pagePath, owner, nil, nil); ownerPage.Status != http.StatusNotFound { + t.Fatalf("owner password bypass ignored disabled project state: %d", ownerPage.Status) + } + if ownerComments := f.request(http.MethodGet, base+"/comment-threads", owner, nil, nil); ownerComments.Status != http.StatusNotFound { + t.Fatalf("owner password bypass exposed disabled project comments: %d", ownerComments.Status) + } + if _, err := f.database.Exec("UPDATE web_project SET status = ?, moderation_status = 'blocked' WHERE id = ?", model.WebProjectStatusEnabled, id); err != nil { + t.Fatal(err) + } + if ownerPage := f.request(http.MethodGet, pagePath, owner, nil, nil); ownerPage.Status != http.StatusNotFound { + t.Fatalf("owner password bypass ignored moderation state: %d", ownerPage.Status) + } + if _, err := f.database.Exec("UPDATE web_project SET moderation_status = 'normal' WHERE id = ?", id); err != nil { + t.Fatal(err) + } + for _, request := range []struct{ method, path string }{{http.MethodHead, pagePath}, {http.MethodGet, "/p/http-private-fixture/style.css"}, {http.MethodGet, base + "/view-context"}} { + response := f.request(request.method, request.path, nil, nil, nil) + if response.Status != http.StatusForbidden { + t.Fatalf("password gate bypassed for %s %s: status=%d code=%d", request.method, request.path, response.Status, response.Code) + } + } + + wrong := f.request(http.MethodPost, base+"/unlock", nil, map[string]interface{}{"password": "wrong password"}, nil) + if wrong.Status != http.StatusForbidden || wrong.Code != 3 { + t.Fatalf("wrong password must be stable 403/code=3: status=%d code=%d", wrong.Status, wrong.Code) + } + unlocked := f.request(http.MethodPost, "/api/web-projects/"+id+"/unlock", nil, map[string]interface{}{"password": "correct horse battery staple"}, nil) + grant := passwordGrantCookie(t, unlocked.Cookies) + if unlocked.Status != http.StatusOK || !grant.Secure || !grant.HttpOnly || grant.Path != "/" || grant.SameSite != http.SameSiteLaxMode { + t.Fatalf("unsafe unlock response: status=%d cookie=%+v", unlocked.Status, grant) + } + cookieHeader := grant.Name + "=" + grant.Value + if response := f.request(http.MethodGet, "/p/http-private-fixture/style.css", nil, nil, map[string]string{"Cookie": cookieHeader}); response.Status != http.StatusOK { + t.Fatalf("valid grant did not unlock an asset: %d", response.Status) + } + if response := f.request(http.MethodGet, base+"/view-context", nil, nil, map[string]string{"Cookie": cookieHeader}); response.Status != http.StatusOK { + t.Fatalf("valid grant did not unlock view context: %d", response.Status) + } + project = requireSuccess(t, f.request(http.MethodPatch, base, owner, map[string]interface{}{"container_mode": "enhanced"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)})) + comment := map[string]interface{}{"request_id": "password-comment", "release_id": release, "page_key": "path:index.html", "page_path": "index.html", "anchor": map[string]interface{}{"kind": "page"}, "body": "protected feedback"} + guestCookies := guest.Cookie.Name + "=" + guest.Cookie.Value + "; " + cookieHeader + if response := f.request(http.MethodPost, base+"/comment-threads", guest, comment, map[string]string{"Cookie": guestCookies}); response.Status != http.StatusCreated { + t.Fatalf("unlocked comment write failed: status=%d body=%s", response.Status, response.Raw) + } + if response := f.request(http.MethodGet, base+"/comment-threads", guest, nil, nil); response.Status != http.StatusForbidden || response.Code != 3 { + t.Fatalf("comment read bypassed password: status=%d code=%d", response.Status, response.Code) + } + + status = requireSuccess(t, f.request(http.MethodPut, base+"/password", owner, map[string]interface{}{"password": "replacement password", "version": 1}, nil)) + if number(status["version"]) != 2 { + t.Fatalf("password version did not advance: %v", status) + } + if response := f.request(http.MethodGet, pagePath, nil, nil, map[string]string{"Cookie": cookieHeader}); response.Status != http.StatusForbidden { + t.Fatalf("old cookie survived password change: %d", response.Status) + } + status = requireSuccess(t, f.request(http.MethodPut, base+"/password", owner, map[string]interface{}{"password": "", "version": 2}, nil)) + if status["password_protected"] != false || number(status["version"]) != 3 { + t.Fatalf("clear did not retain and advance version: %v", status) + } + if response := f.request(http.MethodGet, pagePath, nil, nil, nil); response.Status != http.StatusOK { + t.Fatalf("cleared password still blocked content: %d", response.Status) + } +} + +func passwordGrantCookie(t *testing.T, cookies []*http.Cookie) *http.Cookie { + t.Helper() + for _, cookie := range cookies { + if strings.HasPrefix(cookie.Name, "__Host-cq_read_") && cookie.MaxAge > 0 { + return cookie + } + } + t.Fatal("unlock did not issue a resource grant cookie") + return nil +} diff --git a/api/fileshare/handlers.go b/api/fileshare/handlers.go new file mode 100644 index 0000000..a4484ff --- /dev/null +++ b/api/fileshare/handlers.go @@ -0,0 +1,275 @@ +package fileshare + +import ( + "encoding/json" + "errors" + "io" + "mime" + "mime/multipart" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/mcoder2014/home_server/api/middleware" + application "github.com/mcoder2014/home_server/app/fileshare" + "github.com/mcoder2014/home_server/config" + service "github.com/mcoder2014/home_server/domain/service/fileshare" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" + "github.com/mcoder2014/home_server/utils/ginfmt" + "github.com/sirupsen/logrus" +) + +func listFiles(c *gin.Context) { + cursor, limit, err := service.ParsePagination(c.Query("cursor"), c.Query("limit")) + if err != nil { + ginfmt.Fail(c, err) + return + } + result, err := application.Default.List(ginfmt.RPCContext(c), currentPrincipal(c).UserID, cursor, limit) + respond(c, http.StatusOK, result, err) +} + +func uploadFile(c *gin.Context) { + conf := config.Global().FileSharing + principal := currentPrincipal(c) + release, err := application.Default.AcquireUpload(conf, principal.UserID) + if err != nil { + ginfmt.Fail(c, err) + return + } + defer release() + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, conf.MaxFileBytes+(2<<20)) + mediaType, parameters, err := mime.ParseMediaType(c.GetHeader("Content-Type")) + if err != nil || mediaType != "multipart/form-data" || parameters["boundary"] == "" { + ginfmt.Fail(c, service.ErrInvalid) + return + } + reader := multipart.NewReader(c.Request.Body, parameters["boundary"]) + part, err := reader.NextPart() + if err != nil || part.FormName() != "file" || part.FileName() == "" { + ginfmt.Fail(c, service.ErrInvalid) + return + } + defer part.Close() + result, err := application.Default.UploadReserved(ginfmt.RPCContext(c), principal, part.FileName(), part, func() error { + next, nextErr := reader.NextPart() + if nextErr == io.EOF { + return nil + } + if next != nil { + _ = next.Close() + } + var maxBytesError *http.MaxBytesError + if errors.As(nextErr, &maxBytesError) { + return service.ErrTooLarge + } + return service.ErrInvalid + }) + respond(c, http.StatusCreated, result, err) +} + +func eligibleUsers(c *gin.Context) { + items, err := application.Default.EligibleUsers(ginfmt.RPCContext(c)) + if err != nil { + ginfmt.Fail(c, err) + return + } + ginfmt.Success(c, http.StatusOK, gin.H{"items": items}) +} + +func getFile(c *gin.Context) { + id, err := service.ParsePositiveID(c.Param("id")) + if err != nil { + ginfmt.Fail(c, err) + return + } + result, err := application.Default.Get(ginfmt.RPCContext(c), currentPrincipal(c).UserID, id) + respond(c, http.StatusOK, result, err) +} + +func deleteFile(c *gin.Context) { + id, err := service.ParsePositiveID(c.Param("id")) + if err != nil { + ginfmt.Fail(c, err) + return + } + result, err := application.Default.Delete(ginfmt.RPCContext(c), currentPrincipal(c), id) + respond(c, http.StatusOK, result, err) +} + +func listShares(c *gin.Context) { + fileID, err := service.ParsePositiveID(c.Param("id")) + if err != nil { + ginfmt.Fail(c, err) + return + } + cursor, limit, err := service.ParsePagination(c.Query("cursor"), c.Query("limit")) + if err != nil { + ginfmt.Fail(c, err) + return + } + result, err := application.Default.ListShares(ginfmt.RPCContext(c), currentPrincipal(c).UserID, fileID, cursor, limit) + respond(c, http.StatusOK, result, err) +} + +func createShare(c *gin.Context) { + fileID, err := service.ParsePositiveID(c.Param("id")) + if err != nil { + ginfmt.Fail(c, err) + return + } + var input service.CreateShareInput + if !bindJSON(c, &input, 16<<10) { + return + } + result, err := application.Default.CreateShare(ginfmt.RPCContext(c), currentPrincipal(c), fileID, input) + respond(c, http.StatusCreated, result, err) +} + +func revokeShare(c *gin.Context) { + fileID, err := service.ParsePositiveID(c.Param("id")) + if err != nil { + ginfmt.Fail(c, err) + return + } + shareID, err := service.ParsePositiveID(c.Param("share_id")) + if err != nil { + ginfmt.Fail(c, err) + return + } + result, err := application.Default.RevokeShare(ginfmt.RPCContext(c), currentPrincipal(c), fileID, shareID) + respond(c, http.StatusOK, result, err) +} + +func getPublicShare(c *gin.Context) { + principal, err := optionalReader(c) + if err != nil { + ginfmt.Fail(c, err) + return + } + result, err := application.Default.PublicState(ginfmt.RPCContext(c), c.Param("token"), principal, c.Request) + respond(c, http.StatusOK, result, err) +} + +func unlockPublicShare(c *gin.Context) { + principal, err := optionalReader(c) + if err != nil { + ginfmt.Fail(c, err) + return + } + if !browserPublicWrite(c) { + return + } + input := struct { + Secret string `json:"secret"` + }{} + if !bindJSON(c, &input, 1024) { + return + } + result, err := application.Default.Unlock(ginfmt.RPCContext(c), c.Param("token"), input.Secret, middleware.TrustedClientIP(c.Request), principal) + if err != nil { + ginfmt.Fail(c, err) + return + } + if result.CookieName != "" { + http.SetCookie(c.Writer, &http.Cookie{Name: result.CookieName, Value: result.CookieValue, Path: "/api/file-shares/" + c.Param("token"), MaxAge: maxAge(result.UnlockedUntil), Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + } + ginfmt.Success(c, http.StatusOK, result) +} + +func downloadPublicShare(c *gin.Context) { + if c.GetHeader("Range") != "" || c.GetHeader("Content-Range") != "" { + ginfmt.Fail(c, service.ErrInvalid) + return + } + principal, err := optionalReader(c) + if err != nil { + ginfmt.Fail(c, err) + return + } + if !browserPublicWrite(c) { + return + } + download, err := application.Default.PrepareDownload(ginfmt.RPCContext(c), c.Param("token"), principal, c.Request) + if err != nil { + ginfmt.Fail(c, err) + return + } + defer download.File.Close() + c.Header("Content-Type", "application/octet-stream") + if value := mime.FormatMediaType("attachment", map[string]string{"filename": download.Filename}); value != "" { + c.Header("Content-Disposition", value) + } + c.Header("Content-Length", strconv.FormatInt(download.Info.Size(), 10)) + c.Header("Cache-Control", "no-store") + c.Header("Accept-Ranges", "none") + c.Header("Referrer-Policy", "no-referrer") + c.Header("X-Content-Type-Options", "nosniff") + c.Status(http.StatusOK) + if _, err := io.CopyN(c.Writer, download.File, download.Info.Size()); err != nil { + logrus.WithField("share_download", "interrupted").Warn("file share transfer ended before all bytes were sent") + } +} + +func optionalReader(c *gin.Context) (*utils.Principal, error) { + explicit := len(c.Request.Header.Values("Authorization")) > 0 || len(c.Request.Header.Values(middleware.HeaderKey)) > 0 + hasSession := utils.BrowserSessionToken(c.Request) != "" + if !explicit && !hasSession { + return nil, nil + } + principal, err := middleware.ResolveIdentity(c, "files:read", true, false) + if err == nil { + return principal, nil + } + if explicit || hasSession || errors.Is(err, service.ErrDependency) { + return nil, err + } + return nil, nil +} + +func browserPublicWrite(c *gin.Context) bool { + if c.GetHeader("Authorization") != "" || c.GetHeader(middleware.HeaderKey) != "" { + return true + } + middleware.BrowserWrite()(c) + return !c.IsAborted() +} + +func currentPrincipal(c *gin.Context) *utils.Principal { + value, _ := c.Get(utils.CtxKeyPrincipal) + principal, _ := value.(*utils.Principal) + return principal +} + +func bindJSON(c *gin.Context, value interface{}, limit int64) bool { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + ginfmt.Fail(c, apperrors.ErrInvalid) + return false + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + ginfmt.Fail(c, apperrors.ErrInvalid) + return false + } + return true +} + +func respond(c *gin.Context, status int, value interface{}, err error) { + if err != nil { + ginfmt.Fail(c, err) + return + } + ginfmt.Success(c, status, value) +} + +func maxAge(expiresAt time.Time) int { + seconds := int(time.Until(expiresAt).Seconds()) + if seconds < 1 { + return 1 + } + return seconds +} diff --git a/api/fileshare/integration_test.go b/api/fileshare/integration_test.go new file mode 100644 index 0000000..4be3b22 --- /dev/null +++ b/api/fileshare/integration_test.go @@ -0,0 +1,782 @@ +package fileshare_test + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-sql-driver/mysql" + accountsapi "github.com/mcoder2014/home_server/api/accounts" + applicationsapi "github.com/mcoder2014/home_server/api/applications" + "github.com/mcoder2014/home_server/api/auth" + fileshareapi "github.com/mcoder2014/home_server/api/fileshare" + "github.com/mcoder2014/home_server/api/middleware" + fileapp "github.com/mcoder2014/home_server/app/fileshare" + "github.com/mcoder2014/home_server/app/siteconfig" + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/data" + "github.com/mcoder2014/home_server/domain/dal/migrations" + "github.com/mcoder2014/home_server/domain/db" + applicationservice "github.com/mcoder2014/home_server/domain/service/applications" + fileservice "github.com/mcoder2014/home_server/domain/service/fileshare" + "github.com/mcoder2014/home_server/domain/service/passport" + "github.com/mcoder2014/home_server/internal/accountsmigrate" + "github.com/mcoder2014/home_server/utils" + "github.com/sirupsen/logrus" + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" + "gorm.io/gorm/logger" +) + +const fileSharingPassword = "FileSharingOwner!234" +const fileSharingOrigin = "https://home.example.com" + +var fileSharingFixtureSequence uint64 + +type httpFixture struct { + t *testing.T + router *gin.Engine + database *sql.DB + conf config.Config + owner, member, guest, ip string +} + +type browserSession struct { + Cookie *http.Cookie + CSRF string + ID string + ExtraCookie []*http.Cookie +} + +type apiResponse struct { + Status int + Code int + Data map[string]interface{} + Raw []byte + Header http.Header + Cookies []*http.Cookie +} + +// newHTTPFixture builds an isolated schema, imports three synthetic identities +// through the production migration flow, then registers the real auth and file +// sharing handlers. Package tests stay sequential because these services use +// process-global configuration, database and router registries by design. +func newHTTPFixture(t *testing.T, identitySource string, customize func(*config.Config)) *httpFixture { + t.Helper() + dsn := os.Getenv("FILE_SHARING_TEST_DSN") + if dsn == "" { + t.Skip("FILE_SHARING_TEST_DSN required") + } + parsed, err := mysql.ParseDSN(dsn) + if err != nil || parsed.DBName != "home_server_file_sharing_test_ci" { + t.Fatal("dedicated file sharing test database required") + } + parsed.MultiStatements = false + parsed.ParseTime = true + sqlDB, err := sql.Open("mysql", parsed.FormatDSN()) + if err != nil { + t.Fatal(err) + } + resetSchema(t, sqlDB, parsed.DBName) + applyBaseline(t, sqlDB) + + serial := atomic.AddUint64(&fileSharingFixtureSequence, 1) + fixture := &httpFixture{t: t, database: sqlDB, owner: fmt.Sprintf("file_owner_%d", serial), member: fmt.Sprintf("file_member_%d", serial), guest: fmt.Sprintf("file_guest_%d", serial), ip: fmt.Sprintf("192.0.2.%d:41000", serial%200+1)} + root := t.TempDir() + fixture.conf = config.Config{IdentitySource: identitySource, ConfigSource: "database", UploadHardLimitBytes: 50 << 20} + fixture.conf.Mysql.MasterDB = parsed.FormatDSN() + fixture.conf.Auth.ApplicationsEnabled = true + fixture.conf.Auth.SiteOrigin = fileSharingOrigin + fixture.conf.WebDAV.SharePath = filepath.Join(root, "dav") + fixture.conf.FileSharing = config.FileSharingConfig{Enabled: true, StorageRoot: filepath.Join(root, "files"), MaxFileBytes: 1 << 20, MaxFilesPerUser: 20, MaxUserBytes: 4 << 20, MinFreeDiskBytes: 1, MaxConcurrentUploadsPerUser: 2, MaxConcurrentUploads: 4} + if customize != nil { + customize(&fixture.conf) + } + if err := os.MkdirAll(fixture.conf.WebDAV.SharePath, 0700); err != nil { + t.Fatal(err) + } + hash, err := bcrypt.GenerateFromPassword([]byte(fileSharingPassword), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + identities := make([]map[string]interface{}, 0, 3) + for index, name := range []string{fixture.owner, fixture.member, fixture.guest} { + identities = append(identities, map[string]interface{}{"id": 1001 + index, "user_name": name, "password": string(hash), "email": name + "@example.com", "mobile": ""}) + } + rawUsers, _ := json.Marshal(identities) + fixture.conf.Passport.MockData = string(rawUsers) + migrateAccounts(t, sqlDB, parsed.DBName, fixture.conf, root) + configureFixture(t, fixture, parsed.FormatDSN()) + return fixture +} + +func resetSchema(t *testing.T, database *sql.DB, name string) { + t.Helper() + rows, err := database.Query("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=?", name) + if err != nil { + t.Fatal(err) + } + var tables []string + for rows.Next() { + var table string + if err := rows.Scan(&table); err != nil { + t.Fatal(err) + } + tables = append(tables, table) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + for _, table := range tables { + if _, err := database.Exec("DROP TABLE `" + strings.ReplaceAll(table, "`", "``") + "`"); err != nil { + t.Fatal(err) + } + } +} + +func applyBaseline(t *testing.T, database *sql.DB) { + t.Helper() + _, testFile, _, _ := runtime.Caller(0) + raw, err := os.ReadFile(filepath.Join(filepath.Dir(testFile), "../../domain/dal/create_table.sql")) + if err != nil { + t.Fatal(err) + } + statements := []string{strings.Replace(string(raw), "use home_server;", "", 1)} + for _, name := range []string{"20260909_web_projects.sql", "20260912_applications.sql", "20260919_file_sharing.sql"} { + raw, err := migrations.SQL.ReadFile(name) + if err != nil { + t.Fatal(err) + } + statements = append(statements, string(raw)) + } + for _, raw := range statements { + if err := executeSQL(database, raw); err != nil { + t.Fatalf("baseline schema: %v", err) + } + } +} + +// executeSQL removes line comments before splitting so semicolons in migration +// comments cannot become executable fragments in the synthetic database. +func executeSQL(database *sql.DB, raw string) error { + var lines []string + for _, line := range strings.Split(raw, "\n") { + if !strings.HasPrefix(strings.TrimSpace(line), "--") { + lines = append(lines, line) + } + } + for _, statement := range strings.Split(strings.Join(lines, "\n"), ";") { + statement = strings.TrimSpace(statement) + if statement == "" { + continue + } + if strings.HasPrefix(strings.ToUpper(statement), "USE ") { + return fmt.Errorf("migration attempted to switch database") + } + if _, err := database.Exec(statement); err != nil { + return err + } + } + return nil +} + +func migrateAccounts(t *testing.T, database *sql.DB, databaseName string, conf config.Config, root string) { + t.Helper() + rawConf, err := yaml.Marshal(conf) + if err != nil { + t.Fatal(err) + } + confPath := filepath.Join(root, "source.yaml") + if err := os.WriteFile(confPath, rawConf, 0600); err != nil { + t.Fatal(err) + } + source, err := accountsmigrate.ReadSource(confPath, accountsmigrate.Grants{Admins: []int64{1001}}) + if err != nil { + t.Fatal(err) + } + options := accountsmigrate.Options{Database: databaseName, BinarySHA256: strings.Repeat("b", 64), DDLChecksums: map[string]string{}, Now: time.Now()} + for _, name := range []string{"20260913_runtime_config.sql", "20260913_accounts.sql"} { + raw, err := migrations.SQL.ReadFile(name) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(raw) + options.DDLChecksums[name] = hex.EncodeToString(sum[:]) + steps, err := accountsmigrate.ParseDDL(name, raw) + if err != nil { + t.Fatal(err) + } + options.Steps = append(options.Steps, steps...) + } + plan, err := accountsmigrate.BuildPlan(context.Background(), database, source, options) + if err != nil { + t.Fatal(err) + } + if err := accountsmigrate.Apply(context.Background(), database, source, options, plan.SHA256, databaseName); err != nil { + t.Fatal(err) + } + centerOptions, err := accountsmigrate.AccountCenterOptions(databaseName) + if err != nil { + t.Fatal(err) + } + centerPlan, err := accountsmigrate.BuildAdditivePlan(context.Background(), database, centerOptions) + if err != nil { + t.Fatal(err) + } + if err := accountsmigrate.ApplyAdditive(context.Background(), database, centerOptions, centerPlan.SHA256, databaseName, true); err != nil { + t.Fatal(err) + } +} + +func configureFixture(t *testing.T, fixture *httpFixture, dsn string) { + t.Helper() + config.SetGlobalConfig(fixture.conf) + if err := db.InitDatabase(dsn); err != nil { + t.Fatal(err) + } + db.MasterDB().Logger = logger.Discard + if err := passport.Init(&fixture.conf); err != nil { + t.Fatal(err) + } + if err := applicationservice.Init(fixture.conf.Auth); err != nil { + t.Fatal(err) + } + if err := fileservice.Init(&fixture.conf.FileSharing, fixture.conf.WebDAV.SharePath, fixture.conf.WebProjects.StorageRoot, fixture.conf.Manuals.StorageRoot); err != nil { + t.Fatal(err) + } + config.SetGlobalConfig(fixture.conf) + if fixture.conf.ConfigSource == "database" { + runtimeService := siteconfig.New(db.MasterDB(), fixture.conf) + if err := runtimeService.Initialize(context.Background()); err != nil { + t.Fatal(err) + } + snapshot := config.Runtime() + snapshot.AccountPolicy.MaxActiveSessions = 100 + if err := config.StoreRuntimeSnapshot(snapshot); err != nil { + t.Fatal(err) + } + accountsapi.Configure(runtimeService) + } else { + accountsapi.Configure(nil) + } + fileapp.Default = fileapp.New() + gin.SetMode(gin.TestMode) + logrus.SetOutput(io.Discard) + if err := middleware.ConfigureAuthentication(fixture.conf.Auth); err != nil { + t.Fatal(err) + } + data.RouterMap = map[string]map[string]data.HttpRoute{} + for _, register := range []func() error{auth.InitRouter, applicationsapi.InitRouter, fileshareapi.InitRouter} { + if err := register(); err != nil { + t.Fatal(err) + } + } + fixture.router = gin.New() + fixture.router.Use(gin.RecoveryWithWriter(io.Discard), middleware.AddLogID) + data.ForRange(func(method, path string, handlers ...gin.HandlerFunc) { + fixture.router.Handle(method, path, handlers...) + }) + gormDB := db.MasterDB() + t.Cleanup(func() { + accountsapi.Configure(nil) + connection, _ := gormDB.DB() + _ = connection.Close() + _ = fixture.database.Close() + }) +} + +func (fixture *httpFixture) request(method, path string, session *browserSession, body interface{}, headers map[string]string) apiResponse { + fixture.t.Helper() + var raw []byte + var reader io.Reader + switch value := body.(type) { + case io.Reader: + reader = value + case []byte: + raw = value + case string: + raw = []byte(value) + case nil: + default: + raw, _ = json.Marshal(body) + } + if reader == nil { + reader = bytes.NewReader(raw) + } + request := httptest.NewRequest(method, fileSharingOrigin+path, reader) + request.RemoteAddr = fixture.ip + request.TLS = &tls.ConnectionState{HandshakeComplete: true} + request.Header.Set("Origin", fileSharingOrigin) + request.Header.Set("Content-Type", "application/json") + if session != nil { + if session.Cookie != nil { + request.AddCookie(session.Cookie) + request.Header.Set("X-CSRF-Token", session.CSRF) + } + for _, cookie := range session.ExtraCookie { + request.AddCookie(cookie) + } + } + for name, value := range headers { + if value == "" { + request.Header.Del(name) + } else { + request.Header.Set(name, value) + } + } + recorder := httptest.NewRecorder() + fixture.router.ServeHTTP(recorder, request) + response := apiResponse{Status: recorder.Code, Raw: append([]byte(nil), recorder.Body.Bytes()...), Header: recorder.Header(), Cookies: recorder.Result().Cookies()} + var envelope struct { + Code int `json:"code"` + Data map[string]interface{} `json:"data"` + } + if json.Unmarshal(response.Raw, &envelope) == nil { + response.Code = envelope.Code + response.Data = envelope.Data + } + return response +} + +func (fixture *httpFixture) login(name string) *browserSession { + fixture.t.Helper() + response := fixture.request(http.MethodPost, "/api/auth/login", nil, map[string]interface{}{"user_name": name, "password": fileSharingPassword}, nil) + data := requireSuccess(fixture.t, response) + var cookie *http.Cookie + for _, candidate := range response.Cookies { + if candidate.Name == utils.SessionCookieName && candidate.MaxAge > 0 { + cookie = candidate + } + } + if cookie == nil || !cookie.HttpOnly || !cookie.Secure || cookie.Path != "/" { + fixture.t.Fatal("login did not issue the secure site-wide session cookie") + } + return &browserSession{Cookie: cookie, CSRF: data["csrf_token"].(string), ID: data["id"].(string)} +} + +func requireSuccess(t *testing.T, response apiResponse) map[string]interface{} { + t.Helper() + if response.Status < 200 || response.Status >= 300 || response.Code != 0 { + t.Fatalf("request failed: status=%d code=%d response=%s", response.Status, response.Code, response.Raw) + } + return response.Data +} + +func requireError(t *testing.T, response apiResponse, status, code int) { + t.Helper() + if response.Status != status || response.Code != code { + t.Fatalf("unexpected error: status=%d code=%d response=%s", response.Status, response.Code, response.Raw) + } +} + +func object(value interface{}) map[string]interface{} { + result, _ := value.(map[string]interface{}) + return result +} + +func multipartBody(t *testing.T, filename string, content []byte) ([]byte, string) { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(content); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return body.Bytes(), writer.FormDataContentType() +} + +func (fixture *httpFixture) upload(session *browserSession, filename string, content []byte) map[string]interface{} { + fixture.t.Helper() + body, contentType := multipartBody(fixture.t, filename, content) + return requireSuccess(fixture.t, fixture.request(http.MethodPost, "/api/files", session, body, map[string]string{"Content-Type": contentType})) +} + +func (fixture *httpFixture) createShare(session *browserSession, fileID string, input map[string]interface{}) map[string]interface{} { + fixture.t.Helper() + data := requireSuccess(fixture.t, fixture.request(http.MethodPost, "/api/files/"+fileID+"/shares", session, input, nil)) + share := object(data["share"]) + if share["token"] == "" || share["url"] != fileSharingOrigin+"/s/"+share["token"].(string) { + fixture.t.Fatalf("share URL/token contract broken: %#v", share) + } + return data +} + +func (fixture *httpFixture) downloadCount(token string) int64 { + fixture.t.Helper() + var count int64 + if err := fixture.database.QueryRow("SELECT download_count FROM file_shares WHERE token=?", token).Scan(&count); err != nil { + fixture.t.Fatal(err) + } + return count +} + +func (fixture *httpFixture) grantCookie(response apiResponse) *http.Cookie { + fixture.t.Helper() + for _, cookie := range response.Cookies { + if strings.HasPrefix(cookie.Name, "hs_fs_") && cookie.MaxAge > 0 { + if !cookie.Secure || !cookie.HttpOnly || !strings.HasPrefix(cookie.Path, "/api/file-shares/") { + fixture.t.Fatal("unlock cookie lacks security attributes") + } + return cookie + } + } + fixture.t.Fatal("unlock response did not issue a grant cookie") + return nil +} + +func shareState(t *testing.T, response apiResponse, expected string) map[string]interface{} { + t.Helper() + data := requireSuccess(t, response) + if data["state"] != expected { + t.Fatalf("share state = %#v, want %q", data, expected) + } + return data +} + +func (fixture *httpFixture) applicationToken(session *browserSession, scopes []string) string { + fixture.t.Helper() + created := requireSuccess(fixture.t, fixture.request(http.MethodPost, "/api/applications", session, map[string]interface{}{"name": "file sharing test client", "description": "synthetic", "scopes": scopes}, nil)) + application := object(created["application"]) + form := url.Values{"grant_type": {"client_credentials"}, "client_id": {application["access_key"].(string)}, "client_secret": {created["secret_key"].(string)}} + response := fixture.request(http.MethodPost, "/api/auth/token", nil, form.Encode(), map[string]string{"Content-Type": "application/x-www-form-urlencoded"}) + if response.Status != http.StatusOK { + fixture.t.Fatalf("application token exchange failed: %d %s", response.Status, response.Raw) + } + var issued struct { + AccessToken string `json:"access_token"` + } + if json.Unmarshal(response.Raw, &issued) != nil || issued.AccessToken == "" { + fixture.t.Fatal("application token response missing access_token") + } + return issued.AccessToken +} + +// TestFileSharingHTTPAccessAndLifecycle exercises the complete user-visible +// lifecycle through production Gin handlers. It covers all ACL/secret states, +// download accounting, private storage failures and owner-only management. +func TestFileSharingHTTPAccessAndLifecycle(t *testing.T) { + fixture := newHTTPFixture(t, "database", nil) + owner := fixture.login(fixture.owner) + member := fixture.login(fixture.member) + guest := fixture.login(fixture.guest) + + eligible := requireSuccess(t, fixture.request(http.MethodGet, "/api/files/eligible-users", owner, nil, nil)) + items, ok := eligible["items"].([]interface{}) + if !ok || len(items) != 3 || strings.Contains(string(fixture.request(http.MethodGet, "/api/files/eligible-users", owner, nil, nil).Raw), "password") { + t.Fatalf("eligible users response is incomplete or sensitive: %#v", eligible) + } + + readToken := fixture.applicationToken(owner, []string{"files:read"}) + readHeaders := map[string]string{"Authorization": "Bearer " + readToken} + requireSuccess(t, fixture.request(http.MethodGet, "/api/files", nil, nil, readHeaders)) + body, contentType := multipartBody(t, "scope-denied.bin", []byte("x")) + requireError(t, fixture.request(http.MethodPost, "/api/files", nil, body, map[string]string{"Authorization": "Bearer " + readToken, "Content-Type": contentType}), http.StatusForbidden, 3) + writeToken := fixture.applicationToken(owner, []string{"files:write"}) + body, contentType = multipartBody(t, "scope-write.bin", []byte("app")) + requireSuccess(t, fixture.request(http.MethodPost, "/api/files", nil, body, map[string]string{"Authorization": "Bearer " + writeToken, "Content-Type": contentType})) + requireSuccess(t, fixture.request(http.MethodGet, "/api/files", nil, nil, map[string]string{"Authorization": "Bearer " + writeToken})) + + content := []byte("private file sharing payload") + uploadBody, uploadHeaders := multipartForRequest(t, "report.bin", content) + uploadResponse := fixture.request(http.MethodPost, "/api/files", owner, uploadBody, uploadHeaders) + file := requireSuccess(t, uploadResponse) + fileID := file["id"].(string) + if file["name"] != "report.bin" || int64(file["size_bytes"].(float64)) != int64(len(content)) || strings.Contains(string(uploadResponse.Raw), "storage_key") || strings.Contains(string(uploadResponse.Raw), fixture.conf.FileSharing.StorageRoot) { + t.Fatalf("upload response leaked storage or wrong metadata: %s", uploadResponse.Raw) + } + var storageKey string + if err := fixture.database.QueryRow("SELECT storage_key FROM files WHERE id=?", fileID).Scan(&storageKey); err != nil { + t.Fatal(err) + } + storagePath := filepath.Join(fixture.conf.FileSharing.StorageRoot, filepath.FromSlash(storageKey)) + storageInfo, err := os.Stat(storagePath) + if err != nil || !storageInfo.Mode().IsRegular() || storageInfo.Mode().Perm()&0077 != 0 { + t.Fatalf("stored bytes are missing or not private: info=%#v err=%v", storageInfo, err) + } + requireError(t, fixture.request(http.MethodGet, "/api/files/"+fileID, guest, nil, nil), http.StatusNotFound, 4) + requireError(t, fixture.request(http.MethodDelete, "/api/files/"+fileID, guest, nil, nil), http.StatusNotFound, 4) + + publicResult := fixture.createShare(owner, fileID, map[string]interface{}{"access_mode": "public", "secret_mode": "none", "max_downloads": 1}) + publicShare := object(publicResult["share"]) + publicToken := publicShare["token"].(string) + authShare := object(fixture.createShare(owner, fileID, map[string]interface{}{"access_mode": "authenticated", "secret_mode": "none"})["share"]) + authToken := authShare["token"].(string) + membersResult := fixture.createShare(owner, fileID, map[string]interface{}{"access_mode": "members", "member_user_ids": []string{member.ID}, "secret_mode": "password", "secret": "member-password", "expires_at": time.Now().Add(time.Hour), "max_downloads": 7}) + membersShare := object(membersResult["share"]) + membersToken := membersShare["token"].(string) + codeResult := fixture.createShare(owner, fileID, map[string]interface{}{"access_mode": "public", "secret_mode": "code", "secret": "Ab12Cd"}) + codeShare := object(codeResult["share"]) + codeToken := codeShare["token"].(string) + if publicResult["secret"] != "" || membersResult["secret"] != "member-password" || codeResult["secret"] != "Ab12Cd" { + t.Fatal("create response did not obey one-time secret contract") + } + listed := fixture.request(http.MethodGet, "/api/files/"+fileID+"/shares", owner, nil, nil) + requireSuccess(t, listed) + if strings.Contains(string(listed.Raw), "member-password") || strings.Contains(string(listed.Raw), "Ab12Cd") || strings.Contains(string(listed.Raw), "secret_hash") { + t.Fatal("share listing exposed a secret") + } + + state := shareState(t, fixture.request(http.MethodGet, "/api/file-shares/"+publicToken, nil, nil, nil), "available") + if object(state["file"])["name"] != "report.bin" { + t.Fatalf("available share file = %#v", state["file"]) + } + for _, path := range []string{"/api/file-shares/" + publicToken, "/api/file-shares/" + publicToken + "/download"} { + if response := fixture.request(http.MethodHead, path, nil, nil, nil); response.Status < 400 { + t.Fatalf("HEAD unexpectedly started a download: %s status=%d", path, response.Status) + } + } + requireError(t, fixture.request(http.MethodPost, "/api/file-shares/"+publicToken+"/download", nil, nil, map[string]string{"Range": "bytes=0-3"}), http.StatusBadRequest, 2) + if count := fixture.downloadCount(publicToken); count != 0 { + t.Fatalf("HEAD/Range consumed %d downloads", count) + } + heldPath := storagePath + ".held" + if err := os.Rename(storagePath, heldPath); err != nil { + t.Fatal(err) + } + requireError(t, fixture.request(http.MethodPost, "/api/file-shares/"+publicToken+"/download", nil, nil, nil), http.StatusNotFound, 4) + if count := fixture.downloadCount(publicToken); count != 0 { + t.Fatalf("missing bytes consumed %d downloads", count) + } + if err := os.Rename(heldPath, storagePath); err != nil { + t.Fatal(err) + } + + responses := make(chan apiResponse, 12) + var downloads sync.WaitGroup + for index := 0; index < 12; index++ { + downloads.Add(1) + go func() { + defer downloads.Done() + responses <- fixture.request(http.MethodPost, "/api/file-shares/"+publicToken+"/download", nil, nil, nil) + }() + } + downloads.Wait() + close(responses) + successes := 0 + for response := range responses { + if response.Status == http.StatusOK { + successes++ + if !bytes.Equal(response.Raw, content) || response.Header.Get("Content-Type") != "application/octet-stream" || response.Header.Get("Accept-Ranges") != "none" || response.Header.Get("Referrer-Policy") != "no-referrer" || !strings.HasPrefix(response.Header.Get("Content-Disposition"), "attachment") { + t.Fatalf("download headers/body are unsafe: headers=%v body=%q", response.Header, response.Raw) + } + } else { + requireError(t, response, http.StatusNotFound, 4) + } + } + if successes != 1 || fixture.downloadCount(publicToken) != 1 { + t.Fatalf("one-use share started %d transfers, count=%d", successes, fixture.downloadCount(publicToken)) + } + + loginGate := shareState(t, fixture.request(http.MethodGet, "/api/file-shares/"+authToken, nil, nil, nil), "login_required") + if loginGate["file"] != nil { + t.Fatal("login gate leaked file metadata") + } + shareState(t, fixture.request(http.MethodGet, "/api/file-shares/"+authToken, guest, nil, nil), "available") + if response := fixture.request(http.MethodPost, "/api/file-shares/"+authToken+"/download", guest, nil, nil); response.Status != http.StatusOK || !bytes.Equal(response.Raw, content) { + t.Fatalf("authenticated download failed: %d %s", response.Status, response.Raw) + } + + shareState(t, fixture.request(http.MethodGet, "/api/file-shares/"+membersToken, nil, nil, nil), "login_required") + requireError(t, fixture.request(http.MethodGet, "/api/file-shares/"+membersToken, guest, nil, nil), http.StatusNotFound, 4) + if response := fixture.request(http.MethodPost, "/api/file-shares/"+membersToken+"/download", owner, nil, nil); response.Status != http.StatusOK || fixture.downloadCount(membersToken) != 1 { + t.Fatalf("owner precondition download failed: status=%d count=%d response=%s", response.Status, fixture.downloadCount(membersToken), response.Raw) + } + locked := shareState(t, fixture.request(http.MethodGet, "/api/file-shares/"+membersToken, member, nil, nil), "locked") + if locked["file"] != nil || locked["expires_at"] != nil || locked["remaining_downloads"] != nil || locked["download_count"] != float64(0) || locked["max_downloads"] != float64(0) { + t.Fatalf("locked member gate reflected protected metadata: %#v", locked) + } + requireError(t, fixture.request(http.MethodPost, "/api/file-shares/"+membersToken+"/unlock", member, map[string]interface{}{"secret": "wrong-password"}, nil), http.StatusForbidden, 3) + requireSuccess(t, fixture.request(http.MethodGet, "/api/auth/me", member, nil, nil)) + unlocked := fixture.request(http.MethodPost, "/api/file-shares/"+membersToken+"/unlock", member, map[string]interface{}{"secret": "member-password"}, nil) + requireSuccess(t, unlocked) + member.ExtraCookie = append(member.ExtraCookie, fixture.grantCookie(unlocked)) + shareState(t, fixture.request(http.MethodGet, "/api/file-shares/"+membersToken, member, nil, nil), "available") + if response := fixture.request(http.MethodPost, "/api/file-shares/"+membersToken+"/download", member, nil, nil); response.Status != http.StatusOK { + t.Fatalf("member download failed: %d %s", response.Status, response.Raw) + } + shareState(t, fixture.request(http.MethodGet, "/api/file-shares/"+membersToken, owner, nil, nil), "available") + if response := fixture.request(http.MethodPost, "/api/file-shares/"+membersToken+"/download", owner, nil, nil); response.Status != http.StatusOK { + t.Fatalf("owner secret bypass failed: %d %s", response.Status, response.Raw) + } + + shareState(t, fixture.request(http.MethodGet, "/api/file-shares/"+codeToken, nil, nil, nil), "locked") + requireError(t, fixture.request(http.MethodPost, "/api/file-shares/"+codeToken+"/unlock", nil, map[string]interface{}{"secret": "000000"}, nil), http.StatusForbidden, 3) + codeUnlock := fixture.request(http.MethodPost, "/api/file-shares/"+codeToken+"/unlock", nil, map[string]interface{}{"secret": "Ab12Cd"}, nil) + requireSuccess(t, codeUnlock) + anonymousGrant := &browserSession{ExtraCookie: []*http.Cookie{fixture.grantCookie(codeUnlock)}} + shareState(t, fixture.request(http.MethodGet, "/api/file-shares/"+codeToken, anonymousGrant, nil, nil), "available") + if response := fixture.request(http.MethodPost, "/api/file-shares/"+codeToken+"/download", anonymousGrant, nil, nil); response.Status != http.StatusOK { + t.Fatalf("code download failed: %d %s", response.Status, response.Raw) + } + + requireSuccess(t, fixture.request(http.MethodDelete, "/api/files/"+fileID+"/shares/"+codeShare["id"].(string), owner, nil, nil)) + requireError(t, fixture.request(http.MethodGet, "/api/file-shares/"+codeToken, anonymousGrant, nil, nil), http.StatusNotFound, 4) + expiring := object(fixture.createShare(owner, fileID, map[string]interface{}{"access_mode": "public", "secret_mode": "none"})["share"]) + if _, err := fixture.database.Exec("UPDATE file_shares SET expires_at=DATE_SUB(NOW(6), INTERVAL 1 SECOND) WHERE token=?", expiring["token"]); err != nil { + t.Fatal(err) + } + requireError(t, fixture.request(http.MethodGet, "/api/file-shares/"+expiring["token"].(string), nil, nil, nil), http.StatusNotFound, 4) + + empty := fixture.upload(owner, "empty.dat", nil) + emptyShare := object(fixture.createShare(owner, empty["id"].(string), map[string]interface{}{"access_mode": "public", "secret_mode": "none"})["share"]) + emptyDownload := fixture.request(http.MethodPost, "/api/file-shares/"+emptyShare["token"].(string)+"/download", nil, nil, nil) + if emptyDownload.Status != http.StatusOK || len(emptyDownload.Raw) != 0 || emptyDownload.Header.Get("Content-Length") != "0" || fixture.downloadCount(emptyShare["token"].(string)) != 1 { + t.Fatalf("empty download contract failed: status=%d headers=%v body=%q", emptyDownload.Status, emptyDownload.Header, emptyDownload.Raw) + } + + requireSuccess(t, fixture.request(http.MethodDelete, "/api/files/"+fileID, owner, nil, nil)) + if _, err := os.Stat(storagePath); !os.IsNotExist(err) { + t.Fatalf("deleted file bytes remain visible: %v", err) + } + for _, token := range []string{publicToken, authToken, membersToken, codeToken} { + requireError(t, fixture.request(http.MethodGet, "/api/file-shares/"+token, nil, nil, nil), http.StatusNotFound, 4) + } +} + +func multipartForRequest(t *testing.T, filename string, content []byte) ([]byte, map[string]string) { + body, contentType := multipartBody(t, filename, content) + return body, map[string]string{"Content-Type": contentType} +} + +type gatedReader struct { + reader *bytes.Reader + started chan struct{} + release chan struct{} + once sync.Once +} + +func (reader *gatedReader) Read(target []byte) (int, error) { + reader.once.Do(func() { + close(reader.started) + <-reader.release + }) + return reader.reader.Read(target) +} + +// TestSlowUploadReservationAndExactSessionRecheck proves that the request-body +// receive phase owns the concurrency slot and that revoking only the accepted +// session before commit leaves neither metadata nor private bytes behind. +func TestSlowUploadReservationAndExactSessionRecheck(t *testing.T) { + fixture := newHTTPFixture(t, "database", func(conf *config.Config) { + conf.FileSharing.MaxConcurrentUploadsPerUser = 1 + conf.FileSharing.MaxConcurrentUploads = 1 + }) + owner := fixture.login(fixture.owner) + body, contentType := multipartBody(t, "slow.bin", bytes.Repeat([]byte("s"), 64<<10)) + blocked := &gatedReader{reader: bytes.NewReader(body), started: make(chan struct{}), release: make(chan struct{})} + result := make(chan apiResponse, 1) + go func() { + result <- fixture.request(http.MethodPost, "/api/files", owner, blocked, map[string]string{"Content-Type": contentType}) + }() + <-blocked.started + + secondBody, secondType := multipartBody(t, "second.bin", []byte("second")) + requireError(t, fixture.request(http.MethodPost, "/api/files", owner, secondBody, map[string]string{"Content-Type": secondType}), http.StatusTooManyRequests, 8) + digest := sha256.Sum256([]byte(owner.Cookie.Value)) + updated, err := fixture.database.Exec("UPDATE login_token SET is_expired=1 WHERE token_digest=? AND is_expired=0", digest[:]) + if err != nil { + t.Fatal(err) + } + if count, err := updated.RowsAffected(); err != nil || count != 1 { + t.Fatalf("exact session revocation affected %d rows: %v", count, err) + } + close(blocked.release) + requireError(t, <-result, http.StatusUnauthorized, 403) + + var rows int + if err := fixture.database.QueryRow("SELECT COUNT(*) FROM files").Scan(&rows); err != nil || rows != 0 { + t.Fatalf("revoked upload committed rows=%d err=%v", rows, err) + } + regular := 0 + if err := filepath.Walk(fixture.conf.FileSharing.StorageRoot, func(_ string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.Mode().IsRegular() { + regular++ + } + return nil + }); err != nil || regular != 0 { + t.Fatalf("revoked upload retained %d files: %v", regular, err) + } +} + +// TestConfigIdentityQuotaSerializesAcrossApplicationInstances uses two real +// legacy sessions but two independent Application objects, bypassing the local +// striped lock to verify that credential-row locking enforces one owner quota +// across service instances. +func TestConfigIdentityQuotaSerializesAcrossApplicationInstances(t *testing.T) { + fixture := newHTTPFixture(t, "config", func(conf *config.Config) { + conf.FileSharing.MaxFileBytes = 1 + conf.FileSharing.MaxUserBytes = 1 + }) + first := fixture.login(fixture.owner) + second := fixture.login(fixture.owner) + principals := make([]*utils.Principal, 0, 2) + contexts := make([]context.Context, 0, 2) + for _, session := range []*browserSession{first, second} { + identity, err := passport.CheckToken(context.Background(), session.Cookie.Value) + if err != nil { + t.Fatal(err) + } + principals = append(principals, &utils.Principal{Kind: "user", UserID: identity.ID, AuthVersion: identity.AuthVersion, TokenExpiresAt: identity.SessionExpiry}) + contexts = append(contexts, context.WithValue(context.Background(), utils.CtxKeyLoginToken, session.Cookie.Value)) + } + applications := []*fileapp.Application{fileapp.New(), fileapp.New()} + errorsByUpload := make(chan error, 2) + var group sync.WaitGroup + for index := range applications { + group.Add(1) + go func(index int) { + defer group.Done() + _, err := applications[index].Upload(contexts[index], principals[index], fmt.Sprintf("quota-%d.bin", index), strings.NewReader("x")) + errorsByUpload <- err + }(index) + } + group.Wait() + close(errorsByUpload) + succeeded, limited := 0, 0 + for err := range errorsByUpload { + switch { + case err == nil: + succeeded++ + case errors.Is(err, fileservice.ErrRateLimited): + limited++ + default: + t.Fatalf("unexpected concurrent quota result: %v", err) + } + } + var count, size int64 + if err := fixture.database.QueryRow("SELECT COUNT(*), COALESCE(SUM(size_bytes),0) FROM files WHERE deleted_at IS NULL").Scan(&count, &size); err != nil { + t.Fatal(err) + } + if succeeded != 1 || limited != 1 || count != 1 || size != 1 { + t.Fatalf("quota race results succeeded=%d limited=%d rows=%d bytes=%d", succeeded, limited, count, size) + } +} diff --git a/api/fileshare/route.go b/api/fileshare/route.go index e0a2249..40d1299 100644 --- a/api/fileshare/route.go +++ b/api/fileshare/route.go @@ -2,11 +2,49 @@ package fileshare import ( "net/http" + "strings" + "github.com/gin-gonic/gin" + "github.com/mcoder2014/home_server/api/middleware" "github.com/mcoder2014/home_server/data" + "github.com/mcoder2014/home_server/domain/service/accounts" + service "github.com/mcoder2014/home_server/domain/service/fileshare" + "github.com/mcoder2014/home_server/utils/ginfmt" ) -func InitRoute() error { - data.AddRoute(http.MethodGet, "/fileshare/test/*path", GetShareFile) +func InitRouter() error { + read := middleware.RequireIdentity("files:read", false) + write := middleware.RequireIdentity("files:write", false) + data.AddRoute(http.MethodGet, "/api/files", requireModule, read, listFiles) + data.AddRoute(http.MethodPost, "/api/files", requireModule, write, uploadFile) + data.AddRoute(http.MethodGet, "/api/files/eligible-users", requireModule, read, eligibleUsers) + data.AddRoute(http.MethodGet, "/api/files/:id", requireModule, read, getFile) + data.AddRoute(http.MethodDelete, "/api/files/:id", requireModule, write, deleteFile) + data.AddRoute(http.MethodGet, "/api/files/:id/shares", requireModule, read, listShares) + data.AddRoute(http.MethodPost, "/api/files/:id/shares", requireModule, write, createShare) + data.AddRoute(http.MethodDelete, "/api/files/:id/shares/:share_id", requireModule, write, revokeShare) + data.AddRoute(http.MethodGet, "/api/file-shares/:token", requireModule, getPublicShare) + data.AddRoute(http.MethodPost, "/api/file-shares/:token/unlock", requireModule, unlockPublicShare) + data.AddRoute(http.MethodPost, "/api/file-shares/:token/download", requireModule, downloadPublicShare) return nil } + +func requireModule(c *gin.Context) { + middleware.EnableReadSnapshot(c) + enabled, err := accounts.ModuleEnabled(ginfmt.RPCContext(c), "file_sharing") + if err != nil { + ginfmt.Fail(c, service.ErrDependency) + c.Abort() + return + } + if !enabled { + if strings.HasPrefix(c.Request.URL.Path, "/api/file-shares/") { + ginfmt.Fail(c, service.ErrNotFound) + } else { + ginfmt.Fail(c, service.ErrForbidden) + } + c.Abort() + return + } + c.Next() +} diff --git a/api/fileshare/share.go b/api/fileshare/share.go deleted file mode 100644 index b4b1391..0000000 --- a/api/fileshare/share.go +++ /dev/null @@ -1,8 +0,0 @@ -package fileshare - -import "github.com/gin-gonic/gin" - -// GetShareFile 是历史文件分享路由的占位处理函数,目前没有读取或发送文件;该模块尚未进入主路由初始化链。 -func GetShareFile(c *gin.Context) { - -} diff --git a/api/handler.go b/api/handler.go index 1e221f1..33084af 100644 --- a/api/handler.go +++ b/api/handler.go @@ -5,6 +5,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/library" "github.com/mcoder2014/home_server/api/manuals" "github.com/mcoder2014/home_server/api/middleware" @@ -37,6 +38,7 @@ func InitRouter() error { // 静态网页托管相关接口 webprojects.InitRouter, manuals.InitRouter, + fileshare.InitRouter, auth.InitRouter, applications.InitRouter, } { diff --git a/api/manuals/content.go b/api/manuals/content.go index aa2f912..e040fff 100644 --- a/api/manuals/content.go +++ b/api/manuals/content.go @@ -10,6 +10,7 @@ import ( "github.com/mcoder2014/home_server/config" "github.com/mcoder2014/home_server/domain/model" service "github.com/mcoder2014/home_server/domain/service/manuals" + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" apperrors "github.com/mcoder2014/home_server/errors" "github.com/mcoder2014/home_server/utils/ginfmt" ) @@ -31,7 +32,7 @@ func serveItemResource(c *gin.Context) { return } thumbnail := strings.HasSuffix(c.FullPath(), "/thumbnail") - item, err := application.Default.ResourceItem(ginfmt.RPCContext(c), manualID, itemID, principalUserID(principal), thumbnail) + item, err := application.Default.ResourceItem(ginfmt.RPCContext(c), manualID, itemID, principalUserID(principal), thumbnail, resourcepasswords.GrantToken(c.Request, resourcepasswords.ResourceManual, manualID)) if err != nil { ginfmt.Fail(c, err) return diff --git a/api/manuals/handlers.go b/api/manuals/handlers.go index 7b2a270..f0651ae 100644 --- a/api/manuals/handlers.go +++ b/api/manuals/handlers.go @@ -12,6 +12,7 @@ import ( application "github.com/mcoder2014/home_server/app/manuals" "github.com/mcoder2014/home_server/config" service "github.com/mcoder2014/home_server/domain/service/manuals" + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" apperrors "github.com/mcoder2014/home_server/errors" "github.com/mcoder2014/home_server/utils" "github.com/mcoder2014/home_server/utils/ginfmt" @@ -87,7 +88,7 @@ func getManual(c *gin.Context) { ginfmt.Fail(c, err) return } - manual, err := application.Default.Get(ginfmt.RPCContext(c), manualID, principalUserID(principal), principal) + manual, err := application.Default.Get(ginfmt.RPCContext(c), manualID, principalUserID(principal), principal, resourcepasswords.GrantToken(c.Request, resourcepasswords.ResourceManual, manualID)) respond(c, http.StatusOK, manual, err) } diff --git a/api/manuals/integration_test.go b/api/manuals/integration_test.go index 15bb450..b431ba7 100644 --- a/api/manuals/integration_test.go +++ b/api/manuals/integration_test.go @@ -82,7 +82,7 @@ func newManualHTTPFixture(t *testing.T) *manualHTTPFixture { _, err = sqlDB.Exec(ddl) require.NoError(t, err) } - for _, name := range []string{"20260913_runtime_config.sql", "20260912_applications.sql", "20260919_manuals.sql"} { + for _, name := range []string{"20260913_runtime_config.sql", "20260912_applications.sql", "20260919_manuals.sql", "20260919_resource_passwords.sql"} { raw, readErr := migrations.SQL.ReadFile(name) require.NoError(t, readErr) executeManualTestDDL(t, sqlDB, string(raw)) diff --git a/api/manuals/password.go b/api/manuals/password.go new file mode 100644 index 0000000..42d7a99 --- /dev/null +++ b/api/manuals/password.go @@ -0,0 +1,75 @@ +package manuals + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mcoder2014/home_server/api/middleware" + application "github.com/mcoder2014/home_server/app/manuals" + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils/ginfmt" +) + +func getManualPassword(c *gin.Context) { + manualID, err := pathID(c, "id") + if err != nil { + ginfmt.Fail(c, err) + return + } + principal := currentPrincipal(c) + state, err := application.Default.PasswordState(ginfmt.RPCContext(c), principal.UserID, manualID) + respond(c, http.StatusOK, state, err) +} + +func putManualPassword(c *gin.Context) { + manualID, err := pathID(c, "id") + if err != nil { + ginfmt.Fail(c, err) + return + } + var input resourcepasswords.UpdateInput + if !bindJSON(c, &input, 4096) { + return + } + principal := currentPrincipal(c) + state, err := application.Default.SetPassword(ginfmt.RPCContext(c), principal.UserID, manualID, input, principal) + respond(c, http.StatusOK, state, err) +} + +func unlockManual(c *gin.Context) { + manualID, err := pathID(c, "id") + if err != nil { + ginfmt.Fail(c, err) + return + } + if !middleware.IsHTTPS(c) || !middleware.BrowserOriginAllowed(c.Request) { + ginfmt.Fail(c, apperrors.ErrForbidden) + return + } + var input resourcepasswords.UnlockInput + if !bindJSON(c, &input, 4096) { + return + } + principal, err := optionalReader(c) + if err != nil { + ginfmt.Fail(c, err) + return + } + manual, err := application.Default.ReadableManual(ginfmt.RPCContext(c), manualID, principalUserID(principal)) + if err != nil { + ginfmt.Fail(c, err) + return + } + state, grant, err := resourcepasswords.Default.Unlock(ginfmt.RPCContext(c), resourcepasswords.ResourceManual, manual.ID, input.Password, middleware.TrustedClientIP(c.Request)) + if err != nil { + ginfmt.Fail(c, err) + return + } + if state.PasswordProtected { + http.SetCookie(c.Writer, resourcepasswords.GrantCookie(resourcepasswords.ResourceManual, manual.ID, grant)) + } else { + http.SetCookie(c.Writer, resourcepasswords.ExpiredGrantCookie(resourcepasswords.ResourceManual, manual.ID)) + } + ginfmt.Success(c, http.StatusOK, state) +} diff --git a/api/manuals/password_integration_test.go b/api/manuals/password_integration_test.go new file mode 100644 index 0000000..9f3ef37 --- /dev/null +++ b/api/manuals/password_integration_test.go @@ -0,0 +1,102 @@ +package manuals + +import ( + "net/http" + "strings" + "testing" + + "github.com/mcoder2014/home_server/domain/model" + "github.com/stretchr/testify/require" +) + +func TestManualPasswordProtectsDetailFilesAndListSummary(t *testing.T) { + fixture := newManualHTTPFixture(t) + created := requireManualSuccess(t, fixture.request(http.MethodPost, "/api/manuals", fixture.owner, map[string]interface{}{ + "name": "Protected manual", "description": "hidden summary", "categories": []string{"Kitchen"}, "access_mode": "public", "client_request_id": "password-manual", + }, nil)) + manualID := created["id"].(string) + pngBytes := manualPNG(t) + upload, contentType := fixture.multipart(map[string]string{"title": "hidden cover", "client_request_id": "password-image"}, "cover.png", pngBytes) + uploaded := requireManualSuccess(t, fixture.request(http.MethodPost, "/api/manuals/"+manualID+"/files", fixture.owner, upload, map[string]string{"Content-Type": contentType})) + imageID := uploaded["item"].(map[string]interface{})["id"].(string) + active := requireManualSuccess(t, fixture.request(http.MethodPatch, "/api/manuals/"+manualID, fixture.owner, map[string]interface{}{"revision": 2, "status": "active", "cover_item_id": imageID}, nil)) + require.Equal(t, int64(3), manualRevision(active)) + + base := "/api/manuals/" + manualID + state := requireManualSuccess(t, fixture.request(http.MethodGet, base+"/password", fixture.owner, nil, nil)) + require.Equal(t, false, state["password_protected"]) + require.Equal(t, int64(0), int64(state["version"].(float64))) + state = requireManualSuccess(t, fixture.request(http.MethodPut, base+"/password", fixture.owner, map[string]interface{}{"password": "manual password", "version": 0}, nil)) + require.Equal(t, true, state["password_protected"]) + require.Equal(t, int64(1), int64(state["version"].(float64))) + + listed := requireManualSuccess(t, fixture.request(http.MethodGet, "/api/manuals", nil, nil, nil)) + items := listed["items"].([]interface{}) + require.Len(t, items, 1) + lockedView := items[0].(map[string]interface{}) + require.Equal(t, true, lockedView["password_protected"]) + require.Empty(t, lockedView["description"]) + require.Empty(t, lockedView["cover_url"]) + require.Nil(t, lockedView["cover"]) + + contentPath := base + "/items/" + imageID + "/content" + thumbnailPath := base + "/items/" + imageID + "/thumbnail" + for _, request := range []struct { + method, path string + headers map[string]string + }{ + {http.MethodGet, base, nil}, + {http.MethodGet, contentPath, nil}, + {http.MethodHead, contentPath, nil}, + {http.MethodGet, contentPath, map[string]string{"Range": "bytes=0-7"}}, + {http.MethodGet, thumbnailPath, nil}, + } { + response := fixture.request(request.method, request.path, nil, nil, request.headers) + require.Equal(t, http.StatusForbidden, response.status, "%s %s", request.method, request.path) + if request.method != http.MethodHead { + require.Equal(t, 3, response.code) + } + } + require.Equal(t, http.StatusOK, fixture.request(http.MethodGet, contentPath, fixture.owner, nil, nil).status) + _, err := fixture.database.Exec("UPDATE manuals SET status = ? WHERE id = ?", model.ManualStatusDeleted, manualID) + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, fixture.request(http.MethodGet, contentPath, fixture.owner, nil, nil).status) + _, err = fixture.database.Exec("UPDATE manuals SET status = ? WHERE id = ?", model.ManualStatusActive, manualID) + require.NoError(t, err) + + wrong := fixture.request(http.MethodPost, base+"/unlock", nil, map[string]interface{}{"password": "wrong password"}, nil) + require.Equal(t, http.StatusForbidden, wrong.status) + require.Equal(t, 3, wrong.code) + unlocked := fixture.request(http.MethodPost, base+"/unlock", nil, map[string]interface{}{"password": "manual password"}, nil) + require.Equal(t, http.StatusOK, unlocked.status) + setCookies := unlocked.header.Values("Set-Cookie") + require.NotEmpty(t, setCookies) + grant := strings.SplitN(setCookies[0], ";", 2)[0] + if !strings.HasPrefix(grant, "__Host-cq_read_manual_") { + t.Fatalf("unexpected unlock cookie: %s", grant) + } + headers := map[string]string{"Cookie": grant} + detail := requireManualSuccess(t, fixture.request(http.MethodGet, base, nil, nil, headers)) + require.Equal(t, true, detail["password_protected"]) + require.Equal(t, "hidden summary", detail["description"]) + require.NotEmpty(t, detail["items"]) + require.Equal(t, http.StatusOK, fixture.request(http.MethodGet, contentPath, nil, nil, headers).status) + require.Equal(t, http.StatusOK, fixture.request(http.MethodHead, contentPath, nil, nil, headers).status) + require.Equal(t, http.StatusPartialContent, fixture.request(http.MethodGet, contentPath, nil, nil, map[string]string{"Cookie": grant, "Range": "bytes=0-7"}).status) + require.Equal(t, http.StatusOK, fixture.request(http.MethodGet, thumbnailPath, nil, nil, headers).status) + + state = requireManualSuccess(t, fixture.request(http.MethodPut, base+"/password", fixture.owner, map[string]interface{}{"password": "replacement password", "version": 1}, nil)) + require.Equal(t, int64(2), int64(state["version"].(float64))) + require.Equal(t, http.StatusForbidden, fixture.request(http.MethodGet, contentPath, nil, nil, headers).status) + state = requireManualSuccess(t, fixture.request(http.MethodPut, base+"/password", fixture.owner, map[string]interface{}{"password": "", "version": 2}, nil)) + require.Equal(t, int64(3), int64(state["version"].(float64))) + require.Equal(t, false, state["password_protected"]) + require.Equal(t, http.StatusOK, fixture.request(http.MethodGet, contentPath, nil, nil, nil).status) + + if response := fixture.request(http.MethodPut, base+"/password", fixture.owner, map[string]interface{}{"password": strings.Repeat("界", 25), "version": 3}, nil); response.status != http.StatusBadRequest { + t.Fatalf("password above bcrypt byte limit accepted: status=%d code=%d", response.status, response.code) + } + if response := fixture.request(http.MethodPut, base+"/password", fixture.owner, map[string]interface{}{"password": "12345678", "version": 1}, nil); response.status != http.StatusConflict { + t.Fatalf("stale password version accepted: status=%d code=%d", response.status, response.code) + } +} diff --git a/api/manuals/route.go b/api/manuals/route.go index 3613519..a10d661 100644 --- a/api/manuals/route.go +++ b/api/manuals/route.go @@ -12,11 +12,15 @@ import ( ) func InitRouter() error { + read := middleware.RequireIdentity("manuals:read", false) write := middleware.RequireIdentity("manuals:write", false) data.AddRoute(http.MethodGet, "/api/manuals", requireModule, listManuals) data.AddRoute(http.MethodGet, "/api/manuals/categories", requireModule, listCategories) data.AddRoute(http.MethodPost, "/api/manuals", requireModule, write, createManual) data.AddRoute(http.MethodGet, "/api/manuals/:id", requireModule, getManual) + data.AddRoute(http.MethodGet, "/api/manuals/:id/password", requireModule, read, getManualPassword) + data.AddRoute(http.MethodPut, "/api/manuals/:id/password", requireModule, write, putManualPassword) + data.AddRoute(http.MethodPost, "/api/manuals/:id/unlock", requireModule, unlockManual) data.AddRoute(http.MethodPatch, "/api/manuals/:id", requireModule, write, updateManual) data.AddRoute(http.MethodDelete, "/api/manuals/:id", requireModule, write, deleteManual) data.AddRoute(http.MethodPost, "/api/manuals/:id/items", requireModule, write, addInlineItem) diff --git a/api/middleware/account.go b/api/middleware/account.go index 682ae5d..87978ba 100644 --- a/api/middleware/account.go +++ b/api/middleware/account.go @@ -204,6 +204,8 @@ func AuthorizeCapability(ctx context.Context, principal *utils.Principal, scope module = "web_projects" } else if strings.HasPrefix(scope, "manuals:") { module = "manuals" + } else if strings.HasPrefix(scope, "files:") { + module = "file_sharing" } if module == "" { return nil diff --git a/api/webprojects/comments.go b/api/webprojects/comments.go index e41bfc6..3894f7f 100644 --- a/api/webprojects/comments.go +++ b/api/webprojects/comments.go @@ -11,6 +11,7 @@ import ( "github.com/mcoder2014/home_server/domain/dal" "github.com/mcoder2014/home_server/domain/db" "github.com/mcoder2014/home_server/domain/service/accounts" + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" "github.com/mcoder2014/home_server/domain/service/webcomments" service "github.com/mcoder2014/home_server/domain/service/webprojects" "github.com/mcoder2014/home_server/utils" @@ -46,6 +47,10 @@ func commentContext(c *gin.Context) { if identityErr != nil { utils.ClearBrowserSession(c) } + if _, err := resourcepasswords.Default.Authorize(ginfmt.RPCContext(c), resourcepasswords.ResourceWebProject, id, p != nil && p.UserID == project.OwnerUserID, resourcepasswords.GrantToken(c.Request, resourcepasswords.ResourceWebProject, id)); err != nil { + ginfmt.Fail(c, err) + return + } release, err := dal.QueryWebProjectRelease(id, *project.CurrentReleaseID) if err != nil || release == nil { ginfmt.Fail(c, service.ErrDependency) @@ -103,13 +108,36 @@ func comments(c *gin.Context) { } // Resolve visibility as anonymous before returning an identity error. This // keeps private, missing and disabled projects indistinguishable as 404. - if _, visibleErr := webcomments.Authorize(db.MasterDB().WithContext(c.Request.Context()), id, nil, false, false); visibleErr != nil { + _, visibleErr := webcomments.Authorize(db.MasterDB().WithContext(c.Request.Context()), id, nil, false, c.Request.Method == http.MethodGet) + if visibleErr != nil { ginfmt.Fail(c, visibleErr) return } + if !explicitCommentCredentials(c) { + if _, passwordErr := resourcepasswords.Default.Authorize(ginfmt.RPCContext(c), resourcepasswords.ResourceWebProject, id, false, resourcepasswords.GrantToken(c.Request, resourcepasswords.ResourceWebProject, id)); passwordErr != nil { + ginfmt.Fail(c, passwordErr) + return + } + } ginfmt.Fail(c, identityErr) return } + project, err := webcomments.Authorize(db.MasterDB().WithContext(c.Request.Context()), id, p, false, c.Request.Method == http.MethodGet) + if err != nil { + ginfmt.Fail(c, err) + return + } + passwordState, err := resourcepasswords.Default.Authorize(ginfmt.RPCContext(c), resourcepasswords.ResourceWebProject, id, p.UserID == project.OwnerUserID, resourcepasswords.GrantToken(c.Request, resourcepasswords.ResourceWebProject, id)) + if err != nil { + ginfmt.Fail(c, err) + return + } + if passwordState.PasswordProtected && c.Request.Method == http.MethodGet && p.UserID == project.OwnerUserID { + if _, err := webcomments.Authorize(db.MasterDB().WithContext(c.Request.Context()), id, p, false, false); err != nil { + ginfmt.Fail(c, err) + return + } + } if c.Request.Method == http.MethodPost && c.GetHeader("Authorization") == "" && c.GetHeader(middleware.HeaderKey) == "" { middleware.BrowserScopedWrite("web-comments")(c) if c.IsAborted() { @@ -184,3 +212,7 @@ func comments(c *gin.Context) { } ginfmt.Success(c, status, result) } + +func explicitCommentCredentials(c *gin.Context) bool { + return c.GetHeader("Authorization") != "" || c.GetHeader(middleware.HeaderKey) != "" +} diff --git a/api/webprojects/content.go b/api/webprojects/content.go index 41f0655..c93b865 100644 --- a/api/webprojects/content.go +++ b/api/webprojects/content.go @@ -20,7 +20,9 @@ import ( application "github.com/mcoder2014/home_server/app/webprojects" "github.com/mcoder2014/home_server/config" "github.com/mcoder2014/home_server/domain/model" + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" service "github.com/mcoder2014/home_server/domain/service/webprojects" + "github.com/mcoder2014/home_server/utils" "github.com/mcoder2014/home_server/utils/ginfmt" ) @@ -39,18 +41,26 @@ func serveProjectContent(c *gin.Context) { return } userID := int64(0) + var principal *utils.Principal explicitCredentials := c.GetHeader("Authorization") != "" || c.GetHeader(middleware.HeaderKey) != "" - if project.AccessMode != service.AccessModePublic || explicitCredentials { - principal, loginErr := middleware.ResolveIdentity(c, "web-projects:read", true, false) + hasBrowserSession := utils.BrowserSessionToken(c.Request) != "" + if project.AccessMode != service.AccessModePublic || explicitCredentials || hasBrowserSession { + resolved, loginErr := middleware.ResolveIdentity(c, "web-projects:read", true, false) if loginErr == nil { - userID = principal.UserID + principal = resolved + userID = resolved.UserID } else { - if errors.Is(loginErr, service.ErrDependency) { - ginfmt.Fail(c, loginErr) + if project.AccessMode == service.AccessModePublic && !explicitCredentials && !errors.Is(loginErr, service.ErrDependency) { + principal = nil + userID = 0 } else { - contentNotFound(c) + if errors.Is(loginErr, service.ErrDependency) { + ginfmt.Fail(c, loginErr) + } else { + contentNotFound(c) + } + return } - return } } isMember := false @@ -65,6 +75,21 @@ func serveProjectContent(c *gin.Context) { contentNotFound(c) return } + requested := strings.TrimPrefix(c.Param("path"), "/") + if requested == "" { + requested = release.EntryFile + } + _, passwordErr := resourcepasswords.Default.Authorize(ginfmt.RPCContext(c), resourcepasswords.ResourceWebProject, project.ID, principal != nil && principal.UserID == project.OwnerUserID, resourcepasswords.GrantToken(c.Request, resourcepasswords.ResourceWebProject, project.ID)) + if passwordErr != nil { + c.Header("X-Resource-Password", "required") + c.Header("X-Resource-ID", strconv.FormatInt(project.ID, 10)) + if errors.Is(passwordErr, resourcepasswords.ErrPasswordRequired) && c.Request.Method == http.MethodGet && passwordHTMLRequest(c, requested) { + servePasswordUnlockPage(c, project.ID) + } else { + ginfmt.Fail(c, passwordErr) + } + return + } if c.GetHeader("Service-Worker") != "" { ginfmt.Fail(c, service.ErrForbidden) return @@ -84,10 +109,6 @@ func serveProjectContent(c *gin.Context) { ginfmt.Fail(c, err) return } - requested := strings.TrimPrefix(c.Param("path"), "/") - if requested == "" { - requested = release.EntryFile - } isEntry := requested == release.EntryFile filePath, err := service.ResolveContentPath(contentRoot, requested) if err != nil { @@ -134,6 +155,22 @@ func serveProjectContent(c *gin.Context) { } } +func passwordHTMLRequest(c *gin.Context, requested string) bool { + extension := strings.ToLower(filepath.Ext(requested)) + return c.Param("path") == "" || extension == ".html" || extension == ".htm" || strings.Contains(strings.ToLower(c.GetHeader("Accept")), "text/html") +} + +func servePasswordUnlockPage(c *gin.Context, projectID int64) { + endpoint := "/api/web-share/" + strconv.FormatInt(projectID, 10) + "/unlock" + html := `密码阅读

此网页受密码保护

输入分享者提供的阅读密码。

` + c.Header("Cache-Control", "no-store") + c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'") + c.Header("Referrer-Policy", "no-referrer") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Data(http.StatusForbidden, "text/html; charset=utf-8", []byte(html)) +} + // downloadRelease 处理 GET /api/web-share/:id/releases/:release_id/download 及兼容入口:确认本人版本归属后生成下载包。 func downloadRelease(c *gin.Context) { projectID, err := service.ParsePositiveID(c.Param("id")) diff --git a/api/webprojects/password.go b/api/webprojects/password.go new file mode 100644 index 0000000..2d19592 --- /dev/null +++ b/api/webprojects/password.go @@ -0,0 +1,119 @@ +package webprojects + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mcoder2014/home_server/api/middleware" + application "github.com/mcoder2014/home_server/app/webprojects" + "github.com/mcoder2014/home_server/domain/db" + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" + "github.com/mcoder2014/home_server/domain/service/webcomments" + service "github.com/mcoder2014/home_server/domain/service/webprojects" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" + "github.com/mcoder2014/home_server/utils/ginfmt" +) + +func getProjectPassword(c *gin.Context) { + projectID, err := service.ParsePositiveID(c.Param("id")) + if err != nil { + ginfmt.Fail(c, err) + return + } + state, err := application.Default.PasswordState(ginfmt.RPCContext(c), currentUserID(c), projectID) + if err != nil { + ginfmt.Fail(c, err) + return + } + ginfmt.Success(c, http.StatusOK, state) +} + +func putProjectPassword(c *gin.Context) { + projectID, err := service.ParsePositiveID(c.Param("id")) + if err != nil { + ginfmt.Fail(c, err) + return + } + var input resourcepasswords.UpdateInput + if !decodePasswordJSON(c, &input) { + return + } + state, err := application.Default.SetPassword(ginfmt.RPCContext(c), currentUserID(c), projectID, input, currentPrincipal(c)) + if err != nil { + ginfmt.Fail(c, err) + return + } + ginfmt.Success(c, http.StatusOK, state) +} + +func unlockProject(c *gin.Context) { + projectID, err := service.ParsePositiveID(c.Param("id")) + if err != nil { + ginfmt.Fail(c, err) + return + } + if !middleware.IsHTTPS(c) || !middleware.BrowserOriginAllowed(c.Request) { + ginfmt.Fail(c, apperrors.ErrForbidden) + return + } + var input resourcepasswords.UnlockInput + if !decodePasswordJSON(c, &input) { + return + } + principal, err := optionalProjectReader(c) + if err != nil { + ginfmt.Fail(c, err) + return + } + project, err := webcomments.Authorize(db.MasterDB().WithContext(c.Request.Context()), projectID, principal, false, false) + if err != nil { + ginfmt.Fail(c, err) + return + } + state, grant, err := resourcepasswords.Default.Unlock(ginfmt.RPCContext(c), resourcepasswords.ResourceWebProject, project.ID, input.Password, middleware.TrustedClientIP(c.Request)) + if err != nil { + ginfmt.Fail(c, err) + return + } + if state.PasswordProtected { + http.SetCookie(c.Writer, resourcepasswords.GrantCookie(resourcepasswords.ResourceWebProject, project.ID, grant)) + } else { + http.SetCookie(c.Writer, resourcepasswords.ExpiredGrantCookie(resourcepasswords.ResourceWebProject, project.ID)) + } + ginfmt.Success(c, http.StatusOK, state) +} + +func decodePasswordJSON(c *gin.Context, value interface{}) bool { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4096) + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + ginfmt.Fail(c, service.ErrInvalid) + return false + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + ginfmt.Fail(c, service.ErrInvalid) + return false + } + return true +} + +func optionalProjectReader(c *gin.Context) (*utils.Principal, error) { + explicit := len(c.Request.Header.Values("Authorization")) > 0 || len(c.Request.Header.Values(middleware.HeaderKey)) > 0 + hasSession := utils.BrowserSessionToken(c.Request) != "" + if !explicit && !hasSession { + return nil, nil + } + principal, err := middleware.ResolveIdentity(c, "web-projects:read", true, false) + if err == nil { + return principal, nil + } + if errors.Is(err, service.ErrDependency) || explicit { + return nil, err + } + return nil, nil +} diff --git a/api/webprojects/route.go b/api/webprojects/route.go index a20f6ba..9dcc396 100644 --- a/api/webprojects/route.go +++ b/api/webprojects/route.go @@ -28,6 +28,9 @@ func InitRouter() error { data.AddRoute(http.MethodPost, prefix, requireModule, write, createProject) data.AddRoute(http.MethodGet, prefix+"/eligible-users", requireModule, read, eligibleUsers) data.AddRoute(http.MethodGet, prefix+"/:id", requireModule, read, getProject) + data.AddRoute(http.MethodGet, prefix+"/:id/password", requireModule, read, getProjectPassword) + data.AddRoute(http.MethodPut, prefix+"/:id/password", requireModule, write, putProjectPassword) + data.AddRoute(http.MethodPost, prefix+"/:id/unlock", requireModule, unlockProject) data.AddRoute(http.MethodGet, prefix+"/:id/stats", requireModule, read, projectStatistics) data.AddRoute(http.MethodPatch, prefix+"/:id", requireModule, write, updateProject) data.AddRoute(http.MethodPost, prefix+"/:id/disable", requireModule, write, disableProject) diff --git a/app/fileshare/access.go b/app/fileshare/access.go new file mode 100644 index 0000000..5ca0c0f --- /dev/null +++ b/app/fileshare/access.go @@ -0,0 +1,219 @@ +package fileshare + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" + service "github.com/mcoder2014/home_server/domain/service/fileshare" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" + "golang.org/x/crypto/bcrypt" +) + +type shareAccess struct { + Share *model.FileShare + File *model.StoredFile + Member bool +} + +type UnlockGrant struct { + UnlockedUntil time.Time `json:"unlocked_until"` + CookieName string `json:"-"` + CookieValue string `json:"-"` +} + +func (application *Application) PublicState(ctx context.Context, token string, principal *utils.Principal, request *http.Request) (*service.PublicShareState, error) { + access, err := application.resolveShare(ctx, token, principal) + if err != nil { + return nil, err + } + unlocked := application.grantAuthorized(request, access.Share, application.now()) + viewerID := int64(0) + if principal != nil { + viewerID = principal.UserID + } + return service.PublicState(snapshot(access), service.Viewer{UserID: viewerID}, unlocked, application.now()) +} + +func (application *Application) Unlock(ctx context.Context, token, secret, clientKey string, principal *utils.Principal) (*UnlockGrant, error) { + access, err := application.resolveShare(ctx, token, principal) + if err != nil { + return nil, err + } + viewerID := int64(0) + if principal != nil { + viewerID = principal.UserID + } + state, err := service.PublicState(snapshot(access), service.Viewer{UserID: viewerID}, false, application.now()) + if err != nil { + return nil, err + } + if state.State == "login_required" { + return nil, service.ErrUnauthorized + } + expiresAt := application.now().Add(time.Hour) + if access.Share.ExpiresAt != nil && access.Share.ExpiresAt.Before(expiresAt) { + expiresAt = *access.Share.ExpiresAt + } + if state.State == "available" { + return &UnlockGrant{UnlockedUntil: expiresAt}, nil + } + if !application.allowSecretAttempt(token, clientKey, application.now()) { + return nil, service.ErrRateLimited + } + select { + case application.bcryptGate <- struct{}{}: + defer func() { <-application.bcryptGate }() + default: + return nil, service.ErrRateLimited + } + if bcrypt.CompareHashAndPassword([]byte(access.Share.SecretHash), []byte(secret)) != nil { + return nil, apperrors.WithMessage(service.ErrForbidden, "分享口令错误") + } + application.clearSecretAttempts(token, clientKey) + value, err := application.signGrant(access.Share, expiresAt) + if err != nil { + return nil, service.ErrDependency + } + return &UnlockGrant{UnlockedUntil: expiresAt, CookieName: shareCookieName(token), CookieValue: value}, nil +} + +func (application *Application) resolveShare(ctx context.Context, token string, principal *utils.Principal) (*shareAccess, error) { + if !service.ValidToken(token) { + return nil, service.ErrNotFound + } + database, err := database(ctx) + if err != nil { + return nil, err + } + share, err := dal.FindShareByToken(database, token, false) + if err != nil { + return nil, service.ErrDependency + } + if share == nil || shareInvariant(share) != nil { + return nil, service.ErrNotFound + } + file, err := dal.FindFile(database, share.FileID, false) + if err != nil { + return nil, service.ErrDependency + } + if file == nil || file.OwnerUserID != share.OwnerUserID || fileInvariant(file) != nil { + return nil, service.ErrNotFound + } + active, err := ownerActive(ctx, share.OwnerUserID) + if err != nil { + return nil, err + } + if !active { + return nil, service.ErrNotFound + } + member := false + if principal != nil && principal.UserID > 0 && share.AccessMode == model.FileShareAccessMembers && principal.UserID != share.OwnerUserID { + member, err = dal.IsFileShareMember(database, share.ID, principal.UserID, false) + if err != nil { + return nil, service.ErrDependency + } + } + return &shareAccess{Share: share, File: file, Member: member}, nil +} + +func snapshot(access *shareAccess) service.ShareSnapshot { + return service.ShareSnapshot{AccessMode: access.Share.AccessMode.String(), SecretMode: access.Share.SecretMode.String(), ExpiresAt: access.Share.ExpiresAt, MaxDownloads: access.Share.MaxDownloads, DownloadCount: access.Share.DownloadCount, RevokedAt: access.Share.RevokedAt, FileDeleted: access.File.DeletedAt != nil, Member: access.Member, OwnerUserID: access.Share.OwnerUserID, FileName: access.File.OriginalName, FileSize: access.File.SizeBytes} +} + +func (application *Application) allowSecretAttempt(token, clientKey string, now time.Time) bool { + digest := sha256.Sum256([]byte(token + "\x00" + clientKey)) + key := string(digest[:]) + application.attempts.Lock() + defer application.attempts.Unlock() + entry := application.attempts.entries[key] + if !entry.ExpiresAt.After(now) { + if len(application.attempts.entries) >= 4096 { + for existingKey, existing := range application.attempts.entries { + if !existing.ExpiresAt.After(now) { + delete(application.attempts.entries, existingKey) + } + } + } + if len(application.attempts.entries) >= 4096 { + return false + } + entry = attemptEntry{ExpiresAt: now.Add(5 * time.Minute)} + } + if entry.Count >= 8 { + return false + } + entry.Count++ + application.attempts.entries[key] = entry + return true +} + +func (application *Application) clearSecretAttempts(token, clientKey string) { + digest := sha256.Sum256([]byte(token + "\x00" + clientKey)) + application.attempts.Lock() + delete(application.attempts.entries, string(digest[:])) + application.attempts.Unlock() +} + +func shareCookieName(token string) string { + digest := sha256.Sum256([]byte(token)) + return "hs_fs_" + fmt.Sprintf("%x", digest[:8]) +} + +func (application *Application) signGrant(share *model.FileShare, expiresAt time.Time) (string, error) { + if share == nil || expiresAt.IsZero() { + return "", service.ErrDependency + } + tokenDigest := sha256.Sum256([]byte(share.Token)) + payload := strconv.FormatInt(share.ID, 10) + "." + base64.RawURLEncoding.EncodeToString(tokenDigest[:]) + "." + strconv.FormatInt(expiresAt.Unix(), 10) + mac := hmac.New(sha256.New, application.cookieKey) + _, _ = mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +func (application *Application) grantAuthorized(request *http.Request, share *model.FileShare, now time.Time) bool { + if request == nil || share == nil { + return false + } + cookie, err := request.Cookie(shareCookieName(share.Token)) + if err != nil || len(cookie.Value) > 512 { + return false + } + parts := strings.Split(cookie.Value, ".") + if len(parts) != 2 { + return false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return false + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return false + } + mac := hmac.New(sha256.New, application.cookieKey) + _, _ = mac.Write(payload) + if subtle.ConstantTimeCompare(signature, mac.Sum(nil)) != 1 { + return false + } + fields := strings.Split(string(payload), ".") + if len(fields) != 3 || fields[0] != strconv.FormatInt(share.ID, 10) { + return false + } + expectedDigest := sha256.Sum256([]byte(share.Token)) + if subtle.ConstantTimeCompare([]byte(fields[1]), []byte(base64.RawURLEncoding.EncodeToString(expectedDigest[:]))) != 1 { + return false + } + expires, err := strconv.ParseInt(fields[2], 10, 64) + return err == nil && time.Unix(expires, 0).After(now) +} diff --git a/app/fileshare/application.go b/app/fileshare/application.go new file mode 100644 index 0000000..5f9faf9 --- /dev/null +++ b/app/fileshare/application.go @@ -0,0 +1,107 @@ +package fileshare + +import ( + "context" + "crypto/rand" + "errors" + "sync" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/domain/db" + service "github.com/mcoder2014/home_server/domain/service/fileshare" + apperrors "github.com/mcoder2014/home_server/errors" + "gorm.io/gorm" +) + +type Application struct { + ownerLocks [64]sync.Mutex + uploads struct { + sync.Mutex + byUser map[int64]int + global int + reservedBytes uint64 + } + attempts struct { + sync.Mutex + entries map[string]attemptEntry + } + bcryptGate chan struct{} + cookieKey []byte + now func() time.Time + diskFree func(string) (uint64, error) +} + +type attemptEntry struct { + Count int + ExpiresAt time.Time +} + +var Default = New() + +func New() *Application { + application := &Application{bcryptGate: make(chan struct{}, 4), now: time.Now, diskFree: service.DiskFreeBytes} + application.uploads.byUser = make(map[int64]int) + application.attempts.entries = make(map[string]attemptEntry) + application.cookieKey = make([]byte, 32) + if _, err := rand.Read(application.cookieKey); err != nil { + panic("cannot initialize file share cookie signer") + } + return application +} + +func database(ctx context.Context) (*gorm.DB, error) { + if db.MasterDB() == nil { + return nil, service.ErrDependency + } + return db.MasterDB().WithContext(ctx), nil +} + +func (application *Application) lockOwner(ownerID int64) func() { + index := uint64(ownerID) % uint64(len(application.ownerLocks)) + application.ownerLocks[index].Lock() + return application.ownerLocks[index].Unlock +} + +func persistenceError(err error) error { + if err == nil { + return nil + } + var apiError *apperrors.APIError + if errors.As(err, &apiError) { + return err + } + var mysqlError *mysql.MySQLError + if errors.As(err, &mysqlError) && mysqlError.Number == 1062 { + return service.ErrConflict + } + return service.ErrDependency +} + +func (application *Application) AcquireUpload(conf config.FileSharingConfig, ownerID int64) (func(), error) { + application.uploads.Lock() + defer application.uploads.Unlock() + if application.uploads.byUser[ownerID] >= conf.MaxConcurrentUploadsPerUser || application.uploads.global >= conf.MaxConcurrentUploads { + return nil, service.ErrRateLimited + } + free, err := application.diskFree(conf.StorageRoot) + reserve := uint64(conf.MaxFileBytes) + minimum := uint64(conf.MinFreeDiskBytes) + if err != nil || free < minimum || free-minimum < application.uploads.reservedBytes || free-minimum-application.uploads.reservedBytes < reserve { + return nil, service.ErrRateLimited + } + application.uploads.byUser[ownerID]++ + application.uploads.global++ + application.uploads.reservedBytes += reserve + return func() { + application.uploads.Lock() + defer application.uploads.Unlock() + application.uploads.byUser[ownerID]-- + if application.uploads.byUser[ownerID] == 0 { + delete(application.uploads.byUser, ownerID) + } + application.uploads.global-- + application.uploads.reservedBytes -= reserve + }, nil +} diff --git a/app/fileshare/download.go b/app/fileshare/download.go new file mode 100644 index 0000000..f9ac067 --- /dev/null +++ b/app/fileshare/download.go @@ -0,0 +1,182 @@ +package fileshare + +import ( + "context" + "net/http" + "os" + "sort" + "time" + + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" + "github.com/mcoder2014/home_server/domain/service/accounts" + service "github.com/mcoder2014/home_server/domain/service/fileshare" + "github.com/mcoder2014/home_server/domain/service/passport" + "github.com/mcoder2014/home_server/utils" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Download struct { + File *os.File + Info os.FileInfo + Filename string +} + +// PrepareDownload opens and validates immutable bytes first, then rechecks the +// module, identities, file, share, ACL, grant and limit under row locks. The +// transaction commits the count before the caller streams; interrupted clients +// deliberately do not receive a refund. +func (application *Application) PrepareDownload(ctx context.Context, token string, principal *utils.Principal, request *http.Request) (*Download, error) { + access, err := application.resolveShare(ctx, token, principal) + if err != nil { + return nil, err + } + if err := application.requireAvailable(access, principal, request); err != nil { + return nil, err + } + conf := config.Global().FileSharing + file, info, err := service.OpenStoredFile(&conf, access.File.StorageKey) + if err != nil { + return nil, err + } + if info.Size() != access.File.SizeBytes { + _ = file.Close() + return nil, service.ErrDependency + } + database, err := database(ctx) + if err != nil { + _ = file.Close() + return nil, err + } + err = database.Transaction(func(tx *gorm.DB) error { + if err := lockDownloadActors(ctx, tx, access.Share.OwnerUserID, principal); err != nil { + return err + } + currentFile, err := dal.FindFile(tx, access.File.ID, true) + if err != nil { + return err + } + if currentFile == nil || currentFile.DeletedAt != nil || currentFile.OwnerUserID != access.Share.OwnerUserID || currentFile.StorageKey != access.File.StorageKey || currentFile.SizeBytes != info.Size() || currentFile.SHA256 != access.File.SHA256 { + return service.ErrNotFound + } + currentShare, err := dal.FindShare(tx, access.Share.ID, true) + if err != nil { + return err + } + if currentShare == nil || currentShare.Token != token || currentShare.FileID != currentFile.ID || currentShare.OwnerUserID != currentFile.OwnerUserID || shareInvariant(currentShare) != nil { + return service.ErrNotFound + } + member := false + if currentShare.AccessMode == model.FileShareAccessMembers && principal != nil && principal.UserID != currentShare.OwnerUserID { + member, err = dal.IsFileShareMember(tx, currentShare.ID, principal.UserID, true) + if err != nil { + return err + } + } + current := &shareAccess{Share: currentShare, File: currentFile, Member: member} + if err := application.requireAvailable(current, principal, request); err != nil { + return err + } + updated, err := dal.IncrementDownloadCount(tx, currentShare.ID, application.now()) + if err != nil { + return err + } + if !updated { + return service.ErrNotFound + } + return nil + }) + if err != nil { + _ = file.Close() + return nil, persistenceError(err) + } + return &Download{File: file, Info: info, Filename: access.File.OriginalName}, nil +} + +func (application *Application) requireAvailable(access *shareAccess, principal *utils.Principal, request *http.Request) error { + viewerID := int64(0) + if principal != nil { + viewerID = principal.UserID + } + unlocked := application.grantAuthorized(request, access.Share, application.now()) + state, err := service.PublicState(snapshot(access), service.Viewer{UserID: viewerID}, unlocked, application.now()) + if err != nil { + return err + } + switch state.State { + case "available": + return nil + case "login_required": + return service.ErrUnauthorized + default: + return service.ErrForbidden + } +} + +func lockDownloadActors(ctx context.Context, tx *gorm.DB, ownerID int64, principal *utils.Principal) error { + if !config.Global().FileSharing.Enabled { + return service.ErrNotFound + } + if !accounts.DatabaseMode() { + owner, err := passport.GetByID(ctx, ownerID) + if err != nil { + return service.ErrDependency + } + if owner == nil { + return service.ErrNotFound + } + if principal == nil { + return nil + } + if principal.TokenExpiresAt.IsZero() || !principal.TokenExpiresAt.After(time.Now()) { + return service.ErrUnauthorized + } + if principal.Kind == "user" { + return passport.RequireConfigSessionTx(ctx, tx, utils.GetTokenFromCtx(ctx), principal.UserID, principal.AuthVersion) + } + return accounts.RequireApplicationSnapshotTx(tx, principal, "files:read") + } + if principal != nil && principal.Kind == "application" { + enabled, err := accounts.EnabledTx(tx, "auth", "applications_enabled", true) + if err != nil || !enabled { + if err != nil { + return err + } + return service.ErrForbidden + } + } + ids := []int64{ownerID} + if principal != nil && principal.UserID != ownerID { + ids = append(ids, principal.UserID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + var users []model.UserAccount + err := tx.Table(dal.AccountTable).Select("id", "status", "must_change_password", "auth_version").Where("id IN ?", ids).Order("id ASC").Clauses(clause.Locking{Strength: "UPDATE"}).Find(&users).Error + if err != nil { + return service.ErrDependency + } + if len(users) != len(ids) { + return service.ErrNotFound + } + for _, user := range users { + if user.ID == ownerID && (user.Status != model.AccountActive || user.MustChangePassword) { + return service.ErrNotFound + } + if principal != nil && user.ID == principal.UserID && (user.Status != model.AccountActive || user.MustChangePassword || user.AuthVersion != principal.AuthVersion) { + return service.ErrUnauthorized + } + } + if principal == nil { + return nil + } + if principal.TokenExpiresAt.IsZero() || !principal.TokenExpiresAt.After(time.Now()) { + return service.ErrUnauthorized + } + if principal.Kind == "user" { + _, err = accounts.RequireUserSessionTx(tx, utils.GetTokenFromCtx(ctx), principal.UserID) + return err + } + return accounts.RequireApplicationSnapshotTx(tx, principal, "files:read") +} diff --git a/app/fileshare/files.go b/app/fileshare/files.go new file mode 100644 index 0000000..7bd711e --- /dev/null +++ b/app/fileshare/files.go @@ -0,0 +1,258 @@ +package fileshare + +import ( + "context" + "errors" + "fmt" + "io" + "sort" + "strconv" + + "github.com/go-sql-driver/mysql" + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" + "github.com/mcoder2014/home_server/domain/service/accounts" + service "github.com/mcoder2014/home_server/domain/service/fileshare" + "github.com/mcoder2014/home_server/domain/service/passport" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func (application *Application) Upload(ctx context.Context, principal *utils.Principal, originalName string, source io.Reader) (*service.FileView, error) { + if principal == nil || principal.UserID <= 0 { + return nil, service.ErrUnauthorized + } + conf := config.Global().FileSharing + release, err := application.AcquireUpload(conf, principal.UserID) + if err != nil { + return nil, err + } + defer release() + return application.UploadReserved(ctx, principal, originalName, source, nil) +} + +// UploadReserved consumes a slot already acquired by the HTTP boundary. The +// completion callback validates the multipart terminator after StoreUpload has +// consumed the only file part and before any metadata transaction can commit. +func (application *Application) UploadReserved(ctx context.Context, principal *utils.Principal, originalName string, source io.Reader, complete func() error) (*service.FileView, error) { + if principal == nil || principal.UserID <= 0 { + return nil, service.ErrUnauthorized + } + name, err := service.SafeFilename(originalName) + if err != nil { + return nil, err + } + conf := config.Global().FileSharing + database, err := database(ctx) + if err != nil { + return nil, err + } + if err := checkQuota(database, &conf, principal.UserID, 0); err != nil { + return nil, err + } + fileID := utils.GenInt64ID() + stored, err := service.StoreUpload(&conf, principal.UserID, fileID, source) + if err != nil { + return nil, service.ClassifyStorageError(err) + } + if complete != nil { + if err := complete(); err != nil { + _ = service.RemoveStoredFile(&conf, stored.StorageKey) + return nil, err + } + } + file := &model.StoredFile{ID: fileID, OwnerUserID: principal.UserID, OriginalName: name, StorageKey: stored.StorageKey, SizeBytes: stored.SizeBytes, SHA256: stored.SHA256, CreateTime: application.now()} + unlock := application.lockOwner(principal.UserID) + defer unlock() + err = database.Transaction(func(tx *gorm.DB) error { + if err := requireWritePolicy(ctx, tx, principal, nil); err != nil { + return err + } + if err := checkQuota(tx, &conf, principal.UserID, file.SizeBytes); err != nil { + return err + } + return dal.InsertFile(tx, file) + }) + if err != nil { + return resolveUploadCommit(ctx, database, &conf, file, err) + } + return fileView(file, 0), nil +} + +// resolveUploadCommit removes generated bytes only after proving the row did +// not commit. A database error can include an unknown commit result; in that +// case a failed read deliberately leaves the private bytes in place. +func resolveUploadCommit(ctx context.Context, database *gorm.DB, conf *config.FileSharingConfig, file *model.StoredFile, transactionErr error) (*service.FileView, error) { + var apiError *apperrors.APIError + var mysqlError *mysql.MySQLError + if errors.As(transactionErr, &apiError) || errors.As(transactionErr, &mysqlError) && mysqlError.Number == 1062 { + _ = service.RemoveStoredFile(conf, file.StorageKey) + return nil, persistenceError(transactionErr) + } + existing, queryErr := dal.FindFile(database.WithContext(ctx), file.ID, false) + if queryErr != nil { + return nil, service.ErrDependency + } + if existing == nil { + _ = service.RemoveStoredFile(conf, file.StorageKey) + return nil, service.ErrDependency + } + if existing.StorageKey != file.StorageKey { + _ = service.RemoveStoredFile(conf, file.StorageKey) + return nil, service.ErrConflict + } + if existing.OwnerUserID != file.OwnerUserID || existing.OriginalName != file.OriginalName || existing.SizeBytes != file.SizeBytes || existing.SHA256 != file.SHA256 || existing.DeletedAt != nil { + return nil, service.ErrDependency + } + return fileView(existing, 0), nil +} + +func checkQuota(database *gorm.DB, conf *config.FileSharingConfig, ownerID, addingBytes int64) error { + count, err := dal.CountOwnedFiles(database, ownerID) + if err != nil { + return service.ErrDependency + } + bytes, err := dal.SumOwnedFileBytes(database, ownerID) + if err != nil { + return service.ErrDependency + } + if count >= int64(conf.MaxFilesPerUser) || addingBytes < 0 || addingBytes > conf.MaxUserBytes || bytes > conf.MaxUserBytes-addingBytes { + return service.ErrRateLimited + } + return nil +} + +func (application *Application) List(ctx context.Context, ownerID, cursor int64, limit int) (*service.FilePage, error) { + if ownerID <= 0 || cursor < 0 || limit < 1 || limit > 100 { + return nil, service.ErrInvalid + } + database, err := database(ctx) + if err != nil { + return nil, err + } + files, err := dal.ListOwnedFiles(database, ownerID, cursor, limit+1) + if err != nil { + return nil, service.ErrDependency + } + page := &service.FilePage{Items: []*service.FileView{}} + if len(files) > limit { + page.HasMore = true + files = files[:limit] + } + ids := make([]int64, 0, len(files)) + for _, file := range files { + ids = append(ids, file.ID) + } + counts, err := dal.CountFileShares(database, ids) + if err != nil { + return nil, service.ErrDependency + } + for _, file := range files { + page.Items = append(page.Items, fileView(file, counts[file.ID])) + } + if page.HasMore { + page.NextCursor = strconv.FormatInt(files[len(files)-1].ID, 10) + } + return page, nil +} + +func (application *Application) Get(ctx context.Context, ownerID, fileID int64) (*service.FileView, error) { + database, err := database(ctx) + if err != nil { + return nil, err + } + file, err := dal.FindOwnedFile(database, ownerID, fileID, false, false) + if err != nil { + return nil, service.ErrDependency + } + if file == nil { + return nil, service.ErrNotFound + } + counts, err := dal.CountFileShares(database, []int64{file.ID}) + if err != nil { + return nil, service.ErrDependency + } + return fileView(file, counts[file.ID]), nil +} + +func (application *Application) EligibleUsers(ctx context.Context) ([]service.EligibleUser, error) { + if accounts.DatabaseMode() { + users, err := accounts.ListActiveUsers(ctx) + if err != nil { + return nil, service.ErrDependency + } + result := make([]service.EligibleUser, 0, len(users)) + for _, user := range users { + display := accounts.DisplayUser(user) + result = append(result, service.EligibleUser{ID: strconv.FormatInt(user.ID, 10), UserName: display.UserName, DisplayName: display.DisplayName, AvatarURL: display.AvatarURL}) + } + return result, nil + } + users, err := passport.ListUsersWithError(ctx) + if err != nil { + return nil, service.ErrDependency + } + result := make([]service.EligibleUser, 0, len(users)) + for _, user := range users { + if user != nil && user.ID > 0 { + result = append(result, service.EligibleUser{ID: strconv.FormatInt(user.ID, 10), UserName: user.UserName}) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result, nil +} + +func (application *Application) Delete(ctx context.Context, principal *utils.Principal, fileID int64) (map[string]interface{}, error) { + if principal == nil || principal.UserID <= 0 || fileID <= 0 { + return nil, service.ErrInvalid + } + database, err := database(ctx) + if err != nil { + return nil, err + } + var key string + deletedAt := application.now() + err = database.Transaction(func(tx *gorm.DB) error { + if err := requireWritePolicy(ctx, tx, principal, nil); err != nil { + return err + } + file, err := dal.FindOwnedFile(tx, principal.UserID, fileID, false, true) + if err != nil { + return err + } + if file == nil { + return service.ErrNotFound + } + key = file.StorageKey + updated, err := dal.DeleteOwnedFile(tx, principal.UserID, fileID, deletedAt) + if err != nil { + return err + } + if !updated { + return service.ErrConflict + } + return nil + }) + if err != nil { + return nil, persistenceError(err) + } + conf := config.Global().FileSharing + if err := service.RemoveStoredFile(&conf, key); err != nil { + logrus.WithField("file_id", fileID).Error("deleted file metadata but could not remove private bytes") + } + return map[string]interface{}{"id": strconv.FormatInt(fileID, 10), "deleted_at": deletedAt}, nil +} + +func fileView(file *model.StoredFile, shareCount int64) *service.FileView { + return &service.FileView{ID: strconv.FormatInt(file.ID, 10), Name: file.OriginalName, SizeBytes: file.SizeBytes, SHA256: file.SHA256, ShareCount: shareCount, DeletedAt: file.DeletedAt, CreateTime: file.CreateTime} +} + +func fileInvariant(file *model.StoredFile) error { + if file == nil || file.ID <= 0 || file.OwnerUserID <= 0 || file.SizeBytes < 0 || len(file.SHA256) != 64 || file.StorageKey == "" { + return fmt.Errorf("invalid stored file metadata") + } + return nil +} diff --git a/app/fileshare/policy.go b/app/fileshare/policy.go new file mode 100644 index 0000000..97f3265 --- /dev/null +++ b/app/fileshare/policy.go @@ -0,0 +1,113 @@ +package fileshare + +import ( + "context" + "sort" + "time" + + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" + "github.com/mcoder2014/home_server/domain/service/accounts" + service "github.com/mcoder2014/home_server/domain/service/fileshare" + "github.com/mcoder2014/home_server/domain/service/passport" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func requireWritePolicy(ctx context.Context, tx *gorm.DB, principal *utils.Principal, memberIDs []int64) error { + if !config.Global().FileSharing.Enabled { + return apperrors.WithMessage(service.ErrForbidden, "功能暂未开放") + } + if principal == nil || principal.UserID <= 0 || (principal.Kind != "user" && principal.Kind != "application") || principal.TokenExpiresAt.IsZero() || !principal.TokenExpiresAt.After(time.Now()) { + return service.ErrUnauthorized + } + if principal.Kind == "application" { + enabled, err := accounts.EnabledTx(tx, "auth", "applications_enabled", true) + if err != nil || !enabled { + if err != nil { + return err + } + return service.ErrForbidden + } + } + if !accounts.DatabaseMode() { + // Every request for one config-backed owner locks the stable oldest + // session and application rows before checking its exact credential. The + // two anchors serialize quota writes across credential kinds and service + // instances without scanning unbounded expired-session history. + var sessionIDs, applicationIDs []int64 + if err := tx.Table(dal.TableUserToken).Where("user_id = ?", principal.UserID).Order("id ASC").Limit(1).Clauses(clause.Locking{Strength: "UPDATE"}).Pluck("id", &sessionIDs).Error; err != nil { + return service.ErrDependency + } + if err := tx.Table(dal.ApplicationTable).Where("owner_user_id = ?", principal.UserID).Order("id ASC").Limit(1).Clauses(clause.Locking{Strength: "UPDATE"}).Pluck("id", &applicationIDs).Error; err != nil { + return service.ErrDependency + } + if principal.Kind == "user" { + if err := passport.RequireConfigSessionTx(ctx, tx, utils.GetTokenFromCtx(ctx), principal.UserID, principal.AuthVersion); err != nil { + return err + } + } else if err := accounts.RequireApplicationSnapshotTx(tx, principal, "files:write"); err != nil { + return err + } + for _, userID := range memberIDs { + identity, err := passport.GetByID(ctx, userID) + if err != nil { + return service.ErrDependency + } + if identity == nil { + return service.ErrInvalid + } + } + return nil + } + ids := append([]int64{principal.UserID}, memberIDs...) + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + deduplicated := ids[:0] + for _, id := range ids { + if len(deduplicated) == 0 || deduplicated[len(deduplicated)-1] != id { + deduplicated = append(deduplicated, id) + } + } + var users []model.UserAccount + err := tx.Table(dal.AccountTable).Select("id", "status", "must_change_password", "auth_version").Where("id IN ?", deduplicated).Order("id ASC").Clauses(clause.Locking{Strength: "UPDATE"}).Find(&users).Error + if err != nil { + return service.ErrDependency + } + if len(users) != len(deduplicated) { + return service.ErrInvalid + } + for _, user := range users { + if user.Status != model.AccountActive || user.MustChangePassword { + if user.ID == principal.UserID { + return service.ErrUnauthorized + } + return service.ErrInvalid + } + if user.ID == principal.UserID && user.AuthVersion != principal.AuthVersion { + return service.ErrUnauthorized + } + } + if principal.Kind == "user" { + _, err = accounts.RequireUserSessionTx(tx, utils.GetTokenFromCtx(ctx), principal.UserID) + return err + } + return accounts.RequireApplicationSnapshotTx(tx, principal, "files:write") +} + +func ownerActive(ctx context.Context, ownerID int64) (bool, error) { + if accounts.DatabaseMode() { + owner, err := accounts.GetByID(ctx, ownerID) + if err != nil { + return false, err + } + return owner != nil && owner.Status == model.AccountActive && !owner.MustChangePassword, nil + } + owner, err := passport.GetByID(ctx, ownerID) + if err != nil { + return false, service.ErrDependency + } + return owner != nil, nil +} diff --git a/app/fileshare/shares.go b/app/fileshare/shares.go new file mode 100644 index 0000000..f4ee907 --- /dev/null +++ b/app/fileshare/shares.go @@ -0,0 +1,189 @@ +package fileshare + +import ( + "context" + "crypto/rand" + "fmt" + "strconv" + "strings" + + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" + service "github.com/mcoder2014/home_server/domain/service/fileshare" + "github.com/mcoder2014/home_server/utils" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +func (application *Application) ListShares(ctx context.Context, ownerID, fileID, cursor int64, limit int) (*service.SharePage, error) { + if ownerID <= 0 || fileID <= 0 || cursor < 0 || limit < 1 || limit > 100 { + return nil, service.ErrInvalid + } + database, err := database(ctx) + if err != nil { + return nil, err + } + file, err := dal.FindOwnedFile(database, ownerID, fileID, false, false) + if err != nil { + return nil, service.ErrDependency + } + if file == nil { + return nil, service.ErrNotFound + } + shares, err := dal.ListOwnedShares(database, ownerID, fileID, cursor, limit+1) + if err != nil { + return nil, service.ErrDependency + } + page := &service.SharePage{Items: []*service.ShareView{}} + if len(shares) > limit { + page.HasMore = true + shares = shares[:limit] + } + ids := make([]int64, 0, len(shares)) + for _, share := range shares { + ids = append(ids, share.ID) + } + members, err := dal.ListShareMembers(database, ids) + if err != nil { + return nil, service.ErrDependency + } + for _, share := range shares { + page.Items = append(page.Items, shareView(share, members[share.ID])) + } + if page.HasMore { + page.NextCursor = strconv.FormatInt(shares[len(shares)-1].ID, 10) + } + return page, nil +} + +func (application *Application) CreateShare(ctx context.Context, principal *utils.Principal, fileID int64, input service.CreateShareInput) (*service.CreateShareResult, error) { + if principal == nil || principal.UserID <= 0 || fileID <= 0 { + return nil, service.ErrInvalid + } + normalized, err := service.NormalizeShareInput(input, rand.Reader, application.now()) + if err != nil { + return nil, err + } + memberIDs := normalized.MemberUserIDs[:0] + for _, userID := range normalized.MemberUserIDs { + if userID != principal.UserID { + memberIDs = append(memberIDs, userID) + } + } + if normalized.AccessMode == model.FileShareAccessMembers && len(memberIDs) == 0 { + return nil, service.ErrInvalid + } + secretHash := "" + if normalized.SecretMode != model.FileShareSecretNone { + cost := config.Runtime().AccountPolicy.BcryptCost + if cost < bcrypt.MinCost || cost > bcrypt.MaxCost { + cost = bcrypt.DefaultCost + } + hash, err := bcrypt.GenerateFromPassword([]byte(normalized.Secret), cost) + if err != nil { + return nil, service.ErrDependency + } + secretHash = string(hash) + } + token, err := service.NewToken(rand.Reader) + if err != nil { + return nil, service.ErrDependency + } + now := application.now() + share := &model.FileShare{ID: utils.GenInt64ID(), FileID: fileID, OwnerUserID: principal.UserID, Token: token, AccessMode: normalized.AccessMode, SecretMode: normalized.SecretMode, SecretHash: secretHash, ExpiresAt: normalized.ExpiresAt, MaxDownloads: normalized.MaxDownloads, CreateTime: now, UpdateTime: now} + database, err := database(ctx) + if err != nil { + return nil, err + } + err = database.Transaction(func(tx *gorm.DB) error { + if err := requireWritePolicy(ctx, tx, principal, memberIDs); err != nil { + return err + } + file, err := dal.FindOwnedFile(tx, principal.UserID, fileID, false, true) + if err != nil { + return err + } + if file == nil { + return service.ErrNotFound + } + var count int64 + if err := tx.Table(dal.FileShareTable).Where("file_id = ?", fileID).Count(&count).Error; err != nil { + return err + } + if count >= 1000 { + return service.ErrRateLimited + } + return dal.InsertFileShare(tx, share, memberIDs) + }) + if err != nil { + return nil, persistenceError(err) + } + return &service.CreateShareResult{Share: shareView(share, memberIDs), Secret: normalized.Secret}, nil +} + +func (application *Application) RevokeShare(ctx context.Context, principal *utils.Principal, fileID, shareID int64) (map[string]interface{}, error) { + if principal == nil || principal.UserID <= 0 || fileID <= 0 || shareID <= 0 { + return nil, service.ErrInvalid + } + database, err := database(ctx) + if err != nil { + return nil, err + } + var revokedAt = application.now() + err = database.Transaction(func(tx *gorm.DB) error { + if err := requireWritePolicy(ctx, tx, principal, nil); err != nil { + return err + } + file, err := dal.FindOwnedFile(tx, principal.UserID, fileID, false, true) + if err != nil { + return err + } + if file == nil { + return service.ErrNotFound + } + share, err := dal.FindOwnedShare(tx, principal.UserID, fileID, shareID, true) + if err != nil { + return err + } + if share == nil { + return service.ErrNotFound + } + if share.RevokedAt != nil { + revokedAt = *share.RevokedAt + return nil + } + updated, err := dal.RevokeOwnedShare(tx, principal.UserID, fileID, shareID, revokedAt) + if err != nil { + return err + } + if !updated { + return service.ErrConflict + } + return nil + }) + if err != nil { + return nil, persistenceError(err) + } + return map[string]interface{}{"id": strconv.FormatInt(shareID, 10), "revoked_at": revokedAt}, nil +} + +func shareView(share *model.FileShare, memberIDs []int64) *service.ShareView { + members := make([]string, 0, len(memberIDs)) + for _, userID := range memberIDs { + members = append(members, strconv.FormatInt(userID, 10)) + } + base := strings.TrimSuffix(config.Global().Auth.SiteOrigin, "/") + url := "/s/" + share.Token + if base != "" { + url = base + url + } + return &service.ShareView{ID: strconv.FormatInt(share.ID, 10), FileID: strconv.FormatInt(share.FileID, 10), Token: share.Token, URL: url, AccessMode: share.AccessMode.String(), MemberUserIDs: members, SecretMode: share.SecretMode.String(), ExpiresAt: share.ExpiresAt, MaxDownloads: share.MaxDownloads, DownloadCount: share.DownloadCount, RevokedAt: share.RevokedAt, CreateTime: share.CreateTime} +} + +func shareInvariant(share *model.FileShare) error { + if share == nil || share.ID <= 0 || share.FileID <= 0 || share.OwnerUserID <= 0 || !service.ValidToken(share.Token) || share.AccessMode.String() == "" || share.SecretMode.String() == "" || share.DownloadCount < 0 || share.MaxDownloads < 0 { + return fmt.Errorf("invalid file share metadata") + } + return nil +} diff --git a/app/manuals/password.go b/app/manuals/password.go new file mode 100644 index 0000000..6bdb2c1 --- /dev/null +++ b/app/manuals/password.go @@ -0,0 +1,65 @@ +package manuals + +import ( + "context" + + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" + "gorm.io/gorm" +) + +func (application *Application) PasswordState(ctx context.Context, ownerID, manualID int64) (*resourcepasswords.State, error) { + database, err := database(ctx) + if err != nil { + return nil, err + } + manual, err := dal.FindManual(database, manualID, false) + if err != nil { + return nil, apperrors.ErrDependency + } + if manual == nil || manual.OwnerUserID != ownerID || manual.Status == model.ManualStatusDeleted { + return nil, apperrors.ErrNotFound + } + return resourcepasswords.Default.State(ctx, resourcepasswords.ResourceManual, manualID) +} + +func (application *Application) SetPassword(ctx context.Context, ownerID, manualID int64, input resourcepasswords.UpdateInput, principal *utils.Principal) (*resourcepasswords.State, error) { + if input.Version < 0 { + return nil, apperrors.ErrInvalid + } + passwordHash, err := resourcepasswords.PrepareManagedPassword(input.Password) + if err != nil { + return nil, err + } + database, err := database(ctx) + if err != nil { + return nil, err + } + var state *resourcepasswords.State + unlock := application.lockOwner(ownerID) + defer unlock() + err = database.Transaction(func(tx *gorm.DB) error { + if err := requireWritePolicy(ctx, tx, ownerID, principal); err != nil { + return err + } + manual, err := dal.FindManual(tx, manualID, true) + if err != nil { + return err + } + if manual == nil || manual.OwnerUserID != ownerID || manual.Status == model.ManualStatusDeleted { + return apperrors.ErrNotFound + } + if err := checkWriteExpiry(principal); err != nil { + return err + } + state, err = resourcepasswords.SetTx(tx, resourcepasswords.ResourceManual, manualID, input.Version, passwordHash) + return err + }) + if err != nil { + return nil, persistenceError(err) + } + return state, nil +} diff --git a/app/manuals/read.go b/app/manuals/read.go index 2a9d6fe..1113b24 100644 --- a/app/manuals/read.go +++ b/app/manuals/read.go @@ -12,6 +12,7 @@ import ( "github.com/mcoder2014/home_server/domain/service/accounts" service "github.com/mcoder2014/home_server/domain/service/manuals" "github.com/mcoder2014/home_server/domain/service/passport" + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" apperrors "github.com/mcoder2014/home_server/errors" "github.com/mcoder2014/home_server/utils" "gorm.io/gorm" @@ -47,12 +48,28 @@ func (application *Application) List(ctx context.Context, viewerID int64, mine b page.HasMore = true manualRows = manualRows[:limit] } + manualIDs := make([]int64, 0, len(manualRows)) + for _, manual := range manualRows { + manualIDs = append(manualIDs, manual.ID) + } + passwordStates, err := resourcepasswords.Default.States(ctx, resourcepasswords.ResourceManual, manualIDs) + if err != nil { + return nil, err + } counts, covers, categories, err := listDecorations(database, manualRows) if err != nil { return nil, err } for _, manual := range manualRows { - page.Items = append(page.Items, manualView(manual, categories[manual.ID], counts[manual.ID], covers[manual.ID], nil, principal)) + protected := passwordStates[manual.ID].PasswordProtected + view := manualView(manual, categories[manual.ID], counts[manual.ID], covers[manual.ID], nil, principal, protected) + if protected && viewerID != manual.OwnerUserID { + view.Description = "" + view.CoverItemID = nil + view.CoverURL = "" + view.Cover = nil + } + page.Items = append(page.Items, view) } if page.HasMore { page.NextCursor = strconv.FormatInt(manualRows[len(manualRows)-1].ID, 10) @@ -99,25 +116,23 @@ func (application *Application) Categories(ctx context.Context, viewerID int64, return page, nil } -func (application *Application) Get(ctx context.Context, manualID, viewerID int64, principal *utils.Principal) (*service.ManualView, error) { +func (application *Application) Get(ctx context.Context, manualID, viewerID int64, principal *utils.Principal, grantTokens ...string) (*service.ManualView, error) { database, err := database(ctx) if err != nil { return nil, err } - manual, err := dal.FindManual(database, manualID, false) + manual, err := findReadableManual(ctx, database, manualID, viewerID) if err != nil { - return nil, apperrors.ErrDependency + return nil, err } - if manual == nil { - return nil, apperrors.ErrNotFound + grantToken := "" + if len(grantTokens) > 0 { + grantToken = grantTokens[0] } - active, err := ownerActive(ctx, manual.OwnerUserID) + passwordState, err := resourcepasswords.Default.Authorize(ctx, resourcepasswords.ResourceManual, manual.ID, viewerID == manual.OwnerUserID, grantToken) if err != nil { return nil, err } - if !active || !service.CanReadManual(manual.AccessMode, manual.Status, manual.OwnerUserID, viewerID) { - return nil, apperrors.ErrNotFound - } items, err := dal.ListManualItems(database, manual.ID, false) if err != nil { return nil, apperrors.ErrDependency @@ -127,7 +142,33 @@ func (application *Application) Get(ctx context.Context, manualID, viewerID int6 return nil, apperrors.ErrDependency } cover := chooseDetailCover(manual.CoverItemID, items) - return manualView(manual, categoriesByManualID(categoryRows)[manual.ID], len(items), cover, items, principal), nil + return manualView(manual, categoriesByManualID(categoryRows)[manual.ID], len(items), cover, items, principal, passwordState.PasswordProtected), nil +} + +func (application *Application) ReadableManual(ctx context.Context, manualID, viewerID int64) (*model.Manual, error) { + database, err := database(ctx) + if err != nil { + return nil, err + } + return findReadableManual(ctx, database, manualID, viewerID) +} + +func findReadableManual(ctx context.Context, database *gorm.DB, manualID, viewerID int64) (*model.Manual, error) { + manual, err := dal.FindManual(database, manualID, false) + if err != nil { + return nil, apperrors.ErrDependency + } + if manual == nil { + return nil, apperrors.ErrNotFound + } + active, err := ownerActive(ctx, manual.OwnerUserID) + if err != nil { + return nil, err + } + if !active || !service.CanReadManual(manual.AccessMode, manual.Status, manual.OwnerUserID, viewerID) { + return nil, apperrors.ErrNotFound + } + return manual, nil } func activeOwnerIDs(ctx context.Context) ([]int64, error) { @@ -222,11 +263,12 @@ func previewFromItem(item *model.ManualItem) *dal.ManualItemPreview { return &dal.ManualItemPreview{ID: item.ID, ManualID: item.ManualID, Kind: item.Kind, Title: item.Title, TextExcerpt: excerpt(item.Text, 160), URL: item.URL, ThumbnailKey: item.ThumbnailKey, PreviewStatus: item.PreviewStatus, Position: item.Position} } -func manualView(manual *model.Manual, categories []string, itemCount int, cover *dal.ManualItemPreview, items []*model.ManualItem, principal *utils.Principal) *service.ManualView { +func manualView(manual *model.Manual, categories []string, itemCount int, cover *dal.ManualItemPreview, items []*model.ManualItem, principal *utils.Principal, protected ...bool) *service.ManualView { if categories == nil { categories = []string{} } - view := &service.ManualView{ID: strconv.FormatInt(manual.ID, 10), Name: manual.Name, Description: manual.Description, Categories: categories, AccessMode: manual.AccessMode.String(), Status: manual.Status.String(), Revision: manual.Revision, ItemCount: itemCount, CanEdit: canEdit(manual.OwnerUserID, principal), CreateTime: manual.CreateTime, UpdateTime: manual.UpdateTime} + passwordProtected := len(protected) > 0 && protected[0] + view := &service.ManualView{ID: strconv.FormatInt(manual.ID, 10), Name: manual.Name, Description: manual.Description, Categories: categories, AccessMode: manual.AccessMode.String(), Status: manual.Status.String(), Revision: manual.Revision, ItemCount: itemCount, CanEdit: canEdit(manual.OwnerUserID, principal), PasswordProtected: passwordProtected, CreateTime: manual.CreateTime, UpdateTime: manual.UpdateTime} if manual.CoverItemID != nil { value := strconv.FormatInt(*manual.CoverItemID, 10) view.CoverItemID = &value diff --git a/app/manuals/resource.go b/app/manuals/resource.go index a431ebd..6999345 100644 --- a/app/manuals/resource.go +++ b/app/manuals/resource.go @@ -5,29 +5,26 @@ import ( "github.com/mcoder2014/home_server/domain/dal" "github.com/mcoder2014/home_server/domain/model" - service "github.com/mcoder2014/home_server/domain/service/manuals" + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" apperrors "github.com/mcoder2014/home_server/errors" ) -func (application *Application) ResourceItem(ctx context.Context, manualID, itemID, viewerID int64, thumbnail bool) (*model.ManualItem, error) { +func (application *Application) ResourceItem(ctx context.Context, manualID, itemID, viewerID int64, thumbnail bool, grantTokens ...string) (*model.ManualItem, error) { database, err := database(ctx) if err != nil { return nil, err } - manual, err := dal.FindManual(database, manualID, false) + manual, err := findReadableManual(ctx, database, manualID, viewerID) if err != nil { - return nil, apperrors.ErrDependency + return nil, err } - if manual == nil { - return nil, apperrors.ErrNotFound + grantToken := "" + if len(grantTokens) > 0 { + grantToken = grantTokens[0] } - active, err := ownerActive(ctx, manual.OwnerUserID) - if err != nil { + if _, err := resourcepasswords.Default.Authorize(ctx, resourcepasswords.ResourceManual, manual.ID, viewerID == manual.OwnerUserID, grantToken); err != nil { return nil, err } - if !active || !service.CanReadManual(manual.AccessMode, manual.Status, manual.OwnerUserID, viewerID) { - return nil, apperrors.ErrNotFound - } item, err := dal.FindManualItem(database, manualID, itemID, false) if err != nil { return nil, apperrors.ErrDependency diff --git a/app/webprojects/password.go b/app/webprojects/password.go new file mode 100644 index 0000000..d1165e8 --- /dev/null +++ b/app/webprojects/password.go @@ -0,0 +1,37 @@ +package webprojects + +import ( + "context" + "fmt" + + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" + service "github.com/mcoder2014/home_server/domain/service/webprojects" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" +) + +func (application *Application) PasswordState(ctx context.Context, ownerUserID, projectID int64) (*resourcepasswords.State, error) { + aggregate, err := application.repository.FindOwned(ownerUserID, projectID) + if err != nil { + return nil, fmt.Errorf("%w: query project", service.ErrDependency) + } + if aggregate == nil { + return nil, service.ErrNotFound + } + return resourcepasswords.Default.State(ctx, resourcepasswords.ResourceWebProject, projectID) +} + +func (application *Application) SetPassword(ctx context.Context, ownerUserID, projectID int64, input resourcepasswords.UpdateInput, principal *utils.Principal) (*resourcepasswords.State, error) { + if input.Version < 0 { + return nil, apperrors.ErrInvalid + } + passwordHash, err := resourcepasswords.PrepareManagedPassword(input.Password) + if err != nil { + return nil, err + } + state, err := application.repository.SetResourcePassword(ctx, ownerUserID, projectID, input.Version, passwordHash, principal) + if err != nil { + return nil, projectPersistenceError(err, "set project password") + } + return state, nil +} diff --git a/config/config_example.yaml b/config/config_example.yaml index 5352e3d..d867dc8 100644 --- a/config/config_example.yaml +++ b/config/config_example.yaml @@ -58,6 +58,17 @@ manuals: pdf_preview_output_limit_bytes: 16777216 pdf_preview_open_files_limit: 32 +file_sharing: + # Keep disabled until the additive migration, private directory and proxy are ready. + enabled: false + storage_root: /var/lib/home_server/files + max_file_bytes: 52428800 + max_files_per_user: 1000 + max_user_bytes: 10737418240 + min_free_disk_bytes: 2147483648 + max_concurrent_uploads_per_user: 2 + max_concurrent_uploads: 4 + auth: # Enable after application tables and same-origin HTTPS proxy are installed. applications_enabled: false diff --git a/config/global.go b/config/global.go index 1961a79..0f96273 100644 --- a/config/global.go +++ b/config/global.go @@ -51,6 +51,7 @@ type Config struct { } `json:"webdav" yaml:"webdav"` WebProjects WebProjectsConfig `json:"web_projects" yaml:"web_projects"` Manuals ManualsConfig `json:"manuals" yaml:"manuals"` + FileSharing FileSharingConfig `json:"file_sharing" yaml:"file_sharing"` Auth AuthConfig `json:"auth" yaml:"auth"` } @@ -110,6 +111,20 @@ type ManualsConfig struct { PDFPreviewOpenFilesLimit int `json:"pdf_preview_open_files_limit" yaml:"pdf_preview_open_files_limit"` } +// FileSharingConfig contains private storage and bounded upload settings. The +// module is bootstrap-configured like manuals; storage paths are never editable +// through the runtime configuration API. +type FileSharingConfig struct { + Enabled bool `json:"enabled" yaml:"enabled"` + StorageRoot string `json:"storage_root" yaml:"storage_root"` + MaxFileBytes int64 `json:"max_file_bytes" yaml:"max_file_bytes"` + MaxFilesPerUser int `json:"max_files_per_user" yaml:"max_files_per_user"` + MaxUserBytes int64 `json:"max_user_bytes" yaml:"max_user_bytes"` + MinFreeDiskBytes int64 `json:"min_free_disk_bytes" yaml:"min_free_disk_bytes"` + MaxConcurrentUploadsPerUser int `json:"max_concurrent_uploads_per_user" yaml:"max_concurrent_uploads_per_user"` + MaxConcurrentUploads int `json:"max_concurrent_uploads" yaml:"max_concurrent_uploads"` +} + // 全局配置 var globalConfig = Config{} var globalConfigLock sync.RWMutex diff --git a/config/nginx/file_sharing_locations.conf b/config/nginx/file_sharing_locations.conf new file mode 100644 index 0000000..11415ab --- /dev/null +++ b/config/nginx/file_sharing_locations.conf @@ -0,0 +1,48 @@ +# Every file API request goes through the backend. Never add a static alias for +# file_sharing.storage_root; share tokens and private storage paths are secrets. +location = /api/files { + access_log off; + client_max_body_size 52m; + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_request_buffering off; + proxy_read_timeout 120s; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; +} + +location ^~ /api/files/ { + access_log off; + client_max_body_size 64k; + proxy_pass http://127.0.0.1:18080; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; +} + +location ^~ /api/file-shares/ { + # Tokens must not enter access logs. Buffering is disabled so committed + # downloads stream from the backend without a second resumable copy. + access_log off; + client_max_body_size 16k; + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 300s; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; +} diff --git a/config/runtime.go b/config/runtime.go index eb2c901..b59520e 100644 --- a/config/runtime.go +++ b/config/runtime.go @@ -21,6 +21,7 @@ type RuntimeConfig struct { Auth AuthConfig WebProjects WebProjectsConfig Manuals ManualsConfig + FileSharing FileSharingConfig RegistrationEnabled, LibraryEnabled, WebDAVEnabled, CleanupEnabled bool SiteTitle, SiteNotice string AccountPolicy AccountPolicyConfig @@ -47,7 +48,7 @@ func BuildRuntimeSnapshot(conf Config, generation int64, revisions map[string]in } validated[schema.Namespace] = v } - result := RuntimeConfig{Auth: conf.Auth, WebProjects: conf.WebProjects, Manuals: conf.Manuals, Generation: generation, Revisions: revisions} + result := RuntimeConfig{Auth: conf.Auth, WebProjects: conf.WebProjects, Manuals: conf.Manuals, FileSharing: conf.FileSharing, Generation: generation, Revisions: revisions} for namespace, target := range map[string]interface{}{"auth": &result.Auth, "web_projects": &result.WebProjects, "account_policy": &result.AccountPolicy} { data, err := json.Marshal(validated[namespace]) if err != nil { diff --git a/deploy/pi/README.md b/deploy/pi/README.md index fe0f827..26fc5ef 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -95,3 +95,21 @@ sudo systemctl enable home_server.target home_server.service home_server_fronten ## 多个受信任域名 网关 `server_name` 可以列出主域名和内部域名,例如 `home.example.com home.internal.example.com`。证书必须包含所有域名,DNS 地址在私有配置中维护。后端保留 `auth.site_origin` 作为原入口,通过 `auth.site_origins` 添加完整的额外 HTTPS Origin;不要添加通配域或设置共享 Cookie Domain。两个域名使用相同账号和数据库,但浏览器登录状态分别保存。 + +## 文件分享与密码阅读 + +文件模块使用独立的 `file_sharing` 部署配置,缺省关闭。上线前执行 `20260919_file_sharing.sql` 和 `20260919_resource_passwords.sql` 两份增量迁移;前者创建文件、分享、成员表,后者为既有网页和说明书提供密码版本记录。密码表迁移必须先于新后端切换,不能在表缺失时以“无密码”降级。 + +配置 `file_sharing.enabled: true` 与私有 `storage_root`,建议 `/var/lib/home_server/files`,归服务账号所有、权限0700。该路径不得与 WebDAV、网页托管、说明书或前端静态根重叠。默认单文件50MiB、每用户1000个文件和10GiB总量、保留2GiB磁盘空间;并发上传上限为每用户2个、全局4个。文件统一通过后端附件响应,不安装静态alias。 + +安装 `config/nginx/file_sharing_locations.conf` 到 `/etc/home_server/locations/`,在Pi副本中沿用 `$cq_forwarded_proto` 和 `$cq_client_ip`,并由独立前端Nginx include。TLS网关的两个入口必须同步提供文件API,保留既有原始Host与端口、可信代理、禁缓存与关闭访问日志。实际配置分别执行 `nginx -t` 后生效。 + +| 验收路径 | 预期 | +|---|---| +| `/files`、`/s/:token` | 前端管理和收件页;登录回跳不丢分享目标 | +| `/api/files` | 未登录不能管理,已登录仅本人文件;上传受应用与代理双重大小限制 | +| `/api/file-shares/:token/download` | 仅POST;身份、密码、时间、次数全部通过才发送;HEAD与Range不消耗次数 | +| 网页与说明书密码 | 旧ACL继续有效;错误密码不注销登录;修改或清除密码使旧授权失效 | +| 内容保护 | 网页子资源与评论、说明书正文/封面/原件/缩略图均不得绕过门禁 | + +下载计数表示成功授权并开始传输的次数,客户端断线不会恢复额度。程序回滚时保留四张新增表及私有文件,恢复整套前后端release和对应配置;不得把数据库快照覆盖到已经产生新业务数据的库。 diff --git a/deploy/pi/frontend-nginx.conf b/deploy/pi/frontend-nginx.conf index bf03aee..096e03e 100644 --- a/deploy/pi/frontend-nginx.conf +++ b/deploy/pi/frontend-nginx.conf @@ -35,6 +35,7 @@ http { include /etc/home_server/locations/web_projects_locations.conf; include /etc/home_server/locations/api_auth_locations.conf; include /etc/home_server/locations/manuals_locations.conf; + include /etc/home_server/locations/file_sharing_locations.conf; location ^~ /passport/ { client_max_body_size 16k; proxy_pass http://127.0.0.1:18080; diff --git a/deploy/pi/gateway-api.conf b/deploy/pi/gateway-api.conf index 899095d..2c0bfee 100644 --- a/deploy/pi/gateway-api.conf +++ b/deploy/pi/gateway-api.conf @@ -17,6 +17,63 @@ server { return 400; } + location = /api/files { + client_max_body_size 52m; + proxy_pass http://192.0.2.10:18080; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_request_buffering off; + proxy_read_timeout 120s; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; + } + + location ^~ /api/files/ { + client_max_body_size 64k; + proxy_pass http://192.0.2.10:18080; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; + } + + location ^~ /api/file-shares/ { + client_max_body_size 16k; + proxy_pass http://192.0.2.10:18080; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 300s; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; + } + + # The share landing page belongs to the frontend host. Its token must never + # be written to an access log or sent in a subsequent Referer header. + location ^~ /s/ { + access_log off; + proxy_pass http://192.0.2.10:18081; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_cache off; + proxy_intercept_errors off; + add_header Referrer-Policy "no-referrer" always; + } + # Exact root and ^~ prefix keep manual originals and thumbnails behind auth. location = /api/manuals { client_max_body_size 512k; diff --git a/deploy/pi/gateway-frontend.conf b/deploy/pi/gateway-frontend.conf index c659af0..5501538 100644 --- a/deploy/pi/gateway-frontend.conf +++ b/deploy/pi/gateway-frontend.conf @@ -17,6 +17,63 @@ server { return 400; } + location = /api/files { + client_max_body_size 52m; + proxy_pass http://192.0.2.10:18081; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_request_buffering off; + proxy_read_timeout 120s; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; + } + + location ^~ /api/files/ { + client_max_body_size 64k; + proxy_pass http://192.0.2.10:18081; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; + } + + location ^~ /api/file-shares/ { + client_max_body_size 16k; + proxy_pass http://192.0.2.10:18081; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 300s; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; + } + + # Share tokens are bearer secrets. Serve the Vue landing page without + # recording its path and prevent browsers from forwarding it as a referrer. + location ^~ /s/ { + access_log off; + proxy_pass http://192.0.2.10:18081; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_cache off; + proxy_intercept_errors off; + add_header Referrer-Policy "no-referrer" always; + } + # Exact root and ^~ prefix keep manual originals and thumbnails behind auth. location = /api/manuals { client_max_body_size 512k; diff --git a/docs/specs/file-sharing/design.md b/docs/specs/file-sharing/design.md new file mode 100644 index 0000000..13b56f2 --- /dev/null +++ b/docs/specs/file-sharing/design.md @@ -0,0 +1,92 @@ +# 文件分享与密码阅读:需求及技术方案 + +日期:2026-09-19。代码基线:master `1db4129`。状态:设计已由 GPT-6 审查,功能已实现;验证结果见 verification.md。 + +## 目标与范围 + +面向家庭服务器的文件所有者和收件人:上传一次文件,建立多条独立分享;收件人通过不暴露存储结构的链接访问。网页托管、说明书增加密码阅读;统一全站视觉层级、表单、卡片、空状态与移动端布局。保留既有账号、应用凭证、网页评论、藏书及 WebDAV 行为。 + +| 能力 | 确定行为 | +|---|---| +| 匿名分享 | 无密码直接下载,或输入6位大小写敏感 ASCII 字母/数字分享码;支持指定与安全随机生成 | +| 登录分享 | 所有有效登录用户或指定成员;可叠加独立分享密码(8–72 UTF-8字节),不使用账号密码 | +| 有效期 | 不限、1天、7天、30天、自定义到期时间;“1个月”在界面明确为30天 | +| 次数 | 不限、1次、自定义N次;时间与次数可同时限制,任一达到即失效 | +| 文件和分享 | 文件属于上传者;每个文件可建立多条分享,各有独立链接、策略、计数和撤销状态 | +| 删除 | 撤销分享不删除文件;删除文件让其全部分享失效;过期记录保留供查看次数 | +| 密码阅读 | 网页和说明书在原可见范围上叠加密码;所有者管理可绕过,其他访问者须同时满足身份与密码 | +| 页面 | 管理页支持上传、文件列表、分享记录和复制链接;收件页显示解锁、登录、下载与失效状态 | + +下载次数定义为“服务端通过全部校验,成功占用次数后开始发送文件”。中断传输不返还次数;页面打开、错误密码、HEAD、被拒绝的Range请求不计数。单次分享的下载按钮不会自动触发;不提供绕过计数的原文件预览或断点续传。没有密码的模式指无需额外验证即可点击下载。 + +## 真实现状与方案选择 + +`api/fileshare` 只有未注册的占位路由。网页资源统一经过 `api/webprojects/content.go`,说明书详情和原件、缩略图分别经过 `api/manuals/handlers.go` 与 `content.go`。说明书列表会生成封面和文字摘录,必须与密码保护一起处理。前端为 Vue 3 + Element Plus,后端为 Gin + GORM + MariaDB。 + +采用独立文件与分享表,加小型密码能力。把文件直接塞进网页版本会混淆发布、解压和下载次数;重建全站统一资源平台会扩大变更范围,两者均不采用。 + +## 数据与权限 + +| 数据 | 关键字段与约束 | +|---|---| +| files | id、owner_user_id、原文件名、私有storage_key、size_bytes、sha256、删除状态、创建时间;owner/id列表索引 | +| file_shares | id、file_id、owner_user_id、唯一高熵token、access_mode、secret_mode、bcrypt hash、expires_at、max_downloads、download_count、revoked_at;file/id索引 | +| file_share_members | share_id、user_id复合主键;指定成员必须有效 | +| resource_passwords | resource_type/resource_id复合主键、password_hash、version、update_time;清除密码保留记录并增加version | + +分享token使用 crypto/rand,至少24个随机字节的 URL-safe 编码。公开响应不返回路径、storage_key、密码hash;密码/分享码只在首次创建响应显示,列表不可反查明文。文件下载用服务端文件句柄直接输出附件,无静态alias、绝对路径或对象存储重定向。 + +密码授权使用短期 Secure/HttpOnly/SameSite Cookie,绑定资源、密码版本和到期时间;最长1小时,服务重启失效可接受。每个内容请求仍重新检查所有者状态、原ACL、资源状态与密码版本。口令输入限速必须在bcrypt前执行,并限制全局并发与限速表容量。浏览器写操作沿用现有Origin/CSRF机制;密码不进入URL或日志,分享token路径须脱敏。密码错误/缺少授权返回403,避免既有前端401处理导致登出。登录回跳允许严格匹配的 `/s/:token` 与 `/files`。 + +## 接口契约 + +| 接口 | 语义 | +|---|---| +| GET/POST /api/files | 本人文件游标列表 / multipart上传 | +| GET/DELETE /api/files/:id | 本人文件详情 / 删除并让分享失效 | +| GET/POST /api/files/:id/shares | 分享记录 / 创建分享 | +| DELETE /api/files/:id/shares/:share_id | 撤销单条分享 | +| GET /api/file-shares/:token | 分享状态;未解锁返回通用锁状态,不含文件信息;次数为固定0、时间为空 | +| POST /api/file-shares/:token/unlock | 校验口令并发短期Cookie | +| POST /api/file-shares/:token/download | 校验、原子计数、流式附件响应 | +| GET/PUT /api/web-share/:id/password | 所有者查询或设置/清除网页密码;`/api/web-projects` 为完整兼容别名 | +| POST /api/web-share/:id/unlock | 满足原可见性后解锁网页;`/api/web-projects` 为完整兼容别名 | +| GET/PUT /api/manuals/:id/password | 所有者查询或设置/清除说明书密码 | +| POST /api/manuals/:id/unlock | 满足原可见性后解锁说明书 | + +分享创建字段:access_mode(public/authenticated/members)、member_user_ids、secret_mode(none/code/password)、secret、expires_at(可空)、max_downloads(0不限)。随机分享码可由客户端安全生成并提交或由服务端生成后仅返回一次。资源密码写入使用 `{password,version}`,空字符串表示清除;查询与写入只返回 `{password_protected,version}`。密码缺失或错误固定返回 HTTP 403/code 3,旧版本写入返回 HTTP 409/code 5,限速返回 HTTP 429/code 8。网页 HTML GET 可直接返回不依赖前端构建的同源解锁页,HEAD 和子资源仍返回 403;解锁密码只进入 JSON 请求体。 + +## 数据流 + +![文件与分享下载流程](flow.svg) + +## 并发、存储与兼容 + +下载先验证权限并安全打开常规文件,再锁定资源/分享,重新检查撤销、到期、次数与身份快照,原子增加计数,事务提交成功后发送。计数更新必须通过行锁或条件UPDATE确保N次最多开始N次传输。撤销后已开始传输不强行中断。 + +文件存储根独立于WebDAV、网页和说明书目录;拒绝路径穿越、符号链接和非普通文件。默认文件上限50MiB,设置每用户总量、文件数和并发上传限制;临时文件失败清理,写入前后复核权限和容量。普通文件不解压、不执行,统一附件下载。新增配置默认关闭,增量DDL显式执行,不在服务启动时自动迁移。 + +网页门禁覆盖入口HTML、子资源、HEAD、Range、原始模式和评论读写;所有者管理/下载保持原权限。说明书门禁覆盖详情正文、文本、URL、原件、缩略图、HEAD/Range;受保护列表仅保留允许公开的标题/分类,隐藏内容摘要与封面。旧资源未设置密码时行为不变。 + +## 页面设计 + +延续现有墨绿主色,以浅色背景、清晰标题、统一间距、细边框和克制阴影组织信息。管理页将“文件”和“分享记录”分层展示;收件页以文件信息及一个主操作为中心。密码字段可显隐、随机生成/复制有明确反馈;时间和次数同时展示。覆盖320px窄屏、键盘焦点、上传进度、错误重试和失效状态;不引入新的UI框架。 + +## 验证与发布 + +| 阶段 | 验收内容 | +|---|---| +| 数据和服务 | 身份×密码×状态矩阵、N次并发上限、过期、撤销、删除、跨用户拒绝、错误密码限速、慢上传撤权、私有路径不可达 | +| 阅读保护 | 网页全部资源/评论与说明书详情/缩略图/列表无绕过;修改密码后旧Cookie失效 | +| 浏览器 | 上传→建多个分享→匿名/登录/成员/密码下载;网页与说明书设密/解锁;桌面及320px视觉检查 | +| 回归 | 既有Go CI包、前端行为和浏览器测试、Python技能测试、Nginx匹配与构建 | +| 发布 | 固定PR SHA;备份DB/配置;增量DDL与EXPLAIN;完整前后端release;Nginx -t;切current并健康检查 | +| 回滚 | 恢复旧release和配置,保留新增表和文件,不回滚覆盖业务数据 | + +Go编译与测试在远端隔离环境执行。发布到Pi使用明确的PR提交,不自动合并master;保留worktree待PR合并后清理。文档HTML通过网页托管owner模式发布,并验证匿名访问被拒绝。 + +## 审查结论 + +GPT-6 已指出并纳入:文件与分享分离、POST下载避免预取耗次数、并发原子限额、打开文件后再扣次数、说明书列表侧漏、网页评论门禁、清除密码不能重用版本、未知/撤销/过期统一响应以及代理链路必须显式接入。 + +参考:[Go net/http](https://pkg.go.dev/net/http#ServeContent)、[Go bcrypt](https://pkg.go.dev/golang.org/x/crypto/bcrypt)。 diff --git a/docs/specs/file-sharing/flow.mmd b/docs/specs/file-sharing/flow.mmd new file mode 100644 index 0000000..7f0e3d8 --- /dev/null +++ b/docs/specs/file-sharing/flow.mmd @@ -0,0 +1,9 @@ +flowchart LR + U["上传文件"] --> F["私有文件记录"] + F --> S1["分享 A:公开 + 7 天"] + F --> S2["分享 B:登录 + 密码 + 1 次"] + S1 --> G["身份、密码、状态校验"] + S2 --> G + G --> O["安全打开文件"] + O --> T["事务内复核并占用次数"] + T --> D["附件流式下载"] diff --git a/docs/specs/file-sharing/flow.svg b/docs/specs/file-sharing/flow.svg new file mode 100644 index 0000000..5b877f5 --- /dev/null +++ b/docs/specs/file-sharing/flow.svg @@ -0,0 +1 @@ +

上传文件

私有文件记录

分享 A:公开 + 7 天

分享 B:登录 + 密码 + 1 次

身份、密码、状态校验

安全打开文件

事务内复核并占用次数

附件流式下载

\ No newline at end of file diff --git a/docs/specs/file-sharing/implementation.md b/docs/specs/file-sharing/implementation.md new file mode 100644 index 0000000..5a6f467 --- /dev/null +++ b/docs/specs/file-sharing/implementation.md @@ -0,0 +1,22 @@ +# 文件分享实施计划 + +目标:在现有账号及存储体系中提供独立文件分享、密码阅读与统一页面体验。 + +| 工作包 | 文件范围 | 完成标准 | +|---|---|---| +| 后端文件模块 | api/fileshare、app/fileshare、domain/model、domain/dal、config、API初始化 | 上传/列表/多分享/权限/口令/到期/原子次数/撤销/下载闭环,未授权资源不泄露 | +| 阅读密码 | resource_passwords及小型密码服务,api/webprojects、api/manuals、app/manuals | 原ACL不变,全部内容和评论入口保护,列表无摘要泄漏,旧Cookie随版本失效 | +| 前端 | front_vue/src/api、views、router、components、assets | 文件管理和分享落地页;网页/说明书设密解锁;桌面320px均可用;现有功能回归 | +| 集成与部署 | migrations、Nginx、CI、deploy/pi文档 | 精确增量DDL,代理无静态绕过,远端构建和隔离测试通过 | +| 独立审查 | 精确分支diff和PR | GPT-6方案review;Sol max独立code review;修复后重新核对 | +| 文档与发布 | docs/specs/file-sharing | 需求/方案HTML owner模式,PR固定SHA,Pi完整release与回滚路径 | + +## 验证顺序 + +1. 现有前端、浏览器、Python测试作为基线;远端Pi隔离MariaDB/Redis复用历史测试环境,核对不连接生产DB。 +2. 后端完成后增加新模块到隔离测试清单;先定向权限、口令、次数并发与资源保护用例,再执行既有CI回归和全仓构建。 +3. 前端运行 `npm run test:frontend`、相关浏览器用例与 `VUE_APP_API_BASE_URL=/ npm run build`;实际截图检查桌面和窄屏。 +4. 只读独立审查要求/行为与diff,再修复所有P1/P2;提交并推送PR,由Sol max审查PR提交。 +5. 对PR SHA生成完整release摘要,生产变更前保存配置/数据库备份;验证SQL执行计划与Nginx后切换,检查旧入口和新功能。 + +Go测试禁止本地执行。Node依赖优先复用现有安装。不得把生产凭据或数据库复制到开发目录。 diff --git a/docs/specs/file-sharing/index.html b/docs/specs/file-sharing/index.html new file mode 100644 index 0000000..b90d5d4 --- /dev/null +++ b/docs/specs/file-sharing/index.html @@ -0,0 +1,173 @@ +Home Server · 文件分享与密码阅读方案
HOME SERVER / PRODUCT & ENGINEERING

一次上传,按需分享。

文件与链接分离,用身份、密码、时间与次数控制访问。需求分析、技术设计与交付验收。

文件分享与密码阅读:需求及技术方案

+

日期:2026-09-19。代码基线:master 1db4129。状态:设计已由 GPT-6 审查,功能已实现;验证结果见 verification.md。

目标与范围

+

面向家庭服务器的文件所有者和收件人:上传一次文件,建立多条独立分享;收件人通过不暴露存储结构的链接访问。网页托管、说明书增加密码阅读;统一全站视觉层级、表单、卡片、空状态与移动端布局。保留既有账号、应用凭证、网页评论、藏书及 WebDAV 行为。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
能力确定行为
匿名分享无密码直接下载,或输入6位大小写敏感 ASCII 字母/数字分享码;支持指定与安全随机生成
登录分享所有有效登录用户或指定成员;可叠加独立分享密码(8–72 UTF-8字节),不使用账号密码
有效期不限、1天、7天、30天、自定义到期时间;“1个月”在界面明确为30天
次数不限、1次、自定义N次;时间与次数可同时限制,任一达到即失效
文件和分享文件属于上传者;每个文件可建立多条分享,各有独立链接、策略、计数和撤销状态
删除撤销分享不删除文件;删除文件让其全部分享失效;过期记录保留供查看次数
密码阅读网页和说明书在原可见范围上叠加密码;所有者管理可绕过,其他访问者须同时满足身份与密码
页面管理页支持上传、文件列表、分享记录和复制链接;收件页显示解锁、登录、下载与失效状态
+

下载次数定义为“服务端通过全部校验,成功占用次数后开始发送文件”。中断传输不返还次数;页面打开、错误密码、HEAD、被拒绝的Range请求不计数。单次分享的下载按钮不会自动触发;不提供绕过计数的原文件预览或断点续传。没有密码的模式指无需额外验证即可点击下载。

真实现状与方案选择

+

api/fileshare 只有未注册的占位路由。网页资源统一经过 api/webprojects/content.go,说明书详情和原件、缩略图分别经过 api/manuals/handlers.go 与 content.go。说明书列表会生成封面和文字摘录,必须与密码保护一起处理。前端为 Vue 3 + Element Plus,后端为 Gin + GORM + MariaDB。

+

采用独立文件与分享表,加小型密码能力。把文件直接塞进网页版本会混淆发布、解压和下载次数;重建全站统一资源平台会扩大变更范围,两者均不采用。

数据与权限

+ + + + + + + + + + + + + + + + + + + + + + + + + +
数据关键字段与约束
filesid、owner_user_id、原文件名、私有storage_key、size_bytes、sha256、删除状态、创建时间;owner/id列表索引
file_sharesid、file_id、owner_user_id、唯一高熵token、access_mode、secret_mode、bcrypt hash、expires_at、max_downloads、download_count、revoked_at;file/id索引
file_share_membersshare_id、user_id复合主键;指定成员必须有效
resource_passwordsresource_type/resource_id复合主键、password_hash、version、update_time;清除密码保留记录并增加version
+

分享token使用 crypto/rand,至少24个随机字节的 URL-safe 编码。公开响应不返回路径、storage_key、密码hash;密码/分享码只在首次创建响应显示,列表不可反查明文。文件下载用服务端文件句柄直接输出附件,无静态alias、绝对路径或对象存储重定向。

+

密码授权使用短期 Secure/HttpOnly/SameSite Cookie,绑定资源、密码版本和到期时间;最长1小时,服务重启失效可接受。每个内容请求仍重新检查所有者状态、原ACL、资源状态与密码版本。口令输入限速必须在bcrypt前执行,并限制全局并发与限速表容量。浏览器写操作沿用现有Origin/CSRF机制;密码不进入URL或日志,分享token路径须脱敏。密码错误/缺少授权返回403,避免既有前端401处理导致登出。登录回跳允许严格匹配的 /s/:token 与 /files。

接口契约

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
接口语义
GET/POST /api/files本人文件游标列表 / multipart上传
GET/DELETE /api/files/:id本人文件详情 / 删除并让分享失效
GET/POST /api/files/:id/shares分享记录 / 创建分享
DELETE /api/files/:id/shares/:share_id撤销单条分享
GET /api/file-shares/:token分享状态;未解锁返回通用锁状态,不含文件信息;次数为固定0、时间为空
POST /api/file-shares/:token/unlock校验口令并发短期Cookie
POST /api/file-shares/:token/download校验、原子计数、流式附件响应
GET/PUT /api/web-share/:id/password所有者查询或设置/清除网页密码;/api/web-projects 为完整兼容别名
POST /api/web-share/:id/unlock满足原可见性后解锁网页;/api/web-projects 为完整兼容别名
GET/PUT /api/manuals/:id/password所有者查询或设置/清除说明书密码
POST /api/manuals/:id/unlock满足原可见性后解锁说明书
+

分享创建字段:access_mode(public/authenticated/members)、member_user_ids、secret_mode(none/code/password)、secret、expires_at(可空)、max_downloads(0不限)。随机分享码可由客户端安全生成并提交或由服务端生成后仅返回一次。资源密码写入使用 {password,version},空字符串表示清除;查询与写入只返回 {password_protected,version}。密码缺失或错误固定返回 HTTP 403/code 3,旧版本写入返回 HTTP 409/code 5,限速返回 HTTP 429/code 8。网页 HTML GET 可直接返回不依赖前端构建的同源解锁页,HEAD 和子资源仍返回 403;解锁密码只进入 JSON 请求体。

数据流

+

上传文件

私有文件记录

分享 A:公开 + 7 天

分享 B:登录 + 密码 + 1 次

身份、密码、状态校验

安全打开文件

事务内复核并占用次数

附件流式下载

并发、存储与兼容

+

下载先验证权限并安全打开常规文件,再锁定资源/分享,重新检查撤销、到期、次数与身份快照,原子增加计数,事务提交成功后发送。计数更新必须通过行锁或条件UPDATE确保N次最多开始N次传输。撤销后已开始传输不强行中断。

+

文件存储根独立于WebDAV、网页和说明书目录;拒绝路径穿越、符号链接和非普通文件。默认文件上限50MiB,设置每用户总量、文件数和并发上传限制;临时文件失败清理,写入前后复核权限和容量。普通文件不解压、不执行,统一附件下载。新增配置默认关闭,增量DDL显式执行,不在服务启动时自动迁移。

+

网页门禁覆盖入口HTML、子资源、HEAD、Range、原始模式和评论读写;所有者管理/下载保持原权限。说明书门禁覆盖详情正文、文本、URL、原件、缩略图、HEAD/Range;受保护列表仅保留允许公开的标题/分类,隐藏内容摘要与封面。旧资源未设置密码时行为不变。

页面设计

+

延续现有墨绿主色,以浅色背景、清晰标题、统一间距、细边框和克制阴影组织信息。管理页将“文件”和“分享记录”分层展示;收件页以文件信息及一个主操作为中心。密码字段可显隐、随机生成/复制有明确反馈;时间和次数同时展示。覆盖320px窄屏、键盘焦点、上传进度、错误重试和失效状态;不引入新的UI框架。

验证与发布

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
阶段验收内容
数据和服务身份×密码×状态矩阵、N次并发上限、过期、撤销、删除、跨用户拒绝、错误密码限速、慢上传撤权、私有路径不可达
阅读保护网页全部资源/评论与说明书详情/缩略图/列表无绕过;修改密码后旧Cookie失效
浏览器上传→建多个分享→匿名/登录/成员/密码下载;网页与说明书设密/解锁;桌面及320px视觉检查
回归既有Go CI包、前端行为和浏览器测试、Python技能测试、Nginx匹配与构建
发布固定PR SHA;备份DB/配置;增量DDL与EXPLAIN;完整前后端release;Nginx -t;切current并健康检查
回滚恢复旧release和配置,保留新增表和文件,不回滚覆盖业务数据
+

Go编译与测试在远端隔离环境执行。发布到Pi使用明确的PR提交,不自动合并master;保留worktree待PR合并后清理。文档HTML通过网页托管owner模式发布,并验证匿名访问被拒绝。

审查结论

+

GPT-6 已指出并纳入:文件与分享分离、POST下载避免预取耗次数、并发原子限额、打开文件后再扣次数、说明书列表侧漏、网页评论门禁、清除密码不能重用版本、未知/撤销/过期统一响应以及代理链路必须显式接入。

+

参考:Go net/http、Go bcrypt。

\ No newline at end of file diff --git a/docs/specs/file-sharing/verification.md b/docs/specs/file-sharing/verification.md new file mode 100644 index 0000000..3db7d82 --- /dev/null +++ b/docs/specs/file-sharing/verification.md @@ -0,0 +1,40 @@ +# 文件分享验证记录 + +基线:master `1db41295444ded2f9b7a19a0e34c7b40e05fdc8f`。日期:2026-09-19。新功能验证状态:开发回归通过,独立 PR 审查待完成。 + +## 修改前基线 + +| 检查 | 环境 | 实际结果 | +|---|---|---| +| Go全仓构建 | Pi隔离源码、Go1.21.13,`go build -p 2 ./...` | 通过 | +| 既有主CI包 | 独立MariaDB10.11和Redis,合成数据 | 325个顶层用例通过,20个既有opt-in用例跳过;首次3个过期用例因测试库时区失败,对齐CI的UTC后3/3重跑通过 | +| DAL | 主测试后串行执行 | 13/13通过 | +| 前端行为 | macOS Node24,现有依赖 | 92/92通过 | +| 浏览器 | 本机Chrome、仅loopback合成fixture | 说明书8/8、账号6/6通过 | +| Python与技能 | 本机合成HTTPS fixture | 33/33通过 | +| 方案HTML | Chrome桌面1440px和手机390px | 无横向溢出;1个评论根,10个唯一语义ID;Mermaid已实际渲染 | +| 文档权限 | 托管API与匿名HTTP | owner、enabled、版本读回一致;匿名404 | + +上述基线只用于识别回归,不计作新文件分享或密码门禁已通过。隔离数据库的NOW()原为东八区,与测试DSN的UTC解释不一致;只调整测试实例时区,未修改生产配置或业务代码。 + +## 新功能验收 + +| 检查 | 实际结果 | +|---|---| +| Pi全仓构建 | Go1.21.13,`go build -p 2 ./...` 通过 | +| 主CI回归 | 337个其他包顶层用例通过;3个新增文件HTTP用例定向修复后全部通过,共340个;20个既有opt-in用例跳过 | +| 数据层 | 原14个DAL用例通过;新增敏感SQL日志回归后FileShare两个用例通过,合计15个 | +| 文件HTTP | 多分享、匿名/登录/成员/密码、单次并发上限、撤销/到期/删除、HEAD/Range/缺文件不耗次、空文件、慢上传撤销会话、跨实例容量竞争通过 | +| 密码阅读 | 网页HTML/子资源/HEAD/Range/raw/评论、说明书详情/文件/缩略图/列表摘要、旧授权随版本失效通过 | +| 前端 | `npm run test:frontend` 110/110;`npm run build` 通过;本次修改文件ESLint通过 | +| 浏览器 | Chromium文件6、说明书10、全站视觉2、账号中心6,共24/24;桌面1440与手机320截图已实际复核 | +| Python | API客户端13、网页技能15、说明书技能5,共33/33通过 | +| 配置 | 公开部署示例检查通过;两入口网关候选配置在真实Nginx上`-t`通过,尚未生效 | + +全量lint仍存在原有4项问题,位于Common.vue(2项)、AddBook.vue(1项)、ScanCodePage.vue(1项);本次修改行未引入新的lint问题。Go各包共享测试库时串行执行,所有数据均为隔离合成数据。首次新增fixture配置与锁态JSON断言错误已修正后重跑,未放宽生产权限。 + +独立审查与最终发布使用PR固定提交;生产备份、切换和健康检查结果由服务器发布manifest记录,不将生产配置或凭据写入仓库。 + +## SQL执行计划 + +在隔离MariaDB10.11中各建立2000条合成文件和分享记录、20个所有者。本人游标列表命中 `idx_files_owner_id`(range,估算90行);token查找命中 `uk_file_shares_token`(const,1行);单文件分享列表命中 `idx_file_shares_file_id`(ref,1行);用户容量聚合命中 `idx_files_owner_id`(ref,估算100行)。该结果证明所核对查询没有全表扫描;容量聚合仍按用户文件数线性读取,受每用户文件数上限约束。 diff --git a/domain/dal/fileshare.go b/domain/dal/fileshare.go new file mode 100644 index 0000000..1096a30 --- /dev/null +++ b/domain/dal/fileshare.go @@ -0,0 +1,206 @@ +package dal + +import ( + "errors" + "time" + + "github.com/mcoder2014/home_server/domain/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/logger" +) + +const ( + FileTable = "files" + FileShareTable = "file_shares" + FileShareMemberTable = "file_share_members" +) + +var fileColumns = []string{"id", "owner_user_id", "original_name", "storage_key", "size_bytes", "sha256", "deleted_at", "create_time"} +var fileShareColumns = []string{"id", "file_id", "owner_user_id", "token", "access_mode", "secret_mode", "secret_hash", "expires_at", "max_downloads", "download_count", "revoked_at", "create_time", "update_time"} + +type FileShareCount struct { + FileID int64 `gorm:"column:file_id"` + Count int64 `gorm:"column:share_count"` +} + +func InsertFile(tx *gorm.DB, file *model.StoredFile) error { + return tx.Table(FileTable).Select(fileColumns).Create(file).Error +} + +func FindFile(database *gorm.DB, id int64, lock bool) (*model.StoredFile, error) { + query := database.Table(FileTable).Select(fileColumns).Where("id = ?", id) + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var file model.StoredFile + err := query.Take(&file).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &file, err +} + +func FindOwnedFile(database *gorm.DB, ownerID, id int64, includeDeleted, lock bool) (*model.StoredFile, error) { + query := database.Table(FileTable).Select(fileColumns).Where("owner_user_id = ? AND id = ?", ownerID, id) + if !includeDeleted { + query = query.Where("deleted_at IS NULL") + } + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var file model.StoredFile + err := query.Take(&file).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &file, err +} + +func ListOwnedFiles(database *gorm.DB, ownerID, cursor int64, limit int) ([]*model.StoredFile, error) { + query := database.Table(FileTable).Select(fileColumns).Where("owner_user_id = ? AND deleted_at IS NULL", ownerID) + if cursor > 0 { + query = query.Where("id < ?", cursor) + } + var files []*model.StoredFile + err := query.Order("id DESC").Limit(limit).Find(&files).Error + return files, err +} + +func CountFileShares(database *gorm.DB, fileIDs []int64) (map[int64]int64, error) { + result := make(map[int64]int64, len(fileIDs)) + if len(fileIDs) == 0 { + return result, nil + } + var rows []FileShareCount + err := database.Table(FileShareTable).Select("file_id, COUNT(*) AS share_count").Where("file_id IN ?", fileIDs).Group("file_id").Find(&rows).Error + for _, row := range rows { + result[row.FileID] = row.Count + } + return result, err +} + +func CountOwnedFiles(tx *gorm.DB, ownerID int64) (int64, error) { + var count int64 + err := tx.Table(FileTable).Where("owner_user_id = ? AND deleted_at IS NULL", ownerID).Count(&count).Error + return count, err +} + +func SumOwnedFileBytes(tx *gorm.DB, ownerID int64) (int64, error) { + var result struct { + Bytes int64 `gorm:"column:bytes"` + InvalidRows int64 `gorm:"column:invalid_rows"` + } + err := tx.Table(FileTable).Select("COALESCE(SUM(size_bytes),0) AS bytes, COALESCE(SUM(CASE WHEN size_bytes < 0 THEN 1 ELSE 0 END),0) AS invalid_rows").Where("owner_user_id = ? AND deleted_at IS NULL", ownerID).Scan(&result).Error + if err != nil || result.Bytes < 0 || result.InvalidRows != 0 { + if err == nil { + err = errors.New("invalid stored file size") + } + return 0, err + } + return result.Bytes, nil +} + +func DeleteOwnedFile(tx *gorm.DB, ownerID, id int64, deletedAt time.Time) (bool, error) { + result := tx.Table(FileTable).Where("owner_user_id = ? AND id = ? AND deleted_at IS NULL", ownerID, id).Update("deleted_at", deletedAt) + return result.RowsAffected == 1, result.Error +} + +func InsertFileShare(tx *gorm.DB, share *model.FileShare, memberIDs []int64) error { + if err := tx.Session(&gorm.Session{Logger: logger.Discard}).Table(FileShareTable).Select(fileShareColumns).Create(share).Error; err != nil { + return err + } + if len(memberIDs) == 0 { + return nil + } + members := make([]*model.FileShareMember, 0, len(memberIDs)) + for _, userID := range memberIDs { + members = append(members, &model.FileShareMember{ShareID: share.ID, UserID: userID, CreateTime: share.CreateTime}) + } + return tx.Table(FileShareMemberTable).Select("share_id", "user_id", "create_time").Create(&members).Error +} + +func FindShare(database *gorm.DB, id int64, lock bool) (*model.FileShare, error) { + query := database.Table(FileShareTable).Select(fileShareColumns).Where("id = ?", id) + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var share model.FileShare + err := query.Take(&share).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &share, err +} + +func FindShareByToken(database *gorm.DB, token string, lock bool) (*model.FileShare, error) { + query := database.Session(&gorm.Session{Logger: logger.Discard}).Table(FileShareTable).Select(fileShareColumns).Where("token = ?", token) + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var share model.FileShare + err := query.Take(&share).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &share, err +} + +func FindOwnedShare(database *gorm.DB, ownerID, fileID, shareID int64, lock bool) (*model.FileShare, error) { + query := database.Table(FileShareTable).Select(fileShareColumns).Where("owner_user_id = ? AND file_id = ? AND id = ?", ownerID, fileID, shareID) + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var share model.FileShare + err := query.Take(&share).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &share, err +} + +func ListOwnedShares(database *gorm.DB, ownerID, fileID, cursor int64, limit int) ([]*model.FileShare, error) { + query := database.Table(FileShareTable).Select(fileShareColumns).Where("owner_user_id = ? AND file_id = ?", ownerID, fileID) + if cursor > 0 { + query = query.Where("id < ?", cursor) + } + var shares []*model.FileShare + err := query.Order("id DESC").Limit(limit).Find(&shares).Error + return shares, err +} + +func ListShareMembers(database *gorm.DB, shareIDs []int64) (map[int64][]int64, error) { + result := make(map[int64][]int64, len(shareIDs)) + if len(shareIDs) == 0 { + return result, nil + } + var rows []*model.FileShareMember + err := database.Table(FileShareMemberTable).Select("share_id", "user_id", "create_time").Where("share_id IN ?", shareIDs).Order("share_id ASC, user_id ASC").Find(&rows).Error + for _, row := range rows { + result[row.ShareID] = append(result[row.ShareID], row.UserID) + } + return result, err +} + +func IsFileShareMember(database *gorm.DB, shareID, userID int64, lock bool) (bool, error) { + query := database.Table(FileShareMemberTable).Select("share_id", "user_id").Where("share_id = ? AND user_id = ?", shareID, userID) + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var member model.FileShareMember + err := query.Take(&member).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return err == nil, err +} + +func RevokeOwnedShare(tx *gorm.DB, ownerID, fileID, shareID int64, revokedAt time.Time) (bool, error) { + result := tx.Table(FileShareTable).Where("owner_user_id = ? AND file_id = ? AND id = ? AND revoked_at IS NULL", ownerID, fileID, shareID).Updates(map[string]interface{}{"revoked_at": revokedAt, "update_time": revokedAt}) + return result.RowsAffected == 1, result.Error +} + +func IncrementDownloadCount(tx *gorm.DB, shareID int64, now time.Time) (bool, error) { + result := tx.Table(FileShareTable).Where("id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) AND (max_downloads = 0 OR download_count < max_downloads)", shareID, now).Updates(map[string]interface{}{"download_count": gorm.Expr("download_count + 1"), "update_time": now}) + return result.RowsAffected == 1, result.Error +} diff --git a/domain/dal/fileshare_integration_test.go b/domain/dal/fileshare_integration_test.go new file mode 100644 index 0000000..8794b1f --- /dev/null +++ b/domain/dal/fileshare_integration_test.go @@ -0,0 +1,131 @@ +package dal + +import ( + "bytes" + "fmt" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/mcoder2014/home_server/domain/dal/migrations" + "github.com/mcoder2014/home_server/domain/db" + "github.com/mcoder2014/home_server/domain/model" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type capturedSQLLog struct { + bytes.Buffer +} + +func (capture *capturedSQLLog) Printf(format string, values ...interface{}) { + _, _ = fmt.Fprintf(&capture.Buffer, format, values...) +} + +func fileSharingTestDatabase(t *testing.T) *gorm.DB { + t.Helper() + dsn := os.Getenv("FILE_SHARING_TEST_DSN") + if dsn == "" { + t.Skip("FILE_SHARING_TEST_DSN required") + } + parsed, err := mysql.ParseDSN(dsn) + if err != nil || parsed.DBName != "home_server_file_sharing_test_ci" { + t.Fatal("dedicated file sharing test database required") + } + if err := db.InitDatabase(dsn); err != nil { + t.Fatal(err) + } + database := db.MasterDB() + for _, table := range []string{FileShareMemberTable, FileShareTable, FileTable} { + if err := database.Exec("DROP TABLE IF EXISTS " + table).Error; err != nil { + t.Fatal(err) + } + } + raw, err := migrations.SQL.ReadFile("20260919_file_sharing.sql") + if err != nil { + t.Fatal(err) + } + for _, statement := range strings.Split(string(raw), ";") { + if strings.TrimSpace(statement) != "" { + if err := database.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + } + return database +} + +func TestFileShareDownloadLimitIsAtomic(t *testing.T) { + database := fileSharingTestDatabase(t) + now := time.Now() + file := &model.StoredFile{ID: 101, OwnerUserID: 7, OriginalName: "bounded.bin", StorageKey: "7/101/0123456789abcdef0123456789abcdef", SizeBytes: 10, SHA256: strings.Repeat("a", 64), CreateTime: now} + share := &model.FileShare{ID: 201, FileID: file.ID, OwnerUserID: file.OwnerUserID, Token: strings.Repeat("A", 43), AccessMode: model.FileShareAccessPublic, SecretMode: model.FileShareSecretNone, MaxDownloads: 1, CreateTime: now, UpdateTime: now} + if err := InsertFile(database, file); err != nil { + t.Fatal(err) + } + if err := InsertFileShare(database, share, nil); err != nil { + t.Fatal(err) + } + var starts atomic.Int64 + var group sync.WaitGroup + for index := 0; index < 24; index++ { + group.Add(1) + go func() { + defer group.Done() + err := database.Transaction(func(tx *gorm.DB) error { + updated, err := IncrementDownloadCount(tx, share.ID, time.Now()) + if err == nil && updated { + starts.Add(1) + } + return err + }) + if err != nil { + t.Errorf("increment failed: %v", err) + } + }() + } + group.Wait() + if starts.Load() != 1 { + t.Fatalf("one-download share admitted %d transfers", starts.Load()) + } + stored, err := FindShare(database, share.ID, false) + if err != nil || stored.DownloadCount != 1 { + t.Fatalf("stored count = %#v, %v", stored, err) + } +} + +func TestFileShareSecretsNeverEnterGORMLogs(t *testing.T) { + database := fileSharingTestDatabase(t) + capture := &capturedSQLLog{} + database = database.Session(&gorm.Session{Logger: logger.New(capture, logger.Config{LogLevel: logger.Info, Colorful: false})}) + var marker string + if err := database.Raw("SELECT ? AS marker", "ordinary-visible-marker").Scan(&marker).Error; err != nil { + t.Fatal(err) + } + if !strings.Contains(capture.String(), "ordinary-visible-marker") { + t.Fatal("test logger did not capture SQL parameters") + } + + now := time.Now() + file := &model.StoredFile{ID: 301, OwnerUserID: 9, OriginalName: "private.bin", StorageKey: "9/301/0123456789abcdef0123456789abcdef", SizeBytes: 1, SHA256: strings.Repeat("b", 64), CreateTime: now} + token := "sensitive-share-token-must-never-enter-gorm-log" + secretHash := "$2a$04$sensitive-hash-must-never-enter-gorm-log" + share := &model.FileShare{ID: 401, FileID: file.ID, OwnerUserID: file.OwnerUserID, Token: token, AccessMode: model.FileShareAccessAuthenticated, SecretMode: model.FileShareSecretPassword, SecretHash: secretHash, CreateTime: now, UpdateTime: now} + if err := InsertFile(database, file); err != nil { + t.Fatal(err) + } + if err := InsertFileShare(database, share, []int64{10}); err != nil { + t.Fatal(err) + } + if stored, err := FindShareByToken(database, token, false); err != nil || stored == nil || stored.ID != share.ID { + t.Fatalf("sensitive share lookup failed: share=%#v err=%v", stored, err) + } + logged := capture.String() + if strings.Contains(logged, token) || strings.Contains(logged, secretHash) { + t.Fatalf("GORM log exposed a file share credential: %s", logged) + } +} diff --git a/domain/dal/migrations/20260919_file_sharing.sql b/domain/dal/migrations/20260919_file_sharing.sql new file mode 100644 index 0000000..49f3df9 --- /dev/null +++ b/domain/dal/migrations/20260919_file_sharing.sql @@ -0,0 +1,43 @@ +CREATE TABLE files +( + id BIGINT NOT NULL PRIMARY KEY COMMENT 'file id generated by the service', + owner_user_id BIGINT NOT NULL COMMENT 'uploader and sole manager', + original_name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'display-only upload filename', + storage_key VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'opaque path relative to the private file root', + size_bytes BIGINT NOT NULL COMMENT 'verified stored bytes', + sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'lowercase content digest', + deleted_at DATETIME(6) NULL COMMENT 'logical deletion invalidates every share', + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uk_files_storage_key (storage_key), + KEY idx_files_owner_id (owner_user_id, id), + KEY idx_files_owner_deleted_id (owner_user_id, deleted_at, id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT 'privately stored ordinary files'; + +CREATE TABLE file_shares +( + id BIGINT NOT NULL PRIMARY KEY COMMENT 'share id generated by the service', + file_id BIGINT NOT NULL, + owner_user_id BIGINT NOT NULL COMMENT 'denormalized immutable owner for bounded queries', + token VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '32 random bytes encoded as base64url', + access_mode TINYINT UNSIGNED NOT NULL COMMENT '1 public, 2 authenticated, 3 members', + secret_mode TINYINT UNSIGNED NOT NULL COMMENT '1 none, 2 six-character code, 3 password', + secret_hash VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'bcrypt hash or empty string', + expires_at DATETIME(6) NULL, + max_downloads BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'zero means unlimited', + download_count BIGINT UNSIGNED NOT NULL DEFAULT 0, + revoked_at DATETIME(6) NULL, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + UNIQUE KEY uk_file_shares_token (token), + KEY idx_file_shares_file_id (file_id, id), + KEY idx_file_shares_owner_id (owner_user_id, id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT 'independent access policies for stored files'; + +CREATE TABLE file_share_members +( + share_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (share_id, user_id), + KEY idx_file_share_members_user_share (user_id, share_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT 'explicit readers for member file shares'; diff --git a/domain/dal/migrations/20260919_resource_passwords.sql b/domain/dal/migrations/20260919_resource_passwords.sql new file mode 100644 index 0000000..da38663 --- /dev/null +++ b/domain/dal/migrations/20260919_resource_passwords.sql @@ -0,0 +1,9 @@ +CREATE TABLE resource_passwords +( + resource_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'web_project or manual', + resource_id BIGINT NOT NULL COMMENT 'id in the resource-specific table', + password_hash VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '' COMMENT 'bcrypt hash, empty retains a cleared version', + version BIGINT UNSIGNED NOT NULL COMMENT 'monotonic password policy version starting at one', + update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (resource_type, resource_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT 'independent reading passwords for hosted resources'; diff --git a/domain/dal/resource_password.go b/domain/dal/resource_password.go new file mode 100644 index 0000000..420b98d --- /dev/null +++ b/domain/dal/resource_password.go @@ -0,0 +1,50 @@ +package dal + +import ( + "errors" + "time" + + "github.com/mcoder2014/home_server/domain/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/logger" +) + +const ResourcePasswordTable = "resource_passwords" + +var resourcePasswordColumns = []string{"resource_type", "resource_id", "password_hash", "version", "update_time"} + +func FindResourcePassword(database *gorm.DB, resourceType string, resourceID int64, lock bool) (*model.ResourcePassword, error) { + query := database.Table(ResourcePasswordTable).Select(resourcePasswordColumns).Where("resource_type = ? AND resource_id = ?", resourceType, resourceID) + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var record model.ResourcePassword + err := query.Take(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &record, err +} + +func ListResourcePasswords(database *gorm.DB, resourceType string, resourceIDs []int64) ([]*model.ResourcePassword, error) { + if len(resourceIDs) == 0 { + return []*model.ResourcePassword{}, nil + } + var records []*model.ResourcePassword + err := database.Table(ResourcePasswordTable).Select(resourcePasswordColumns). + Where("resource_type = ? AND resource_id IN ?", resourceType, resourceIDs). + Order("resource_id ASC").Find(&records).Error + return records, err +} + +func InsertResourcePassword(tx *gorm.DB, record *model.ResourcePassword) error { + return tx.Session(&gorm.Session{Logger: logger.Discard}).Table(ResourcePasswordTable).Select(resourcePasswordColumns).Create(record).Error +} + +func UpdateResourcePassword(tx *gorm.DB, resourceType string, resourceID, expectedVersion int64, passwordHash string, updatedAt time.Time) (bool, error) { + result := tx.Session(&gorm.Session{Logger: logger.Discard}).Table(ResourcePasswordTable). + Where("resource_type = ? AND resource_id = ? AND version = ?", resourceType, resourceID, expectedVersion). + Updates(map[string]interface{}{"password_hash": passwordHash, "version": gorm.Expr("version + 1"), "update_time": updatedAt}) + return result.RowsAffected == 1, result.Error +} diff --git a/domain/model/fileshare.go b/domain/model/fileshare.go new file mode 100644 index 0000000..b5454e1 --- /dev/null +++ b/domain/model/fileshare.go @@ -0,0 +1,104 @@ +package model + +import "time" + +type FileShareAccess uint8 + +const ( + FileShareAccessPublic FileShareAccess = 1 + FileShareAccessAuthenticated FileShareAccess = 2 + FileShareAccessMembers FileShareAccess = 3 +) + +func (value FileShareAccess) String() string { + switch value { + case FileShareAccessPublic: + return "public" + case FileShareAccessAuthenticated: + return "authenticated" + case FileShareAccessMembers: + return "members" + default: + return "" + } +} + +func ParseFileShareAccess(value string) (FileShareAccess, bool) { + switch value { + case "public": + return FileShareAccessPublic, true + case "authenticated": + return FileShareAccessAuthenticated, true + case "members": + return FileShareAccessMembers, true + default: + return 0, false + } +} + +type FileShareSecret uint8 + +const ( + FileShareSecretNone FileShareSecret = 1 + FileShareSecretCode FileShareSecret = 2 + FileShareSecretPassword FileShareSecret = 3 +) + +func (value FileShareSecret) String() string { + switch value { + case FileShareSecretNone: + return "none" + case FileShareSecretCode: + return "code" + case FileShareSecretPassword: + return "password" + default: + return "" + } +} + +func ParseFileShareSecret(value string) (FileShareSecret, bool) { + switch value { + case "none": + return FileShareSecretNone, true + case "code": + return FileShareSecretCode, true + case "password": + return FileShareSecretPassword, true + default: + return 0, false + } +} + +type StoredFile struct { + ID int64 `gorm:"column:id"` + OwnerUserID int64 `gorm:"column:owner_user_id"` + OriginalName string `gorm:"column:original_name"` + StorageKey string `gorm:"column:storage_key"` + SizeBytes int64 `gorm:"column:size_bytes"` + SHA256 string `gorm:"column:sha256"` + DeletedAt *time.Time `gorm:"column:deleted_at"` + CreateTime time.Time `gorm:"column:create_time"` +} + +type FileShare struct { + ID int64 `gorm:"column:id"` + FileID int64 `gorm:"column:file_id"` + OwnerUserID int64 `gorm:"column:owner_user_id"` + Token string `gorm:"column:token"` + AccessMode FileShareAccess `gorm:"column:access_mode"` + SecretMode FileShareSecret `gorm:"column:secret_mode"` + SecretHash string `gorm:"column:secret_hash"` + ExpiresAt *time.Time `gorm:"column:expires_at"` + MaxDownloads int64 `gorm:"column:max_downloads"` + DownloadCount int64 `gorm:"column:download_count"` + RevokedAt *time.Time `gorm:"column:revoked_at"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +type FileShareMember struct { + ShareID int64 `gorm:"column:share_id"` + UserID int64 `gorm:"column:user_id"` + CreateTime time.Time `gorm:"column:create_time"` +} diff --git a/domain/model/resource_password.go b/domain/model/resource_password.go new file mode 100644 index 0000000..bc61aed --- /dev/null +++ b/domain/model/resource_password.go @@ -0,0 +1,15 @@ +package model + +import "time" + +// ResourcePassword stores only a bcrypt verifier. A retained empty row records +// the monotonic version after password protection has been cleared. +type ResourcePassword struct { + ResourceType string `gorm:"column:resource_type;primaryKey" json:"-"` + ResourceID int64 `gorm:"column:resource_id;primaryKey" json:"-"` + PasswordHash string `gorm:"column:password_hash" json:"-"` + Version int64 `gorm:"column:version" json:"version"` + UpdateTime time.Time `gorm:"column:update_time" json:"-"` +} + +func (ResourcePassword) TableName() string { return "resource_passwords" } diff --git a/domain/repository/webprojects/password.go b/domain/repository/webprojects/password.go new file mode 100644 index 0000000..8877d16 --- /dev/null +++ b/domain/repository/webprojects/password.go @@ -0,0 +1,62 @@ +package webprojects + +import ( + "context" + + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/db" + "github.com/mcoder2014/home_server/domain/model" + "github.com/mcoder2014/home_server/domain/service/accounts" + "github.com/mcoder2014/home_server/domain/service/passport" + "github.com/mcoder2014/home_server/domain/service/resourcepasswords" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" + "gorm.io/gorm" +) + +// SetResourcePassword serializes password version changes behind the owning +// project row and revalidates the exact browser session before committing. +func (repository *Repository) SetResourcePassword(ctx context.Context, ownerUserID, projectID, expectedVersion int64, passwordHash string, principal *utils.Principal) (*resourcepasswords.State, error) { + if db.MasterDB() == nil { + return nil, apperrors.ErrDependency + } + var state *resourcepasswords.State + err := db.MasterDB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + principals := []*utils.Principal{principal} + if err := requireWritePolicy(tx, ownerUserID, principals, nil); err != nil { + return err + } + if principal == nil || principal.UserID != ownerUserID { + return apperrors.ErrUnauthorized + } + if principal.Kind == "user" { + if accounts.DatabaseMode() { + user, err := accounts.RequireUserSessionTx(tx, utils.GetTokenFromCtx(ctx), ownerUserID) + if err != nil { + return err + } + if user.AuthVersion != principal.AuthVersion { + return apperrors.ErrUnauthorized + } + } else if err := passport.RequireConfigSessionTx(ctx, tx, utils.GetTokenFromCtx(ctx), ownerUserID, principal.AuthVersion); err != nil { + return err + } + } + project, err := dal.LockOwnedWebProject(tx, ownerUserID, projectID) + if err != nil { + return err + } + if project == nil || project.Status == model.WebProjectStatusDeleted { + return apperrors.ErrNotFound + } + if project.ModerationStatus != "" && project.ModerationStatus != "normal" { + return apperrors.ErrForbidden + } + if err := checkWriteExpiry(principals); err != nil { + return err + } + state, err = resourcepasswords.SetTx(tx, resourcepasswords.ResourceWebProject, projectID, expectedVersion, passwordHash) + return err + }) + return state, err +} diff --git a/domain/service/accounts/service.go b/domain/service/accounts/service.go index eaff635..a32b133 100644 --- a/domain/service/accounts/service.go +++ b/domain/service/accounts/service.go @@ -311,11 +311,14 @@ func UserIdentity(user *model.UserAccount) *model.UserIdentity { // EnabledTx 从配置文件或事务内的配置行读取布尔开关;数据库配置需通过版本、摘要和字段校验。 func EnabledTx(tx *gorm.DB, namespace, key string, lock bool) (bool, error) { conf := config.Global() - if namespace == "manuals" { + if namespace == "manuals" || namespace == "file_sharing" { if key != "enabled" { return false, apperrors.ErrDependency } - return conf.Manuals.Enabled, nil + if namespace == "manuals" { + return conf.Manuals.Enabled, nil + } + return conf.FileSharing.Enabled, nil } if conf.ConfigSource != "database" { value, ok := config.DefaultRuntimeValues(conf)[namespace][key].(bool) @@ -348,8 +351,11 @@ func EnabledTx(tx *gorm.DB, namespace, key string, lock bool) (bool, error) { } func ModuleEnabled(ctx context.Context, namespace string) (bool, error) { - if namespace == "manuals" { - return config.Global().Manuals.Enabled, nil + if namespace == "manuals" || namespace == "file_sharing" { + if namespace == "manuals" { + return config.Global().Manuals.Enabled, nil + } + return config.Global().FileSharing.Enabled, nil } key := "enabled" if namespace == "auth" { diff --git a/domain/service/applications/service.go b/domain/service/applications/service.go index c8fb7ef..a130ad7 100644 --- a/domain/service/applications/service.go +++ b/domain/service/applications/service.go @@ -36,6 +36,8 @@ const ( ScopeWebDAVWrite = "webdav:write" ScopeManualsRead = "manuals:read" ScopeManualsWrite = "manuals:write" + ScopeFilesRead = "files:read" + ScopeFilesWrite = "files:write" StatusEnabledName = "enabled" StatusDisabledName = "disabled" @@ -58,6 +60,8 @@ var orderedScopes = []string{ ScopeWebDAVWrite, ScopeManualsRead, ScopeManualsWrite, + ScopeFilesRead, + ScopeFilesWrite, } var readForWrite = map[string]string{ @@ -66,6 +70,7 @@ var readForWrite = map[string]string{ ScopeLibraryWrite: ScopeLibraryRead, ScopeWebDAVWrite: ScopeWebDAVRead, ScopeManualsWrite: ScopeManualsRead, + ScopeFilesWrite: ScopeFilesRead, } type Repository interface { diff --git a/domain/service/fileshare/storage.go b/domain/service/fileshare/storage.go new file mode 100644 index 0000000..0ed0ce1 --- /dev/null +++ b/domain/service/fileshare/storage.go @@ -0,0 +1,259 @@ +package fileshare + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "math" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/mcoder2014/home_server/config" +) + +type StoredUpload struct { + StorageKey string + SizeBytes int64 + SHA256 string +} + +func Init(conf *config.FileSharingConfig, otherRoots ...string) error { + if conf == nil { + return fmt.Errorf("file_sharing configuration is missing") + } + applyDefaults(conf) + if conf.MaxFileBytes < 1 || conf.MaxFilesPerUser < 1 || conf.MaxFilesPerUser > 100000 || conf.MaxUserBytes < conf.MaxFileBytes || conf.MinFreeDiskBytes < 0 || conf.MaxConcurrentUploadsPerUser < 1 || conf.MaxConcurrentUploadsPerUser > 100 || conf.MaxConcurrentUploads < conf.MaxConcurrentUploadsPerUser || conf.MaxConcurrentUploads > 1000 { + return fmt.Errorf("invalid file_sharing storage limits") + } + if !conf.Enabled { + return nil + } + if !filepath.IsAbs(conf.StorageRoot) { + return fmt.Errorf("file_sharing.storage_root must be an absolute path") + } + if err := os.MkdirAll(conf.StorageRoot, 0700); err != nil { + return fmt.Errorf("create file sharing storage root: %w", err) + } + rootInfo, err := os.Lstat(conf.StorageRoot) + if err != nil || rootInfo.Mode()&os.ModeSymlink != 0 || !rootInfo.IsDir() { + return fmt.Errorf("file_sharing.storage_root must be a regular directory") + } + for _, other := range otherRoots { + if strings.TrimSpace(other) == "" { + continue + } + if err := rejectStorageOverlap(conf.StorageRoot, other); err != nil { + return err + } + } + if err := os.Chmod(conf.StorageRoot, 0700); err != nil { + return fmt.Errorf("secure file sharing storage root: %w", err) + } + return os.MkdirAll(filepath.Join(conf.StorageRoot, ".staging"), 0700) +} + +func applyDefaults(conf *config.FileSharingConfig) { + if conf.MaxFileBytes <= 0 { + conf.MaxFileBytes = 50 << 20 + } + if conf.MaxFilesPerUser <= 0 { + conf.MaxFilesPerUser = 1000 + } + if conf.MaxUserBytes <= 0 { + conf.MaxUserBytes = 10 << 30 + } + if conf.MinFreeDiskBytes <= 0 { + conf.MinFreeDiskBytes = 2 << 30 + } + if conf.MaxConcurrentUploadsPerUser <= 0 { + conf.MaxConcurrentUploadsPerUser = 2 + } + if conf.MaxConcurrentUploads <= 0 { + conf.MaxConcurrentUploads = 4 + } +} + +func rejectStorageOverlap(storageRoot, otherRoot string) error { + left, err := canonicalPath(storageRoot) + if err != nil { + return fmt.Errorf("resolve file_sharing storage: %w", err) + } + right, err := canonicalPath(otherRoot) + if err != nil { + return fmt.Errorf("resolve existing storage: %w", err) + } + separator := string(os.PathSeparator) + if left == right || strings.HasPrefix(left, right+separator) || strings.HasPrefix(right, left+separator) { + return fmt.Errorf("file_sharing.storage_root must not overlap another storage root") + } + return nil +} + +func canonicalPath(value string) (string, error) { + absolute, err := filepath.Abs(value) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(absolute) + if err == nil { + return filepath.Clean(resolved), nil + } + parent, parentErr := filepath.EvalSymlinks(filepath.Dir(absolute)) + if parentErr != nil { + return "", err + } + return filepath.Join(parent, filepath.Base(absolute)), nil +} + +// StoreUpload writes to private staging, fsyncs the complete bounded file, then +// atomically renames it under service-generated owner and file directories. +func StoreUpload(conf *config.FileSharingConfig, ownerID, fileID int64, source io.Reader) (*StoredUpload, error) { + if conf == nil || !conf.Enabled || ownerID <= 0 || fileID <= 0 || source == nil { + return nil, ErrDependency + } + temporary, err := os.CreateTemp(filepath.Join(conf.StorageRoot, ".staging"), "upload-") + if err != nil { + return nil, ErrDependency + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + digest := sha256.New() + written, copyErr := io.Copy(io.MultiWriter(temporary, digest), io.LimitReader(source, conf.MaxFileBytes+1)) + syncErr := temporary.Sync() + closeErr := temporary.Close() + if copyErr != nil || syncErr != nil || closeErr != nil { + return nil, ErrDependency + } + if written > conf.MaxFileBytes { + return nil, ErrTooLarge + } + ownerDirectory, err := ensureDirectory(conf.StorageRoot, strconv.FormatInt(ownerID, 10)) + if err != nil { + return nil, ErrDependency + } + fileDirectory, err := ensureDirectory(ownerDirectory, strconv.FormatInt(fileID, 10)) + if err != nil { + return nil, ErrDependency + } + randomName, err := randomStorageName() + if err != nil { + return nil, ErrDependency + } + finalPath := filepath.Join(fileDirectory, randomName) + if err := os.Rename(temporaryPath, finalPath); err != nil { + return nil, ErrDependency + } + if err := syncDirectory(fileDirectory); err != nil { + _ = os.Remove(finalPath) + return nil, ErrDependency + } + return &StoredUpload{StorageKey: path.Join(strconv.FormatInt(ownerID, 10), strconv.FormatInt(fileID, 10), randomName), SizeBytes: written, SHA256: hex.EncodeToString(digest.Sum(nil))}, nil +} + +func ensureDirectory(parent, name string) (string, error) { + target := filepath.Join(parent, name) + if err := os.Mkdir(target, 0700); err != nil && !os.IsExist(err) { + return "", err + } + info, err := os.Lstat(target) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "", fmt.Errorf("unsafe storage directory") + } + if err := os.Chmod(target, 0700); err != nil { + return "", err + } + return target, nil +} + +func randomStorageName() (string, error) { + raw := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, raw); err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} + +func OpenStoredFile(conf *config.FileSharingConfig, key string) (*os.File, os.FileInfo, error) { + if conf == nil || !validStorageKey(key) { + return nil, nil, ErrNotFound + } + root, err := filepath.EvalSymlinks(conf.StorageRoot) + if err != nil { + return nil, nil, ErrDependency + } + target := filepath.Join(conf.StorageRoot, filepath.FromSlash(key)) + resolved, err := filepath.EvalSymlinks(target) + if err != nil || !strings.HasPrefix(resolved, root+string(os.PathSeparator)) { + return nil, nil, ErrNotFound + } + file, err := os.Open(resolved) + if err != nil { + return nil, nil, ErrNotFound + } + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + _ = file.Close() + return nil, nil, ErrNotFound + } + return file, info, nil +} + +func RemoveStoredFile(conf *config.FileSharingConfig, key string) error { + file, _, err := OpenStoredFile(conf, key) + if err != nil { + if err == ErrNotFound { + return nil + } + return err + } + name := file.Name() + _ = file.Close() + if err := os.Remove(name); err != nil && !os.IsNotExist(err) { + return ErrDependency + } + return nil +} + +func validStorageKey(key string) bool { + if key == "" || path.Clean(key) != key || strings.Contains(key, "\\") || strings.HasPrefix(key, "/") { + return false + } + parts := strings.Split(key, "/") + if len(parts) != 3 || len(parts[2]) != 32 { + return false + } + for _, part := range parts[:2] { + value, err := strconv.ParseInt(part, 10, 64) + if err != nil || value <= 0 || strconv.FormatInt(value, 10) != part { + return false + } + } + _, err := hex.DecodeString(parts[2]) + return err == nil +} + +func DiskFreeBytes(root string) (uint64, error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(root, &stat); err != nil { + return 0, err + } + if stat.Bsize <= 0 || uint64(stat.Bavail) > math.MaxUint64/uint64(stat.Bsize) { + return 0, fmt.Errorf("invalid filesystem free-space result") + } + return uint64(stat.Bavail) * uint64(stat.Bsize), nil +} + +func syncDirectory(directory string) error { + file, err := os.Open(directory) + if err != nil { + return err + } + defer file.Close() + return file.Sync() +} diff --git a/domain/service/fileshare/storage_test.go b/domain/service/fileshare/storage_test.go new file mode 100644 index 0000000..cb90d6a --- /dev/null +++ b/domain/service/fileshare/storage_test.go @@ -0,0 +1,65 @@ +package fileshare + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mcoder2014/home_server/config" +) + +func TestPrivateStorageRejectsOverlapAndSymlinkReplacement(t *testing.T) { + root := t.TempDir() + shared := filepath.Join(root, "shared") + if err := os.Mkdir(shared, 0700); err != nil { + t.Fatal(err) + } + conf := config.FileSharingConfig{Enabled: true, StorageRoot: filepath.Join(shared, "files")} + if err := Init(&conf, shared); err == nil || !strings.Contains(err.Error(), "must not overlap") { + t.Fatalf("nested private root was accepted: %v", err) + } + conf.StorageRoot = filepath.Join(root, "private") + if err := Init(&conf, shared); err != nil { + t.Fatal(err) + } + stored, err := StoreUpload(&conf, 7, 9, strings.NewReader("ordinary bytes")) + if err != nil { + t.Fatal(err) + } + file, info, err := OpenStoredFile(&conf, stored.StorageKey) + if err != nil || info.Size() != int64(len("ordinary bytes")) { + t.Fatalf("stored upload cannot be opened: %v", err) + } + _ = file.Close() + path := filepath.Join(conf.StorageRoot, filepath.FromSlash(stored.StorageKey)) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(shared, "outside"), path); err != nil { + t.Fatal(err) + } + if _, _, err := OpenStoredFile(&conf, stored.StorageKey); err != ErrNotFound { + t.Fatalf("symlink replacement was not hidden: %v", err) + } +} + +func TestPrivateStorageAcceptsEmptyFile(t *testing.T) { + conf := config.FileSharingConfig{Enabled: true, StorageRoot: filepath.Join(t.TempDir(), "private")} + if err := Init(&conf); err != nil { + t.Fatal(err) + } + stored, err := StoreUpload(&conf, 7, 10, bytes.NewReader(nil)) + if err != nil { + t.Fatal(err) + } + if stored.SizeBytes != 0 || stored.SHA256 != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { + t.Fatalf("empty file metadata = %#v", stored) + } + file, info, err := OpenStoredFile(&conf, stored.StorageKey) + if err != nil || info.Size() != 0 { + t.Fatalf("empty file cannot be opened: info=%#v err=%v", info, err) + } + _ = file.Close() +} diff --git a/domain/service/fileshare/types.go b/domain/service/fileshare/types.go new file mode 100644 index 0000000..dfd7169 --- /dev/null +++ b/domain/service/fileshare/types.go @@ -0,0 +1,308 @@ +package fileshare + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/mcoder2014/home_server/domain/model" + apperrors "github.com/mcoder2014/home_server/errors" +) + +var ( + ErrInvalid = apperrors.ErrInvalid + ErrUnauthorized = apperrors.ErrUnauthorized + ErrForbidden = apperrors.ErrForbidden + ErrNotFound = apperrors.ErrNotFound + ErrConflict = apperrors.ErrConflict + ErrTooLarge = apperrors.ErrTooLarge + ErrUnprocessable = apperrors.ErrUnprocessable + ErrRateLimited = apperrors.ErrRateLimited + ErrDependency = apperrors.ErrDependency +) + +type CreateShareInput struct { + AccessMode string `json:"access_mode"` + MemberUserIDs []string `json:"member_user_ids"` + SecretMode string `json:"secret_mode"` + Secret string `json:"secret"` + ExpiresAt *time.Time `json:"expires_at"` + MaxDownloads int64 `json:"max_downloads"` +} + +type NormalizedShareInput struct { + AccessMode model.FileShareAccess + MemberUserIDs []int64 + SecretMode model.FileShareSecret + Secret string + ExpiresAt *time.Time + MaxDownloads int64 +} + +type FileView struct { + ID string `json:"id"` + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` + ShareCount int64 `json:"share_count"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + CreateTime time.Time `json:"create_time"` +} + +type FilePage struct { + Items []*FileView `json:"items"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` +} + +type EligibleUser struct { + ID string `json:"id"` + UserName string `json:"user_name"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` +} + +type ShareView struct { + ID string `json:"id"` + FileID string `json:"file_id"` + Token string `json:"token"` + URL string `json:"url"` + AccessMode string `json:"access_mode"` + MemberUserIDs []string `json:"member_user_ids"` + SecretMode string `json:"secret_mode"` + ExpiresAt *time.Time `json:"expires_at"` + MaxDownloads int64 `json:"max_downloads"` + DownloadCount int64 `json:"download_count"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + CreateTime time.Time `json:"create_time"` +} + +type SharePage struct { + Items []*ShareView `json:"items"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` +} + +type CreateShareResult struct { + Share *ShareView `json:"share"` + Secret string `json:"secret"` +} + +type PublicFile struct { + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` +} + +type PublicShareState struct { + State string `json:"state"` + File *PublicFile `json:"file,omitempty"` + ExpiresAt *time.Time `json:"expires_at"` + MaxDownloads int64 `json:"max_downloads"` + DownloadCount int64 `json:"download_count"` + RemainingDownloads *int64 `json:"remaining_downloads"` + SecretMode string `json:"secret_mode"` + AccessMode string `json:"access_mode"` +} + +type ShareSnapshot struct { + AccessMode string + SecretMode string + ExpiresAt *time.Time + MaxDownloads int64 + DownloadCount int64 + RevokedAt *time.Time + FileDeleted bool + Member bool + OwnerUserID int64 + FileName string + FileSize int64 +} + +type Viewer struct { + UserID int64 +} + +func NormalizeShareInput(input CreateShareInput, random io.Reader, now time.Time) (*NormalizedShareInput, error) { + access, ok := model.ParseFileShareAccess(input.AccessMode) + if !ok || input.MaxDownloads < 0 || input.ExpiresAt != nil && !input.ExpiresAt.After(now) { + return nil, ErrInvalid + } + secretMode, ok := model.ParseFileShareSecret(input.SecretMode) + if !ok { + return nil, ErrInvalid + } + members, err := parseMemberIDs(input.MemberUserIDs) + if err != nil || (access == model.FileShareAccessMembers && len(members) == 0) || (access != model.FileShareAccessMembers && len(members) != 0) { + return nil, ErrInvalid + } + secret := input.Secret + switch secretMode { + case model.FileShareSecretNone: + if secret != "" { + return nil, ErrInvalid + } + case model.FileShareSecretCode: + if access != model.FileShareAccessPublic { + return nil, ErrInvalid + } + if secret == "" { + secret, err = randomCode(random) + } + if err != nil || !ValidCode(secret) { + return nil, ErrInvalid + } + case model.FileShareSecretPassword: + if access == model.FileShareAccessPublic || !validPassword(secret) { + return nil, ErrInvalid + } + } + return &NormalizedShareInput{AccessMode: access, MemberUserIDs: members, SecretMode: secretMode, Secret: secret, ExpiresAt: input.ExpiresAt, MaxDownloads: input.MaxDownloads}, nil +} + +func ValidCode(value string) bool { + if len(value) != 6 { + return false + } + for _, character := range []byte(value) { + if character < '0' || character > '9' { + if character < 'A' || character > 'Z' { + if character < 'a' || character > 'z' { + return false + } + } + } + } + return true +} + +func validPassword(value string) bool { + return utf8.ValidString(value) && len([]byte(value)) >= 8 && len([]byte(value)) <= 72 +} + +func randomCode(source io.Reader) (string, error) { + if source == nil { + source = rand.Reader + } + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" + raw := make([]byte, 6) + if _, err := io.ReadFull(source, raw); err != nil { + return "", err + } + for index := range raw { + raw[index] = alphabet[int(raw[index])%len(alphabet)] + } + return string(raw), nil +} + +func NewToken(source io.Reader) (string, error) { + if source == nil { + source = rand.Reader + } + raw := make([]byte, 32) + if _, err := io.ReadFull(source, raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func ValidToken(token string) bool { + if len(token) < 32 || len(token) > 128 { + return false + } + for _, character := range token { + if character != '-' && character != '_' && (character < '0' || character > '9') && (character < 'A' || character > 'Z') && (character < 'a' || character > 'z') { + return false + } + } + return true +} + +func PublicState(snapshot ShareSnapshot, viewer Viewer, unlocked bool, now time.Time) (*PublicShareState, error) { + if snapshot.FileDeleted || snapshot.RevokedAt != nil || snapshot.ExpiresAt != nil && !snapshot.ExpiresAt.After(now) || snapshot.MaxDownloads > 0 && snapshot.DownloadCount >= snapshot.MaxDownloads { + return nil, ErrNotFound + } + if snapshot.AccessMode == "authenticated" && viewer.UserID <= 0 || snapshot.AccessMode == "members" && viewer.UserID <= 0 { + return &PublicShareState{State: "login_required", AccessMode: snapshot.AccessMode, SecretMode: snapshot.SecretMode}, nil + } + if snapshot.AccessMode == "members" && viewer.UserID != snapshot.OwnerUserID && !snapshot.Member { + return nil, ErrNotFound + } + state := "available" + if snapshot.SecretMode != "" && snapshot.SecretMode != "none" && viewer.UserID != snapshot.OwnerUserID && !unlocked { + return &PublicShareState{State: "locked", AccessMode: snapshot.AccessMode, SecretMode: snapshot.SecretMode}, nil + } + result := &PublicShareState{State: state, File: &PublicFile{Name: snapshot.FileName, SizeBytes: snapshot.FileSize}, ExpiresAt: snapshot.ExpiresAt, MaxDownloads: snapshot.MaxDownloads, DownloadCount: snapshot.DownloadCount, SecretMode: snapshot.SecretMode, AccessMode: snapshot.AccessMode} + if snapshot.MaxDownloads > 0 { + remaining := snapshot.MaxDownloads - snapshot.DownloadCount + result.RemainingDownloads = &remaining + } + return result, nil +} + +func ParsePositiveID(value string) (int64, error) { + id, err := strconv.ParseInt(value, 10, 64) + if err != nil || id <= 0 || strconv.FormatInt(id, 10) != value { + return 0, ErrInvalid + } + return id, nil +} + +func ParsePagination(cursorValue, limitValue string) (int64, int, error) { + cursor, limit := int64(0), 20 + var err error + if cursorValue != "" { + cursor, err = ParsePositiveID(cursorValue) + if err != nil { + return 0, 0, err + } + } + if limitValue != "" { + limit, err = strconv.Atoi(limitValue) + if err != nil || limit < 1 || limit > 100 { + return 0, 0, ErrInvalid + } + } + return cursor, limit, nil +} + +func SafeFilename(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" || !utf8.ValidString(value) || utf8.RuneCountInString(value) > 255 || strings.ContainsAny(value, "\x00\r\n/\\") { + return "", ErrInvalid + } + return value, nil +} + +func parseMemberIDs(values []string) ([]int64, error) { + if len(values) > 100 { + return nil, ErrInvalid + } + seen := make(map[int64]bool, len(values)) + ids := make([]int64, 0, len(values)) + for _, value := range values { + id, err := ParsePositiveID(value) + if err != nil { + return nil, err + } + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids, nil +} + +func ClassifyStorageError(err error) error { + if errors.Is(err, ErrInvalid) || errors.Is(err, ErrTooLarge) || errors.Is(err, ErrRateLimited) { + return err + } + return fmt.Errorf("%w: file storage failure", ErrDependency) +} diff --git a/domain/service/fileshare/validation_test.go b/domain/service/fileshare/validation_test.go new file mode 100644 index 0000000..c97e0be --- /dev/null +++ b/domain/service/fileshare/validation_test.go @@ -0,0 +1,81 @@ +package fileshare + +import ( + "strings" + "testing" + "time" +) + +func TestNormalizeShareInputEnforcesModeAndSecretContracts(t *testing.T) { + tests := []struct { + name string + input CreateShareInput + wantErr bool + }{ + {name: "public without secret", input: CreateShareInput{AccessMode: "public", SecretMode: "none"}}, + {name: "generated public code", input: CreateShareInput{AccessMode: "public", SecretMode: "code"}}, + {name: "explicit public code", input: CreateShareInput{AccessMode: "public", SecretMode: "code", Secret: "aB30Zx"}}, + {name: "authenticated password", input: CreateShareInput{AccessMode: "authenticated", SecretMode: "password", Secret: "eight-byte-secret"}}, + {name: "members", input: CreateShareInput{AccessMode: "members", MemberUserIDs: []string{"42", "7", "42"}, SecretMode: "none"}}, + {name: "public cannot name members", input: CreateShareInput{AccessMode: "public", MemberUserIDs: []string{"42"}, SecretMode: "none"}, wantErr: true}, + {name: "members need members", input: CreateShareInput{AccessMode: "members", SecretMode: "none"}, wantErr: true}, + {name: "code is exactly six ascii alnum", input: CreateShareInput{AccessMode: "public", SecretMode: "code", Secret: "abcdefg"}, wantErr: true}, + {name: "code rejects unicode", input: CreateShareInput{AccessMode: "public", SecretMode: "code", Secret: "密码abcd"}, wantErr: true}, + {name: "password is not a public code", input: CreateShareInput{AccessMode: "public", SecretMode: "password", Secret: "eight-byte-secret"}, wantErr: true}, + {name: "code is not a login password", input: CreateShareInput{AccessMode: "authenticated", SecretMode: "code", Secret: "aB30Zx"}, wantErr: true}, + {name: "password minimum bytes", input: CreateShareInput{AccessMode: "authenticated", SecretMode: "password", Secret: "short"}, wantErr: true}, + {name: "password maximum bytes", input: CreateShareInput{AccessMode: "authenticated", SecretMode: "password", Secret: strings.Repeat("x", 73)}, wantErr: true}, + {name: "negative downloads", input: CreateShareInput{AccessMode: "public", SecretMode: "none", MaxDownloads: -1}, wantErr: true}, + {name: "past expiry", input: CreateShareInput{AccessMode: "public", SecretMode: "none", ExpiresAt: timePointer(time.Unix(99, 0))}, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := NormalizeShareInput(test.input, strings.NewReader(strings.Repeat("A", 64)), time.Unix(100, 0)) + if (err != nil) != test.wantErr { + t.Fatalf("NormalizeShareInput() error = %v, wantErr %v", err, test.wantErr) + } + if err == nil && test.input.SecretMode == "code" { + if len(result.Secret) != 6 || !ValidCode(result.Secret) { + t.Fatalf("normalized code %q is invalid", result.Secret) + } + } + if err == nil && test.input.AccessMode == "members" && len(result.MemberUserIDs) != 2 { + t.Fatalf("members were not sorted and deduplicated: %#v", result.MemberUserIDs) + } + }) + } +} + +func TestPublicStateDoesNotDistinguishTerminalReasons(t *testing.T) { + now := time.Unix(1000, 0) + terminal := []ShareSnapshot{ + {FileDeleted: true}, + {RevokedAt: timePointer(now.Add(-time.Second))}, + {ExpiresAt: timePointer(now.Add(-time.Second))}, + {MaxDownloads: 2, DownloadCount: 2}, + } + for _, snapshot := range terminal { + state, err := PublicState(snapshot, Viewer{}, false, now) + if err != ErrNotFound || state != nil { + t.Fatalf("terminal share leaked its reason: state=%#v err=%v", state, err) + } + } +} + +func TestPublicStateSeparatesLoginAndSecretWithoutLeakingMembership(t *testing.T) { + now := time.Unix(1000, 0) + state, err := PublicState(ShareSnapshot{AccessMode: "authenticated", SecretMode: "password"}, Viewer{}, false, now) + if err != nil || state.State != "login_required" { + t.Fatalf("anonymous authenticated state = %#v, %v", state, err) + } + state, err = PublicState(ShareSnapshot{AccessMode: "authenticated", SecretMode: "password"}, Viewer{UserID: 7}, false, now) + if err != nil || state.State != "locked" { + t.Fatalf("authenticated locked state = %#v, %v", state, err) + } + state, err = PublicState(ShareSnapshot{AccessMode: "members", Member: false}, Viewer{UserID: 7}, true, now) + if err != ErrNotFound || state != nil { + t.Fatalf("non-member learned share state: %#v, %v", state, err) + } +} + +func timePointer(value time.Time) *time.Time { return &value } diff --git a/domain/service/manuals/types.go b/domain/service/manuals/types.go index 7b56d58..0c640b9 100644 --- a/domain/service/manuals/types.go +++ b/domain/service/manuals/types.go @@ -70,21 +70,22 @@ type ItemView struct { } type ManualView struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Categories []string `json:"categories"` - AccessMode string `json:"access_mode"` - Status string `json:"status"` - Revision int64 `json:"revision"` - CoverItemID *string `json:"cover_item_id"` - CoverURL string `json:"cover_url"` - Cover *CoverView `json:"cover"` - ItemCount int `json:"item_count"` - CanEdit bool `json:"can_edit"` - CreateTime time.Time `json:"create_time"` - UpdateTime time.Time `json:"update_time"` - Items []*ItemView `json:"items,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Categories []string `json:"categories"` + AccessMode string `json:"access_mode"` + Status string `json:"status"` + Revision int64 `json:"revision"` + CoverItemID *string `json:"cover_item_id"` + CoverURL string `json:"cover_url"` + Cover *CoverView `json:"cover"` + ItemCount int `json:"item_count"` + CanEdit bool `json:"can_edit"` + PasswordProtected bool `json:"password_protected"` + CreateTime time.Time `json:"create_time"` + UpdateTime time.Time `json:"update_time"` + Items []*ItemView `json:"items,omitempty"` } type ManualPage struct { diff --git a/domain/service/resourcepasswords/service.go b/domain/service/resourcepasswords/service.go new file mode 100644 index 0000000..ca01814 --- /dev/null +++ b/domain/service/resourcepasswords/service.go @@ -0,0 +1,402 @@ +package resourcepasswords + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/db" + "github.com/mcoder2014/home_server/domain/model" + apperrors "github.com/mcoder2014/home_server/errors" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type ResourceType string + +const ( + ResourceWebProject ResourceType = "web_project" + ResourceManual ResourceType = "manual" + GrantTTL = time.Hour + grantFormatVersion byte = 1 +) + +var ErrPasswordRequired = apperrors.WithMessage(apperrors.ErrForbidden, "resource password required") + +type State struct { + PasswordProtected bool `json:"password_protected"` + Version int64 `json:"version"` +} + +type UpdateInput struct { + Password string `json:"password"` + Version int64 `json:"version"` +} + +type UnlockInput struct { + Password string `json:"password"` +} + +type Grant struct { + Token string + ExpiresAt time.Time +} + +type Service struct { + secret []byte + now func() time.Time + random io.Reader + attempts *attemptLimiter +} + +var Default = mustDefaultService() + +func mustDefaultService() *Service { + secret := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, secret); err != nil { + panic("initialize resource password signer: " + err.Error()) + } + return newService(secret, time.Now, rand.Reader) +} + +func New(secret []byte) (*Service, error) { + if len(secret) < 32 { + return nil, fmt.Errorf("resource password signing secret must contain at least 32 bytes") + } + return newService(secret, time.Now, rand.Reader), nil +} + +func newTestService(secret []byte, now func() time.Time) *Service { + return newService(secret, now, strings.NewReader(strings.Repeat("x", 128))) +} + +func newService(secret []byte, now func() time.Time, random io.Reader) *Service { + copySecret := append([]byte(nil), secret...) + return &Service{ + secret: copySecret, now: now, random: random, + attempts: newAttemptLimiter(4096, 8, 8, time.Minute, now), + } +} + +func ValidateManagedPassword(password string) error { + if password == "" { + return nil + } + if !utf8.ValidString(password) || len([]byte(password)) < 8 || len([]byte(password)) > 72 { + return apperrors.ErrInvalid + } + return nil +} + +func PrepareManagedPassword(password string) (string, error) { + if err := ValidateManagedPassword(password); err != nil { + return "", err + } + if password == "" { + return "", nil + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", apperrors.ErrDependency + } + return string(hash), nil +} + +func (service *Service) State(ctx context.Context, resourceType ResourceType, resourceID int64) (*State, error) { + if !validResource(resourceType, resourceID) || db.MasterDB() == nil { + return nil, apperrors.ErrDependency + } + record, err := dal.FindResourcePassword(db.MasterDB().WithContext(ctx), string(resourceType), resourceID, false) + if err != nil { + return nil, apperrors.ErrDependency + } + return stateOf(record), nil +} + +func (service *Service) States(ctx context.Context, resourceType ResourceType, resourceIDs []int64) (map[int64]State, error) { + result := make(map[int64]State, len(resourceIDs)) + if !validType(resourceType) || db.MasterDB() == nil { + return nil, apperrors.ErrDependency + } + for _, resourceID := range resourceIDs { + if resourceID <= 0 { + return nil, apperrors.ErrInvalid + } + result[resourceID] = State{} + } + records, err := dal.ListResourcePasswords(db.MasterDB().WithContext(ctx), string(resourceType), resourceIDs) + if err != nil { + return nil, apperrors.ErrDependency + } + for _, record := range records { + result[record.ResourceID] = *stateOf(record) + } + return result, nil +} + +func SetTx(tx *gorm.DB, resourceType ResourceType, resourceID, expectedVersion int64, passwordHash string) (*State, error) { + if tx == nil || !validResource(resourceType, resourceID) || expectedVersion < 0 || (passwordHash != "" && len(passwordHash) > 100) { + return nil, apperrors.ErrInvalid + } + record, err := dal.FindResourcePassword(tx, string(resourceType), resourceID, true) + if err != nil { + return nil, apperrors.ErrDependency + } + now := time.Now() + if record == nil { + if expectedVersion != 0 { + return nil, apperrors.ErrConflict + } + record = &model.ResourcePassword{ResourceType: string(resourceType), ResourceID: resourceID, PasswordHash: passwordHash, Version: 1, UpdateTime: now} + if err := dal.InsertResourcePassword(tx, record); err != nil { + return nil, apperrors.ErrDependency + } + return stateOf(record), nil + } + if record.Version != expectedVersion { + return nil, apperrors.ErrConflict + } + updated, err := dal.UpdateResourcePassword(tx, string(resourceType), resourceID, expectedVersion, passwordHash, now) + if err != nil { + return nil, apperrors.ErrDependency + } + if !updated { + return nil, apperrors.ErrConflict + } + record.PasswordHash = passwordHash + record.Version++ + return stateOf(record), nil +} + +func (service *Service) Authorize(ctx context.Context, resourceType ResourceType, resourceID int64, owner bool, token string) (*State, error) { + if !validResource(resourceType, resourceID) || db.MasterDB() == nil { + return nil, apperrors.ErrDependency + } + record, err := dal.FindResourcePassword(db.MasterDB().WithContext(ctx), string(resourceType), resourceID, false) + if err != nil { + return nil, apperrors.ErrDependency + } + if err := service.authorizeRecord(record, owner, token); err != nil { + return nil, err + } + return stateOf(record), nil +} + +func (service *Service) Unlock(ctx context.Context, resourceType ResourceType, resourceID int64, password, clientKey string) (*State, Grant, error) { + if !validResource(resourceType, resourceID) || db.MasterDB() == nil { + return nil, Grant{}, apperrors.ErrDependency + } + record, err := dal.FindResourcePassword(db.MasterDB().WithContext(ctx), string(resourceType), resourceID, false) + if err != nil { + return nil, Grant{}, apperrors.ErrDependency + } + state := stateOf(record) + if !state.PasswordProtected { + return state, Grant{}, nil + } + key := string(resourceType) + ":" + strconv.FormatInt(resourceID, 10) + ":" + clientKey + release, err := service.attempts.acquire(key) + if err != nil { + return nil, Grant{}, err + } + defer release() + if !utf8.ValidString(password) || len(password) < 1 || len(password) > 72 || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(password)) != nil { + return nil, Grant{}, ErrPasswordRequired + } + service.attempts.success(key) + grant, err := service.issueGrant(resourceType, resourceID, record.Version) + if err != nil { + return nil, Grant{}, apperrors.ErrDependency + } + return state, grant, nil +} + +func (service *Service) authorizeRecord(record *model.ResourcePassword, owner bool, token string) error { + if record == nil || record.PasswordHash == "" || owner { + return nil + } + if !service.validGrant(token, ResourceType(record.ResourceType), record.ResourceID, record.Version) { + return ErrPasswordRequired + } + return nil +} + +func stateOf(record *model.ResourcePassword) *State { + if record == nil { + return &State{} + } + return &State{PasswordProtected: record.PasswordHash != "", Version: record.Version} +} + +func validResource(resourceType ResourceType, resourceID int64) bool { + return resourceID > 0 && validType(resourceType) +} + +func validType(resourceType ResourceType) bool { + return resourceType == ResourceWebProject || resourceType == ResourceManual +} + +func (service *Service) issueGrant(resourceType ResourceType, resourceID, version int64) (Grant, error) { + if !validResource(resourceType, resourceID) || version <= 0 { + return Grant{}, apperrors.ErrInvalid + } + expires := service.now().Add(GrantTTL) + payload := make([]byte, 34) + payload[0] = grantFormatVersion + payload[1] = resourceTypeByte(resourceType) + binary.BigEndian.PutUint64(payload[2:10], uint64(resourceID)) + binary.BigEndian.PutUint64(payload[10:18], uint64(version)) + binary.BigEndian.PutUint64(payload[18:26], uint64(expires.Unix())) + if _, err := io.ReadFull(service.random, payload[26:34]); err != nil { + return Grant{}, err + } + signature := service.sign(payload) + token := base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(signature) + return Grant{Token: token, ExpiresAt: expires}, nil +} + +func (service *Service) validGrant(token string, resourceType ResourceType, resourceID, version int64) bool { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil || len(payload) != 34 { + return false + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || len(signature) != sha256.Size || subtle.ConstantTimeCompare(signature, service.sign(payload)) != 1 { + return false + } + expires := int64(binary.BigEndian.Uint64(payload[18:26])) + return payload[0] == grantFormatVersion && payload[1] == resourceTypeByte(resourceType) && + int64(binary.BigEndian.Uint64(payload[2:10])) == resourceID && + int64(binary.BigEndian.Uint64(payload[10:18])) == version && + expires > service.now().Unix() +} + +func (service *Service) sign(payload []byte) []byte { + digest := hmac.New(sha256.New, service.secret) + _, _ = digest.Write(payload) + return digest.Sum(nil) +} + +func resourceTypeByte(resourceType ResourceType) byte { + if resourceType == ResourceWebProject { + return 1 + } + if resourceType == ResourceManual { + return 2 + } + return 0 +} + +func CookieName(resourceType ResourceType, resourceID int64) string { + label := "invalid" + if resourceType == ResourceWebProject { + label = "web" + } else if resourceType == ResourceManual { + label = "manual" + } + return "__Host-cq_read_" + label + "_" + strconv.FormatInt(resourceID, 10) +} + +func GrantToken(request *http.Request, resourceType ResourceType, resourceID int64) string { + if request == nil { + return "" + } + cookie, err := request.Cookie(CookieName(resourceType, resourceID)) + if err != nil { + return "" + } + return cookie.Value +} + +func GrantCookie(resourceType ResourceType, resourceID int64, grant Grant) *http.Cookie { + return &http.Cookie{Name: CookieName(resourceType, resourceID), Value: grant.Token, Path: "/", MaxAge: int(GrantTTL / time.Second), Expires: grant.ExpiresAt, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode} +} + +func ExpiredGrantCookie(resourceType ResourceType, resourceID int64) *http.Cookie { + return &http.Cookie{Name: CookieName(resourceType, resourceID), Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode} +} + +type attemptLimiter struct { + sync.Mutex + entries map[string]*attemptEntry + semaphore chan struct{} + maxKeys int + maxAttempts int + window time.Duration + now func() time.Time +} + +type attemptEntry struct { + count int + windowStart time.Time + lastSeen time.Time +} + +func newAttemptLimiter(maxKeys, maxConcurrent, maxAttempts int, window time.Duration, now func() time.Time) *attemptLimiter { + return &attemptLimiter{entries: make(map[string]*attemptEntry), semaphore: make(chan struct{}, maxConcurrent), maxKeys: maxKeys, maxAttempts: maxAttempts, window: window, now: now} +} + +func (limiter *attemptLimiter) acquire(key string) (func(), error) { + now := limiter.now() + limiter.Lock() + for candidate, entry := range limiter.entries { + if now.Sub(entry.lastSeen) >= limiter.window { + delete(limiter.entries, candidate) + } + } + entry := limiter.entries[key] + if entry == nil { + if len(limiter.entries) >= limiter.maxKeys { + limiter.Unlock() + return nil, apperrors.ErrRateLimited + } + entry = &attemptEntry{windowStart: now} + limiter.entries[key] = entry + } else if now.Sub(entry.windowStart) >= limiter.window { + entry.count = 0 + entry.windowStart = now + } + entry.lastSeen = now + if entry.count >= limiter.maxAttempts { + limiter.Unlock() + return nil, apperrors.ErrRateLimited + } + entry.count++ + limiter.Unlock() + select { + case limiter.semaphore <- struct{}{}: + var once sync.Once + return func() { once.Do(func() { <-limiter.semaphore }) }, nil + default: + return nil, apperrors.ErrRateLimited + } +} + +func (limiter *attemptLimiter) success(key string) { + limiter.Lock() + delete(limiter.entries, key) + limiter.Unlock() +} + +func (limiter *attemptLimiter) size() int { + limiter.Lock() + defer limiter.Unlock() + return len(limiter.entries) +} diff --git a/domain/service/resourcepasswords/service_test.go b/domain/service/resourcepasswords/service_test.go new file mode 100644 index 0000000..a4dec36 --- /dev/null +++ b/domain/service/resourcepasswords/service_test.go @@ -0,0 +1,84 @@ +package resourcepasswords + +import ( + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/mcoder2014/home_server/domain/model" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/stretchr/testify/require" +) + +func TestValidateManagedPasswordUsesUTF8Bytes(t *testing.T) { + require.NoError(t, ValidateManagedPassword("12345678")) + require.NoError(t, ValidateManagedPassword(strings.Repeat("界", 24))) + require.ErrorIs(t, ValidateManagedPassword("1234567"), apperrors.ErrInvalid) + require.ErrorIs(t, ValidateManagedPassword(strings.Repeat("界", 25)), apperrors.ErrInvalid) + require.ErrorIs(t, ValidateManagedPassword(string([]byte{0xff, 0xfe})), apperrors.ErrInvalid) + require.NoError(t, ValidateManagedPassword(""), "empty password clears protection") +} + +func TestGrantBindsResourceVersionAndExpiry(t *testing.T) { + now := time.Date(2026, 9, 19, 1, 2, 3, 0, time.UTC) + service := newTestService([]byte("0123456789abcdef0123456789abcdef"), func() time.Time { return now }) + record := &model.ResourcePassword{ResourceType: string(ResourceWebProject), ResourceID: 101, PasswordHash: "$2a$04$0d3NQ5z/0G4xP8xPtp0seux64uXXToQxVrYVSlGBXkMZ2HfW0Z4oS", Version: 7} + grant, err := service.issueGrant(ResourceWebProject, 101, 7) + require.NoError(t, err) + require.NoError(t, service.authorizeRecord(record, false, grant.Token)) + require.ErrorIs(t, service.authorizeRecord(record, false, ""), ErrPasswordRequired) + + changed := *record + changed.Version++ + require.ErrorIs(t, service.authorizeRecord(&changed, false, grant.Token), ErrPasswordRequired) + require.NoError(t, service.authorizeRecord(record, true, ""), "owner bypasses only the password layer") + + now = now.Add(GrantTTL + time.Second) + require.ErrorIs(t, service.authorizeRecord(record, false, grant.Token), ErrPasswordRequired) +} + +func TestGrantCookieIsHostOnlySecureAndHttpOnly(t *testing.T) { + expires := time.Date(2026, 9, 19, 2, 0, 0, 0, time.UTC) + cookie := GrantCookie(ResourceManual, 22, Grant{Token: "signed", ExpiresAt: expires}) + require.Equal(t, "__Host-cq_read_manual_22", cookie.Name) + require.Equal(t, "/", cookie.Path) + require.Empty(t, cookie.Domain) + require.True(t, cookie.Secure) + require.True(t, cookie.HttpOnly) + require.Equal(t, http.SameSiteLaxMode, cookie.SameSite) + require.Equal(t, int(GrantTTL/time.Second), cookie.MaxAge) + require.Equal(t, expires, cookie.Expires) +} + +func TestAttemptLimiterCapsKeysAndConcurrentChecks(t *testing.T) { + now := time.Date(2026, 9, 19, 1, 0, 0, 0, time.UTC) + limiter := newAttemptLimiter(2, 1, 2, time.Minute, func() time.Time { return now }) + release, err := limiter.acquire("first") + require.NoError(t, err) + _, err = limiter.acquire("second") + require.ErrorIs(t, err, apperrors.ErrRateLimited, "bcrypt concurrency is checked before work starts") + release() + + release, err = limiter.acquire("second") + require.NoError(t, err) + release() + _, err = limiter.acquire("third") + require.ErrorIs(t, err, apperrors.ErrRateLimited, "tracked keys stay bounded") + + now = now.Add(2 * time.Minute) + release, err = limiter.acquire("third") + require.NoError(t, err, "expired keys are pruned before rejecting new clients") + release() + limiter.success("third") + require.LessOrEqual(t, limiter.size(), 1) +} + +func TestAuthorizeUnprotectedAndOwnerDoNotNeedGrant(t *testing.T) { + service := newTestService([]byte("0123456789abcdef0123456789abcdef"), time.Now) + require.NoError(t, service.authorizeRecord(nil, false, "")) + require.NoError(t, service.authorizeRecord(&model.ResourcePassword{PasswordHash: "", Version: 3}, false, "")) + require.NoError(t, service.authorizeRecord(&model.ResourcePassword{PasswordHash: "protected", Version: 3}, true, "")) + require.True(t, errors.Is(service.authorizeRecord(&model.ResourcePassword{PasswordHash: "protected", Version: 3}, false, "bad"), ErrPasswordRequired)) +} diff --git a/domain/service/service.go b/domain/service/service.go index 4d7f9f4..dab54c7 100644 --- a/domain/service/service.go +++ b/domain/service/service.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/mcoder2014/home_server/config" "github.com/mcoder2014/home_server/domain/service/applications" + "github.com/mcoder2014/home_server/domain/service/fileshare" "github.com/mcoder2014/home_server/domain/service/manuals" "github.com/mcoder2014/home_server/domain/service/passport" "github.com/mcoder2014/home_server/domain/service/webdav" @@ -24,6 +25,9 @@ func Init(conf *config.Config) error { if err := manuals.Init(&conf.Manuals, conf.WebDAV.SharePath, conf.WebProjects.StorageRoot); err != nil { return err } + if err := fileshare.Init(&conf.FileSharing, conf.WebDAV.SharePath, conf.WebProjects.StorageRoot, conf.Manuals.StorageRoot); err != nil { + return err + } // Database configuration can enable web projects after startup. Storage // boundaries must already be valid even when the bootstrap switch is off. if conf.WebProjects.Enabled || conf.ConfigSource == "database" { diff --git a/front_vue/package.json b/front_vue/package.json index af4c5ba..4461da4 100644 --- a/front_vue/package.json +++ b/front_vue/package.json @@ -8,7 +8,9 @@ "lint": "vue-cli-service lint", "test:frontend": "node --test test/*.test.cjs", "test:account-center-browser": "node --test test/account-center-browser.cjs", + "test:file-sharing-browser": "node --test test/file-sharing-browser.cjs", "test:manuals-browser": "node --test test/manuals-browser.cjs", + "test:site-visual-browser": "node --test test/site-visual-browser.cjs", "test:web-projects": "node --test test/web-projects-*.test.cjs" }, "dependencies": { diff --git a/front_vue/public/index.html b/front_vue/public/index.html index 7f81ca4..dfe3fe2 100644 --- a/front_vue/public/index.html +++ b/front_vue/public/index.html @@ -3,8 +3,9 @@ + - + CQ Home Server · 首页 diff --git a/front_vue/src/App.vue b/front_vue/src/App.vue index 07dea38..a9b3c3d 100644 --- a/front_vue/src/App.vue +++ b/front_vue/src/App.vue @@ -6,6 +6,6 @@ diff --git a/front_vue/src/api/files.cjs b/front_vue/src/api/files.cjs new file mode 100644 index 0000000..caa1313 --- /dev/null +++ b/front_vue/src/api/files.cjs @@ -0,0 +1,67 @@ +const axios = require('axios') +const {browserHeaders, requestError} = require('./browser_client.cjs') + +function envelopeError(response) { + const body = response && response.data || {} + const error = new Error(body.message || body.msg || '请求失败') + error.code = body.code + error.status = response && response.status + return error +} + +async function downloadRequestError(error, sentCSRF) { + const response = error && error.response + if (response && typeof Blob !== 'undefined' && response.data instanceof Blob) { + try { + const parsed = JSON.parse(await response.data.text()) + error.response = {...response, data: parsed} + } catch (_) { /* 非 JSON 下载错误继续使用通用提示。 */ } + } + return requestError(error, sentCSRF) +} + +function createFilesApi(transport, getCSRF) { + const client = transport || axios.create({baseURL: '/', withCredentials: true}) + const headers = () => browserHeaders({}, getCSRF) + async function request(config, upload = false) { + try { + const response = await client.request(config) + if (!response.data || response.data.code !== 0) throw envelopeError(response) + return response.data.data + } catch (error) { + if (upload && error && error.response && error.response.status === 413) { + const result = new Error('文件上传失败:单个文件不能超过 50 MiB') + result.code = error.response.data && error.response.data.code + result.status = 413 + throw result + } + throw requestError(error, config.headers && config.headers['X-CSRF-Token'] || '') + } + } + const fileURL = id => `/api/files/${encodeURIComponent(String(id))}` + const publicURL = token => `/api/file-shares/${encodeURIComponent(String(token))}` + return { + listFiles(params) { return request({method: 'get', url: '/api/files', params, headers: headers()}) }, + listEligibleUsers() { return request({method: 'get', url: '/api/files/eligible-users', headers: headers()}) }, + uploadFile(file, onUploadProgress) { + const data = new FormData() + data.append('file', file) + return request({method: 'post', url: '/api/files', data, headers: headers(), onUploadProgress}, true) + }, + getFile(id) { return request({method: 'get', url: fileURL(id), headers: headers()}) }, + deleteFile(id) { return request({method: 'delete', url: fileURL(id), headers: headers()}) }, + listShares(id, params) { return request({method: 'get', url: `${fileURL(id)}/shares`, params, headers: headers()}) }, + createShare(id, data) { return request({method: 'post', url: `${fileURL(id)}/shares`, data, headers: headers()}) }, + revokeShare(id, shareID) { return request({method: 'delete', url: `${fileURL(id)}/shares/${encodeURIComponent(String(shareID))}`, headers: headers()}) }, + getShare(token) { return request({method: 'get', url: publicURL(token), headers: headers()}) }, + unlockShare(token, secret) { return request({method: 'post', url: `${publicURL(token)}/unlock`, data: {secret}, headers: headers()}) }, + async downloadShare(token) { + const config = {method: 'post', url: `${publicURL(token)}/download`, data: null, responseType: 'blob', headers: headers()} + try { return await client.request(config) } + catch (error) { throw await downloadRequestError(error, config.headers['X-CSRF-Token'] || '') } + }, + } +} + +const filesApi = createFilesApi() +module.exports = {createFilesApi, filesApi} diff --git a/front_vue/src/api/manuals.cjs b/front_vue/src/api/manuals.cjs index a9c4e4b..fbbde75 100644 --- a/front_vue/src/api/manuals.cjs +++ b/front_vue/src/api/manuals.cjs @@ -75,6 +75,15 @@ function createManualsApi(transport, getCSRF) { getManual(manualID) { return request({method: 'get', url: manualURL(manualID), headers: headers()}) }, + getPassword(manualID) { + return request({method: 'get', url: manualURL(manualID, '/password'), headers: headers()}) + }, + setPassword(manualID, data) { + return request({method: 'put', url: manualURL(manualID, '/password'), data, headers: headers()}) + }, + unlockManual(manualID, password) { + return request({method: 'post', url: manualURL(manualID, '/unlock'), data: {password}, headers: headers()}) + }, updateManual(manualID, data) { return request({method: 'patch', url: manualURL(manualID), data, headers: headers()}) }, diff --git a/front_vue/src/api/web_projects.cjs b/front_vue/src/api/web_projects.cjs index 449d736..2c94c73 100644 --- a/front_vue/src/api/web_projects.cjs +++ b/front_vue/src/api/web_projects.cjs @@ -48,6 +48,15 @@ function createWebShareApi(transport, getCSRF) { } return request({method: 'get', url: `/api/web-share/${encodeURIComponent(projectID)}/stats`, params: {days}, headers: headers()}) }, + getPassword(projectID) { + return request({method: 'get', url: `/api/web-share/${encodeURIComponent(projectID)}/password`, headers: headers()}) + }, + setPassword(projectID, data) { + return request({method: 'put', url: `/api/web-share/${encodeURIComponent(projectID)}/password`, data, headers: headers()}) + }, + unlockProject(projectID, password) { + return request({method: 'post', url: `/api/web-share/${encodeURIComponent(projectID)}/unlock`, data: {password}, headers: headers()}) + }, updateProject(projectID, revision, data) { return request({ method: 'patch', diff --git a/front_vue/src/assets/global.css b/front_vue/src/assets/global.css index 5ad21ea..0667141 100644 --- a/front_vue/src/assets/global.css +++ b/front_vue/src/assets/global.css @@ -2,12 +2,14 @@ --primary-color: #28756b; --primary-dark: #205d56; --primary-darker: #193d39; - --bg-color: #f5f6f4; + --bg-color: #f3f6f3; --card-bg: #ffffff; + --surface-muted: #f7faf7; --text-primary: #203330; --text-secondary: #687773; --border-color: #e2e8e4; - --card-shadow: 0 4px 24px rgba(29, 53, 45, 0.035); + --border-strong: #cfdbd4; + --card-shadow: 0 10px 34px rgba(29, 53, 45, 0.055); --header-height: 76px; --el-color-primary: var(--primary-color); --el-color-primary-light-3: #639e96; @@ -29,12 +31,16 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; - background: var(--bg-color); + background: + radial-gradient(circle at 5% -10%, rgba(99, 158, 150, 0.12), transparent 31rem), + linear-gradient(180deg, #f7f9f6 0, var(--bg-color) 32rem); color: var(--text-primary); -webkit-font-smoothing: antialiased; line-height: 1.6; } +::selection { background: #cfe3d9; color: var(--primary-darker); } + a { color: var(--primary-color); } a:focus-visible, button:focus-visible { @@ -45,13 +51,13 @@ a:focus-visible, button:focus-visible { .page-container { max-width: 1200px; margin: 0 auto; - padding: 42px 28px 64px; + padding: 38px 28px 68px; } .card, .surface-card { background: var(--card-bg); border: 1px solid var(--border-color); - border-radius: 16px; + border-radius: 18px; box-shadow: var(--card-shadow); padding: 28px; } @@ -65,6 +71,8 @@ a:focus-visible, button:focus-visible { letter-spacing: -0.5px; } +h1, h2, h3 { text-wrap: balance; } + .page-eyebrow { display: block; color: var(--primary-color); @@ -97,23 +105,32 @@ a:focus-visible, button:focus-visible { letter-spacing: -0.7px; } -.el-button { font-weight: 500; } +.el-button { font-weight: 550; transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease; } +.el-button:not(.is-disabled):active { transform: translateY(1px); } .el-button + .el-button { margin-left: 8px; } .el-button--primary { box-shadow: 0 2px 4px rgba(32, 93, 86, 0.08); } -.el-input__wrapper, .el-textarea__inner { background: #fcfdfc; } +.el-input__wrapper, .el-textarea__inner { background: #fcfdfc; transition: box-shadow .18s ease, border-color .18s ease; } +.el-input__wrapper.is-focus, .el-textarea__inner:focus { box-shadow: 0 0 0 1px var(--primary-color) inset, 0 0 0 3px rgba(40, 117, 107, .09) !important; } .el-form-item__label { font-weight: 550; } .el-tag { border-radius: 6px; } -.el-table { --el-table-header-bg-color: #f7f9f7; --el-table-row-hover-bg-color: #f2f7f4; } +.el-table { --el-table-header-bg-color: #f4f7f4; --el-table-row-hover-bg-color: #f0f6f2; border-radius: 12px; } +.el-table__inner-wrapper::before { background-color: var(--border-color); } .el-table th.el-table__cell { color: var(--text-secondary); font-size: 12px; font-weight: 550; padding: 14px 0; } .el-table td.el-table__cell { padding: 16px 0; } -.el-dialog, .el-message-box { border-radius: 14px; max-width: calc(100vw - 32px); } +.el-dialog, .el-message-box { border: 1px solid var(--border-color); border-radius: 16px; max-width: calc(100vw - 32px); box-shadow: 0 26px 80px rgba(25, 61, 57, .16); } .el-upload-dragger { border-radius: 12px; background: #f8faf8; } +.el-empty { min-height: 190px; border-radius: 13px; background: linear-gradient(145deg, rgba(246, 249, 246, .72), rgba(251, 252, 250, .94)); } +.el-empty__description p { color: var(--text-secondary); font-size: 13px; } +input, textarea, select { outline: none; } +input:focus-visible, textarea:focus-visible, select:focus-visible { border-color: var(--primary-color) !important; box-shadow: 0 0 0 3px rgba(40, 117, 107, .1); } @media (max-width: 640px) { - .page-container { padding: 28px 16px 44px; } + .page-container { padding: 24px 16px 48px; } .card { border-radius: 12px; padding: 20px 16px; } .page-title { font-size: 22px; } .action-bar { gap: 16px; flex-wrap: wrap; } + .el-button--large { min-height: 44px; } + .el-message { width: calc(100% - 24px); min-width: 0; } } @media (prefers-reduced-motion: reduce) { diff --git a/front_vue/src/components/MyHeader.vue b/front_vue/src/components/MyHeader.vue index 272b00e..dfc06e5 100644 --- a/front_vue/src/components/MyHeader.vue +++ b/front_vue/src/components/MyHeader.vue @@ -3,6 +3,7 @@