feat(i18n): add English and Simplified Chinese

This commit is contained in:
eezd committed 2026-10-26 22:04:33 +08:00
1 parent 38c4c8b781
commit 1e9e0678ee
149 files changed
+5356 -1144

No files matched your search

+49 -24
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"path"
"rawmark/internal/backup"
"rawmark/internal/i18n"
"strings"
"sync"
"sync/atomic"
@@ -162,9 +163,11 @@ func (s *Server) Handler(static fs.FS) http.Handler {
}
mux.Handle(rt.pattern, h)
}
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, 404, "接口不存在") })
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, 404, message(w, "api_endpoint_not_found_c49d86"))
})
mux.Handle("/", newStaticHandler(static))
return securityHeaders(sameOrigin(s.guardAPI(mux)))
return localized(securityHeaders(sameOrigin(s.guardAPI(mux))))
}
// ---- 中间件 ----
@@ -189,7 +192,7 @@ func sameOrigin(next http.Handler) http.Handler {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
if origin := r.Header.Get("Origin"); origin != "" {
if host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://"); host != r.Host {
writeError(w, 403, "跨站请求被拒绝")
writeError(w, 403, message(w, "cross_site_request_rejected_5e7a50"))
return
}
}
@@ -216,9 +219,9 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
}
switch err := s.auth.Setup(body.Password); {
case errors.Is(err, auth.ErrWeakPassword):
writeError(w, 400, err.Error())
writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, auth.ErrAlreadySetUp):
writeError(w, 409, err.Error())
writeError(w, 409, i18n.RenderError(err, responseLocale(w)))
case err != nil:
internalError(w, r, err)
default:
@@ -242,13 +245,13 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password s
token, err := s.auth.AttemptLogin(clientIP(r), password, challenge)
switch {
case errors.Is(err, auth.ErrRateLimited):
writeError(w, 429, err.Error())
writeError(w, 429, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, errCaptchaInvalid):
writeFailure(w, 400, CodeCaptchaInvalid, err.Error(), nil)
writeFailure(w, 400, CodeCaptchaInvalid, i18n.RenderError(err, responseLocale(w)), nil)
case errors.Is(err, errCaptchaExpired):
writeFailure(w, 400, CodeCaptchaExpired, err.Error(), nil)
writeFailure(w, 400, CodeCaptchaExpired, i18n.RenderError(err, responseLocale(w)), nil)
case errors.Is(err, auth.ErrBadCredentials):
writeFailure(w, 401, CodeBadCredentials, err.Error(), nil)
writeFailure(w, 401, CodeBadCredentials, i18n.RenderError(err, responseLocale(w)), nil)
case err != nil:
internalError(w, r, err)
default:
@@ -294,7 +297,7 @@ func (s *Server) setTreeIcon(w http.ResponseWriter, r *http.Request) {
}
var icon *vault.TreeIcon
if len(body.Icon) == 0 || json.Unmarshal(body.Icon, &icon) != nil {
writeError(w, 400, "请求格式错误:需要 icon(null 恢复默认)")
writeError(w, 400, message(w, "invalid_request_icon_is_required_16149e"))
return
}
if err := s.vault.SetTreeIcon(body.Path, icon); err != nil {
@@ -332,7 +335,7 @@ func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
func (s *Server) writeFile(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxNoteBytes))
if err != nil {
writeError(w, 413, "笔记过大")
writeError(w, 413, message(w, "note_is_too_large_86d890"))
return
}
pre := vault.Precondition{
@@ -394,14 +397,14 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, 400, "缺少上传文件")
writeError(w, 400, message(w, "upload_file_is_missing_a2be00"))
return
}
defer file.Close()
var link string
if r.URL.Query().Has("folder") {
if r.URL.Query().Has("note") {
writeError(w, 400, "不能同时指定笔记和文件夹")
writeError(w, 400, message(w, "a_note_and_a_folder_8b62b5"))
return
}
link, err = s.vault.SaveFolderAsset(r.URL.Query().Get("folder"), header.Filename, file, maxAssetBytes)
@@ -421,16 +424,16 @@ func (s *Server) replaceImage(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeError(w, 413, "图片不能超过 50 MB")
writeError(w, 413, message(w, "images_must_not_exceed_mb_61d721"))
} else {
writeError(w, 400, "无法读取上传文件")
writeError(w, 400, message(w, "could_not_read_uploaded_file_4f04cc"))
}
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, 400, "缺少上传文件")
writeError(w, 400, message(w, "upload_file_is_missing_a2be00"))
return
}
defer file.Close()
@@ -504,7 +507,7 @@ func clientIP(r *http.Request) string {
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxJSONBytes)).Decode(v); err != nil {
writeError(w, 400, "请求格式错误")
writeError(w, 400, message(w, "invalid_request_format_2529b1"))
return false
}
return true
@@ -512,22 +515,22 @@ func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
func internalError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error("请求处理失败", "method", r.Method, "path", logPath(r.URL.Path), "err", err)
writeError(w, 500, "服务器内部错误")
writeError(w, 500, message(w, "internal_server_error_036549"))
}
func vaultError(w http.ResponseWriter, r *http.Request, err error) {
var conflict *vault.ConflictError
switch {
case errors.As(err, &conflict):
writeFailure(w, 412, CodeConflict, err.Error(), map[string]string{"rev": conflict.Rev})
writeFailure(w, 412, CodeConflict, message(w, "version_conflict"), map[string]string{"rev": conflict.Rev})
case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport), errors.Is(err, vault.ErrInvalidIcon):
writeError(w, 400, err.Error())
writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, vault.ErrNotFound):
writeError(w, 404, err.Error())
writeError(w, 404, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, vault.ErrExists):
writeError(w, 409, err.Error())
writeError(w, 409, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, vault.ErrTooLarge):
writeError(w, 413, err.Error())
writeError(w, 413, i18n.RenderError(err, responseLocale(w)))
default:
internalError(w, r, err)
}
@@ -539,5 +542,27 @@ func (s *Server) metadata(w http.ResponseWriter, r *http.Request) {
internalError(w, r, err)
return
}
writeJSON(w, 200, map[string]any{"notes": notes, "ready": ready})
// Older derived indexes store rendered warnings. Reanalyze only affected notes
// and keep each returned note at one revision while the index catches up.
currentNotes := notes[:0]
for _, note := range notes {
if len(note.Warnings) > 0 {
content, rev, err := s.vault.Read(note.Path)
if errors.Is(err, vault.ErrNotFound) {
continue
}
if err != nil {
vaultError(w, r, err)
return
}
note.Metadata = vault.Analyze(note.Path, content)
note.Rev = rev
note.Warnings = []string{}
for _, warning := range note.Diagnostics {
note.Warnings = append(note.Warnings, i18n.RenderError(warning, responseLocale(w)))
}
}
currentNotes = append(currentNotes, note)
}
writeJSON(w, 200, map[string]any{"notes": currentNotes, "ready": ready})
}
+5 -1
View File
@@ -1,6 +1,7 @@
package api
import "net/http"
import "rawmark/internal/i18n"
func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
var body struct {
@@ -10,7 +11,7 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
return
}
if len(body.Paths) == 0 || len(body.Paths) > 1000 {
writeError(w, 400, "请选择 1~1000 张待清理图片")
writeError(w, 400, message(w, "select_between_and_images_to_338e4e"))
return
}
result, err := s.vault.CleanupImages(body.Paths)
@@ -18,5 +19,8 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
vaultError(w, r, err)
return
}
for i := range result.Skipped {
result.Skipped[i].Reason = i18n.RenderError(result.Skipped[i].Cause, responseLocale(w))
}
writeJSON(w, 200, result)
}
+1 -1
View File
@@ -34,7 +34,7 @@ func TestImageCleanupRejectsDirectories(t *testing.T) {
t.Fatalf("清理应逐项汇报:%d", res.StatusCode)
}
result := decode[vault.CleanupResult](t, res)
if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 1 || result.Skipped[0].Path != "assets/folder.png" || result.Skipped[0].Reason != "不是图片文件" {
if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 1 || result.Skipped[0].Path != "assets/folder.png" || result.Skipped[0].Reason != "Not an image file" {
t.Fatalf("目录应跳过,普通孤立图片仍可清理:%+v", result)
}
kept := f.do(f.admin, "GET", "/api/v1/files/assets/folder.png/keep.md", "", nil)
+4 -4
View File
@@ -33,7 +33,7 @@ func (s *Server) requireAuth(next http.Handler, allowAPI bool) http.Handler {
header := r.Header.Get("Authorization")
token, ok := strings.CutPrefix(header, "Bearer ")
if !ok || token == "" || strings.ContainsAny(token, " \t\r\n") {
writeError(w, 401, "请先登录")
writeError(w, 401, message(w, "please_sign_in_48144f"))
return
}
p := principal{Token: token}
@@ -50,16 +50,16 @@ func (s *Server) requireAuth(next http.Handler, allowAPI bool) http.Handler {
return
}
if !valid {
writeError(w, 401, "登录凭据无效或已过期")
writeError(w, 401, message(w, "credentials_are_invalid_or_expired_e1f63b"))
return
}
if p.SessionHash == "" {
if !allowAPI {
writeError(w, 403, "此接口需要登录会话")
writeError(w, 403, message(w, "this_endpoint_requires_a_login_d34b96"))
return
}
if p.Scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") {
writeError(w, 403, "只读令牌不能修改数据")
writeError(w, 403, message(w, "read_only_tokens_cannot_modify_6cf8e2"))
return
}
}
+4 -4
View File
@@ -5,8 +5,8 @@ import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"net/http"
"rawmark/internal/i18n"
"sync"
"time"
@@ -15,8 +15,8 @@ import (
)
var (
errCaptchaInvalid = errors.New("验证码错误或已使用,请刷新后重试")
errCaptchaExpired = errors.New("验证码已过期,请刷新后重试")
errCaptchaInvalid = i18n.New("the_verification_code_is_incorrect_6e789b")
errCaptchaExpired = i18n.New("the_verification_code_has_expired_0b3384")
)
type loginChallenge struct {
@@ -113,7 +113,7 @@ func (s *Server) createCaptcha(w http.ResponseWriter, r *http.Request) {
}
id, item, err := s.captchas.create(clientIP(r), body.ReplaceID)
if err != nil {
writeError(w, 429, err.Error())
writeError(w, 429, i18n.RenderError(err, responseLocale(w)))
return
}
var png bytes.Buffer
+13 -13
View File
@@ -5,12 +5,12 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"rawmark/internal/i18n"
"regexp"
"strings"
@@ -46,7 +46,7 @@ func (s *Server) openFont(id string) (*os.Root, fontInfo, error) {
err = json.Unmarshal(data, &info)
}
if err == nil && (info.ID != id || info.Name == "" || !validFontFormat(info.Format)) {
err = fmt.Errorf("字体安装信息无效")
err = i18n.New("invalid_font_installation_information_77cff8")
}
if err != nil {
dir.Close()
@@ -88,13 +88,13 @@ func (s *Server) listFonts(w http.ResponseWriter, r *http.Request) {
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")
writeError(w, 400, message(w, "could_not_read_font_file_b7b543"))
return
}
defer r.MultipartForm.RemoveAll()
f, header, err := r.FormFile("file")
if err != nil {
writeError(w, 400, "请选择字体文件")
writeError(w, 400, message(w, "choose_a_font_file_4c95b9"))
return
}
defer f.Close()
@@ -105,23 +105,23 @@ func (s *Server) importFont(w http.ResponseWriter, r *http.Request) {
return
}
if len(data) > maxFontBytes {
writeError(w, 413, "字体文件超过 30 MB")
writeError(w, 413, message(w, "font_file_exceeds_mb_cc001c"))
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 字体文件")
writeError(w, 400, message(w, "import_a_valid_ttf_otf_b67fd3"))
return
}
if s.FontsDir == "" {
writeError(w, 503, "字体目录未配置")
writeError(w, 503, message(w, "font_directory_is_not_configured_73d32a"))
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, "字体文件名无效或过长")
writeError(w, 400, message(w, "font_filename_is_invalid_or_a3fd5e"))
return
}
s.settingsMu.Lock()
@@ -167,7 +167,7 @@ func (s *Server) fontFile(w http.ResponseWriter, r *http.Request) {
dir, info, err := s.openFont(r.PathValue("id"))
if errors.Is(err, fs.ErrNotExist) {
s.settingsMu.Unlock()
writeError(w, 404, "字体不存在")
writeError(w, 404, message(w, "font_not_found_4383e9"))
return
}
if err != nil {
@@ -186,7 +186,7 @@ func (s *Server) fontFile(w http.ResponseWriter, r *http.Request) {
stat, err := f.Stat()
if err != nil || !stat.Mode().IsRegular() {
if err == nil {
err = fmt.Errorf("字体必须为普通文件")
err = i18n.New("a_font_must_be_a_5b8b51")
}
internalError(w, r, err)
return
@@ -198,14 +198,14 @@ func (s *Server) fontFile(w http.ResponseWriter, r *http.Request) {
func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "maple" {
writeError(w, 400, "Maple 是默认字体,不可删除")
writeError(w, 400, message(w, "maple_is_the_default_font_a8a571"))
return
}
s.settingsMu.Lock()
defer s.settingsMu.Unlock()
dir, _, err := s.openFont(id)
if errors.Is(err, fs.ErrNotExist) {
writeError(w, 404, "字体不存在")
writeError(w, 404, message(w, "font_not_found_4383e9"))
return
}
if err != nil {
@@ -247,7 +247,7 @@ func (s *Server) deleteFont(w http.ResponseWriter, r *http.Request) {
}
s.hub.Broadcast(events.Event{Type: "settings"})
if err := root.RemoveAll(removed); err != nil {
internalError(w, r, fmt.Errorf("字体已停用,但文件清理失败:%w", err))
internalError(w, r, i18n.New("font_disabled_but_file_cleanup_d8b892", err))
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
+13 -13
View File
@@ -4,13 +4,13 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"net/url"
"rawmark/internal/i18n"
"syscall"
"time"
@@ -18,8 +18,8 @@ import (
)
var (
errImageAddress = errors.New("图片地址必须是公网 HTTP 或 HTTPS 地址")
errImageType = errors.New("仅支持导入 PNG、JPEG、GIF 和 WebP 图片")
errImageAddress = i18n.New("image_url_must_be_a_391763")
errImageType = i18n.New("only_png_jpeg_gif_and_a413cf")
)
var blockedImageNetworks = []netip.Prefix{
@@ -98,7 +98,7 @@ var remoteImageClient = &http.Client{
},
CheckRedirect: func(r *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("图片来源重定向次数过多")
return i18n.New("too_many_redirects_from_the_6738ba")
}
r.Header.Del("Referer")
return checkImageURL(r.URL)
@@ -137,12 +137,12 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
}
u, err := url.Parse(body.URL)
if err != nil || checkImageURL(u) != nil {
writeError(w, 400, errImageAddress.Error())
writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w)))
return
}
note := r.URL.Query().Get("note")
if !vault.IsNote(note) {
writeError(w, 400, "所属笔记路径无效")
writeError(w, 400, message(w, "invalid_note_path_be69bd"))
return
}
if _, err := s.vault.Stat(note); err != nil {
@@ -161,7 +161,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u.String(), nil)
if err != nil {
writeError(w, 400, errImageAddress.Error())
writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w)))
return
}
res, err := remoteImageClient.Do(req)
@@ -171,7 +171,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
continue
}
if errors.Is(err, errImageAddress) {
writeError(w, 400, errImageAddress.Error())
writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w)))
return
}
// url.Error 含完整来源 URL,其中可能有临时签名,日志只保留底层原因。
@@ -180,7 +180,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
err = urlError.Err
}
slog.Error("下载图片失败", "err", err)
writeError(w, 502, "下载图片失败")
writeError(w, 502, message(w, "could_not_download_image_b4a293"))
return
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
@@ -188,7 +188,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
if attempt == 0 && (res.StatusCode == 429 || res.StatusCode >= 500 && res.StatusCode < 600) {
continue
}
writeError(w, 502, fmt.Sprintf("图片来源返回 HTTP %d", res.StatusCode))
writeError(w, 502, message(w, "image_source_http", res.StatusCode))
return
}
data, ext, err := readImageDownload(res.Body)
@@ -201,10 +201,10 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, vault.ErrTooLarge) {
vaultError(w, r, err)
} else if errors.Is(err, errImageType) {
writeError(w, 422, err.Error())
writeError(w, 422, i18n.RenderError(err, responseLocale(w)))
} else {
slog.Error("读取下载图片失败", "err", err)
writeError(w, 502, "读取下载图片失败")
writeError(w, 502, message(w, "could_not_read_downloaded_image_4b7fdf"))
}
return
}
@@ -215,7 +215,7 @@ func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
return
}
if info.IsDir() {
writeError(w, 400, "所属路径必须是笔记文件")
writeError(w, 400, message(w, "the_owning_path_must_be_867b53"))
return
}
link, err := s.vault.SaveAsset(note, "image"+ext, bytes.NewReader(data), maxAssetBytes)
+7 -3
View File
@@ -3,6 +3,7 @@ package api
import (
"errors"
"net/http"
"rawmark/internal/i18n"
"rawmark/internal/vault"
)
@@ -13,16 +14,16 @@ func (s *Server) importNotes(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(8 << 20); err != nil {
var limit *http.MaxBytesError
if errors.As(err, &limit) {
writeError(w, 413, "导入文件总量不能超过 100 MB")
writeError(w, 413, message(w, "total_import_size_must_not_23ac37"))
} else {
writeError(w, 400, "无法读取导入表单")
writeError(w, 400, message(w, "could_not_read_import_form_69f1c3"))
}
return
}
defer r.MultipartForm.RemoveAll()
headers := r.MultipartForm.File["files"]
if len(headers) == 0 || len(headers) > 10_000 {
writeError(w, 400, "请选择 Markdown 文件或一个 ZIP")
writeError(w, 400, message(w, "choose_markdown_files_or_a_bea10e"))
return
}
files := make([]vault.ImportFile, 0, len(headers))
@@ -40,5 +41,8 @@ func (s *Server) importNotes(w http.ResponseWriter, r *http.Request) {
vaultError(w, r, err)
return
}
for i, warning := range result.WarningMessages {
result.Warnings[i] = i18n.RenderError(warning, responseLocale(w))
}
writeJSON(w, http.StatusCreated, result)
}
+36
View File
@@ -0,0 +1,36 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"rawmark/internal/i18n"
"strings"
)
func localized(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Language", string(i18n.Negotiate(r.Header.Get("Accept-Language"))))
}
next.ServeHTTP(w, r)
})
}
func responseLocale(w http.ResponseWriter) i18n.Locale {
return i18n.Negotiate(w.Header().Get("Content-Language"))
}
func message(w http.ResponseWriter, key string, args ...any) string {
return i18n.Text(responseLocale(w), key, args...)
}
func validateLocale(settings map[string]json.RawMessage) error {
if raw, ok := settings["locale"]; ok {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil
}
var locale string
if err := json.Unmarshal(raw, &locale); err != nil || (locale != "en" && locale != "zh-CN") {
return i18n.New("invalid_locale")
}
}
return nil
}
+214
View File
@@ -0,0 +1,214 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"rawmark/internal/search"
"rawmark/internal/vault"
"reflect"
"strings"
"testing"
"time"
"rawmark/internal/i18n"
)
func localizedResponse(t *testing.T, res *http.Response) response {
t.Helper()
defer res.Body.Close()
var body response
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatal(err)
}
return body
}
func TestLocalizedHTTPContracts(t *testing.T) {
f := newFixture(t)
for _, language := range []string{"en", "zh-CN"} {
h := map[string]string{"Accept-Language": language}
res := f.do(newClient(), "GET", "/api/v1/settings", "", h)
if res.StatusCode != 401 || res.Header.Get("Content-Language") != language || res.Header.Get("Cache-Control") != "no-store" {
t.Fatal("authentication response contract changed")
}
body := localizedResponse(t, res)
want := "Please sign in"
if language == "zh-CN" {
want = "请先登录"
}
if body.Message != want || body.Code != 40100 {
t.Fatalf("%s: %+v", language, body)
}
res = f.do(f.admin, "PATCH", "/api/v1/settings", `{"locale":"fr"}`, h)
if res.StatusCode != 400 {
t.Fatal("invalid locale accepted")
}
if body := localizedResponse(t, res); body.Code != 40000 || body.Message != i18n.Text(i18n.Locale(language), "invalid_locale") {
t.Fatal(body)
}
id, _ := challengeFor(t, f)
res = f.do(newClient(), "POST", "/api/v1/auth/login", loginBody(id, "invalid", "password123"), h)
if res.StatusCode != 400 {
t.Fatal("invalid captcha status changed")
}
if body := localizedResponse(t, res); body.Code != CodeCaptchaInvalid || !strings.Contains(body.Message, map[string]string{"en": "Refresh", "zh-CN": "刷新"}[language]) {
t.Fatal(body)
}
id, code := challengeFor(t, f)
f.server.captchas.mu.Lock()
challenge := f.server.captchas.challenges[id]
challenge.expires = time.Now().Add(-time.Second)
f.server.captchas.challenges[id] = challenge
f.server.captchas.mu.Unlock()
res = f.do(newClient(), "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), h)
if body := localizedResponse(t, res); body.Code != CodeCaptchaExpired {
t.Fatal(body)
}
}
for _, value := range []string{`{"locale":"zh-CN"}`, `{"locale":"en"}`, `{"locale":null}`} {
res := f.do(f.admin, "PATCH", "/api/v1/settings", value, nil)
if res.StatusCode != 200 {
t.Fatal("valid language rejected")
}
res.Body.Close()
}
if err := validatePreferences(map[string]json.RawMessage{settingsKey: json.RawMessage(`{"locale":"fr"}`)}); !errors.Is(err, errMaintenanceInput) {
t.Fatal("invalid imported language accepted")
}
}
func TestMaintenanceStatusIsRenderedForEachReader(t *testing.T) {
f := newFixture(t)
if err := f.server.maintain("export_backup_e9d3dd", func() error { return i18n.New("invalid_locale") }); err == nil {
t.Fatal("missing failure")
}
for _, language := range []string{"en", "zh-CN"} {
data := decode[maintenanceStatus](t, f.do(f.admin, "GET", "/api/v1/maintenance/status", "", map[string]string{"Accept-Language": language}))
if data.Operation != i18n.Text(i18n.Locale(language), "export_backup_e9d3dd") || data.Error != i18n.Text(i18n.Locale(language), "invalid_locale") {
t.Fatal(data)
}
}
}
func TestLocalizedResultDetailsDoNotRewriteSourceData(t *testing.T) {
f := newFixture(t)
const content = "---\ntitle: User title\ntags: 1\n---\nUser content\n"
f.write("bad.md", content)
f.write("used.md", "![](./assets/used.png)")
f.write("assets/used.png", "synthetic image")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
for _, language := range []string{"en", "zh-CN"} {
h := map[string]string{"Accept-Language": language}
result := decode[struct {
Notes []struct {
Path string
Warnings []string
}
}](t, f.do(f.admin, "GET", "/api/v1/metadata", "", h))
found := false
for _, note := range result.Notes {
if note.Path == "bad.md" {
found = true
if len(note.Warnings) != 1 || note.Warnings[0] != i18n.Text(i18n.Locale(language), "metadata_tags") {
t.Fatal(note.Warnings)
}
}
}
if !found {
t.Fatal("note missing")
}
// Metadata warnings still block destructive cleanup, in the requested language.
res := f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/used.png"]}`, h)
body := localizedResponse(t, res)
if res.StatusCode != 400 || !strings.Contains(body.Message, i18n.Text(i18n.Locale(language), "metadata_tags")) {
t.Fatal(body)
}
}
got, _, err := f.server.vault.Read("bad.md")
if err != nil || string(got) != content {
t.Fatal("localization changed note contents")
}
if _, err := f.server.vault.Delete("bad.md"); err != nil {
t.Fatal(err)
}
for _, language := range []string{"en", "zh-CN"} {
result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/used.png"]}`, map[string]string{"Accept-Language": language}))
if len(result.Skipped) != 1 || result.Skipped[0].Reason != i18n.Text(i18n.Locale(language), "image_in_use") {
t.Fatal(result)
}
}
}
func TestLocalizedMetadataSkipsDeletedOrMovedWarningNotes(t *testing.T) {
for _, operation := range []string{"delete", "move"} {
t.Run(operation, func(t *testing.T) {
f := newFixture(t)
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
f.write("good.md", "Good\n")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
var err error
if operation == "delete" {
_, err = f.server.vault.Delete("bad.md")
} else {
_, err = f.server.vault.Move("bad.md", "moved.md")
}
if err != nil {
t.Fatal(err)
}
for _, language := range []string{"en", "zh-CN"} {
res := f.do(f.admin, "GET", "/api/v1/metadata", "", map[string]string{"Accept-Language": language})
if res.StatusCode != http.StatusOK {
t.Fatalf("stale warning note invalidated metadata: HTTP %d", res.StatusCode)
}
result := decode[struct{ Notes []search.NoteMetadata }](t, res)
if len(result.Notes) != 1 || result.Notes[0].Path != "good.md" {
t.Fatalf("unexpected surviving metadata: %+v", result.Notes)
}
}
})
}
}
func TestLocalizedMetadataKeepsReanalyzedNoteConsistent(t *testing.T) {
f := newFixture(t)
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
const latest = "---\ntitle: Updated title\ntags: [new]\n---\n![](./assets/live.png)\n[Note](./other.md)\n"
f.write("bad.md", latest)
for _, language := range []string{"en", "zh-CN"} {
result := decode[struct{ Notes []search.NoteMetadata }](t, f.do(f.admin, "GET", "/api/v1/metadata", "", map[string]string{"Accept-Language": language}))
if len(result.Notes) != 1 {
t.Fatal(result)
}
note := result.Notes[0]
if note.Rev != vault.Rev([]byte(latest)) || !reflect.DeepEqual(note.Metadata, vault.Analyze("bad.md", []byte(latest))) {
t.Fatalf("metadata mixes file revisions: %+v", note)
}
}
}
func TestLocalizedMetadataPreservesReadErrors(t *testing.T) {
f := newFixture(t)
f.write("bad.md", "---\ntags: 1\n---\nOld\n")
if err := f.server.index.Rebuild(context.Background()); err != nil {
t.Fatal(err)
}
file := filepath.Join(f.root, "bad.md")
if err := os.Remove(file); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(file, 0o755); err != nil {
t.Fatal(err)
}
if res := f.do(f.admin, "GET", "/api/v1/metadata", "", nil); res.StatusCode != http.StatusInternalServerError {
t.Fatalf("read failure was hidden: HTTP %d", res.StatusCode)
}
}
+43 -31
View File
@@ -5,12 +5,12 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"rawmark/internal/i18n"
"runtime"
"strings"
"syscall"
@@ -26,6 +26,7 @@ type maintenanceStatus struct {
Error string `json:"error"`
FinishedAt int64 `json:"finishedAt"`
Backup string `json:"backup,omitempty"`
cause error
}
// gate 排空普通 API 操作后再执行维护;WebSocket 不持有长期读锁。
@@ -36,7 +37,7 @@ func (s *Server) guardAPI(next http.Handler) http.Handler {
return
}
if s.maintenanceBusy.Load() && r.URL.Path != "/api/v1/maintenance/status" {
writeError(w, 503, "正在维护,请稍后重试")
writeError(w, 503, message(w, "maintenance_is_in_progress_please_64ef9d"))
return
}
resourcePath, resource := strings.CutPrefix(r.URL.Path, "/api/v1/resources/")
@@ -47,7 +48,7 @@ func (s *Server) guardAPI(next http.Handler) http.Handler {
return
}
if !s.activity.TryRLock() {
writeError(w, 503, "正在维护,请稍后重试")
writeError(w, 503, message(w, "maintenance_is_in_progress_please_64ef9d"))
return
}
defer s.activity.RUnlock()
@@ -55,8 +56,8 @@ func (s *Server) guardAPI(next http.Handler) http.Handler {
})
}
var errMaintenanceBusy = errors.New("其他维护操作正在执行")
var errMaintenanceInput = errors.New("维护请求无效")
var errMaintenanceBusy = i18n.New("another_maintenance_operation_is_in_da1aa4")
var errMaintenanceInput = i18n.New("invalid_maintenance_request_ef9c16")
func (s *Server) maintain(operation string, fn func() error) error {
if !s.maintenanceMu.TryLock() {
@@ -75,6 +76,7 @@ func (s *Server) maintain(operation string, fn func() error) error {
s.maintenanceState.FinishedAt = time.Now().UnixMilli()
if err != nil {
s.maintenanceState.Error = err.Error()
s.maintenanceState.cause = err
}
s.maintenanceStatusMu.Unlock()
if !s.restarting.Load() {
@@ -85,19 +87,26 @@ func (s *Server) maintain(operation string, fn func() error) error {
func (s *Server) maintenanceInfo(w http.ResponseWriter, r *http.Request) {
s.maintenanceStatusMu.Lock()
defer s.maintenanceStatusMu.Unlock()
writeJSON(w, 200, s.maintenanceState)
state := s.maintenanceState
if state.Operation != "" {
state.Operation = message(w, state.Operation)
}
if state.cause != nil {
state.Error = i18n.RenderError(state.cause, responseLocale(w))
}
writeJSON(w, 200, state)
}
func (s *Server) maintenanceError(w http.ResponseWriter, r *http.Request, err error) {
var pathError *os.PathError
switch {
case errors.Is(err, errMaintenanceBusy):
writeError(w, 503, err.Error())
writeError(w, 503, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, syscall.ENOSPC):
writeError(w, 503, "磁盘空间不足")
writeError(w, 503, message(w, "not_enough_disk_space_a53a70"))
case errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR):
internalError(w, r, err)
case errors.Is(err, errMaintenanceInput), errors.Is(err, backup.ErrInvalidArchive):
writeError(w, 400, "操作失败:"+err.Error())
writeError(w, 400, message(w, "operation_failed", i18n.RenderError(err, responseLocale(w))))
default:
internalError(w, r, err)
}
@@ -200,7 +209,7 @@ func sendDownload(w http.ResponseWriter, r *http.Request, file, name string) err
func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) {
kind := r.PathValue("kind")
if kind != "full" && kind != "settings" {
writeError(w, 400, "备份类型无效")
writeError(w, 400, message(w, "invalid_backup_type_f5ff70"))
return
}
temp, err := s.tempDir()
@@ -217,7 +226,7 @@ func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) {
file.Close()
os.Remove(name)
defer os.Remove(name)
err = s.maintain("导出备份", func() error {
err = s.maintain("export_backup_e9d3dd", func() error {
if err := s.createArchive(r.Context(), kind, name); err != nil {
return err
}
@@ -228,7 +237,7 @@ func (s *Server) exportBackup(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) {
err := s.maintain("导出笔记数据", func() error {
err := s.maintain("export_note_data_45a4e1", func() error {
dataRoot, err := filepath.EvalSymlinks(s.DataDir)
if err != nil {
return err
@@ -253,39 +262,42 @@ func (s *Server) exportNotes(w http.ResponseWriter, r *http.Request) {
func validatePreferences(values map[string]json.RawMessage) error {
for key, raw := range values {
if len(raw) > maxJSONBytes {
return fmt.Errorf("%w:单项设置超过 1 MiB", errMaintenanceInput)
return i18n.New("w_a_single_setting_exceeds_c7dccb", errMaintenanceInput)
}
switch {
case key == settingsKey:
var settings map[string]json.RawMessage
if err := json.Unmarshal(raw, &settings); err != nil || settings == nil {
return fmt.Errorf("%w:偏好设置必须为对象", errMaintenanceInput)
return i18n.New("w_preferences_must_be_an_8d2724", errMaintenanceInput)
}
if err := validateLocale(settings); err != nil {
return errors.Join(errMaintenanceInput, err)
}
if _, err := trashRetentionDays(settings); err != nil {
return fmt.Errorf("%w:%w", errMaintenanceInput, err)
return i18n.New("w_w_f0fd7d", errMaintenanceInput, err)
}
if value, ok := settings["shortcuts"]; ok {
var shortcuts map[string][]string
if err := json.Unmarshal(value, &shortcuts); err != nil {
return fmt.Errorf("%w:快捷键格式无效", errMaintenanceInput)
return i18n.New("w_invalid_shortcut_format_0acab4", errMaintenanceInput)
}
}
case key == pluginEnabledKey:
var ids map[string]bool
if err := json.Unmarshal(raw, &ids); err != nil {
return fmt.Errorf("%w:%w", errMaintenanceInput, err)
return i18n.New("w_w_f0fd7d", errMaintenanceInput, err)
}
for id := range ids {
if !validPluginID(id) {
return fmt.Errorf("%w:插件 ID 无效", errMaintenanceInput)
return i18n.New("w_invalid_plugin_id_7f3512", errMaintenanceInput)
}
}
case strings.HasPrefix(key, "plugin.data."):
if !validPluginID(strings.TrimPrefix(key, "plugin.data.")) || !json.Valid(raw) {
return fmt.Errorf("%w:插件数据无效", errMaintenanceInput)
return i18n.New("w_invalid_plugin_data_1f5fe3", errMaintenanceInput)
}
default:
return fmt.Errorf("%w:设置包包含不允许的设置项", errMaintenanceInput)
return i18n.New("w_the_settings_package_contains_e70b59", errMaintenanceInput)
}
}
return nil
@@ -296,7 +308,7 @@ func readPreferences(stage string) (map[string]json.RawMessage, error) {
return nil, err
}
if info.Size() > 32<<20 {
return nil, fmt.Errorf("%w:设置包设置内容超过 32 MiB", errMaintenanceInput)
return nil, i18n.New("w_settings_package_content_exceeds_baf8eb", errMaintenanceInput)
}
raw, err := os.ReadFile(filepath.Join(stage, "settings.json"))
if err != nil {
@@ -304,7 +316,7 @@ func readPreferences(stage string) (map[string]json.RawMessage, error) {
}
var values map[string]json.RawMessage
if err = json.Unmarshal(raw, &values); err != nil || values == nil {
return nil, fmt.Errorf("%w:设置包格式无效", errMaintenanceInput)
return nil, i18n.New("w_invalid_settings_package_format_2e7bee", errMaintenanceInput)
}
if err = validatePreferences(values); err != nil {
return nil, err
@@ -322,16 +334,16 @@ func (s *Server) applySettings(w http.ResponseWriter, r *http.Request) {
return
}
if !body.Confirmed {
writeError(w, 400, "请先确认导入设置")
writeError(w, 400, message(w, "confirm_settings_import_first_e89aa3"))
return
}
err := s.maintain("导入设置", func() error {
err := s.maintain("import_settings_1a80d4", func() error {
meta, dir, err := s.readUpload(r, body.ID)
if err != nil {
return err
}
if !meta.Validated || meta.Kind != "settings" {
return fmt.Errorf("%w:请先校验设置包", errMaintenanceInput)
return i18n.New("w_validate_the_settings_package_0f4f56", errMaintenanceInput)
}
stage := filepath.Join(dir, "stage")
values, err := readPreferences(stage)
@@ -436,10 +448,10 @@ func (s *Server) resetSettings(w http.ResponseWriter, r *http.Request) {
return
}
if !body.Confirmed {
writeError(w, 400, "请先确认重置")
writeError(w, 400, message(w, "confirm_reset_first_22ae3d"))
return
}
err := s.maintain("重置设置", func() error {
err := s.maintain("reset_settings_9a70ef", func() error {
if _, err := s.saveRollback(r.Context(), "settings"); err != nil {
return err
}
@@ -458,10 +470,10 @@ func (s *Server) resetSettings(w http.ResponseWriter, r *http.Request) {
func (s *Server) maintainIndex(w http.ResponseWriter, r *http.Request) {
operation := r.PathValue("operation")
if operation != "optimize" && operation != "rebuild" {
writeError(w, 400, "索引操作无效")
writeError(w, 400, message(w, "invalid_index_operation_be898b"))
return
}
err := s.maintain("索引维护", func() error {
err := s.maintain("index_maintenance_676706", func() error {
if operation == "rebuild" {
return s.index.Rebuild(r.Context())
}
@@ -476,10 +488,10 @@ func (s *Server) maintainIndex(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) exportLogs(w http.ResponseWriter, r *http.Request) {
if s.Logs == nil {
writeError(w, 503, "当前服务尚未启用日志文件")
writeError(w, 503, message(w, "log_files_are_not_enabled_dcf4df"))
return
}
err := s.maintain("导出系统日志", func() error {
err := s.maintain("export_system_logs_d05855", func() error {
temp, err := s.tempDir()
if err != nil {
return err
+2 -2
View File
@@ -307,7 +307,7 @@ func TestMaintenanceErrorsPreserveHTTPStatus(t *testing.T) {
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if res.StatusCode != 400 || body.Code != 40000 || !strings.Contains(body.Message, "备份内容无效") {
if res.StatusCode != 400 || body.Code != 40000 || !strings.Contains(body.Message, "Invalid backup contents") {
t.Fatalf("无效备份响应错误: %d %+v", res.StatusCode, body)
}
})
@@ -348,7 +348,7 @@ func TestMaintenanceErrorsPreserveHTTPStatus(t *testing.T) {
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if res.StatusCode != 500 || body.Code != 50000 || body.Message != "服务器内部错误" {
if res.StatusCode != 500 || body.Code != 50000 || body.Message != "Internal server error" {
t.Fatalf("磁盘故障被当作输入错误: %d %+v", res.StatusCode, body)
}
})
+29 -29
View File
@@ -5,11 +5,11 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"rawmark/internal/i18n"
"regexp"
"strconv"
"strings"
@@ -39,12 +39,12 @@ func uploadOwner(r *http.Request) string {
func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, string, error) {
var meta maintenanceUpload
if !uploadIDPattern.MatchString(id) {
return meta, "", fmt.Errorf("%w:上传 ID 无效", errMaintenanceInput)
return meta, "", i18n.New("w_invalid_upload_id_23d358", errMaintenanceInput)
}
dir := filepath.Join(s.DataDir, ".maintenance", "uploads", id)
data, err := os.ReadFile(filepath.Join(dir, "upload.json"))
if errors.Is(err, os.ErrNotExist) {
return meta, "", fmt.Errorf("%w:上传不存在或已过期", errMaintenanceInput)
return meta, "", i18n.New("w_upload_does_not_exist_918d8d", errMaintenanceInput)
}
if err != nil {
return meta, "", err
@@ -53,7 +53,7 @@ func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, stri
return meta, "", err
}
if meta.Owner != uploadOwner(r) {
return meta, "", fmt.Errorf("%w:上传不属于当前会话", errMaintenanceInput)
return meta, "", i18n.New("w_upload_does_not_belong_c4d06d", errMaintenanceInput)
}
return meta, dir, nil
}
@@ -70,7 +70,7 @@ func enoughSpace(dir string, size int64) error {
return err
}
if size < 0 || uint64(size)+(32<<20) > stat.Bavail*uint64(stat.Bsize) {
return fmt.Errorf("磁盘空间不足:%w", syscall.ENOSPC)
return i18n.New("not_enough_disk_space_w_a39b20", syscall.ENOSPC)
}
return nil
}
@@ -83,11 +83,11 @@ func (s *Server) startMaintenanceUpload(w http.ResponseWriter, r *http.Request)
return
}
if (body.Kind != "full" && body.Kind != "settings") || body.Size <= 0 || body.Size > backup.MaxArchiveBytes {
writeError(w, 400, "请选择有效备份,压缩大小上限 2 GiB")
writeError(w, 400, message(w, "choose_a_valid_backup_with_51ba41"))
return
}
var id string
err := s.maintain("准备上传", func() error {
err := s.maintain("prepare_upload_ed0bd9", func() error {
root := filepath.Join(s.DataDir, ".maintenance", "uploads")
if err := os.MkdirAll(root, 0700); err != nil {
return err
@@ -121,17 +121,17 @@ func randBytes() []byte { bytes := make([]byte, 16); rand.Read(bytes); return by
func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request) {
offset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
if err != nil || offset < 0 {
writeError(w, 400, "上传偏移无效")
writeError(w, 400, message(w, "invalid_upload_offset_3bdbf1"))
return
}
var next int64
err = s.maintain("上传备份", func() error {
err = s.maintain("upload_backup_83b1a9", func() error {
meta, dir, err := s.readUpload(r, r.PathValue("id"))
if err != nil {
return err
}
if meta.Validated {
return fmt.Errorf("%w:已校验的上传不可修改", errMaintenanceInput)
return i18n.New("w_a_validated_upload_cannot_ce0e63", errMaintenanceInput)
}
f, err := os.OpenFile(filepath.Join(dir, "archive.zip"), os.O_WRONLY, 0600)
if err != nil {
@@ -143,22 +143,22 @@ func (s *Server) appendMaintenanceUpload(w http.ResponseWriter, r *http.Request)
return err
}
if info.Size() != offset {
return fmt.Errorf("%w:上传偏移不匹配,请重新上传", errMaintenanceInput)
return i18n.New("w_upload_offset_does_not_95e21e", errMaintenanceInput)
}
limit := min(maintenanceChunkBytes, meta.Size-offset)
if limit <= 0 {
return fmt.Errorf("%w:上传大小超出清单", errMaintenanceInput)
return i18n.New("w_upload_size_exceeds_the_0bb329", errMaintenanceInput)
}
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return fmt.Errorf("%w:%w", errMaintenanceInput, err)
return i18n.New("w_w_f0fd7d", errMaintenanceInput, err)
}
return err
}
if len(data) == 0 {
return fmt.Errorf("%w:上传内容为空", errMaintenanceInput)
return i18n.New("w_upload_content_is_empty_7c0bb2", errMaintenanceInput)
}
if err = enoughSpace(dir, int64(len(data))); err != nil {
return err
@@ -185,7 +185,7 @@ func backupResourceError(err error) error {
if errors.As(err, &pathError) && !errors.Is(pathError, os.ErrNotExist) && !errors.Is(pathError, syscall.ENOTDIR) && !errors.Is(pathError, syscall.EISDIR) {
return err
}
return fmt.Errorf("%w:%w", backup.ErrInvalidArchive, err)
return i18n.New("w_w_f0fd7d", backup.ErrInvalidArchive, err)
}
func (s *Server) validateResources(stage string) error {
@@ -200,7 +200,7 @@ func (s *Server) validateResources(stage string) error {
}
for _, entry := range entries {
if !entry.IsDir() {
return fmt.Errorf("%w:资源目录内包含非目录条目", errMaintenanceInput)
return i18n.New("w_the_resource_directory_contains_3898a5", errMaintenanceInput)
}
switch kind {
case "fonts":
@@ -227,7 +227,7 @@ func (s *Server) validateResources(stage string) error {
func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Request) {
var manifest backup.Manifest
var bytes int64
err := s.maintain("校验备份", func() error {
err := s.maintain("validate_backup_09df97", func() error {
meta, dir, err := s.readUpload(r, r.PathValue("id"))
if err != nil {
return err
@@ -237,7 +237,7 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
return err
}
if info.Size() != meta.Size {
return fmt.Errorf("%w:上传尚未完成", errMaintenanceInput)
return i18n.New("w_upload_is_not_complete_8ef575", errMaintenanceInput)
}
meta.Validated = false
if err = saveUpload(dir, meta); err != nil {
@@ -255,12 +255,12 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
return err
}
if manifest.Kind != meta.Kind {
return fmt.Errorf("%w:备份类型与所选操作不符", errMaintenanceInput)
return i18n.New("w_backup_type_does_not_8e5e8c", errMaintenanceInput)
}
for _, entry := range manifest.Entries {
bytes += entry.Size
if strings.HasPrefix(entry.Path, "vault/") && backup.VaultExcluded(s.DataDir, s.vault.Root(), strings.TrimPrefix(entry.Path, "vault/")) {
return fmt.Errorf("%w:笔记库内容与应用目录冲突", errMaintenanceInput)
return i18n.New("w_vault_content_conflicts_with_6e3d24", errMaintenanceInput)
}
}
if err = enoughSpace(dir, bytes*2); err != nil {
@@ -284,7 +284,7 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
} else {
var databaseError *sqlite.Error
if !errors.As(err, &databaseError) {
err = fmt.Errorf("%w:%w", backup.ErrInvalidArchive, err)
err = i18n.New("w_w_f0fd7d", backup.ErrInvalidArchive, err)
}
}
err = errors.Join(err, st.Close())
@@ -304,7 +304,7 @@ func (s *Server) validateMaintenanceUpload(w http.ResponseWriter, r *http.Reques
writeJSON(w, 200, map[string]any{"kind": manifest.Kind, "createdAt": manifest.CreatedAt, "entries": len(manifest.Entries), "size": bytes})
}
func (s *Server) deleteMaintenanceUpload(w http.ResponseWriter, r *http.Request) {
err := s.maintain("取消上传", func() error {
err := s.maintain("cancel_upload_a24836", func() error {
_, dir, err := s.readUpload(r, r.PathValue("id"))
if err != nil {
return err
@@ -326,20 +326,20 @@ func (s *Server) restoreInstance(w http.ResponseWriter, r *http.Request) {
return
}
if !body.Confirmed {
writeError(w, 400, "请先确认替换实例数据并重新登录")
writeError(w, 400, message(w, "confirm_replacing_instance_data_and_52f3ca"))
return
}
if s.Restart == nil {
writeError(w, 503, "当前服务不支持实例恢复")
writeError(w, 503, message(w, "this_service_does_not_support_a9a8ba"))
return
}
err := s.maintain("恢复完整实例", func() error {
err := s.maintain("restore_full_instance_9b4ee0", func() error {
meta, dir, err := s.readUpload(r, body.ID)
if err != nil {
return err
}
if !meta.Validated || meta.Kind != "full" {
return fmt.Errorf("%w:请先校验完整服务备份", errMaintenanceInput)
return i18n.New("w_validate_the_full_instance_6d329d", errMaintenanceInput)
}
file, err := s.saveRollback(r.Context(), "full")
if err != nil {
@@ -367,7 +367,7 @@ type cleanupResult struct {
func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
result := cleanupResult{Failed: []string{}}
err := s.maintain("清理临时文件", func() error {
err := s.maintain("clean_temporary_files_af91ae", func() error {
// temp 为进程专属目录,普通 API 与维护已排空;恢复包和回退备份在其他目录。
temp, err := s.tempDir()
if err != nil {
@@ -435,7 +435,7 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
e = os.RemoveAll(target)
}
if e != nil {
result.Failed = append(result.Failed, e.Error())
result.Failed = append(result.Failed, i18n.RenderError(e, responseLocale(w)))
} else {
result.Files += count
result.Bytes += size
@@ -454,5 +454,5 @@ func (s *Server) clearTemp(w http.ResponseWriter, r *http.Request) {
func (s *Server) SetRestoreFailure(err error) {
s.maintenanceStatusMu.Lock()
defer s.maintenanceStatusMu.Unlock()
s.maintenanceState = maintenanceStatus{Operation: "恢复完整实例", Error: err.Error(), FinishedAt: time.Now().UnixMilli()}
s.maintenanceState = maintenanceStatus{Operation: "restore_full_instance_9b4ee0", Error: err.Error(), cause: err, FinishedAt: time.Now().UnixMilli()}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "RawMark API",
"version": "1",
"description": "RawMark单用户API。普通JSON统一{code,data,message},code=0成功;保留HTTP语义。登录会话固定30天;API令牌保留read/write权限。受保护API只接受Bearer,不接受Cookie。文件、下载、事件、204与OpenAPI文档不包装。"
"description": "RawMark单用户API。普通JSON统一{code,data,message},code=0成功;保留HTTP语义。登录会话固定30天;API令牌保留read/write权限。受保护API只接受Bearer,不接受Cookie。文件、下载、事件、204与OpenAPI文档不包装。 响应提示支持Accept-Language: en或zh-CN(按q权重选择,缺省英文),Content-Language返回实际语言;状态码与业务码不变。/settings的locale为账户语言,支持en、zh-CN,null删除覆盖。"
},
"servers": [
{
+24 -24
View File
@@ -7,7 +7,6 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"mime"
@@ -15,6 +14,7 @@ import (
"os"
"path"
"path/filepath"
"rawmark/internal/i18n"
"regexp"
"strings"
@@ -48,7 +48,7 @@ func validPluginID(id string) bool {
func parsePluginManifest(data []byte) (pluginManifest, error) {
var m pluginManifest
if len(data) > 16<<10 || json.Unmarshal(data, &m) != nil || !validPluginID(m.ID) || strings.TrimSpace(m.Name) == "" || strings.TrimSpace(m.Version) == "" || m.APIVersion != 1 {
return m, fmt.Errorf("插件 manifest 无效,必须包含合法的 id、name、version 和 apiVersion: 1")
return m, i18n.New("invalid_plugin_manifest_a_valid_121d94")
}
return m, nil
}
@@ -82,14 +82,14 @@ func (s *Server) openPlugin(id string) (*os.Root, pluginInfo, error) {
return nil, info, err
}
if m.ID != id {
return nil, info, fmt.Errorf("插件目录与 manifest id 不一致")
return nil, info, i18n.New("plugin_directory_does_not_match_0eda90")
}
rev, err := dir.ReadFile(".rawmark-revision")
if err != nil {
return nil, info, err
}
if !pluginRevisionPattern.Match(rev) {
return nil, info, fmt.Errorf("插件安装信息无效")
return nil, info, i18n.New("invalid_plugin_installation_information_3e7873")
}
info.pluginManifest, info.Revision = m, string(rev)
ok = true
@@ -106,7 +106,7 @@ func (s *Server) enabledPlugins() (map[string]bool, error) {
err = json.Unmarshal([]byte(value), &enabled)
}
if enabled == nil && err == nil {
err = fmt.Errorf("插件启用配置无效")
err = i18n.New("invalid_plugin_enabled_configuration_26d4bb")
}
return enabled, err
}
@@ -130,7 +130,7 @@ func (s *Server) setPluginEnabled(id string, enabled bool) error {
func (s *Server) pluginError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, fs.ErrNotExist) {
writeError(w, 404, "插件不存在或安装不完整")
writeError(w, 404, message(w, "plugin_not_found_or_installation_f577c6"))
} else {
internalError(w, r, err)
}
@@ -172,28 +172,28 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
var info pluginInfo
archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return info, fmt.Errorf("无法读取插件 ZIP")
return info, i18n.New("could_not_read_plugin_zip_922fd9")
}
if len(archive.File) > 128 {
return info, fmt.Errorf("插件最多包含 128 个文件或目录")
return info, i18n.New("a_plugin_may_contain_at_41c105")
}
seen := map[string]bool{}
var total int64
for _, file := range archive.File {
name := strings.TrimSuffix(file.Name, "/")
if !fs.ValidPath(name) || strings.ContainsAny(name, "\\:\x00") || strings.HasPrefix(name, ".") || name != path.Clean(name) || seen[name] || (!file.FileInfo().IsDir() && !file.Mode().IsRegular()) {
return info, fmt.Errorf("插件包含非法路径、重复路径或特殊文件")
return info, i18n.New("plugin_contains_invalid_paths_duplicate_6a1e05")
}
seen[name] = true
if file.FileInfo().IsDir() {
continue
}
if file.UncompressedSize64 > maxPluginExpanded || total+int64(file.UncompressedSize64) > maxPluginExpanded {
return info, fmt.Errorf("插件解压后超过 20 MiB")
return info, i18n.New("expanded_plugin_exceeds_mib_e06d7b")
}
src, err := file.Open()
if err != nil {
return info, fmt.Errorf("无法读取插件文件:%w", err)
return info, i18n.New("could_not_read_plugin_file_016a17", err)
}
content, readErr := io.ReadAll(io.LimitReader(src, maxPluginExpanded-total+1))
closeErr := src.Close()
@@ -205,7 +205,7 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
}
total += int64(len(content))
if total > maxPluginExpanded {
return info, fmt.Errorf("插件解压后超过 20 MiB")
return info, i18n.New("expanded_plugin_exceeds_mib_e06d7b")
}
if name == "manifest.json" {
info.pluginManifest, err = parsePluginManifest(content)
@@ -222,11 +222,11 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
}
}
if info.ID == "" || !seen["main.js"] {
return info, fmt.Errorf("ZIP 根目录必须包含 manifest.json 和 main.js")
return info, i18n.New("the_zip_root_must_contain_ee8588")
}
main, err := os.Stat(filepath.Join(destination, "main.js"))
if err != nil || !main.Mode().IsRegular() {
return info, fmt.Errorf("main.js 必须为普通文件")
return info, i18n.New("main_js_must_be_a_e84ee8")
}
hash := sha256.Sum256(data)
info.Revision = hex.EncodeToString(hash[:])
@@ -236,18 +236,18 @@ func unpackPlugin(data []byte, destination string) (pluginInfo, error) {
func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) {
if s.PluginsDir == "" {
writeError(w, 503, "插件目录未配置")
writeError(w, 503, message(w, "plugin_directory_is_not_configured_5e0928"))
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxPluginZip+(64<<10))
if err := r.ParseMultipartForm(maxPluginZip); err != nil {
writeError(w, 400, "无法读取插件上传,请使用不超过 10 MiB 的 ZIP")
writeError(w, 400, message(w, "could_not_read_plugin_upload_5fd8bc"))
return
}
defer r.MultipartForm.RemoveAll()
file, _, err := r.FormFile("file")
if err != nil {
writeError(w, 400, "请选择插件 ZIP 文件")
writeError(w, 400, message(w, "choose_a_plugin_zip_file_7360c5"))
return
}
defer file.Close()
@@ -257,7 +257,7 @@ func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) {
return
}
if len(data) > maxPluginZip {
writeError(w, 413, "插件 ZIP 超过 10 MiB")
writeError(w, 413, message(w, "plugin_zip_exceeds_mib_046e37"))
return
}
s.pluginsMu.Lock()
@@ -274,12 +274,12 @@ func (s *Server) installPlugin(w http.ResponseWriter, r *http.Request) {
defer os.RemoveAll(stage)
info, err := unpackPlugin(data, stage)
if err != nil {
writeError(w, 400, err.Error())
writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
return
}
target := filepath.Join(s.PluginsDir, info.ID)
if _, err := os.Lstat(target); err == nil {
writeError(w, 409, "同名插件已安装,请先卸载")
writeError(w, 409, message(w, "a_plugin_with_this_id_1551cb"))
return
} else if !errors.Is(err, fs.ErrNotExist) {
internalError(w, r, err)
@@ -305,7 +305,7 @@ func (s *Server) togglePlugin(w http.ResponseWriter, r *http.Request) {
return
}
if body.Enabled == nil {
writeError(w, 400, "必须指定 enabled")
writeError(w, 400, message(w, "enabled_must_be_specified_12873c"))
return
}
s.pluginsMu.Lock()
@@ -358,7 +358,7 @@ func (s *Server) pluginAsset(w http.ResponseWriter, r *http.Request) {
defer dir.Close()
name := r.PathValue("path")
if r.PathValue("revision") != info.Revision || !fs.ValidPath(name) || strings.ContainsAny(name, "\\:") || strings.HasPrefix(name, ".") {
writeError(w, 404, "插件资源不存在")
writeError(w, 404, message(w, "plugin_resource_not_found_8e73f3"))
return
}
file, err := dir.Open(name)
@@ -373,7 +373,7 @@ func (s *Server) pluginAsset(w http.ResponseWriter, r *http.Request) {
return
}
if !stat.Mode().IsRegular() {
writeError(w, 404, "插件资源不存在")
writeError(w, 404, message(w, "plugin_resource_not_found_8e73f3"))
return
}
w.Header().Set("X-Content-Type-Options", "nosniff")
@@ -420,7 +420,7 @@ func (s *Server) putPluginData(w http.ResponseWriter, r *http.Request) {
return
}
if len(body.Data) == 0 {
writeError(w, 400, "必须提供 data")
writeError(w, 400, message(w, "data_must_be_provided_0dc1bc"))
return
}
s.pluginsMu.Lock()
+1 -1
View File
@@ -21,7 +21,7 @@ type response struct {
}
func writeJSON(w http.ResponseWriter, status int, v any) {
writeResponse(w, status, response{Code: 0, Data: v, Message: "成功"})
writeResponse(w, status, response{Code: 0, Data: v, Message: message(w, "success")})
}
func writeFailure(w http.ResponseWriter, status, code int, message string, data any) {
writeResponse(w, status, response{Code: code, Data: data, Message: message})
+9 -9
View File
@@ -24,7 +24,7 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
switch body.Kind {
case "images", "fonts":
if body.PluginID != "" || body.Revision != "" {
writeError(w, 400, "资源票据范围无效")
writeError(w, 400, message(w, "invalid_resource_ticket_scope_7abece"))
return
}
case "events":
@@ -40,13 +40,13 @@ func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
}
dir.Close()
if info.Revision != body.Revision {
writeError(w, 409, "插件版本已改变,请刷新")
writeError(w, 409, message(w, "plugin_version_changed_refresh_the_9fab75"))
return
}
t.Resource, t.Revision = body.PluginID, body.Revision
suffix = "plugins/" + body.PluginID + "/assets/" + body.Revision + "/"
default:
writeError(w, 400, "资源票据用途无效")
writeError(w, 400, message(w, "invalid_resource_ticket_purpose_e266b8"))
return
}
plain := opaqueToken()
@@ -66,7 +66,7 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
return
}
if !ok {
writeError(w, 401, "资源票据已失效")
writeError(w, 401, message(w, "resource_ticket_has_expired_309b64"))
return
}
parent := principal{SessionHash: t.SessionHash, ExpiresAt: t.ExpiresAt}
@@ -76,7 +76,7 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
return
}
if !valid {
writeError(w, 401, "登录已失效")
writeError(w, 401, message(w, "login_session_has_expired_075903"))
return
}
r = withIdentity(r, parent)
@@ -84,14 +84,14 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
switch {
case kind == "images" && t.Kind == "images":
if !vault.IsImage(name) {
writeError(w, 403, "票据仅允许读取图片")
writeError(w, 403, message(w, "this_ticket_only_permits_reading_f26030"))
return
}
s.raw(w, r)
case kind == "fonts" && t.Kind == "fonts":
id, ok := strings.CutSuffix(name, "/file")
if !ok || strings.Contains(id, "/") {
writeError(w, 403, "字体票据范围不匹配")
writeError(w, 403, message(w, "font_ticket_scope_does_not_95339a"))
return
}
r.SetPathValue("id", id)
@@ -99,7 +99,7 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
case kind == "plugins" && t.Kind == "plugin":
parts := strings.SplitN(name, "/", 4)
if len(parts) != 4 || parts[0] != t.Resource || parts[1] != "assets" || parts[2] != t.Revision {
writeError(w, 403, "插件票据范围不匹配")
writeError(w, 403, message(w, "plugin_ticket_scope_does_not_349049"))
return
}
r.SetPathValue("id", t.Resource)
@@ -109,6 +109,6 @@ func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
case kind == "events" && t.Kind == "events" && name == "":
s.events(w, r)
default:
writeError(w, 403, "资源票据用途不匹配")
writeError(w, 403, message(w, "resource_ticket_purpose_does_not_1f573e"))
}
}
+12 -7
View File
@@ -6,6 +6,7 @@ import (
"errors"
"io"
"net/http"
"rawmark/internal/i18n"
"rawmark/internal/auth"
"rawmark/internal/events"
@@ -38,12 +39,12 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxJSONBytes))
if err != nil {
writeError(w, 413, "设置过大")
writeError(w, 413, message(w, "settings_are_too_large_8a580f"))
return
}
var patch map[string]json.RawMessage
if err := json.Unmarshal(data, &patch); err != nil || patch == nil {
writeError(w, 400, "设置必须是 JSON 对象")
writeError(w, 400, message(w, "settings_must_be_a_json_45b36b"))
return
}
s.settingsMu.Lock()
@@ -60,8 +61,12 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
settings[k] = v
}
}
if err := validateLocale(settings); err != nil {
writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
return
}
if _, err := trashRetentionDays(settings); err != nil {
writeError(w, 400, err.Error())
writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
return
}
merged, err := json.Marshal(settings)
@@ -70,7 +75,7 @@ func (s *Server) patchSettings(w http.ResponseWriter, r *http.Request) {
return
}
if len(merged) > maxJSONBytes {
writeError(w, 413, "设置过大")
writeError(w, 413, message(w, "settings_are_too_large_8a580f"))
return
}
if err := s.store.SetSetting(settingsKey, string(merged)); err != nil {
@@ -89,11 +94,11 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
}
switch err := s.auth.ChangePassword(clientIP(r), identity(r).Token, body.Current, body.Next); {
case errors.Is(err, auth.ErrRateLimited):
writeError(w, 429, err.Error())
writeError(w, 429, i18n.RenderError(err, responseLocale(w)))
case errors.Is(err, auth.ErrBadCredentials):
writeFailure(w, 400, CodeCurrentPassword, "当前密码错误", nil)
writeFailure(w, 400, CodeCurrentPassword, message(w, "current_password_is_incorrect_e0f0aa"), nil)
case errors.Is(err, auth.ErrWeakPassword):
writeError(w, 400, err.Error())
writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
case err != nil:
internalError(w, r, err)
default:
+3 -3
View File
@@ -38,13 +38,13 @@ func newStaticHandler(fsys fs.FS) http.Handler {
func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(w, 405, "不支持的请求方法")
writeError(w, 405, message(w, "unsupported_request_method_bc2f08"))
return
}
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
// 已移除的公开入口不能回退到应用首页。
if name == "s" || strings.HasPrefix(name, "s/") || name == "share.html" {
writeError(w, 404, "页面不存在")
writeError(w, 404, message(w, "page_not_found_e75737"))
return
}
f, err := h.load(name)
@@ -52,7 +52,7 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 前端路由:未知路径一律返回入口页面
name = "index.html"
if f, err = h.load(name); err != nil {
writeError(w, 404, "前端资源缺失")
writeError(w, 404, message(w, "frontend_resources_are_missing_2c9b3e"))
return
}
}
+2 -2
View File
@@ -3,10 +3,10 @@ package api
import (
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"path/filepath"
"rawmark/internal/i18n"
"strings"
"time"
)
@@ -44,7 +44,7 @@ func (s *Server) getStorage(w http.ResponseWriter, r *http.Request) {
func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, error) {
usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}}
if dataDir == "" {
return usage, fmt.Errorf("未配置应用数据目录")
return usage, i18n.New("application_data_directory_is_not_a1f183")
}
dataRoot, err := filepath.EvalSymlinks(dataDir)
if err != nil {
+13 -8
View File
@@ -4,11 +4,11 @@ import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"rawmark/internal/i18n"
"regexp"
)
@@ -54,14 +54,14 @@ func readThemeFile(root *os.Root, name string, limit int64) ([]byte, error) {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("主题文件必须为普通文件")
return nil, i18n.New("theme_file_must_be_a_1fe9df")
}
data, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("主题文件过大")
return nil, i18n.New("theme_file_is_too_large_08667a")
}
return data, nil
}
@@ -89,19 +89,21 @@ func (s *Server) readTheme(id string) (themeManifest, string, error) {
return manifest, "", err
}
if err := json.Unmarshal(data, &manifest); err != nil {
return manifest, "", fmt.Errorf("主题 %s 的 manifest.json 无法解析:%w", id, err)
return manifest, "", i18n.New("could_not_parse_manifest_json_4bef07", id, err)
}
if manifest.ID != id || manifest.Name == "" || manifest.Version == "" {
return manifest, "", fmt.Errorf("主题 %s 的 id、name 或 version 无效", id)
return manifest, "", i18n.New("theme_s_has_an_invalid_8ed29c", id)
}
css, err := readThemeFile(dir, "theme.css", 256<<10)
return manifest, string(css), err
}
func (s *Server) listThemes(w http.ResponseWriter, r *http.Request) {
themes := []themeManifest{{ID: "default", Name: "默认", Version: "1"}}
themes := []themeManifest{{ID: "default", Name: message(w, "theme_default"), Version: "1"}}
for _, theme := range builtinThemes {
themes = append(themes, theme.themeManifest)
manifest := theme.themeManifest
manifest.Name = message(w, "theme_"+theme.ID)
themes = append(themes, manifest)
}
if s.ThemesDir != "" {
entries, err := os.ReadDir(s.ThemesDir)
@@ -130,13 +132,16 @@ func (s *Server) listThemes(w http.ResponseWriter, r *http.Request) {
func (s *Server) getTheme(w http.ResponseWriter, r *http.Request) {
manifest, css, err := s.readTheme(r.PathValue("id"))
if errors.Is(err, fs.ErrNotExist) {
writeError(w, 404, "主题不存在或文件不完整")
writeError(w, 404, message(w, "theme_not_found_or_files_3b0d5b"))
return
}
if err != nil {
internalError(w, r, err)
return
}
if _, _, ok := builtinTheme(manifest.ID); ok {
manifest.Name = message(w, "theme_"+manifest.ID)
}
writeJSON(w, 200, struct {
themeManifest
CSS string `json:"css"`
+4 -3
View File
@@ -4,6 +4,7 @@ import (
_ "embed"
"errors"
"net/http"
"rawmark/internal/i18n"
"strings"
"rawmark/internal/auth"
@@ -26,12 +27,12 @@ func (s *Server) createToken(w http.ResponseWriter, r *http.Request) {
}
name := strings.TrimSpace(body.Name)
if name == "" || len([]rune(name)) > 100 {
writeError(w, 400, "名称不能为空且不超过 100 个字符")
writeError(w, 400, message(w, "name_must_contain_between_and_7c157b"))
return
}
plain, t, err := s.auth.CreateAPIToken(name, body.Scope)
if errors.Is(err, auth.ErrBadScope) {
writeError(w, 400, err.Error())
writeError(w, 400, i18n.RenderError(err, responseLocale(w)))
return
}
if err != nil {
@@ -48,7 +49,7 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) {
return
}
if !ok {
writeError(w, 404, "令牌不存在")
writeError(w, 404, message(w, "token_not_found_da59eb"))
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
+6 -3
View File
@@ -4,9 +4,9 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"rawmark/internal/i18n"
"time"
)
@@ -20,7 +20,7 @@ func trashRetentionDays(settings map[string]json.RawMessage) (int64, error) {
}
var days int64
if err := json.Unmarshal(raw, &days); err != nil || days < 0 || days > maxRetentionDays {
return 0, fmt.Errorf("回收站保留天数必须为非负安全整数,0 表示永不自动删除")
return 0, i18n.New("trash_retention_must_be_a_d6bd8f")
}
return days, nil
}
@@ -31,6 +31,9 @@ func (s *Server) trashClear(w http.ResponseWriter, r *http.Request) {
internalError(w, r, err)
return
}
for i := range result.Failed {
result.Failed[i].Error = i18n.RenderError(result.Failed[i].Cause, responseLocale(w))
}
writeJSON(w, http.StatusOK, result)
}
@@ -53,7 +56,7 @@ func (s *Server) cleanupTrash(now time.Time) error {
return err
}
if len(result.Failed) > 0 {
return fmt.Errorf("回收站清理部分失败: %v", result.Failed)
return i18n.New("some_trash_items_could_not_0a4ca6", result.Failed)
}
if len(result.Deleted) > 0 {
slog.Info("已清理过期回收站条目", "count", len(result.Deleted))