diff --git a/server/auth.go b/server/auth.go index 00368ce..701b3be 100644 --- a/server/auth.go +++ b/server/auth.go @@ -126,11 +126,11 @@ func (s *Server) requireAuth(h http.Handler) http.Handler { } tok := extractToken(r) if tok == "" { - writeErr(w, 401, "未授权") + writeProductError(w, 401, "unauthorized", "未授权", nil) return } if !verifyJWT(tok, s.jwtKey) { - writeErr(w, 401, "token 无效或已过期") + writeProductError(w, 401, "token_expired", "token 无效或已过期", nil) return } h.ServeHTTP(w, r) @@ -148,7 +148,7 @@ func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) { } hash, _, err := pg.GetSetting(authPassKey) if err != nil { - writeErr(w, 503, errDataSourceUnavailable) + writeProductError(w, 503, "data_unavailable", errDataSourceUnavailable, nil) return } writeJSON(w, 200, map[string]any{"initialized": hash != ""}) @@ -162,27 +162,28 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) { } existing, _, err := pg.GetSetting(authPassKey) if err != nil { - writeErr(w, 503, errDataSourceUnavailable) + writeProductError(w, 503, "data_unavailable", errDataSourceUnavailable, nil) return } if existing != "" { - writeErr(w, 403, "密码已设置") + writeProductError(w, 403, "password_already_set", "密码已设置", nil) return } var req struct { Password string `json:"password"` } if err := decode(r, &req); err != nil || req.Password == "" { - writeErr(w, 400, "密码不能为空") + writeProductError(w, 400, "password_required", "密码不能为空", nil) return } if msg := validatePassword(req.Password); msg != "" { - writeErr(w, 400, msg) + code, params := passwordValidationCode(req.Password) + writeProductError(w, 400, code, msg, params) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - writeErr(w, 500, "密码加密失败") + writeProductError(w, 500, "password_hash_failed", "密码加密失败", nil) return } // 用 INSERT ... ON CONFLICT DO NOTHING 而不是 upsert:上面那次 GetSetting 只是 @@ -190,16 +191,16 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) { // 这期间别的请求完全可能先把密码设好,而读检查本身也可能因故障而失效。 inserted, err := pg.InsertSettingIfAbsent(authPassKey, string(hash)) if err != nil { - writeErr(w, 500, "保存失败: "+err.Error()) + writeProductError(w, 500, "save_failed", "保存失败: "+err.Error(), nil) return } if !inserted { - writeErr(w, 403, "密码已设置") + writeProductError(w, 403, "password_already_set", "密码已设置", nil) return } tok, err := signJWT(s.jwtKey) if err != nil { - writeErr(w, 500, "token 生成失败") + writeProductError(w, 500, "token_failed", "token 生成失败", nil) return } writeJSON(w, 200, map[string]any{"token": tok}) @@ -214,7 +215,7 @@ func (s *Server) authChangePassword(w http.ResponseWriter, r *http.Request) { return } if !verifyJWT(extractToken(r), s.jwtKey) { - writeErr(w, 401, "未授权") + writeProductError(w, 401, "unauthorized", "未授权", nil) return } var req struct { @@ -222,37 +223,38 @@ func (s *Server) authChangePassword(w http.ResponseWriter, r *http.Request) { NewPassword string `json:"new_password"` } if err := decode(r, &req); err != nil { - writeErr(w, 400, "请求格式错误") + writeProductError(w, 400, "invalid_request", "请求格式错误", nil) return } if req.NewPassword == "" { - writeErr(w, 400, "新密码不能为空") + writeProductError(w, 400, "new_password_required", "新密码不能为空", nil) return } if msg := validatePassword(req.NewPassword); msg != "" { - writeErr(w, 400, msg) + code, params := passwordValidationCode(req.NewPassword) + writeProductError(w, 400, code, msg, params) return } hash, ok, err := pg.GetSetting(authPassKey) if err != nil { - writeErr(w, 503, errDataSourceUnavailable) + writeProductError(w, 503, "data_unavailable", errDataSourceUnavailable, nil) return } if !ok || hash == "" { - writeErr(w, 403, "密码未初始化,请先设置密码") + writeProductError(w, 403, "password_not_initialized", "密码未初始化,请先设置密码", nil) return } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.OldPassword)); err != nil { - writeErr(w, 401, "当前密码错误") + writeProductError(w, 401, "current_password_incorrect", "当前密码错误", nil) return } newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - writeErr(w, 500, "密码加密失败") + writeProductError(w, 500, "password_hash_failed", "密码加密失败", nil) return } if err := pg.SetSetting(authPassKey, string(newHash)); err != nil { - writeErr(w, 500, "保存失败: "+err.Error()) + writeProductError(w, 500, "save_failed", "保存失败: "+err.Error(), nil) return } writeJSON(w, 200, map[string]any{"ok": true}) @@ -269,30 +271,37 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) { Password string `json:"password"` } if err := decode(r, &req); err != nil { - writeErr(w, 400, "请求格式错误") + writeProductError(w, 400, "invalid_request", "请求格式错误", nil) return } if req.Username != "ARTEX" { - writeErr(w, 401, "用户名或密码错误") + writeProductError(w, 401, "invalid_credentials", "用户名或密码错误", nil) return } hash, ok, err := pg.GetSetting(authPassKey) if err != nil { - writeErr(w, 503, errDataSourceUnavailable) + writeProductError(w, 503, "data_unavailable", errDataSourceUnavailable, nil) return } if !ok || hash == "" { - writeErr(w, 403, "密码未初始化,请先设置密码") + writeProductError(w, 403, "password_not_initialized", "密码未初始化,请先设置密码", nil) return } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil { - writeErr(w, 401, "用户名或密码错误") + writeProductError(w, 401, "invalid_credentials", "用户名或密码错误", nil) return } tok, err := signJWT(s.jwtKey) if err != nil { - writeErr(w, 500, "token 生成失败") + writeProductError(w, 500, "token_failed", "token 生成失败", nil) return } writeJSON(w, 200, map[string]any{"token": tok}) } + +func passwordValidationCode(password string) (string, map[string]any) { + if utf8.RuneCountInString(password) < minPasswordRunes { + return "password_too_short", map[string]any{"min": minPasswordRunes} + } + return "password_too_long", map[string]any{"max": maxPasswordBytes} +} diff --git a/server/basic_auth.go b/server/basic_auth.go index dc9c1c4..1add6c1 100644 --- a/server/basic_auth.go +++ b/server/basic_auth.go @@ -140,13 +140,13 @@ func (s *Server) requireBasicAuth(next http.Handler) http.Handler { if !ok || len(password) > maxPasswordBytes || username != cfg.Username || bcrypt.CompareHashAndPassword([]byte(cfg.PasswordHash), []byte(password)) != nil { w.Header().Set("WWW-Authenticate", `Basic realm="Restricted", charset="UTF-8"`) - writeErr(w, http.StatusUnauthorized, "请先完成 HTTP Basic Auth 验证") + writeProductError(w, http.StatusUnauthorized, "basic_auth_required", "请先完成 HTTP Basic Auth 验证", nil) return } // Browser API requests already use Authorization: Bearer. A separate // HttpOnly cookie keeps the gate satisfied without replacing that header. if err := s.setBasicAuthCookie(w, r, cfg); err != nil { - writeErr(w, 500, "认证会话生成失败") + writeProductError(w, 500, "auth_session_failed", "认证会话生成失败", nil) return } next.ServeHTTP(w, r) @@ -169,7 +169,7 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) { Password string `json:"password"` } if err := decode(r, &req); err != nil { - writeErr(w, 400, "请求格式错误") + writeProductError(w, 400, "invalid_request", "请求格式错误", nil) return } g := &s.basicAuth @@ -179,7 +179,7 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) { cfg, store := g.cfg, g.store g.mu.RUnlock() if store == nil { - writeErr(w, 503, errDataSourceUnavailable) + writeProductError(w, 503, "data_unavailable", errDataSourceUnavailable, nil) return } if req.Enabled != nil { @@ -189,23 +189,24 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) { cfg.Username = strings.TrimSpace(*req.Username) } if (cfg.Enabled || cfg.Username != "") && !validBasicUsername(cfg.Username) { - writeErr(w, 400, "Basic Auth 用户名不能为空、超过 128 字节或包含冒号及控制字符") + writeProductError(w, 400, "basic_auth_username_invalid", "Basic Auth 用户名不能为空、超过 128 字节或包含冒号及控制字符", nil) return } if req.Password != "" { if msg := validatePassword(req.Password); msg != "" { - writeErr(w, 400, msg) + code, params := passwordValidationCode(req.Password) + writeProductError(w, 400, code, msg, params) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - writeErr(w, 500, "密码加密失败") + writeProductError(w, 500, "password_hash_failed", "密码加密失败", nil) return } cfg.PasswordHash = string(hash) } if cfg.Enabled && cfg.PasswordHash == "" { - writeErr(w, 400, "请先设置 Basic Auth 密码") + writeProductError(w, 400, "basic_auth_password_required", "请先设置 Basic Auth 密码", nil) return } // Every save revokes previous gate cookies, including disable/re-enable. @@ -214,7 +215,7 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), basicAuthSaveTimeout) defer cancel() if err != nil || store.SetSettingContext(ctx, basicAuthKey, string(data)) != nil { - writeErr(w, 503, "Basic Auth 配置保存失败,请重试") + writeProductError(w, 503, "basic_auth_save_failed", "Basic Auth 配置保存失败,请重试", nil) return } // Hashing, persistence and response output never hold the request mutex. @@ -226,7 +227,7 @@ func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") // Keep the administrator who just saved the settings in their session. if err := s.setBasicAuthCookie(w, r, cfg); err != nil { - writeErr(w, 500, "认证会话生成失败") + writeProductError(w, 500, "auth_session_failed", "认证会话生成失败", nil) return } writeJSON(w, 200, basicAuthPublicSettings(cfg)) diff --git a/server/chat_mentions.go b/server/chat_mentions.go index 6f237cb..6adf61d 100644 --- a/server/chat_mentions.go +++ b/server/chat_mentions.go @@ -17,10 +17,12 @@ const maxChatMentions = 10 // The visible token survives drafts, uploads, retries and conversation history. // Labels are only for display: the server trusts only the type and numeric ID. -var chatMentionPattern = regexp.MustCompile(`@\[(漏洞|资产|企业|接口|IP|应用|域名|子域名|服务)#([0-9]+)(?: [^\]\r\n]*)?\]`) +var chatMentionPattern = regexp.MustCompile(`@\[(finding|asset|company|endpoint|ip|app|root_domain|subdomain|service|漏洞|资产|企业|接口|IP|应用|域名|子域名|服务)#([0-9]+)(?: [^\]\r\n]*)?\]`) var chatMentionKinds = map[string]string{ "漏洞": "finding", "资产": "asset", "企业": "company", "接口": "endpoint", "IP": "ip", "应用": "app", "域名": "root_domain", "子域名": "subdomain", "服务": "service", + "finding": "finding", "asset": "asset", "company": "company", "endpoint": "endpoint", + "ip": "ip", "app": "app", "root_domain": "root_domain", "subdomain": "subdomain", "service": "service", } type chatMentionRef struct { diff --git a/server/chat_mentions_test.go b/server/chat_mentions_test.go index 616a00e..a7fcee1 100644 --- a/server/chat_mentions_test.go +++ b/server/chat_mentions_test.go @@ -349,3 +349,30 @@ func TestChatMentionConversationReceivesServerDetails(t *testing.T) { t.Fatal("invalid reference persisted a turn") } } + +func TestChatMentionStableAndLegacyTokens(t *testing.T) { + kinds := []string{"finding", "asset", "company", "endpoint", "ip", "app", "root_domain", "subdomain", "service"} + legacy := []string{"漏洞", "资产", "企业", "接口", "IP", "应用", "域名", "子域名", "服务"} + for i, kind := range kinds { + message := fmt.Sprintf("@[%s#%d original name] @[%s#%d 旧名称]", kind, i+1, legacy[i], i+1) + refs, err := parseChatMentions(message) + if err != nil || len(refs) != 1 || refs[0].Kind != kind || refs[0].ID != int64(i+1) { + t.Fatalf("%s: refs=%+v err=%v", kind, refs, err) + } + } + for _, input := range []string{"@[finding#0]", "@[endpoint#999999999999999999999999]"} { + if _, err := parseChatMentions(input); err == nil { + t.Fatalf("accepted invalid token %s", input) + } + } + var many strings.Builder + for i := 1; i <= 11; i++ { + fmt.Fprintf(&many, "@[asset#%d] ", i) + } + if _, err := parseChatMentions(many.String()); err == nil { + t.Fatal("accepted too many stable references") + } + if refs, err := parseChatMentions("@[unknown#1]"); err != nil || len(refs) != 0 { + t.Fatalf("accepted unknown kind: %+v %v", refs, err) + } +} diff --git a/server/egress_probe.go b/server/egress_probe.go index a848d7d..b5f6648 100644 --- a/server/egress_probe.go +++ b/server/egress_probe.go @@ -167,17 +167,19 @@ type egressCheck struct { Path string `json:"path"` // 实际使用的代理地址(空 = 直连) Skipped bool `json:"skipped,omitempty"` SkipReason string `json:"skip_reason,omitempty"` + SkipCode string `json:"skip_code,omitempty"` Result egressProbeResult `json:"result"` } // egressReport 是 POST /api/settings/proxy/test 的返回体。 type egressReport struct { - OK bool `json:"ok"` // 以"② 经全局代理"为准;未配代理时看"① 直连" - Capture bool `json:"capture"` // 当前是否开启流量捕获 - Configured string `json:"configured"` // 表单传上来的 global_proxy - AgentProxy string `json:"agent_proxy"` // ProxyAddr() 的实际返回值 - Checks []egressCheck `json:"checks"` - Warnings []string `json:"warnings,omitempty"` + OK bool `json:"ok"` // 以"② 经全局代理"为准;未配代理时看"① 直连" + Capture bool `json:"capture"` // 当前是否开启流量捕获 + Configured string `json:"configured"` // 表单传上来的 global_proxy + AgentProxy string `json:"agent_proxy"` // ProxyAddr() 的实际返回值 + Checks []egressCheck `json:"checks"` + Warnings []string `json:"warnings,omitempty"` + WarningDetails []productMessage `json:"warning_details,omitempty"` } // probeEgress 不碰数据库、不碰 Server,所以测试里可以直接调、完全离线。 @@ -430,21 +432,25 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { }) } else { skipReason := "输入框为空,未配置代理" + skipCode := "no_proxy" if savedProxy != "" { // 说"未配置代理"会和 W1("Agent 仍使用 xxx")互相否定。 skipReason = "输入框为空;已保存的代理仍在生效" + skipCode = "saved_proxy_active" } rep.Checks = append(rep.Checks, egressCheck{ Key: "global", Label: "② 经全局代理的出口 IP", Skipped: true, SkipReason: skipReason, + SkipCode: skipCode, Result: egressProbeResult{Probes: []egressAttempt{}}, }) } rep.OK = judgeEgress(rep) rep.Warnings = egressWarnings(rep, pending, savedProxy) + rep.WarningDetails = egressWarningDetails(rep, pending, savedProxy) if req.Resume && rep.OK && !pending { // The navbar uses the same probe endpoint for display-only polling but // omits resume. Only an explicit settings-page test can release the @@ -477,18 +483,29 @@ func judgeEgress(rep egressReport) bool { // egressWarnings 生成"看数字容易忽略"的提醒。 func egressWarnings(rep egressReport, pending bool, savedProxy string) []string { var out []string + for _, notice := range egressWarningDetails(rep, pending, savedProxy) { + out = append(out, notice.Message) + } + return out +} + +func egressWarningDetails(rep egressReport, pending bool, savedProxy string) []productMessage { + var out []productMessage + add := func(code string, params map[string]any, message string) { + out = append(out, productMessage{Code: code, Params: params, Message: message}) + } safeSavedProxy := maskSecrets(savedProxy) // W1 agent 走的是已保存配置,不说清会被当成"测通了=配好了"。 if pending { switch { case rep.Configured == "": - out = append(out, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+"。保存后即改为直连。") + add("pending_direct", map[string]any{"proxy": safeSavedProxy}, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+"。保存后即改为直连。") case savedProxy == "": - out = append(out, "此地址尚未保存,Agent 当前仍为直连。"+ + add("pending_proxy", nil, "此地址尚未保存,Agent 当前仍为直连。"+ "上方结果对应输入框中的值,保存后生效。") default: - out = append(out, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+ + add("pending_change", map[string]any{"proxy": safeSavedProxy}, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+ "。上方结果对应输入框中的值,保存后生效。") } } @@ -509,26 +526,26 @@ func egressWarnings(rep egressReport, pending bool, savedProxy string) []string // W2 看已保存值;只在 ② 通过时提示——没通的话"这里通不代表"本身就不成立。 if !rep.Capture && proxyOK && strings.HasPrefix(strings.ToLower(savedProxy), "socks5") { - out = append(out, "当前是「关闭捕获 + socks5」:本测试用的是 Go 客户端(一定遵守代理),"+ + add("socks_without_capture", nil, "当前是「关闭捕获 + socks5」:本测试用的是 Go 客户端(一定遵守代理),"+ "但 agent 的工具不一定——curl 能走,python urllib 会直接报错。"+ "所以这里通不代表工具都能走;要稳定生效请开启流量捕获(由 MITM 亲自拨号)。") } // W3 两行相同 → 代理可能没生效,或对端只是透明转发。 if directIP != "" && proxyIP != "" && directIP == proxyIP { - out = append(out, fmt.Sprintf("直连与代理的出口 IP 相同(%s):可能代理未生效、只是透明转发,"+ + add("same_ip", map[string]any{"ip": directIP}, fmt.Sprintf("直连与代理的出口 IP 相同(%s):可能代理未生效、只是透明转发,"+ "也可能跳板机与本机在同一 NAT 后面(出口本来就相同)。"+ "要区分:在「自定义 IP 探测服务」里填一台内网回显服务,看它看到的源 IP 是否变成了跳板机。", directIP)) } // W4 同样看已保存值,否则会和 W1 自相矛盾。 if rep.Capture && savedProxy == "" { - out = append(out, "已开启流量捕获,但全局代理为空:捕获只负责记录,出口仍是直连。") + add("capture_direct", nil, "已开启流量捕获,但全局代理为空:捕获只负责记录,出口仍是直连。") } // W5 容器里的 127.0.0.1 指容器自己,连不到宿主机上的代理。 if isLoopbackProxy(rep.Configured) { - out = append(out, "代理地址是回环地址(127.0.0.1 / localhost)。"+ + add("loopback_proxy", nil, "代理地址是回环地址(127.0.0.1 / localhost)。"+ "如果 ARTEX 跑在容器里,这里的 127.0.0.1 指容器自身,连不到宿主机上的代理;"+ "请改用宿主机的可达 IP(如 http://192.168.x.x:端口)。") } diff --git a/server/product_errors.go b/server/product_errors.go new file mode 100644 index 0000000..b580729 --- /dev/null +++ b/server/product_errors.go @@ -0,0 +1,19 @@ +package server + +import "net/http" + +// productMessage keeps the original detail alongside a stable translation key. +type productMessage struct { + Code string `json:"code"` + Params map[string]any `json:"params,omitempty"` + Message string `json:"message"` +} + +// Preserve error for existing clients and add stable fields for localized UIs. +func writeProductError(w http.ResponseWriter, status int, code, message string, params map[string]any) { + payload := map[string]any{"error": message, "code": code} + if len(params) > 0 { + payload["params"] = params + } + writeJSON(w, status, payload) +} diff --git a/server/product_errors_test.go b/server/product_errors_test.go new file mode 100644 index 0000000..7f3789b --- /dev/null +++ b/server/product_errors_test.go @@ -0,0 +1,86 @@ +package server + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" +) + +func TestProductErrorPreservesLegacyMessage(t *testing.T) { + w := httptest.NewRecorder() + writeProductError(w, 400, "password_too_short", "密码长度至少 8 位", map[string]any{"min": 8}) + var payload struct { + Error string `json:"error"` + Code string `json:"code"` + Params map[string]int `json:"params"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if w.Code != 400 || payload.Error != "密码长度至少 8 位" || payload.Code != "password_too_short" || payload.Params["min"] != 8 { + t.Fatalf("unexpected localized error: %d %+v", w.Code, payload) + } +} + +func TestPasswordValidationCodes(t *testing.T) { + for _, item := range []struct { + password, code, param string + value int + }{ + {"短密码", "password_too_short", "min", 8}, + {"这是一个超过七十二字节上限的密码这是一个超过七十二字节上限的密码", "password_too_long", "max", 72}, + } { + if validatePassword(item.password) == "" { + t.Fatalf("expected invalid password for %s", item.code) + } + code, params := passwordValidationCode(item.password) + if code != item.code || params[item.param] != item.value { + t.Fatalf("code=%s params=%v", code, params) + } + } +} + +func TestBasicAuthErrorsIncludeTranslationCodes(t *testing.T) { + s, _ := basicAuthTestServer(t) + for _, tc := range []struct{ body, code string }{ + {`not-json`, "invalid_request"}, + {`{"enabled":true}`, "basic_auth_username_invalid"}, + {`{"username":"bad:name"}`, "basic_auth_username_invalid"}, + {`{"username":"entry","password":"short"}`, "password_too_short"}, + {`{"enabled":true,"username":"entry"}`, "basic_auth_password_required"}, + } { + w := saveBasicAuthForTest(s, tc.body) + var payload struct { + Error string `json:"error"` + Code string `json:"code"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if w.Code != 400 || payload.Code != tc.code || payload.Error == "" { + t.Fatalf("body=%s status=%d payload=%+v", tc.body, w.Code, payload) + } + } +} + +func TestEgressWarningDetailsPreserveLegacyTextAndMaskCredentials(t *testing.T) { + rep := egressReport{Configured: "http://new-proxy:8080"} + legacy := egressWarnings(rep, true, "http://user:secret@old-proxy:8080") + details := egressWarningDetails(rep, true, "http://user:secret@old-proxy:8080") + if len(legacy) != len(details) || len(details) != 1 || details[0].Code != "pending_change" { + t.Fatalf("legacy=%v details=%+v", legacy, details) + } + for i, notice := range details { + if notice.Message != legacy[i] { + t.Fatalf("legacy message changed: %+v", notice) + } + data, err := json.Marshal(notice) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "secret") { + t.Fatal("proxy credentials exposed in localized warning") + } + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 217ec58..b2bc4c9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -33,6 +33,7 @@ "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", "geist": "^1.7.2", + "i18next": "^27.1.0", "input-otp": "^1.4.2", "lucide-react": "^1.22.0", "next": "^16.2.9", @@ -42,6 +43,7 @@ "react-day-picker": "^10.0.1", "react-dom": "^19.2.7", "react-hook-form": "^7.80.0", + "react-i18next": "^18.0.1", "react-is": "^19.2.7", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.12.0", @@ -6822,6 +6824,15 @@ "node": ">=16.9.0" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -6890,6 +6901,37 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "27.1.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-27.1.0.tgz", + "integrity": "sha512-xdjb2OXFRm33rClqknpJmmYBvrPcaoEQ7/4kur5uTrbqckqzkbRO0ns6gFFki5TDmHzbIrFYpNBXhaRafmhJ/Q==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -9595,6 +9637,32 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-18.0.1.tgz", + "integrity": "sha512-kPWXI0E/lrPj3+/umcKNzYZVnWmEedLiamMzDIHfbs+5ANf6yzvlZsjKou8f7kaGkfMMDLIZBejV+4aSN1CyBw==", + "license": "MIT", + "dependencies": { + "html-parse-stringify": "^4.0.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": "*", + "i18next": ">= 27.0.1", + "react": ">= 18.0.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", diff --git a/web/package.json b/web/package.json index 9f8c6ef..29b3b7f 100644 --- a/web/package.json +++ b/web/package.json @@ -11,13 +11,12 @@ "format": "biome format --write", "check": "biome check", "check:fix": "biome check --write", + "test:i18n": "node --experimental-strip-types --test src/i18n/i18n.test.cjs src/i18n/screen-pages.test.cjs src/lib/chat-mentions.test.mjs src/lib/api-errors.test.cjs src/lib/api-intelligence.test.cjs src/lib/coverage-graph-lifecycle.test.cjs", "prepare": "husky", "generate:presets": "ts-node -P tsconfig.scripts.json src/scripts/generate-theme-presets.ts" }, "lint-staged": { - "*.{js,ts,jsx,tsx}": [ - "biome check --write --no-errors-on-unmatched" - ] + "*.{js,ts,jsx,tsx}": ["biome check --write --no-errors-on-unmatched"] }, "dependencies": { "@antv/g6": "^5.1.1", @@ -45,6 +44,7 @@ "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", "geist": "^1.7.2", + "i18next": "^27.1.0", "input-otp": "^1.4.2", "lucide-react": "^1.22.0", "next": "^16.2.9", @@ -54,6 +54,7 @@ "react-day-picker": "^10.0.1", "react-dom": "^19.2.7", "react-hook-form": "^7.80.0", + "react-i18next": "^18.0.1", "react-is": "^19.2.7", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.12.0", diff --git a/web/src/app/(auth)/layout.tsx b/web/src/app/(auth)/layout.tsx index 5fbad15..d4d3768 100644 --- a/web/src/app/(auth)/layout.tsx +++ b/web/src/app/(auth)/layout.tsx @@ -1,5 +1,14 @@ import type { ReactNode } from "react"; +import { LanguageSwitcher } from "@/components/language-switcher"; + export default function AuthLayout({ children }: { readonly children: ReactNode }) { - return <>{children}>; + return ( + <> +
欢迎回来,请输入密码以继续使用 ARTEX
+{t("auth.welcome")}
- 版本 v1.0 · 生效日期 2026-09-18 · 请在登录前完整阅读以下全部条款 -
+{t("terms.version")}
- 本《使用须知与免责声明》(以下简称"本声明")是您与 ARTEX - 项目作者及贡献者之间就使用本软件所达成的约定。请您在使用前审慎阅读、充分理解各条款内容,特别是以粗体或色块标注的免责、责任限制及禁止性条款。 - - {" "} - 一旦您下载、安装、访问或以任何方式使用本软件,即视为您已阅读、理解并同意接受本声明的全部约束。 - + {t("terms.introduction")} + {t("terms.acceptance")}
- 本软件(ARTEX)是一款基于 GNU Affero General Public License - v3.0(AGPL-3.0)发布的开源程序。您可依据该协议自由使用、复制、修改和分发本软件;但任何衍生作品(包括通过网络向第三方提供的在线服务)均须同样以 - AGPL-3.0 协议开源并向使用者公开对应的完整源代码。AGPL-3.0 完整条款以随附的 LICENSE 文件为准。 -
+{t("terms.license")}
- 本软件仅供个人学习、代码研究、安全技术原理探讨,以及在您自行搭建的本地隔离环境中进行技术验证之用,适用于学习、学术研究、代码审阅等非攻击性、非破坏性用途。除本条明确许可的情形外,您不得将本软件用于任何其他目的。 -
+{t("terms.scope")}
- 本软件的著作权及相关知识产权归项目作者及贡献者所有,并在 AGPL-3.0 - 协议约定的范围内向您授予相应权利。除该协议明确授予的权利外,本声明未以明示或默示方式授予您任何其他权利。 -
+{t("terms.ip")}
- 本软件为可自行部署的开源程序,作者不运营任何集中式服务、亦不会收集或上传您的使用数据。您在使用过程中产生、处理或接触的一切数据,均由您自行掌控并负责其合法性与安全性;因数据处理不当引发的任何后果由您自行承担。 -
+{t("terms.privacy")}
- 您应自行遵守所在国家或地区关于网络安全、数据安全与个人信息保护、计算机犯罪等方面的全部法律法规(在中国大陆包括但不限于《网络安全法》《数据安全法》《个人信息保护法》及相关司法解释)。 - - {" "} - 因您违反上述法律法规或本声明约定而产生的一切法律责任与后果,均由您本人独立承担,与本软件作者及贡献者无关。 - + {t("terms.compliance")} + {t("terms.responsibility")}
- 本软件按"现状(AS IS)"与"现有(AS - AVAILABLE)"状态提供,不附带任何明示或默示的担保,包括但不限于对适销性、特定用途适用性、准确性及不侵权的担保。在适用法律允许的最大范围内,本软件作者及贡献者不对因使用或无法使用本软件(无论使用方式是否得当)而导致的任何直接、间接、偶然、特殊或后果性损失承担责任,包括但不限于数据丢失、系统损坏、业务中断、利润损失或法律纠纷。 -
+{t("terms.disclaimer")}
- 作者有权根据法律法规或项目发展需要不时更新本声明,更新后的版本将随项目发布并自公布之日起生效;您继续使用本软件即视为接受修订后的条款。在法律允许的范围内,本声明的最终解释权归项目作者所有。若本声明任一条款被认定为无效,不影响其余条款的效力。 -
+{t("terms.changes")}
- {readToEnd ? "您已浏览全部条款" : "请将条款滚动至底部后再确认"} -
+{readToEnd ? t("terms.readAll") : t("terms.scrollToEnd")}
- {unavailable - ? "后端或数据库暂时不可用。为避免覆盖实例上已有的密码,初始化入口已临时关闭——请恢复服务后重试。" - : "首次使用 ARTEX,请为账户设置一个登录密码(至少 8 位)"} + {unavailable ? t("auth.setupUnavailable") : t("auth.setupWelcome")}
{error}
} + {Boolean(error) &&{localizedError(error, t)}
}