Files
jiang13-forum/services/friend_link_reciprocal.go
freefire 3f50316ad0 fix: 完成 Gitea 目录改组收尾(import、构建与 LICENSE)
同步包路径与路由,去掉 SPA 构建步骤,对齐 Gitea 式 LICENSE,并更新规格/规则与占位 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 04:01:20 +08:00

270 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
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)<a[^>]+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
}