Remove InfoGenie-frontend and Go .env from version control; add .env.example templates; ignore .claude local settings. Admin UI reads site gate from env only. Note: rotate secrets if repo history was ever public. Made-with: Cursor
392 lines
12 KiB
Go
392 lines
12 KiB
Go
package handler
|
||
|
||
import (
|
||
"crypto/subtle"
|
||
"errors"
|
||
"log"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
|
||
"infogenie-backend/config"
|
||
"infogenie-backend/internal/cache"
|
||
"infogenie-backend/internal/database"
|
||
"infogenie-backend/internal/model"
|
||
)
|
||
|
||
type SiteConfigHandler struct{}
|
||
|
||
func NewSiteConfigHandler() *SiteConfigHandler { return &SiteConfigHandler{} }
|
||
|
||
func siteAdminTokenOK(headerToken string) bool {
|
||
if config.Cfg == nil {
|
||
return false
|
||
}
|
||
expected := strings.TrimSpace(config.Cfg.SiteAdminToken)
|
||
if expected == "" {
|
||
return false
|
||
}
|
||
return subtle.ConstantTimeCompare([]byte(headerToken), []byte(expected)) == 1
|
||
}
|
||
|
||
// Get60sDisabled 公开:返回当前隐藏的 60s 功能 id 列表(与前端 item.id 对应)
|
||
func (h *SiteConfigHandler) Get60sDisabled(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
var cached struct {
|
||
Disabled []string `json:"disabled"`
|
||
}
|
||
if hit, err := cache.GetJSON(ctx, cache.KeySite60sDisabled, &cached); err != nil {
|
||
log.Printf("redis GET %s: %v", cache.KeySite60sDisabled, err)
|
||
} else if hit {
|
||
c.JSON(http.StatusOK, gin.H{"disabled": cached.Disabled})
|
||
return
|
||
}
|
||
|
||
var rows []model.Site60sDisabled
|
||
if err := database.DB.Order("feature_id").Find(&rows).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||
return
|
||
}
|
||
ids := make([]string, 0, len(rows))
|
||
for _, r := range rows {
|
||
ids = append(ids, r.FeatureID)
|
||
}
|
||
payload := gin.H{"disabled": ids}
|
||
if err := cache.SetJSON(ctx, cache.KeySite60sDisabled, payload, 0); err != nil {
|
||
log.Printf("redis SET %s: %v", cache.KeySite60sDisabled, err)
|
||
}
|
||
c.JSON(http.StatusOK, payload)
|
||
}
|
||
|
||
type put60sDisabledBody struct {
|
||
Disabled []string `json:"disabled"`
|
||
}
|
||
|
||
// Put60sDisabled 需请求头 X-Site-Admin-Token,与后端环境变量 INFOGENIE_SITE_ADMIN_TOKEN 一致(建议与前端管理员口令相同)
|
||
func (h *SiteConfigHandler) Put60sDisabled(c *gin.Context) {
|
||
if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" {
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||
"error": "admin_not_configured",
|
||
"message": "服务端未配置 INFOGENIE_SITE_ADMIN_TOKEN,禁止写入",
|
||
})
|
||
return
|
||
}
|
||
if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden", "message": "站点管理员令牌无效"})
|
||
return
|
||
}
|
||
|
||
var body put60sDisabledBody
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
|
||
return
|
||
}
|
||
if len(body.Disabled) > 512 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "too_many"})
|
||
return
|
||
}
|
||
|
||
seen := make(map[string]struct{})
|
||
clean := make([]string, 0, len(body.Disabled))
|
||
for _, raw := range body.Disabled {
|
||
id := strings.TrimSpace(raw)
|
||
if id == "" || len(id) > 96 {
|
||
continue
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
clean = append(clean, id)
|
||
}
|
||
|
||
tx := database.DB.Begin()
|
||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Site60sDisabled{}).Error; err != nil {
|
||
tx.Rollback()
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||
return
|
||
}
|
||
for _, id := range clean {
|
||
if err := tx.Create(&model.Site60sDisabled{FeatureID: id}).Error; err != nil {
|
||
tx.Rollback()
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||
return
|
||
}
|
||
}
|
||
if err := tx.Commit().Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||
return
|
||
}
|
||
cache.Delete(c.Request.Context(), cache.KeySite60sDisabled)
|
||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(clean)})
|
||
}
|
||
|
||
// —— 60s API 上游节点(仅管理员可切换)——
|
||
|
||
type sixtySrcInfo struct {
|
||
Base string
|
||
Label string
|
||
}
|
||
|
||
var sixtyUpstreamRegistry = map[string]sixtySrcInfo{
|
||
"self": {Base: "https://60s.api.shumengya.top", Label: "萌芽节点"},
|
||
"official": {Base: "https://60s.viki.moe", Label: "官方节点"},
|
||
}
|
||
|
||
func resolve60sUpstream(sourceID string) (id string, info sixtySrcInfo) {
|
||
id = strings.TrimSpace(sourceID)
|
||
if id == "" {
|
||
id = "self"
|
||
}
|
||
var ok bool
|
||
info, ok = sixtyUpstreamRegistry[id]
|
||
if !ok {
|
||
id = "self"
|
||
info = sixtyUpstreamRegistry["self"]
|
||
}
|
||
return id, info
|
||
}
|
||
|
||
// EffectiveSixtyUpstream 返回当前站点配置的 60s API 根地址(读库;无记录或库不可用则回落默认 self)
|
||
func EffectiveSixtyUpstream(db *gorm.DB) (sourceID string, base string, label string) {
|
||
sid := ""
|
||
if db != nil {
|
||
var row model.Site60sUpstream
|
||
if err := db.First(&row, 1).Error; err == nil {
|
||
sid = row.SourceID
|
||
}
|
||
}
|
||
id, info := resolve60sUpstream(sid)
|
||
return id, info.Base, info.Label
|
||
}
|
||
|
||
// Get60sSource 公开:当前站点使用的 60s 上游 base_url(供静态页 iframe 传参)
|
||
func (h *SiteConfigHandler) Get60sSource(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
var cached struct {
|
||
SourceID string `json:"source_id"`
|
||
BaseURL string `json:"base_url"`
|
||
Label string `json:"label"`
|
||
}
|
||
if hit, err := cache.GetJSON(ctx, cache.KeySite60sSource, &cached); err != nil {
|
||
log.Printf("redis GET %s: %v", cache.KeySite60sSource, err)
|
||
} else if hit {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"source_id": cached.SourceID,
|
||
"base_url": cached.BaseURL,
|
||
"label": cached.Label,
|
||
})
|
||
return
|
||
}
|
||
|
||
var row model.Site60sUpstream
|
||
_ = database.DB.First(&row, 1).Error
|
||
sid, info := resolve60sUpstream(row.SourceID)
|
||
payload := gin.H{
|
||
"source_id": sid,
|
||
"base_url": info.Base,
|
||
"label": info.Label,
|
||
}
|
||
if err := cache.SetJSON(ctx, cache.KeySite60sSource, payload, 0); err != nil {
|
||
log.Printf("redis SET %s: %v", cache.KeySite60sSource, err)
|
||
}
|
||
c.JSON(http.StatusOK, payload)
|
||
}
|
||
|
||
type put60sSourceBody struct {
|
||
SourceID string `json:"source_id"`
|
||
}
|
||
|
||
// Put60sSource 管理员切换节点:source_id 为 self | official
|
||
func (h *SiteConfigHandler) Put60sSource(c *gin.Context) {
|
||
if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" {
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "admin_not_configured"})
|
||
return
|
||
}
|
||
if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||
return
|
||
}
|
||
var body put60sSourceBody
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
|
||
return
|
||
}
|
||
sid := strings.TrimSpace(body.SourceID)
|
||
if _, ok := sixtyUpstreamRegistry[sid]; !ok {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_source_id"})
|
||
return
|
||
}
|
||
var row model.Site60sUpstream
|
||
err := database.DB.First(&row, 1).Error
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
row = model.Site60sUpstream{ID: 1}
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||
return
|
||
}
|
||
}
|
||
row.SourceID = sid
|
||
if err := database.DB.Save(&row).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||
return
|
||
}
|
||
cache.Delete(c.Request.Context(), cache.KeySite60sSource)
|
||
_, info := resolve60sUpstream(sid)
|
||
c.JSON(http.StatusOK, gin.H{"ok": true, "source_id": sid, "base_url": info.Base, "label": info.Label})
|
||
}
|
||
|
||
// —— AI 应用可见性控制(仅管理员可配置)——
|
||
|
||
// GetAIModelDisabled 公开:返回当前隐藏的 AI 应用 id 列表(与前端 StaticPageConfig 中 AI_MODEL_APPS 的索引对应)
|
||
func (h *SiteConfigHandler) GetAIModelDisabled(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
var cached struct {
|
||
Disabled []string `json:"disabled"`
|
||
}
|
||
if hit, err := cache.GetJSON(ctx, cache.KeySiteAIModelDisabled, &cached); err != nil {
|
||
log.Printf("redis GET %s: %v", cache.KeySiteAIModelDisabled, err)
|
||
} else if hit {
|
||
c.JSON(http.StatusOK, gin.H{"disabled": cached.Disabled})
|
||
return
|
||
}
|
||
|
||
var rows []model.SiteAIModelDisabled
|
||
if err := database.DB.Order("app_id").Find(&rows).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||
return
|
||
}
|
||
ids := make([]string, 0, len(rows))
|
||
for _, r := range rows {
|
||
ids = append(ids, r.AppID)
|
||
}
|
||
payload := gin.H{"disabled": ids}
|
||
if err := cache.SetJSON(ctx, cache.KeySiteAIModelDisabled, payload, 0); err != nil {
|
||
log.Printf("redis SET %s: %v", cache.KeySiteAIModelDisabled, err)
|
||
}
|
||
c.JSON(http.StatusOK, payload)
|
||
}
|
||
|
||
type putAIModelDisabledBody struct {
|
||
Disabled []string `json:"disabled"`
|
||
}
|
||
|
||
// PutAIModelDisabled 需请求头 X-Site-Admin-Token,与后端环境变量 INFOGENIE_SITE_ADMIN_TOKEN 一致
|
||
func (h *SiteConfigHandler) PutAIModelDisabled(c *gin.Context) {
|
||
if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" {
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||
"error": "admin_not_configured",
|
||
"message": "服务端未配置 INFOGENIE_SITE_ADMIN_TOKEN,禁止写入",
|
||
})
|
||
return
|
||
}
|
||
if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden", "message": "站点管理员令牌无效"})
|
||
return
|
||
}
|
||
|
||
var body putAIModelDisabledBody
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
|
||
return
|
||
}
|
||
if len(body.Disabled) > 64 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "too_many"})
|
||
return
|
||
}
|
||
|
||
seen := make(map[string]struct{})
|
||
clean := make([]string, 0, len(body.Disabled))
|
||
for _, raw := range body.Disabled {
|
||
id := strings.TrimSpace(raw)
|
||
if id == "" || len(id) > 96 {
|
||
continue
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
clean = append(clean, id)
|
||
}
|
||
|
||
tx := database.DB.Begin()
|
||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.SiteAIModelDisabled{}).Error; err != nil {
|
||
tx.Rollback()
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||
return
|
||
}
|
||
for _, id := range clean {
|
||
if err := tx.Create(&model.SiteAIModelDisabled{AppID: id}).Error; err != nil {
|
||
tx.Rollback()
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||
return
|
||
}
|
||
}
|
||
if err := tx.Commit().Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||
return
|
||
}
|
||
cache.Delete(c.Request.Context(), cache.KeySiteAIModelDisabled)
|
||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(clean)})
|
||
}
|
||
|
||
// GetDiagnostics 返回当前进程解析到的连接配置摘要(不含密码/密钥明文),需 X-Site-Admin-Token
|
||
func (h *SiteConfigHandler) GetDiagnostics(c *gin.Context) {
|
||
if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" {
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||
"error": "admin_not_configured",
|
||
"message": "服务端未配置 INFOGENIE_SITE_ADMIN_TOKEN",
|
||
})
|
||
return
|
||
}
|
||
if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||
return
|
||
}
|
||
cfg := config.Cfg
|
||
|
||
sixtyID, sixtyBase, sixtyLabel := EffectiveSixtyUpstream(database.DB)
|
||
|
||
redisOut := gin.H{"enabled": cfg.Redis.Enabled}
|
||
if cfg.Redis.Enabled {
|
||
redisOut["addr"] = cfg.Redis.Addr
|
||
redisOut["logical_db"] = cfg.Redis.DB
|
||
redisOut["key_prefix"] = cfg.Redis.KeyPrefix
|
||
redisOut["site_cache_ttl_sec"] = int(cfg.Redis.SiteTTL.Seconds())
|
||
redisOut["password_configured"] = strings.TrimSpace(cfg.Redis.Password) != ""
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"app": gin.H{
|
||
"env": cfg.Env,
|
||
"listen_addr": "0.0.0.0:" + cfg.Port,
|
||
"listen_port": cfg.Port,
|
||
},
|
||
"mysql": gin.H{
|
||
"host": cfg.DB.Host,
|
||
"port": cfg.DB.Port,
|
||
"database": cfg.DB.Name,
|
||
"user": cfg.DB.User,
|
||
"password_configured": strings.TrimSpace(cfg.DB.Password) != "",
|
||
},
|
||
"redis": redisOut,
|
||
"sixty_upstream": gin.H{
|
||
"source_id": sixtyID,
|
||
"base_url": sixtyBase,
|
||
"label": sixtyLabel,
|
||
},
|
||
"auth_center": gin.H{
|
||
"api_url": cfg.AuthCenter.APIURL,
|
||
},
|
||
"mail": gin.H{
|
||
"host": cfg.Mail.Host,
|
||
"port": cfg.Mail.Port,
|
||
"username": cfg.Mail.Username,
|
||
"password_configured": strings.TrimSpace(cfg.Mail.Password) != "",
|
||
},
|
||
})
|
||
}
|