Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ storage_root/

升级顺序为迁移、后端和同源代理、最后前端。模块关闭时仍应保留同源 API 代理,由后端返回明确的 404,兼容旧登录;不能让登录同步请求落到静态页面 fallback。

默认上传包上限为 50 MiB,解压后 200 MiB、5,000 个文件、16 层目录;每项目默认 1 GiB、最多 10 个成功版本。完整参数见 `config/config_example.yaml`。增大上传限制时同时调整 Nginx 的 `client_max_body_size`,当前版本不会被历史清理删除。
默认上传包上限为 50 MiB,解压后 200 MiB、5,000 个文件、16 层目录;每用户默认可创建 100,000 个网页项目,每项目默认 1 GiB、最多 10 个成功版本。说明书默认每用户 100,000 份。数量增加不改变存储容量、上传体积或访问权限;完整参数见 `config/config_example.yaml`。增大上传限制时同时调整 Nginx 的 `client_max_body_size`,当前版本不会被历史清理删除。

### 前端产物约定

Expand Down Expand Up @@ -247,7 +247,7 @@ go build -o output/bin/web-projects-storage-migrate ./cmd/web-projects-storage-m
| --- | --- |
| 邀请注册 | 存量用户每个 Asia/Singapore 自然月最多生成3个单次邀请码,固定168小时有效;新用户次月获得资格。关闭注册会永久废弃旧未用码。 |
| 个人中心 | 编辑昵称和联系资料、裁剪上传或移除头像;全站优先显示当前昵称与头像。改密验证当前密码后退出旧会话、停用原应用,联系资料不改变登录别名。 |
| 登录管理 | 查看有效会话的登录 IP、UA、客户端、系统、登录时间、到期及剩余时间;可退出选中或其他全部登录,当前登录与应用凭证保持。默认最多同时 5 个有效会话,管理员在账号策略调整上限;满额拒绝新登录。数量不代表物理设备数或在线人数。 |
| 登录管理 | 查看有效会话的登录 IP、UA、客户端、系统、登录时间、到期及剩余时间;可退出选中或其他全部登录,当前登录与应用凭证保持。默认最多同时 5 个有效会话,管理员在账号策略调整上限;满额成功登录默认退出最早会话。数量不代表物理设备数或在线人数。 |
| 管理员 | 查看用户有效会话数量及详情,达到 10 个提示;可人工封禁或重置昵称、头像。重置需要当前密码、原因与版本校验并记录审计;封禁/删除阻断会话、应用和 WebDAV,恢复不复活旧凭证。不能操作自己的封禁/删除/撤销管理员。 |
| 功能授权 | 家庭藏书和WebDAV分别授权,默认关闭;管理员也需显式授权。应用权限是用户能力与scope的交集。藏书继续共用库存,WebDAV保留现有共享根。 |
| 网页审核 | 管理员可审核所有可见性和保留版本,预览入口记录审计;下架/删除带审核锁,用户不能自行重新发布,恢复不会自动上线。 |
Expand Down
5 changes: 3 additions & 2 deletions api/accounts/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ func view(user *model.UserAccount, token string) accountView {
// 数据库模式交由账号服务签发受版本约束的会话;配置模式保留旧用户和令牌兼容。
func login(c *gin.Context) {
var input struct {
UserName string `json:"user_name"`
Password string `json:"password"`
UserName string `json:"user_name"`
Password string `json:"password"`
LegacyReplacement bool `json:"replace_oldest_session"` // 兼容已上线旧请求,仅解析,不控制登录策略。
}
if !bind(c, &input, 16<<10) {
return
Expand Down
51 changes: 23 additions & 28 deletions api/accounts/session_limit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@ import (
"encoding/base64"
"encoding/pem"
"net/http"
"strings"
"testing"

passportapi "github.com/mcoder2014/home_server/api/passport"
"github.com/mcoder2014/home_server/config"
"github.com/mcoder2014/home_server/domain/service/passport"
apperrors "github.com/mcoder2014/home_server/errors"
)

// newSessionLimitHTTPFixture opts into the production default rather than the
Expand All @@ -34,30 +32,16 @@ func newSessionLimitHTTPFixture(t *testing.T) *httpFixture {
return f
}

func requireSessionLimitDenied(t *testing.T, response apiResponse, legacy bool) {
t.Helper()
status := http.StatusTooManyRequests
if legacy {
status = http.StatusOK
}
if response.Status != status || response.Code != int(apperrors.ErrorCodeRateLimited) || !strings.Contains(string(response.Raw), "网站上限") || response.Data["token"] != nil {
t.Fatalf("full session limit must preserve the response contract and explain how to recover: status=%d code=%d body=%s", response.Status, response.Code, response.Raw)
}
for _, cookie := range response.Cookies {
if cookie.MaxAge > 0 {
t.Fatal("a rejected login issued a live browser cookie")
}
}
}

func TestHTTPSessionLimitDefaultRejectsSixthAndExemptsApplicationsAndBasic(t *testing.T) {
func TestHTTPSessionLimitDefaultReplacesSixthAndExemptsApplicationsAndBasic(t *testing.T) {
f := newSessionLimitHTTPFixture(t)
logins := []*browserSession{}
for i := 0; i < 5; i++ {
logins = append(logins, f.login(f.member, integrationPassword))
}
failed := f.request(http.MethodPost, "/api/auth/login", nil, map[string]string{"user_name": f.member, "password": integrationPassword}, nil)
requireSessionLimitDenied(t, failed, false)
oldest := logins[0]
latest := f.login(f.member, integrationPassword)
requireDenied(t, f.request(http.MethodGet, "/api/auth/me", oldest, nil, nil))
logins = append(logins[1:], latest)
for _, login := range logins {
f.me(login)
}
Expand Down Expand Up @@ -91,7 +75,7 @@ func TestHTTPSessionLimitDefaultRejectsSixthAndExemptsApplicationsAndBasic(t *te
requireSuccess(t, f.request(http.MethodGet, "/api/web-share", nil, nil, bearer))
}

func TestHTTPSessionLimitLegacyCannotBypassAndCookieExchangeDoesNotConsumeSlots(t *testing.T) {
func TestHTTPSessionLimitLegacyReplacesOldestAndCookieExchangeDoesNotConsumeSlots(t *testing.T) {
f := newSessionLimitHTTPFixture(t)
f.router.POST("/passport/login", passportapi.Login)
public, _, err := passport.GetLoginRsa(context.Background())
Expand All @@ -116,8 +100,10 @@ func TestHTTPSessionLimitLegacyCannotBypassAndCookieExchangeDoesNotConsumeSlots(
for i := 0; i < 4; i++ {
f.login(f.member, integrationPassword)
}
requireSessionLimitDenied(t, f.request(http.MethodPost, "/passport/login", nil, payload, nil), true)
requireSessionLimitDenied(t, f.request(http.MethodPost, "/api/auth/login", nil, map[string]string{"user_name": f.member, "password": integrationPassword}, nil), false)
legacy = requireSuccess(t, f.request(http.MethodPost, "/passport/login", nil, payload, nil))
requireDenied(t, f.request(http.MethodGet, "/api/account/sessions", nil, nil, map[string]string{"passport": token}))
token = legacy["token"].(string)
f.login(f.member, integrationPassword)
page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", nil, nil, map[string]string{"passport": token}))
currentID := object(page["current_session"])["id"]
for _, path := range []string{"/api/auth/browser-login", "/api/web-share/browser-login", "/api/web-projects/browser-login"} {
Expand Down Expand Up @@ -159,7 +145,6 @@ func TestHTTPSessionLimitAdministratorChangesApplyToNextLoginWithoutRevokingOld(
for _, login := range logins {
f.me(login)
}
requireSessionLimitDenied(t, f.request(http.MethodPost, "/api/auth/login", nil, map[string]string{"user_name": f.member, "password": integrationPassword}, nil), false)
for _, request := range []struct {
path string
session *browserSession
Expand All @@ -169,10 +154,20 @@ func TestHTTPSessionLimitAdministratorChangesApplyToNextLoginWithoutRevokingOld(
t.Fatal("lowered session limit hid or invalidated existing logins")
}
}
result := requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke-others", logins[0], nil, nil))
if number(result["revoked_count"]) != 5 {
latest := f.login(f.member, integrationPassword)
requireDenied(t, f.request(http.MethodGet, "/api/auth/me", logins[0], nil, nil))
page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", latest, nil, nil))
if number(page["total_count"]) != 2 {
t.Fatal("next login did not replace enough old sessions after lowering the cap")
}
result := requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke-others", latest, nil, nil))
if number(result["revoked_count"]) != 1 {
t.Fatal("could not release slots after lowering the session limit")
}
f.login(f.member, integrationPassword)
requireSessionLimitDenied(t, f.request(http.MethodPost, "/api/auth/login", nil, map[string]string{"user_name": f.member, "password": integrationPassword}, nil), false)
latest = f.login(f.member, integrationPassword)
page = requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", latest, nil, nil))
if number(page["total_count"]) != 2 {
t.Fatal("repeated login exceeded the lowered cap")
}
}
145 changes: 145 additions & 0 deletions api/accounts/session_replacement_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package accounts_test

import (
"crypto/sha256"
"net/http"
"strconv"
"testing"
"time"
)

func TestHTTPLoginReplacementDefaultsToRemovingOldestSessions(t *testing.T) {
for _, limit := range []int{5, 2} {
t.Run(strconv.Itoa(limit), func(t *testing.T) {
f := newSessionLimitHTTPFixture(t)
admin := f.login(f.owner, integrationPassword)
old := make([]*browserSession, 5)
for i := range old {
old[i] = f.login(f.member, integrationPassword)
digest := sha256.Sum256([]byte(old[i].Cookie.Value))
stamp := time.Now().UTC().Add(time.Duration(i-5) * time.Minute).Truncate(time.Second)
if _, err := f.database.Exec("UPDATE login_token SET create_time=?,authenticated_at=? WHERE token_digest=?", stamp, stamp, digest[:]); err != nil {
t.Fatal(err)
}
}
current := requireSuccess(t, f.request("GET", "/api/admin/config/account_policy", admin, nil, nil))
values := object(current["values"])
values["max_active_sessions"] = limit
if limit != 5 {
requireSuccess(t, f.publish(admin, "account_policy", values, "lower-session-replacement-limit"))
}
input := map[string]interface{}{"user_name": f.member, "password": integrationPassword}
input["password"] = "incorrect-password"
wrong := f.request("POST", "/api/auth/login", nil, input, nil)
requireDenied(t, wrong)
if wrong.Data["can_replace_oldest_session"] == true {
t.Fatal("invalid credentials exposed session replacement availability")
}
for _, session := range old {
f.me(session)
}
input["password"] = integrationPassword
replaced := f.request("POST", "/api/auth/login", nil, input, nil)
identity := requireSuccess(t, replaced)
var issued *http.Cookie
for _, cookie := range replaced.Cookies {
if cookie.MaxAge > 0 {
if issued != nil {
t.Fatal("replacement issued multiple live cookies")
}
issued = cookie
}
}
if issued == nil || !issued.Secure || !issued.HttpOnly {
t.Fatal("replacement must establish the usual secure browser session")
}
f.me(&browserSession{Cookie: issued, CSRF: identity["csrf_token"].(string)})
for i, session := range old {
response := f.request("GET", "/api/auth/me", session, nil, nil)
if i < len(old)-limit+1 {
requireDenied(t, response)
} else {
requireSuccess(t, response)
}
}
f.me(admin)
var active int
if err := f.database.QueryRow("SELECT COUNT(*) FROM login_token WHERE user_id=? AND is_expired=0", old[0].ID).Scan(&active); err != nil || active != limit {
t.Fatalf("replacement did not converge to the limit: active=%d limit=%d error=%v", active, limit, err)
}
})
}
}

func TestHTTPLoginLegacyReplacementParameterDoesNotControlPolicy(t *testing.T) {
f := newSessionLimitHTTPFixture(t)
old := make([]*browserSession, 5)
for i := range old {
old[i] = f.login(f.member, integrationPassword)
}
for i, value := range []bool{false, true} {
response := f.request("POST", "/api/auth/login", nil, map[string]interface{}{"user_name": f.member, "password": integrationPassword, "replace_oldest_session": value}, nil)
requireSuccess(t, response)
var issued *http.Cookie
for _, cookie := range response.Cookies {
if cookie.MaxAge > 0 {
issued = cookie
}
}
if issued == nil {
t.Fatal("legacy request did not establish a browser session")
}
f.me(&browserSession{Cookie: issued})
requireDenied(t, f.request("GET", "/api/auth/me", old[i], nil, nil))
var active int
if err := f.database.QueryRow("SELECT COUNT(*) FROM login_token WHERE is_expired=0").Scan(&active); err != nil || active != 5 {
t.Fatalf("legacy parameter changed the fixed replacement policy: active=%d error=%v", active, err)
}
}
}

func TestHTTPLoginReplacementRollsBackWhenIssuanceFails(t *testing.T) {
f := newSessionLimitHTTPFixture(t)
old := make([]*browserSession, 5)
for i := range old {
old[i] = f.login(f.member, integrationPassword)
}
if _, err := f.database.Exec("CREATE TRIGGER fail_replacement_insert BEFORE INSERT ON login_token FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='synthetic insert failure'"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := f.database.Exec("DROP TRIGGER IF EXISTS fail_replacement_insert"); err != nil {
t.Error(err)
}
})
response := f.request("POST", "/api/auth/login", nil, map[string]interface{}{"user_name": f.member, "password": integrationPassword}, nil)
if response.Status != http.StatusServiceUnavailable || len(response.Cookies) != 0 {
t.Fatalf("failed issuance must not create a browser session: status=%d", response.Status)
}
for _, session := range old {
f.me(session)
}
}

func TestHTTPConcurrentLoginReplacementKeepsSessionCap(t *testing.T) {
f := newSessionLimitHTTPFixture(t)
for i := 0; i < 5; i++ {
f.login(f.member, integrationPassword)
}
start := make(chan struct{})
results := make(chan apiResponse, 8)
for i := 0; i < cap(results); i++ {
go func() {
<-start
results <- f.request("POST", "/api/auth/login", nil, map[string]interface{}{"user_name": f.member, "password": integrationPassword}, nil)
}()
}
close(start)
for i := 0; i < cap(results); i++ {
requireSuccess(t, <-results)
}
var active int
if err := f.database.QueryRow("SELECT COUNT(*) FROM login_token WHERE is_expired=0").Scan(&active); err != nil || active != 5 {
t.Fatalf("concurrent replacement exceeded the cap: active=%d error=%v", active, err)
}
}
2 changes: 1 addition & 1 deletion config/config_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ manuals:
max_file_bytes: 52428800
max_items_per_manual: 100
max_manual_bytes: 524288000
max_manuals_per_user: 1000
max_manuals_per_user: 100000
max_user_bytes: 10737418240
min_free_disk_bytes: 2147483648
pdftoppm_path: /usr/bin/pdftoppm
Expand Down
2 changes: 1 addition & 1 deletion config/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func Registry(conf Config) []NamespaceSchema {
{"web_projects", "max_file_count", "单次文件数量上限", "个", 1, 100000, 5000},
{"web_projects", "max_directory_depth", "目录深度上限", "层", 1, 64, 16},
{"web_projects", "max_project_bytes", "单项目体积上限", "bytes", 1, 1 << 40, int64(1 << 30)},
{"web_projects", "max_projects_per_user", "每人可用项目数量", "个", 1, 100, 10},
{"web_projects", "max_projects_per_user", "每人可用项目数量", "个", 1, 100000, 100000},
{"web_projects", "max_user_bytes", "每人网页存储上限", "bytes", 1, 1 << 40, int64(10 << 30)},
{"web_projects", "min_free_disk_bytes", "网页磁盘保留空间", "bytes", 1 << 30, 1 << 40, int64(20 << 30)},
{"web_projects", "max_releases", "保留版本数量", "个", 1, 1000, 10},
Expand Down
10 changes: 9 additions & 1 deletion config/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,17 @@ func TestBootstrapChangeInvalidatesPreviousSnapshot(t *testing.T) {
func TestResourceQuotaSchemaDefaultsAndRelationships(t *testing.T) {
conf := Config{}
values := DefaultRuntimeValues(conf)["web_projects"]
if values["max_projects_per_user"] != 10 || values["max_user_bytes"] != int64(10<<30) || values["min_free_disk_bytes"] != int64(20<<30) {
if values["max_projects_per_user"] != 100000 || values["max_user_bytes"] != int64(10<<30) || values["min_free_disk_bytes"] != int64(20<<30) {
t.Fatal("resource quota defaults are missing")
}
for _, limit := range []int{1, 100000, 0, 100001} {
values["max_projects_per_user"] = limit
_, err := ValidateValues(conf, "web_projects", values)
if (err == nil) != (limit >= 1 && limit <= 100000) {
t.Fatalf("project count limit %d has unexpected validation result: %v", limit, err)
}
}
values["max_projects_per_user"] = 100000
values["max_user_bytes"] = int64(1)
if _, err := ValidateValues(conf, "web_projects", values); err == nil {
t.Fatal("per-user quota accepted below per-project quota")
Expand Down
10 changes: 10 additions & 0 deletions deploy/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@

新网页存储根使用 `/var/lib/home_server/web-share`,归服务用户所有、权限 0700,并保持在 WebDAV 共享树之外。

## 用户数量与登录替换升级

| 配置 | 新站点默认值 | 既有站点升级 |
| --- | --- | --- |
| `web_projects.max_projects_per_user` | 100000,允许 1–100000 | 发布现有动态配置的新 revision,仅调整该字段;保留其他字段、历史、校验和及 generation 语义 |
| `manuals.max_manuals_per_user` | 100000 | 私有 YAML 有显式值时改为 100000,重启后端生效 |
| `account_policy.max_active_sessions` | 5,允许 1–100 | 保留既有值;网站及旧 RSA 登录满额时默认退出最早有效会话并继续 |

部署前备份 YAML、动态配置及完整旧 release。前后端使用同一提交,验证满额登录默认退出最早会话、旧客户端参数不改变策略、错误密码和其他账号不受影响,以及新会话签发失败时撤销回滚。该版本不需要新增 DDL。网页数量超过 100 后,旧程序的配置范围校验会拒绝启动;回滚后端前须先用兼容新范围的程序发布旧范围内的数量,不能直接切回旧二进制。说明书、网页文件、存储容量与权限配置沿用原值。

## 账号接口的同源代理

账号版本上线时,同时更新 `/etc/home_server/locations/api_auth_locations.conf`,让站点引导、个人账号和管理中心请求进入后端。`frontend-nginx.conf` 保留未知 `/api/` 的 404 兜底;新接口由更具体的 `^~` 前缀匹配,管理员预览中的 HTML、JS、CSS 也经过后端鉴权。
Expand Down
Loading
Loading