diff --git a/internal/api/api.go b/internal/api/api.go index 3361060..fe2de22 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -124,6 +124,9 @@ func (s *Server) Handler(static fs.FS) http.Handler { func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() + if strings.HasPrefix(r.URL.Path, "/api/") { + h.Set("Cache-Control", "no-store") + } h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "same-origin") h.Set("X-Frame-Options", "DENY") @@ -394,7 +397,7 @@ func (s *Server) serveRaw(w http.ResponseWriter, r *http.Request, rel string) { if ct := mime.TypeByExtension(path.Ext(info.Name())); ct != "" { h.Set("Content-Type", ct) } - h.Set("Cache-Control", "private, no-cache") + h.Set("Cache-Control", "no-store") http.ServeContent(w, r, info.Name(), info.ModTime(), f) } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 50544bf..f09170d 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1,6 +1,7 @@ package api import ( + "compress/gzip" "encoding/json" "io" "net/http" @@ -139,6 +140,41 @@ func TestOpenAPICoversRoutes(t *testing.T) { } } +func TestAPIResponsesAreNotCached(t *testing.T) { + f := newFixture(t) + f.write("笔记.md", "# 正文") + // 登录状态与目录曾在 Firefox 离线时命中 HTTP 缓存,误进入无法加载的工作区。 + 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" { + t.Errorf("%s:状态 %d,缓存策略 %q", path, res.StatusCode, res.Header.Get("Cache-Control")) + } + } +} + +func TestCompressedManifestContentType(t *testing.T) { + body := strings.Repeat(" ", 300) + `{"name":"RawMark","display":"standalone"}` + handler := newStaticHandler(fstest.MapFS{"manifest.webmanifest": {Data: []byte(body)}}) + req := httptest.NewRequest("GET", "/manifest.webmanifest", nil) + req.Header.Set("Accept-Encoding", "gzip") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + res := recorder.Result() + defer res.Body.Close() + if res.Header.Get("Content-Type") != "application/manifest+json" || res.Header.Get("Content-Encoding") != "gzip" { + t.Fatalf("清单响应头不正确:%v", res.Header) + } + zr, err := gzip.NewReader(res.Body) + if err != nil { + t.Fatal(err) + } + defer zr.Close() + var manifest map[string]string + if err := json.NewDecoder(zr).Decode(&manifest); err != nil || manifest["name"] != "RawMark" { + t.Fatalf("压缩清单无法解析:%v", err) + } +} + func TestShareAccess(t *testing.T) { f := newFixture(t) f.write("目录/笔记.md", "# 标题\n\n![](笔记.assets/图.png)\n") diff --git a/internal/api/static.go b/internal/api/static.go index 715d3b8..a639948 100644 --- a/internal/api/static.go +++ b/internal/api/static.go @@ -68,7 +68,10 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if share { hd.Set("X-Robots-Tag", "noindex") } - if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { + if path.Ext(name) == ".webmanifest" { + // Go 的 MIME 表未必包含此扩展名,不能让 ServeContent 对压缩后的数据嗅探类型。 + hd.Set("Content-Type", "application/manifest+json") + } else if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { hd.Set("Content-Type", ct) } hd.Add("Vary", "Accept-Encoding") diff --git a/web/index.html b/web/index.html index 418c210..ad4d015 100644 --- a/web/index.html +++ b/web/index.html @@ -6,6 +6,9 @@ RawMark + + +
diff --git a/web/public/icon.svg b/web/public/icon.svg new file mode 100644 index 0000000..944f4ba --- /dev/null +++ b/web/public/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/public/icons/apple-touch-icon.png b/web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..b157102 Binary files /dev/null and b/web/public/icons/apple-touch-icon.png differ diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png new file mode 100644 index 0000000..063e07c Binary files /dev/null and b/web/public/icons/icon-192.png differ diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png new file mode 100644 index 0000000..8df2768 Binary files /dev/null and b/web/public/icons/icon-512.png differ diff --git a/web/public/icons/maskable-512.png b/web/public/icons/maskable-512.png new file mode 100644 index 0000000..8df2768 Binary files /dev/null and b/web/public/icons/maskable-512.png differ diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..a41b40e --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "id": "/", + "name": "RawMark", + "short_name": "RawMark", + "lang": "zh-CN", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/web/src/app/App.vue b/web/src/app/App.vue index 2ad4f71..3c13d16 100644 --- a/web/src/app/App.vue +++ b/web/src/app/App.vue @@ -2,6 +2,8 @@ import { onMounted, ref } from 'vue' import { api, setUnauthorizedHandler } from './api' import AuthView from './AuthView.vue' +import AppStatus from './AppStatus.vue' +import { online, registerPwa } from './pwa' import Toasts from './Toasts.vue' import Workspace from './Workspace.vue' import { stopWorkspace } from './workspace' @@ -11,6 +13,8 @@ const phase = ref<'loading' | 'setup' | 'login' | 'ready' | 'error'>('loading') async function check() { try { const s = await api.authState() + // 请求成功比浏览器的网络状态提示更可靠(局域网和网络切换时可能不同步)。 + online.value = true phase.value = !s.setUp ? 'setup' : s.authenticated ? 'ready' : 'login' } catch { phase.value = 'error' @@ -21,15 +25,19 @@ setUnauthorizedHandler(() => { stopWorkspace() phase.value = 'login' }) -onMounted(check) +onMounted(() => { + void check() + void registerPwa() +})