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
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:
ACCOUNTS_HTTP_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_accounts_http_test_ci?parseTime=true'
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'
run: go test -v ./config ./api/accounts ./api/webprojects ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/webprojects ./domain/service/applications ./domain/service/passport ./domain/service/rsa ./internal/accountsmigrate ./cmd/accounts-migrate ./utils ./utils/md ./utils/log ./utils/ginfmt ./utils/cache ./domain/service/webanalytics
run: go test -v ./config ./api/accounts ./api/webprojects ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/webprojects ./domain/service/webcomments ./domain/service/applications ./domain/service/passport ./domain/service/rsa ./internal/accountsmigrate ./cmd/accounts-migrate ./utils ./utils/md ./utils/log ./utils/ginfmt ./utils/cache ./domain/service/webanalytics

- name: Test isolated DAL cache bindings
env:
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ cq_ddns_client -conf /etc/home_server/client_config.yaml -dry-run
| --- | --- |
| 创建 `report` | 得到 `/p/report/`,默认仅自己可见,发布前为草稿。 |
| 上传并发布 | 单 HTML 成为 `index.html`;ZIP 保留目录结构,发布时切换完整版本。 |
| 页面容器 | 新项目默认使用增强容器,提供固定导航、登录状态、浏览/评论切换;旧项目迁移后保持原始页面模式。 |
| 评论 | 登录且可见页面的用户可评论、回复;作者或项目所有者可解决、重开及重新关联,匿名用户看不到评论入口。 |
| 更改 URL | 修改 slug,文件目录和用户授权保持不变;旧地址不再指向该项目。 |
| 指定用户 | 创建者和选中的有效账号可读,其他账号不可读。 |
| 下线或删除 | 后续页面和资源请求返回 404;删除有 7 天回收期。 |
Expand All @@ -89,9 +91,11 @@ cq_ddns_client -conf /etc/home_server/client_config.yaml -dry-run

项目共享浏览器源,适合本人和受信任维护者上传的网页。服务端检查每个 HTML、JS、图片和附件请求,但不提供不可信 JavaScript 的项目间隔离。

增强容器只变换响应,不修改已发布文件。默认浏览模式不加载评论正文;评论模式的文字标记不接收指针事件,图片和交互模块从侧栏选择。地图、图表、播放器等模块使用 `data-hs-comment-interaction="preserve"`,容器不遍历内部 DOM、不代理业务事件。产品边界和实现细节见[需求文档](docs/specs/web-container-comments-requirements.md)与[技术方案](docs/specs/web-container-comments-technical-design.md),结构标准和 AK/SK 评论命令见 [home-server-web-share skill](skills/home-server-web-share/SKILL.md)。原始页面模式不注入菜单或评论运行时,已有评论数据仍保留。

### 部署配置

先检查现有 `login_token` 的索引;网页资源会逐请求验证 token,需要以 `token` 为首列的索引。首次安装的 `domain/dal/migrations/20260909_web_projects.sql` 包含普通 token 索引和三张新表,已有等价 token 索引时跳过相应建索引语句,不假设历史 token 数据唯一。已安装旧 PR 文字枚举表的环境改用 `20260912_web_projects_refactor.sql`,两者不能对同一个库重复执行;升级脚本遇到未知枚举会中止,修正数据后可重跑。迁移在目标数据库执行并核对后,再添加服务端配置:
先检查现有 `login_token` 的索引;网页资源会逐请求验证 token,需要以 `token` 为首列的索引。首次安装的 `domain/dal/migrations/20260909_web_projects.sql` 包含普通 token 索引和三张新表,已有等价 token 索引时跳过相应建索引语句,不假设历史 token 数据唯一。已安装旧 PR 文字枚举表的环境改用 `20260912_web_projects_refactor.sql`,两者不能对同一个库重复执行;升级脚本遇到未知枚举会中止,修正数据后可重跑。启用增强容器前执行 `20260916_web_comments.sql`:现有项目增加 `container_mode=raw`,并创建评论主题和事件表;新项目由应用层默认设为 `enhanced`。迁移在目标数据库执行并核对后,再添加服务端配置:

```yaml
web_projects:
Expand Down Expand Up @@ -291,7 +295,8 @@ AK/SK 管理在服务端按用户隔离;应用不是新的共享管理员账

1. 已有旧网页托管表先执行 `domain/dal/migrations/20260912_web_projects_refactor.sql`;首次安装仅执行新的 `20260909_web_projects.sql`。升级保留 HTTP 字符串枚举,但数据库使用 INT;name/slug/幂等键为 256,entry_file 为 2048,诊断信息进入 `extra TEXT` JSON。
2. 启用应用身份前执行 `domain/dal/migrations/20260912_applications.sql`,创建应用和访问 Token 表。数据库账号仅授予目标库权限,先在隔离库验证,不直接导入包含 `USE home_server` 的历史建表文件。
3. 在配置增加下列字段,更新同源 Nginx 片段并设置实际后端端口,再部署后端和前端。
3. 启用网页评论前执行 `domain/dal/migrations/20260916_web_comments.sql`,再部署包含容器运行时的后端;旧项目保持 `raw`,由所有者明确切换。
4. 在配置增加下列字段,更新同源 Nginx 片段并设置实际后端端口,再部署后端和前端。

```yaml
auth:
Expand Down
4 changes: 4 additions & 0 deletions api/accounts/analytics_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ func TestHTTPAnalyticsCountsOnlyDocumentsAndKeepsRevocation(t *testing.T) {
member := f.login(f.member, integrationPassword)
project, _ := f.privatePage(member)
id := project["id"].(string)
// Enhanced HTML is generated per response and deliberately ignores a stale
// conditional validator. Keep this analytics case on raw delivery because it
// explicitly verifies the existing 304 accounting path.
project = requireSuccess(t, f.request("PATCH", "/api/web-share/"+id, member, map[string]interface{}{"container_mode": "raw"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)}))
numericID, _ := strconv.ParseInt(id, 10, 64)
ddl, err := migrations.SQL.ReadFile("20260914_web_project_stats.sql")
if err != nil {
Expand Down
174 changes: 174 additions & 0 deletions api/accounts/comments_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package accounts_test

import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"testing"

"github.com/mcoder2014/home_server/config"
)

// Comments exercise real cookies, project ACLs and durable thread transitions.
func TestHTTPCommentsLifecycle(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
context := f.request("GET", base+"/view-context", owner, nil, nil)
if context.Status != 200 || context.Data["can_comment"] != true {
t.Fatalf("missing authenticated container context: %d %v", context.Status, context.Data)
}
if context.Data["csrf_token"] == owner.CSRF {
t.Fatal("hosted page received account-wide CSRF token")
}
if r := f.request("GET", "/p/http-private-fixture/", nil, nil, map[string]string{"Accept": "text/html"}); r.Status != 404 || !strings.Contains(string(r.Raw), "<h1>404</h1>") {
t.Fatalf("private anonymous page must be an HTML 404: %d", r.Status)
}
if r := f.request("GET", "/p/http-private-fixture/", guest, nil, map[string]string{"Accept": "text/html"}); r.Status != 404 || !strings.Contains(string(r.Raw), "<h1>404</h1>") {
t.Fatalf("private invisible user page must be an HTML 404: %d", r.Status)
}
if r := f.request("GET", base+"/comment-threads", guest, nil, nil); r.Status != 404 {
t.Fatalf("invisible comments: %d", r.Status)
}
input := map[string]interface{}{"request_id": "first-comment", "release_id": release, "page_key": "path:index.html", "page_path": "index.html", "anchor": map[string]interface{}{"kind": "text", "exact": "private review fixture"}, "body": "Review this paragraph"}
thread := requireSuccess(t, f.request("POST", base+"/comment-threads", owner, input, nil))
tid := thread["id"].(string)
duplicate := requireSuccess(t, f.request("POST", base+"/comment-threads", owner, input, nil))
if duplicate["id"] != tid {
t.Fatal("retry duplicated thread")
}
input["body"] = "different payload"
if r := f.request("POST", base+"/comment-threads", owner, input, nil); r.Status != 409 {
t.Fatalf("idempotency conflict: %d", r.Status)
}
oldReleaseID, _ := strconv.ParseInt(release, 10, 64)
newReleaseID := oldReleaseID + 1
var oldStorageKey string
if err := f.database.QueryRow("SELECT storage_key FROM web_project_release WHERE project_id=? AND id=?", id, oldReleaseID).Scan(&oldStorageKey); err != nil {
t.Fatal(err)
}
if _, err := f.database.Exec("INSERT INTO web_project_release (id,project_id,uploaded_by,storage_key,status,entry_file,sha256,file_count,total_bytes,extra) VALUES (?,?,?,?,2,'index.html',?,1,1,'{}')",
newReleaseID, id, owner.ID, "synthetic/version/"+strconv.FormatInt(newReleaseID, 10), strings.Repeat("b", 64)); err != nil {
t.Fatal(err)
}
if _, err := f.database.Exec("UPDATE web_project SET current_release_id=? WHERE id=?", newReleaseID, id); err != nil {
t.Fatal(err)
}
start := make(chan struct{})
concurrent := make(chan apiResponse, 2)
for range []int{0, 1} {
go func() {
<-start
concurrent <- f.request("POST", base+"/comment-threads", owner, map[string]interface{}{
"request_id": "concurrent-comment", "release_id": strconv.FormatInt(newReleaseID, 10), "page_key": "path:index.html", "page_path": "index.html",
"anchor": map[string]interface{}{"kind": "page"}, "body": "Concurrent retry",
}, nil)
}()
}
close(start)
firstConcurrent, secondConcurrent := requireSuccess(t, <-concurrent), requireSuccess(t, <-concurrent)
if firstConcurrent["id"] != secondConcurrent["id"] {
t.Fatal("concurrent retry duplicated thread")
}
if _, err := f.database.Exec("DELETE FROM web_project_release WHERE project_id=? AND id=?", id, oldReleaseID); err != nil {
t.Fatal(err)
}
path := base + "/comment-threads/" + tid
thread = requireSuccess(t, f.request("POST", path+"/replies", owner, map[string]interface{}{"request_id": "reply-1", "release_id": release, "body": "Fixed in next release"}, nil))
if r := f.request("POST", path+"/replies", owner, map[string]interface{}{"request_id": "unknown-release", "release_id": "9223372036854775806", "body": "must not forge trace metadata"}, nil); r.Status != http.StatusConflict {
t.Fatalf("unknown source release accepted: %d", r.Status)
}
legacyToken := f.applicationToken(owner, []string{"web-projects:write"})
if r := f.request("POST", path+"/replies", nil, map[string]interface{}{"request_id": "legacy-scope", "body": "must not be accepted"}, map[string]string{"Authorization": "Bearer " + legacyToken}); r.Status != http.StatusForbidden {
t.Fatalf("project scope granted comments: %d", r.Status)
}
commentToken := f.applicationToken(owner, []string{"web-comments:write"})
thread = requireSuccess(t, f.request("POST", path+"/replies", nil, map[string]interface{}{"request_id": "app-reply", "body": "Application response"}, map[string]string{"Authorization": "Bearer " + commentToken}))
for _, action := range []string{"resolve", "reopen"} {
thread = requireSuccess(t, f.request("POST", path+"/"+action, owner, map[string]interface{}{"request_id": action + "-1", "release_id": release}, map[string]string{"If-Match": fmt.Sprint(number(thread["revision"]))}))
}
events := requireSuccess(t, f.request("GET", path+"/events", owner, nil, nil))
items := events["items"].([]interface{})
if len(items) != 5 {
t.Fatal("comment/reply/state history lost")
}
appEvent := object(items[2])
if object(items[1])["source_release_id"] != release {
t.Fatalf("stale page reply lost its source release: %v", object(items[1]))
}
for _, index := range []int{3, 4} {
if object(items[index])["source_release_id"] != release {
t.Fatalf("stale page state event lost its source release: %v", object(items[index]))
}
}
if appEvent["actor_application_id"] == "0" || appEvent["actor_name_snapshot"] != "HTTP fixture application" {
t.Fatalf("application attribution lost: %v", appEvent)
}
if thread["status"] != "open" {
t.Fatal("reopen failed")
}
if _, err := f.database.Exec("INSERT INTO web_project_release (id,project_id,uploaded_by,storage_key,status,entry_file,sha256,file_count,total_bytes,extra) VALUES (?,?,?,?,2,'index.html',?,1,1,'{}')",
oldReleaseID, id, owner.ID, oldStorageKey, strings.Repeat("a", 64)); err != nil {
t.Fatal(err)
}
if _, err := f.database.Exec("UPDATE web_project SET current_release_id=? WHERE id=?", oldReleaseID, id); err != nil {
t.Fatal(err)
}
// Public readers may comment when logged in; anonymous users may only read the page.
requireSuccess(t, f.request("PATCH", base, owner, map[string]interface{}{"access_mode": "public"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)}))
if r := f.request("GET", base+"/view-context", nil, nil, nil); r.Status != 200 || r.Data["can_comment"] != false {
t.Fatal("anonymous comment capability exposed")
}
if r := f.request("GET", base+"/comment-threads", nil, nil, nil); r.Status != http.StatusUnauthorized {
t.Fatalf("anonymous comments leaked: %d", r.Status)
}
requireSuccess(t, f.request("POST", path+"/replies", guest, map[string]interface{}{"request_id": "guest-reply", "body": "Readable feedback"}, nil))
if r := f.request("POST", path+"/resolve", guest, map[string]interface{}{"request_id": "guest-resolve"}, map[string]string{"If-Match": "5"}); r.Status != 403 {
t.Fatalf("guest changed another thread: %d", r.Status)
}
page := f.request("GET", "/p/http-private-fixture/", nil, nil, nil)
if page.Status != 200 || !strings.Contains(string(page.Raw), "/api/web-share/container.js") {
t.Fatal("enhanced HTML lacks platform runtime")
}
}

func TestHTTPCommentWriteRechecksApplicationInLegacyIdentityMode(t *testing.T) {
f := newHTTPFixture(t)
owner := f.login(f.owner, integrationPassword)
project, release := f.privatePage(owner)
id := project["id"].(string)
token := f.applicationToken(owner, []string{"web-comments:write"})
var applicationID int64
if err := f.database.QueryRow("SELECT id FROM application ORDER BY id DESC LIMIT 1").Scan(&applicationID); err != nil {
t.Fatal(err)
}
before := config.Global()
t.Cleanup(func() { config.SetGlobalConfig(before) })
legacy := before
legacy.IdentitySource = "file"
payload, _ := json.Marshal(map[string]interface{}{
"request_id": "paused-legacy-comment", "release_id": release, "page_key": "path:index.html", "page_path": "index.html",
"anchor": map[string]interface{}{"kind": "page"}, "body": "must be rejected after revoke",
})
blocked, done := f.beginPausedRequest("/api/web-share/"+id+"/comment-threads", token, "application/json", payload)
config.SetGlobalConfig(legacy)
if _, err := f.database.Exec("UPDATE application SET status=2, revision=revision+1 WHERE id=?", applicationID); err != nil {
t.Fatal(err)
}
response := f.finishPausedRequest(blocked, done)
if response.Status < 400 || response.Status >= 500 {
t.Fatalf("revoked legacy-mode application write status=%d code=%d", response.Status, response.Code)
}
var count int
if err := f.database.QueryRow("SELECT COUNT(*) FROM web_comment_thread WHERE project_id=?", id).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("revoked application committed %d comment threads", count)
}
}
2 changes: 1 addition & 1 deletion api/accounts/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} {
for _, name := range []string{"20260909_web_projects.sql", "20260912_applications.sql", "20260916_web_comments.sql"} {
raw, err := migrations.SQL.ReadFile(name)
if err != nil {
t.Fatal(err)
Expand Down
25 changes: 23 additions & 2 deletions api/middleware/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ func CSRFToken(token string) string {
return base64.RawURLEncoding.EncodeToString(digest[:])
}

func ScopedCSRFToken(token, scope string) string {
digest := sha256.Sum256([]byte("home-server-csrf-v1:" + scope + ":" + token))
return base64.RawURLEncoding.EncodeToString(digest[:])
}

func BrowserOriginAllowed(request *http.Request) bool {
origins := request.Header.Values("Origin")
if len(origins) != 1 || origins[0] == "" {
Expand All @@ -87,14 +92,30 @@ func BrowserOriginAllowed(request *http.Request) bool {
}

func BrowserWrite() gin.HandlerFunc {
return browserWrite("")
}

// BrowserScopedWrite accepts the existing browser CSRF token for compatibility
// or a token that can authorize only this endpoint family. Hosted pages receive
// the scoped form so they never learn the account-wide write token.
func BrowserScopedWrite(scope string) gin.HandlerFunc {
return browserWrite(scope)
}

func browserWrite(scope string) gin.HandlerFunc {
return func(c *gin.Context) {
if !IsHTTPS(c) || !BrowserOriginAllowed(c.Request) {
ginfmt.Fail(c, apperrors.ErrForbidden)
c.Abort()
return
}
token := c.GetString(utils.CtxKeyLoginToken)
if token != "" && (len(c.Request.Header.Values("X-CSRF-Token")) != 1 || subtle.ConstantTimeCompare([]byte(c.GetHeader("X-CSRF-Token")), []byte(CSRFToken(token))) != 1) {
provided := c.GetHeader("X-CSRF-Token")
valid := subtle.ConstantTimeCompare([]byte(provided), []byte(CSRFToken(token))) == 1
if scope != "" {
valid = valid || subtle.ConstantTimeCompare([]byte(provided), []byte(ScopedCSRFToken(token, scope))) == 1
}
if token != "" && (len(c.Request.Header.Values("X-CSRF-Token")) != 1 || !valid) {
ginfmt.Fail(c, apperrors.WithMessage(apperrors.ErrForbidden, "请刷新页面后重试"))
c.Abort()
return
Expand Down Expand Up @@ -174,7 +195,7 @@ func AuthorizeCapability(ctx context.Context, principal *utils.Principal, scope
module = "library"
} else if strings.HasPrefix(scope, "webdav:") {
module = "webdav"
} else if strings.HasPrefix(scope, "web-projects:") {
} else if strings.HasPrefix(scope, "web-projects:") || strings.HasPrefix(scope, "web-comments:") {
module = "web_projects"
}
if module == "" {
Expand Down
Loading
Loading