diff --git a/README.md b/README.md
index 90e00b5..7c40c7d 100644
--- a/README.md
+++ b/README.md
@@ -386,3 +386,19 @@ A: 本项目是家庭服务,内网只暴露了 http,外网通过一个虚拟
可选缓存覆盖书目、库位、已发布版本元数据及应用 Token 快照;权限和撤销信息保持实时检查。托管页面支持浏览器口径的每日/累计 PV、近似 UV,保存最近 90 日每日汇总与长期总量,编辑页可查看 7/30/90 日趋势。
新开关默认关闭。统计开启前需要手动执行增量统计表迁移,并配置独立私有访客密钥;应用不会修改 Redis 配置或自动建表。统计只使用普通 Redis 命令和 pipeline,不需要 Lua/EVAL 权限,也不要求特定淘汰策略。完整口径、启用配置、故障边界和回滚步骤见 [Redis 缓存与访问统计](docs/redis-cache-analytics.md)。
+
+### 密码长度配置与网页快速解锁
+
+管理员在“管理中心 → 通用配置 → 账号策略”中调整密码长度,校验并发布后可查看实际生效版本。
+
+| 配置项 | 可配置范围 | 默认值 | 适用范围 |
+| --- | --- | --- | --- |
+| 账号密码最少字符数 | 4~64 个字符 | 15 | 注册、改密、管理员创建及重置密码 |
+| 网页、说明书与文件分享密码最少字节数 | 4~72 个 UTF-8 字节 | 8 | 网页阅读密码、说明书阅读密码、要求登录的文件分享密码 |
+| 公开文件分享码长度 | 4~12 位 | 6 | 新建公开文件分享的随机或自定义 ASCII 字母数字分享码 |
+
+ASCII 字符各占 1 字节,因此三类口令均可配置为 4 位。账号与分享密码最多 72 个 UTF-8 字节;账号自动生成的临时密码仍至少 20 位。配置只约束新建或修改操作,已有密码与分享码仍按原值验证。升级不会覆盖现有账号策略,也不自动降低默认长度。
+
+网页阅读链接支持 `/p/report/?code=1234`。独立解锁页会立即从地址栏删除全部 `code` 参数,再通过原 POST 解锁接口验证密码;成功后打开原页面,保留其他查询参数和 `#` 定位。错误密码显示“密码不正确”,可直接手动重试。原始页面和增强页面均适用,已有解锁 Cookie 或所有者访问也会清除参数。链接不会绕过仅自己、指定用户等访问范围;文件分享和说明书的 URL 不使用此参数。
+
+`code` 仍会出现在首次请求中,地址栏清除仅避免打开后复制时再次带出密码。服务端日志会对该参数脱敏;部署应同步更新网页代理日志配置,避免代理层记录原始请求。分享完整带码链接等于同时分享密码。
diff --git a/api/accounts/handlers.go b/api/accounts/handlers.go
index 1f3ceed..a195785 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, "file_sharing": runtime.FileSharing.Enabled}}, nil)
+ respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value, "share_password_policy": map[string]int{"min_length": runtime.AccountPolicy.MinSharePasswordLength, "code_length": runtime.AccountPolicy.ShareCodeLength}, "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/resource_password_integration_test.go b/api/accounts/resource_password_integration_test.go
index 64c4992..59e01d2 100644
--- a/api/accounts/resource_password_integration_test.go
+++ b/api/accounts/resource_password_integration_test.go
@@ -123,3 +123,43 @@ func passwordGrantCookie(t *testing.T, cookies []*http.Cookie) *http.Cookie {
t.Fatal("unlock did not issue a resource grant cookie")
return nil
}
+
+func TestHTTPFourBytePolicyAndQueryPasswordGate(t *testing.T) {
+ f := newHTTPFixture(t)
+ owner := f.login(f.owner, integrationPassword)
+ current := requireSuccess(t, f.request("GET", "/api/admin/config/account_policy", owner, nil, nil))
+ values := object(current["values"])
+ values["min_password_length"], values["min_share_password_length"], values["share_code_length"] = 4, 4, 4
+ requireSuccess(t, f.publish(owner, "account_policy", values, "four-byte-password-policy"))
+ bootstrap := requireSuccess(t, f.request("GET", "/api/site/bootstrap", nil, nil, nil))
+ if number(object(bootstrap["share_password_policy"])["min_length"]) != 4 || number(object(bootstrap["registration"])["min_password_length"]) != 4 {
+ t.Fatal("bootstrap omitted active password lengths")
+ }
+ project, _ := f.privatePage(owner)
+ id := project["id"].(string)
+ base := "/api/web-share/" + id
+ queryPath := "/p/http-private-fixture/index.html?code=1234&view=wide"
+ if response := f.request("GET", queryPath, nil, nil, map[string]string{"Accept": "text/html"}); response.Status != http.StatusNotFound || !strings.Contains(string(response.Raw), "history.replaceState") || response.Header.Get("Referrer-Policy") != "no-referrer" {
+ t.Fatal("private page must retain 404 while clearing the query password")
+ }
+ requireSuccess(t, f.request("PATCH", base, owner, map[string]interface{}{"access_mode": "public", "container_mode": "raw"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)}))
+ if response := f.request("PUT", base+"/password", owner, map[string]interface{}{"password": "123", "version": 0}, nil); response.Status != http.StatusBadRequest {
+ t.Fatal("three-byte password accepted")
+ }
+ requireSuccess(t, f.request("PUT", base+"/password", owner, map[string]interface{}{"password": "1234", "version": 0}, nil))
+ unlocked := f.request("POST", base+"/unlock", nil, map[string]interface{}{"password": "1234"}, nil)
+ requireSuccess(t, unlocked)
+ grant := passwordGrantCookie(t, unlocked.Cookies)
+ for _, headers := range []map[string]string{nil, {"Cookie": grant.Name + "=" + grant.Value}, {"Cookie": owner.Cookie.Name + "=" + owner.Cookie.Value}} {
+ page := f.request("GET", queryPath, nil, nil, headers)
+ if page.Status != http.StatusForbidden || !strings.Contains(string(page.Raw), "history.replaceState") || strings.Contains(string(page.Raw), "code=1234") {
+ t.Fatalf("query did not use isolated gate: %d", page.Status)
+ }
+ }
+ values["min_share_password_length"] = 8
+ requireSuccess(t, f.publish(owner, "account_policy", values, "restore-share-password-policy"))
+ requireSuccess(t, f.request("POST", base+"/unlock", nil, map[string]interface{}{"password": "1234"}, nil))
+ if bad := f.request("POST", base+"/unlock", nil, map[string]interface{}{"password": "4321"}, nil); bad.Status != http.StatusForbidden {
+ t.Fatal("incorrect password accepted")
+ }
+}
diff --git a/api/webprojects/container.go b/api/webprojects/container.go
index 2849b48..564ae12 100644
--- a/api/webprojects/container.go
+++ b/api/webprojects/container.go
@@ -82,7 +82,8 @@ func contentNotFound(c *gin.Context) {
return
}
if strings.Contains(c.GetHeader("Accept"), "text/html") {
- c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(`
404404
页面不存在或不可见。
系统主页`))
+ c.Header("Referrer-Policy", "no-referrer")
+ c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(`404404
页面不存在或不可见。
系统主页`))
} else {
c.Status(http.StatusNotFound)
}
diff --git a/api/webprojects/content.go b/api/webprojects/content.go
index c93b865..7ced697 100644
--- a/api/webprojects/content.go
+++ b/api/webprojects/content.go
@@ -79,6 +79,12 @@ func serveProjectContent(c *gin.Context) {
if requested == "" {
requested = release.EntryFile
}
+ // Consume query passwords in an isolated page even for owners or existing grants,
+ // before any hosted script or resource can observe the original URL.
+ if _, hasCode := c.Request.URL.Query()["code"]; hasCode && c.Request.Method == http.MethodGet && passwordHTMLRequest(c, requested) {
+ servePasswordUnlockPage(c, project.ID)
+ return
+ }
_, 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")
@@ -162,7 +168,7 @@ func passwordHTMLRequest(c *gin.Context, requested string) bool {
func servePasswordUnlockPage(c *gin.Context, projectID int64) {
endpoint := "/api/web-share/" + strconv.FormatInt(projectID, 10) + "/unlock"
- html := `密码阅读此网页受密码保护
输入分享者提供的阅读密码。
`
+ 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")
diff --git a/app/siteconfig/service.go b/app/siteconfig/service.go
index 63bd2ba..5a9acb7 100644
--- a/app/siteconfig/service.go
+++ b/app/siteconfig/service.go
@@ -156,10 +156,12 @@ func (s *Service) checkedValues(row model.SiteConfigCurrent) (map[string]interfa
if err != nil {
return nil, appErrors.ErrDependency
}
- // Published v1 account policies omit this additive key; preserve their original JSON and checksum.
+ // Published v1 policies may omit additive keys; preserve their original JSON and checksum.
if row.Namespace == "account_policy" && values != nil {
- if _, exists := values["max_active_sessions"]; !exists {
- values["max_active_sessions"] = 5
+ for key, fallback := range map[string]int{"max_active_sessions": 5, "min_share_password_length": 8, "share_code_length": 6} {
+ if _, exists := values[key]; !exists {
+ values[key] = fallback
+ }
}
}
normalized, err := config.ValidateValues(s.bootstrap, row.Namespace, values)
diff --git a/app/siteconfig/session_policy_test.go b/app/siteconfig/session_policy_test.go
index f0c48a4..f6cc179 100644
--- a/app/siteconfig/session_policy_test.go
+++ b/app/siteconfig/session_policy_test.go
@@ -107,3 +107,30 @@ func TestStoredSessionPolicyIsReadOnlyAndLegacyRollbackAddsDefault(t *testing.T)
t.Fatal("legacy rollback rewrote historical JSON or checksum")
}
}
+
+func TestStoredLegacyPasswordPolicyKeepsOriginalDocument(t *testing.T) {
+ service := New(nil, config.Config{})
+ values := config.DefaultRuntimeValues(config.Config{})["account_policy"]
+ delete(values, "min_share_password_length")
+ delete(values, "share_code_length")
+ raw, _ := json.Marshal(values)
+ hash := sha256.Sum256(raw)
+ row := model.SiteConfigCurrent{Namespace: "account_policy", Revision: 1, SchemaVersion: 1, ValuesJSON: string(raw), ValuesSHA256: hash[:]}
+ actual, err := service.checkedValues(row)
+ if err != nil || actual["min_share_password_length"] != int64(8) || actual["share_code_length"] != int64(6) {
+ t.Fatalf("legacy defaults: %v %v", actual, err)
+ }
+ if row.ValuesJSON != string(raw) || !bytes.Equal(row.ValuesSHA256, hash[:]) {
+ t.Fatal("legacy stored policy changed")
+ }
+ for _, key := range []string{"min_share_password_length", "share_code_length"} {
+ values[key] = 3
+ raw, _ = json.Marshal(values)
+ hash = sha256.Sum256(raw)
+ row.ValuesJSON, row.ValuesSHA256 = string(raw), hash[:]
+ if _, err := service.checkedValues(row); err == nil {
+ t.Fatalf("invalid stored %s accepted", key)
+ }
+ delete(values, key)
+ }
+}
diff --git a/config/nginx/web_projects_locations.conf b/config/nginx/web_projects_locations.conf
index 5004033..13102a8 100644
--- a/config/nginx/web_projects_locations.conf
+++ b/config/nginx/web_projects_locations.conf
@@ -59,6 +59,10 @@ location ^~ /api/web-projects/ {
}
location ^~ /p/ {
+ # Query passwords must not appear in access or upstream error logs.
+ access_log off;
+ error_log /dev/null;
+ add_header Referrer-Policy "no-referrer" always;
proxy_pass http://127.0.0.1:18080;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
diff --git a/config/password_policy_test.go b/config/password_policy_test.go
new file mode 100644
index 0000000..7b2f6ae
--- /dev/null
+++ b/config/password_policy_test.go
@@ -0,0 +1,19 @@
+package config
+
+import "testing"
+
+func TestConfigurablePasswordBoundaries(t *testing.T) {
+ for _, key := range []string{"min_password_length", "min_share_password_length", "share_code_length"} {
+ values := DefaultRuntimeValues(Config{})
+ values["account_policy"][key] = 4
+ if _, err := BuildRuntimeSnapshot(Config{}, 0, nil, values); err != nil {
+ t.Fatalf("%s=4: %v", key, err)
+ }
+ for _, invalid := range []interface{}{3, 0, nil, 4.5, 73} {
+ values["account_policy"][key] = invalid
+ if _, err := ValidateValues(Config{}, "account_policy", values["account_policy"]); err == nil {
+ t.Fatalf("accepted %s=%v", key, invalid)
+ }
+ }
+ }
+}
diff --git a/config/registry.go b/config/registry.go
index 0eeaf8f..3d2107e 100644
--- a/config/registry.go
+++ b/config/registry.go
@@ -86,7 +86,9 @@ func Registry(conf Config) []NamespaceSchema {
{"account_policy", "session_ttl_seconds", "用户会话有效期", "秒", 300, 2592000, 604800},
{"account_policy", "max_active_sessions", "每个账号同时有效的网站登录上限", "个会话", 1, 100, 5},
{"account_policy", "temporary_password_ttl_days", "临时密码有效期", "天", 1, 30, 7},
- {"account_policy", "min_password_length", "密码最少字符数", "字符", 15, 64, 15},
+ {"account_policy", "min_password_length", "账号密码最少字符数", "字符", 4, 64, 15},
+ {"account_policy", "min_share_password_length", "网页、说明书与文件分享密码最少字节数", "UTF-8 字节", 4, 72, 8},
+ {"account_policy", "share_code_length", "公开文件分享码长度", "位", 4, 12, 6},
{"account_policy", "bcrypt_cost", "密码哈希成本", "", 10, 14, 12},
} {
min, max := limit.min, limit.max
diff --git a/config/runtime.go b/config/runtime.go
index b59520e..5fadddc 100644
--- a/config/runtime.go
+++ b/config/runtime.go
@@ -8,6 +8,8 @@ import (
)
type AccountPolicyConfig struct {
+ MinSharePasswordLength int `json:"min_share_password_length"`
+ ShareCodeLength int `json:"share_code_length"`
SessionTTLSeconds int `json:"session_ttl_seconds"`
MaxActiveSessions int `json:"max_active_sessions"`
TemporaryPasswordTTLDays int `json:"temporary_password_ttl_days"`
diff --git a/deploy/pi/README.md b/deploy/pi/README.md
index ea1f29e..0d0c492 100644
--- a/deploy/pi/README.md
+++ b/deploy/pi/README.md
@@ -115,3 +115,11 @@ sudo systemctl enable home_server.target home_server.service home_server_fronten
下载计数表示成功授权并开始传输的次数,客户端断线不会恢复额度。程序回滚时保留四张新增表及私有文件,先核对下一段的密码兼容性条件;不得把数据库快照覆盖到已经产生新业务数据的库。
回滚密码阅读后端前必须检查 `resource_passwords`:旧版本不识别新密码。如果已有非空密码,优先仅回滚前端;回滚后端前先停止相关内容对外访问,不能让密码资源退化为仅原ACL保护。自动回滚只有在确认尚无密码记录时才允许切回旧后端;无法核实则停止服务并保留新release与备份,等待恢复。
+
+## 可配置密码长度升级
+
+本版本没有新增表或 DDL。旧 `account_policy` 缺少 `min_share_password_length` 与 `share_code_length` 时,仅在读取时补充 8、6,保留原 JSON、校验和及历史版本;新发布配置须包含完整字段。账号密码下限允许设为 4,已有值保持不变。发布配置后,旧程序不识别新增字段,不能直接切回旧后端;应部署兼容新字段的修复版本;管理页面回滚历史配置也会补齐新增字段,不能据此恢复旧程序。旧版本严格校验配置字段,单纯恢复前端不影响后端配置。
+
+前后端须使用同一源码提交构建。更新 `/etc/home_server/locations/web_projects_locations.conf`,沿用现有可信代理变量;同步将两个 TLS 网关模板的 `/p/` location 配置应用到实际站点,关闭该路径访问日志与错误日志,增加 `Referrer-Policy: no-referrer`。分别运行 Nginx 配置检查再 reload。保留旧 release 和配置副本用于未发布新策略前的程序回滚。
+
+验收应覆盖:网页配置下限为 4、3 位口令拒绝、4 位口令成功、提高下限后旧密码仍有效;带码链接正确及错误两种情况、地址栏删除所有 code 参数、保留其他参数与片段、原有 Cookie/所有者场景以及仅自己可见的权限约束。
diff --git a/deploy/pi/gateway-api.conf b/deploy/pi/gateway-api.conf
index 75fb0a9..5d02eef 100644
--- a/deploy/pi/gateway-api.conf
+++ b/deploy/pi/gateway-api.conf
@@ -143,6 +143,19 @@ server {
proxy_intercept_errors off;
}
+ # Query passwords are consumed by the backend password gate.
+ location ^~ /p/ {
+ access_log off;
+ error_log /dev/null;
+ 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 Referrer-Policy "no-referrer" always;
+ }
+
location / {
proxy_pass http://192.0.2.10:18080;
proxy_http_version 1.1;
diff --git a/deploy/pi/gateway-frontend.conf b/deploy/pi/gateway-frontend.conf
index 9ac98f2..9aff183 100644
--- a/deploy/pi/gateway-frontend.conf
+++ b/deploy/pi/gateway-frontend.conf
@@ -149,6 +149,19 @@ server {
proxy_intercept_errors off;
}
+ # Query passwords are consumed by the backend password gate.
+ location ^~ /p/ {
+ access_log off;
+ error_log /dev/null;
+ 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 Referrer-Policy "no-referrer" always;
+ }
+
location / {
proxy_pass http://192.0.2.10:18081;
proxy_http_version 1.1;
diff --git a/domain/service/accounts/rules.go b/domain/service/accounts/rules.go
index 54703b3..403407b 100644
--- a/domain/service/accounts/rules.go
+++ b/domain/service/accounts/rules.go
@@ -19,7 +19,7 @@ var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`)
var invitationLocation = time.FixedZone("Asia/Singapore", 8*60*60)
func ValidatePassword(password, confirmation string, minimum int) error {
- if minimum < 15 {
+ if minimum < 4 {
minimum = 15
}
if !utf8.ValidString(password) || utf8.RuneCountInString(password) < minimum || len(password) > 72 || strings.ContainsRune(password, '\x00') {
diff --git a/domain/service/accounts/rules_test.go b/domain/service/accounts/rules_test.go
index 0d65e88..c0ddb72 100644
--- a/domain/service/accounts/rules_test.go
+++ b/domain/service/accounts/rules_test.go
@@ -43,3 +43,17 @@ func TestWebDAVPermissionChecksEveryReadAndWriteMethod(t *testing.T) {
}
}
}
+
+func TestFourCharacterPasswordPolicy(t *testing.T) {
+ for _, password := range []string{"1234", "四个字符"} {
+ if err := ValidatePassword(password, password, 4); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := ValidatePassword("123", "123", 4); err == nil {
+ t.Fatal("three-character password accepted")
+ }
+ if err := ValidatePassword("1234", "1234", 15); err == nil {
+ t.Fatal("default policy was relaxed")
+ }
+}
diff --git a/domain/service/fileshare/types.go b/domain/service/fileshare/types.go
index dfd7169..d60f59a 100644
--- a/domain/service/fileshare/types.go
+++ b/domain/service/fileshare/types.go
@@ -12,6 +12,7 @@ import (
"time"
"unicode/utf8"
+ "github.com/mcoder2014/home_server/config"
"github.com/mcoder2014/home_server/domain/model"
apperrors "github.com/mcoder2014/home_server/errors"
)
@@ -167,7 +168,7 @@ func NormalizeShareInput(input CreateShareInput, random io.Reader, now time.Time
}
func ValidCode(value string) bool {
- if len(value) != 6 {
+ if len(value) != config.Runtime().AccountPolicy.ShareCodeLength {
return false
}
for _, character := range []byte(value) {
@@ -183,7 +184,7 @@ func ValidCode(value string) bool {
}
func validPassword(value string) bool {
- return utf8.ValidString(value) && len([]byte(value)) >= 8 && len([]byte(value)) <= 72
+ return utf8.ValidString(value) && len([]byte(value)) >= config.Runtime().AccountPolicy.MinSharePasswordLength && len([]byte(value)) <= 72
}
func randomCode(source io.Reader) (string, error) {
@@ -191,7 +192,7 @@ func randomCode(source io.Reader) (string, error) {
source = rand.Reader
}
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
- raw := make([]byte, 6)
+ raw := make([]byte, config.Runtime().AccountPolicy.ShareCodeLength)
if _, err := io.ReadFull(source, raw); err != nil {
return "", err
}
diff --git a/domain/service/fileshare/validation_test.go b/domain/service/fileshare/validation_test.go
index c97e0be..bd09dc6 100644
--- a/domain/service/fileshare/validation_test.go
+++ b/domain/service/fileshare/validation_test.go
@@ -1,6 +1,7 @@
package fileshare
import (
+ "github.com/mcoder2014/home_server/config"
"strings"
"testing"
"time"
@@ -79,3 +80,31 @@ func TestPublicStateSeparatesLoginAndSecretWithoutLeakingMembership(t *testing.T
}
func timePointer(value time.Time) *time.Time { return &value }
+
+func TestFourByteFileShareSecrets(t *testing.T) {
+ original := config.Runtime()
+ t.Cleanup(func() {
+ if err := config.StoreRuntimeSnapshot(original); err != nil {
+ t.Fatal(err)
+ }
+ })
+ policy := original
+ policy.AccountPolicy.MinSharePasswordLength = 4
+ policy.AccountPolicy.ShareCodeLength = 4
+ if err := config.StoreRuntimeSnapshot(policy); err != nil {
+ t.Fatal(err)
+ }
+ for _, input := range []CreateShareInput{
+ {AccessMode: "public", SecretMode: "code", Secret: "aB12"},
+ {AccessMode: "public", SecretMode: "code"},
+ {AccessMode: "authenticated", SecretMode: "password", Secret: "1234"},
+ } {
+ result, err := NormalizeShareInput(input, strings.NewReader("synthetic-random"), time.Now())
+ if err != nil || len(result.Secret) != 4 {
+ t.Fatalf("four-byte secret failed: %v %v", result, err)
+ }
+ }
+ if _, err := NormalizeShareInput(CreateShareInput{AccessMode: "authenticated", SecretMode: "password", Secret: "123"}, nil, time.Now()); err == nil {
+ t.Fatal("three-byte password accepted")
+ }
+}
diff --git a/domain/service/resourcepasswords/service.go b/domain/service/resourcepasswords/service.go
index ca01814..10170ea 100644
--- a/domain/service/resourcepasswords/service.go
+++ b/domain/service/resourcepasswords/service.go
@@ -17,6 +17,7 @@ import (
"time"
"unicode/utf8"
+ "github.com/mcoder2014/home_server/config"
"github.com/mcoder2014/home_server/domain/dal"
"github.com/mcoder2014/home_server/domain/db"
"github.com/mcoder2014/home_server/domain/model"
@@ -95,7 +96,7 @@ func ValidateManagedPassword(password string) error {
if password == "" {
return nil
}
- if !utf8.ValidString(password) || len([]byte(password)) < 8 || len([]byte(password)) > 72 {
+ if !utf8.ValidString(password) || len([]byte(password)) < config.Runtime().AccountPolicy.MinSharePasswordLength || len([]byte(password)) > 72 {
return apperrors.ErrInvalid
}
return nil
diff --git a/front_vue/src/store/index.js b/front_vue/src/store/index.js
index b1f4f6b..0295b6d 100644
--- a/front_vue/src/store/index.js
+++ b/front_vue/src/store/index.js
@@ -19,6 +19,7 @@ const store = createStore({
sessionLoaded: false,
sessionEpoch: 0,
site: {title: 'CQ Home Server', notice: ''},
+ sharePasswordPolicy: {min_length: 8, code_length: 6},
registration: {enabled: false, monthly_limit: 3, ttl_days: 7},
modules: {manuals: false, file_sharing: false},
bootstrapLoaded: false,
@@ -35,6 +36,7 @@ const store = createStore({
localStorage.setItem('isbn', isbn)
},
SET_BOOTSTRAP(state, value) {
+ state.sharePasswordPolicy = value.share_password_policy || state.sharePasswordPolicy
state.site = value.site || state.site
state.registration = value.registration || state.registration
state.modules = value.modules || state.modules
diff --git a/front_vue/src/utils/accounts_behavior.cjs b/front_vue/src/utils/accounts_behavior.cjs
index ce842c4..4a8946f 100644
--- a/front_vue/src/utils/accounts_behavior.cjs
+++ b/front_vue/src/utils/accounts_behavior.cjs
@@ -23,7 +23,7 @@ function routeDecision(route, user, modules = {}) {
}
function passwordError(password, confirmation, minimum = 15) {
- minimum = Math.max(15, minimum)
+ minimum = Math.max(4, minimum)
if ((password || '').includes('\u0000')) return '密码包含无效字符'
if (Array.from(password || '').length < minimum) return `密码至少需要 ${minimum} 个字符,允许空格和粘贴`
if (new TextEncoder().encode(password).length > 72) return '密码不能超过 72 个 UTF-8 字节(中文通常占 3 字节)'
diff --git a/front_vue/src/utils/file_sharing_behavior.cjs b/front_vue/src/utils/file_sharing_behavior.cjs
index fcf2442..8d98283 100644
--- a/front_vue/src/utils/file_sharing_behavior.cjs
+++ b/front_vue/src/utils/file_sharing_behavior.cjs
@@ -13,31 +13,31 @@ function uploadFileError(file) {
return ''
}
-function secretError(mode, value) {
+function secretError(mode, value, minimum = 8, codeLength = 6) {
const secret = String(value || '')
if (mode === 'none') return ''
- if (mode === 'code') return /^[A-Za-z0-9]{6}$/.test(secret) ? '' : '分享码必须是 6 位 ASCII 字母或数字,区分大小写'
+ if (mode === 'code') return /^[A-Za-z0-9]+$/.test(secret) && secret.length === codeLength ? '' : `分享码必须是 ${codeLength} 位 ASCII 字母或数字,区分大小写`
if (mode !== 'password') return '请选择有效的口令方式'
const bytes = utf8Length(secret)
- return bytes >= 8 && bytes <= 72 ? '' : '登录分享密码必须为 8~72 个 UTF-8 字节'
+ return bytes >= minimum && bytes <= 72 ? '' : `登录分享密码必须为 ${minimum}~72 个 UTF-8 字节`
}
-function resourcePasswordError(password, confirmation) {
+function resourcePasswordError(password, confirmation, minimum = 8) {
const bytes = utf8Length(password)
- if (bytes < 8 || bytes > 72) return '阅读密码必须为 8~72 个 UTF-8 字节'
+ if (bytes < minimum || bytes > 72) return `阅读密码必须为 ${minimum}~72 个 UTF-8 字节`
if (password !== confirmation) return '两次输入的阅读密码不一致'
return ''
}
-function randomShareCode(fillRandom = array => crypto.getRandomValues(array)) {
+function randomShareCode(fillRandom = array => crypto.getRandomValues(array), length = 6) {
let code = ''
- while (code.length < 6) {
+ while (code.length < length) {
const bytes = new Uint8Array(12)
fillRandom(bytes)
for (const byte of bytes) {
if (byte >= 248) continue
code += CODE_CHARACTERS[byte % CODE_CHARACTERS.length]
- if (code.length === 6) break
+ if (code.length === length) break
}
}
return code
diff --git a/front_vue/src/views/AdminConfig.vue b/front_vue/src/views/AdminConfig.vue
index 51e943b..32eb50e 100644
--- a/front_vue/src/views/AdminConfig.vue
+++ b/front_vue/src/views/AdminConfig.vue
@@ -25,7 +25,7 @@
关闭注册后,不能生成或兑换邀请码,现有未使用邀请码永久失效。重新开启、版本回滚均不会复活旧邀请码;管理员仍可手动添加用户。
全站开关与个人授权共同生效。WebDAV 使用共享根目录,打开站点能力不会自动授权任何账号。
全站开关与个人授权共同生效,打开站点能力不会自动授予个人藏书权限。
- 网站登录上限按有效会话计数,不代表物理设备数。达到上限会拒绝新的登录;降低上限不会主动退出已有会话,超额会话保留至主动退出或过期。
+ 密码长度仅约束新建或修改密码,已有密码和分享码仍可使用。账号密码按字符计数,分享密码按 UTF-8 字节计数(ASCII 字符各占 1 字节),上限均受 72 字节限制。网站登录上限按有效会话计数,不代表物理设备数。达到上限会拒绝新的登录;降低上限不会主动退出已有会话,超额会话保留至主动退出或过期。
校验并查看差异撤销草稿
@@ -106,9 +106,11 @@ export default {
this.validating = true; this.historyError = ''
try {
const candidate = {...record.values}
- if (this.namespace === 'account_policy' && !Object.prototype.hasOwnProperty.call(candidate, 'max_active_sessions')) {
- const field = this.schema.fields.find(field => field.key === 'max_active_sessions')
- if (field) candidate.max_active_sessions = field.default_value
+ if (this.namespace === 'account_policy') {
+ for (const key of ['max_active_sessions', 'min_share_password_length', 'share_code_length']) {
+ const field = this.schema.fields.find(field => field.key === key)
+ if (field && !Object.prototype.hasOwnProperty.call(candidate, key)) candidate[key] = field.default_value
+ }
}
const values = configValues(this.schema, candidate)
this.validation = await accountsApi.validateConfig(this.namespace, values)
diff --git a/front_vue/src/views/FileShareManager.vue b/front_vue/src/views/FileShareManager.vue
index 403dd21..f59ef16 100644
--- a/front_vue/src/views/FileShareManager.vue
+++ b/front_vue/src/views/FileShareManager.vue
@@ -71,14 +71,14 @@
-
+
@@ -135,12 +135,16 @@ export default {
files: [], filesCursor: '', filesHasMore: false, loadingFiles: false, pendingFile: null, uploading: false, uploadProgress: 0, uploadError: '', pageError: '',
selectedFile: null, shares: [], sharesCursor: '', sharesHasMore: false, loadingShares: false, creatingShare: false, savingShare: false, shareError: '', shareForm: emptyShareForm(), createdShare: null,
eligibleUsers: [], accessOptions: [
- {value: 'public', label: '任何拿到链接的人', description: '无需登录,可选择 6 位分享码'},
+ {value: 'public', label: '任何拿到链接的人', description: '无需登录,可选择分享码'},
{value: 'authenticated', label: '本站登录用户', description: '有效账号登录后可以下载'},
{value: 'members', label: '指定成员', description: '只允许选中的本站账号'},
],
}
},
+ computed: {
+ sharePasswordMinimum() { return this.$store.state.sharePasswordPolicy?.min_length || 8 },
+ shareCodeLength() { return this.$store.state.sharePasswordPolicy?.code_length || 6 },
+ },
async created() {
if (!this.$store.state.userInfo) {
this.$router.replace({path: '/login', query: {redirect: this.$route.fullPath}})
@@ -206,10 +210,10 @@ export default {
this.shareForm.code_source = 'random'; this.generateCode()
},
generateCode() {
- this.shareForm.secret = randomShareCode()
+ this.shareForm.secret = randomShareCode(undefined, this.shareCodeLength)
},
sharePayload() {
- const secretValidation = secretError(this.shareForm.secret_mode, this.shareForm.secret)
+ const secretValidation = secretError(this.shareForm.secret_mode, this.shareForm.secret, this.sharePasswordMinimum, this.shareCodeLength)
if (secretValidation) throw new Error(secretValidation)
if (this.shareForm.access_mode === 'members' && this.shareForm.member_user_ids.length === 0) throw new Error('请至少选择一位成员')
const maxDownloads = this.shareForm.download_limit === 'unlimited' ? 0 : this.shareForm.download_limit === 'once' ? 1 : Number(this.shareForm.max_downloads)
@@ -263,7 +267,7 @@ export default {
shareState(share) { return fileShareState(share) },
absoluteShareURL(share) { return new URL(share.url || `/s/${share.token}`, window.location.origin).href },
accessText(mode) { return {public: '任何人', authenticated: '登录用户', members: '指定成员'}[mode] || mode },
- secretText(mode) { return {none: '无额外口令', code: '6 位分享码', password: '分享密码'}[mode] || mode },
+ secretText(mode) { return {none: '无额外口令', code: '分享码', password: '分享密码'}[mode] || mode },
formatDate(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? '未知时间' : date.toLocaleString('zh-CN', {hour12: false}) },
formatBytes(bytes) { const value = Number(bytes || 0); return value < 1024 ? `${value} B` : value < 1048576 ? `${(value / 1024).toFixed(1)} KiB` : `${(value / 1048576).toFixed(1)} MiB` },
fileExtension(name) { const part = String(name || '').split('.').pop(); return part && part !== name ? part.slice(0, 4).toUpperCase() : 'FILE' },
diff --git a/front_vue/src/views/FileShareReceive.vue b/front_vue/src/views/FileShareReceive.vue
index 19825d4..a499a1e 100644
--- a/front_vue/src/views/FileShareReceive.vue
+++ b/front_vue/src/views/FileShareReceive.vue
@@ -11,7 +11,7 @@
diff --git a/front_vue/src/views/ManualEditor.vue b/front_vue/src/views/ManualEditor.vue
index 8bcce19..c602e9a 100644
--- a/front_vue/src/views/ManualEditor.vue
+++ b/front_vue/src/views/ManualEditor.vue
@@ -26,7 +26,7 @@
阅读密码
密码独立于可见范围;所有者仍可直接查看和管理。
{{ passwordState.password_protected ? '已启用阅读密码' : '尚未设置阅读密码' }}
-
+
@@ -132,6 +132,7 @@ export default {
}
},
computed: {
+ sharePasswordMinimum() { return this.$store.state.sharePasswordPolicy?.min_length || 8 },
manualID() { return this.manual?.id || this.$route.params.id || '' },
totalItemCount() { return (this.manual?.items?.length || 0) + this.queue.filter(item => item.status !== 'success').length },
hasFailures() { return this.queue.some(item => item.status === 'failed') },
@@ -181,7 +182,7 @@ export default {
},
async saveManualPassword() {
if (this.passwordSaving) return
- const validation = resourcePasswordError(this.passwordDraft, this.passwordConfirmation)
+ const validation = resourcePasswordError(this.passwordDraft, this.passwordConfirmation, this.sharePasswordMinimum)
if (validation) { this.passwordNotice = validation; this.passwordNoticeType = 'error'; return }
await this.updateManualPassword(this.passwordDraft)
},
diff --git a/front_vue/src/views/WebProjectEditor.vue b/front_vue/src/views/WebProjectEditor.vue
index bd3bb47..fb2437b 100644
--- a/front_vue/src/views/WebProjectEditor.vue
+++ b/front_vue/src/views/WebProjectEditor.vue
@@ -91,7 +91,7 @@
{{ passwordState.password_protected ? '已启用' : '未设置' }}
-
+
@@ -264,6 +264,7 @@ export default {
}
},
computed: {
+ sharePasswordMinimum() { return this.$store.state.sharePasswordPolicy?.min_length || 8 },
isCreate() {
return this.$route.name === 'WebShareCreate'
},
@@ -335,7 +336,7 @@ export default {
},
async saveProjectPassword() {
if (this.passwordSaving) return
- const validation = resourcePasswordError(this.passwordDraft, this.passwordConfirmation)
+ const validation = resourcePasswordError(this.passwordDraft, this.passwordConfirmation, this.sharePasswordMinimum)
if (validation) { this.passwordNotice = validation; this.passwordNoticeType = 'error'; return }
await this.updateProjectPassword(this.passwordDraft)
},
diff --git a/front_vue/test/accounts-pages.test.cjs b/front_vue/test/accounts-pages.test.cjs
index 2800b0f..fe0726b 100644
--- a/front_vue/test/accounts-pages.test.cjs
+++ b/front_vue/test/accounts-pages.test.cjs
@@ -74,13 +74,13 @@ test('a config publish conflict retains the draft and revision for explicit revi
assert.equal(view.pendingRequest, null)
})
-test('legacy account policy rollback fills the additive session limit without modifying history', async () => {
+test('legacy account policy rollback fills additive session and password limits without modifying history', async () => {
const legacy = {session_ttl_seconds: 604800, temporary_password_ttl_days: 7, min_password_length: 15, bcrypt_cost: 12}
- const fields = Object.entries({...legacy, max_active_sessions: 5}).map(([key, default_value]) => ({key, label: key, type: 'integer', default_value, minimum: 1, maximum: key === 'max_active_sessions' ? 100 : 2592000}))
+ const fields = Object.entries({...legacy, max_active_sessions: 5, min_share_password_length: 8, share_code_length: 6}).map(([key, default_value]) => ({key, label: key, type: 'integer', default_value, minimum: 1, maximum: key === 'max_active_sessions' ? 100 : 2592000}))
const requests = []
const {view} = component('AdminConfig', {
validateConfig: async (...args) => {requests.push(['validate', ...args]); return {revision: 7, values: args[1]}},
- rollbackConfig: async (...args) => {requests.push(['rollback', ...args]); return {namespace: 'account_policy', revision: 8, values: {...legacy, max_active_sessions: 5}}},
+ rollbackConfig: async (...args) => {requests.push(['rollback', ...args]); return {namespace: 'account_policy', revision: 8, values: {...legacy, max_active_sessions: 5, min_share_password_length: 8, share_code_length: 6}}},
configStatus: async () => ({apply_state: 'applied'}),
})
view.namespace = 'account_policy'; view.schemas = [{namespace: 'account_policy', fields}]
@@ -90,6 +90,9 @@ test('legacy account policy rollback fills the additive session limit without mo
await view.prepareRollback(record)
assert.equal(view.historyError, '')
assert.equal(requests[0][2].max_active_sessions, 5)
+ assert.equal(requests[0][2].min_share_password_length, 8)
+ assert.equal(requests[0][2].share_code_length, 6)
+ assert.equal(Object.hasOwn(record.values, 'share_code_length'), false)
assert.equal(Object.hasOwn(record.values, 'max_active_sessions'), false)
assert.equal(view.draft.max_active_sessions, 3)
assert.equal(view.confirmVisible, true)
diff --git a/front_vue/test/file-sharing-browser.cjs b/front_vue/test/file-sharing-browser.cjs
index ff9a57f..2122c40 100644
--- a/front_vue/test/file-sharing-browser.cjs
+++ b/front_vue/test/file-sharing-browser.cjs
@@ -215,7 +215,7 @@ test('收件页在1440与320展示通用口令门禁,解锁后连点只发送
await page.setViewportSize({width: 320, height: 820})
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
await page.screenshot({path: path.join(visualOutput, 'file-receive-password-mobile-320.png'), fullPage: true})
- await page.getByLabel('6 位分享码').fill('aB09zZ')
+ await page.getByLabel('分享码', {exact: true}).fill('aB09zZ')
await page.getByRole('button', {name: '解锁文件', exact: true}).click()
await page.getByRole('button', {name: '下载文件', exact: true}).waitFor()
const downloadPromise = page.waitForEvent('download')
diff --git a/front_vue/test/password-policy.test.cjs b/front_vue/test/password-policy.test.cjs
new file mode 100644
index 0000000..96d4989
--- /dev/null
+++ b/front_vue/test/password-policy.test.cjs
@@ -0,0 +1,68 @@
+const test = require('node:test')
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+const vm = require('node:vm')
+const {passwordError} = require('../src/utils/accounts_behavior.cjs')
+const {secretError, resourcePasswordError, randomShareCode} = require('../src/utils/file_sharing_behavior.cjs')
+
+test('configured four-character account and sharing secrets retain upper bounds and confirmation', () => {
+ assert.equal(passwordError('1234', '1234', 4), '')
+ assert.match(passwordError('123', '123', 4), /4/)
+ assert.match(passwordError('1234', '4321', 4), /不一致/)
+ assert.match(passwordError('😀'.repeat(19), '😀'.repeat(19), 4), /72/)
+ assert.equal(secretError('password', '1234', 4), '')
+ assert.equal(resourcePasswordError('1234', '1234', 4), '')
+ assert.match(resourcePasswordError('123', '123', 4), /4/)
+ assert.equal(secretError('code', 'aB12', 4, 4), '')
+ assert.match(secretError('code', 'aB1234', 4, 4), /4 位/)
+ for (const length of [4, 6, 12]) assert.equal(randomShareCode(a => a.fill(1), length).length, length)
+ assert.match(resourcePasswordError('1234', '1234'), /8/)
+})
+
+// Execute both scripts from the actual Go-served gate in document order.
+function gate(query, responseStatus = 200) {
+ const source = fs.readFileSync(path.join(__dirname, '../../api/webprojects/content.go'), 'utf8')
+ const html = source.match(/html := `([\s\S]*?)`\n/)[1].replace('` + endpoint + `', '/api/web-share/42/unlock')
+ const scripts = Array.from(html.matchAll(/