mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 09:27:03 +08:00
* feat(logs): export usage logs from the common log page The common log page has an Export button next to Search. It asks for a time range and downloads the logs of the current view, with the page's other filters, as a CSV file in the viewer's language. Users export their own logs; administrators in the all-users view export every user's logs. Each user runs one export at a time. A second export started while one is running, from another tab or another node, is refused until the first one ends. With Redis the reservation holds across nodes and lapses within a minute if its node stops. Log Maintenance settings add two options: whether users may export (on by default) and the longest log time range in days (30 by default). Regular users can search their logs within that range at once, and every export is limited to it. A user's log search had no limit before. * fix(web): split the log maintenance page into four modules The log maintenance page ran its settings, the history log cleanup and the server log files together. It now shows four modules, each under its own heading and separated by a divider, like the performance page: log recording, log search and export, history log cleanup, and server log management. The history log cleanup no longer sits in a box of its own. The history log cleanup and the server log files are now components of their own; what they do is unchanged. * feat(logs): default the log time range to 365 days and apply it to the dashboard The longest log time range now defaults to 365 days instead of 30. Regular users had no limit on log searches before this setting, and 30 days could not cover a 31-day month. A user's own usage and traffic charts on the dashboard were held to a fixed one-month range. They now follow the same setting, so the dashboard, the log search and the export share one limit, and a range that is too long gets the same "cannot exceed N days" message.
329 lines
12 KiB
Go
329 lines
12 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/QuantumNous/new-api/i18n"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
"github.com/QuantumNous/new-api/middleware"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/oauth"
|
|
"github.com/QuantumNous/new-api/service"
|
|
"github.com/QuantumNous/new-api/setting"
|
|
"github.com/QuantumNous/new-api/setting/console_setting"
|
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func TestStatus(c *gin.Context) {
|
|
err := model.PingDB()
|
|
if err != nil {
|
|
writeAccountStatusError(c, http.StatusServiceUnavailable, "Database connection failed")
|
|
return
|
|
}
|
|
// 获取HTTP统计信息
|
|
httpStats := middleware.GetStats()
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Server is running",
|
|
"http_stats": httpStats,
|
|
})
|
|
return
|
|
}
|
|
|
|
func GetStatus(c *gin.Context) {
|
|
|
|
cs := console_setting.GetConsoleSetting()
|
|
passkeySetting := system_setting.PasskeySettingsSnapshot()
|
|
common.OptionMapRWMutex.RLock()
|
|
defer common.OptionMapRWMutex.RUnlock()
|
|
|
|
legalSetting := system_setting.GetLegalSettings()
|
|
|
|
data := gin.H{
|
|
"version": common.Version,
|
|
"start_time": common.StartTime,
|
|
"email_verification": common.EmailVerificationEnabled,
|
|
"github_oauth": common.GitHubOAuthEnabled,
|
|
"github_client_id": common.GitHubClientId,
|
|
"discord_oauth": system_setting.GetDiscordSettings().Enabled,
|
|
"discord_client_id": system_setting.GetDiscordSettings().ClientId,
|
|
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
|
|
"linuxdo_client_id": common.LinuxDOClientId,
|
|
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
|
|
"telegram_oauth": common.TelegramOAuthEnabled,
|
|
"telegram_oauth_configured": oauth.TelegramConfigurationError() == nil,
|
|
"telegram_bot_name": common.TelegramBotName,
|
|
"theme": "default",
|
|
"system_name": common.SystemName,
|
|
"logo": common.Logo,
|
|
"footer_html": common.Footer,
|
|
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
|
|
"wechat_login": common.WeChatAuthEnabled,
|
|
"server_address": system_setting.ServerAddress,
|
|
"turnstile_check": common.TurnstileCheckEnabled,
|
|
"turnstile_site_key": common.TurnstileSiteKey,
|
|
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
|
"quota_per_unit": common.QuotaPerUnit,
|
|
// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
|
|
"display_in_currency": operation_setting.IsCurrencyDisplay(),
|
|
"quota_display_type": operation_setting.GetQuotaDisplayType(),
|
|
"custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
|
|
"custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
|
|
"enable_batch_update": common.BatchUpdateEnabled,
|
|
"enable_drawing": common.DrawingEnabled,
|
|
"enable_task": common.TaskEnabled,
|
|
"enable_data_export": common.DataExportEnabled,
|
|
"data_export_default_time": common.DataExportDefaultTime,
|
|
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
|
"mj_notify_enabled": setting.MjNotifyEnabled,
|
|
"chats": setting.Chats,
|
|
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
|
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
|
"register_enabled": common.RegisterEnabled,
|
|
"password_login_enabled": common.PasswordLoginEnabled,
|
|
"password_register_enabled": common.PasswordRegisterEnabled,
|
|
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
|
|
|
"password_login_encryption_enabled": common.PasswordLoginEncryptionEnabled,
|
|
|
|
"usd_exchange_rate": operation_setting.USDExchangeRate,
|
|
"price": operation_setting.Price,
|
|
"stripe_unit_price": setting.StripeUnitPrice,
|
|
|
|
// 面板启用开关
|
|
"api_info_enabled": cs.ApiInfoEnabled,
|
|
"uptime_kuma_enabled": cs.UptimeKumaEnabled,
|
|
"announcements_enabled": cs.AnnouncementsEnabled,
|
|
"faq_enabled": cs.FAQEnabled,
|
|
|
|
// 模块管理配置
|
|
"HeaderNavModules": common.OptionMap["HeaderNavModules"],
|
|
"SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
|
|
|
|
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
|
|
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
|
|
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
|
|
"oidc_display_name": system_setting.GetOIDCSettings().GetEffectiveDisplayName(),
|
|
"passkey_login": passkeySetting.Enabled,
|
|
"passkey_display_name": passkeySetting.RPDisplayName,
|
|
"passkey_rp_id": passkeySetting.EffectiveRPID(),
|
|
"passkey_rp_ids": passkeySetting.RelyingPartyIDs(),
|
|
"passkey_allow_insecure": passkeySetting.AllowInsecureOrigin,
|
|
"passkey_user_verification": passkeySetting.UserVerification,
|
|
"passkey_attachment": passkeySetting.AttachmentPreference,
|
|
"setup": constant.Setup,
|
|
"user_agreement_enabled": legalSetting.UserAgreement != "",
|
|
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
|
|
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
|
|
"log_export_enabled": operation_setting.GetLogSetting().UserExportEnabled,
|
|
"log_max_range_days": operation_setting.GetLogSetting().MaxRangeDays,
|
|
}
|
|
|
|
// 根据启用状态注入可选内容
|
|
if cs.ApiInfoEnabled {
|
|
data["api_info"] = console_setting.GetApiInfo()
|
|
}
|
|
if cs.AnnouncementsEnabled {
|
|
data["announcements"] = console_setting.GetAnnouncements()
|
|
}
|
|
if cs.FAQEnabled {
|
|
data["faq"] = console_setting.GetFAQ()
|
|
}
|
|
|
|
// Add enabled custom OAuth providers
|
|
customProviders := oauth.GetEnabledCustomProviders()
|
|
if len(customProviders) > 0 {
|
|
type CustomOAuthInfo struct {
|
|
Id int `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Icon string `json:"icon"`
|
|
ClientId string `json:"client_id"`
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
Scopes string `json:"scopes"`
|
|
}
|
|
providersInfo := make([]CustomOAuthInfo, 0, len(customProviders))
|
|
for _, p := range customProviders {
|
|
config := p.GetConfig()
|
|
providersInfo = append(providersInfo, CustomOAuthInfo{
|
|
Id: config.Id,
|
|
Name: config.Name,
|
|
Slug: config.Slug,
|
|
Icon: config.Icon,
|
|
ClientId: config.ClientId,
|
|
AuthorizationEndpoint: config.AuthorizationEndpoint,
|
|
Scopes: config.Scopes,
|
|
})
|
|
}
|
|
data["custom_oauth_providers"] = providersInfo
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": data,
|
|
})
|
|
return
|
|
}
|
|
|
|
func GetNotice(c *gin.Context) {
|
|
common.OptionMapRWMutex.RLock()
|
|
notice := common.OptionMap["Notice"]
|
|
common.OptionMapRWMutex.RUnlock()
|
|
serveRevalidatedJSON(c, notice)
|
|
}
|
|
|
|
func GetAbout(c *gin.Context) {
|
|
common.OptionMapRWMutex.RLock()
|
|
about := common.OptionMap["About"]
|
|
common.OptionMapRWMutex.RUnlock()
|
|
serveRevalidatedJSON(c, about)
|
|
}
|
|
|
|
func GetUserAgreement(c *gin.Context) {
|
|
serveRevalidatedJSON(c, system_setting.GetLegalSettings().UserAgreement)
|
|
}
|
|
|
|
func GetPrivacyPolicy(c *gin.Context) {
|
|
serveRevalidatedJSON(c, system_setting.GetLegalSettings().PrivacyPolicy)
|
|
}
|
|
|
|
func GetMidjourney(c *gin.Context) {
|
|
common.OptionMapRWMutex.RLock()
|
|
defer common.OptionMapRWMutex.RUnlock()
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": common.OptionMap["Midjourney"],
|
|
})
|
|
return
|
|
}
|
|
|
|
func GetHomePageContent(c *gin.Context) {
|
|
common.OptionMapRWMutex.RLock()
|
|
homePageContent := common.OptionMap["HomePageContent"]
|
|
common.OptionMapRWMutex.RUnlock()
|
|
serveRevalidatedJSON(c, homePageContent)
|
|
}
|
|
|
|
func SendEmailVerification(c *gin.Context) {
|
|
email, err := service.ValidateAccountEmail(c.Query("email"))
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
if model.IsEmailAlreadyTaken(email) {
|
|
common.ApiErrorT(c, "Email address is already in use")
|
|
return
|
|
}
|
|
code := common.GenerateVerificationCode(6)
|
|
common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose)
|
|
// The address may not belong to an account yet, so the mail follows the
|
|
// request language.
|
|
systemName := map[string]any{"SystemName": common.SystemName}
|
|
subject := i18n.T(c, i18n.MsgEmailVerificationSubject, systemName)
|
|
content := fmt.Sprintf("<p>%s</p>"+
|
|
"<p>%s</p>"+
|
|
"<p>%s</p>",
|
|
i18n.T(c, i18n.MsgEmailVerificationGreeting, systemName),
|
|
i18n.T(c, i18n.MsgEmailVerificationCode, map[string]any{"Code": code}),
|
|
i18n.T(c, i18n.MsgEmailVerificationValidity, map[string]any{"Minutes": common.VerificationValidMinutes}))
|
|
err = common.SendEmail(subject, email, content)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
})
|
|
return
|
|
}
|
|
|
|
func SendPasswordResetEmail(c *gin.Context) {
|
|
email := model.NormalizeEmail(c.Query("email"))
|
|
if err := common.Validate.Var(email, "required,email"); err != nil {
|
|
common.ApiErrorT(c, "Invalid parameters")
|
|
return
|
|
}
|
|
if user, err := model.GetUniqueUserByEmail(email); err == nil {
|
|
code := common.GenerateVerificationCode(0)
|
|
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
|
|
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
|
|
lang := user.GetSetting().Language
|
|
if lang == "" {
|
|
lang = i18n.StatedLang(c)
|
|
}
|
|
systemName := map[string]any{"SystemName": common.SystemName}
|
|
linkParams := map[string]any{"Link": link}
|
|
subject := i18n.Translate(lang, i18n.MsgEmailPasswordResetSubject, systemName)
|
|
content := fmt.Sprintf("<p>%s</p>"+
|
|
"<p>%s</p>"+
|
|
"<p>%s </p>"+
|
|
"<p>%s</p>",
|
|
i18n.Translate(lang, i18n.MsgEmailPasswordResetGreeting, systemName),
|
|
i18n.Translate(lang, i18n.MsgEmailPasswordResetLink, linkParams),
|
|
i18n.Translate(lang, i18n.MsgEmailPasswordResetLinkFallback, linkParams),
|
|
i18n.Translate(lang, i18n.MsgEmailPasswordResetValidity, map[string]any{"Minutes": common.VerificationValidMinutes}))
|
|
err := common.SendEmail(subject, email, content)
|
|
if err != nil {
|
|
logger.LogError(c.Request.Context(), common.LogText("failed to send password reset email to %s: %s", email, err.Error()))
|
|
}
|
|
} else if err != nil && !errors.Is(err, model.ErrEmailNotFound) {
|
|
logger.LogWarn(c.Request.Context(), common.LogText("skip password reset email for %s: %s", email, err.Error()))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
})
|
|
}
|
|
|
|
type PasswordResetRequest struct {
|
|
Email string `json:"email"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
func ResetPassword(c *gin.Context) {
|
|
var req PasswordResetRequest
|
|
err := json.NewDecoder(c.Request.Body).Decode(&req)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
req.Email = model.NormalizeEmail(req.Email)
|
|
if req.Email == "" || req.Token == "" {
|
|
common.ApiErrorT(c, "Invalid parameters")
|
|
return
|
|
}
|
|
if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) {
|
|
common.ApiErrorT(c, "Password reset link is invalid or has expired")
|
|
return
|
|
}
|
|
password := common.GenerateVerificationCode(12)
|
|
err = model.ResetUserPasswordByEmail(req.Email, password)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrEmailNotFound) || errors.Is(err, model.ErrEmailAmbiguous) {
|
|
common.ApiErrorT(c, "Password reset link is invalid or has expired")
|
|
return
|
|
}
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.DeleteKey(req.Email, common.PasswordResetPurpose)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": password,
|
|
})
|
|
return
|
|
}
|