同步包路径与路由,去掉 SPA 构建步骤,对齐 Gitea 式 LICENSE,并更新规格/规则与占位 SSR。 Co-authored-by: Cursor <cursoragent@cursor.com>
270 lines
6.8 KiB
Go
270 lines
6.8 KiB
Go
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
|
||
}
|