security: stop tracking env files; admin gate via VITE_SITE_ADMIN_GATE; Redis/cache/docs

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
This commit is contained in:
2026-04-03 16:10:12 +08:00
parent 284b5a5260
commit 6b3fcc1791
25 changed files with 1078 additions and 972 deletions

View File

@@ -1,11 +1,13 @@
package handler
import (
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"infogenie-backend/internal/cache"
"infogenie-backend/internal/database"
"infogenie-backend/internal/model"
)
@@ -43,6 +45,19 @@ func (h *SiteConfigHandler) GetFeatureCardClicks(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_section"})
return
}
ctx := c.Request.Context()
key := cache.KeySiteFeatureClicks(section)
var cached struct {
Section string `json:"section"`
Counts map[string]uint64 `json:"counts"`
}
if hit, err := cache.GetJSON(ctx, key, &cached); err != nil {
log.Printf("redis GET %s: %v", key, err)
} else if hit {
c.JSON(http.StatusOK, gin.H{"section": cached.Section, "counts": cached.Counts})
return
}
var rows []model.SiteFeatureCardClick
if err := database.DB.Where("section = ?", section).Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
@@ -52,7 +67,11 @@ func (h *SiteConfigHandler) GetFeatureCardClicks(c *gin.Context) {
for _, r := range rows {
counts[r.ItemID] = r.ClickCount
}
c.JSON(http.StatusOK, gin.H{"section": section, "counts": counts})
payload := gin.H{"section": section, "counts": counts}
if err := cache.SetJSON(ctx, key, payload, 0); err != nil {
log.Printf("redis SET %s: %v", key, err)
}
c.JSON(http.StatusOK, payload)
}
type postFeatureCardClickBody struct {
@@ -91,5 +110,6 @@ ON DUPLICATE KEY UPDATE click_count = click_count + 1, updated_at = NOW()`
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
cache.Delete(c.Request.Context(), cache.KeySiteFeatureClicks(section))
c.JSON(http.StatusOK, gin.H{"section": section, "item_id": itemID, "count": row.ClickCount})
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"crypto/subtle"
"errors"
"log"
"net/http"
"strings"
@@ -10,6 +11,7 @@ import (
"gorm.io/gorm"
"infogenie-backend/config"
"infogenie-backend/internal/cache"
"infogenie-backend/internal/database"
"infogenie-backend/internal/model"
)
@@ -31,6 +33,17 @@ func siteAdminTokenOK(headerToken string) bool {
// 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"})
@@ -40,7 +53,11 @@ func (h *SiteConfigHandler) Get60sDisabled(c *gin.Context) {
for _, r := range rows {
ids = append(ids, r.FeatureID)
}
c.JSON(http.StatusOK, gin.H{"disabled": ids})
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 {
@@ -102,6 +119,7 @@ func (h *SiteConfigHandler) Put60sDisabled(c *gin.Context) {
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)})
}
@@ -146,14 +164,35 @@ func EffectiveSixtyUpstream(db *gorm.DB) (sourceID string, base string, label st
// 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)
c.JSON(http.StatusOK, gin.H{
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 {
@@ -195,6 +234,7 @@ func (h *SiteConfigHandler) Put60sSource(c *gin.Context) {
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})
}
@@ -203,6 +243,17 @@ func (h *SiteConfigHandler) Put60sSource(c *gin.Context) {
// 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"})
@@ -212,7 +263,11 @@ func (h *SiteConfigHandler) GetAIModelDisabled(c *gin.Context) {
for _, r := range rows {
ids = append(ids, r.AppID)
}
c.JSON(http.StatusOK, gin.H{"disabled": ids})
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 {
@@ -274,5 +329,63 @@ func (h *SiteConfigHandler) PutAIModelDisabled(c *gin.Context) {
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) != "",
},
})
}