Files

563 lines
16 KiB
Go
Raw Permalink 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 main
import (
"crypto/hmac"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"hash/fnv"
"math"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode"
)
func ms() int64 { return time.Now().UnixMilli() }
func today() string { return time.Now().Format("2006-01-02") }
// weekStart 本周一 0 点(本地时区)。
func weekStart() int64 {
t := time.Now()
wd := int(t.Weekday())
if wd == 0 {
wd = 7
}
d := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -(wd - 1))
return d.UnixMilli()
}
func dayStart() int64 {
t := time.Now()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).UnixMilli()
}
// 去掉 0/O/1/I 的 32 字符表;256 % 32 == 0,取模无偏。
const codeAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
func randBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b
}
func randHex(n int) string { return hex.EncodeToString(randBytes(n)) }
func randCode(n int) string {
b := randBytes(n)
for i := range b {
b[i] = codeAlphabet[int(b[i])%len(codeAlphabet)]
}
return string(b)
}
func randToken() string { return base64.RawURLEncoding.EncodeToString(randBytes(24)) }
// ---- 口令:PBKDF2-SHA256 ----
const pbkdfIters = 120000
func hashPassword(pw string) string {
salt := randBytes(16)
key, err := pbkdf2.Key(sha256.New, pw, salt, pbkdfIters, 32)
if err != nil {
panic(err)
}
return fmt.Sprintf("pbkdf2$%d$%s$%s", pbkdfIters, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key))
}
func checkPassword(hash, pw string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 4 || parts[0] != "pbkdf2" {
return false
}
iters, err := strconv.Atoi(parts[1])
if err != nil || iters < 1000 || iters > 10000000 {
return false
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[2])
want, err2 := base64.RawStdEncoding.DecodeString(parts[3])
if err1 != nil || err2 != nil || len(want) == 0 {
return false
}
got, err := pbkdf2.Key(sha256.New, pw, salt, iters, len(want))
if err != nil {
return false
}
return hmac.Equal(got, want)
}
// ---- 会话:siteID.exp.pwtag.hmac(pwtag 随口令变化,改口令后旧会话作废)----
func pwTag(passHash string) string { return hashToken(passHash)[:8] }
func signSession(secret []byte, siteID, exp int64, tag string) string {
payload := strconv.FormatInt(siteID, 10) + "." + strconv.FormatInt(exp, 10) + "." + tag
m := hmac.New(sha256.New, secret)
m.Write([]byte(payload))
return payload + "." + hex.EncodeToString(m.Sum(nil))[:40]
}
// parseSession 返回 siteID 与口令标签;无效返回 0。
func parseSession(secret []byte, v string) (int64, string) {
parts := strings.Split(v, ".")
if len(parts) != 4 {
return 0, ""
}
siteID, err1 := strconv.ParseInt(parts[0], 10, 64)
exp, err2 := strconv.ParseInt(parts[1], 10, 64)
if err1 != nil || err2 != nil || exp < time.Now().Unix() {
return 0, ""
}
if !hmac.Equal([]byte(signSession(secret, siteID, exp, parts[2])), []byte(v)) {
return 0, ""
}
return siteID, parts[2]
}
// ---- 金额:内部一律用 1e-8 整数(e8),字符串进出 ----
var reAmount = regexp.MustCompile(`^(\d{1,10})(?:\.(\d{1,8}))?$`)
func parseAmountE8(s string, maxDecimals int) (int64, error) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "+")
if strings.HasPrefix(s, ".") {
s = "0" + s
}
m := reAmount.FindStringSubmatch(s)
if m == nil {
return 0, errors.New("金额格式不对")
}
if len(m[2]) > maxDecimals {
return 0, fmt.Errorf("最多 %d 位小数", maxDecimals)
}
ip, _ := strconv.ParseInt(m[1], 10, 64)
frac := m[2] + strings.Repeat("0", 8-len(m[2]))
fp, _ := strconv.ParseInt(frac, 10, 64)
return ip*100000000 + fp, nil
}
func fmtE8(v int64) string {
neg := v < 0
if neg {
v = -v
}
s := strconv.FormatInt(v/100000000, 10)
if fp := v % 100000000; fp > 0 {
s += "." + strings.TrimRight(fmt.Sprintf("%08d", fp), "0")
}
if neg {
s = "-" + s
}
return s
}
// ---- 文本清洗 ----
func cleanText(s string, max int, multiline bool) string {
var b strings.Builder
for _, r := range s {
if r == '\n' && multiline {
b.WriteRune(r)
continue
}
if unicode.IsControl(r) || r == '\uFEFF' {
continue
}
b.WriteRune(r)
}
s = b.String()
if multiline {
lines := strings.Split(s, "\n")
for i := range lines {
lines[i] = strings.Join(strings.Fields(lines[i]), " ")
}
s = strings.Join(lines, "\n")
for strings.Contains(s, "\n\n\n") {
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
}
} else {
s = strings.Join(strings.Fields(s), " ")
}
if r := []rune(s); len(r) > max {
s = string(r[:max])
}
return strings.TrimSpace(s)
}
func nickKey(n string) string { return strings.ToLower(strings.Join(strings.Fields(n), " ")) }
var reSlug = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,23}$`)
var reservedSlugs = map[string]bool{
"admin": true, "manage": true, "api": true, "pay": true, "d": true, "new": true, "rank": true, "login": true, "logout": true,
"static": true, "bpg": true, "healthz": true, "u": true, "main": true, "www": true, "root": true, "assets": true,
"about": true, "help": true, "me": true, "site": true, "sites": true, "donate": true, "beggar": true, "binance": true,
"official": true, "qianyu": true, "x": true, "twitter": true, "robots.txt": true,
}
func validSlug(s string) bool { return reSlug.MatchString(s) && !reservedSlugs[s] }
// slugForHandle 站长路径 = X 用户名(小写);撞上保留字或不合规(单字符/下划线开头)就加前缀 x_。
func slugForHandle(handle string) string {
s := strings.ToLower(strings.TrimSpace(handle))
if validSlug(s) {
return s
}
return "x_" + s
}
var reBinanceOrder = regexp.MustCompile(`^\d{18}$`)
// reContact 广告/联系方式特征:网址、域名、微信/QQ/TG/WhatsApp、大陆手机号。
var reContact = regexp.MustCompile(`(?i)(https?://|www\.|[a-z0-9-]+\.(com|cn|net|org|io|xyz|top|vip|me|tv|cc|app|link|shop|site|club|pro|live|fun|icu)\b|微信|weixin|wechat|\bwx\b|(?:^|[^a-z])vx(?:[^a-z]|$)|v信|加v|威信|薇信|q{1,2}\s*[::]?\s*\d{5,}|qq群|telegram|\bt\.me\b|电报|\btg\s*[::]|whatsapp|\bwa\.me\b|line\s*id|1[3-9]\d{9})`)
// hasContact 判断文本里是否带链接或联系方式(拒绝,防广告)。
func hasContact(s string) bool { return reContact.MatchString(s) }
var reTweetID = regexp.MustCompile(`(?i)(?:x\.com|twitter\.com)/(?:[A-Za-z0-9_]{1,15}|i/web)/status(?:es)?/(\d{1,25})`)
// tweetIDFrom 从推文链接里取出 ID。
func tweetIDFrom(s string) string {
m := reTweetID.FindStringSubmatch(strings.TrimSpace(s))
if m == nil {
return ""
}
return m[1]
}
// tweetToken 模拟 X 嵌入组件的 token 算法:(id/1e15*π) 的 36 进制去掉 0 和小数点。
func tweetToken(id string) string {
n, err := strconv.ParseFloat(id, 64)
if err != nil {
return "x"
}
v := n / 1e15 * math.Pi
ip := math.Floor(v)
frac := v - ip
out := strconv.FormatInt(int64(ip), 36)
for i := 0; i < 12 && frac > 0; i++ {
frac *= 36
d := int(frac)
out += strconv.FormatInt(int64(d), 36)
frac -= float64(d)
}
return strings.NewReplacer("0", "", ".", "").Replace(out)
}
func normHandle(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "@")
if u, err := url.Parse(s); err == nil && u.Host != "" && xHosts[strings.ToLower(u.Hostname())] {
s = strings.Trim(u.Path, "/")
if i := strings.Index(s, "/"); i >= 0 {
s = s[:i]
}
}
if !reXHandle.MatchString(s) {
return ""
}
return s
}
var reUID = regexp.MustCompile(`^\d{1,20}$`)
// ---- 展示 ----
func ago(t int64) string {
if t <= 0 {
return ""
}
d := time.Since(time.UnixMilli(t))
switch {
case d < time.Minute:
return "刚刚"
case d < time.Hour:
return fmt.Sprintf("%d 分钟前", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%d 小时前", int(d.Hours()))
case d < 30*24*time.Hour:
return fmt.Sprintf("%d 天前", int(d.Hours()/24))
}
return time.UnixMilli(t).Format("2006-01-02")
}
// LevelInfo 丐帮袋位:按 EXP 分档(一袋弟子 … 九袋长老),Pct 为当前袋位内的进度。
// EXP = 钱(U) × MONEY_EXP + 钢镚 × COIN_EXP,默认 0.1 U = 1 EXP、1 钢镚 = 1 EXP。
type LevelInfo struct {
Lv int
Title string
Pct int
Next string // 升到下一袋需要的 EXP,空表示满级
NextT string // 下一袋名称
}
var levelSteps = []struct {
Min int64
Title string
}{{0, "空碗未入帮"}, {1, "一袋弟子"}, {10, "二袋弟子"}, {50, "三袋弟子"}, {200, "四袋弟子"}, {500, "五袋弟子"},
{1000, "六袋弟子"}, {3000, "七袋弟子"}, {10000, "八袋弟子"}, {50000, "九袋长老"}}
func levelOf(exp int64) LevelInfo {
lv := 0
for i, st := range levelSteps {
if exp >= st.Min {
lv = i
}
}
info := LevelInfo{Lv: lv, Title: levelSteps[lv].Title, Pct: 100}
if lv+1 < len(levelSteps) {
lo, hi := levelSteps[lv].Min, levelSteps[lv+1].Min
info.Pct = int((exp - lo) * 100 / (hi - lo))
info.Next = strconv.FormatInt(hi, 10)
info.NextT = levelSteps[lv+1].Title
}
if exp <= 0 {
info.Pct = 0
}
return info
}
// sectTitle 丐帮职位:按乞丐榜名次授予,掉出名次自动易主。pos 从 1 起,0 = 未上榜。
func sectTitle(pos int) string {
switch {
case pos <= 0:
return ""
case pos == 1:
return "帮主"
case pos == 2:
return "副帮主"
case pos == 3:
return "传功长老"
case pos == 4:
return "执法长老"
case pos == 5:
return "掌棒龙头"
case pos == 6:
return "掌钵龙头"
case pos <= 10:
return "长老"
case pos <= 30:
return "舵主"
}
return "弟子"
}
// satiety 饱食度:今天每收到 1 个币 +20%,封顶 100(每天清零,所以「他今天还没吃饭」)。
func satiety(todayE8 int64) int {
v := todayE8 * 20 / 1e8
if v > 100 {
return 100
}
return int(v)
}
// expOf 钱 + 钢镚折算成 EXP。
func expOf(totalE8, coins, moneyExp, coinExp int64) int64 {
return totalE8*moneyExp/100000000 + coins*coinExp
}
func beggarLevel(exp int64) string { return levelOf(exp).Title }
func donorTier(e8 int64) string {
switch {
case e8 < 1e8:
return "投了个硬币"
case e8 < 10*1e8:
return "善心人"
case e8 < 100*1e8:
return "大善人"
case e8 < 1000*1e8:
return "活菩萨"
}
return "财神爷"
}
func rankBadge(i int) string {
switch i {
case 0:
return "🥇"
case 1:
return "🥈"
case 2:
return "🥉"
}
return strconv.Itoa(i + 1)
}
// initial 取名号首字用作头像。
func initial(s string) string {
for _, r := range s {
return string(unicode.ToUpper(r))
}
return "?"
}
// hue 名号 → 稳定色相,头像配色用。
func hue(key string) int {
h := fnv.New32a()
h.Write([]byte(key))
return int(h.Sum32() % 360)
}
// ---- HTTP 辅助 ----
var reMobileUA = regexp.MustCompile(`(?i)android|iphone|ipad|ipod|mobile|harmonyos`)
func isMobile(r *http.Request) bool { return reMobileUA.MatchString(r.UserAgent()) }
// clientIP 访客 IP。开启 TRUST_PROXY 时,只有请求来自回环/内网(即反代)才信 header;
// X-Forwarded-For 取最后一跳(反代自己追加的那个,客户端伪造不了),其它头取整值。
func clientIP(r *http.Request, trustProxy bool, header string) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if !trustProxy {
return host
}
if ip := net.ParseIP(host); ip == nil || !(ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified()) {
return host
}
v := strings.TrimSpace(r.Header.Get(header))
if v == "" {
return host
}
if strings.EqualFold(header, "X-Forwarded-For") {
parts := strings.Split(v, ",")
v = strings.TrimSpace(parts[len(parts)-1])
}
if net.ParseIP(v) == nil {
return host
}
return v
}
// validateBinanceLink 只接受币安域名的 https 链接,防止有人借本站挂钓鱼链接。
var reHTTPS = regexp.MustCompile(`https://[^\s<>"'))]+`)
func validateBinanceLink(s string) (string, error) {
s = strings.TrimSpace(s)
if s == "" {
return "", nil
}
// 用户常把币安 App「分享」出来的整段文字一起贴进来:从里面挑出链接
if !strings.HasPrefix(strings.ToLower(s), "https://") {
if m := reHTTPS.FindString(s); m != "" {
s = m
}
}
if len(s) > 300 {
return "", errors.New("收款链接过长")
}
u, err := url.Parse(s)
if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" {
return "", errors.New("收款链接须为 https 链接")
}
h := strings.ToLower(u.Hostname())
if h != "binance.com" && !strings.HasSuffix(h, ".binance.com") {
return "", errors.New("收款链接必须是币安域名(app.binance.com/… 之类)")
}
return s, nil
}
var reXHandle = regexp.MustCompile(`^[A-Za-z0-9_]{1,15}$`)
var reAvatarFile = regexp.MustCompile(`^[a-f0-9]{40}\.(jpg|png|webp|gif)$`)
var xHosts = map[string]bool{"x.com": true, "www.x.com": true, "twitter.com": true, "www.twitter.com": true, "mobile.twitter.com": true}
// validateXLink 接受 @名字 / 名字 / X 或 Twitter 主页链接,统一成 https://x.com/名字。
func validateXLink(s string) (string, error) {
s = strings.TrimSpace(s)
if s == "" {
return "", nil
}
if h := strings.TrimPrefix(s, "@"); reXHandle.MatchString(h) {
return "https://x.com/" + h, nil
}
if !strings.Contains(s, "://") {
s = "https://" + s
}
u, err := url.Parse(s)
if err != nil || !xHosts[strings.ToLower(u.Hostname())] {
return "", errors.New("X 主页链接应形如 https://x.com/你的名字")
}
h := strings.Trim(u.Path, "/")
if i := strings.Index(h, "/"); i >= 0 {
h = h[:i]
}
if !reXHandle.MatchString(h) {
return "", errors.New("X 主页链接应形如 https://x.com/你的名字")
}
return "https://x.com/" + h, nil
}
// shortURL 去掉 scheme 和末尾斜杠,给推文用:https://newbeggar.com/ → newbeggar.com(X 会自动识别成链接)。
func shortURL(u string) string {
u = strings.TrimSuffix(u, "/")
u = strings.TrimPrefix(strings.TrimPrefix(u, "https://"), "http://")
return strings.TrimPrefix(u, "www.")
}
// reInApp 各家 App 的内置浏览器:X(Twitter for iPhone / TwitterAndroid)、Android WebView(; wv))、微信、FB、Line、IG、微博。
var reInApp = regexp.MustCompile(`(?i)Twitter|; wv\)|FBAN|FBAV|MicroMessenger|Line/|Instagram|Weibo`)
// inAppBrowser 是否在 App 内置浏览器里(深链拉不起、cookie 不稳,要提示用系统浏览器打开)。
// iOS WKWebView 的 UA 有 Mobile/ 却没有 Safari/(Safari、Chrome、Firefox 都带 Safari/)。
func inAppBrowser(ua string) bool {
if reInApp.MatchString(ua) {
return true
}
return strings.Contains(ua, "Mobile/") && strings.Contains(ua, "AppleWebKit") && !strings.Contains(ua, "Safari/")
}
var reAnyCode = regexp.MustCompile(`BEG-[A-Z0-9]{5}`)
func xHandle(link string) string {
if i := strings.LastIndex(link, "/"); i >= 0 {
return link[i+1:]
}
return link
}
// 访客标识:<随机>.<签发时间>.<签名>,只有本站页面能发放,防止脚本自造 cookie 刷钢镚。
func signVisitor(secret []byte, id string, issued int64) string {
payload := id + "." + strconv.FormatInt(issued, 10)
m := hmac.New(sha256.New, secret)
m.Write([]byte("bv:" + payload))
return payload + "." + hex.EncodeToString(m.Sum(nil))[:16]
}
// parseVisitor 校验访客标识,返回签发时间;无效返回 0。
func parseVisitor(secret []byte, v string) int64 {
parts := strings.Split(v, ".")
if len(parts) != 3 || len(parts[0]) != 24 {
return 0
}
issued, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || issued <= 0 || issued > time.Now().Unix()+300 {
return 0
}
if !hmac.Equal([]byte(signVisitor(secret, parts[0], issued)), []byte(v)) {
return 0
}
return issued
}
func hashToken(t string) string {
h := sha256.Sum256([]byte(t))
return hex.EncodeToString(h[:])
}