mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend
Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.
Text is now translated where its reader is:
- Web console APIs return the English source text as message_key with
message_params, and the web console translates it in all seven languages.
message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
the backend (en, zh-CN, zh-TW) in the language saved on the account, then
the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
messages that were Chinese before, word for word. Gateways that use another
new-api as their upstream match that text, for example to disable a channel
whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
params) and rendered in the viewer's language. Task fail reasons and refund
reasons written by the gateway are English keys. Rows written earlier are
shown as before.
- Server logs are English.
Also:
- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
non-English string literal, on a web console message without translations
and on a locale key without a constant.
* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it
Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.
common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.
* docs: list LOG_LANGUAGE in the docker compose example
* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE
Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.
DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.
Review fixes:
- Channel test logs store the token name "Model test"; the console shows
it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
translates. The HTTP status and the upstream detail go to the server log
when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.
Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
translated message key.
- Remove guards and helpers that no caller needs.
* fix(i18n): address review on language lookup, notices and error responses
- Relay requests no longer load the user a second time to find the
language: the settings TokenAuth put into the request are used as they
are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
messages again, with the keys and status codes used before this PR.
Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
senders. Running the rendered text through Sprintf appended
%!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
306 lines
11 KiB
Go
306 lines
11 KiB
Go
package model
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var (
|
|
ErrPasskeyNotFound = errors.New("passkey credential not found")
|
|
ErrFriendlyPasskeyNotFound = errors.New("Passkey verification failed. Please try again or contact the administrator.")
|
|
)
|
|
|
|
type PasskeyCredential struct {
|
|
ID int `json:"id" gorm:"primaryKey"`
|
|
UserID int `json:"user_id" gorm:"uniqueIndex;not null"`
|
|
RPID *string `json:"rp_id,omitempty" gorm:"column:rp_id;type:varchar(253)"`
|
|
CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded
|
|
PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded
|
|
AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"`
|
|
AAGUID string `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded
|
|
SignCount uint32 `json:"sign_count" gorm:"default:0"`
|
|
CloneWarning bool `json:"clone_warning"`
|
|
UserPresent bool `json:"user_present"`
|
|
UserVerified bool `json:"user_verified"`
|
|
BackupEligible bool `json:"backup_eligible"`
|
|
BackupState bool `json:"backup_state"`
|
|
Transports string `json:"transports" gorm:"type:text"`
|
|
Attachment string `json:"attachment" gorm:"type:varchar(32)"`
|
|
LastUsedAt *time.Time `json:"last_used_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
|
}
|
|
|
|
func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport {
|
|
if p == nil || strings.TrimSpace(p.Transports) == "" {
|
|
return nil
|
|
}
|
|
var transports []string
|
|
if err := common.Unmarshal([]byte(p.Transports), &transports); err != nil {
|
|
return nil
|
|
}
|
|
result := make([]protocol.AuthenticatorTransport, 0, len(transports))
|
|
for _, transport := range transports {
|
|
result = append(result, protocol.AuthenticatorTransport(transport))
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport) {
|
|
if len(list) == 0 {
|
|
p.Transports = ""
|
|
return
|
|
}
|
|
stringList := make([]string, len(list))
|
|
for i, transport := range list {
|
|
stringList[i] = string(transport)
|
|
}
|
|
encoded, err := common.Marshal(stringList)
|
|
if err != nil {
|
|
return
|
|
}
|
|
p.Transports = string(encoded)
|
|
}
|
|
|
|
func (p *PasskeyCredential) ToWebAuthnCredential() webauthn.Credential {
|
|
flags := webauthn.CredentialFlags{
|
|
UserPresent: p.UserPresent,
|
|
UserVerified: p.UserVerified,
|
|
BackupEligible: p.BackupEligible,
|
|
BackupState: p.BackupState,
|
|
}
|
|
|
|
credID, _ := base64.StdEncoding.DecodeString(p.CredentialID)
|
|
pubKey, _ := base64.StdEncoding.DecodeString(p.PublicKey)
|
|
aaguid, _ := base64.StdEncoding.DecodeString(p.AAGUID)
|
|
|
|
return webauthn.Credential{
|
|
ID: credID,
|
|
PublicKey: pubKey,
|
|
AttestationType: p.AttestationType,
|
|
Transport: p.TransportList(),
|
|
Flags: flags,
|
|
Authenticator: webauthn.Authenticator{
|
|
AAGUID: aaguid,
|
|
SignCount: p.SignCount,
|
|
CloneWarning: p.CloneWarning,
|
|
Attachment: protocol.AuthenticatorAttachment(p.Attachment),
|
|
},
|
|
}
|
|
}
|
|
|
|
func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credential) *PasskeyCredential {
|
|
if credential == nil {
|
|
return nil
|
|
}
|
|
passkey := &PasskeyCredential{
|
|
UserID: userID,
|
|
CredentialID: base64.StdEncoding.EncodeToString(credential.ID),
|
|
PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey),
|
|
AttestationType: credential.AttestationType,
|
|
AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID),
|
|
SignCount: credential.Authenticator.SignCount,
|
|
CloneWarning: credential.Authenticator.CloneWarning,
|
|
UserPresent: credential.Flags.UserPresent,
|
|
UserVerified: credential.Flags.UserVerified,
|
|
BackupEligible: credential.Flags.BackupEligible,
|
|
BackupState: credential.Flags.BackupState,
|
|
Attachment: string(credential.Authenticator.Attachment),
|
|
}
|
|
passkey.SetTransports(credential.Transport)
|
|
return passkey
|
|
}
|
|
|
|
func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) {
|
|
if userID == 0 {
|
|
common.SysLog(common.LogText("GetPasskeyByUserID: empty user ID"))
|
|
return nil, ErrFriendlyPasskeyNotFound
|
|
}
|
|
var credential PasskeyCredential
|
|
if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
// 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志
|
|
return nil, ErrPasskeyNotFound
|
|
}
|
|
// 只有真正的数据库错误才记录日志
|
|
common.SysLog(common.LogText("GetPasskeyByUserID: database error for user %d: %v", userID, err))
|
|
return nil, ErrFriendlyPasskeyNotFound
|
|
}
|
|
return &credential, nil
|
|
}
|
|
|
|
func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) {
|
|
if len(credentialID) == 0 {
|
|
common.SysLog(common.LogText("GetPasskeyByCredentialID: empty credential ID"))
|
|
return nil, ErrFriendlyPasskeyNotFound
|
|
}
|
|
|
|
credIDStr := base64.StdEncoding.EncodeToString(credentialID)
|
|
var credential PasskeyCredential
|
|
if err := DB.Where("credential_id = ?", credIDStr).First(&credential).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
common.SysLog(common.LogText("GetPasskeyByCredentialID: passkey not found for credential ID length %d", len(credentialID)))
|
|
return nil, ErrFriendlyPasskeyNotFound
|
|
}
|
|
common.SysLog(common.LogText("GetPasskeyByCredentialID: database error for credential ID: %v", err))
|
|
return nil, ErrFriendlyPasskeyNotFound
|
|
}
|
|
|
|
return &credential, nil
|
|
}
|
|
|
|
// UpdatePasskeyAssertionState persists only fields produced by a successful
|
|
// assertion. Registration identity (credential ID, public key, AAGUID,
|
|
// transports and attestation metadata) is immutable on this path.
|
|
func UpdatePasskeyAssertionState(userID int, credential *webauthn.Credential, lastUsedAt time.Time, rpID string) error {
|
|
if userID <= 0 || credential == nil || len(credential.ID) == 0 || lastUsedAt.IsZero() || rpID == "" {
|
|
return errors.New("failed to save the Passkey, please try again")
|
|
}
|
|
credentialID := base64.StdEncoding.EncodeToString(credential.ID)
|
|
passkeyOptionMutex.Lock()
|
|
defer passkeyOptionMutex.Unlock()
|
|
return DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := validatePasskeyRPIDWithTx(tx, rpID); err != nil {
|
|
return err
|
|
}
|
|
var stored PasskeyCredential
|
|
if err := lockForUpdate(tx).Where("user_id = ? AND credential_id = ?", userID, credentialID).First(&stored).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrPasskeyNotFound
|
|
}
|
|
return err
|
|
}
|
|
// Compare in Go: the database collation may fold historical case.
|
|
if stored.RPID != nil && *stored.RPID != "" && *stored.RPID != rpID {
|
|
return system_setting.ErrPasskeyRPIDUnavailable
|
|
}
|
|
result := tx.Model(&PasskeyCredential{}).
|
|
Where("user_id = ? AND credential_id = ?", userID, credentialID).
|
|
Where("rp_id IS NULL OR rp_id = ? OR rp_id = ?", "", rpID).
|
|
Updates(map[string]any{
|
|
"rp_id": rpID,
|
|
"sign_count": credential.Authenticator.SignCount,
|
|
"clone_warning": credential.Authenticator.CloneWarning,
|
|
"user_present": credential.Flags.UserPresent,
|
|
"user_verified": credential.Flags.UserVerified,
|
|
"backup_eligible": credential.Flags.BackupEligible,
|
|
"backup_state": credential.Flags.BackupState,
|
|
"last_used_at": lastUsedAt,
|
|
})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return ErrPasskeyNotFound
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func upsertPasskeyCredentialWithTx(tx *gorm.DB, credential *PasskeyCredential) error {
|
|
if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil {
|
|
common.SysLog(common.LogText("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err))
|
|
return errors.New("failed to save the Passkey, please try again")
|
|
}
|
|
if err := tx.Create(credential).Error; err != nil {
|
|
common.SysLog(common.LogText("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err))
|
|
return errors.New("failed to save the Passkey, please try again")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpsertPasskeyCredentialWithAuthVersion is reserved for enrollment changes;
|
|
// assertion sign-count updates must use UpdatePasskeyAssertionState.
|
|
func UpsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential) error {
|
|
return upsertPasskeyCredentialWithAuthVersion(credential, nil)
|
|
}
|
|
|
|
func RegisterPasskeyForSession(identity AuthSessionIdentity, credential *PasskeyCredential) error {
|
|
return upsertPasskeyCredentialWithAuthVersion(credential, &identity)
|
|
}
|
|
|
|
func upsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential, identity *AuthSessionIdentity) error {
|
|
if credential == nil || credential.UserID <= 0 {
|
|
return errors.New("failed to save the Passkey, please try again")
|
|
}
|
|
passkeyOptionMutex.Lock()
|
|
defer passkeyOptionMutex.Unlock()
|
|
if err := DB.Transaction(func(tx *gorm.DB) error {
|
|
if identity != nil && (credential.RPID == nil || *credential.RPID == "") {
|
|
return system_setting.ErrPasskeyRPIDUnavailable
|
|
}
|
|
if credential.RPID != nil && *credential.RPID != "" {
|
|
if err := validatePasskeyRPIDWithTx(tx, *credential.RPID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if identity != nil {
|
|
if identity.UserID != credential.UserID {
|
|
return ErrUserSessionInactive
|
|
}
|
|
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := IncrementUserAuthVersionWithTx(tx, credential.UserID); err != nil {
|
|
return err
|
|
}
|
|
return upsertPasskeyCredentialWithTx(tx, credential)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return PublishUserAuthCache(credential.UserID)
|
|
}
|
|
|
|
func DeletePasskeyByUserIDWithAuthVersion(userID int) error {
|
|
return deletePasskeyWithAuthVersion(userID, nil)
|
|
}
|
|
|
|
func DeletePasskeyForSession(identity AuthSessionIdentity) error {
|
|
return deletePasskeyWithAuthVersion(identity.UserID, &identity)
|
|
}
|
|
|
|
func deletePasskeyWithAuthVersion(userID int, identity *AuthSessionIdentity) error {
|
|
if userID == 0 {
|
|
return errors.New("failed to delete the Passkey, please try again")
|
|
}
|
|
if err := DB.Transaction(func(tx *gorm.DB) error {
|
|
if identity != nil {
|
|
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var credential PasskeyCredential
|
|
if err := lockForUpdate(tx).Where("user_id = ?", userID).First(&credential).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrPasskeyNotFound
|
|
}
|
|
return err
|
|
}
|
|
if _, err := IncrementUserAuthVersionWithTx(tx, userID); err != nil {
|
|
return err
|
|
}
|
|
result := tx.Unscoped().Delete(&credential)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return ErrPasskeyNotFound
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return PublishUserAuthCache(userID)
|
|
}
|