Unverified Commit 0c4f1762 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #232 from Edric-Li/feat/api-key-ip-restriction

feat(settings): 首页自定义内容 & 配置注入优化
parents 24d19a5f 0fa5a601
...@@ -148,7 +148,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { ...@@ -148,7 +148,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, redisClient) engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, settingService, redisClient)
httpServer := server.ProvideHTTPServer(configConfig, engine) httpServer := server.ProvideHTTPServer(configConfig, engine)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository) accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
......
...@@ -274,6 +274,13 @@ type DatabaseConfig struct { ...@@ -274,6 +274,13 @@ type DatabaseConfig struct {
} }
func (d *DatabaseConfig) DSN() string { func (d *DatabaseConfig) DSN() string {
// 当密码为空时不包含 password 参数,避免 libpq 解析错误
if d.Password == "" {
return fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s sslmode=%s",
d.Host, d.Port, d.User, d.DBName, d.SSLMode,
)
}
return fmt.Sprintf( return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
...@@ -285,6 +292,13 @@ func (d *DatabaseConfig) DSNWithTimezone(tz string) string { ...@@ -285,6 +292,13 @@ func (d *DatabaseConfig) DSNWithTimezone(tz string) string {
if tz == "" { if tz == "" {
tz = "Asia/Shanghai" tz = "Asia/Shanghai"
} }
// 当密码为空时不包含 password 参数,避免 libpq 解析错误
if d.Password == "" {
return fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s sslmode=%s TimeZone=%s",
d.Host, d.Port, d.User, d.DBName, d.SSLMode, tz,
)
}
return fmt.Sprintf( return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s", "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s",
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, tz, d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, tz,
......
...@@ -62,6 +62,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ...@@ -62,6 +62,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
APIBaseURL: settings.APIBaseURL, APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo, ContactInfo: settings.ContactInfo,
DocURL: settings.DocURL, DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
DefaultConcurrency: settings.DefaultConcurrency, DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance, DefaultBalance: settings.DefaultBalance,
EnableModelFallback: settings.EnableModelFallback, EnableModelFallback: settings.EnableModelFallback,
...@@ -107,6 +108,7 @@ type UpdateSettingsRequest struct { ...@@ -107,6 +108,7 @@ type UpdateSettingsRequest struct {
APIBaseURL string `json:"api_base_url"` APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"` ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"` DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
// 默认配置 // 默认配置
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
...@@ -229,6 +231,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -229,6 +231,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
APIBaseURL: req.APIBaseURL, APIBaseURL: req.APIBaseURL,
ContactInfo: req.ContactInfo, ContactInfo: req.ContactInfo,
DocURL: req.DocURL, DocURL: req.DocURL,
HomeContent: req.HomeContent,
DefaultConcurrency: req.DefaultConcurrency, DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance, DefaultBalance: req.DefaultBalance,
EnableModelFallback: req.EnableModelFallback, EnableModelFallback: req.EnableModelFallback,
...@@ -277,6 +280,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -277,6 +280,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
APIBaseURL: updatedSettings.APIBaseURL, APIBaseURL: updatedSettings.APIBaseURL,
ContactInfo: updatedSettings.ContactInfo, ContactInfo: updatedSettings.ContactInfo,
DocURL: updatedSettings.DocURL, DocURL: updatedSettings.DocURL,
HomeContent: updatedSettings.HomeContent,
DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance, DefaultBalance: updatedSettings.DefaultBalance,
EnableModelFallback: updatedSettings.EnableModelFallback, EnableModelFallback: updatedSettings.EnableModelFallback,
...@@ -377,6 +381,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, ...@@ -377,6 +381,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.DocURL != after.DocURL { if before.DocURL != after.DocURL {
changed = append(changed, "doc_url") changed = append(changed, "doc_url")
} }
if before.HomeContent != after.HomeContent {
changed = append(changed, "home_content")
}
if before.DefaultConcurrency != after.DefaultConcurrency { if before.DefaultConcurrency != after.DefaultConcurrency {
changed = append(changed, "default_concurrency") changed = append(changed, "default_concurrency")
} }
......
...@@ -28,6 +28,7 @@ type SystemSettings struct { ...@@ -28,6 +28,7 @@ type SystemSettings struct {
APIBaseURL string `json:"api_base_url"` APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"` ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"` DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"` DefaultBalance float64 `json:"default_balance"`
...@@ -55,6 +56,7 @@ type PublicSettings struct { ...@@ -55,6 +56,7 @@ type PublicSettings struct {
APIBaseURL string `json:"api_base_url"` APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"` ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"` DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version"` Version string `json:"version"`
} }
...@@ -42,6 +42,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ...@@ -42,6 +42,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
APIBaseURL: settings.APIBaseURL, APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo, ContactInfo: settings.ContactInfo,
DocURL: settings.DocURL, DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: h.version, Version: h.version,
}) })
......
...@@ -326,7 +326,8 @@ func TestAPIContracts(t *testing.T) { ...@@ -326,7 +326,8 @@ func TestAPIContracts(t *testing.T) {
"fallback_model_gemini": "gemini-2.5-pro", "fallback_model_gemini": "gemini-2.5-pro",
"fallback_model_openai": "gpt-4o", "fallback_model_openai": "gpt-4o",
"enable_identity_patch": true, "enable_identity_patch": true,
"identity_patch_prompt": "" "identity_patch_prompt": "",
"home_content": ""
} }
}`, }`,
}, },
......
...@@ -31,6 +31,7 @@ func ProvideRouter( ...@@ -31,6 +31,7 @@ func ProvideRouter(
apiKeyAuth middleware2.APIKeyAuthMiddleware, apiKeyAuth middleware2.APIKeyAuthMiddleware,
apiKeyService *service.APIKeyService, apiKeyService *service.APIKeyService,
subscriptionService *service.SubscriptionService, subscriptionService *service.SubscriptionService,
settingService *service.SettingService,
redisClient *redis.Client, redisClient *redis.Client,
) *gin.Engine { ) *gin.Engine {
if cfg.Server.Mode == "release" { if cfg.Server.Mode == "release" {
...@@ -49,7 +50,7 @@ func ProvideRouter( ...@@ -49,7 +50,7 @@ func ProvideRouter(
} }
} }
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg, redisClient) return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, settingService, cfg, redisClient)
} }
// ProvideHTTPServer 提供 HTTP 服务器 // ProvideHTTPServer 提供 HTTP 服务器
......
package server package server
import ( import (
"log"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/handler"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
...@@ -21,6 +23,7 @@ func SetupRouter( ...@@ -21,6 +23,7 @@ func SetupRouter(
apiKeyAuth middleware2.APIKeyAuthMiddleware, apiKeyAuth middleware2.APIKeyAuthMiddleware,
apiKeyService *service.APIKeyService, apiKeyService *service.APIKeyService,
subscriptionService *service.SubscriptionService, subscriptionService *service.SubscriptionService,
settingService *service.SettingService,
cfg *config.Config, cfg *config.Config,
redisClient *redis.Client, redisClient *redis.Client,
) *gin.Engine { ) *gin.Engine {
...@@ -29,9 +32,17 @@ func SetupRouter( ...@@ -29,9 +32,17 @@ func SetupRouter(
r.Use(middleware2.CORS(cfg.CORS)) r.Use(middleware2.CORS(cfg.CORS))
r.Use(middleware2.SecurityHeaders(cfg.Security.CSP)) r.Use(middleware2.SecurityHeaders(cfg.Security.CSP))
// Serve embedded frontend if available // Serve embedded frontend with settings injection if available
if web.HasEmbeddedFrontend() { if web.HasEmbeddedFrontend() {
r.Use(web.ServeEmbeddedFrontend()) frontendServer, err := web.NewFrontendServer(settingService)
if err != nil {
log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err)
r.Use(web.ServeEmbeddedFrontend())
} else {
// Register cache invalidation callback
settingService.SetOnUpdateCallback(frontendServer.InvalidateCache)
r.Use(frontendServer.Middleware())
}
} }
// 注册路由 // 注册路由
......
...@@ -90,6 +90,7 @@ const ( ...@@ -90,6 +90,7 @@ const (
SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入) SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入)
SettingKeyContactInfo = "contact_info" // 客服联系方式 SettingKeyContactInfo = "contact_info" // 客服联系方式
SettingKeyDocURL = "doc_url" // 文档链接 SettingKeyDocURL = "doc_url" // 文档链接
SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src)
// 默认配置 // 默认配置
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
......
...@@ -32,6 +32,8 @@ type SettingRepository interface { ...@@ -32,6 +32,8 @@ type SettingRepository interface {
type SettingService struct { type SettingService struct {
settingRepo SettingRepository settingRepo SettingRepository
cfg *config.Config cfg *config.Config
onUpdate func() // Callback when settings are updated (for cache invalidation)
version string // Application version
} }
// NewSettingService 创建系统设置服务实例 // NewSettingService 创建系统设置服务实例
...@@ -65,6 +67,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -65,6 +67,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyAPIBaseURL, SettingKeyAPIBaseURL,
SettingKeyContactInfo, SettingKeyContactInfo,
SettingKeyDocURL, SettingKeyDocURL,
SettingKeyHomeContent,
SettingKeyLinuxDoConnectEnabled, SettingKeyLinuxDoConnectEnabled,
} }
...@@ -91,10 +94,62 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -91,10 +94,62 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
APIBaseURL: settings[SettingKeyAPIBaseURL], APIBaseURL: settings[SettingKeyAPIBaseURL],
ContactInfo: settings[SettingKeyContactInfo], ContactInfo: settings[SettingKeyContactInfo],
DocURL: settings[SettingKeyDocURL], DocURL: settings[SettingKeyDocURL],
HomeContent: settings[SettingKeyHomeContent],
LinuxDoOAuthEnabled: linuxDoEnabled, LinuxDoOAuthEnabled: linuxDoEnabled,
}, nil }, nil
} }
// SetOnUpdateCallback sets a callback function to be called when settings are updated
// This is used for cache invalidation (e.g., HTML cache in frontend server)
func (s *SettingService) SetOnUpdateCallback(callback func()) {
s.onUpdate = callback
}
// SetVersion sets the application version for injection into public settings
func (s *SettingService) SetVersion(version string) {
s.version = version
}
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection
// This implements the web.PublicSettingsProvider interface
func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
settings, err := s.GetPublicSettings(ctx)
if err != nil {
return nil, err
}
// Return a struct that matches the frontend's expected format
return &struct {
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo,omitempty"`
SiteSubtitle string `json:"site_subtitle,omitempty"`
APIBaseURL string `json:"api_base_url,omitempty"`
ContactInfo string `json:"contact_info,omitempty"`
DocURL string `json:"doc_url,omitempty"`
HomeContent string `json:"home_content,omitempty"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version,omitempty"`
}{
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
SiteName: settings.SiteName,
SiteLogo: settings.SiteLogo,
SiteSubtitle: settings.SiteSubtitle,
APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo,
DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: s.version,
}, nil
}
// UpdateSettings 更新系统设置 // UpdateSettings 更新系统设置
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error { func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
updates := make(map[string]string) updates := make(map[string]string)
...@@ -136,6 +191,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -136,6 +191,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyAPIBaseURL] = settings.APIBaseURL updates[SettingKeyAPIBaseURL] = settings.APIBaseURL
updates[SettingKeyContactInfo] = settings.ContactInfo updates[SettingKeyContactInfo] = settings.ContactInfo
updates[SettingKeyDocURL] = settings.DocURL updates[SettingKeyDocURL] = settings.DocURL
updates[SettingKeyHomeContent] = settings.HomeContent
// 默认配置 // 默认配置
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
...@@ -152,7 +208,11 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet ...@@ -152,7 +208,11 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch) updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch)
updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt
return s.settingRepo.SetMultiple(ctx, updates) err := s.settingRepo.SetMultiple(ctx, updates)
if err == nil && s.onUpdate != nil {
s.onUpdate() // Invalidate cache after settings update
}
return err
} }
// IsRegistrationEnabled 检查是否开放注册 // IsRegistrationEnabled 检查是否开放注册
...@@ -263,6 +323,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -263,6 +323,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
APIBaseURL: settings[SettingKeyAPIBaseURL], APIBaseURL: settings[SettingKeyAPIBaseURL],
ContactInfo: settings[SettingKeyContactInfo], ContactInfo: settings[SettingKeyContactInfo],
DocURL: settings[SettingKeyDocURL], DocURL: settings[SettingKeyDocURL],
HomeContent: settings[SettingKeyHomeContent],
} }
// 解析整数类型 // 解析整数类型
......
...@@ -31,6 +31,7 @@ type SystemSettings struct { ...@@ -31,6 +31,7 @@ type SystemSettings struct {
APIBaseURL string APIBaseURL string
ContactInfo string ContactInfo string
DocURL string DocURL string
HomeContent string
DefaultConcurrency int DefaultConcurrency int
DefaultBalance float64 DefaultBalance float64
...@@ -58,6 +59,7 @@ type PublicSettings struct { ...@@ -58,6 +59,7 @@ type PublicSettings struct {
APIBaseURL string APIBaseURL string
ContactInfo string ContactInfo string
DocURL string DocURL string
HomeContent string
LinuxDoOAuthEnabled bool LinuxDoOAuthEnabled bool
Version string Version string
} }
...@@ -4,11 +4,38 @@ ...@@ -4,11 +4,38 @@
package web package web
import ( import (
"context"
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// PublicSettingsProvider is an interface to fetch public settings
// This stub is needed for compilation when frontend is not embedded
type PublicSettingsProvider interface {
GetPublicSettingsForInjection(ctx context.Context) (any, error)
}
// FrontendServer is a stub for non-embed builds
type FrontendServer struct{}
// NewFrontendServer returns an error when frontend is not embedded
func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer, error) {
return nil, errors.New("frontend not embedded")
}
// InvalidateCache is a no-op for non-embed builds
func (s *FrontendServer) InvalidateCache() {}
// Middleware returns a handler that returns 404 for non-embed builds
func (s *FrontendServer) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.String(http.StatusNotFound, "Frontend not embedded. Build with -tags embed to include frontend.")
c.Abort()
}
}
func ServeEmbeddedFrontend() gin.HandlerFunc { func ServeEmbeddedFrontend() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
c.String(http.StatusNotFound, "Frontend not embedded. Build with -tags embed to include frontend.") c.String(http.StatusNotFound, "Frontend not embedded. Build with -tags embed to include frontend.")
......
...@@ -3,11 +3,15 @@ ...@@ -3,11 +3,15 @@
package web package web
import ( import (
"bytes"
"context"
"embed" "embed"
"encoding/json"
"io" "io"
"io/fs" "io/fs"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
...@@ -15,6 +19,162 @@ import ( ...@@ -15,6 +19,162 @@ import (
//go:embed all:dist //go:embed all:dist
var frontendFS embed.FS var frontendFS embed.FS
// PublicSettingsProvider is an interface to fetch public settings
type PublicSettingsProvider interface {
GetPublicSettingsForInjection(ctx context.Context) (any, error)
}
// FrontendServer serves the embedded frontend with settings injection
type FrontendServer struct {
distFS fs.FS
fileServer http.Handler
baseHTML []byte
cache *HTMLCache
settings PublicSettingsProvider
}
// NewFrontendServer creates a new frontend server with settings injection
func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer, error) {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
return nil, err
}
// Read base HTML once
file, err := distFS.Open("index.html")
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
baseHTML, err := io.ReadAll(file)
if err != nil {
return nil, err
}
cache := NewHTMLCache()
cache.SetBaseHTML(baseHTML)
return &FrontendServer{
distFS: distFS,
fileServer: http.FileServer(http.FS(distFS)),
baseHTML: baseHTML,
cache: cache,
settings: settingsProvider,
}, nil
}
// InvalidateCache invalidates the HTML cache (call when settings change)
func (s *FrontendServer) InvalidateCache() {
if s != nil && s.cache != nil {
s.cache.Invalidate()
}
}
// Middleware returns the Gin middleware handler
func (s *FrontendServer) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// Skip API routes
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/v1/") ||
strings.HasPrefix(path, "/v1beta/") ||
strings.HasPrefix(path, "/antigravity/") ||
strings.HasPrefix(path, "/setup/") ||
path == "/health" ||
path == "/responses" {
c.Next()
return
}
cleanPath := strings.TrimPrefix(path, "/")
if cleanPath == "" {
cleanPath = "index.html"
}
// For index.html or SPA routes, serve with injected settings
if cleanPath == "index.html" || !s.fileExists(cleanPath) {
s.serveIndexHTML(c)
return
}
// Serve static files normally
s.fileServer.ServeHTTP(c.Writer, c.Request)
c.Abort()
}
}
func (s *FrontendServer) fileExists(path string) bool {
file, err := s.distFS.Open(path)
if err != nil {
return false
}
_ = file.Close()
return true
}
func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
// Check cache first
cached := s.cache.Get()
if cached != nil {
// Check If-None-Match for 304 response
if match := c.GetHeader("If-None-Match"); match == cached.ETag {
c.Status(http.StatusNotModified)
c.Abort()
return
}
c.Header("ETag", cached.ETag)
c.Header("Cache-Control", "no-cache") // Must revalidate
c.Data(http.StatusOK, "text/html; charset=utf-8", cached.Content)
c.Abort()
return
}
// Cache miss - fetch settings and render
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
settings, err := s.settings.GetPublicSettingsForInjection(ctx)
if err != nil {
// Fallback: serve without injection
c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML)
c.Abort()
return
}
settingsJSON, err := json.Marshal(settings)
if err != nil {
// Fallback: serve without injection
c.Data(http.StatusOK, "text/html; charset=utf-8", s.baseHTML)
c.Abort()
return
}
rendered := s.injectSettings(settingsJSON)
s.cache.Set(rendered, settingsJSON)
cached = s.cache.Get()
if cached != nil {
c.Header("ETag", cached.ETag)
}
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", rendered)
c.Abort()
}
func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte {
// Create the script tag to inject
script := []byte(`<script>window.__APP_CONFIG__=` + string(settingsJSON) + `;</script>`)
// Inject before </head>
headClose := []byte("</head>")
return bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1)
}
// ServeEmbeddedFrontend returns a middleware for serving embedded frontend
// This is the legacy function for backward compatibility when no settings provider is available
func ServeEmbeddedFrontend() gin.HandlerFunc { func ServeEmbeddedFrontend() gin.HandlerFunc {
distFS, err := fs.Sub(frontendFS, "dist") distFS, err := fs.Sub(frontendFS, "dist")
if err != nil { if err != nil {
......
//go:build embed
package web
import (
"crypto/sha256"
"encoding/hex"
"sync"
)
// HTMLCache manages the cached index.html with injected settings
type HTMLCache struct {
mu sync.RWMutex
cachedHTML []byte
etag string
baseHTMLHash string // Hash of the original index.html (immutable after build)
settingsVersion uint64 // Incremented when settings change
}
// CachedHTML represents the cache state
type CachedHTML struct {
Content []byte
ETag string
}
// NewHTMLCache creates a new HTML cache instance
func NewHTMLCache() *HTMLCache {
return &HTMLCache{}
}
// SetBaseHTML initializes the cache with the base HTML template
func (c *HTMLCache) SetBaseHTML(baseHTML []byte) {
c.mu.Lock()
defer c.mu.Unlock()
hash := sha256.Sum256(baseHTML)
c.baseHTMLHash = hex.EncodeToString(hash[:8]) // First 8 bytes for brevity
}
// Invalidate marks the cache as stale
func (c *HTMLCache) Invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.settingsVersion++
c.cachedHTML = nil
c.etag = ""
}
// Get returns the cached HTML or nil if cache is stale
func (c *HTMLCache) Get() *CachedHTML {
c.mu.RLock()
defer c.mu.RUnlock()
if c.cachedHTML == nil {
return nil
}
return &CachedHTML{
Content: c.cachedHTML,
ETag: c.etag,
}
}
// Set updates the cache with new rendered HTML
func (c *HTMLCache) Set(html []byte, settingsJSON []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.cachedHTML = html
c.etag = c.generateETag(settingsJSON)
}
// generateETag creates an ETag from base HTML hash + settings hash
func (c *HTMLCache) generateETag(settingsJSON []byte) string {
settingsHash := sha256.Sum256(settingsJSON)
return `"` + c.baseHTMLHash + "-" + hex.EncodeToString(settingsHash[:8]) + `"`
}
...@@ -22,6 +22,7 @@ export interface SystemSettings { ...@@ -22,6 +22,7 @@ export interface SystemSettings {
api_base_url: string api_base_url: string
contact_info: string contact_info: string
doc_url: string doc_url: string
home_content: string
// SMTP settings // SMTP settings
smtp_host: string smtp_host: string
smtp_port: number smtp_port: number
...@@ -55,6 +56,7 @@ export interface UpdateSettingsRequest { ...@@ -55,6 +56,7 @@ export interface UpdateSettingsRequest {
api_base_url?: string api_base_url?: string
contact_info?: string contact_info?: string
doc_url?: string doc_url?: string
home_content?: string
smtp_host?: string smtp_host?: string
smtp_port?: number smtp_port?: number
smtp_username?: string smtp_username?: string
......
...@@ -1900,7 +1900,11 @@ export default { ...@@ -1900,7 +1900,11 @@ export default {
logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.', logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.',
logoSizeError: 'Image size exceeds 300KB limit ({size}KB)', logoSizeError: 'Image size exceeds 300KB limit ({size}KB)',
logoTypeError: 'Please select an image file', logoTypeError: 'Please select an image file',
logoReadError: 'Failed to read the image file' logoReadError: 'Failed to read the image file',
homeContent: 'Home Page Content',
homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.',
homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.',
homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.'
}, },
smtp: { smtp: {
title: 'SMTP Settings', title: 'SMTP Settings',
......
...@@ -2043,7 +2043,11 @@ export default { ...@@ -2043,7 +2043,11 @@ export default {
logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。', logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。',
logoSizeError: '图片大小超过 300KB 限制({size}KB)', logoSizeError: '图片大小超过 300KB 限制({size}KB)',
logoTypeError: '请选择图片文件', logoTypeError: '请选择图片文件',
logoReadError: '读取图片文件失败' logoReadError: '读取图片文件失败',
homeContent: '首页内容',
homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。'
}, },
smtp: { smtp: {
title: 'SMTP 设置', title: 'SMTP 设置',
......
...@@ -6,7 +6,20 @@ import i18n from './i18n' ...@@ -6,7 +6,20 @@ import i18n from './i18n'
import './style.css' import './style.css'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) const pinia = createPinia()
app.use(pinia)
// Initialize settings from injected config BEFORE mounting (prevents flash)
// This must happen after pinia is installed but before router and i18n
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
appStore.initFromInjectedConfig()
// Set document title immediately after config is loaded
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
document.title = `${appStore.siteName} - AI API Gateway`
}
app.use(router) app.use(router)
app.use(i18n) app.use(i18n)
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
/** /**
* Route definitions with lazy loading * Route definitions with lazy loading
...@@ -323,10 +324,12 @@ router.beforeEach((to, _from, next) => { ...@@ -323,10 +324,12 @@ router.beforeEach((to, _from, next) => {
} }
// Set page title // Set page title
const appStore = useAppStore()
const siteName = appStore.siteName || 'Sub2API'
if (to.meta.title) { if (to.meta.title) {
document.title = `${to.meta.title} - Sub2API` document.title = `${to.meta.title} - ${siteName}`
} else { } else {
document.title = 'Sub2API' document.title = siteName
} }
// Check if route requires authentication // Check if route requires authentication
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment