diff --git a/internal/analytics/umami.go b/internal/analytics/umami.go new file mode 100644 index 0000000..4875499 --- /dev/null +++ b/internal/analytics/umami.go @@ -0,0 +1,188 @@ +package analytics + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type Config struct { + Mode string + APIURL string + Credential string + WebsiteID string +} + +type Stats struct { + Pageviews int + Visitors int + Visits int + Bounces int + TotalTime int64 +} + +type Point struct { + X string `json:"x"` + Y int `json:"y"` +} + +type Client struct { + httpClient *http.Client +} + +func NewClient() *Client { + return &Client{httpClient: &http.Client{Timeout: 5 * time.Second}} +} + +func (c *Client) GetStats(ctx context.Context, cfg Config, startAt, endAt time.Time) (Stats, error) { + var response struct { + Pageviews json.RawMessage `json:"pageviews"` + Visitors json.RawMessage `json:"visitors"` + Visits json.RawMessage `json:"visits"` + Bounces json.RawMessage `json:"bounces"` + TotalTime json.RawMessage `json:"totaltime"` + } + if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/stats", startAt, endAt, "", &response); err != nil { + return Stats{}, err + } + return Stats{ + Pageviews: rawNumber(response.Pageviews), + Visitors: rawNumber(response.Visitors), + Visits: rawNumber(response.Visits), + Bounces: rawNumber(response.Bounces), + TotalTime: int64(rawNumber(response.TotalTime)), + }, nil +} + +func (c *Client) GetActive(ctx context.Context, cfg Config) (int, error) { + var response struct { + Visitors int `json:"visitors"` + } + if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/active", time.Time{}, time.Time{}, "", &response); err != nil { + return 0, err + } + return response.Visitors, nil +} + +func (c *Client) GetPageviews(ctx context.Context, cfg Config, startAt, endAt time.Time) ([]Point, error) { + var response struct { + Pageviews []Point `json:"pageviews"` + } + if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/pageviews", startAt, endAt, "day", &response); err != nil { + return nil, err + } + return response.Pageviews, nil +} + +func (c *Client) GetMetrics(ctx context.Context, cfg Config, startAt, endAt time.Time, metricType string) ([]Point, error) { + var response []Point + if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/metrics", startAt, endAt, metricType, &response); err != nil { + return nil, err + } + return response, nil +} + +func (c *Client) Test(ctx context.Context, cfg Config) error { + if strings.TrimSpace(cfg.WebsiteID) == "" { + return errors.New("Umami Website ID 不能为空") + } + if strings.TrimSpace(cfg.Credential) == "" { + return errors.New("Umami API 凭据未配置") + } + _, err := c.GetStats(ctx, cfg, time.Now().Add(-24*time.Hour), time.Now()) + return err +} + +func (c *Client) get(ctx context.Context, cfg Config, path string, startAt, endAt time.Time, unitOrType string, target any) error { + baseURL, err := normalizeBaseURL(cfg.Mode, cfg.APIURL) + if err != nil { + return err + } + requestURL, err := url.Parse(baseURL + path) + if err != nil { + return errors.New("Umami API 地址无效") + } + query := requestURL.Query() + if !startAt.IsZero() { + query.Set("startAt", strconv.FormatInt(startAt.UnixMilli(), 10)) + } + if !endAt.IsZero() { + query.Set("endAt", strconv.FormatInt(endAt.UnixMilli(), 10)) + } + if strings.HasSuffix(path, "/pageviews") && unitOrType != "" { + query.Set("unit", unitOrType) + } + if strings.HasSuffix(path, "/metrics") && unitOrType != "" { + query.Set("type", unitOrType) + } + requestURL.RawQuery = query.Encode() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil) + if err != nil { + return errors.New("创建 Umami 请求失败") + } + request.Header.Set("Accept", "application/json") + if cfg.Mode == "cloud" { + request.Header.Set("x-umami-api-key", cfg.Credential) + } else { + request.Header.Set("Authorization", "Bearer "+cfg.Credential) + } + response, err := c.httpClient.Do(request) + if err != nil { + return fmt.Errorf("Umami 请求失败: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("Umami 返回 HTTP %d", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20)) + if err != nil { + return errors.New("读取 Umami 响应失败") + } + if err := json.Unmarshal(body, target); err != nil { + return errors.New("解析 Umami 响应失败") + } + return nil +} + +func normalizeBaseURL(mode, raw string) (string, error) { + base := strings.TrimRight(strings.TrimSpace(raw), "/") + if base == "" && mode == "cloud" { + base = "https://api.umami.is/v1" + } + if base == "" { + return "", errors.New("Umami API 地址不能为空") + } + parsed, err := url.Parse(base) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return "", errors.New("Umami API 地址必须是有效的 http/https 地址") + } + if mode == "cloud" && !strings.HasSuffix(parsed.Path, "/v1") { + base += "/v1" + } + return strings.TrimRight(base, "/"), nil +} + +func rawNumber(raw json.RawMessage) int { + if len(raw) == 0 || string(raw) == "null" { + return 0 + } + var number float64 + if json.Unmarshal(raw, &number) == nil { + return int(number) + } + var wrapped struct { + Value float64 `json:"value"` + } + if json.Unmarshal(raw, &wrapped) == nil { + return int(wrapped.Value) + } + return 0 +} diff --git a/internal/analytics/umami_test.go b/internal/analytics/umami_test.go new file mode 100644 index 0000000..0e22b03 --- /dev/null +++ b/internal/analytics/umami_test.go @@ -0,0 +1,56 @@ +package analytics + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestSelfHostedStatsAndPageviews(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer self-secret" { + t.Errorf("missing self-hosted auth header: %q", r.Header.Get("Authorization")) + } + if strings.HasSuffix(r.URL.Path, "/pageviews") && r.URL.Query().Get("unit") != "day" { + t.Errorf("missing pageviews unit: %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/pageviews") { + _, _ = w.Write([]byte(`{"pageviews":[{"x":"8/5","y":4}]}`)) + return + } + _, _ = w.Write([]byte(`{"pageviews":{"value":12},"visitors":3,"visits":5,"bounces":1,"totaltime":90}`)) + })) + defer server.Close() + + client := NewClient() + cfg := Config{Mode: "selfhost", APIURL: server.URL, Credential: "self-secret", WebsiteID: "website"} + stats, err := client.GetStats(context.Background(), cfg, time.Unix(0, 0), time.Now()) + if err != nil || stats.Pageviews != 12 || stats.Visitors != 3 { + t.Fatalf("unexpected stats: %+v, %v", stats, err) + } + points, err := client.GetPageviews(context.Background(), cfg, time.Now().Add(-24*time.Hour), time.Now()) + if err != nil || len(points) != 1 || points[0].Y != 4 { + t.Fatalf("unexpected pageviews: %+v, %v", points, err) + } +} + +func TestCloudUsesAPIKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-umami-api-key") != "cloud-secret" || r.Header.Get("Authorization") != "" { + t.Errorf("unexpected cloud auth headers: api-key=%q authorization=%q", r.Header.Get("x-umami-api-key"), r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"visitors":1}`)) + })) + defer server.Close() + + client := NewClient() + _, err := client.GetStats(context.Background(), Config{Mode: "cloud", APIURL: server.URL + "/v1", Credential: "cloud-secret", WebsiteID: "website"}, time.Time{}, time.Time{}) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/api/analytics.go b/internal/api/analytics.go new file mode 100644 index 0000000..4107551 --- /dev/null +++ b/internal/api/analytics.go @@ -0,0 +1,62 @@ +package api + +import ( + "errors" + "io" + "net/http" + "strings" + + "home-vue-go/internal/analytics" + "home-vue-go/internal/config" + "home-vue-go/internal/database" + + "github.com/gin-gonic/gin" +) + +func TestUmamiConnection(db *database.Database, appConfig *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + var req struct { + Mode *string `json:"mode"` + APIURL *string `json:"apiUrl"` + WebsiteID *string `json:"websiteId"` + Credential *string `json:"credential"` + } + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{"error": "测试参数格式错误"}) + return + } + settings, err := db.LoadSiteSettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"}) + return + } + credential := "" + if req.Credential != nil { + credential = strings.TrimSpace(*req.Credential) + } else if settings.UmamiCredential != "" && appConfig != nil { + credential, err = appConfig.DecryptSecret(settings.UmamiCredential) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "已保存的统计凭据无法解密"}) + return + } + } + mode := settings.UmamiAPIMode + apiURL := settings.UmamiAPIURL + websiteID := settings.UmamiWebsiteID + if req.Mode != nil { + mode = strings.TrimSpace(*req.Mode) + } + if req.APIURL != nil { + apiURL = strings.TrimSpace(*req.APIURL) + } + if req.WebsiteID != nil { + websiteID = strings.TrimSpace(*req.WebsiteID) + } + clientConfig := analytics.Config{Mode: mode, APIURL: apiURL, Credential: credential, WebsiteID: websiteID} + if err := umamiClient.Test(c.Request.Context(), clientConfig); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "Umami连接失败,请检查地址、Website ID 和凭据"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "message": "Umami连接成功"}) + } +} diff --git a/internal/api/config.go b/internal/api/config.go index 08b7a28..f429ae1 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -1,7 +1,10 @@ package api import ( + "fmt" "net/http" + "net/url" + "strings" "home-vue-go/internal/config" "home-vue-go/internal/database" @@ -9,271 +12,294 @@ import ( "github.com/gin-gonic/gin" ) -// GetSiteConfig 获取站点配置(含底部年份配置) -func GetSiteConfig(db *database.Database, cfg *config.Config) gin.HandlerFunc { +type updateSiteConfigRequest struct { + SiteName *string `json:"siteName"` + SiteURL *string `json:"siteURL"` + SiteIcon *string `json:"siteIcon"` + SiteDescription *string `json:"siteDescription"` + SiteKeywords *string `json:"siteKeywords"` + UserName *string `json:"userName"` + ProfileImageURL *string `json:"profileImageURL"` + ICPNumber *string `json:"icpNumber"` + PoliceNumber *string `json:"policeNumber"` + PageTitle *string `json:"pageTitle"` + Favicon *string `json:"favicon"` + IconLibrary *string `json:"iconLibrary"` + FontLibrary *string `json:"fontLibrary"` + + FooterYearStart *string `json:"footerYearStart"` + FooterYearEnd *string `json:"footerYearEnd"` + ShowVisitTimer *bool `json:"showVisitTimer"` + RotatingTexts *[]string `json:"rotatingTexts"` + + GreetingText *string `json:"greetingText"` + OnlineStatusText *string `json:"onlineStatusText"` + FooterLabel *string `json:"footerLabel"` + ShowAbout *bool `json:"showAbout"` + ShowSites *bool `json:"showSites"` + ShowContacts *bool `json:"showContacts"` + ShowThemeToggle *bool `json:"showThemeToggle"` + ShowFooter *bool `json:"showFooter"` + AboutTitle *string `json:"aboutTitle"` + AboutDescription *string `json:"aboutDescription"` + AboutLinks *[]config.AboutLink `json:"aboutLinks"` + SitePageSize *int `json:"sitePageSize"` + OpenLinksNewTab *bool `json:"openLinksInNewTab"` + + AnalyticsProvider *string `json:"analyticsProvider"` + UmamiScript *string `json:"umamiScript"` + UmamiScriptURL *string `json:"umamiScriptUrl"` + UmamiWebsiteID *string `json:"umamiWebsiteId"` + UmamiAPIMode *string `json:"umamiApiMode"` + UmamiAPIURL *string `json:"umamiApiUrl"` + UmamiCredential *string `json:"umamiCredential"` + ClearUmamiCredential *bool `json:"clearUmamiCredential"` + UmamiDomains *string `json:"umamiDomains"` + UmamiDoNotTrack *bool `json:"umamiDoNotTrack"` + UmamiExcludeSearch *bool `json:"umamiExcludeSearch"` + UmamiExcludeHash *bool `json:"umamiExcludeHash"` + UmamiPerformance *bool `json:"umamiPerformance"` + UmamiTag *string `json:"umamiTag"` + UmamiTrackerOptions *struct { + Domains *string `json:"domains"` + DoNotTrack *bool `json:"doNotTrack"` + ExcludeSearch *bool `json:"excludeSearch"` + ExcludeHash *bool `json:"excludeHash"` + Performance *bool `json:"performance"` + Tag *string `json:"tag"` + } `json:"umamiTrackerOptions"` +} + +func GetSiteConfig(db *database.Database) gin.HandlerFunc { return func(c *gin.Context) { - ctx := c.Request.Context() - - footerCfg, _ := cfg.LoadFooterYear() - visitTimerCfg, _ := cfg.LoadVisitTimer() - - siteCfg, err := db.Client.SiteConfig.Get(ctx, 1) + settings, err := db.LoadSiteSettings(c.Request.Context()) if err != nil { - // 如果不存在,返回默认值 + 年份配置 - c.JSON(http.StatusOK, gin.H{ - "siteName": "个人主页", - "siteURL": "https://example.com", - "siteIcon": "/favicon.ico", - "siteDescription": "一个基于Vue3的个人主页", - "siteKeywords": "个人主页,Vue3", - "userName": "用户", - "profileImageURL": "", - "icpNumber": "暂未填写", - "policeNumber": "暂未填写", - "pageTitle": "个人主页", - "favicon": "/favicon.ico", - "umamiScript": "", - "umamiWebsiteId": "", - "iconLibrary": "//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css", - "fontLibrary": "", - "showVisitTimer": visitTimerCfg.ShowVisitTimer, - "footerYearStart": footerCfg.Start, - "footerYearEnd": footerCfg.End, - }) + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"}) return } - - c.JSON(http.StatusOK, gin.H{ - "siteName": siteCfg.SiteName, - "siteURL": siteCfg.SiteURL, - "siteIcon": siteCfg.SiteIcon, - "siteDescription": siteCfg.SiteDescription, - "siteKeywords": siteCfg.SiteKeywords, - "userName": siteCfg.UserName, - "profileImageURL": siteCfg.ProfileImageURL, - "icpNumber": siteCfg.IcpNumber, - "policeNumber": siteCfg.PoliceNumber, - "pageTitle": siteCfg.PageTitle, - "favicon": siteCfg.Favicon, - "umamiScript": siteCfg.UmamiScript, - "umamiWebsiteId": siteCfg.UmamiWebsiteID, - "iconLibrary": siteCfg.IconLibrary, - "fontLibrary": siteCfg.FontLibrary, - "showVisitTimer": visitTimerCfg.ShowVisitTimer, - "footerYearStart": footerCfg.Start, - "footerYearEnd": footerCfg.End, - }) + c.JSON(http.StatusOK, siteConfigResponse(settings, false)) + } +} + +func GetAdminSiteConfig(db *database.Database) gin.HandlerFunc { + return func(c *gin.Context) { + settings, err := db.LoadSiteSettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"}) + return + } + c.JSON(http.StatusOK, siteConfigResponse(settings, true)) } } -// UpdateSiteConfig 更新站点配置(含底部年份配置) func UpdateSiteConfig(db *database.Database, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { - var req struct { - SiteName string `json:"siteName"` - SiteURL string `json:"siteURL"` - SiteIcon string `json:"siteIcon"` - SiteDescription string `json:"siteDescription"` - SiteKeywords string `json:"siteKeywords"` - UserName string `json:"userName"` - ProfileImageURL string `json:"profileImageURL"` - ICPNumber string `json:"icpNumber"` - PoliceNumber string `json:"policeNumber"` - PageTitle string `json:"pageTitle"` - Favicon string `json:"favicon"` - UmamiScript string `json:"umamiScript"` - UmamiWebsiteId string `json:"umamiWebsiteId"` - IconLibrary string `json:"iconLibrary"` - FontLibrary string `json:"fontLibrary"` - ShowVisitTimer *bool `json:"showVisitTimer"` - - FooterYearStart string `json:"footerYearStart"` - FooterYearEnd string `json:"footerYearEnd"` + var req updateSiteConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "配置参数格式错误"}) + return } - if err := c.ShouldBindJSON(&req); err != nil { + ctx := c.Request.Context() + settings, err := db.LoadSiteSettings(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载现有站点配置失败"}) + return + } + next := settings.Clone() + applyString(&next.SiteName, req.SiteName) + applyString(&next.SiteURL, req.SiteURL) + applyString(&next.SiteIcon, req.SiteIcon) + applyString(&next.SiteDescription, req.SiteDescription) + applyString(&next.SiteKeywords, req.SiteKeywords) + applyString(&next.UserName, req.UserName) + applyString(&next.ProfileImageURL, req.ProfileImageURL) + applyString(&next.ICPNumber, req.ICPNumber) + applyString(&next.PoliceNumber, req.PoliceNumber) + applyString(&next.PageTitle, req.PageTitle) + applyString(&next.Favicon, req.Favicon) + applyString(&next.IconLibrary, req.IconLibrary) + applyString(&next.FontLibrary, req.FontLibrary) + applyString(&next.FooterYearStart, req.FooterYearStart) + applyString(&next.FooterYearEnd, req.FooterYearEnd) + applyBool(&next.ShowVisitTimer, req.ShowVisitTimer) + applySlice(&next.RotatingTexts, req.RotatingTexts) + applyString(&next.GreetingText, req.GreetingText) + applyString(&next.OnlineStatusText, req.OnlineStatusText) + applyString(&next.FooterLabel, req.FooterLabel) + applyBool(&next.ShowAbout, req.ShowAbout) + applyBool(&next.ShowSites, req.ShowSites) + applyBool(&next.ShowContacts, req.ShowContacts) + applyBool(&next.ShowThemeToggle, req.ShowThemeToggle) + applyBool(&next.ShowFooter, req.ShowFooter) + applyString(&next.AboutTitle, req.AboutTitle) + applyString(&next.AboutDescription, req.AboutDescription) + applySlice(&next.AboutLinks, req.AboutLinks) + applyInt(&next.SitePageSize, req.SitePageSize) + applyBool(&next.OpenLinksNewTab, req.OpenLinksNewTab) + applyString(&next.AnalyticsProvider, req.AnalyticsProvider) + applyString(&next.UmamiScript, req.UmamiScript) + applyString(&next.UmamiScript, req.UmamiScriptURL) + applyString(&next.UmamiWebsiteID, req.UmamiWebsiteID) + applyString(&next.UmamiAPIMode, req.UmamiAPIMode) + applyString(&next.UmamiAPIURL, req.UmamiAPIURL) + applyString(&next.UmamiDomains, req.UmamiDomains) + applyBool(&next.UmamiDoNotTrack, req.UmamiDoNotTrack) + applyBool(&next.UmamiExcludeSearch, req.UmamiExcludeSearch) + applyBool(&next.UmamiExcludeHash, req.UmamiExcludeHash) + applyBool(&next.UmamiPerformance, req.UmamiPerformance) + applyString(&next.UmamiTag, req.UmamiTag) + if options := req.UmamiTrackerOptions; options != nil { + applyString(&next.UmamiDomains, options.Domains) + applyBool(&next.UmamiDoNotTrack, options.DoNotTrack) + applyBool(&next.UmamiExcludeSearch, options.ExcludeSearch) + applyBool(&next.UmamiExcludeHash, options.ExcludeHash) + applyBool(&next.UmamiPerformance, options.Performance) + applyString(&next.UmamiTag, options.Tag) + } + + if req.ClearUmamiCredential != nil && *req.ClearUmamiCredential { + next.UmamiCredential = "" + } else if req.UmamiCredential != nil { + if strings.TrimSpace(*req.UmamiCredential) == "" { + next.UmamiCredential = "" + } else { + if cfg == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加密配置不可用"}) + return + } + encrypted, err := cfg.EncryptSecret(strings.TrimSpace(*req.UmamiCredential)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存统计凭据失败"}) + return + } + next.UmamiCredential = encrypted + } + } + + if err := validateSiteSettings(next); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - - ctx := c.Request.Context() - update := db.Client.SiteConfig.UpdateOneID(1) - - if req.SiteName != "" { - update.SetSiteName(req.SiteName) - } - if req.SiteURL != "" { - update.SetSiteURL(req.SiteURL) - } - if req.SiteIcon != "" { - update.SetSiteIcon(req.SiteIcon) - } - if req.SiteDescription != "" { - update.SetSiteDescription(req.SiteDescription) - } - if req.SiteKeywords != "" { - update.SetSiteKeywords(req.SiteKeywords) - } - if req.UserName != "" { - update.SetUserName(req.UserName) - } - if req.ProfileImageURL != "" { - update.SetProfileImageURL(req.ProfileImageURL) - } - if req.ICPNumber != "" { - update.SetIcpNumber(req.ICPNumber) - } - if req.PoliceNumber != "" { - update.SetPoliceNumber(req.PoliceNumber) - } - if req.PageTitle != "" { - update.SetPageTitle(req.PageTitle) - } - if req.Favicon != "" { - update.SetFavicon(req.Favicon) - } - if req.UmamiScript != "" { - update.SetUmamiScript(req.UmamiScript) - } - if req.UmamiWebsiteId != "" { - update.SetUmamiWebsiteID(req.UmamiWebsiteId) - } - if req.IconLibrary != "" { - update.SetIconLibrary(req.IconLibrary) - } - if req.FontLibrary != "" { - update.SetFontLibrary(req.FontLibrary) - } - - siteCfg, err := update.Save(ctx) - if err != nil { - // 如果不存在,创建新的 - create := db.Client.SiteConfig.Create(). - SetSiteName(req.SiteName). - SetSiteURL(req.SiteURL). - SetSiteIcon(req.SiteIcon). - SetSiteDescription(req.SiteDescription). - SetSiteKeywords(req.SiteKeywords). - SetUserName(req.UserName) - if req.ProfileImageURL != "" { - create.SetProfileImageURL(req.ProfileImageURL) - } - if req.ICPNumber != "" { - create.SetIcpNumber(req.ICPNumber) - } - if req.PoliceNumber != "" { - create.SetPoliceNumber(req.PoliceNumber) - } - if req.PageTitle != "" { - create.SetPageTitle(req.PageTitle) - } - if req.Favicon != "" { - create.SetFavicon(req.Favicon) - } - if req.UmamiScript != "" { - create.SetUmamiScript(req.UmamiScript) - } - if req.UmamiWebsiteId != "" { - create.SetUmamiWebsiteID(req.UmamiWebsiteId) - } - if req.IconLibrary != "" { - create.SetIconLibrary(req.IconLibrary) - } - if req.FontLibrary != "" { - create.SetFontLibrary(req.FontLibrary) - } - siteCfg, err = create.Save(ctx) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - // 保存底部年份配置到独立文件 - _ = cfg.SaveFooterYear(&config.FooterYearConfig{ - Start: req.FooterYearStart, - End: req.FooterYearEnd, - }) - - // 保存访问时间显示配置 - if req.ShowVisitTimer != nil { - _ = cfg.SaveVisitTimer(&config.VisitTimerConfig{ - ShowVisitTimer: *req.ShowVisitTimer, - }) - } - - // 读取最新的配置用于返回 - footerCfg, _ := cfg.LoadFooterYear() - visitTimerCfg, _ := cfg.LoadVisitTimer() - - c.JSON(http.StatusOK, gin.H{ - "siteName": siteCfg.SiteName, - "siteURL": siteCfg.SiteURL, - "siteIcon": siteCfg.SiteIcon, - "siteDescription": siteCfg.SiteDescription, - "siteKeywords": siteCfg.SiteKeywords, - "userName": siteCfg.UserName, - "profileImageURL": siteCfg.ProfileImageURL, - "icpNumber": siteCfg.IcpNumber, - "policeNumber": siteCfg.PoliceNumber, - "pageTitle": siteCfg.PageTitle, - "favicon": siteCfg.Favicon, - "umamiScript": siteCfg.UmamiScript, - "umamiWebsiteId": siteCfg.UmamiWebsiteID, - "iconLibrary": siteCfg.IconLibrary, - "fontLibrary": siteCfg.FontLibrary, - "showVisitTimer": visitTimerCfg.ShowVisitTimer, - "footerYearStart": footerCfg.Start, - "footerYearEnd": footerCfg.End, - }) - } -} - -// GetRotatingTexts 获取轮换文本配置 -func GetRotatingTexts(cfg *config.Config) gin.HandlerFunc { - return func(c *gin.Context) { - textsCfg, err := cfg.LoadRotatingTexts() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "加载轮换文本配置失败: " + err.Error()}) + if err := db.SaveSiteSettings(ctx, next); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点配置失败"}) return } - - c.JSON(http.StatusOK, gin.H{ - "texts": textsCfg.Texts, - }) + c.JSON(http.StatusOK, siteConfigResponse(next, true)) } } -// UpdateRotatingTexts 更新轮换文本配置 -func UpdateRotatingTexts(cfg *config.Config) gin.HandlerFunc { +func GetRotatingTexts(db *database.Database) gin.HandlerFunc { + return func(c *gin.Context) { + settings, err := db.LoadSiteSettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载轮换文本配置失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"texts": settings.RotatingTexts}) + } +} + +func UpdateRotatingTexts(db *database.Database) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Texts []string `json:"texts"` } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": "轮换文本参数格式错误"}) return } - - // 限制最多8条文本 - if len(req.Texts) > 8 { - req.Texts = req.Texts[:8] - } - - textsCfg := &config.RotatingTextsConfig{ - Texts: req.Texts, - } - - if err := cfg.SaveRotatingTexts(textsCfg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "保存轮换文本配置失败: " + err.Error()}) + settings, err := db.LoadSiteSettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"}) return } - - c.JSON(http.StatusOK, gin.H{ - "message": "轮换文本配置已保存", - "texts": textsCfg.Texts, - }) + settings.RotatingTexts = req.Texts + settings.Normalize() + if err := db.SaveSiteSettings(c.Request.Context(), settings); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存轮换文本配置失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "轮换文本配置已保存", "texts": settings.RotatingTexts}) } } +func siteConfigResponse(settings *config.SiteSettings, admin bool) gin.H { + result := gin.H{ + "siteName": settings.SiteName, "siteURL": settings.SiteURL, "siteIcon": settings.SiteIcon, + "siteDescription": settings.SiteDescription, "siteKeywords": settings.SiteKeywords, "userName": settings.UserName, + "profileImageURL": settings.ProfileImageURL, "icpNumber": settings.ICPNumber, "policeNumber": settings.PoliceNumber, + "pageTitle": settings.PageTitle, "favicon": settings.Favicon, "iconLibrary": settings.IconLibrary, "fontLibrary": settings.FontLibrary, + "footerYearStart": settings.FooterYearStart, "footerYearEnd": settings.FooterYearEnd, "showVisitTimer": settings.ShowVisitTimer, + "rotatingTexts": settings.RotatingTexts, "greetingText": settings.GreetingText, "onlineStatusText": settings.OnlineStatusText, + "footerLabel": settings.FooterLabel, "showAbout": settings.ShowAbout, "showSites": settings.ShowSites, "showContacts": settings.ShowContacts, + "showThemeToggle": settings.ShowThemeToggle, "showFooter": settings.ShowFooter, "aboutTitle": settings.AboutTitle, + "aboutDescription": settings.AboutDescription, "aboutLinks": settings.AboutLinks, "sitePageSize": settings.SitePageSize, + "openLinksInNewTab": settings.OpenLinksNewTab, "analyticsProvider": settings.AnalyticsProvider, "umamiScript": settings.UmamiScript, "umamiScriptUrl": settings.UmamiScript, + "umamiWebsiteId": settings.UmamiWebsiteID, "umamiDomains": settings.UmamiDomains, "umamiDoNotTrack": settings.UmamiDoNotTrack, + "umamiExcludeSearch": settings.UmamiExcludeSearch, "umamiExcludeHash": settings.UmamiExcludeHash, + "umamiPerformance": settings.UmamiPerformance, "umamiTag": settings.UmamiTag, + "umamiTrackerOptions": gin.H{"domains": settings.UmamiDomains, "doNotTrack": settings.UmamiDoNotTrack, "excludeSearch": settings.UmamiExcludeSearch, "excludeHash": settings.UmamiExcludeHash, "performance": settings.UmamiPerformance, "tag": settings.UmamiTag}, + } + if admin { + result["umamiApiMode"] = settings.UmamiAPIMode + result["umamiApiUrl"] = settings.UmamiAPIURL + result["umamiCredentialConfigured"] = settings.UmamiCredential != "" + } + return result +} + +func validateSiteSettings(settings *config.SiteSettings) error { + for name, value := range map[string]string{"站点URL": settings.SiteURL, "Umami脚本地址": settings.UmamiScript, "Umami API地址": settings.UmamiAPIURL} { + if value == "" { + continue + } + parsed, err := url.ParseRequestURI(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return fmt.Errorf("%s必须是有效的 http/https 地址", name) + } + } + if settings.SitePageSize != 6 && settings.SitePageSize != 9 && settings.SitePageSize != 12 { + return fmt.Errorf("站点每页数量只能是 6、9 或 12") + } + if settings.AnalyticsProvider != "local" && settings.AnalyticsProvider != "umami" { + return fmt.Errorf("统计来源只能是 local 或 umami") + } + if settings.UmamiAPIMode != "selfhost" && settings.UmamiAPIMode != "cloud" { + return fmt.Errorf("Umami API 模式不正确") + } + if len(settings.AboutLinks) > 8 { + return fmt.Errorf("关于链接最多支持 8 条") + } + for _, link := range settings.AboutLinks { + if strings.TrimSpace(link.URL) == "" { + return fmt.Errorf("关于链接地址不能为空") + } + parsed, err := url.ParseRequestURI(link.URL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return fmt.Errorf("关于链接必须是有效的 http/https 地址") + } + } + return nil +} + +func applyString(target *string, value *string) { + if value != nil { + *target = strings.TrimSpace(*value) + } +} +func applyBool(target *bool, value *bool) { + if value != nil { + *target = *value + } +} +func applyInt(target *int, value *int) { + if value != nil { + *target = *value + } +} +func applySlice[T any](target *[]T, value *[]T) { + if value != nil { + *target = *value + } +} diff --git a/internal/api/contacts.go b/internal/api/contacts.go index 3e1ec6b..862e496 100644 --- a/internal/api/contacts.go +++ b/internal/api/contacts.go @@ -3,8 +3,10 @@ package api import ( "net/http" "strconv" + "strings" "home-vue-go/internal/database" + "home-vue-go/internal/ent" "home-vue-go/internal/ent/contact" "github.com/gin-gonic/gin" @@ -22,13 +24,104 @@ func GetContacts(db *database.Database) gin.HandlerFunc { result := make([]gin.H, len(contacts)) for i, contact := range contacts { result[i] = gin.H{ - "id": contact.ID, - "type": contact.Type, - "icon": contact.Icon, - "url": contact.URL, - "qrCode": contact.QrCode, + "id": contact.ID, + "type": contact.Type, + "icon": contact.Icon, + "url": contact.URL, + "qrCode": contact.QrCode, "hoverColor": contact.HoverColor, - "sortOrder": contact.SortOrder, + "sortOrder": contact.SortOrder, + } + } + + c.JSON(http.StatusOK, result) + } +} + +func ReorderContacts(db *database.Database) gin.HandlerFunc { + return func(c *gin.Context) { + var req struct { + IDs []int `json:"ids" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表不能为空"}) + return + } + + seen := make(map[int]bool, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含无效ID"}) + return + } + if seen[id] { + c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含重复ID"}) + return + } + seen[id] = true + } + + ctx := c.Request.Context() + existingIDs, err := db.Client.Contact.Query().IDs(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if len(existingIDs) != len(req.IDs) { + c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表与当前联系方式数量不一致"}) + return + } + existing := make(map[int]bool, len(existingIDs)) + for _, id := range existingIDs { + existing[id] = true + } + for _, id := range req.IDs { + if !existing[id] { + c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含不存在的联系方式"}) + return + } + } + + tx, err := db.Client.Tx(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + for index, id := range req.IDs { + if _, err := tx.Contact.UpdateOneID(id).SetSortOrder((index + 1) * 10).Save(ctx); err != nil { + _ = tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + if err := tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + contacts, err := db.Client.Contact.Query().Order(contact.BySortOrder(), contact.ByID()).All(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + result := make([]gin.H, len(contacts)) + for i, contact := range contacts { + result[i] = gin.H{ + "id": contact.ID, + "type": contact.Type, + "icon": contact.Icon, + "url": contact.URL, + "qrCode": contact.QrCode, + "hoverColor": contact.HoverColor, + "sortOrder": contact.SortOrder, } } @@ -51,13 +144,31 @@ func CreateContact(db *database.Database) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + req.Type = strings.TrimSpace(req.Type) + req.Icon = strings.TrimSpace(req.Icon) + req.URL = strings.TrimSpace(req.URL) + req.QrCode = strings.TrimSpace(req.QrCode) + req.HoverColor = strings.TrimSpace(req.HoverColor) + if req.Type == "" || req.Icon == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "类型和图标不能为空"}) + return + } - // 验证Email类型必须是mailto格式 - if req.Type == "Email" && req.URL != "" { - if len(req.URL) < 7 || req.URL[:7] != "mailto:" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Email URL必须是mailto:格式"}) - return - } + if req.URL != "" && !validContactURL(req.URL) { + c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式URL必须是有效的http/https/mailto/tel地址"}) + return + } + if req.QrCode != "" && !validQRCode(req.QrCode) { + c.JSON(http.StatusBadRequest, gin.H{"error": "二维码必须使用本地上传路径或安全的http/https图片地址"}) + return + } + if req.URL != "" && req.QrCode != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式不能同时设置链接和二维码"}) + return + } + if req.URL == "" && req.QrCode == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "链接或二维码不能为空"}) + return } ctx := c.Request.Context() @@ -83,13 +194,13 @@ func CreateContact(db *database.Database) gin.HandlerFunc { } c.JSON(http.StatusOK, gin.H{ - "id": contact.ID, - "type": contact.Type, - "icon": contact.Icon, - "url": contact.URL, - "qrCode": contact.QrCode, + "id": contact.ID, + "type": contact.Type, + "icon": contact.Icon, + "url": contact.URL, + "qrCode": contact.QrCode, "hoverColor": contact.HoverColor, - "sortOrder": contact.SortOrder, + "sortOrder": contact.SortOrder, }) } } @@ -103,8 +214,8 @@ func UpdateContact(db *database.Database) gin.HandlerFunc { } var req struct { - Type string `json:"type"` - Icon string `json:"icon"` + Type string `json:"type" binding:"required"` + Icon string `json:"icon" binding:"required"` URL string `json:"url"` QrCode string `json:"qrCode"` HoverColor string `json:"hoverColor"` @@ -115,48 +226,72 @@ func UpdateContact(db *database.Database) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + req.Type = strings.TrimSpace(req.Type) + req.Icon = strings.TrimSpace(req.Icon) + req.URL = strings.TrimSpace(req.URL) + req.QrCode = strings.TrimSpace(req.QrCode) + req.HoverColor = strings.TrimSpace(req.HoverColor) + if req.Type == "" || req.Icon == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "类型和图标不能为空"}) + return + } - // 验证Email类型必须是mailto格式 - if req.Type == "Email" && req.URL != "" { - if len(req.URL) < 7 || req.URL[:7] != "mailto:" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Email URL必须是mailto:格式"}) - return - } + if req.URL != "" && !validContactURL(req.URL) { + c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式URL必须是有效的http/https/mailto/tel地址"}) + return + } + if req.QrCode != "" && !validQRCode(req.QrCode) { + c.JSON(http.StatusBadRequest, gin.H{"error": "二维码必须使用本地上传路径或安全的http/https图片地址"}) + return + } + if req.URL != "" && req.QrCode != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式不能同时设置链接和二维码"}) + return + } + if req.URL == "" && req.QrCode == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "链接或二维码不能为空"}) + return } ctx := c.Request.Context() update := db.Client.Contact.UpdateOneID(id) - if req.Type != "" { - update.SetType(req.Type) - } - if req.Icon != "" { - update.SetIcon(req.Icon) - } + update.SetType(req.Type) + update.SetIcon(req.Icon) if req.URL != "" { update.SetURL(req.URL) + } else { + update.ClearURL() } if req.QrCode != "" { update.SetQrCode(req.QrCode) + } else { + update.ClearQrCode() } if req.HoverColor != "" { update.SetHoverColor(req.HoverColor) + } else { + update.ClearHoverColor() } update.SetSortOrder(req.SortOrder) contact, err := update.Save(ctx) if err != nil { + if ent.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "联系方式不存在"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ - "id": contact.ID, - "type": contact.Type, - "icon": contact.Icon, - "url": contact.URL, - "qrCode": contact.QrCode, + "id": contact.ID, + "type": contact.Type, + "icon": contact.Icon, + "url": contact.URL, + "qrCode": contact.QrCode, "hoverColor": contact.HoverColor, - "sortOrder": contact.SortOrder, + "sortOrder": contact.SortOrder, }) } } @@ -171,6 +306,10 @@ func DeleteContact(db *database.Database) gin.HandlerFunc { ctx := c.Request.Context() if err := db.Client.Contact.DeleteOneID(id).Exec(ctx); err != nil { + if ent.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "联系方式不存在"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/internal/api/frontend_config.go b/internal/api/frontend_config.go index 3c4a46a..0022fe0 100644 --- a/internal/api/frontend_config.go +++ b/internal/api/frontend_config.go @@ -8,51 +8,34 @@ import ( "github.com/gin-gonic/gin" ) -// GetFrontendConfig 获取前端配置(用于index.html等) +// GetFrontendConfig returns only public head, runtime and tracker settings. func GetFrontendConfig(db *database.Database) gin.HandlerFunc { return func(c *gin.Context) { - ctx := c.Request.Context() - config, err := db.Client.SiteConfig.Get(ctx, 1) + settings, err := db.LoadSiteSettings(c.Request.Context()) if err != nil { - // 如果不存在,返回默认值 - c.JSON(http.StatusOK, gin.H{ - "title": "个人主页", - "keywords": "个人主页,Vue3", - "description": "一个基于Vue3的个人主页", - "favicon": "/favicon.ico", - "umamiScript": "", - "umamiWebsiteId": "", - "iconLibrary": "//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css", - "fontLibrary": "", - }) + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载前端配置失败"}) return } - - pageTitle := config.PageTitle - if pageTitle == "" { - pageTitle = "个人主页" - } - favicon := config.Favicon - if favicon == "" { - favicon = "/favicon.ico" - } - iconLibrary := config.IconLibrary - if iconLibrary == "" { - iconLibrary = "//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css" - } - - // 确保返回完整的配置信息,包括站点名称和URL c.JSON(http.StatusOK, gin.H{ - "title": pageTitle, - "siteName": config.SiteName, - "siteURL": config.SiteURL, - "keywords": config.SiteKeywords, - "description": config.SiteDescription, - "favicon": favicon, - "umamiScript": config.UmamiScript, - "umamiWebsiteId": config.UmamiWebsiteID, - "iconLibrary": iconLibrary, - "fontLibrary": config.FontLibrary, + "title": settings.PageTitle, + "siteName": settings.SiteName, + "siteURL": settings.SiteURL, + "keywords": settings.SiteKeywords, + "description": settings.SiteDescription, + "favicon": settings.Favicon, + "iconLibrary": settings.IconLibrary, + "fontLibrary": settings.FontLibrary, + "analyticsProvider": settings.AnalyticsProvider, + "umamiScript": settings.UmamiScript, + "umamiScriptUrl": settings.UmamiScript, + "umamiWebsiteId": settings.UmamiWebsiteID, + "umamiDomains": settings.UmamiDomains, + "umamiDoNotTrack": settings.UmamiDoNotTrack, + "umamiExcludeSearch": settings.UmamiExcludeSearch, + "umamiExcludeHash": settings.UmamiExcludeHash, + "umamiPerformance": settings.UmamiPerformance, + "umamiTag": settings.UmamiTag, + "umamiTrackerOptions": gin.H{"domains": settings.UmamiDomains, "doNotTrack": settings.UmamiDoNotTrack, "excludeSearch": settings.UmamiExcludeSearch, "excludeHash": settings.UmamiExcludeHash, "performance": settings.UmamiPerformance, "tag": settings.UmamiTag}, }) } } diff --git a/internal/api/logs.go b/internal/api/logs.go index 2ce2533..d0b80b5 100644 --- a/internal/api/logs.go +++ b/internal/api/logs.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "sync" "time" "home-vue-go/internal/config" @@ -16,14 +17,19 @@ import ( var logBuffer []string var maxLogLines = 1000 +var logBufferMutex sync.RWMutex // 初始化日志缓冲区 func InitLogBuffer() { + logBufferMutex.Lock() + defer logBufferMutex.Unlock() logBuffer = make([]string, 0, maxLogLines) } // 添加日志到缓冲区 func AddLogToBuffer(logLine string) { + logBufferMutex.Lock() + defer logBufferMutex.Unlock() logBuffer = append(logBuffer, logLine) if len(logBuffer) > maxLogLines { logBuffer = logBuffer[len(logBuffer)-maxLogLines:] @@ -48,11 +54,11 @@ func GetBackendLogs(cfg *config.Config) gin.HandlerFunc { } // 从缓冲区获取日志 - logs := make([]string, 0) - if len(logBuffer) > linesInt { - logs = logBuffer[len(logBuffer)-linesInt:] - } else { - logs = logBuffer + logBufferMutex.RLock() + logs := append([]string(nil), logBuffer...) + logBufferMutex.RUnlock() + if len(logs) > linesInt { + logs = logs[len(logs)-linesInt:] } // 尝试从日志文件读取(如果存在) diff --git a/internal/api/routes.go b/internal/api/routes.go index 4057688..8682292 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -10,19 +10,19 @@ import ( func SetupRoutes(r *gin.Engine, db *database.Database, cfg *config.Config) { // 初始化日志缓冲区 InitLogBuffer() - + // 添加日志中间件 r.Use(LoggingMiddleware()) - + api := r.Group("/api") { // 公开API - 获取数据 api.GET("/sites", GetSites(db)) api.GET("/contacts", GetContacts(db)) - api.GET("/config", GetSiteConfig(db, cfg)) + api.GET("/config", GetSiteConfig(db)) api.GET("/frontend-config", GetFrontendConfig(db)) - api.GET("/rotating-texts", GetRotatingTexts(cfg)) - + api.GET("/rotating-texts", GetRotatingTexts(db)) + // 访问统计API(公开,用于记录访问) api.POST("/track-visit", TrackVisit(db)) @@ -39,38 +39,41 @@ func SetupRoutes(r *gin.Engine, db *database.Database, cfg *config.Config) { // 站点管理 admin.GET("/sites", GetSites(db)) admin.POST("/sites", CreateSite(db)) + admin.PUT("/sites/reorder", ReorderSites(db)) admin.PUT("/sites/:id", UpdateSite(db)) admin.DELETE("/sites/:id", DeleteSite(db)) // 联系方式管理 admin.GET("/contacts", GetContacts(db)) admin.POST("/contacts", CreateContact(db)) + admin.PUT("/contacts/reorder", ReorderContacts(db)) admin.PUT("/contacts/:id", UpdateContact(db)) admin.DELETE("/contacts/:id", DeleteContact(db)) // 站点配置管理 - admin.GET("/config", GetSiteConfig(db, cfg)) + admin.GET("/config", GetAdminSiteConfig(db)) admin.PUT("/config", UpdateSiteConfig(db, cfg)) - + // 轮换文本配置 - admin.GET("/rotating-texts", GetRotatingTexts(cfg)) - admin.PUT("/rotating-texts", UpdateRotatingTexts(cfg)) + admin.GET("/rotating-texts", GetRotatingTexts(db)) + admin.PUT("/rotating-texts", UpdateRotatingTexts(db)) // 文件上传 admin.POST("/upload", UploadFile(cfg)) // 统计API - admin.GET("/stats", GetStats(db)) - admin.GET("/charts", GetChartData(db)) + admin.GET("/stats", GetStats(db, cfg)) + admin.GET("/charts", GetChartData(db, cfg)) admin.GET("/recent-visits", GetRecentVisits(db)) + admin.POST("/analytics/test", TestUmamiConnection(db, cfg)) admin.POST("/notify-update", NotifyConfigUpdate()) // 用户管理 admin.PUT("/change-password", ChangePassword(db)) - + // 日志API admin.GET("/logs", GetBackendLogs(cfg)) - + // 登录历史API admin.GET("/login-history", GetLoginHistory(db)) } diff --git a/internal/api/sites.go b/internal/api/sites.go index 6ebab5a..0009469 100644 --- a/internal/api/sites.go +++ b/internal/api/sites.go @@ -3,8 +3,10 @@ package api import ( "net/http" "strconv" + "strings" "home-vue-go/internal/database" + "home-vue-go/internal/ent" "home-vue-go/internal/ent/site" "github.com/gin-gonic/gin" @@ -34,6 +36,95 @@ func GetSites(db *database.Database) gin.HandlerFunc { } } +func ReorderSites(db *database.Database) gin.HandlerFunc { + return func(c *gin.Context) { + var req struct { + IDs []int `json:"ids" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表不能为空"}) + return + } + + seen := make(map[int]bool, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含无效ID"}) + return + } + if seen[id] { + c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含重复ID"}) + return + } + seen[id] = true + } + + ctx := c.Request.Context() + existingIDs, err := db.Client.Site.Query().IDs(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if len(existingIDs) != len(req.IDs) { + c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表与当前站点数量不一致"}) + return + } + existing := make(map[int]bool, len(existingIDs)) + for _, id := range existingIDs { + existing[id] = true + } + for _, id := range req.IDs { + if !existing[id] { + c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含不存在的站点"}) + return + } + } + + tx, err := db.Client.Tx(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + for index, id := range req.IDs { + if _, err := tx.Site.UpdateOneID(id).SetSortOrder((index + 1) * 10).Save(ctx); err != nil { + _ = tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + if err := tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + sites, err := db.Client.Site.Query().Order(site.BySortOrder(), site.ByID()).All(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + result := make([]gin.H, len(sites)) + for i, site := range sites { + result[i] = gin.H{ + "id": site.ID, + "name": site.Name, + "url": site.URL, + "icon": site.Icon, + "sortOrder": site.SortOrder, + } + } + + c.JSON(http.StatusOK, result) + } +} + func CreateSite(db *database.Database) gin.HandlerFunc { return func(c *gin.Context) { var req struct { @@ -47,6 +138,11 @@ func CreateSite(db *database.Database) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + req.Name, req.URL, req.Icon = strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), strings.TrimSpace(req.Icon) + if req.Name == "" || req.URL == "" || req.Icon == "" || !validHTTPURL(req.URL) { + c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"}) + return + } ctx := c.Request.Context() site, err := db.Client.Site.Create(). @@ -90,22 +186,25 @@ func UpdateSite(db *database.Database) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + req.Name, req.URL, req.Icon = strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), strings.TrimSpace(req.Icon) + if req.Name == "" || req.URL == "" || req.Icon == "" || !validHTTPURL(req.URL) { + c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"}) + return + } ctx := c.Request.Context() update := db.Client.Site.UpdateOneID(id) - if req.Name != "" { - update.SetName(req.Name) - } - if req.URL != "" { - update.SetURL(req.URL) - } - if req.Icon != "" { - update.SetIcon(req.Icon) - } + update.SetName(req.Name) + update.SetURL(req.URL) + update.SetIcon(req.Icon) update.SetSortOrder(req.SortOrder) site, err := update.Save(ctx) if err != nil { + if ent.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -130,6 +229,10 @@ func DeleteSite(db *database.Database) gin.HandlerFunc { ctx := c.Request.Context() if err := db.Client.Site.DeleteOneID(id).Exec(ctx); err != nil { + if ent.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/internal/api/stats.go b/internal/api/stats.go index 72047da..f6dcc07 100644 --- a/internal/api/stats.go +++ b/internal/api/stats.go @@ -3,191 +3,381 @@ package api import ( "fmt" "net/http" + "sort" + "strconv" "strings" + "sync" "time" + "home-vue-go/internal/analytics" + "home-vue-go/internal/config" "home-vue-go/internal/database" + "home-vue-go/internal/ent/visit" "github.com/gin-gonic/gin" ) -// GetStats 获取统计数据 -func GetStats(db *database.Database) gin.HandlerFunc { - return func(c *gin.Context) { - ctx := c.Request.Context() - - // 获取站点数量 - sites, _ := db.Client.Site.Query().All(ctx) - totalSites := len(sites) - - // 获取总访问量(从内存记录) - records := getVisitRecords() - totalViews := len(records) - - // 获取独立访客数(去重IP) - uniqueIPs := make(map[string]bool) - for _, r := range records { - uniqueIPs[r.IP] = true - } - uniqueVisitors := len(uniqueIPs) - - // 获取今日访问量 - today := time.Now() - todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()) - todayViews := 0 - for _, r := range records { - if r.VisitTime.After(todayStart) { - todayViews++ - } - } - - stats := gin.H{ - "totalViews": totalViews, - "uniqueVisitors": uniqueVisitors, - "todayViews": todayViews, - "totalSites": totalSites, - } +type analyticsCache struct { + mu sync.Mutex + statsExpires time.Time + chartsExpires time.Time + statsKey string + chartsKey string + stats gin.H + trend []gin.H + sources []gin.H +} - c.JSON(http.StatusOK, stats) +var dashboardAnalyticsCache analyticsCache +var umamiClient = analytics.NewClient() + +func GetStats(db *database.Database, cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + settings, err := db.LoadSiteSettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"}) + return + } + siteCount, _ := db.Client.Site.Query().Count(c.Request.Context()) + if settings.AnalyticsProvider == "umami" { + stats, status, err := loadUmamiStats(c, settings, siteCount, cfg) + if err != nil { + c.JSON(status, gin.H{"error": "Umami统计暂时不可用", "analyticsSource": "umami", "analyticsConfigured": false, "analyticsError": "unavailable"}) + return + } + c.JSON(http.StatusOK, stats) + return + } + c.JSON(http.StatusOK, localStats(c, db, siteCount)) } } -// GetChartData 获取图表数据 -func GetChartData(db *database.Database) gin.HandlerFunc { +func GetChartData(db *database.Database, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { - period := c.DefaultQuery("period", "7") - - // 根据period确定天数 - periodDays := 7 - if period == "30" { - periodDays = 30 - } else if period == "90" { - periodDays = 90 + settings, err := db.LoadSiteSettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"}) + return } - - // 获取真实趋势数据(从内存记录) - records := getVisitRecords() - trend := []gin.H{} - now := time.Now() - - for i := 0; i < periodDays; i++ { - date := now.AddDate(0, 0, -(periodDays-1-i)) - dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - dayEnd := dayStart.Add(24 * time.Hour) - - // 统计当天的访问量 - count := 0 - for _, r := range records { - if r.VisitTime.After(dayStart) && r.VisitTime.Before(dayEnd) { - count++ - } + periodDays := chartPeriodDays(c.DefaultQuery("period", "7")) + if settings.AnalyticsProvider == "umami" { + trend, sources, status, err := loadUmamiCharts(c, settings, periodDays, cfg) + if err != nil { + c.JSON(status, gin.H{"error": "Umami统计暂时不可用", "analyticsSource": "umami", "analyticsConfigured": false, "analyticsError": "unavailable"}) + return } - - trend = append(trend, gin.H{ - "label": date.Format("1/2"), - "value": count, - }) + c.JSON(http.StatusOK, gin.H{"trend": trend, "sources": sources, "analyticsSource": "umami", "analyticsConfigured": true, "analyticsError": ""}) + return } - - // 获取访问来源数据(基于referer) - sourceMap := make(map[string]int) - total := len(records) - - for _, r := range records { - ref := strings.ToLower(r.Referer) - if ref == "" { - sourceMap["直接访问"]++ - } else if strings.Contains(ref, "google") || strings.Contains(ref, "baidu") || strings.Contains(ref, "bing") || strings.Contains(ref, "yahoo") || strings.Contains(ref, "sogou") { - sourceMap["搜索引擎"]++ - } else if strings.Contains(ref, "twitter") || strings.Contains(ref, "facebook") || strings.Contains(ref, "weibo") || strings.Contains(ref, "wechat") || strings.Contains(ref, "qq") { - sourceMap["社交媒体"]++ - } else { - sourceMap["其他"]++ - } - } - - sources := []gin.H{} - if total > 0 { - for label, count := range sourceMap { - sources = append(sources, gin.H{ - "label": label, - "value": (count * 100) / total, // 转换为百分比 - }) - } - } else { - // 如果没有数据,返回默认值 - sources = []gin.H{ - {"label": "直接访问", "value": 100}, - } - } - - c.JSON(http.StatusOK, gin.H{ - "trend": trend, - "sources": sources, - }) + trend, sources := localCharts(c, db, periodDays) + c.JSON(http.StatusOK, gin.H{"trend": trend, "sources": sources, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": ""}) } } - -// GetRecentVisits 获取最近访问记录 func GetRecentVisits(db *database.Database) gin.HandlerFunc { return func(c *gin.Context) { - // 支持通过 query 参数自定义条数,默认 5,最大 50 + settings, err := db.LoadSiteSettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"}) + return + } + if settings.AnalyticsProvider == "umami" { + c.JSON(http.StatusOK, []gin.H{}) + return + } limit := 5 - if q := c.DefaultQuery("limit", "5"); q != "" { - if n, err := parseInt(q); err == nil && n > 0 { - limit = n - if limit > 50 { - limit = 50 - } + if value, err := strconv.Atoi(c.DefaultQuery("limit", "5")); err == nil && value > 0 { + limit = value + if limit > 50 { + limit = 50 } } - - // 从内存记录获取最近访问 - records := getVisitRecords() - result := make([]gin.H, 0, limit) + records, err := queryVisits(c, db, 0) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载访问记录失败"}) + return + } + result := make([]gin.H, 0, min(limit, len(records))) now := time.Now() - - // 取最近 limit 条 - start := len(records) - limit - if start < 0 { - start = 0 + for index := len(records) - 1; index >= 0 && len(result) < limit; index-- { + record := records[index] + result = append(result, gin.H{"path": record.Path, "ip": record.IP, "time": relativeVisitTime(now, record.VisitTime)}) } - - for i := len(records) - 1; i >= start && i >= 0; i-- { - r := records[i] - - // 计算相对时间 - diff := now.Sub(r.VisitTime) - var timeStr string - if diff < time.Minute { - timeStr = "刚刚" - } else if diff < time.Hour { - timeStr = fmt.Sprintf("%.0f分钟前", diff.Minutes()) - } else if diff < 24*time.Hour { - timeStr = fmt.Sprintf("%.0f小时前", diff.Hours()) - } else { - timeStr = fmt.Sprintf("%.0f天前", diff.Hours()/24) - } - - result = append(result, gin.H{ - "path": r.Path, - "ip": r.IP, - "time": timeStr, - }) - } - c.JSON(http.StatusOK, result) } } -// NotifyConfigUpdate 通知配置更新(用于热重载) func NotifyConfigUpdate() gin.HandlerFunc { return func(c *gin.Context) { - // 这里可以添加通知逻辑,比如通过WebSocket或SSE通知前端 - // 目前简单返回成功 - c.JSON(http.StatusOK, gin.H{ - "message": "配置更新通知已发送", - }) + resetAnalyticsCache() + c.JSON(http.StatusOK, gin.H{"message": "配置更新通知已发送"}) } } + +func loadUmamiStats(c *gin.Context, settings *config.SiteSettings, siteCount int, appConfig *config.Config) (gin.H, int, error) { + credential, err := decryptUmamiCredential(settings, appConfig) + if err != nil { + return nil, http.StatusBadGateway, err + } + cacheKey := "stats:" + settings.UmamiWebsiteID + ":" + settings.UmamiAPIURL + dashboardAnalyticsCache.mu.Lock() + if dashboardAnalyticsCache.statsKey == cacheKey && time.Now().Before(dashboardAnalyticsCache.statsExpires) && dashboardAnalyticsCache.stats != nil { + value := dashboardAnalyticsCache.stats + dashboardAnalyticsCache.mu.Unlock() + return value, http.StatusOK, nil + } + dashboardAnalyticsCache.mu.Unlock() + + clientConfig := analytics.Config{Mode: settings.UmamiAPIMode, APIURL: settings.UmamiAPIURL, Credential: credential, WebsiteID: settings.UmamiWebsiteID} + now := time.Now() + allTime, err := umamiClient.GetStats(c.Request.Context(), clientConfig, time.Unix(0, 0), now) + if err != nil { + return nil, http.StatusBadGateway, err + } + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + todayStats, err := umamiClient.GetStats(c.Request.Context(), clientConfig, today, now) + if err != nil { + return nil, http.StatusBadGateway, err + } + active, err := umamiClient.GetActive(c.Request.Context(), clientConfig) + if err != nil { + return nil, http.StatusBadGateway, err + } + result := gin.H{"totalViews": allTime.Pageviews, "uniqueVisitors": allTime.Visitors, "todayViews": todayStats.Pageviews, "activeVisitors": active, "totalSites": siteCount, "analyticsSource": "umami", "analyticsConfigured": true} + dashboardAnalyticsCache.mu.Lock() + dashboardAnalyticsCache.statsKey, dashboardAnalyticsCache.statsExpires, dashboardAnalyticsCache.stats = cacheKey, time.Now().Add(30*time.Second), result + dashboardAnalyticsCache.mu.Unlock() + return result, http.StatusOK, nil +} + +func loadUmamiCharts(c *gin.Context, settings *config.SiteSettings, periodDays int, appConfig *config.Config) ([]gin.H, []gin.H, int, error) { + credential, err := decryptUmamiCredential(settings, appConfig) + if err != nil { + return nil, nil, http.StatusBadGateway, err + } + cacheKey := fmt.Sprintf("charts:%s:%d:%s", settings.UmamiWebsiteID, periodDays, settings.UmamiAPIURL) + dashboardAnalyticsCache.mu.Lock() + if dashboardAnalyticsCache.chartsKey == cacheKey && time.Now().Before(dashboardAnalyticsCache.chartsExpires) && dashboardAnalyticsCache.trend != nil { + trend, sources := dashboardAnalyticsCache.trend, dashboardAnalyticsCache.sources + dashboardAnalyticsCache.mu.Unlock() + return trend, sources, http.StatusOK, nil + } + dashboardAnalyticsCache.mu.Unlock() + + clientConfig := analytics.Config{Mode: settings.UmamiAPIMode, APIURL: settings.UmamiAPIURL, Credential: credential, WebsiteID: settings.UmamiWebsiteID} + now := time.Now() + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -periodDays+1) + pageviews, err := umamiClient.GetPageviews(c.Request.Context(), clientConfig, start, now) + if err != nil { + return nil, nil, http.StatusBadGateway, err + } + referrers, err := umamiClient.GetMetrics(c.Request.Context(), clientConfig, start, now, "referrer") + if err != nil { + return nil, nil, http.StatusBadGateway, err + } + trend := make([]gin.H, 0, len(pageviews)) + for _, point := range pageviews { + trend = append(trend, gin.H{"label": point.X, "value": point.Y}) + } + sources := percentageSources(referrers) + dashboardAnalyticsCache.mu.Lock() + dashboardAnalyticsCache.chartsKey, dashboardAnalyticsCache.chartsExpires, dashboardAnalyticsCache.trend, dashboardAnalyticsCache.sources = cacheKey, time.Now().Add(30*time.Second), trend, sources + dashboardAnalyticsCache.mu.Unlock() + return trend, sources, http.StatusOK, nil +} + +func localStats(c *gin.Context, db *database.Database, siteCount int) gin.H { + records, err := queryVisits(c, db, 0) + if err != nil { + return gin.H{"totalViews": 0, "uniqueVisitors": 0, "todayViews": 0, "activeVisitors": 0, "totalSites": siteCount, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": "query_failed"} + } + now := time.Now() + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + unique := map[string]struct{}{} + active := map[string]struct{}{} + todayViews := 0 + for _, record := range records { + key := record.IP + "|" + record.UserAgent + unique[key] = struct{}{} + if !record.VisitTime.Before(start) { + todayViews++ + } + if !record.VisitTime.Before(now.Add(-5 * time.Minute)) { + active[key] = struct{}{} + } + } + return gin.H{"totalViews": len(records), "uniqueVisitors": len(unique), "todayViews": todayViews, "activeVisitors": len(active), "totalSites": siteCount, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": ""} +} + +func localCharts(c *gin.Context, db *database.Database, periodDays int) ([]gin.H, []gin.H) { + records, err := queryVisits(c, db, 0) + if err != nil { + return emptyTrend(periodDays), []gin.H{{"label": "直接访问", "value": 100}} + } + now := time.Now() + trend := make([]gin.H, 0, periodDays) + for index := 0; index < periodDays; index++ { + date := now.AddDate(0, 0, -(periodDays - 1 - index)) + dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + dayEnd := dayStart.AddDate(0, 0, 1) + count := 0 + for _, record := range records { + if !record.VisitTime.Before(dayStart) && record.VisitTime.Before(dayEnd) { + count++ + } + } + trend = append(trend, gin.H{"label": date.Format("1/2"), "value": count}) + } + counts := make(map[string]int) + for _, record := range records { + counts[classifyReferer(record.Referer)]++ + } + sources := percentageCounts(counts, len(records)) + return trend, sources +} + +func queryVisits(c *gin.Context, db *database.Database, days int) ([]*structVisit, error) { + query := db.Client.Visit.Query().Order(visit.ByVisitTime()) + if days > 0 { + query = query.Where(visit.VisitTimeGTE(time.Now().AddDate(0, 0, -days))) + } + rows, err := query.All(c.Request.Context()) + if err != nil { + return nil, err + } + result := make([]*structVisit, len(rows)) + for index, row := range rows { + result[index] = &structVisit{Path: row.Path, IP: row.IP, UserAgent: row.UserAgent, Referer: row.Referer, VisitTime: row.VisitTime} + } + return result, nil +} + +type structVisit struct { + Path, IP, UserAgent, Referer string + VisitTime time.Time +} + +func percentageSources(points []analytics.Point) []gin.H { + counts := make(map[string]int) + for _, point := range points { + counts[classifyReferer(point.X)] += point.Y + } + total := 0 + for _, count := range counts { + total += count + } + return percentageCounts(counts, total) +} + +func percentageCounts(counts map[string]int, total int) []gin.H { + if total <= 0 { + return []gin.H{{"label": "直接访问", "value": 100}} + } + type item struct { + label string + count, value, remainder int + } + items := make([]item, 0, len(counts)) + used := 0 + for label, count := range counts { + value := count * 100 / total + items = append(items, item{label: label, count: count, value: value, remainder: count * 100 % total}) + used += value + } + sort.Slice(items, func(i, j int) bool { + if items[i].remainder != items[j].remainder { + return items[i].remainder > items[j].remainder + } + return items[i].label < items[j].label + }) + for index := 0; index < 100-used && len(items) > 0; index++ { + items[index%len(items)].value++ + } + sort.Slice(items, func(i, j int) bool { + if items[i].count != items[j].count { + return items[i].count > items[j].count + } + return items[i].label < items[j].label + }) + result := make([]gin.H, 0, len(items)) + for _, item := range items { + result = append(result, gin.H{"label": item.label, "value": item.value}) + } + return result +} + +func classifyReferer(referrer string) string { + referrer = strings.ToLower(strings.TrimSpace(referrer)) + if referrer == "" { + return "直接访问" + } + for _, source := range []string{"google", "baidu", "bing", "yahoo", "sogou"} { + if strings.Contains(referrer, source) { + return "搜索引擎" + } + } + for _, source := range []string{"twitter", "facebook", "weibo", "wechat", "qq"} { + if strings.Contains(referrer, source) { + return "社交媒体" + } + } + return "其他" +} + +func decryptUmamiCredential(settings *config.SiteSettings, appConfig *config.Config) (string, error) { + if settings.UmamiWebsiteID == "" || settings.UmamiCredential == "" { + return "", fmt.Errorf("Umami配置不完整") + } + if appConfig == nil { + return "", fmt.Errorf("统计加密配置不可用") + } + return appConfig.DecryptSecret(settings.UmamiCredential) +} + +func resetAnalyticsCache() { + dashboardAnalyticsCache.mu.Lock() + dashboardAnalyticsCache.statsKey = "" + dashboardAnalyticsCache.chartsKey = "" + dashboardAnalyticsCache.statsExpires = time.Time{} + dashboardAnalyticsCache.chartsExpires = time.Time{} + dashboardAnalyticsCache.stats = nil + dashboardAnalyticsCache.trend = nil + dashboardAnalyticsCache.sources = nil + dashboardAnalyticsCache.mu.Unlock() +} +func chartPeriodDays(value string) int { + switch value { + case "30": + return 30 + case "90": + return 90 + default: + return 7 + } +} +func emptyTrend(days int) []gin.H { + result := make([]gin.H, days) + for index := range result { + result[index] = gin.H{"label": "", "value": 0} + } + return result +} +func relativeVisitTime(now, visited time.Time) string { + diff := now.Sub(visited) + switch { + case diff < time.Minute: + return "刚刚" + case diff < time.Hour: + return fmt.Sprintf("%.0f分钟前", diff.Minutes()) + case diff < 24*time.Hour: + return fmt.Sprintf("%.0f小时前", diff.Hours()) + default: + return fmt.Sprintf("%.0f天前", diff.Hours()/24) + } +} +func min(left, right int) int { + if left < right { + return left + } + return right +} diff --git a/internal/api/track.go b/internal/api/track.go index 997363c..ed719bc 100644 --- a/internal/api/track.go +++ b/internal/api/track.go @@ -2,75 +2,49 @@ package api import ( "net/http" - "sync" - "time" + "strings" "home-vue-go/internal/database" "github.com/gin-gonic/gin" ) -// VisitRecord 表示一次访问记录(暂存在内存中) -type VisitRecord struct { - Path string - IP string - UserAgent string - Referer string - VisitTime time.Time -} - -// 简单的内存存储与读写锁 -var ( - visitMutex sync.RWMutex - visitRecords []VisitRecord - maxVisits = 1000 // 最多保留的访问记录条数 -) - -// TrackVisit 记录访问 +// TrackVisit stores a local visit only when local analytics is selected. +// Umami mode is tracked by the browser and must not be duplicated here. func TrackVisit(db *database.Database) gin.HandlerFunc { return func(c *gin.Context) { + settings, err := db.LoadSiteSettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"}) + return + } + if settings.AnalyticsProvider == "umami" { + c.JSON(http.StatusNoContent, nil) + return + } var req struct { Path string `json:"path"` Referer string `json:"referer"` } - if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) return } - - ip := c.ClientIP() - userAgent := c.GetHeader("User-Agent") - - // 记录访问到内存 - visitMutex.Lock() - visitRecords = append(visitRecords, VisitRecord{ - Path: req.Path, - IP: ip, - UserAgent: userAgent, - Referer: req.Referer, - VisitTime: time.Now(), - }) - - // 限制记录数量 - if len(visitRecords) > maxVisits { - visitRecords = visitRecords[len(visitRecords)-maxVisits:] + path := strings.TrimSpace(req.Path) + if path == "" || len(path) > 2048 { + c.JSON(http.StatusBadRequest, gin.H{"error": "访问路径无效"}) + return } - visitMutex.Unlock() - - // TODO: 等Ent代码生成后,改用数据库存储 - // ctx := c.Request.Context() - // if db.Client.Visit != nil { - // db.Client.Visit.Create()... - // } - - c.JSON(http.StatusOK, gin.H{"message": "访问已记录"}) + _, err = db.Client.Visit.Create(). + SetPath(path). + SetIP(c.ClientIP()). + SetUserAgent(c.GetHeader("User-Agent")). + SetReferer(strings.TrimSpace(req.Referer)). + Save(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "访问记录保存失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "访问已记录", "analyticsSource": "local"}) } } - -// 获取访问记录(从内存) -func getVisitRecords() []VisitRecord { - visitMutex.RLock() - defer visitMutex.RUnlock() - return visitRecords -} diff --git a/internal/api/validation.go b/internal/api/validation.go new file mode 100644 index 0000000..9e50648 --- /dev/null +++ b/internal/api/validation.go @@ -0,0 +1,32 @@ +package api + +import ( + "net/url" + "path" + "strings" +) + +func validHTTPURL(value string) bool { + parsed, err := url.ParseRequestURI(strings.TrimSpace(value)) + return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https") +} + +func validContactURL(value string) bool { + value = strings.TrimSpace(value) + parsed, err := url.ParseRequestURI(value) + if err != nil { + return false + } + if parsed.Scheme == "http" || parsed.Scheme == "https" { + return parsed.Host != "" + } + return parsed.Scheme == "mailto" || parsed.Scheme == "tel" +} + +func validQRCode(value string) bool { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, "/uploads/") { + return !strings.Contains(value, "\\") && path.Clean(value) == value + } + return validHTTPURL(value) +} diff --git a/internal/api/validation_test.go b/internal/api/validation_test.go new file mode 100644 index 0000000..f61af6e --- /dev/null +++ b/internal/api/validation_test.go @@ -0,0 +1,42 @@ +package api + +import "testing" + +func TestURLValidation(t *testing.T) { + for _, value := range []string{"https://example.com", "http://localhost:8080"} { + if !validHTTPURL(value) { + t.Errorf("expected valid site URL: %s", value) + } + } + for _, value := range []string{"javascript:alert(1)", "//example.com", "example.com"} { + if validHTTPURL(value) { + t.Errorf("expected invalid site URL: %s", value) + } + } + for _, value := range []string{"mailto:a@example.com", "tel:+8613800000000", "https://example.com"} { + if !validContactURL(value) { + t.Errorf("expected valid contact URL: %s", value) + } + } + for _, value := range []string{"/uploads/qr.png", "https://example.com/qr.png"} { + if !validQRCode(value) { + t.Errorf("expected valid QR path: %s", value) + } + } + for _, value := range []string{"/uploads/../secret.png", "data:image/png;base64,abc"} { + if validQRCode(value) { + t.Errorf("expected invalid QR path: %s", value) + } + } +} + +func TestPercentageCountsSumsToOneHundred(t *testing.T) { + values := percentageCounts(map[string]int{"direct": 1, "search": 1, "other": 1}, 3) + total := 0 + for _, value := range values { + total += value["value"].(int) + } + if total != 100 { + t.Fatalf("percentage total = %d, want 100", total) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 1016bb3..0839dc9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,15 +1,25 @@ package config import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" "os" "path/filepath" + "strings" ) type Config struct { - DataDir string - DatabasePath string - UploadDir string - JWTSecret string + DataDir string + DatabasePath string + UploadDir string + JWTSecret string + EncryptionKey []byte } func New(dataDir string) *Config { @@ -26,10 +36,71 @@ func New(dataDir string) *Config { jwtSecret = "your-secret-key-change-in-production" } + encryptionSource := os.Getenv("CONFIG_ENCRYPTION_KEY") + if encryptionSource == "" { + // Keep existing installations decryptable while allowing production deployments + // to use a dedicated key that is independent from JWT signing. + encryptionSource = jwtSecret + } + encryptionKey := sha256.Sum256([]byte(encryptionSource)) + return &Config{ - DataDir: dataDir, - DatabasePath: filepath.Join(dataDir, "home.db"), - UploadDir: uploadDir, - JWTSecret: jwtSecret, + DataDir: dataDir, + DatabasePath: filepath.Join(dataDir, "home.db"), + UploadDir: uploadDir, + JWTSecret: jwtSecret, + EncryptionKey: encryptionKey[:], } } + +// EncryptSecret encrypts a value for storage in the application database. +func (c *Config) EncryptSecret(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "", nil + } + block, err := aes.NewCipher(c.EncryptionKey) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nil, nonce, []byte(value), nil) + encoded := base64.RawStdEncoding.EncodeToString(append(nonce, ciphertext...)) + return "v1:" + encoded, nil +} + +// DecryptSecret decrypts a value previously returned by EncryptSecret. +func (c *Config) DecryptSecret(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "", nil + } + if !strings.HasPrefix(value, "v1:") { + return "", errors.New("unsupported encrypted secret format") + } + raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(value, "v1:")) + if err != nil { + return "", fmt.Errorf("decode encrypted secret: %w", err) + } + block, err := aes.NewCipher(c.EncryptionKey) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("encrypted secret is too short") + } + plaintext, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil) + if err != nil { + return "", errors.New("encrypted secret authentication failed") + } + return string(plaintext), nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..27dfacb --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,26 @@ +package config + +import "testing" + +func TestSecretRoundTripAndIsolation(t *testing.T) { + first := &Config{EncryptionKey: []byte("01234567890123456789012345678901")} + second := &Config{EncryptionKey: []byte("abcdefghijklmnopqrstuvwxyz123456")} + ciphertext, err := first.EncryptSecret("umami-secret") + if err != nil { + t.Fatal(err) + } + if ciphertext == "umami-secret" || ciphertext == "" { + t.Fatalf("secret was not encrypted: %q", ciphertext) + } + plaintext, err := first.DecryptSecret(ciphertext) + if err != nil || plaintext != "umami-secret" { + t.Fatalf("round trip failed: %q, %v", plaintext, err) + } + if _, err := second.DecryptSecret(ciphertext); err == nil { + t.Fatal("ciphertext decrypted with the wrong key") + } + cleared, err := first.EncryptSecret("") + if err != nil || cleared != "" { + t.Fatalf("empty secret should clear storage: %q, %v", cleared, err) + } +} diff --git a/internal/config/site_settings.go b/internal/config/site_settings.go new file mode 100644 index 0000000..00f66da --- /dev/null +++ b/internal/config/site_settings.go @@ -0,0 +1,180 @@ +package config + +import "strings" + +// AboutLink is a configurable link displayed in the about dialog. +type AboutLink struct { + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Icon string `json:"icon"` +} + +// SiteSettings is the single JSON payload stored alongside the SiteConfig row. +// The encrypted Umami credential is intentionally kept in this internal type. +type SiteSettings struct { + SiteName string `json:"siteName"` + SiteURL string `json:"siteURL"` + SiteIcon string `json:"siteIcon"` + SiteDescription string `json:"siteDescription"` + SiteKeywords string `json:"siteKeywords"` + UserName string `json:"userName"` + ProfileImageURL string `json:"profileImageURL"` + ICPNumber string `json:"icpNumber"` + PoliceNumber string `json:"policeNumber"` + PageTitle string `json:"pageTitle"` + Favicon string `json:"favicon"` + IconLibrary string `json:"iconLibrary"` + FontLibrary string `json:"fontLibrary"` + + FooterYearStart string `json:"footerYearStart"` + FooterYearEnd string `json:"footerYearEnd"` + ShowVisitTimer bool `json:"showVisitTimer"` + RotatingTexts []string `json:"rotatingTexts"` + + GreetingText string `json:"greetingText"` + OnlineStatusText string `json:"onlineStatusText"` + FooterLabel string `json:"footerLabel"` + ShowAbout bool `json:"showAbout"` + ShowSites bool `json:"showSites"` + ShowContacts bool `json:"showContacts"` + ShowThemeToggle bool `json:"showThemeToggle"` + ShowFooter bool `json:"showFooter"` + AboutTitle string `json:"aboutTitle"` + AboutDescription string `json:"aboutDescription"` + AboutLinks []AboutLink `json:"aboutLinks"` + SitePageSize int `json:"sitePageSize"` + OpenLinksNewTab bool `json:"openLinksInNewTab"` + + AnalyticsProvider string `json:"analyticsProvider"` + UmamiScript string `json:"umamiScript"` + UmamiWebsiteID string `json:"umamiWebsiteId"` + UmamiAPIMode string `json:"umamiApiMode"` + UmamiAPIURL string `json:"umamiApiUrl"` + UmamiCredential string `json:"umamiCredential"` + UmamiDomains string `json:"umamiDomains"` + UmamiDoNotTrack bool `json:"umamiDoNotTrack"` + UmamiExcludeSearch bool `json:"umamiExcludeSearch"` + UmamiExcludeHash bool `json:"umamiExcludeHash"` + UmamiPerformance bool `json:"umamiPerformance"` + UmamiTag string `json:"umamiTag"` +} + +var defaultRotatingTexts = []string{ + "你好鸭,欢迎来到我的主页!!", + "随时可以联系我,期待与你交流。", + "愿你历尽千帆,归来仍是少年。", + "梦想还是要有的,万一实现了呢?", + "I hope you have a happy day every day.", +} + +func DefaultSiteSettings() *SiteSettings { + return &SiteSettings{ + SiteName: "个人主页", + SiteURL: "https://example.com", + SiteIcon: "/favicon.ico", + SiteDescription: "一个基于Vue3的个人主页", + SiteKeywords: "个人主页,Vue3", + UserName: "用户", + PageTitle: "个人主页", + Favicon: "/favicon.ico", + FooterYearStart: "", + FooterYearEnd: "", + ShowVisitTimer: true, + RotatingTexts: append([]string(nil), defaultRotatingTexts...), + GreetingText: "Hi,", + OnlineStatusText: "在线中", + FooterLabel: "Made by", + ShowAbout: true, + ShowSites: true, + ShowContacts: true, + ShowThemeToggle: true, + ShowFooter: true, + AboutTitle: "关于本站", + AboutLinks: []AboutLink{ + {Title: "静态原项目", Description: "Home-Vue", URL: "https://github.com/JLinMr/Home-Vue", Icon: "fab fa-github"}, + {Title: "动态现项目", Description: "Home-Vue-go", URL: "https://github.com/QWQLwToo/Home-Vue-go", Icon: "fab fa-github"}, + }, + SitePageSize: 6, + OpenLinksNewTab: true, + AnalyticsProvider: "local", + UmamiAPIMode: "selfhost", + UmamiDoNotTrack: true, + UmamiExcludeSearch: false, + UmamiExcludeHash: false, + UmamiPerformance: false, + } +} + +func (s *SiteSettings) Normalize() { + defaults := DefaultSiteSettings() + if strings.TrimSpace(s.SiteName) == "" { + s.SiteName = defaults.SiteName + } + if strings.TrimSpace(s.SiteURL) == "" { + s.SiteURL = defaults.SiteURL + } + if strings.TrimSpace(s.SiteIcon) == "" { + s.SiteIcon = defaults.SiteIcon + } + if strings.TrimSpace(s.SiteDescription) == "" { + s.SiteDescription = defaults.SiteDescription + } + if strings.TrimSpace(s.SiteKeywords) == "" { + s.SiteKeywords = defaults.SiteKeywords + } + if strings.TrimSpace(s.UserName) == "" { + s.UserName = defaults.UserName + } + if strings.TrimSpace(s.PageTitle) == "" { + s.PageTitle = defaults.PageTitle + } + if strings.TrimSpace(s.Favicon) == "" { + s.Favicon = defaults.Favicon + } + if strings.TrimSpace(s.GreetingText) == "" { + s.GreetingText = defaults.GreetingText + } + if strings.TrimSpace(s.OnlineStatusText) == "" { + s.OnlineStatusText = defaults.OnlineStatusText + } + if strings.TrimSpace(s.FooterLabel) == "" { + s.FooterLabel = defaults.FooterLabel + } + if strings.TrimSpace(s.AboutTitle) == "" { + s.AboutTitle = defaults.AboutTitle + } + if s.SitePageSize != 6 && s.SitePageSize != 9 && s.SitePageSize != 12 { + s.SitePageSize = defaults.SitePageSize + } + if s.AnalyticsProvider != "umami" { + s.AnalyticsProvider = "local" + } + if s.UmamiAPIMode != "cloud" { + s.UmamiAPIMode = "selfhost" + } + + filteredTexts := make([]string, 0, 8) + for _, text := range s.RotatingTexts { + if value := strings.TrimSpace(text); value != "" { + filteredTexts = append(filteredTexts, value) + } + if len(filteredTexts) == 8 { + break + } + } + if len(filteredTexts) == 0 { + filteredTexts = append([]string(nil), defaults.RotatingTexts...) + } + s.RotatingTexts = filteredTexts + if len(s.AboutLinks) > 8 { + s.AboutLinks = s.AboutLinks[:8] + } +} + +func (s *SiteSettings) Clone() *SiteSettings { + copyValue := *s + copyValue.RotatingTexts = append([]string(nil), s.RotatingTexts...) + copyValue.AboutLinks = append([]AboutLink(nil), s.AboutLinks...) + return ©Value +} diff --git a/internal/database/database.go b/internal/database/database.go index 715e6e9..01647c5 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -3,10 +3,16 @@ package database import ( "context" "database/sql" - "log" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" "entgo.io/ent/dialect" entsql "entgo.io/ent/dialect/sql" + "home-vue-go/internal/config" "home-vue-go/internal/ent" "home-vue-go/internal/ent/migrate" @@ -14,11 +20,14 @@ import ( "golang.org/x/crypto/bcrypt" ) +const siteConfigPayloadColumn = "config_json" + type Database struct { Client *ent.Client + SQL *sql.DB } -func Init(dbPath string) (*Database, error) { +func Init(dbPath string, cfg *config.Config) (*Database, error) { db, err := sql.Open("sqlite3", dbPath+"?_fk=1") if err != nil { return nil, err @@ -26,74 +35,239 @@ func Init(dbPath string) (*Database, error) { drv := entsql.OpenDB(dialect.SQLite, db) client := ent.NewClient(ent.Driver(drv)) - - // 运行数据库迁移 ctx := context.Background() if err := client.Schema.Create(ctx, migrate.WithForeignKeys(false)); err != nil { - log.Fatalf("数据库迁移失败: %v", err) + _ = db.Close() + return nil, fmt.Errorf("数据库迁移失败: %w", err) + } + if err := ensureSiteConfigPayload(db); err != nil { + _ = client.Close() + _ = db.Close() + return nil, err } - // 初始化默认数据 + database := &Database{Client: client, SQL: db} if err := initDefaultData(ctx, client); err != nil { - log.Printf("初始化默认数据失败: %v", err) + _ = database.Close() + return nil, fmt.Errorf("初始化默认数据失败: %w", err) } - - return &Database{Client: client}, nil + if err := initializeSiteSettings(ctx, database, cfg); err != nil { + _ = database.Close() + return nil, fmt.Errorf("初始化站点配置载荷失败: %w", err) + } + return database, nil } func (d *Database) Close() error { - return d.Client.Close() + if d.Client != nil { + return d.Client.Close() + } + if d.SQL != nil { + return d.SQL.Close() + } + return nil } -func initDefaultData(ctx context.Context, client *ent.Client) error { - // 检查是否已有站点配置 - _, err := client.SiteConfig.Get(ctx, 1) - if err == nil { - // 已存在配置,不初始化 - return nil - } - - // 创建默认站点配置 - _, err = client.SiteConfig.Create(). - SetSiteName("个人主页"). - SetSiteURL("https://example.com"). - SetSiteIcon("/favicon.ico"). - SetSiteDescription("一个基于Vue3的个人主页"). - SetSiteKeywords("个人主页,Vue3"). - SetUserName("用户"). - SetProfileImageURL(""). - SetIcpNumber(""). - SetPoliceNumber(""). - SetPageTitle("个人主页"). - SetFavicon("/favicon.ico"). - SetUmamiScript(""). - SetUmamiWebsiteID(""). - SetIconLibrary("//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css"). - SetFontLibrary(""). - Save(ctx) +func ensureSiteConfigPayload(db *sql.DB) error { + rows, err := db.Query("PRAGMA table_info(site_configs)") if err != nil { return err } + defer rows.Close() + for rows.Next() { + var cid int + var name, columnType string + var notNull, primaryKey int + var defaultValue any + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + return err + } + if name == siteConfigPayloadColumn { + return rows.Err() + } + } + if err := rows.Err(); err != nil { + return err + } + _, err = db.Exec(`ALTER TABLE site_configs ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}'`) + return err +} + +func initDefaultData(ctx context.Context, client *ent.Client) error { + if _, err := client.SiteConfig.Get(ctx, 1); err != nil { + if !ent.IsNotFound(err) { + return err + } + if _, err := client.SiteConfig.Create(). + SetSiteName("个人主页"). + SetSiteURL("https://example.com"). + SetSiteIcon("/favicon.ico"). + SetSiteDescription("一个基于Vue3的个人主页"). + SetSiteKeywords("个人主页,Vue3"). + SetUserName("用户"). + SetProfileImageURL(""). + SetIcpNumber(""). + SetPoliceNumber(""). + SetPageTitle("个人主页"). + SetFavicon("/favicon.ico"). + SetUmamiScript(""). + SetUmamiWebsiteID(""). + SetIconLibrary(""). + SetFontLibrary(""). + Save(ctx); err != nil { + return err + } + } - // 检查是否已有用户,如果没有才创建默认管理员用户 userCount, err := client.User.Query().Count(ctx) if err != nil { return err } - - // 只有在没有任何用户时才创建默认管理员 if userCount == 0 { hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) if err != nil { return err } - _, err = client.User.Create(). SetUsername("admin"). SetPassword(string(hashedPassword)). Save(ctx) return err } - + return nil +} + +func initializeSiteSettings(ctx context.Context, db *Database, cfg *config.Config) error { + payload, err := db.loadSiteConfigPayload(ctx) + if err != nil { + return err + } + if strings.TrimSpace(payload) != "" && strings.TrimSpace(payload) != "{}" { + var settings config.SiteSettings + if err := json.Unmarshal([]byte(payload), &settings); err != nil { + return fmt.Errorf("解析站点配置载荷失败: %w", err) + } + return nil + } + + siteCfg, err := db.Client.SiteConfig.Get(ctx, 1) + if err != nil { + return err + } + settings := config.DefaultSiteSettings() + settings.SiteName = siteCfg.SiteName + settings.SiteURL = siteCfg.SiteURL + settings.SiteIcon = siteCfg.SiteIcon + settings.SiteDescription = siteCfg.SiteDescription + settings.SiteKeywords = siteCfg.SiteKeywords + settings.UserName = siteCfg.UserName + settings.ProfileImageURL = siteCfg.ProfileImageURL + settings.ICPNumber = siteCfg.IcpNumber + settings.PoliceNumber = siteCfg.PoliceNumber + settings.PageTitle = siteCfg.PageTitle + settings.Favicon = siteCfg.Favicon + settings.IconLibrary = siteCfg.IconLibrary + settings.FontLibrary = siteCfg.FontLibrary + settings.UmamiScript = siteCfg.UmamiScript + settings.UmamiWebsiteID = siteCfg.UmamiWebsiteID + + if cfg != nil { + if err := importLegacyJSON(cfg.DataDir, settings); err != nil { + return err + } + } + settings.Normalize() + return db.SaveSiteSettings(ctx, settings) +} + +func importLegacyJSON(dataDir string, settings *config.SiteSettings) error { + if dataDir == "" { + return nil + } + var footer struct { + Start string `json:"start"` + End string `json:"end"` + } + if err := readOptionalJSON(filepath.Join(dataDir, "footer_year.json"), &footer); err != nil { + return err + } + if footer.Start != "" || footer.End != "" { + settings.FooterYearStart, settings.FooterYearEnd = footer.Start, footer.End + } + + var timer config.VisitTimerConfig + if err := readOptionalJSON(filepath.Join(dataDir, "visit_timer.json"), &timer); err != nil { + return err + } + if _, err := os.Stat(filepath.Join(dataDir, "visit_timer.json")); err == nil { + settings.ShowVisitTimer = timer.ShowVisitTimer + } + + var texts config.RotatingTextsConfig + if err := readOptionalJSON(filepath.Join(dataDir, "rotating_texts.json"), &texts); err != nil { + return err + } + if len(texts.Texts) > 0 { + settings.RotatingTexts = texts.Texts + } + return nil +} + +func readOptionalJSON(path string, target any) error { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("解析 %s 失败: %w", filepath.Base(path), err) + } + return nil +} + +func (d *Database) loadSiteConfigPayload(ctx context.Context) (string, error) { + var payload string + err := d.SQL.QueryRowContext(ctx, "SELECT config_json FROM site_configs WHERE id = 1").Scan(&payload) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return payload, err +} + +func (d *Database) LoadSiteSettings(ctx context.Context) (*config.SiteSettings, error) { + payload, err := d.loadSiteConfigPayload(ctx) + if err != nil { + return nil, err + } + settings := config.DefaultSiteSettings() + if strings.TrimSpace(payload) != "" && strings.TrimSpace(payload) != "{}" { + if err := json.Unmarshal([]byte(payload), settings); err != nil { + return nil, err + } + } + settings.Normalize() + return settings, nil +} + +func (d *Database) SaveSiteSettings(ctx context.Context, settings *config.SiteSettings) error { + if settings == nil { + return errors.New("site settings cannot be nil") + } + settings.Normalize() + payload, err := json.Marshal(settings) + if err != nil { + return err + } + result, err := d.SQL.ExecContext(ctx, "UPDATE site_configs SET config_json = ? WHERE id = 1", string(payload)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err != nil { + return err + } else if affected != 1 { + return errors.New("site config row does not exist") + } return nil } diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..9e2c654 --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,60 @@ +package database + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "home-vue-go/internal/config" +) + +func TestSiteSettingsMigrationAndBooleanPersistence(t *testing.T) { + dataDir := t.TempDir() + writeJSON := func(name, value string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dataDir, name), []byte(value), 0600); err != nil { + t.Fatal(err) + } + } + writeJSON("footer_year.json", `{"start":"2020","end":"2024"}`) + writeJSON("visit_timer.json", `{"showVisitTimer":false}`) + writeJSON("rotating_texts.json", `{"texts":["legacy text"]}`) + + cfg := config.New(dataDir) + db, err := Init(cfg.DatabasePath, cfg) + if err != nil { + if strings.Contains(err.Error(), "CGO_ENABLED=0") { + t.Skip("go-sqlite3 requires cgo for the database integration test") + } + t.Fatal(err) + } + defer db.Close() + + settings, err := db.LoadSiteSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if settings.FooterYearStart != "2020" || settings.FooterYearEnd != "2024" || settings.ShowVisitTimer || len(settings.RotatingTexts) != 1 { + t.Fatalf("legacy settings were not migrated: %+v", settings) + } + + settings.ShowAbout = false + settings.ShowSites = false + settings.ShowContacts = false + settings.ShowThemeToggle = false + settings.ShowFooter = false + settings.AnalyticsProvider = "umami" + settings.UmamiCredential = "v1:stored-secret" + if err := db.SaveSiteSettings(context.Background(), settings); err != nil { + t.Fatal(err) + } + loaded, err := db.LoadSiteSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + if loaded.ShowAbout || loaded.ShowSites || loaded.ShowContacts || loaded.ShowThemeToggle || loaded.ShowFooter || loaded.AnalyticsProvider != "umami" || loaded.UmamiCredential != "v1:stored-secret" { + t.Fatalf("false values or private payload were not persisted: %+v", loaded) + } +} diff --git a/internal/ent/generate.go b/internal/ent/generate.go index 0b4f35c..ad82082 100644 --- a/internal/ent/generate.go +++ b/internal/ent/generate.go @@ -1,3 +1,3 @@ package ent -//go:generate go run -mod=mod entgo.io/ent/cmd/ent@latest generate ./schema +//go:generate go run -mod=mod entgo.io/ent/cmd/ent@v0.14.5 generate ./schema diff --git a/main.go b/main.go index bcc8412..a498bb3 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "io/fs" "log" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -46,7 +47,7 @@ func main() { cfg := config.New(dataDir) // 初始化数据库 - db, err := database.Init(cfg.DatabasePath) + db, err := database.Init(cfg.DatabasePath, cfg) if err != nil { log.Fatal("数据库初始化失败:", err) } @@ -104,6 +105,11 @@ func main() { // 初始化API路由 api.SetupRoutes(r, db, cfg) + apiPort := os.Getenv("API_PORT") + if apiPort == "" { + apiPort = "1551" + } + // 创建前端服务器(1552端口)- 从嵌入的文件系统提供前端文件 frontendRouter := gin.New() frontendRouter.Use(gin.Recovery()) @@ -114,7 +120,7 @@ func main() { if err == nil { // 使用嵌入的文件系统 frontendRouter.StaticFS("/static", http.FS(distRoot)) - + // 提供favicon frontendRouter.GET("/favicon.ico", func(c *gin.Context) { data, err := distRoot.Open("favicon.ico") @@ -130,52 +136,13 @@ func main() { } c.Data(http.StatusOK, "image/x-icon", content) }) - - // API代理:将/api请求代理到1551端口 - frontendRouter.Any("/api/*path", func(c *gin.Context) { - client := &http.Client{ - Timeout: 30 * time.Second, - } - - targetURL := "http://localhost:1551" + c.Request.URL.Path - if c.Request.URL.RawQuery != "" { - targetURL += "?" + c.Request.URL.RawQuery - } - - req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "创建代理请求失败"}) - return - } - - for key, values := range c.Request.Header { - for _, value := range values { - req.Header.Add(key, value) - } - } - - resp, err := client.Do(req) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败: " + err.Error()}) - return - } - defer resp.Body.Close() - - for key, values := range resp.Header { - for _, value := range values { - c.Writer.Header().Add(key, value) - } - } - - // 复制响应状态码和内容 - c.Status(resp.StatusCode) - c.Header("Content-Type", resp.Header.Get("Content-Type")) - io.Copy(c.Writer, resp.Body) - }) - + + // API代理:将/api请求转发到配置的API端口 + frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort)) + // /uploads代理 frontendRouter.Static("/uploads", cfg.UploadDir) - + // SPA路由支持 frontendRouter.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path @@ -183,7 +150,7 @@ func main() { c.Status(404) return } - + // 尝试打开文件 filePath := strings.TrimPrefix(path, "/") if filePath == "" { @@ -217,7 +184,7 @@ func main() { } } } - + // 返回index.html(SPA路由) indexFile, err := distRoot.Open("index.html") if err == nil { @@ -232,7 +199,7 @@ func main() { c.Status(http.StatusNotFound) } }) - + log.Println("使用嵌入的前端文件(单一可执行文件模式)") } else { // 回退到文件系统(开发模式) @@ -241,36 +208,9 @@ func main() { if _, err := os.Stat(distPath); err == nil { frontendRouter.Static("/static", filepath.Join(distPath, "static")) frontendRouter.StaticFile("/favicon.ico", filepath.Join(distPath, "favicon.ico")) - - frontendRouter.Any("/api/*path", func(c *gin.Context) { - client := &http.Client{Timeout: 30 * time.Second} - targetURL := "http://localhost:1551" + c.Request.URL.Path - if c.Request.URL.RawQuery != "" { - targetURL += "?" + c.Request.URL.RawQuery - } - req, _ := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body) - for key, values := range c.Request.Header { - for _, value := range values { - req.Header.Add(key, value) - } - } - resp, err := client.Do(req) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败"}) - return - } - defer resp.Body.Close() - for key, values := range resp.Header { - for _, value := range values { - c.Writer.Header().Add(key, value) - } - } - // 复制响应状态码和内容 - c.Status(resp.StatusCode) - c.Header("Content-Type", resp.Header.Get("Content-Type")) - io.Copy(c.Writer, resp.Body) - }) - + + frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort)) + frontendRouter.Static("/uploads", cfg.UploadDir) frontendRouter.NoRoute(func(c *gin.Context) { if !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") { @@ -281,34 +221,7 @@ func main() { } else if _, err := os.Stat("./dist"); err == nil { frontendRouter.Static("/static", "./dist/static") frontendRouter.StaticFile("/favicon.ico", "./dist/favicon.ico") - frontendRouter.Any("/api/*path", func(c *gin.Context) { - client := &http.Client{Timeout: 30 * time.Second} - targetURL := "http://localhost:1551" + c.Request.URL.Path - if c.Request.URL.RawQuery != "" { - targetURL += "?" + c.Request.URL.RawQuery - } - req, _ := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body) - for key, values := range c.Request.Header { - for _, value := range values { - req.Header.Add(key, value) - } - } - resp, err := client.Do(req) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败"}) - return - } - defer resp.Body.Close() - for key, values := range resp.Header { - for _, value := range values { - c.Writer.Header().Add(key, value) - } - } - // 复制响应状态码和内容 - c.Status(resp.StatusCode) - c.Header("Content-Type", resp.Header.Get("Content-Type")) - io.Copy(c.Writer, resp.Body) - }) + frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort)) frontendRouter.Static("/uploads", cfg.UploadDir) frontendRouter.NoRoute(func(c *gin.Context) { if !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") { @@ -322,11 +235,6 @@ func main() { } // 启动两个服务器 - apiPort := os.Getenv("API_PORT") - if apiPort == "" { - apiPort = "1551" - } - frontendPort := os.Getenv("FRONTEND_PORT") if frontendPort == "" { frontendPort = "1552" @@ -335,7 +243,7 @@ func main() { // 创建API服务器 apiServer := &http.Server{ Addr: ":" + apiPort, - Handler: r, + Handler: r, } // 创建前端服务器 @@ -372,9 +280,23 @@ func main() { } func corsMiddleware() gin.HandlerFunc { + allowedOrigins := make(map[string]struct{}) + for _, origin := range strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), ",") { + if value := strings.TrimSpace(origin); value != "" { + allowedOrigins[value] = struct{}{} + } + } + if len(allowedOrigins) == 0 { + allowedOrigins["http://localhost:1552"] = struct{}{} + allowedOrigins["http://127.0.0.1:1552"] = struct{}{} + } return func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") - c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + origin := c.GetHeader("Origin") + if _, ok := allowedOrigins[origin]; ok { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Add("Vary", "Origin") + } c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") @@ -386,3 +308,33 @@ func corsMiddleware() gin.HandlerFunc { c.Next() } } + +func proxyAPIRequest(apiPort string) gin.HandlerFunc { + client := &http.Client{Timeout: 30 * time.Second} + return func(c *gin.Context) { + target := &url.URL{Scheme: "http", Host: "localhost:" + apiPort, Path: c.Request.URL.Path, RawQuery: c.Request.URL.RawQuery} + req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target.String(), c.Request.Body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建代理请求失败"}) + return + } + for key, values := range c.Request.Header { + for _, value := range values { + req.Header.Add(key, value) + } + } + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败"}) + return + } + defer resp.Body.Close() + for key, values := range resp.Header { + for _, value := range values { + c.Writer.Header().Add(key, value) + } + } + c.Status(resp.StatusCode) + _, _ = io.Copy(c.Writer, resp.Body) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..123dd2f --- /dev/null +++ b/main_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestProxyAPIRequestUsesConfiguredPort(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"path":"` + r.URL.RequestURI() + `"}`)) + })) + defer backend.Close() + + backendURL, err := url.Parse(backend.URL) + if err != nil { + t.Fatal(err) + } + _, port, err := net.SplitHostPort(backendURL.Host) + if err != nil { + t.Fatal(err) + } + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Any("/api/*path", proxyAPIRequest(port)) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/config?custom=1", nil)) + + if recorder.Code != http.StatusOK || recorder.Body.String() != `{"path":"/api/config?custom=1"}` { + t.Fatalf("unexpected proxy response: status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} diff --git a/src/App.vue b/src/App.vue index a5e51d9..2706c06 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,20 +4,20 @@
“{{ previewText }}”
@@ -113,18 +161,21 @@ import { computed, ref, watch } from 'vue' import IconSelector from '../IconSelector.vue' +const emit = defineEmits(['update:activeSection', 'addText', 'removeText', 'saveTexts', 'testAnalytics']) const props = defineProps({ config: { type: Object, required: true }, rotatingTexts: { type: Array, required: true }, activeSection: { type: String, required: true }, + actionLoading: { type: Boolean, default: false }, }) -const emit = defineEmits(['update:activeSection', 'addText', 'removeText', 'saveTexts']) const previewImageFailed = ref(false) const sections = [ { name: '基础信息', icon: 'fas fa-circle-info', description: '站点身份、搜索信息和版权年份' }, { name: '用户信息', icon: 'fas fa-user', description: '主页展示的用户名与头像' }, + { name: '主页显示', icon: 'fas fa-eye', description: '主页文案、模块显示和外链行为' }, { name: '图标配置', icon: 'fas fa-image', description: '站点图标和浏览器标签图标' }, { name: '备案信息', icon: 'fas fa-shield-halved', description: '页脚展示的备案信息' }, + { name: '关于信息', icon: 'fas fa-circle-question', description: '关于弹窗标题、简介和相关链接' }, { name: '前端配置', icon: 'fas fa-code', description: '网页标题、外部资源和计时器' }, { name: '轮换文本', icon: 'fas fa-i-cursor', description: '主页逐条打字展示的短句,最多八条' }, { name: '统计配置', icon: 'fas fa-chart-line', description: 'Umami 访问统计接入信息' }, @@ -136,7 +187,7 @@ const previewFooter = computed(() => { const start = String(props.config.footerYearStart || '').trim() const end = String(props.config.footerYearEnd || '').trim() const year = start && end && start !== end ? `${start}~${end}` : start || end || currentYear - return `© ${year} Made by ${props.config.userName || '用户'}` + return `© ${year} ${props.config.footerLabel || 'Made by'} ${props.config.userName || '用户'}` }) watch(() => props.config.profileImageURL, () => { previewImageFailed.value = false }) @@ -165,8 +216,10 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal .switch-field small { color: var(--text-muted); font-size: 12px; font-weight: 400; } .field input, .field textarea, +.field select, .rotating-row input { width: 100%; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-color); background: var(--surface-muted); } .field input { height: 40px; padding: 0 11px; } +.field select { height: 40px; padding: 0 11px; } .field textarea { padding: 10px 11px; resize: vertical; } .field input:focus, .field textarea:focus, @@ -182,7 +235,16 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal .rotating-row button { width: 36px; height: 36px; border: 1px solid transparent; border-radius: 6px; color: var(--danger-color); background: transparent; cursor: pointer; } .rotating-row button:hover:not(:disabled) { border-color: currentColor; background: rgba(201, 54, 43, 0.07); } .rotating-row button:disabled { opacity: 0.35; cursor: not-allowed; } +.about-links-editor { display: flex; flex-direction: column; gap: 10px; } +.about-link-row { display: grid; grid-template-columns: minmax(90px, 0.8fr) minmax(90px, 0.9fr) minmax(150px, 1.4fr) minmax(90px, 0.7fr) 36px; gap: 7px; align-items: center; } +.about-link-row input { min-width: 0; height: 38px; padding: 0 9px; } +.about-link-row button { width: 36px; height: 36px; border: 1px solid transparent; border-radius: 6px; color: var(--danger-color); background: transparent; cursor: pointer; } +.about-link-row button:hover { border-color: currentColor; background: rgba(201, 54, 43, 0.07); } .inline-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 10px; } +.analytics-state { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-left: 3px solid var(--success-color); color: var(--text-muted); background: var(--surface-muted); font-size: 12px; } +.analytics-state i { color: var(--success-color); } +.analytics-state--warning { border-left-color: var(--warning-color); } +.analytics-state--warning i { color: var(--warning-color); } .primary-button, .secondary-button { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border-radius: 6px; cursor: pointer; } .primary-button { border: 1px solid #d1a700; color: #292100; background: var(--hover-link-color); font-weight: 700; } @@ -234,5 +296,8 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal .field--full { grid-column: auto; } .inline-actions { flex-direction: column-reverse; } .inline-actions button { width: 100%; } + .about-link-row { grid-template-columns: 1fr 36px; } + .about-link-row input { grid-column: 1; } + .about-link-row button { grid-column: 2; grid-row: 1 / span 4; } } diff --git a/src/composables/useAnalytics.js b/src/composables/useAnalytics.js new file mode 100644 index 0000000..21f4a62 --- /dev/null +++ b/src/composables/useAnalytics.js @@ -0,0 +1,81 @@ +import api from '../api' + +let scriptSignature = '' +let scriptPromise = null +const trackedPages = new Set() + +function removeUmamiScript() { + document.getElementById('umami-script')?.remove() + try { window.umami = undefined } catch {} + scriptSignature = '' + scriptPromise = null +} + +function setOptionalAttribute(script, name, value) { + if (value) script.setAttribute(name, String(value)) + else script.removeAttribute(name) +} + +function loadUmamiScript(config) { + const src = String(config.umamiScript || '').trim() + const websiteId = String(config.umamiWebsiteId || '').trim() + if (config.analyticsProvider !== 'umami' || !src || !websiteId) { + removeUmamiScript() + return Promise.resolve(false) + } + + const signature = JSON.stringify({ + src, websiteId, domains: config.umamiDomains, doNotTrack: config.umamiDoNotTrack, + excludeSearch: config.umamiExcludeSearch, excludeHash: config.umamiExcludeHash, + performance: config.umamiPerformance, tag: config.umamiTag, + }) + const existing = document.getElementById('umami-script') + if (existing && scriptSignature === signature) return scriptPromise || Promise.resolve(true) + removeUmamiScript() + + scriptPromise = new Promise((resolve) => { + const script = document.createElement('script') + script.id = 'umami-script' + script.defer = true + script.src = src + script.setAttribute('data-website-id', websiteId) + script.setAttribute('data-auto-track', 'false') + setOptionalAttribute(script, 'data-domains', String(config.umamiDomains || '').trim()) + setOptionalAttribute(script, 'data-do-not-track', config.umamiDoNotTrack ? 'true' : '') + setOptionalAttribute(script, 'data-exclude-search', config.umamiExcludeSearch ? 'true' : '') + setOptionalAttribute(script, 'data-exclude-hash', config.umamiExcludeHash ? 'true' : '') + setOptionalAttribute(script, 'data-performance', config.umamiPerformance ? 'true' : '') + setOptionalAttribute(script, 'data-tag', String(config.umamiTag || '').trim()) + script.addEventListener('load', () => resolve(true), { once: true }) + script.addEventListener('error', () => resolve(false), { once: true }) + document.head.appendChild(script) + }) + scriptSignature = signature + return scriptPromise +} + +export async function applyAnalytics(config) { + if (config.analyticsProvider !== 'umami') { + removeUmamiScript() + return false + } + return loadUmamiScript(config) +} + +export async function recordHomePageView(config) { + const trackerKey = config.analyticsProvider === 'umami' + ? `umami:${config.umamiWebsiteId}:${config.umamiScript}` + : 'local' + const pageKey = `${trackerKey}:${window.location.pathname}${window.location.search}${window.location.hash}` + if (trackedPages.has(pageKey)) return + if (config.analyticsProvider === 'umami') { + const loaded = await applyAnalytics(config) + if (loaded && window.umami?.track) { + window.umami.track() + trackedPages.add(pageKey) + } + return + } + trackedPages.add(pageKey) + api.post('/track-visit', { path: window.location.pathname, referer: document.referrer || '' }).catch(() => {}) +} diff --git a/src/composables/usePublicConfig.js b/src/composables/usePublicConfig.js new file mode 100644 index 0000000..69f5e14 --- /dev/null +++ b/src/composables/usePublicConfig.js @@ -0,0 +1,70 @@ +import { reactive, ref } from 'vue' +import { getSiteConfig } from '../api' + +const defaultTexts = [ + '你好鸭,欢迎来到我的主页!!', + '随时可以联系我,期待与你交流。', + '愿你历尽千帆,归来仍是少年。', + '梦想还是要有的,万一实现了呢?', + 'I hope you have a happy day every day.', +] + +const defaultConfig = { + siteName: '个人主页', siteURL: 'https://example.com', siteIcon: '/favicon.ico', + siteDescription: '一个基于Vue3的个人主页', siteKeywords: '个人主页,Vue3', userName: '用户', + profileImageURL: '', icpNumber: '', policeNumber: '', pageTitle: '个人主页', favicon: '/favicon.ico', + iconLibrary: '', fontLibrary: '', footerYearStart: '', footerYearEnd: '', showVisitTimer: true, + rotatingTexts: defaultTexts, greetingText: 'Hi,', onlineStatusText: '在线中', footerLabel: 'Made by', + showAbout: true, showSites: true, showContacts: true, showThemeToggle: true, showFooter: true, + aboutTitle: '关于本站', aboutDescription: '', aboutLinks: [], sitePageSize: 6, openLinksInNewTab: true, + analyticsProvider: 'local', umamiScript: '', umamiScriptUrl: '', umamiWebsiteId: '', umamiDomains: '', umamiDoNotTrack: true, + umamiExcludeSearch: false, umamiExcludeHash: false, umamiPerformance: false, umamiTag: '', +} + +export const publicConfig = reactive({ ...defaultConfig }) +export const configLoading = ref(false) +let pendingRequest = null + +function normalizeConfig(value = {}) { + const trackerOptions = value.umamiTrackerOptions || {} + return { + ...defaultConfig, + ...value, + umamiScript: value.umamiScriptUrl || value.umamiScript || '', + umamiScriptUrl: value.umamiScriptUrl || value.umamiScript || '', + umamiDomains: trackerOptions.domains ?? value.umamiDomains ?? '', + umamiDoNotTrack: trackerOptions.doNotTrack ?? value.umamiDoNotTrack ?? true, + umamiExcludeSearch: trackerOptions.excludeSearch ?? value.umamiExcludeSearch ?? false, + umamiExcludeHash: trackerOptions.excludeHash ?? value.umamiExcludeHash ?? false, + umamiPerformance: trackerOptions.performance ?? value.umamiPerformance ?? false, + umamiTag: trackerOptions.tag ?? value.umamiTag ?? '', + rotatingTexts: Array.isArray(value.rotatingTexts) && value.rotatingTexts.length ? [...value.rotatingTexts] : [...defaultTexts], + aboutLinks: Array.isArray(value.aboutLinks) ? value.aboutLinks.map((link) => ({ ...link })) : [], + showVisitTimer: value.showVisitTimer === undefined ? true : Boolean(value.showVisitTimer), + showAbout: value.showAbout === undefined ? true : Boolean(value.showAbout), + showSites: value.showSites === undefined ? true : Boolean(value.showSites), + showContacts: value.showContacts === undefined ? true : Boolean(value.showContacts), + showThemeToggle: value.showThemeToggle === undefined ? true : Boolean(value.showThemeToggle), + showFooter: value.showFooter === undefined ? true : Boolean(value.showFooter), + openLinksInNewTab: value.openLinksInNewTab === undefined ? true : Boolean(value.openLinksInNewTab), + } +} + +export async function loadPublicConfig(force = false) { + if (pendingRequest && !force) return pendingRequest + configLoading.value = true + pendingRequest = getSiteConfig() + .then(({ data }) => { + Object.assign(publicConfig, normalizeConfig(data)) + return publicConfig + }) + .finally(() => { + configLoading.value = false + pendingRequest = null + }) + return pendingRequest +} + +export function resetPublicConfig(value = {}) { + Object.assign(publicConfig, normalizeConfig(value)) +} diff --git a/src/utils/frontendConfig.js b/src/utils/frontendConfig.js index b967187..45b3cd4 100644 --- a/src/utils/frontendConfig.js +++ b/src/utils/frontendConfig.js @@ -1,95 +1,80 @@ -import { getFrontendConfig } from '../api' - -/** - * 从API获取前端配置并更新页面 - */ -export async function loadAndApplyFrontendConfig() { - try { - const res = await getFrontendConfig() - const config = res.data - - // 更新页面标题 - if (config.title) { - document.title = config.title - } - - // 更新meta标签 - if (config.keywords) { - updateMetaTag('keywords', config.keywords) - } - if (config.description) { - updateMetaTag('description', config.description) - } - - // 更新favicon - if (config.favicon) { - updateFavicon(config.favicon) - } - - // 动态加载图标库 - if (config.iconLibrary) { - loadStylesheet(config.iconLibrary, 'icon-library') - } - - // 动态加载字体库 - if (config.fontLibrary) { - loadStylesheet(config.fontLibrary, 'font-library') - } - - // 动态加载Umami统计脚本 - if (config.umamiScript && config.umamiWebsiteId) { - loadUmamiScript(config.umamiScript, config.umamiWebsiteId) - } - } catch (error) { - console.error('加载前端配置失败:', error) - // 如果API失败,使用默认值(从环境变量或index.html中的占位符) - } -} - -function updateMetaTag(name, content) { - if (!content) return - - let meta = document.querySelector(`meta[name="${name}"]`) - if (!meta) { - meta = document.createElement('meta') - meta.setAttribute('name', name) - document.head.appendChild(meta) - } - meta.setAttribute('content', content) -} - -function updateFavicon(href) { - let link = document.querySelector("link[rel*='icon']") - if (!link) { - link = document.createElement('link') - link.rel = 'icon' - document.head.appendChild(link) - } - link.href = href -} - -function loadStylesheet(href, id) { - // 检查是否已加载 - if (document.getElementById(id)) { - return - } - - const link = document.createElement('link') - link.id = id - link.rel = 'stylesheet' - link.href = href.startsWith('//') ? `https:${href}` : href - document.head.appendChild(link) -} - -function loadUmamiScript(src, websiteId) { - // 检查是否已加载 - if (document.querySelector(`script[data-website-id="${websiteId}"]`)) { - return - } - - const script = document.createElement('script') - script.defer = true - script.src = src - script.setAttribute('data-website-id', websiteId) - document.head.appendChild(script) -} +import { getFrontendConfig } from '../api' + +export async function loadAndApplyFrontendConfig() { + try { + const { data: config } = await getFrontendConfig() + if (config.title) document.title = config.title + updateMetaTag('keywords', config.keywords || '') + updateMetaTag('description', config.description || '') + updateMetaProperty('og:title', config.title || '') + updateMetaProperty('og:site_name', config.siteName || '') + updateMetaProperty('og:description', config.description || '') + updateMetaProperty('og:url', config.siteURL || window.location.href) + updateCanonical(config.siteURL) + if (config.favicon) updateFavicon(config.favicon) + updateStylesheet(config.iconLibrary, 'icon-library') + updateStylesheet(config.fontLibrary, 'font-library') + } catch (error) { + console.error('加载前端配置失败:', error) + } +} + +function updateMetaTag(name, content) { + let meta = document.querySelector(`meta[name="${name}"]`) + if (!meta) { + meta = document.createElement('meta') + meta.setAttribute('name', name) + document.head.appendChild(meta) + } + meta.setAttribute('content', content) +} + +function updateMetaProperty(property, content) { + let meta = document.querySelector(`meta[property="${property}"]`) + if (!meta) { + meta = document.createElement('meta') + meta.setAttribute('property', property) + document.head.appendChild(meta) + } + meta.setAttribute('content', content) +} + +function updateCanonical(href) { + if (!href) return + let link = document.querySelector('link[rel="canonical"]') + if (!link) { + link = document.createElement('link') + link.rel = 'canonical' + document.head.appendChild(link) + } + link.href = href +} + +function updateFavicon(href) { + let link = document.querySelector("link[rel*='icon']") + if (!link) { + link = document.createElement('link') + link.rel = 'icon' + document.head.appendChild(link) + } + link.href = href +} + +function updateStylesheet(href, id) { + const existing = document.getElementById(id) + if (!href) { + existing?.remove() + return + } + const normalizedHref = href.startsWith('//') ? `https:${href}` : href + if (!/^https?:\/\//i.test(normalizedHref) && !normalizedHref.startsWith('/')) return + if (existing) { + if (existing.href !== new URL(normalizedHref, document.baseURI).href) existing.href = normalizedHref + return + } + const link = document.createElement('link') + link.id = id + link.rel = 'stylesheet' + link.href = normalizedHref + document.head.appendChild(link) +} diff --git a/vite.config.js b/vite.config.js index 416d0f6..c8bb012 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,8 @@ import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; +const apiPort = process.env.API_PORT || '1551'; + export default defineConfig({ plugins: [vue()], css: { @@ -14,13 +16,13 @@ export default defineConfig({ port: 1552, proxy: { '/api': { - target: 'http://localhost:1551', + target: `http://localhost:${apiPort}`, changeOrigin: true, }, '/uploads': { - target: 'http://localhost:1551', + target: `http://localhost:${apiPort}`, changeOrigin: true, }, }, }, -}); \ No newline at end of file +});