package service import ( "context" "errors" "fmt" "io" "net" "net/http" "net/url" "regexp" "strings" "time" ) const ( reciprocalCheckTimeout = 8 * time.Second // 整次检测硬上限(DNS / 抓取 / 解析) reciprocalFetchTimeout = 5 * time.Second reciprocalMaxBodyBytes = 512 * 1024 reciprocalMaxHrefs = 4000 ) var hrefRe = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`) // VerifyReciprocalLink 检测页面 HTML 是否包含指向本站的链接 func VerifyReciprocalLink(pageURL, ourSiteURL string) (verified bool, note string) { ctx, cancel := context.WithTimeout(context.Background(), reciprocalCheckTimeout) defer cancel() return verifyReciprocalLink(ctx, pageURL, ourSiteURL) } func verifyReciprocalLink(ctx context.Context, pageURL, ourSiteURL string) (verified bool, note string) { pageURL = strings.TrimSpace(pageURL) ourSiteURL = strings.TrimSpace(ourSiteURL) if pageURL == "" { return false, "未提供回链页地址" } if ourSiteURL == "" { return false, "本站 URL 未配置" } pageParsed, err := normalizeFriendLinkApplyURL(pageURL) if err != nil { return false, err.Error() } ourParsed, err := url.Parse(ourSiteURL) if err != nil || ourParsed.Host == "" { return false, "本站 URL 无效" } ourHost := strings.ToLower(strings.TrimSuffix(ourParsed.Host, ":443")) ourHost = strings.TrimSuffix(ourHost, ":80") if err := assertSafeFetchURL(ctx, pageParsed); err != nil { return false, err.Error() } body, err := fetchHTMLBody(ctx, pageParsed) if err != nil { if isTimeoutErr(err) || ctx.Err() != nil { return false, "访问回链页超时" } return false, fmt.Sprintf("无法访问回链页:%v", err) } if ctx.Err() != nil { return false, "访问回链页超时" } if pageContainsLinkToHost(body, pageParsed, ourParsed, ourHost) { return true, "已检测到本站链接" } return false, "未在该页面检测到指向本站的链接" } func isTimeoutErr(err error) bool { if err == nil { return false } if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return true } var ne net.Error return errors.As(err, &ne) && ne.Timeout() } func assertSafeFetchURL(ctx context.Context, raw string) error { u, err := url.Parse(raw) if err != nil { return fmt.Errorf("URL 无效") } if u.Scheme != "http" && u.Scheme != "https" { return fmt.Errorf("仅支持 http/https") } host := u.Hostname() if host == "" { return fmt.Errorf("URL 无效") } lower := strings.ToLower(host) if lower == "localhost" || strings.HasSuffix(lower, ".localhost") || lower == "0.0.0.0" { return fmt.Errorf("不允许访问内网地址") } ips, err := lookupHostIPs(ctx, host) if err != nil { if isTimeoutErr(err) { return fmt.Errorf("解析域名超时") } return fmt.Errorf("无法解析域名") } for _, ip := range ips { if isPrivateOrLoopbackIP(ip) { return fmt.Errorf("不允许访问内网地址") } } return nil } func lookupHostIPs(ctx context.Context, host string) ([]net.IP, error) { addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, err } ips := make([]net.IP, 0, len(addrs)) for _, a := range addrs { if a.IP != nil { ips = append(ips, a.IP) } } return ips, nil } func isPrivateOrLoopbackIP(ip net.IP) bool { if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return true } if ip4 := ip.To4(); ip4 != nil { return ip4[0] == 10 || (ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) || (ip4[0] == 192 && ip4[1] == 168) || (ip4[0] == 127) || (ip4[0] == 169 && ip4[1] == 254) || (ip4[0] == 0) } return false } func fetchHTMLBody(ctx context.Context, rawURL string) (string, error) { client := &http.Client{ Timeout: reciprocalFetchTimeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return fmt.Errorf("重定向过多") } if err := assertSafeFetchURL(req.Context(), req.URL.String()); err != nil { return err } return nil }, } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return "", err } req.Close = true req.Header.Set("User-Agent", "Jiang13Forum-FriendLinkCheck/1.0") resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 400 { return "", fmt.Errorf("HTTP %d", resp.StatusCode) } limited := io.LimitReader(resp.Body, reciprocalMaxBodyBytes+1) data, err := io.ReadAll(limited) if err != nil { return "", err } if len(data) > reciprocalMaxBodyBytes { return "", fmt.Errorf("页面过大") } return string(data), nil } func pageContainsLinkToHost(html, pageURL string, ourURL *url.URL, ourHost string) bool { ourHost = strings.ToLower(ourHost) ourPath := strings.TrimSuffix(ourURL.Path, "/") if ourPath == "" { ourPath = "/" } base, err := url.Parse(pageURL) if err != nil { return false } checkHref := func(href string) bool { href = strings.TrimSpace(href) if href == "" || strings.HasPrefix(strings.ToLower(href), "javascript:") || strings.HasPrefix(strings.ToLower(href), "mailto:") { return false } resolved, err := url.Parse(href) if err != nil { return false } resolved = base.ResolveReference(resolved) host := strings.ToLower(resolved.Hostname()) if host == "" { return false } host = strings.TrimSuffix(strings.TrimSuffix(host, ":443"), ":80") if host != ourHost { return false } path := strings.TrimSuffix(resolved.Path, "/") if path == "" { path = "/" } // 允许首页或完整路径匹配 if ourPath == "/" || path == ourPath || strings.HasPrefix(path, ourPath+"/") { return true } return path == "/" || ourPath == path } // 逐条扫描,命中即停;限制条数避免超大页面占用过多 CPU rest := html for i := 0; i < reciprocalMaxHrefs; i++ { loc := hrefRe.FindStringSubmatchIndex(rest) if loc == nil { break } if loc[2] >= 0 && loc[3] >= loc[2] && checkHref(rest[loc[2]:loc[3]]) { return true } if loc[1] <= 0 { break } rest = rest[loc[1]:] } // 兜底:页面源码中包含本站域名 lower := strings.ToLower(html) if strings.Contains(lower, ourHost) { return strings.Contains(lower, ourHost+"/") || strings.Contains(lower, "://"+ourHost) } return false } func normalizeFriendLinkLogo(raw string) (string, error) { logo := strings.TrimSpace(raw) if logo == "" { return "", fmt.Errorf("请填写或上传网站 LOGO") } if len(logo) > maxFriendLinkURL { return "", fmt.Errorf("LOGO 地址过长") } if strings.HasPrefix(logo, "/uploads/") { return logo, nil } return normalizeFriendLinkApplyURL(logo) } // normalizeFriendLinkLogoOptional LOGO 可选(友链列表项) func normalizeFriendLinkLogoOptional(raw string) string { logo, err := normalizeFriendLinkLogo(raw) if err != nil { return "" } return logo }