diff --git a/docs/deployment.md b/docs/deployment.md index 51de31b..46f11be 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -160,6 +160,7 @@ The Bearer authentication upgrade requires the frontend and backend to be releas - Resetting settings retains the account, resources, plugin storage, and local tabs. - Logs live in `logs/`, with three rotated files retained, each up to 10 MiB. Settings can export logs and a version summary; this does not reconstruct historical logs. - If the interface breaks after an update, first check that the frontend and backend come from the same build, then inspect browser network requests and application logs. See the [user guide](guide.md#plugins-and-interface-recovery) for plugin and appearance recovery entry points. +- Note reads send `Cache-Control: no-store, no-transform` so proxies preserve the strong revision ETag used by conditional saves. A proxy rewriting it to a weak ETag can cause otherwise valid edits to return HTTP 412; preserve this response policy through the public ingress. ## Managed development and production diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index e607e20..d14f3bb 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -160,6 +160,7 @@ Bearer 认证升级需要前后端一起发布。旧 Cookie 页面不能继续 - 重置设置保留账户、资源、插件存储和本机标签页。 - 日志位于 `logs/`,轮转保留三份,每份至多 10 MiB;可在设置中导出日志及版本摘要。该功能不补录历史日志。 - 更新后界面异常先核对前后端是否来自同一构建,再查看浏览器网络请求和应用日志;插件与外观恢复入口见[使用指南](guide.zh-CN.md#插件与界面恢复)。 +- 笔记读取返回 `Cache-Control: no-store, no-transform`,防止代理改写条件保存使用的强版本 ETag。代理将其改为弱 ETag 可能导致正常编辑返回 HTTP 412;公网入口须保留该响应策略。 ## 开发与生产托管 diff --git a/internal/api/api.go b/internal/api/api.go index 4f640e1..0f3f4e4 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -327,7 +327,8 @@ func (s *Server) readFile(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/markdown; charset=utf-8") w.Header().Set("ETag", `"`+rev+`"`) - w.Header().Set("Cache-Control", "no-store") + // Preserve the strong revision ETag through compression proxies for If-Match writes. + w.Header().Set("Cache-Control", "no-store, no-transform") w.Write(data) } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index f98ee08..7f5dd4f 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -37,6 +37,23 @@ type fixture struct { admin *http.Client } +func TestNoteRevisionSurvivesProxyBoundary(t *testing.T) { + f := newFixture(t) + f.write("proxy.md", "original") + res := f.do(f.admin, "GET", "/api/v1/files/proxy.md", "", nil) + defer res.Body.Close() + etag := res.Header.Get("ETag") + if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store, no-transform" || !strings.HasPrefix(etag, `"`) { + t.Fatalf("笔记响应必须保留强版本标识并禁止代理转换: %d %v", res.StatusCode, res.Header) + } + if code := f.status(f.admin, "PUT", "/api/v1/files/proxy.md", "updated", map[string]string{"If-Match": etag}); code != http.StatusOK { + t.Fatalf("读取的版本标识应允许条件保存: %d", code) + } + if code := f.status(f.admin, "PUT", "/api/v1/files/proxy.md", "stale", map[string]string{"If-Match": etag}); code != http.StatusPreconditionFailed { + t.Fatalf("过期版本必须仍拒绝保存: %d", code) + } +} + func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) { f := newFixture(t) f.write("from.md", "![](from.assets/x.png)") @@ -274,7 +291,11 @@ func TestAPIResponsesAreNotCached(t *testing.T) { // Firefox once cached identity and tree responses offline, incorrectly opening an unloadable workspace. for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} { res := f.do(f.admin, "GET", path, "", nil) - if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store" { + want := "no-store" + if strings.HasPrefix(path, "/api/v1/files/") { + want = "no-store, no-transform" + } + if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != want { t.Errorf("%s:状态 %d,缓存策略 %q", path, res.StatusCode, res.Header.Get("Cache-Control")) } }