feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 06:23:54 +08:00
parent df19752a1e
commit 2208af7070
80 changed files with 9620 additions and 641 deletions

View 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
}