diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index b33bb8a..794c72c 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -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:
diff --git a/README.md b/README.md
index 78c873b..b5b49de 100644
--- a/README.md
+++ b/README.md
@@ -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 天回收期。 |
@@ -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:
@@ -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:
diff --git a/api/accounts/analytics_integration_test.go b/api/accounts/analytics_integration_test.go
index d38b9d2..73a2190 100644
--- a/api/accounts/analytics_integration_test.go
+++ b/api/accounts/analytics_integration_test.go
@@ -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 {
diff --git a/api/accounts/comments_integration_test.go b/api/accounts/comments_integration_test.go
new file mode 100644
index 0000000..5f8b90f
--- /dev/null
+++ b/api/accounts/comments_integration_test.go
@@ -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), "
404
") {
+ 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), "404
") {
+ 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)
+ }
+}
diff --git a/api/accounts/integration_test.go b/api/accounts/integration_test.go
index c014a86..dd7e25b 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"} {
+ 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)
diff --git a/api/middleware/account.go b/api/middleware/account.go
index b43194d..8cbced7 100644
--- a/api/middleware/account.go
+++ b/api/middleware/account.go
@@ -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] == "" {
@@ -87,6 +92,17 @@ 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)
@@ -94,7 +110,12 @@ func BrowserWrite() gin.HandlerFunc {
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
@@ -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 == "" {
diff --git a/api/webprojects/comments.go b/api/webprojects/comments.go
new file mode 100644
index 0000000..b61c0a0
--- /dev/null
+++ b/api/webprojects/comments.go
@@ -0,0 +1,185 @@
+package webprojects
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/mcoder2014/home_server/api/middleware"
+ "github.com/mcoder2014/home_server/domain/dal"
+ "github.com/mcoder2014/home_server/domain/db"
+ "github.com/mcoder2014/home_server/domain/service/accounts"
+ "github.com/mcoder2014/home_server/domain/service/webcomments"
+ service "github.com/mcoder2014/home_server/domain/service/webprojects"
+ "github.com/mcoder2014/home_server/utils"
+ "github.com/mcoder2014/home_server/utils/ginfmt"
+)
+
+// commentContext is the only anonymous comment-related API. Public viewers get
+// login/navigation state, never discussion data or mutation credentials.
+func commentContext(c *gin.Context) {
+ id, err := service.ParsePositiveID(c.Param("id"))
+ if err != nil {
+ ginfmt.Fail(c, service.ErrInvalid)
+ return
+ }
+ p, identityErr := middleware.ResolveIdentity(c, "web-comments:read", true, false)
+ if identityErr != nil {
+ p = nil
+ }
+ project, err := webcomments.Authorize(db.MasterDB().WithContext(c.Request.Context()), id, p, false, false)
+ if err != nil {
+ ginfmt.Fail(c, err)
+ return
+ }
+ explicitCredentials := c.GetHeader("Authorization") != "" || c.GetHeader(middleware.HeaderKey) != ""
+ if identityErr != nil && explicitCredentials {
+ ginfmt.Fail(c, identityErr)
+ return
+ }
+ if identityErr != nil && errors.Is(identityErr, service.ErrDependency) {
+ ginfmt.Fail(c, identityErr)
+ return
+ }
+ if identityErr != nil {
+ utils.ClearBrowserSession(c)
+ }
+ release, err := dal.QueryWebProjectRelease(id, *project.CurrentReleaseID)
+ if err != nil || release == nil {
+ ginfmt.Fail(c, service.ErrDependency)
+ return
+ }
+ result := gin.H{"user_id": "", "display_name": "未登录", "can_comment": false, "csrf_token": "", "release_id": strconv.FormatInt(release.ID, 10), "entry_file": release.EntryFile, "container_mode": project.ContainerMode, "owner_user_id": strconv.FormatInt(project.OwnerUserID, 10), "project_name": project.Name}
+ if p != nil {
+ result["user_id"] = strconv.FormatInt(p.UserID, 10)
+ result["display_name"] = "用户 " + strconv.FormatInt(p.UserID, 10)
+ if accounts.DatabaseMode() {
+ if u, e := accounts.GetByID(c.Request.Context(), p.UserID); e == nil && u != nil {
+ if u.DisplayName != "" {
+ result["display_name"] = u.DisplayName
+ } else {
+ result["display_name"] = u.Username
+ }
+ }
+ }
+ result["can_comment"] = project.ContainerMode == "enhanced" && (p.Kind == "user" || p.Allows("web-comments:write"))
+ if token := c.GetString(utils.CtxKeyLoginToken); token != "" {
+ result["csrf_token"] = middleware.ScopedCSRFToken(token, "web-comments")
+ }
+ }
+ c.Header("Cache-Control", "no-store")
+ ginfmt.Success(c, http.StatusOK, result)
+}
+
+// comments handles the bounded reader APIs and explicit thread actions. All
+// authorization and mutation rules live in webcomments, shared with AK/SK apps.
+func comments(c *gin.Context) {
+ c.Header("Cache-Control", "no-store")
+ id, err := service.ParsePositiveID(c.Param("id"))
+ if err != nil {
+ ginfmt.Fail(c, service.ErrInvalid)
+ return
+ }
+ threadID := int64(0)
+ if c.Param("thread_id") != "" {
+ threadID, err = service.ParsePositiveID(c.Param("thread_id"))
+ if err != nil {
+ ginfmt.Fail(c, service.ErrInvalid)
+ return
+ }
+ }
+ scope := "web-comments:read"
+ if c.Request.Method == http.MethodPost {
+ scope = "web-comments:write"
+ }
+ p, identityErr := middleware.ResolveIdentity(c, scope, true, false)
+ if identityErr != nil {
+ if errors.Is(identityErr, service.ErrForbidden) || errors.Is(identityErr, service.ErrDependency) {
+ ginfmt.Fail(c, identityErr)
+ return
+ }
+ // 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 {
+ ginfmt.Fail(c, visibleErr)
+ return
+ }
+ ginfmt.Fail(c, identityErr)
+ return
+ }
+ if c.Request.Method == http.MethodPost && c.GetHeader("Authorization") == "" && c.GetHeader(middleware.HeaderKey) == "" {
+ middleware.BrowserScopedWrite("web-comments")(c)
+ if c.IsAborted() {
+ return
+ }
+ }
+ if c.Request.Method == http.MethodGet {
+ cursorValue := c.Query("cursor")
+ if strings.HasSuffix(c.FullPath(), "/events") && c.Query("after_seq") != "" {
+ cursorValue = c.Query("after_seq")
+ }
+ cursor, e := optionalID(cursorValue)
+ if e != nil {
+ ginfmt.Fail(c, service.ErrInvalid)
+ return
+ }
+ limit, e := optionalLimit(c.Query("limit"))
+ if e != nil {
+ ginfmt.Fail(c, service.ErrInvalid)
+ return
+ }
+ if limit > 100 {
+ ginfmt.Fail(c, service.ErrInvalid)
+ return
+ }
+ var result interface{}
+ switch {
+ case strings.HasSuffix(c.FullPath(), "/events"):
+ result, err = webcomments.Events(c.Request.Context(), id, threadID, p, cursor, limit, c.Query("request_id"))
+ case threadID > 0:
+ result, err = webcomments.Detail(c.Request.Context(), id, threadID, p)
+ default:
+ result, err = webcomments.List(c.Request.Context(), id, p, c.Query("status"), cursor, limit, c.Query("request_id"))
+ }
+ if err != nil {
+ ginfmt.Fail(c, err)
+ return
+ }
+ ginfmt.Success(c, http.StatusOK, result)
+ return
+ }
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
+ var input webcomments.Input
+ if err = c.ShouldBindJSON(&input); err != nil {
+ ginfmt.Fail(c, service.ErrInvalid)
+ return
+ }
+ action := "comment"
+ revision := int64(0)
+ if threadID > 0 {
+ parts := strings.Split(c.FullPath(), "/")
+ action = parts[len(parts)-1]
+ if action == "replies" {
+ action = "reply"
+ }
+ }
+ if action != "comment" && action != "reply" {
+ revision, err = parseIfMatch(c.GetHeader("If-Match"))
+ if err != nil {
+ ginfmt.Fail(c, service.ErrInvalid)
+ return
+ }
+ }
+ result, err := webcomments.Mutate(c.Request.Context(), id, threadID, revision, p, action, input)
+ if err != nil {
+ ginfmt.Fail(c, err)
+ return
+ }
+ status := http.StatusOK
+ if action == "comment" {
+ status = http.StatusCreated
+ }
+ ginfmt.Success(c, status, result)
+}
diff --git a/api/webprojects/container.go b/api/webprojects/container.go
new file mode 100644
index 0000000..2849b48
--- /dev/null
+++ b/api/webprojects/container.go
@@ -0,0 +1,89 @@
+package webprojects
+
+import (
+ "bytes"
+ _ "embed"
+ "fmt"
+ stdhtml "html"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/mcoder2014/home_server/domain/model"
+ xhtml "golang.org/x/net/html"
+)
+
+//go:embed container/runtime.js
+var containerJS []byte
+
+func containerScript(c *gin.Context) {
+ c.Header("Cache-Control", "no-cache")
+ c.Header("X-Content-Type-Options", "nosniff")
+ c.Data(http.StatusOK, "application/javascript; charset=utf-8", containerJS)
+}
+
+func injectContainerScript(raw, script []byte) ([]byte, error) {
+ tokenizer := xhtml.NewTokenizer(bytes.NewReader(raw))
+ body := make([]byte, 0, len(raw)+len(script))
+ injected := false
+ for {
+ tokenType := tokenizer.Next()
+ if tokenType == xhtml.ErrorToken {
+ if tokenizer.Err() != io.EOF {
+ return nil, tokenizer.Err()
+ }
+ if !injected {
+ body = append(body, script...)
+ }
+ return body, nil
+ }
+ if !injected && tokenType == xhtml.EndTagToken {
+ name, _ := tokenizer.TagName()
+ if bytes.EqualFold(name, []byte("head")) || bytes.EqualFold(name, []byte("body")) {
+ body = append(body, script...)
+ injected = true
+ }
+ }
+ body = append(body, tokenizer.Raw()...)
+ }
+}
+
+// Serve only a transformed response; immutable release bytes and downloads stay
+// unchanged. The asset URL is absolute to avoid uploaded changing it.
+func serveEnhancedHTML(c *gin.Context, file *os.File, info os.FileInfo, project *model.WebProject, release *model.WebProjectRelease) error {
+ raw, err := io.ReadAll(io.LimitReader(file, 50<<20+1))
+ if err != nil || len(raw) > 50<<20 {
+ return fmt.Errorf("read enhanced HTML")
+ }
+ scheme := "https"
+ if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
+ scheme = "http"
+ }
+ source := stdhtml.EscapeString(scheme + "://" + c.Request.Host + "/api/web-share/container.js")
+ script := fmt.Sprintf(``, source, project.ID, release.ID, stdhtml.EscapeString(release.EntryFile))
+ body, err := injectContainerScript(raw, []byte(script))
+ if err != nil {
+ return fmt.Errorf("inject enhanced HTML: %w", err)
+ }
+ c.Header("ETag", "")
+ // A conditional request must not reuse an older raw/enhanced representation.
+ c.Request.Header.Del("If-Modified-Since")
+ c.Request.Header.Del("If-None-Match")
+ http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), bytes.NewReader(body))
+ return nil
+}
+
+func contentNotFound(c *gin.Context) {
+ c.Header("Cache-Control", "no-store")
+ if c.Request.Method == http.MethodHead {
+ c.Status(http.StatusNotFound)
+ return
+ }
+ if strings.Contains(c.GetHeader("Accept"), "text/html") {
+ c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(`404404
页面不存在或不可见。
系统主页`))
+ } else {
+ c.Status(http.StatusNotFound)
+ }
+}
diff --git a/api/webprojects/container/browser.test.cjs b/api/webprojects/container/browser.test.cjs
new file mode 100644
index 0000000..770eb95
--- /dev/null
+++ b/api/webprojects/container/browser.test.cjs
@@ -0,0 +1,408 @@
+// 本地浏览器验收:真实 Chromium 与 DOM;HTTP 接口使用本地 fixture,不连接生产。
+const {test, before, after} = require('node:test')
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+const http = require('node:http')
+let playwrightModule = process.env.PLAYWRIGHT_PATH
+if (!playwrightModule) {
+ try { playwrightModule = require.resolve('playwright') }
+ catch (_) { playwrightModule = path.join(require('node:os').homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') }
+}
+const {chromium} = require(playwrightModule)
+let browser, server, origin
+const source = path.join(__dirname, 'runtime.js')
+const context = {user_id: '42', owner_user_id: '42', display_name: '测试用户', can_comment: true, csrf_token: 'test-csrf', release_id: '11', entry_file: 'index.html', container_mode: 'enhanced'}
+const html = `真实交互测试页上午参观博物馆,下午沿河步行。
没有结构标记也能选择唯一文本。
私人表单内容

地图内部文本不能被读取为评论引用
`
+
+before(async () => {
+ server = http.createServer((req, res) => {
+ if (req.url === '/api/web-share/container.js') {
+ res.setHeader('Content-Type', 'application/javascript')
+ res.end(fs.existsSync(source) ? fs.readFileSync(source) : '')
+ } else if (req.url.includes('/deleted.html')) {
+ res.statusCode = 404
+ res.end('not found')
+ } else { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(req.url.includes('defer=1') ? html.replace('content