feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
269
service/friend_link_reciprocal.go
Normal file
269
service/friend_link_reciprocal.go
Normal file
@@ -0,0 +1,269 @@
|
||||
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)<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
|
||||
}
|
||||
Reference in New Issue
Block a user