feat: 管理端网站监控,浏览量写入独立 monitor.db
请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,16 +9,16 @@ func TestNormalizeAsideWidgetsPreservesOrder(t *testing.T) {
|
||||
{ID: AsideWidgetRecentComments, Enabled: false},
|
||||
}
|
||||
out := NormalizeAsideWidgets(in)
|
||||
if len(out) != 4 {
|
||||
t.Fatalf("want 4 widgets, got %d", len(out))
|
||||
if len(out) != 5 {
|
||||
t.Fatalf("want 5 widgets, got %d", len(out))
|
||||
}
|
||||
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers}
|
||||
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetShowcase}
|
||||
for i, id := range want {
|
||||
if out[i].ID != id {
|
||||
t.Fatalf("index %d: want %s, got %s", i, id, out[i].ID)
|
||||
}
|
||||
}
|
||||
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled {
|
||||
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled || out[4].Enabled {
|
||||
t.Fatalf("enabled flags mismatch: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func NewBackupService(dbPath, dataDir string) *BackupService {
|
||||
return &BackupService{dbPath: dbPath, dataDir: dataDir}
|
||||
}
|
||||
|
||||
// ExportSQLite 导出 SQLite 备份文件到 data 目录
|
||||
// ExportSQLite 导出主库 SQLite 备份到 data 目录(不含 monitor.db)
|
||||
func (s *BackupService) ExportSQLite() (string, error) {
|
||||
src, err := os.Open(s.dbPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -162,6 +162,10 @@ func (c *CommunityService) trySendHeartbeat() {
|
||||
|
||||
// SendHeartbeatOnce 立即发送一次心跳;requestOrigin 可在管理端保存时传入以补全本站地址
|
||||
func (c *CommunityService) SendHeartbeatOnce(requestOrigin string) error {
|
||||
// 枢纽站(含官网)不出站上报,避免自己心跳给自己
|
||||
if c.settings.CommunityHubEnabled(requestOrigin) {
|
||||
return nil
|
||||
}
|
||||
cfg := c.settings.CommunityConfig()
|
||||
if !cfg.ReportEnabled {
|
||||
return nil
|
||||
@@ -228,9 +232,9 @@ func (c *CommunityService) buildPayload(requestOrigin string) (*CommunityHeartbe
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReceiveHeartbeat 枢纽接收心跳并 upsert
|
||||
func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP string) error {
|
||||
if !c.settings.CommunityConfig().HubEnabled {
|
||||
// ReceiveHeartbeat 枢纽接收心跳并 upsert;requestHint 用于按请求 Host 识别官网
|
||||
func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP, requestHint string) error {
|
||||
if !c.settings.CommunityHubEnabled(requestHint) {
|
||||
return ErrCommunityHubDisabled
|
||||
}
|
||||
in.InstanceID = strings.TrimSpace(in.InstanceID)
|
||||
@@ -343,9 +347,9 @@ func (c *CommunityService) SetInstanceFeatured(instanceID string, in CommunityFe
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListShowcase 公开展柜:仅精选;枢纽关闭时返回空
|
||||
func (c *CommunityService) ListShowcase() ([]CommunityShowcaseItem, error) {
|
||||
if !c.settings.CommunityConfig().HubEnabled {
|
||||
// ListShowcase 公开展柜:仅精选;枢纽关闭时返回空;requestHint 用于按 Host 识别官网
|
||||
func (c *CommunityService) ListShowcase(requestHint string) ([]CommunityShowcaseItem, error) {
|
||||
if !c.settings.CommunityHubEnabled(requestHint) {
|
||||
return []CommunityShowcaseItem{}, nil
|
||||
}
|
||||
var rows []model.CommunityInstance
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestCommunityHeartbeatHubDisabled(t *testing.T) {
|
||||
Version: "1.0.0",
|
||||
Users: 1,
|
||||
Posts: 2,
|
||||
}, "127.0.0.1")
|
||||
}, "127.0.0.1", "https://other.example")
|
||||
if !errors.Is(err, ErrCommunityHubDisabled) {
|
||||
t.Fatalf("want ErrCommunityHubDisabled, got %v", err)
|
||||
}
|
||||
@@ -65,12 +65,12 @@ func TestCommunityHeartbeatAcceptAndList(t *testing.T) {
|
||||
Users: 10,
|
||||
Posts: 20,
|
||||
}
|
||||
if err := svc.ReceiveHeartbeat(payload, "203.0.113.9"); err != nil {
|
||||
if err := svc.ReceiveHeartbeat(payload, "203.0.113.9", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload.Users = 11
|
||||
payload.Posts = 21
|
||||
if err := svc.ReceiveHeartbeat(payload, "203.0.113.9"); err != nil {
|
||||
if err := svc.ReceiveHeartbeat(payload, "203.0.113.9", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestCommunityHeartbeatBadURL(t *testing.T) {
|
||||
InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
SiteURL: "javascript:alert(1)",
|
||||
SiteName: "坏",
|
||||
}, "127.0.0.1")
|
||||
}, "127.0.0.1", "")
|
||||
if !errors.Is(err, ErrCommunityBadPayload) {
|
||||
t.Fatalf("want ErrCommunityBadPayload, got %v", err)
|
||||
}
|
||||
@@ -204,11 +204,11 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
|
||||
SiteName: "示例论坛",
|
||||
Version: "2.0.0",
|
||||
}
|
||||
if err := svc.ReceiveHeartbeat(payload, "127.0.0.1"); err != nil {
|
||||
if err := svc.ReceiveHeartbeat(payload, "127.0.0.1", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
empty, err := svc.ListShowcase()
|
||||
empty, err := svc.ListShowcase("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -227,7 +227,7 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
|
||||
t.Fatalf("unexpected view: %+v", view)
|
||||
}
|
||||
|
||||
items, err := svc.ListShowcase()
|
||||
items, err := svc.ListShowcase("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -237,10 +237,10 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
|
||||
|
||||
// 心跳更新不得清掉精选
|
||||
payload.Users = 9
|
||||
if err := svc.ReceiveHeartbeat(payload, "127.0.0.1"); err != nil {
|
||||
if err := svc.ReceiveHeartbeat(payload, "127.0.0.1", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err = svc.ListShowcase()
|
||||
items, err = svc.ListShowcase("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
|
||||
}
|
||||
|
||||
settings.SetCommunityHubEnabled(false)
|
||||
items, err = svc.ListShowcase()
|
||||
items, err = svc.ListShowcase("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -281,3 +281,154 @@ func TestCommunitySiteURLFromOrigin(t *testing.T) {
|
||||
t.Fatalf("payload site_url=%s", payload.SiteURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityHubByOfficialHost(t *testing.T) {
|
||||
settings, svc := setupCommunityTest(t)
|
||||
|
||||
// 其它域名、未开运维开关 → 拒绝
|
||||
err := svc.ReceiveHeartbeat(CommunityHeartbeatPayload{
|
||||
InstanceID: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
|
||||
SiteURL: "https://forum.example.org",
|
||||
SiteName: "他站",
|
||||
Version: "1.0.0",
|
||||
}, "127.0.0.1", "https://other.example")
|
||||
if !errors.Is(err, ErrCommunityHubDisabled) {
|
||||
t.Fatalf("want disabled for other host, got %v", err)
|
||||
}
|
||||
|
||||
// 请求 Host 为官网 → 自动枢纽
|
||||
payload := CommunityHeartbeatPayload{
|
||||
InstanceID: "cccccccc-dddd-eeee-ffff-000000000001",
|
||||
SiteURL: "https://forum.example.org",
|
||||
SiteName: "上报站",
|
||||
Version: "1.1.0",
|
||||
Users: 3,
|
||||
Posts: 5,
|
||||
}
|
||||
if err := svc.ReceiveHeartbeat(payload, "203.0.113.1", "https://bbs.iioio.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !settings.CommunityHubEnabled("https://www.bbs.iioio.com") {
|
||||
t.Fatal("www 前缀也应识别为官网")
|
||||
}
|
||||
if !settings.CommunityConfigForRequest("https://bbs.iioio.com").HubEnabled {
|
||||
t.Fatal("CommunityConfigForRequest should enable hub for official host")
|
||||
}
|
||||
|
||||
list, err := svc.ListInstances()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("want 1 instance, got %d", len(list))
|
||||
}
|
||||
|
||||
// 已存 ROOT_URL 为官网时,无请求上下文也应开启
|
||||
if err := settings.setString(SettingOIDCRootURL, DefaultCommunityHubURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !settings.CommunityHubEnabled("") {
|
||||
t.Fatal("ROOT_URL as official should enable hub without request hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityHubSkipsOutboundHeartbeat(t *testing.T) {
|
||||
settings, svc := setupCommunityTest(t)
|
||||
var hits atomic.Int32
|
||||
|
||||
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
t.Cleanup(hub.Close)
|
||||
|
||||
prevHub := communityHubBaseURL
|
||||
communityHubBaseURL = hub.URL
|
||||
t.Cleanup(func() { communityHubBaseURL = prevHub })
|
||||
|
||||
if err := settings.setString(SettingOIDCRootURL, DefaultCommunityHubURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SendHeartbeatOnce(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatal("hub site must not send outbound heartbeat")
|
||||
}
|
||||
|
||||
// 运维开关开启同样跳过
|
||||
settings2, svc2 := setupCommunityTest(t)
|
||||
settings2.SetCommunityHubEnabled(true)
|
||||
if err := settings2.setString(SettingOIDCRootURL, "http://mirror.local"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := settings2.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prevHub2 := communityHubBaseURL
|
||||
communityHubBaseURL = hub.URL
|
||||
t.Cleanup(func() { communityHubBaseURL = prevHub2 })
|
||||
if err := svc2.SendHeartbeatOnce(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatal("ops hub must not send outbound heartbeat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostFromURLOrHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"https://bbs.iioio.com", "bbs.iioio.com"},
|
||||
{"https://www.bbs.iioio.com/", "bbs.iioio.com"},
|
||||
{"https://bbs.iioio.com:443/path", "bbs.iioio.com"},
|
||||
{"BBS.IIOIO.COM", "bbs.iioio.com"},
|
||||
{"https://other.example", "other.example"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := hostFromURLOrHost(tc.in); got != tc.want {
|
||||
t.Fatalf("hostFromURLOrHost(%q)=%q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsideShowcaseEntrySync(t *testing.T) {
|
||||
settings, _ := setupCommunityTest(t)
|
||||
if settings.NavShowShowcase() || settings.FooterShowShowcase() || settings.AsideShowShowcase() {
|
||||
t.Fatal("showcase entry should be off by default")
|
||||
}
|
||||
if err := settings.SetNavShowShowcase(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := settings.SetFooterShowShowcase(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := settings.SetAsideShowcaseEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !settings.NavShowShowcase() || !settings.FooterShowShowcase() || !settings.AsideShowShowcase() {
|
||||
t.Fatal("showcase entry should be enabled")
|
||||
}
|
||||
cfg := settings.Limits()
|
||||
if !cfg.NavShowShowcase || !cfg.FooterShowShowcase || !cfg.AsideShowShowcase {
|
||||
t.Fatalf("limits missing showcase flags: %+v", cfg)
|
||||
}
|
||||
found := false
|
||||
for _, w := range cfg.AsideWidgets {
|
||||
if w.ID == AsideWidgetShowcase {
|
||||
found = true
|
||||
if !w.Enabled {
|
||||
t.Fatal("aside showcase widget should be enabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("aside widgets should include showcase")
|
||||
}
|
||||
}
|
||||
|
||||
258
service/geo_zh.go
Normal file
258
service/geo_zh.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// countryZh ISO2 → 中文国名(展示用;存储仍用 ISO)
|
||||
var countryZh = map[string]string{
|
||||
"CN": "中国", "HK": "中国香港", "MO": "中国澳门", "TW": "中国台湾",
|
||||
"US": "美国", "JP": "日本", "KR": "韩国", "SG": "新加坡",
|
||||
"GB": "英国", "DE": "德国", "FR": "法国", "RU": "俄罗斯",
|
||||
"CA": "加拿大", "AU": "澳大利亚", "IN": "印度", "TH": "泰国",
|
||||
"VN": "越南", "MY": "马来西亚", "ID": "印度尼西亚", "PH": "菲律宾",
|
||||
"NL": "荷兰", "IT": "意大利", "ES": "西班牙", "BR": "巴西",
|
||||
"MX": "墨西哥", "SE": "瑞典", "CH": "瑞士", "PL": "波兰",
|
||||
"TR": "土耳其", "SA": "沙特阿拉伯", "AE": "阿联酋", "NZ": "新西兰",
|
||||
"IE": "爱尔兰", "BE": "比利时", "AT": "奥地利", "NO": "挪威",
|
||||
"DK": "丹麦", "FI": "芬兰", "PT": "葡萄牙", "CZ": "捷克",
|
||||
"UA": "乌克兰", "IL": "以色列", "ZA": "南非", "AR": "阿根廷",
|
||||
}
|
||||
|
||||
type cnRegionMeta struct {
|
||||
Name string
|
||||
ISO string
|
||||
}
|
||||
|
||||
// cnRegionMap 中国省级英文名/别名 → 中文名 + 地图 ISO
|
||||
var cnRegionMap = map[string]cnRegionMeta{
|
||||
"beijing": {Name: "北京", ISO: "BJ"},
|
||||
"peking": {Name: "北京", ISO: "BJ"},
|
||||
"tianjin": {Name: "天津", ISO: "TJ"},
|
||||
"shanghai": {Name: "上海", ISO: "SH"},
|
||||
"chongqing": {Name: "重庆", ISO: "CQ"},
|
||||
"chungking": {Name: "重庆", ISO: "CQ"},
|
||||
"hebei": {Name: "河北", ISO: "HE"},
|
||||
"shanxi": {Name: "山西", ISO: "SX"},
|
||||
"liaoning": {Name: "辽宁", ISO: "LN"},
|
||||
"jilin": {Name: "吉林", ISO: "JL"},
|
||||
"heilongjiang": {Name: "黑龙江", ISO: "HL"},
|
||||
"jiangsu": {Name: "江苏", ISO: "JS"},
|
||||
"zhejiang": {Name: "浙江", ISO: "ZJ"},
|
||||
"anhui": {Name: "安徽", ISO: "AH"},
|
||||
"fujian": {Name: "福建", ISO: "FJ"},
|
||||
"jiangxi": {Name: "江西", ISO: "JX"},
|
||||
"shandong": {Name: "山东", ISO: "SD"},
|
||||
"henan": {Name: "河南", ISO: "HA"},
|
||||
"hubei": {Name: "湖北", ISO: "HB"},
|
||||
"hunan": {Name: "湖南", ISO: "HN"},
|
||||
"guangdong": {Name: "广东", ISO: "GD"},
|
||||
"guangxi": {Name: "广西", ISO: "GX"},
|
||||
"hainan": {Name: "海南", ISO: "HI"},
|
||||
"sichuan": {Name: "四川", ISO: "SC"},
|
||||
"guizhou": {Name: "贵州", ISO: "GZ"},
|
||||
"yunnan": {Name: "云南", ISO: "YN"},
|
||||
"xizang": {Name: "西藏", ISO: "XZ"},
|
||||
"tibet": {Name: "西藏", ISO: "XZ"},
|
||||
"shaanxi": {Name: "陕西", ISO: "SN"},
|
||||
"shaanxi province": {Name: "陕西", ISO: "SN"},
|
||||
"gansu": {Name: "甘肃", ISO: "GS"},
|
||||
"qinghai": {Name: "青海", ISO: "QH"},
|
||||
"ningxia": {Name: "宁夏", ISO: "NX"},
|
||||
"xinjiang": {Name: "新疆", ISO: "XJ"},
|
||||
"inner mongolia": {Name: "内蒙古", ISO: "NM"},
|
||||
"nei mongol": {Name: "内蒙古", ISO: "NM"},
|
||||
"hong kong": {Name: "香港", ISO: "HK"},
|
||||
"macau": {Name: "澳门", ISO: "MO"},
|
||||
"macao": {Name: "澳门", ISO: "MO"},
|
||||
"taiwan": {Name: "台湾", ISO: "TW"},
|
||||
}
|
||||
|
||||
// cityZh 常见城市英文名 → 中文
|
||||
var cityZh = map[string]string{
|
||||
"beijing": "北京", "peking": "北京",
|
||||
"shanghai": "上海",
|
||||
"chongqing": "重庆", "chungking": "重庆",
|
||||
"tianjin": "天津",
|
||||
"hangzhou": "杭州",
|
||||
"shenzhen": "深圳",
|
||||
"guangzhou": "广州", "canton": "广州",
|
||||
"chengdu": "成都",
|
||||
"wuhan": "武汉",
|
||||
"xi'an": "西安", "xian": "西安",
|
||||
"nanjing": "南京", "nanking": "南京",
|
||||
"suzhou": "苏州",
|
||||
"qingdao": "青岛",
|
||||
"dalian": "大连",
|
||||
"ningbo": "宁波",
|
||||
"xiamen": "厦门", "amoy": "厦门",
|
||||
"changsha": "长沙",
|
||||
"zhengzhou": "郑州",
|
||||
"jinan": "济南",
|
||||
"harbin": "哈尔滨",
|
||||
"shenyang": "沈阳",
|
||||
"kunming": "昆明",
|
||||
"nanning": "南宁",
|
||||
"urumqi": "乌鲁木齐",
|
||||
"lhasa": "拉萨",
|
||||
"hohhot": "呼和浩特",
|
||||
"taipei": "台北",
|
||||
"hong kong": "香港",
|
||||
}
|
||||
|
||||
// asnOrgZh 常见 ASN / AS 组织名 → 中文运营商
|
||||
var asnOrgZh = map[uint]string{
|
||||
4134: "中国电信",
|
||||
4812: "中国电信",
|
||||
4837: "中国联通",
|
||||
4808: "中国联通",
|
||||
9808: "中国移动",
|
||||
56040: "中国移动",
|
||||
56041: "中国移动",
|
||||
56042: "中国移动",
|
||||
4538: "教育网",
|
||||
23910: "教育网",
|
||||
58539: "教育网",
|
||||
7497: "教育网",
|
||||
}
|
||||
|
||||
var asnOrgKeywordZh = []struct {
|
||||
kw string
|
||||
zh string
|
||||
}{
|
||||
{"chinanet", "中国电信"},
|
||||
{"china telecom", "中国电信"},
|
||||
{"chinatelecom", "中国电信"},
|
||||
{"unicom", "中国联通"},
|
||||
{"china unicom", "中国联通"},
|
||||
{"mobile", "中国移动"},
|
||||
{"china mobile", "中国移动"},
|
||||
{"cernet", "教育网"},
|
||||
{"education", "教育网"},
|
||||
}
|
||||
|
||||
// CountryLabelZh ISO2 → 中文国名(未知则返回原码)
|
||||
func CountryLabelZh(iso string) string {
|
||||
iso = strings.ToUpper(strings.TrimSpace(iso))
|
||||
if iso == "" {
|
||||
return ""
|
||||
}
|
||||
if v, ok := countryZh[iso]; ok {
|
||||
return v
|
||||
}
|
||||
return iso
|
||||
}
|
||||
|
||||
// ApplyGeoZh 就地补充中文省/市/运营商;country 保持 ISO2
|
||||
func ApplyGeoZh(g *GeoInfo) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.Country = normalizeCountryCode(g.Country)
|
||||
if g.Country == "CN" || g.Country == "HK" || g.Country == "MO" || g.Country == "TW" {
|
||||
mapCNRegionCity(g)
|
||||
}
|
||||
if g.ASN > 0 {
|
||||
if zh, ok := asnOrgZh[g.ASN]; ok {
|
||||
g.ASOrg = zh
|
||||
} else if g.ASOrg != "" {
|
||||
g.ASOrg = mapASOrgZh(g.ASOrg)
|
||||
}
|
||||
} else if g.ASOrg != "" {
|
||||
g.ASOrg = mapASOrgZh(g.ASOrg)
|
||||
}
|
||||
g.Region = truncateRunes(strings.TrimSpace(g.Region), 64)
|
||||
g.City = truncateRunes(strings.TrimSpace(g.City), 64)
|
||||
g.ASOrg = truncateRunes(strings.TrimSpace(g.ASOrg), 128)
|
||||
}
|
||||
|
||||
func mapCNRegionCity(g *GeoInfo) {
|
||||
if g.RegionISO == "" {
|
||||
if meta, ok := lookupCNRegion(g.Region); ok {
|
||||
g.Region = meta.Name
|
||||
g.RegionISO = meta.ISO
|
||||
}
|
||||
} else if g.Region != "" {
|
||||
if meta, ok := lookupCNRegion(g.Region); ok {
|
||||
if g.RegionISO == "" {
|
||||
g.RegionISO = meta.ISO
|
||||
}
|
||||
g.Region = meta.Name
|
||||
}
|
||||
} else if g.RegionISO != "" {
|
||||
for _, meta := range cnRegionMap {
|
||||
if meta.ISO == strings.ToUpper(g.RegionISO) {
|
||||
g.Region = meta.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.City != "" {
|
||||
if zh, ok := lookupCityZh(g.City); ok {
|
||||
g.City = zh
|
||||
}
|
||||
}
|
||||
// 直辖市:BIN 有时只给城市不给省
|
||||
if g.Region == "" && g.City != "" {
|
||||
switch g.City {
|
||||
case "北京", "上海", "天津", "重庆":
|
||||
g.Region = g.City
|
||||
if meta, ok := cnRegionMap[strings.ToLower(g.City)]; ok {
|
||||
g.RegionISO = meta.ISO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lookupCNRegion(name string) (cnRegionMeta, bool) {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
return cnRegionMeta{}, false
|
||||
}
|
||||
if meta, ok := cnRegionMap[key]; ok {
|
||||
return meta, true
|
||||
}
|
||||
// 去掉常见后缀再试
|
||||
for _, suffix := range []string{" province", " sheng", " autonomous region", " municipality"} {
|
||||
if strings.HasSuffix(key, suffix) {
|
||||
if meta, ok := cnRegionMap[strings.TrimSuffix(key, suffix)]; ok {
|
||||
return meta, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return cnRegionMeta{}, false
|
||||
}
|
||||
|
||||
func lookupCityZh(name string) (string, bool) {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if zh, ok := cityZh[key]; ok {
|
||||
return zh, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func mapASOrgZh(org string) string {
|
||||
l := strings.ToLower(strings.TrimSpace(org))
|
||||
if l == "" {
|
||||
return org
|
||||
}
|
||||
for _, rule := range asnOrgKeywordZh {
|
||||
if strings.Contains(l, rule.kw) {
|
||||
return rule.zh
|
||||
}
|
||||
}
|
||||
// "AS4134 Chinanet" 等形式
|
||||
if strings.HasPrefix(l, "as") {
|
||||
num := strings.TrimPrefix(l, "as")
|
||||
if i := strings.IndexByte(num, ' '); i >= 0 {
|
||||
num = num[:i]
|
||||
}
|
||||
if n, err := strconv.ParseUint(num, 10, 32); err == nil {
|
||||
if zh, ok := asnOrgZh[uint(n)]; ok {
|
||||
return zh
|
||||
}
|
||||
}
|
||||
}
|
||||
return org
|
||||
}
|
||||
333
service/geoip.go
Normal file
333
service/geoip.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ip2location/ip2location-go/v9"
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
)
|
||||
|
||||
// GeoInfo IP 地理与运营商解析结果
|
||||
type GeoInfo struct {
|
||||
Country string
|
||||
Region string // 省/州(写入前经 ApplyGeoZh 中文化)
|
||||
RegionISO string // 省/州 ISO,如 GD
|
||||
City string
|
||||
ASN uint
|
||||
ASOrg string
|
||||
}
|
||||
|
||||
type geoIPSuite struct {
|
||||
dataDir string
|
||||
mu sync.RWMutex
|
||||
binV4 *binHandle
|
||||
binV6 *binHandle
|
||||
asn *mmdbHandle
|
||||
country *mmdbHandle // 可选:BIN 未命中时国家兜底
|
||||
}
|
||||
|
||||
type binHandle struct {
|
||||
path string
|
||||
db *ip2location.DB
|
||||
mod time.Time
|
||||
}
|
||||
|
||||
type mmdbHandle struct {
|
||||
path string
|
||||
db *maxminddb.Reader
|
||||
mod time.Time
|
||||
}
|
||||
|
||||
func newGeoIPSuite(dataDir string) *geoIPSuite {
|
||||
s := &geoIPSuite{dataDir: dataDir}
|
||||
s.binV4 = &binHandle{path: filepath.Join(dataDir, "IP2LOCATION-LITE-DB3.BIN")}
|
||||
s.binV6 = &binHandle{path: filepath.Join(dataDir, "IP2LOCATION-LITE-DB3.IPV6.BIN")}
|
||||
s.asn = &mmdbHandle{path: filepath.Join(dataDir, "GeoLite2-ASN.mmdb")}
|
||||
s.country = &mmdbHandle{path: filepath.Join(dataDir, "GeoLite2-Country.mmdb")}
|
||||
s.openAll()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *geoIPSuite) openAll() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
openBIN(s.binV4)
|
||||
openBIN(s.binV6)
|
||||
openMMDB(s.asn)
|
||||
openMMDB(s.country)
|
||||
}
|
||||
|
||||
func openBIN(h *binHandle) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(h.path)
|
||||
if err != nil {
|
||||
closeBIN(h)
|
||||
return
|
||||
}
|
||||
if h.db != nil && st.ModTime().Equal(h.mod) {
|
||||
return
|
||||
}
|
||||
db, err := ip2location.OpenDB(h.path)
|
||||
if err != nil {
|
||||
closeBIN(h)
|
||||
return
|
||||
}
|
||||
closeBIN(h)
|
||||
h.db = db
|
||||
h.mod = st.ModTime()
|
||||
}
|
||||
|
||||
func closeBIN(h *binHandle) {
|
||||
if h != nil && h.db != nil {
|
||||
h.db.Close()
|
||||
h.db = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openMMDB(h *mmdbHandle) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(h.path)
|
||||
if err != nil {
|
||||
closeMMDB(h)
|
||||
return
|
||||
}
|
||||
if h.db != nil && st.ModTime().Equal(h.mod) {
|
||||
return
|
||||
}
|
||||
db, err := maxminddb.Open(h.path)
|
||||
if err != nil {
|
||||
closeMMDB(h)
|
||||
return
|
||||
}
|
||||
closeMMDB(h)
|
||||
h.db = db
|
||||
h.mod = st.ModTime()
|
||||
}
|
||||
|
||||
func closeMMDB(h *mmdbHandle) {
|
||||
if h != nil && h.db != nil {
|
||||
_ = h.db.Close()
|
||||
h.db = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *geoIPSuite) ReloadIfNeeded() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
reloadBIN(s.binV4)
|
||||
reloadBIN(s.binV6)
|
||||
reloadMMDB(s.asn)
|
||||
reloadMMDB(s.country)
|
||||
}
|
||||
|
||||
func reloadBIN(h *binHandle) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(h.path)
|
||||
if err != nil {
|
||||
closeBIN(h)
|
||||
return
|
||||
}
|
||||
if h.db != nil && st.ModTime().Equal(h.mod) {
|
||||
return
|
||||
}
|
||||
openBIN(h)
|
||||
}
|
||||
|
||||
func reloadMMDB(h *mmdbHandle) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(h.path)
|
||||
if err != nil {
|
||||
closeMMDB(h)
|
||||
return
|
||||
}
|
||||
if h.db != nil && st.ModTime().Equal(h.mod) {
|
||||
return
|
||||
}
|
||||
openMMDB(h)
|
||||
}
|
||||
|
||||
func (s *geoIPSuite) Close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
closeBIN(s.binV4)
|
||||
closeBIN(s.binV6)
|
||||
closeMMDB(s.asn)
|
||||
closeMMDB(s.country)
|
||||
}
|
||||
|
||||
func (s *geoIPSuite) BINV4Available() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.binV4 != nil && s.binV4.db != nil
|
||||
}
|
||||
|
||||
func (s *geoIPSuite) BINV6Available() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.binV6 != nil && s.binV6.db != nil
|
||||
}
|
||||
|
||||
func (s *geoIPSuite) ASNAvailable() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.asn != nil && s.asn.db != nil
|
||||
}
|
||||
|
||||
func (s *geoIPSuite) CountryAvailable() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.country != nil && s.country.db != nil
|
||||
}
|
||||
|
||||
func (s *geoIPSuite) AnyAvailable() bool {
|
||||
return s.BINV4Available() || s.BINV6Available() || s.ASNAvailable() || s.CountryAvailable()
|
||||
}
|
||||
|
||||
// Paths 返回 v4 BIN、v6 BIN、ASN、Country 兜底库路径
|
||||
func (s *geoIPSuite) Paths() (v4, v6, asn, country string) {
|
||||
return filepath.Join(s.dataDir, "IP2LOCATION-LITE-DB3.BIN"),
|
||||
filepath.Join(s.dataDir, "IP2LOCATION-LITE-DB3.IPV6.BIN"),
|
||||
filepath.Join(s.dataDir, "GeoLite2-ASN.mmdb"),
|
||||
filepath.Join(s.dataDir, "GeoLite2-Country.mmdb")
|
||||
}
|
||||
|
||||
type geoCountryOnlyRecord struct {
|
||||
Country struct {
|
||||
ISOCode string `maxminddb:"iso_code"`
|
||||
} `maxminddb:"country"`
|
||||
}
|
||||
|
||||
type geoASNRecord struct {
|
||||
AutonomousSystemNumber uint `maxminddb:"autonomous_system_number"`
|
||||
AutonomousSystemOrganization string `maxminddb:"autonomous_system_organization"`
|
||||
}
|
||||
|
||||
// Lookup 解析 IP 地理与 ASN(BIN 负责国家/省/市,ASN 独立查询)
|
||||
func (s *geoIPSuite) Lookup(ipStr string) GeoInfo {
|
||||
var out GeoInfo
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil || s == nil {
|
||||
return out
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
v4DB := (*ip2location.DB)(nil)
|
||||
v6DB := (*ip2location.DB)(nil)
|
||||
asnDB := (*maxminddb.Reader)(nil)
|
||||
countryDB := (*maxminddb.Reader)(nil)
|
||||
if s.binV4 != nil {
|
||||
v4DB = s.binV4.db
|
||||
}
|
||||
if s.binV6 != nil {
|
||||
v6DB = s.binV6.db
|
||||
}
|
||||
if s.asn != nil {
|
||||
asnDB = s.asn.db
|
||||
}
|
||||
if s.country != nil {
|
||||
countryDB = s.country.db
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
binDB := v6DB
|
||||
if ip.To4() != nil {
|
||||
binDB = v4DB
|
||||
}
|
||||
if binDB != nil {
|
||||
rec, err := binDB.Get_all(ipStr)
|
||||
if err == nil {
|
||||
out.Country = normalizeCountryCode(rec.Country_short)
|
||||
out.Region = strings.TrimSpace(rec.Region)
|
||||
out.City = strings.TrimSpace(rec.City)
|
||||
}
|
||||
}
|
||||
|
||||
if out.Country == "" && countryDB != nil {
|
||||
var rec geoCountryOnlyRecord
|
||||
if err := countryDB.Lookup(ip, &rec); err == nil {
|
||||
out.Country = normalizeCountryCode(rec.Country.ISOCode)
|
||||
}
|
||||
}
|
||||
|
||||
if asnDB != nil {
|
||||
var rec geoASNRecord
|
||||
if err := asnDB.Lookup(ip, &rec); err == nil {
|
||||
out.ASN = rec.AutonomousSystemNumber
|
||||
out.ASOrg = strings.TrimSpace(rec.AutonomousSystemOrganization)
|
||||
}
|
||||
}
|
||||
|
||||
ApplyGeoZh(&out)
|
||||
return out
|
||||
}
|
||||
|
||||
// GeoIPService 对外暴露的 Geo 查询(诊断命令等)
|
||||
type GeoIPService struct {
|
||||
inner *geoIPSuite
|
||||
}
|
||||
|
||||
// NewGeoIPService 打开数据目录下的 BIN/MMDB
|
||||
func NewGeoIPService(dataDir string) *GeoIPService {
|
||||
return &GeoIPService{inner: newGeoIPSuite(dataDir)}
|
||||
}
|
||||
|
||||
func (s *GeoIPService) Close() {
|
||||
if s != nil && s.inner != nil {
|
||||
s.inner.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GeoIPService) Lookup(ip string) GeoInfo {
|
||||
if s == nil || s.inner == nil {
|
||||
return GeoInfo{}
|
||||
}
|
||||
return s.inner.Lookup(ip)
|
||||
}
|
||||
|
||||
func (s *GeoIPService) Paths() (v4, v6, asn, country string) {
|
||||
if s == nil || s.inner == nil {
|
||||
return "", "", "", ""
|
||||
}
|
||||
return s.inner.Paths()
|
||||
}
|
||||
|
||||
func (s *GeoIPService) BINV4Available() bool {
|
||||
if s == nil || s.inner == nil {
|
||||
return false
|
||||
}
|
||||
return s.inner.BINV4Available()
|
||||
}
|
||||
|
||||
func (s *GeoIPService) BINV6Available() bool {
|
||||
if s == nil || s.inner == nil {
|
||||
return false
|
||||
}
|
||||
return s.inner.BINV6Available()
|
||||
}
|
||||
|
||||
func (s *GeoIPService) ASNAvailable() bool {
|
||||
if s == nil || s.inner == nil {
|
||||
return false
|
||||
}
|
||||
return s.inner.ASNAvailable()
|
||||
}
|
||||
|
||||
func (s *GeoIPService) CountryAvailable() bool {
|
||||
if s == nil || s.inner == nil {
|
||||
return false
|
||||
}
|
||||
return s.inner.CountryAvailable()
|
||||
}
|
||||
1225
service/monitor.go
Normal file
1225
service/monitor.go
Normal file
File diff suppressed because it is too large
Load Diff
85
service/monitor_test.go
Normal file
85
service/monitor_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeCountryCode(t *testing.T) {
|
||||
if normalizeCountryCode("cn") != "CN" {
|
||||
t.Fatal("expected CN")
|
||||
}
|
||||
if normalizeCountryCode("XX") != "" {
|
||||
t.Fatal("XX should be empty")
|
||||
}
|
||||
if normalizeCountryCode("USA") != "" {
|
||||
t.Fatal("USA should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipDefaults(t *testing.T) {
|
||||
rules := DefaultMonitorExcludeRules()
|
||||
for _, path := range []string{
|
||||
"/health", "/uploads/a.png", "/api/admin/monitor/overview",
|
||||
"/assets/app.js", "/admin", "/admin/dashboard", "/api/me", "/api/site-branding",
|
||||
} {
|
||||
skip := false
|
||||
lower := strings.ToLower(path)
|
||||
for _, rule := range rules {
|
||||
r := strings.ToLower(strings.TrimSpace(rule))
|
||||
if r == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(r, ".") {
|
||||
if strings.HasSuffix(lower, r) {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(lower, r) || lower == strings.TrimSuffix(r, "/") {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !skip {
|
||||
t.Fatalf("expected skip for %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePageViewPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
ok bool
|
||||
want string
|
||||
}{
|
||||
{"/", true, "/"},
|
||||
{"/post/1", true, "/post/1"},
|
||||
{"/post/1?x=1", true, "/post/1?x=1"},
|
||||
{"/admin", false, ""},
|
||||
{"/admin/dashboard", false, ""},
|
||||
{"/login", false, ""},
|
||||
{"/api/posts", false, ""},
|
||||
{"https://evil.com/", false, ""},
|
||||
{"", false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := NormalizePageViewPath(c.in)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Fatalf("NormalizePageViewPath(%q)=(%q,%v) want (%q,%v)", c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyUA(t *testing.T) {
|
||||
if classifyUA("Mozilla/5.0 Chrome/120", "browser") != "Chrome" {
|
||||
t.Fatal("browser")
|
||||
}
|
||||
if classifyUA("Mozilla/5.0 (Windows NT 10.0)", "os") != "Windows" {
|
||||
t.Fatal("os")
|
||||
}
|
||||
if classifyUA("iPhone", "device") != "Mobile" {
|
||||
t.Fatal("device")
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@ func (r *RateLimiter) limitFor(action string) int {
|
||||
if action == "community_heartbeat" {
|
||||
return 30
|
||||
}
|
||||
if action == "monitor_pageview" {
|
||||
return 120 // SPA 路由切换较频繁
|
||||
}
|
||||
return r.settings.RateLimitFor(action)
|
||||
}
|
||||
|
||||
@@ -67,6 +70,9 @@ func (r *RateLimiter) windowFor(action string) time.Duration {
|
||||
if action == "community_heartbeat" {
|
||||
return time.Hour
|
||||
}
|
||||
if action == "monitor_pageview" {
|
||||
return time.Minute
|
||||
}
|
||||
return time.Duration(r.settings.RateLimitWindowSec()) * time.Second
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -42,9 +43,12 @@ const (
|
||||
SettingAsideShowTagCloud = "aside_show_tag_cloud"
|
||||
SettingAsideShowRecentComments = "aside_show_recent_comments"
|
||||
SettingAsideShowFriendLinks = "aside_show_friend_links"
|
||||
SettingAsideShowShowcase = "aside_show_showcase"
|
||||
SettingAsideWidgets = "aside_widgets"
|
||||
SettingNavShowFriendLinks = "nav_show_friend_links"
|
||||
SettingFooterShowFriendLinks = "footer_show_friend_links"
|
||||
SettingNavShowShowcase = "nav_show_showcase"
|
||||
SettingFooterShowShowcase = "footer_show_showcase"
|
||||
SettingFeedListStyle = "feed_list_style"
|
||||
|
||||
// 伪静态键名见 permalink.go:SettingPermalinkEnabled / SettingPermalinkExt
|
||||
@@ -99,9 +103,18 @@ const (
|
||||
SettingCommunityHubURL = "community_hub_url"
|
||||
SettingCommunitySiteURL = "community_site_url" // 上报用的本站公开地址(可回退 OIDC ROOT_URL)
|
||||
|
||||
SettingMonitorEnabled = "monitor_enabled"
|
||||
SettingMonitorRetention = "monitor_retention_days"
|
||||
SettingMonitorAccessLogRetention = "monitor_access_log_retention_days"
|
||||
SettingMonitorExclude = "monitor_exclude_json"
|
||||
SettingMonitorTrustProxy = "monitor_trust_proxy"
|
||||
|
||||
// DefaultCommunityHubURL 官方演示站(社区枢纽默认地址)
|
||||
DefaultCommunityHubURL = "https://bbs.iioio.com"
|
||||
|
||||
// DefaultMonitorExcludeJSON 默认排除路径/后缀(JSON 数组)
|
||||
DefaultMonitorExcludeJSON = `["/admin","/api/admin/","/api/me","/api/site-branding","/health","/uploads/","/media/","/static/","/spa/","/api/admin/monitor","/api/monitor/pageview","/favicon.ico",".js",".css",".map",".woff",".woff2",".ttf",".png",".jpg",".jpeg",".gif",".webp",".avif",".svg",".ico"]`
|
||||
|
||||
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
|
||||
pageSizeAPIMax = 100
|
||||
)
|
||||
@@ -138,9 +151,12 @@ type ForumLimits struct {
|
||||
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
|
||||
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
|
||||
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
|
||||
AsideShowShowcase bool `json:"aside_show_showcase"`
|
||||
AsideWidgets []AsideWidget `json:"aside_widgets"`
|
||||
NavShowFriendLinks bool `json:"nav_show_friend_links"`
|
||||
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
|
||||
NavShowShowcase bool `json:"nav_show_showcase"`
|
||||
FooterShowShowcase bool `json:"footer_show_showcase"`
|
||||
|
||||
FeedListStyle string `json:"feed_list_style"`
|
||||
|
||||
@@ -159,6 +175,7 @@ const (
|
||||
AsideWidgetRecentComments = "recent_comments"
|
||||
AsideWidgetRecentUsers = "recent_users"
|
||||
AsideWidgetFriendLinks = "friend_links"
|
||||
AsideWidgetShowcase = "showcase"
|
||||
)
|
||||
|
||||
var asideWidgetDefaultOrder = []string{
|
||||
@@ -166,6 +183,7 @@ var asideWidgetDefaultOrder = []string{
|
||||
AsideWidgetRecentComments,
|
||||
AsideWidgetRecentUsers,
|
||||
AsideWidgetFriendLinks,
|
||||
AsideWidgetShowcase,
|
||||
}
|
||||
|
||||
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
||||
@@ -189,14 +207,20 @@ type ForumLimitsPublic struct {
|
||||
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
|
||||
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
|
||||
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
|
||||
AsideShowShowcase bool `json:"aside_show_showcase"`
|
||||
AsideWidgets []AsideWidget `json:"aside_widgets"`
|
||||
NavShowFriendLinks bool `json:"nav_show_friend_links"`
|
||||
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
|
||||
NavShowShowcase bool `json:"nav_show_showcase"`
|
||||
FooterShowShowcase bool `json:"footer_show_showcase"`
|
||||
|
||||
FeedListStyle string `json:"feed_list_style"`
|
||||
|
||||
PermalinkEnabled bool `json:"permalink_enabled"`
|
||||
PermalinkExt string `json:"permalink_ext"`
|
||||
|
||||
// MonitorPageview 是否接受前台路由 pageview 信标(与 monitor_enabled 同步)
|
||||
MonitorPageview bool `json:"monitor_pageview"`
|
||||
}
|
||||
|
||||
type settingDef struct {
|
||||
@@ -243,7 +267,8 @@ var asideSettingDefaults = map[string]string{
|
||||
SettingAsideShowTagCloud: "0",
|
||||
SettingAsideShowRecentComments: "0",
|
||||
SettingAsideShowFriendLinks: "1",
|
||||
SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true}]`,
|
||||
SettingAsideShowShowcase: "0",
|
||||
SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true},{"id":"showcase","enabled":false}]`,
|
||||
}
|
||||
|
||||
var mailSettingDefaults = map[string]string{
|
||||
@@ -289,6 +314,8 @@ var friendLinkSettingDefaults = map[string]string{
|
||||
SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
|
||||
SettingNavShowFriendLinks: "1",
|
||||
SettingFooterShowFriendLinks: "1",
|
||||
SettingNavShowShowcase: "0",
|
||||
SettingFooterShowShowcase: "0",
|
||||
}
|
||||
|
||||
var communitySettingDefaults = map[string]string{
|
||||
@@ -299,6 +326,14 @@ var communitySettingDefaults = map[string]string{
|
||||
SettingCommunitySiteURL: "",
|
||||
}
|
||||
|
||||
var monitorSettingDefaults = map[string]string{
|
||||
SettingMonitorEnabled: "0",
|
||||
SettingMonitorRetention: "30",
|
||||
SettingMonitorAccessLogRetention: "7",
|
||||
SettingMonitorExclude: DefaultMonitorExcludeJSON,
|
||||
SettingMonitorTrustProxy: "1",
|
||||
}
|
||||
|
||||
var siteBrandingDefaults = map[string]string{
|
||||
SettingSiteName: "姜十三论坛",
|
||||
SettingSiteSlogan: "拾三一隅,自在交流",
|
||||
@@ -392,15 +427,35 @@ type GiteaSyncConfig struct {
|
||||
RepoCount int64 `json:"repo_count"`
|
||||
}
|
||||
|
||||
// CommunityConfig 社区上报配置(HubEnabled 只读,来自运维配置)
|
||||
// CommunityConfig 社区上报配置(HubEnabled 只读:运维开关或官网域名)
|
||||
type CommunityConfig struct {
|
||||
ReportEnabled bool `json:"report_enabled"`
|
||||
HubEnabled bool `json:"hub_enabled"` // 只读:app.ini / 环境变量
|
||||
HubEnabled bool `json:"hub_enabled"` // 只读:app.ini / 环境变量 / 官网 Host
|
||||
HubURL string `json:"hub_url"`
|
||||
SiteURL string `json:"site_url"` // 上报用的本站公开地址
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
|
||||
// MonitorConfig 网站监控采集设置
|
||||
type MonitorConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RetentionDays int `json:"retention_days"` // page_views 保留
|
||||
AccessLogRetentionDays int `json:"access_log_retention_days"` // JSONL 请求日志保留
|
||||
ExcludeRules []string `json:"exclude_rules"`
|
||||
DefaultExcludeRules []string `json:"default_exclude_rules"` // 只读:恢复推荐规则
|
||||
TrustProxy bool `json:"trust_proxy"`
|
||||
AccessLogDir string `json:"access_log_dir"`
|
||||
IP2LocationV4Path string `json:"ip2location_v4_path"`
|
||||
IP2LocationV6Path string `json:"ip2location_v6_path"`
|
||||
IP2LocationV4Available bool `json:"ip2location_v4_available"`
|
||||
IP2LocationV6Available bool `json:"ip2location_v6_available"`
|
||||
GeoIPAvailable bool `json:"geoip_available"`
|
||||
GeoIPCountryPath string `json:"geoip_country_path"`
|
||||
GeoIPASNPath string `json:"geoip_asn_path"`
|
||||
GeoIPCountryAvailable bool `json:"geoip_country_available"`
|
||||
GeoIPASNAvailable bool `json:"geoip_asn_available"`
|
||||
}
|
||||
|
||||
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients)
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -505,6 +560,13 @@ func (s *ForumSettingsService) ensureDefaults() {
|
||||
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
for key, val := range monitorSettingDefaults {
|
||||
var count int64
|
||||
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||
if count == 0 {
|
||||
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) getString(key, fallback string) string {
|
||||
@@ -586,9 +648,12 @@ func (s *ForumSettingsService) Limits() ForumLimits {
|
||||
AsideShowTagCloud: bools.tagCloud,
|
||||
AsideShowRecentComments: bools.recentComments,
|
||||
AsideShowFriendLinks: bools.friendLinks,
|
||||
AsideShowShowcase: bools.showcase,
|
||||
AsideWidgets: widgets,
|
||||
NavShowFriendLinks: s.NavShowFriendLinks(),
|
||||
FooterShowFriendLinks: s.FooterShowFriendLinks(),
|
||||
NavShowShowcase: s.NavShowShowcase(),
|
||||
FooterShowShowcase: s.FooterShowShowcase(),
|
||||
|
||||
FeedListStyle: s.FeedListStyle(),
|
||||
|
||||
@@ -619,14 +684,19 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
|
||||
AsideShowTagCloud: limits.AsideShowTagCloud,
|
||||
AsideShowRecentComments: limits.AsideShowRecentComments,
|
||||
AsideShowFriendLinks: limits.AsideShowFriendLinks,
|
||||
AsideShowShowcase: limits.AsideShowShowcase,
|
||||
AsideWidgets: limits.AsideWidgets,
|
||||
NavShowFriendLinks: limits.NavShowFriendLinks,
|
||||
FooterShowFriendLinks: limits.FooterShowFriendLinks,
|
||||
NavShowShowcase: limits.NavShowShowcase,
|
||||
FooterShowShowcase: limits.FooterShowShowcase,
|
||||
|
||||
FeedListStyle: limits.FeedListStyle,
|
||||
|
||||
PermalinkEnabled: limits.PermalinkEnabled,
|
||||
PermalinkExt: limits.PermalinkExt,
|
||||
|
||||
MonitorPageview: s.MonitorEnabled(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,8 +739,11 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
||||
SettingAsideShowTagCloud: bools.tagCloud,
|
||||
SettingAsideShowRecentComments: bools.recentComments,
|
||||
SettingAsideShowFriendLinks: bools.friendLinks,
|
||||
SettingAsideShowShowcase: bools.showcase,
|
||||
SettingNavShowFriendLinks: in.NavShowFriendLinks,
|
||||
SettingFooterShowFriendLinks: in.FooterShowFriendLinks,
|
||||
SettingNavShowShowcase: in.NavShowShowcase,
|
||||
SettingFooterShowShowcase: in.FooterShowShowcase,
|
||||
SettingPermalinkEnabled: in.PermalinkEnabled,
|
||||
}
|
||||
for key, on := range boolUpdates {
|
||||
@@ -770,6 +843,26 @@ func (s *ForumSettingsService) FooterShowFriendLinks() bool {
|
||||
return s.getString(SettingFooterShowFriendLinks, "1") == "1"
|
||||
}
|
||||
|
||||
// NavShowShowcase 左侧栏「站点」是否展示开源展柜入口;缺省关闭
|
||||
func (s *ForumSettingsService) NavShowShowcase() bool {
|
||||
return s.getString(SettingNavShowShowcase, "0") == "1"
|
||||
}
|
||||
|
||||
// FooterShowShowcase 页脚是否展示开源展柜入口;缺省关闭
|
||||
func (s *ForumSettingsService) FooterShowShowcase() bool {
|
||||
return s.getString(SettingFooterShowShowcase, "0") == "1"
|
||||
}
|
||||
|
||||
// AsideShowShowcase 右侧栏是否展示开源展柜组件
|
||||
func (s *ForumSettingsService) AsideShowShowcase() bool {
|
||||
for _, w := range s.AsideWidgets() {
|
||||
if w.ID == AsideWidgetShowcase {
|
||||
return w.Enabled
|
||||
}
|
||||
}
|
||||
return s.getString(SettingAsideShowShowcase, "0") == "1"
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
|
||||
v := "0"
|
||||
if enabled {
|
||||
@@ -794,19 +887,44 @@ func (s *ForumSettingsService) SetFooterShowFriendLinks(enabled bool) error {
|
||||
return s.setString(SettingFooterShowFriendLinks, v)
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) SetNavShowShowcase(enabled bool) error {
|
||||
v := "0"
|
||||
if enabled {
|
||||
v = "1"
|
||||
}
|
||||
return s.setString(SettingNavShowShowcase, v)
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) SetFooterShowShowcase(enabled bool) error {
|
||||
v := "0"
|
||||
if enabled {
|
||||
v = "1"
|
||||
}
|
||||
return s.setString(SettingFooterShowShowcase, v)
|
||||
}
|
||||
|
||||
// SetAsideFriendLinksEnabled 更新右侧栏友链组件开关(与 aside_widgets 同步)
|
||||
func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
|
||||
return s.setAsideWidgetEnabled(AsideWidgetFriendLinks, SettingAsideShowFriendLinks, enabled)
|
||||
}
|
||||
|
||||
// SetAsideShowcaseEnabled 更新右侧栏开源展柜组件开关(与 aside_widgets 同步)
|
||||
func (s *ForumSettingsService) SetAsideShowcaseEnabled(enabled bool) error {
|
||||
return s.setAsideWidgetEnabled(AsideWidgetShowcase, SettingAsideShowShowcase, enabled)
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) setAsideWidgetEnabled(widgetID, boolSettingKey string, enabled bool) error {
|
||||
widgets := s.AsideWidgets()
|
||||
found := false
|
||||
for i := range widgets {
|
||||
if widgets[i].ID == AsideWidgetFriendLinks {
|
||||
if widgets[i].ID == widgetID {
|
||||
widgets[i].Enabled = enabled
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
widgets = append(widgets, AsideWidget{ID: AsideWidgetFriendLinks, Enabled: enabled})
|
||||
widgets = append(widgets, AsideWidget{ID: widgetID, Enabled: enabled})
|
||||
}
|
||||
widgets = NormalizeAsideWidgets(widgets)
|
||||
bools := asideBoolsFromWidgets(widgets)
|
||||
@@ -818,16 +936,28 @@ func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
|
||||
return err
|
||||
}
|
||||
v := "0"
|
||||
if bools.friendLinks {
|
||||
v = "1"
|
||||
switch widgetID {
|
||||
case AsideWidgetFriendLinks:
|
||||
if bools.friendLinks {
|
||||
v = "1"
|
||||
}
|
||||
case AsideWidgetShowcase:
|
||||
if bools.showcase {
|
||||
v = "1"
|
||||
}
|
||||
default:
|
||||
if enabled {
|
||||
v = "1"
|
||||
}
|
||||
}
|
||||
return s.setString(SettingAsideShowFriendLinks, v)
|
||||
return s.setString(boolSettingKey, v)
|
||||
}
|
||||
|
||||
type asideWidgetBools struct {
|
||||
tagCloud bool
|
||||
recentComments bool
|
||||
friendLinks bool
|
||||
showcase bool
|
||||
}
|
||||
|
||||
func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
|
||||
@@ -835,6 +965,7 @@ func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWi
|
||||
{ID: AsideWidgetTagCloud, Enabled: tagCloud},
|
||||
{ID: AsideWidgetRecentComments, Enabled: recentComments},
|
||||
{ID: AsideWidgetFriendLinks, Enabled: friendLinks},
|
||||
{ID: AsideWidgetShowcase, Enabled: false},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,6 +979,8 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
|
||||
out.recentComments = w.Enabled
|
||||
case AsideWidgetFriendLinks:
|
||||
out.friendLinks = w.Enabled
|
||||
case AsideWidgetShowcase:
|
||||
out.showcase = w.Enabled
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -855,7 +988,7 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
|
||||
|
||||
func isValidAsideWidgetID(id string) bool {
|
||||
switch id {
|
||||
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks:
|
||||
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks, AsideWidgetShowcase:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1131,20 +1264,75 @@ func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommunityConfig 读取社区上报配置
|
||||
// CommunityConfig 读取社区上报配置(无请求上下文;官网需靠已存 ROOT_URL / site_url)
|
||||
func (s *ForumSettingsService) CommunityConfig() CommunityConfig {
|
||||
s.mu.RLock()
|
||||
hubEnabled := s.communityHubEnabled
|
||||
s.mu.RUnlock()
|
||||
return s.CommunityConfigForRequest("")
|
||||
}
|
||||
|
||||
// CommunityConfigForRequest 读取社区配置;requestHint 可为 Origin / 公开 URL,用于识别官网 Host
|
||||
func (s *ForumSettingsService) CommunityConfigForRequest(requestHint string) CommunityConfig {
|
||||
return CommunityConfig{
|
||||
ReportEnabled: s.getString(SettingCommunityReportEnabled, "0") == "1",
|
||||
HubEnabled: hubEnabled,
|
||||
HubEnabled: s.CommunityHubEnabled(requestHint),
|
||||
HubURL: DefaultCommunityHubURL,
|
||||
SiteURL: s.CommunitySiteURL(""),
|
||||
SiteURL: s.CommunitySiteURL(requestHint),
|
||||
InstanceID: strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")),
|
||||
}
|
||||
}
|
||||
|
||||
// CommunityHubEnabled 是否作为社区枢纽:运维开关,或本站即为官方演示站域名
|
||||
func (s *ForumSettingsService) CommunityHubEnabled(requestHint string) bool {
|
||||
s.mu.RLock()
|
||||
ops := s.communityHubEnabled
|
||||
s.mu.RUnlock()
|
||||
if ops {
|
||||
return true
|
||||
}
|
||||
official := officialCommunityHubHost()
|
||||
if official == "" {
|
||||
return false
|
||||
}
|
||||
for _, cand := range []string{
|
||||
s.getString(SettingOIDCRootURL, ""),
|
||||
s.getString(SettingCommunitySiteURL, ""),
|
||||
requestHint,
|
||||
} {
|
||||
if hostFromURLOrHost(cand) == official {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// officialCommunityHubHost 官方枢纽规范化主机名(如 bbs.iioio.com)
|
||||
func officialCommunityHubHost() string {
|
||||
return hostFromURLOrHost(DefaultCommunityHubURL)
|
||||
}
|
||||
|
||||
// hostFromURLOrHost 从 URL 或裸 Host 提取规范化主机名(小写、去 www、去端口)
|
||||
func hostFromURLOrHost(raw string) string {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
raw = u.Host
|
||||
} else if i := strings.IndexAny(raw, "/?"); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(raw); err == nil {
|
||||
raw = h
|
||||
}
|
||||
return strings.TrimPrefix(raw, "www.")
|
||||
}
|
||||
|
||||
// CommunitySiteURL 上报用的本站公开地址:已持久化 > OIDC ROOT_URL > 请求 Origin
|
||||
func (s *ForumSettingsService) CommunitySiteURL(requestOrigin string) string {
|
||||
if u := normalizeRootURL(s.getString(SettingCommunitySiteURL, "")); u != "" {
|
||||
@@ -1199,6 +1387,104 @@ func (s *ForumSettingsService) UpdateCommunityConfig(in CommunityConfig) (wasRep
|
||||
return wasReportEnabled, nil
|
||||
}
|
||||
|
||||
// DefaultMonitorExcludeRules 导出推荐排除规则(供设置页「恢复默认」)
|
||||
func DefaultMonitorExcludeRules() []string {
|
||||
return parseMonitorExcludeJSON(DefaultMonitorExcludeJSON)
|
||||
}
|
||||
|
||||
func normalizeMonitorRetentionDays(v int) int {
|
||||
if v < 1 {
|
||||
return 1
|
||||
}
|
||||
if v > 365 {
|
||||
return 365
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// MonitorConfig 读取网站监控设置
|
||||
func (s *ForumSettingsService) MonitorConfig() MonitorConfig {
|
||||
retention, _ := strconv.Atoi(s.getString(SettingMonitorRetention, "30"))
|
||||
accessRetention, _ := strconv.Atoi(s.getString(SettingMonitorAccessLogRetention, "7"))
|
||||
return MonitorConfig{
|
||||
Enabled: s.getString(SettingMonitorEnabled, "0") == "1",
|
||||
RetentionDays: normalizeMonitorRetentionDays(retention),
|
||||
AccessLogRetentionDays: normalizeMonitorRetentionDays(accessRetention),
|
||||
ExcludeRules: parseMonitorExcludeJSON(s.getString(SettingMonitorExclude, DefaultMonitorExcludeJSON)),
|
||||
DefaultExcludeRules: DefaultMonitorExcludeRules(),
|
||||
TrustProxy: s.getString(SettingMonitorTrustProxy, "1") == "1",
|
||||
}
|
||||
}
|
||||
|
||||
// MonitorEnabled 采集是否开启
|
||||
func (s *ForumSettingsService) MonitorEnabled() bool {
|
||||
return s.getString(SettingMonitorEnabled, "0") == "1"
|
||||
}
|
||||
|
||||
// UpdateMonitorConfig 更新网站监控设置
|
||||
func (s *ForumSettingsService) UpdateMonitorConfig(in MonitorConfig) error {
|
||||
enabled := "0"
|
||||
if in.Enabled {
|
||||
enabled = "1"
|
||||
}
|
||||
retention := normalizeMonitorRetentionDays(in.RetentionDays)
|
||||
accessRetention := normalizeMonitorRetentionDays(in.AccessLogRetentionDays)
|
||||
trust := "0"
|
||||
if in.TrustProxy {
|
||||
trust = "1"
|
||||
}
|
||||
rules := in.ExcludeRules
|
||||
if rules == nil {
|
||||
rules = parseMonitorExcludeJSON(DefaultMonitorExcludeJSON)
|
||||
}
|
||||
raw, err := json.Marshal(normalizeMonitorExclude(rules))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]string{
|
||||
SettingMonitorEnabled: enabled,
|
||||
SettingMonitorRetention: strconv.Itoa(retention),
|
||||
SettingMonitorAccessLogRetention: strconv.Itoa(accessRetention),
|
||||
SettingMonitorExclude: string(raw),
|
||||
SettingMonitorTrustProxy: trust,
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setString(key, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMonitorExcludeJSON(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
raw = DefaultMonitorExcludeJSON
|
||||
}
|
||||
var rules []string
|
||||
if err := json.Unmarshal([]byte(raw), &rules); err != nil {
|
||||
_ = json.Unmarshal([]byte(DefaultMonitorExcludeJSON), &rules)
|
||||
}
|
||||
return normalizeMonitorExclude(rules)
|
||||
}
|
||||
|
||||
func normalizeMonitorExclude(rules []string) []string {
|
||||
seen := make(map[string]struct{}, len(rules))
|
||||
out := make([]string, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
r = strings.TrimSpace(r)
|
||||
if r == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[r]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r] = struct{}{}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StorageConfig 读取上传存储配置(含密钥明文,供内部使用)
|
||||
func (s *ForumSettingsService) StorageConfig() StorageConfig {
|
||||
secret := s.getString(SettingStorageSecretKey, "")
|
||||
|
||||
Reference in New Issue
Block a user