diff --git a/README.md b/README.md index e6d0f6b..e4c91ae 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ docker run -d --name rawmark \ - 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。 - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,粘贴、上传和导入的图片统一存放在笔记同级的 `assets/`,链接使用 `./assets/文件名`,同目录相同内容去重。笔记跨目录移动时图片跟随,仍被其他笔记引用的原图保留;删除单篇笔记不会删除共享图片。旧 `笔记名.assets/` 仍可读取、移动与恢复;外部修改会实时同步到网页。 - 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。 -- 外观:浅色玻璃工作台与深色模式,默认字体为 Maple Mono Normal NL NF CN v7.9(界面、正文与代码),字体资源随应用本地提供,文字在所需字体加载完成后显示,加载失败会提示刷新;代码块使用 highlight.js,固定采用官网的 Atom One Dark 配色,与页面明暗模式独立;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 +- 外观:浅色玻璃工作台与深色模式,默认字体为 Maple Mono Normal NL NF CN v7.9(界面、正文与代码),字体资源随应用本地提供,文字在所需字体加载完成后显示,加载失败会提示刷新;代码块使用 highlight.js,固定采用官网的 Atom One Dark 配色,与页面明暗模式独立;界面、正文与代码字体可分别选择,各字体下拉均支持导入 TTF/OTF/WOFF/WOFF2(单个不超过30 MB)及删除;Maple为不可删除的默认字体,删除正在使用的字体会恢复Maple,导入字体保存在服务器数据目录的fonts/并跨设备共用。界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。 - 选区格式:电脑与触屏选中文字后提供外观、加粗、斜体、下划线、删除线、高亮和链接。外观可设置配色、字体、局部字号与效果;下划线及外观以受限的标准 HTML 行内标签保存在 Markdown 中,源码与导出保留标签,其他软件的显示取决于其 HTML 支持。清除样式只重置局部外观;底部不再显示格式栏。 - 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 diff --git a/cmd/rawmark/main.go b/cmd/rawmark/main.go index 262d730..727a441 100644 --- a/cmd/rawmark/main.go +++ b/cmd/rawmark/main.go @@ -83,6 +83,7 @@ func run(addr string) error { srv := api.New(v, am, st, hub, idx) srv.ThemesDir = filepath.Join(dataDir, "themes") srv.PluginsDir = filepath.Join(dataDir, "plugins") + srv.FontsDir = filepath.Join(dataDir, "fonts") httpServer := &http.Server{ Addr: addr, Handler: srv.Handler(web.Dist()), diff --git a/internal/api/api.go b/internal/api/api.go index 0e3053c..de907ca 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -40,6 +40,7 @@ type Server struct { // ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。 ThemesDir string PluginsDir string + FontsDir string pluginsMu sync.Mutex } @@ -98,6 +99,10 @@ func (s *Server) routes() []route { {"GET /api/v1/settings", authed, s.getSettings}, {"PATCH /api/v1/settings", sessionOnly, s.patchSettings}, + {"GET /api/v1/fonts", sessionOnly, s.listFonts}, + {"POST /api/v1/fonts", sessionOnly, s.importFont}, + {"GET /api/v1/fonts/{id}/file", sessionOnly, s.fontFile}, + {"DELETE /api/v1/fonts/{id}", sessionOnly, s.deleteFont}, {"GET /api/v1/plugins", sessionOnly, s.listPlugins}, {"POST /api/v1/plugins", sessionOnly, s.installPlugin}, {"PATCH /api/v1/plugins/{id}", sessionOnly, s.togglePlugin}, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 0823d3c..7d9218f 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -130,6 +130,7 @@ func newFixture(t *testing.T) *fixture { server := New(v, am, st, hub, idx) server.ThemesDir = filepath.Join(dir, "themes") server.PluginsDir = filepath.Join(dir, "plugins") + server.FontsDir = filepath.Join(dir, "fonts") srv := httptest.NewServer(server.Handler(fstest.MapFS{})) t.Cleanup(srv.Close) f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient()} diff --git a/internal/api/fonts.go b/internal/api/fonts.go new file mode 100644 index 0000000..7e1b839 --- /dev/null +++ b/internal/api/fonts.go @@ -0,0 +1,254 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "rawmark/internal/events" +) + +const maxFontBytes = 30 << 20 + +var fontIDPattern = regexp.MustCompile(`^custom-[a-f0-9]{64}$`) + +type fontInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Format string `json:"format"` +} + +func (s *Server) openFont(id string) (*os.Root, fontInfo, error) { + var info fontInfo + if !fontIDPattern.MatchString(id) || s.FontsDir == "" { + return nil, info, fs.ErrNotExist + } + root, err := os.OpenRoot(s.FontsDir) + if err != nil { + return nil, info, err + } + defer root.Close() + dir, err := root.OpenRoot(id) + if err != nil { + return nil, info, err + } + data, err := dir.ReadFile("manifest.json") + if err == nil { + err = json.Unmarshal(data, &info) + } + if err == nil && (info.ID != id || info.Name == "" || !validFontFormat(info.Format)) { + err = fmt.Errorf("字体安装信息无效") + } + if err != nil { + dir.Close() + return nil, info, err + } + return dir, info, nil +} + +func validFontFormat(format string) bool { + return format == "ttf" || format == "otf" || format == "woff" || format == "woff2" +} + +func (s *Server) listFonts(w http.ResponseWriter, r *http.Request) { + s.settingsMu.Lock() + defer s.settingsMu.Unlock() + fonts := []fontInfo{} + if s.FontsDir != "" { + entries, err := os.ReadDir(s.FontsDir) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + internalError(w, r, err) + return + } + for _, entry := range entries { + if !entry.IsDir() || !fontIDPattern.MatchString(entry.Name()) { + continue + } + dir, info, err := s.openFont(entry.Name()) + if err != nil { + internalError(w, r, err) + return + } + dir.Close() + fonts = append(fonts, info) + } + } + writeJSON(w, 200, map[string]any{"fonts": fonts}) +} + +func (s *Server) importFont(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxFontBytes+(1<<20)) + if err := r.ParseMultipartForm(1 << 20); err != nil { + writeError(w, 400, "无法读取字体文件,单个文件上限 30 MB") + return + } + defer r.MultipartForm.RemoveAll() + f, header, err := r.FormFile("file") + if err != nil { + writeError(w, 400, "请选择字体文件") + return + } + defer f.Close() + format := strings.TrimPrefix(strings.ToLower(filepath.Ext(header.Filename)), ".") + data, err := io.ReadAll(io.LimitReader(f, maxFontBytes+1)) + if err != nil { + internalError(w, r, err) + return + } + if len(data) > maxFontBytes { + writeError(w, 413, "字体文件超过 30 MB") + return + } + magic := map[string]string{"ttf": "\x00\x01\x00\x00", "otf": "OTTO", "woff": "wOFF", "woff2": "wOF2"} + if !validFontFormat(format) || len(data) < 12 || string(data[:4]) != magic[format] { + writeError(w, 400, "请导入有效的 TTF、OTF、WOFF 或 WOFF2 字体文件") + return + } + if s.FontsDir == "" { + writeError(w, 503, "字体目录未配置") + return + } + hash := sha256.Sum256(data) + id := "custom-" + hex.EncodeToString(hash[:]) + info := fontInfo{id, strings.TrimSuffix(filepath.Base(header.Filename), filepath.Ext(header.Filename)), format} + if strings.TrimSpace(info.Name) == "" || len(info.Name) > 200 || strings.ContainsAny(info.Name, "\x00\r\n") { + writeError(w, 400, "字体文件名无效或过长") + return + } + s.settingsMu.Lock() + defer s.settingsMu.Unlock() + if dir, existing, err := s.openFont(id); err == nil { + dir.Close() + writeJSON(w, 200, existing) + return + } else if !errors.Is(err, fs.ErrNotExist) { + internalError(w, r, err) + return + } + if err := os.MkdirAll(s.FontsDir, 0o755); err != nil { + internalError(w, r, err) + return + } + stage, err := os.MkdirTemp(s.FontsDir, ".import-") + if err != nil { + internalError(w, r, err) + return + } + defer os.RemoveAll(stage) + manifest, err := json.Marshal(info) + if err == nil { + err = os.WriteFile(filepath.Join(stage, "manifest.json"), manifest, 0o600) + } + if err == nil { + err = os.WriteFile(filepath.Join(stage, "font."+format), data, 0o600) + } + if err == nil { + err = os.Rename(stage, filepath.Join(s.FontsDir, id)) + } + if err != nil { + internalError(w, r, err) + return + } + s.hub.Broadcast(events.Event{Type: "settings"}) + writeJSON(w, 201, info) +} + +func (s *Server) fontFile(w http.ResponseWriter, r *http.Request) { + s.settingsMu.Lock() + dir, info, err := s.openFont(r.PathValue("id")) + if errors.Is(err, fs.ErrNotExist) { + s.settingsMu.Unlock() + writeError(w, 404, "字体不存在") + return + } + if err != nil { + s.settingsMu.Unlock() + internalError(w, r, err) + return + } + defer dir.Close() + f, err := dir.Open("font." + info.Format) + s.settingsMu.Unlock() + if err != nil { + internalError(w, r, err) + return + } + defer f.Close() + stat, err := f.Stat() + if err != nil || !stat.Mode().IsRegular() { + if err == nil { + err = fmt.Errorf("字体必须为普通文件") + } + internalError(w, r, err) + return + } + w.Header().Set("Content-Type", "font/"+info.Format) + http.ServeContent(w, r, "font."+info.Format, stat.ModTime(), f) +} + +func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "maple" { + writeError(w, 400, "Maple 是默认字体,不可删除") + return + } + s.settingsMu.Lock() + defer s.settingsMu.Unlock() + dir, _, err := s.openFont(id) + if errors.Is(err, fs.ErrNotExist) { + writeError(w, 404, "字体不存在") + return + } + if err != nil { + internalError(w, r, err) + return + } + dir.Close() + settings, err := s.readSettings() + if err != nil { + internalError(w, r, err) + return + } + for _, key := range []string{"uiFont", "textFont", "codeFont"} { + var selected string + if json.Unmarshal(settings[key], &selected) == nil && selected == id { + settings[key] = json.RawMessage(`"maple"`) + } + } + data, err := json.Marshal(settings) + if err != nil { + internalError(w, r, err) + return + } + root, err := os.OpenRoot(s.FontsDir) + if err != nil { + internalError(w, r, err) + return + } + defer root.Close() + // 先撤下资源;设置写失败时恢复,避免留下指向已删除字体的设置。 + removed := ".remove-" + id + if err := root.Rename(id, removed); err != nil { + internalError(w, r, err) + return + } + if err := s.store.SetSetting(settingsKey, string(data)); err != nil { + internalError(w, r, errors.Join(err, root.Rename(removed, id))) + return + } + s.hub.Broadcast(events.Event{Type: "settings"}) + if err := root.RemoveAll(removed); err != nil { + internalError(w, r, fmt.Errorf("字体已停用,但文件清理失败:%w", err)) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} diff --git a/internal/api/fonts_test.go b/internal/api/fonts_test.go new file mode 100644 index 0000000..0f64d99 --- /dev/null +++ b/internal/api/fonts_test.go @@ -0,0 +1,173 @@ +package api + +import ( + "bytes" + "database/sql" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func uploadTestFont(t *testing.T, f *fixture, client *http.Client, name string, data []byte) *http.Response { + t.Helper() + var body bytes.Buffer + form := multipart.NewWriter(&body) + file, err := form.CreateFormFile("file", name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(data); err != nil { + t.Fatal(err) + } + if err := form.Close(); err != nil { + t.Fatal(err) + } + req, err := http.NewRequest("POST", f.url+"/api/v1/fonts", &body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", form.FormDataContentType()) + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + return res +} + +func testFontData(t *testing.T) []byte { + t.Helper() + data, err := os.ReadFile("../../web/src/themes/fonts/maple/regular-00.woff2") + if err != nil { + t.Fatal(err) + } + return data +} + +func TestFontImportReuseReadAndDelete(t *testing.T) { + f := newFixture(t) + data := testFontData(t) + info := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "示例字体.woff2", data)) + if !fontIDPattern.MatchString(info.ID) || info.Name != "示例字体" || info.Format != "woff2" { + t.Fatalf("字体信息错误:%+v", info) + } + again := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "renamed.woff2", data)) + if again != info { + t.Fatalf("相同内容未复用原字体:%+v", again) + } + list := decode[struct{ Fonts []fontInfo }](t, f.do(f.admin, "GET", "/api/v1/fonts", "", nil)) + if len(list.Fonts) != 1 || list.Fonts[0] != info { + t.Fatalf("列表不符:%+v", list) + } + res := f.do(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil) + got, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil || !bytes.Equal(got, data) || res.Header.Get("Content-Type") != "font/woff2" { + t.Fatal("字体文件不一致", err) + } + f.do(f.admin, "PATCH", "/api/v1/settings", `{"uiFont":"`+info.ID+`","textFont":"`+info.ID+`","codeFont":"`+info.ID+`","plugin.data":{"keep":true}}`, nil).Body.Close() + f.do(f.admin, "DELETE", "/api/v1/fonts/"+info.ID, "", nil).Body.Close() + settings := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil)) + for _, key := range []string{"uiFont", "textFont", "codeFont"} { + if settings[key] != "maple" { + t.Fatalf("%s 未恢复默认:%v", key, settings[key]) + } + } + if settings["plugin.data"] == nil { + t.Fatal("无关设置被删除") + } + if code := f.status(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil); code != 404 { + t.Fatal("已删除字体仍可读取", code) + } + list = decode[struct{ Fonts []fontInfo }](t, f.do(f.admin, "GET", "/api/v1/fonts", "", nil)) + if len(list.Fonts) != 0 { + t.Fatal("已删除字体仍在列表") + } +} + +func TestFontBoundaries(t *testing.T) { + f := newFixture(t) + guest := newClient() + for _, endpoint := range []string{"/api/v1/fonts", "/api/v1/fonts/maple/file"} { + if code := f.status(guest, "GET", endpoint, "", nil); code != 401 { + t.Fatal("匿名字体访问未拒绝", code) + } + } + res := uploadTestFont(t, f, guest, "font.woff2", testFontData(t)) + res.Body.Close() + if res.StatusCode != 401 { + t.Fatal("匿名上传未拒绝", res.StatusCode) + } + if code := f.status(f.admin, "DELETE", "/api/v1/fonts/maple", "", nil); code != 400 { + t.Fatal("Maple不应可删除", code) + } + for _, tc := range []struct { + name string + data []byte + }{ + {"font.exe", testFontData(t)}, {"font.ttf", testFontData(t)}, {".woff2", testFontData(t)}, {"font.woff2", []byte("invalid")}, + } { + res := uploadTestFont(t, f, f.admin, tc.name, tc.data) + res.Body.Close() + if res.StatusCode != 400 { + t.Fatalf("非法字体 %s 未拒绝:%d", tc.name, res.StatusCode) + } + } + res = uploadTestFont(t, f, f.admin, "large.woff2", bytes.Repeat([]byte{'x'}, maxFontBytes+1)) + res.Body.Close() + if res.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatal("超大字体未拒绝", res.StatusCode) + } + token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"fonts","scope":"write"}`, nil)) + for _, tc := range []struct{ method, path string }{{"GET", "/api/v1/fonts"}, {"DELETE", "/api/v1/fonts/maple"}} { + if code := f.status(guest, tc.method, tc.path, "", map[string]string{"Authorization": "Bearer " + token.Token}); code != 403 { + t.Fatal("令牌不应管理字体", code) + } + } + info := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "font.woff2", testFontData(t))) + dir := filepath.Join(filepath.Dir(f.root), "fonts", info.ID) + if err := os.Remove(filepath.Join(dir, "font.woff2")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(filepath.Dir(f.root), "rawmark.db"), filepath.Join(dir, "font.woff2")); err != nil { + t.Fatal(err) + } + if code := f.status(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil); code != 500 { + t.Fatal("字体不应读取目录外的数据", code) + } +} + +func TestFontDeleteRollsBackWhenSettingsWriteFails(t *testing.T) { + f := newFixture(t) + info := decode[fontInfo](t, uploadTestFont(t, f, f.admin, "font.woff2", testFontData(t))) + f.do(f.admin, "PATCH", "/api/v1/settings", `{"uiFont":"`+info.ID+`"}`, nil).Body.Close() + db, err := sql.Open("sqlite", filepath.Join(filepath.Dir(f.root), "rawmark.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TRIGGER reject_font_settings BEFORE UPDATE ON settings BEGIN SELECT RAISE(ABORT, 'test write failure'); END`); err != nil { + t.Fatal(err) + } + if code := f.status(f.admin, "DELETE", "/api/v1/fonts/"+info.ID, "", nil); code != 500 { + t.Fatal("设置失败应报告错误", code) + } + res := f.do(f.admin, "GET", "/api/v1/fonts/"+info.ID+"/file", "", nil) + res.Body.Close() + settings := decode[map[string]any](t, f.do(f.admin, "GET", "/api/v1/settings", "", nil)) + if settings["uiFont"] != info.ID { + t.Fatal("失败后设置未保留") + } + entries, err := os.ReadDir(filepath.Join(filepath.Dir(f.root), "fonts")) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".remove-") { + t.Fatal("失败后字体未恢复") + } + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 5baaff3..f5690f8 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -2022,6 +2022,139 @@ } } } + }, + "/fonts": { + "get": { + "summary": "列出已导入字体", + "tags": [ + "设置" + ], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "200": { + "description": "成功" + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "只接受登录会话" + } + } + }, + "post": { + "summary": "导入字体,按内容去重(TTF/OTF/WOFF/WOFF2,上限30 MB)", + "tags": [ + "设置" + ], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "201": { + "description": "成功" + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "只接受登录会话" + } + }, + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "/fonts/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "summary": "删除导入字体,将使用它的界面、正文和代码字体恢复Maple;Maple不可删除", + "tags": [ + "设置" + ], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "200": { + "description": "成功" + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "只接受登录会话" + } + } + } + }, + "/fonts/{id}/file": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "summary": "读取导入字体文件", + "tags": [ + "设置" + ], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "200": { + "description": "成功" + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "只接受登录会话" + } + } + } } }, "components": { diff --git a/web/src/app/FontPicker.vue b/web/src/app/FontPicker.vue new file mode 100644 index 0000000..cfdc74b --- /dev/null +++ b/web/src/app/FontPicker.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/web/src/app/SettingsDialog.vue b/web/src/app/SettingsDialog.vue index 5b15bab..bede109 100644 --- a/web/src/app/SettingsDialog.vue +++ b/web/src/app/SettingsDialog.vue @@ -4,7 +4,8 @@ import { api, ApiError } from './api' import { runCommand } from './commands' import Icon from './Icon.vue' import AppearanceSettings from './AppearanceSettings.vue' -import { CODE_FONTS, TEXT_FONTS, UI_FONT_SIZE } from '../themes/typography' +import { UI_FONT_SIZE } from '../themes/typography' +import FontPicker from './FontPicker.vue' import PluginsPanel from './PluginsPanel.vue' import { installApp, installPrompt } from './pwa' import { CONTENT_WIDTHS, DEFAULTS, TAB_SIZES, loadSettings, settings, settingsSave, SettingsAppearanceError, updateSettings, type Settings } from './settings' @@ -164,6 +165,7 @@ async function changePassword() {