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.
330 lines
10 KiB
Go
330 lines
10 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
const (
|
|
AccessTokenPrefix = "nap_"
|
|
AccessTokenSessionPrefix = "pat:"
|
|
AccessTokenSessionVersion = 1
|
|
legacyAccessTokenRetireAtKey = "LegacyAccessTokenRetireAt"
|
|
legacyAccessTokenTransitionDays = 30
|
|
userAccessTokenCleanupBatchSize = 500
|
|
userAccessTokenTouchIntervalSecs = 60
|
|
)
|
|
|
|
var (
|
|
ErrAccessTokenNotFound = errors.New("access token not found")
|
|
ErrAccessTokenLimit = errors.New("access token limit reached")
|
|
ErrAccessTokenExpired = errors.New("access token has expired")
|
|
ErrLegacyAccessTokenRetired = errors.New("legacy access token has been retired")
|
|
errLegacyRetireAtReadOnly = errors.New("LegacyAccessTokenRetireAt is managed by the server")
|
|
)
|
|
|
|
// legacyAccessTokenRetireAt caches the deployment-wide deadline loaded at
|
|
// startup. Zero means it was never loaded, which is treated as retired.
|
|
var legacyAccessTokenRetireAt atomic.Int64
|
|
|
|
// UserAccessToken is a scoped dashboard personal access token. Only the
|
|
// SHA-256 fingerprint of the plaintext is stored; it doubles as the audit
|
|
// token_ref.
|
|
type UserAccessToken struct {
|
|
Id int `json:"id"`
|
|
UserId int `json:"-" gorm:"index"`
|
|
Name string `json:"name" gorm:"type:varchar(64)"`
|
|
TokenHash string `json:"token_ref" gorm:"type:varchar(64);uniqueIndex"`
|
|
TokenHint string `json:"token_hint" gorm:"type:varchar(8)"`
|
|
Scopes string `json:"-" gorm:"type:varchar(2048)"`
|
|
ExpiresAt int64 `json:"expires_at" gorm:"type:bigint;index"`
|
|
LastUsedAt int64 `json:"last_used_at" gorm:"type:bigint"`
|
|
LastUsedIp string `json:"last_used_ip" gorm:"type:varchar(64)"`
|
|
CreatedAt int64 `json:"created_at" gorm:"type:bigint"`
|
|
}
|
|
|
|
func (UserAccessToken) TableName() string {
|
|
return "user_access_tokens"
|
|
}
|
|
|
|
// GetScopes returns the stored scope list; a malformed value grants nothing.
|
|
func (token *UserAccessToken) GetScopes() []string {
|
|
if token == nil || token.Scopes == "" {
|
|
return nil
|
|
}
|
|
var scopes []string
|
|
if err := common.UnmarshalJsonStr(token.Scopes, &scopes); err != nil {
|
|
common.SysError(common.LogText("invalid access token scopes: %s", err.Error()))
|
|
return nil
|
|
}
|
|
return scopes
|
|
}
|
|
|
|
func (token *UserAccessToken) SetScopes(scopes []string) error {
|
|
data, err := common.Marshal(scopes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token.Scopes = string(data)
|
|
return nil
|
|
}
|
|
|
|
// Expired reports whether the token has an absolute expiry at or before now.
|
|
func (token *UserAccessToken) Expired(now int64) bool {
|
|
return token.ExpiresAt > 0 && token.ExpiresAt <= now
|
|
}
|
|
|
|
func FindUserAccessTokenByHash(hash string) (*UserAccessToken, error) {
|
|
if hash == "" {
|
|
return nil, nil
|
|
}
|
|
var token UserAccessToken
|
|
err := DB.Where("token_hash = ?", hash).First(&token).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &token, nil
|
|
}
|
|
|
|
func GetUserAccessToken(userID, id int) (*UserAccessToken, error) {
|
|
var token UserAccessToken
|
|
err := DB.Where("id = ? AND user_id = ?", id, userID).First(&token).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrAccessTokenNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &token, nil
|
|
}
|
|
|
|
func ListUserAccessTokens(userID int) ([]UserAccessToken, error) {
|
|
tokens := make([]UserAccessToken, 0)
|
|
err := DB.Where("user_id = ?", userID).Order("created_at DESC").Order("id DESC").Find(&tokens).Error
|
|
return tokens, err
|
|
}
|
|
|
|
// CreateUserAccessToken inserts a token while holding the owner's users row, so
|
|
// concurrent creates cannot exceed limit. Expired rows awaiting cleanup count.
|
|
func CreateUserAccessToken(userID int, token *UserAccessToken, limit int) error {
|
|
if userID <= 0 || token == nil || token.TokenHash == "" {
|
|
return ErrAccessTokenNotFound
|
|
}
|
|
return DB.Transaction(func(tx *gorm.DB) error {
|
|
var user User
|
|
if err := lockForUpdate(tx).Select("id").First(&user, userID).Error; err != nil {
|
|
return err
|
|
}
|
|
var count int64
|
|
if err := tx.Model(&UserAccessToken{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count >= int64(limit) {
|
|
return ErrAccessTokenLimit
|
|
}
|
|
token.Id = 0
|
|
token.UserId = userID
|
|
if token.CreatedAt == 0 {
|
|
token.CreatedAt = common.GetTimestamp()
|
|
}
|
|
return tx.Create(token).Error
|
|
})
|
|
}
|
|
|
|
// UpdateUserAccessToken renames one token owned by userID and, when scopes is
|
|
// not nil, replaces its grant.
|
|
func UpdateUserAccessToken(userID, id int, name string, scopes []string) (*UserAccessToken, error) {
|
|
updates := map[string]any{"name": name}
|
|
if scopes != nil {
|
|
var grant UserAccessToken
|
|
if err := grant.SetScopes(scopes); err != nil {
|
|
return nil, err
|
|
}
|
|
updates["scopes"] = grant.Scopes
|
|
}
|
|
result := DB.Model(&UserAccessToken{}).Where("id = ? AND user_id = ?", id, userID).Updates(updates)
|
|
if result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
token, err := GetUserAccessToken(userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// DeleteUserAccessToken hard-deletes one token owned by userID and returns the
|
|
// deleted row for auditing.
|
|
func DeleteUserAccessToken(userID, id int) (*UserAccessToken, error) {
|
|
var token UserAccessToken
|
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := lockForUpdate(tx).Where("id = ? AND user_id = ?", id, userID).First(&token).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrAccessTokenNotFound
|
|
}
|
|
return err
|
|
}
|
|
return tx.Where("id = ? AND user_id = ?", id, userID).Delete(&UserAccessToken{}).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &token, nil
|
|
}
|
|
|
|
func DeleteUserAccessTokensWithTx(tx *gorm.DB, userID int) (int64, error) {
|
|
result := tx.Where("user_id = ?", userID).Delete(&UserAccessToken{})
|
|
return result.RowsAffected, result.Error
|
|
}
|
|
|
|
// TouchUserAccessToken records the last use at most once per interval per token.
|
|
func TouchUserAccessToken(id int, ip string, now int64) error {
|
|
if len(ip) > 64 {
|
|
ip = ip[:64]
|
|
}
|
|
return DB.Model(&UserAccessToken{}).
|
|
Where("id = ? AND last_used_at < ?", id, now-userAccessTokenTouchIntervalSecs).
|
|
Updates(map[string]any{"last_used_at": now, "last_used_ip": ip}).Error
|
|
}
|
|
|
|
// DeleteExpiredUserAccessTokens removes tokens that expired before the cutoff.
|
|
// Tokens that never expire are kept.
|
|
func DeleteExpiredUserAccessTokens(before int64) error {
|
|
for {
|
|
var ids []int
|
|
if err := DB.Model(&UserAccessToken{}).
|
|
Where("expires_at > 0 AND expires_at < ?", before).
|
|
Order("id").Limit(userAccessTokenCleanupBatchSize).Pluck("id", &ids).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
if err := DB.Where("id IN ?", ids).Delete(&UserAccessToken{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(ids) < userAccessTokenCleanupBatchSize {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// AccessTokenHint is the last four characters of a token, shown so users can
|
|
// tell their tokens apart. PostgreSQL pads legacy CHAR(32) values with spaces.
|
|
func AccessTokenHint(token string) string {
|
|
token = strings.TrimRight(token, " ")
|
|
if len(token) <= 4 {
|
|
return ""
|
|
}
|
|
return token[len(token)-4:]
|
|
}
|
|
|
|
// AccessTokenSessionID is the step-up session identifier bound to a token.
|
|
// Browser session IDs are UUIDs and can never carry this prefix.
|
|
func AccessTokenSessionID(id int) string {
|
|
return AccessTokenSessionPrefix + strconv.Itoa(id)
|
|
}
|
|
|
|
func ParseAccessTokenSessionID(sid string) (int, bool) {
|
|
raw, ok := strings.CutPrefix(sid, AccessTokenSessionPrefix)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
id, err := strconv.Atoi(raw)
|
|
if err != nil || id <= 0 || strconv.Itoa(id) != raw {
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// ValidateAccessTokenIdentity checks a token-bound step-up identity without
|
|
// taking locks, for use before issuing a verification proof.
|
|
func ValidateAccessTokenIdentity(identity AuthSessionIdentity) error {
|
|
tokenID, ok := ParseAccessTokenSessionID(identity.SessionID)
|
|
if !ok {
|
|
return ErrUserSessionInactive
|
|
}
|
|
return checkAccessTokenIdentity(DB, tokenID, identity, false)
|
|
}
|
|
|
|
func validateAccessTokenIdentityWithTx(tx *gorm.DB, tokenID int, identity AuthSessionIdentity) error {
|
|
return checkAccessTokenIdentity(tx, tokenID, identity, true)
|
|
}
|
|
|
|
// checkAccessTokenIdentity locks users before user_access_tokens, matching
|
|
// CreateUserAccessToken, and reports every mismatch as an inactive session.
|
|
func checkAccessTokenIdentity(tx *gorm.DB, tokenID int, identity AuthSessionIdentity, lock bool) error {
|
|
if identity.UserID <= 0 || identity.UserAuthVersion <= 0 || identity.SessionVersion != AccessTokenSessionVersion {
|
|
return ErrUserSessionInactive
|
|
}
|
|
query := tx
|
|
if lock {
|
|
query = lockForUpdate(tx)
|
|
}
|
|
var user User
|
|
if err := query.Select("id", "status", "auth_version").First(&user, identity.UserID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrUserSessionInactive
|
|
}
|
|
return err
|
|
}
|
|
if user.Status != common.UserStatusEnabled || user.AuthVersion != identity.UserAuthVersion {
|
|
return ErrUserSessionInactive
|
|
}
|
|
query = tx
|
|
if lock {
|
|
query = lockForUpdate(tx)
|
|
}
|
|
var token UserAccessToken
|
|
if err := query.Where("id = ? AND user_id = ?", tokenID, identity.UserID).First(&token).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrUserSessionInactive
|
|
}
|
|
return err
|
|
}
|
|
if token.Expired(time.Now().Unix()) {
|
|
return ErrUserSessionInactive
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnsureLegacyAccessTokenRetireAt records the legacy token deadline on first
|
|
// start (now + 30 days) and loads the stored value. The first node to start
|
|
// wins; later starts and other nodes only read it.
|
|
func EnsureLegacyAccessTokenRetireAt(now int64) error {
|
|
deadline := strconv.FormatInt(now+legacyAccessTokenTransitionDays*24*60*60, 10)
|
|
if err := DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&Option{Key: legacyAccessTokenRetireAtKey, Value: deadline}).Error; err != nil {
|
|
return err
|
|
}
|
|
var option Option
|
|
if err := DB.Where(&Option{Key: legacyAccessTokenRetireAtKey}).First(&option).Error; err != nil {
|
|
return err
|
|
}
|
|
retireAt, err := strconv.ParseInt(strings.TrimSpace(option.Value), 10, 64)
|
|
if err != nil || retireAt <= 0 {
|
|
return errors.New("invalid LegacyAccessTokenRetireAt option value")
|
|
}
|
|
legacyAccessTokenRetireAt.Store(retireAt)
|
|
return nil
|
|
}
|
|
|
|
func LegacyAccessTokenRetireAt() int64 {
|
|
return legacyAccessTokenRetireAt.Load()
|
|
}
|
|
|
|
func LegacyAccessTokensRetired(now int64) bool {
|
|
retireAt := legacyAccessTokenRetireAt.Load()
|
|
return retireAt <= 0 || now >= retireAt
|
|
}
|