feat(i18n): add English and Simplified Chinese
This commit is contained in:
1 parent
38c4c8b781
commit
1e9e0678ee
149 files changed
+5356
-1144
No files matched your search
+49
-24
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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", "")
|
||||
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\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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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})
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user