From e66fee9dac62dd43f116a71bb338785cf65b35fa Mon Sep 17 00:00:00 2001 From: eezd Date: Tue, 20 Oct 2026 14:41:43 +0800 Subject: [PATCH] Include note filenames in new share links --- internal/api/api_test.go | 48 ++++++++++++++++++++++++++++++++++++++ internal/api/openapi.json | 2 +- internal/api/shares.go | 8 +++---- web/src/app/SharePanel.vue | 2 +- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index b2886e3..6eedd5f 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -3,11 +3,13 @@ package api import ( "compress/gzip" "context" + "encoding/base64" "encoding/json" "io" "net/http" "net/http/cookiejar" "net/http/httptest" + "net/url" "os" "path/filepath" "regexp" @@ -350,6 +352,52 @@ func TestShareAccess(t *testing.T) { } } +func TestShareFilenameToken(t *testing.T) { + f := newFixture(t) + name := "计划 #1? 50% +;\"" + p := "目录/" + name + ".md" + f.write(p, "正文") + f.write("目录/"+name+".assets/图.png", "PNG") + body, err := json.Marshal(map[string]string{"path": p, "password": "访问密码"}) + if err != nil { + t.Fatal(err) + } + sh := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil)) + suffix, ok := strings.CutPrefix(sh.Token, name+"-") + random, err := base64.RawURLEncoding.DecodeString(suffix) + if !ok || err != nil || len(random) != 16 { + t.Fatal("分享应包含完整文件名和 128 位随机字符,不包含目录") + } + other := decode[shareView](t, f.do(f.admin, "POST", "/api/v1/shares", string(body), nil)) + if other.Token == sh.Token { + t.Fatal("同一笔记的分享链接不能重复") + } + base := "/api/v1/public/shares/" + url.PathEscape(sh.Token) + guest := newClient() + if code := f.status(guest, "GET", base, "", nil); code != 401 { + t.Fatalf("未解锁应拒绝访问,得到 %d", code) + } + if code := f.status(guest, "POST", base+"/unlock", `{"password":"访问密码"}`, nil); code != 200 { + t.Fatalf("解锁失败:%d", code) + } + if code := f.status(guest, "GET", base, "", nil); code != 200 { + t.Fatalf("带保留字符的链接应可读取正文,得到 %d", code) + } + asset := base + "/assets/" + url.PathEscape(name+".assets") + "/图.png" + if code := f.status(guest, "GET", asset, "", nil); code != 200 { + t.Fatalf("解锁凭据应覆盖图片路径,得到 %d", code) + } + if code := f.status(guest, "GET", "/api/v1/public/shares/"+url.PathEscape(other.Token), "", nil); code != 401 { + t.Fatalf("凭据不能解锁另一分享,得到 %d", code) + } + if code := f.status(f.admin, "DELETE", "/api/v1/shares/"+url.PathEscape(sh.Token), "", nil); code != 200 { + t.Fatalf("撤销带保留字符的链接失败:%d", code) + } + if code := f.status(guest, "GET", base, "", nil); code != 404 { + t.Fatalf("撤销后应返回 404,得到 %d", code) + } +} + func TestShareWithoutPasswordAndExpiry(t *testing.T) { f := newFixture(t) f.write("a.md", "公开") diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 999d446..58a1607 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -1083,7 +1083,7 @@ }, "responses": { "200": { - "description": "新分享,访问地址为 /s/{token}", + "description": "新分享,token 为文件名(不含 .md)加连字符和随机字符,访问地址为 /s/{URL 编码后的 token};文件移动或改名不改变已生成的链接", "content": { "application/json": { "schema": { diff --git a/internal/api/shares.go b/internal/api/shares.go index bd31a1b..2a058c1 100644 --- a/internal/api/shares.go +++ b/internal/api/shares.go @@ -76,7 +76,7 @@ func (s *Server) createShare(w http.ResponseWriter, r *http.Request) { } now := time.Now() sh := store.Share{ - Token: auth.NewShareToken(), + Token: strings.TrimSuffix(path.Base(p), vault.NoteExt) + "-" + auth.NewShareToken(), Path: p, PasswordHash: auth.HashSharePassword(body.Password), CreatedAt: now.UnixMilli(), @@ -212,9 +212,9 @@ func (s *Server) unlockShare(w http.ResponseWriter, r *http.Request) { ttl = time.Until(time.UnixMilli(sh.ExpiresAt)) } http.SetCookie(w, &http.Cookie{ - Name: shareCookieName, - Value: value, - Path: "/api/v1/public/shares/" + sh.Token, + Name: shareCookieName, + Value: value, + // 默认 Path 为 /unlock 所在目录,避免非 ASCII 文件名违反 Cookie Path 格式。 MaxAge: int(ttl / time.Second), HttpOnly: true, Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", diff --git a/web/src/app/SharePanel.vue b/web/src/app/SharePanel.vue index d3dcc29..492dae4 100644 --- a/web/src/app/SharePanel.vue +++ b/web/src/app/SharePanel.vue @@ -22,7 +22,7 @@ const busy = ref(false) const failed = ref(false) const fmt = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }) -const linkOf = (s: Share) => `${location.origin}/s/${s.token}` +const linkOf = (s: Share) => `${location.origin}/s/${encodeURIComponent(s.token)}` const expired = (s: Share) => s.expiresAt !== 0 && s.expiresAt <= Date.now() function errorText(err: unknown) {