diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1a9e1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# 私有配置与数据 +config.env +*.db +*.db-wal +*.db-shm +# 构建产物 +/beggar +/beggar-linux-amd64 +/dist/ +# 本机工具链 / 编辑器 +.toolchain/ +.claude/ +.DS_Store +# 背景音乐(可选,自备 AAC 循环片段;仓库不附带) +/static/bgm.m4a diff --git a/LICENSE b/LICENSE index 261eeb9..d645695 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6df88aa --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# 赛博要饭 · newbeggar + +> 一个可以**要饭**的网站:你摆一只碗,别人用币安 Pay 施舍、留一句话、上施主榜; +> 任何人发一条推就能开自己的要饭站(`域名/X用户名`),钱直接进他自己的币安账户。 +> 单二进制 + SQLite,收款走开源网关 [BinancePayTool](https://github.com/qianyubtc/BinancePayTool)(币安官方只读 API,秒级到账)。 + +**体验站:** —— 作者本人的碗,欢迎来投币、丢钢镚,或者发条推开你自己的分舵。 + +## 玩法 + +- **主站**:站长的碗。展示碗里总额、施主数、今日进账;施主填名号 + 金额 + 一句话,**不离开本站**:弹窗里显示唯一金额、收款二维码 / 一键唤起币安 App、备注码,页面自动轮询,到账后弹窗直接庆祝并刻上功德簿。付了没自动确认可在弹窗里回填币安订单编号,由本站代转网关核对。 +- **两个榜**:**施主榜**(给当前站长施舍最多的人)和 **乞丐榜**(所有子站按要到的金额降序)。每个站点页都有,`/rank` 是全站版。 +- **开分舵(X 发推验证)**:不用先施舍。发一条带验证码的推文、贴回链接,就能开自己的要饭站 `BASE_URL/X用户名`:路径就是 X 用户名,站名锁定为 X 昵称、头像用 X 头像、一个 X 账号一个站(广告号得先养 X 号公开发推,可追溯)。开站时设口令,以后凭「X 用户名 + 口令」登录后台,忘了口令再发推找回(按 X 数字 ID 认人,改过用户名也找得回)。 +- **钢镚(免费点赞币)**:不想花钱也能参与——匿名丢钢镚,每人每站每天 1 个(cookie + IP 双限制),有硬币飞进碗里的动画;**不能留言**(只有真钱能留言,堵住垃圾信息)。子站有**人气榜**按钢镚数排。 +- **施主 X 身份**:投币时可填自己的 X,功德簿和施主榜显示 X 头像和 @名(头像服务端抓取缓存本地),同一 X 的施舍合并计榜。 +- **反垃圾**:留言 / 名号 / 口号过滤网址和微信 / QQ / TG / 手机号等联系方式;站长可「屏蔽留言」(记录保留,显示 ***)或「屏蔽此人」(名号也隐藏,同名再投自动隐藏);可选「新站待收录」模式(`SUBSITE_REVIEW`),未收录站不进榜、noindex。 +- **丐帮体系**:EXP = 碗里的钱(U) × `MONEY_EXP`(默认 10,即 0.1 U = 1 EXP)+ 钢镚 × `COIN_EXP`(默认 1)。袋位随 EXP 升:空碗未入帮 → 一袋弟子(1) → 二袋(10) → 三袋(50) → 四袋(200) → 五袋(500) → 六袋(1000) → 七袋(3000) → 八袋(10000) → 九袋长老(50000);职位按全站 EXP 综合名次动态授予:帮主、副帮主、传功长老、执法长老、掌棒龙头、掌钵龙头、长老、舵主、弟子,掉出名次自动易主(主站是总舵)。 +- **更多玩法**:施主榜 / 乞丐榜有总榜和本周榜;施主徽章 🏁 首位施主 💎 单笔最大 🔥 常客;站长可回复留言;感谢页一键生成像素分享卡。 +- **声音**:8-bit 音效全部用 Web Audio 现场合成(丢钢镚「叮」、到账小旋律、按钮 blip),不加载任何音频文件;背景音乐可选——把一段 AAC 循环片段放到 `static/bgm.m4a` 再编译就有,没有就只有音效。右上角喇叭一键静音,状态记在访客浏览器里。浏览器不允许页面一打开就出声,所以音乐在访客第一次触屏 / 点击时起播。 +- **子站怎么收钱**(后台「收款方式」二选一): + - **绑定币安只读 API Key(推荐)**:本站把 Key 注册到 BinancePayTool 网关成为独立收款账号,网关按账号轮询他的流水,到账自动确认上榜——和主站一样全自动。Key 只要「允许读取」,网关侧 AES 加密存储。 + - **直接转账**(零门槛兜底):子站展示站长的币安收款码 / Pay ID,施主转完点「我已施舍」,站长在后台确认后上榜。 +- **后台**:改资料、设收款、确认 / 拒绝手动施舍、**屏蔽留言**(记录保留,留言显示 `***`)、回复、改口令(弹窗);列表每页 5 条分页。 + 主站站长(`ADMIN_PASSWORD`)额外能巡查全站留言、给违规子站除名、帮忘口令的站长重置。 +- **像素游戏风 UI**:端着碗的像素小人吉祥物(晃碗、眨眼、掉泪),角色卡有等级 / EXP / 饱食度条,口号是 RPG 对话框打字机,投币面板闪 INSERT COIN,榜单是街机 HIGH SCORE。全站用 [缝合怪像素字体](https://github.com/TakWolf/fusion-pixel-font)(OFL,内嵌 660KB)。像素画由 `tools/sprite.py` 从字符网格生成 SVG。 +- **H5 优先**:手机端单列、底部悬浮投币按钮、功德簿 / 施主榜 / 乞丐榜分段切换;桌面端双栏。 + +## 流程 + +```mermaid +sequenceDiagram + participant U as 施主(浏览器) + participant B as 赛博要饭 + participant G as BinancePayTool + participant X as 币安 + U->>B: 名号 + 金额 + 留言 + B->>G: 创建订单 (merchant_order_id=施舍编号, account_id=站点绑定的账号) + G-->>B: pay_url / 唯一金额 + B-->>U: 本站弹窗:唯一金额 + 收款码 + 唤起 App(网关收银页仅兜底) + U->>X: 币安 App 扫码 / 唤起,转账 + G->>X: 用该账号的只读 Key 轮询流水,三规则匹配 + G->>B: POST /bpg/notify(HMAC 签名) + B->>B: 记到账、上榜 + U->>B: /d/{编号} 轮询状态 → 感谢页 + 开站入口 +``` + +回调丢了也不怕:状态页轮询时本站每 4 秒主动向网关查一次单。 + +## 快速开始 + +```bash +go build -o beggar . +cp config.example.env config.env +./beggar -gen-key # 得到 ADMIN_PASSWORD 填入 config.env +# 填 BASE_URL、BPG_URL / BPG_KEY(网关地址与它的 API_AUTH_KEY) +./beggar -config config.env +``` + +打开 `BASE_URL/` 施舍,`BASE_URL/login`(X 用户名留空 + `ADMIN_PASSWORD`)进主站后台。之后可在后台改口令;`ADMIN_PASSWORD` 只在首次建站或 config 里的值改变时覆盖。 +不想接网关也行:留空 `BPG_URL`,填 `MAIN_RECEIVE_LINK` / `MAIN_PAY_ID`,主站按直接转账模式跑(此时子站只能直接转账)。 + +配置项全部在 [config.example.env](config.example.env),每项有注释。 + +## 页面 + +| 路径 | 说明 | +|---|---| +| `/` | 主站 | +| `/{x用户名}` | 子站(路径 = 站长的 X 用户名小写;撞上保留字自动加 `x_` 前缀) | +| `/d/{编号}` | 一笔施舍的状态 / 感谢页(网关 return_url 指向这里) | +| `/pay/{编号}` | 直接转账模式的付款页(二维码 / 唤起 App / Pay ID / 备注码 / 我已施舍) | +| `/new` `/reset` | 用 X 发推验证开站 / 找回口令 | +| `POST /coin` | 丢钢镚(免费,JSON) | +| `/login` `/manage` | 站长登录(X 用户名 + 口令)与后台 | +| `/rank` | 全站施主榜 + 乞丐榜 | +| `POST /bpg/notify` | 网关回调(用 `BPG_KEY` 验签,按 merchant_order_id 落状态) | + +## 部署(VPS + Caddy) + +```bash +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags="-s -w" -o beggar-linux-amd64 . +# 服务器 +sudo useradd -r -s /usr/sbin/nologin beggar && sudo mkdir -p /opt/beggar && sudo chown beggar:beggar /opt/beggar +sudo install -o beggar -g beggar -m 755 beggar-linux-amd64 /opt/beggar/beggar +sudo -u beggar cp config.example.env /opt/beggar/config.env && sudo chmod 600 /opt/beggar/config.env # 填好 +sudo cp deploy/beggar.service /etc/systemd/system/ && sudo systemctl enable --now beggar +``` + +Caddy 片段见 [deploy/Caddyfile](deploy/Caddyfile)。放在 Cloudflare 橙色云朵后面时,再加一个不跳转的 `http://域名` 块(兼容 Flexible 模式),并把 `TRUST_PROXY_HEADER` 设为 `CF-Connecting-IP`。与网关同机时 `BPG_URL` 可填网关内网地址(如 `http://127.0.0.1:8123`),回调地址 `BASE_URL/bpg/notify` 必须能被网关访问到。 +网关需为支持多账号的版本(BinancePayTool 协议 §2.6)。上线前想清掉测试数据:`sudo bash deploy/reset-data.sh`(先备份库,只留主站资料)。 + +## 安全边界 + +- 留言、名号、口号全部经 html/template 转义;留言 80 字、名号 20 字。 +- 站长口令 PBKDF2-SHA256 哈希存储;会话为 HMAC 签名 cookie(HttpOnly + SameSite=Lax),30 天。登录按 IP 与路径限流。 +- 子站的币安 API Key / Secret **不落本站数据库**:直接交给网关注册,网关 AES-GCM 加密存储,任何页面都不展示。 +- 子站的币安收款链接只接受 `binance.com` 域名(防止借站挂钓鱼链接);X 只接受 `x.com` / `twitter.com` 主页。 +- 开站凭证 = X 发推验证(推文正文含一次性验证码,24 小时有效、只能用一次),推文通过 X 公开的嵌入接口读取,不需要 API Key;一个 X 账号一个站。验证码绑定生成它的浏览器(cookie 里的密钥),别人看到推文里的码也用不了;找回口令时按 X 数字 ID 校验,旧用户名被别人注册也接管不了;主站口令不走 X 找回。 +- 在 X / 微信等 App 内置浏览器里打开开站页会提示「用系统浏览器打开」(内置浏览器拉不起发帖深链、cookie 也不稳);其它页面不拦,不影响传播。 +- 施主头像通过公开头像服务抓取后缓存到本地 `AVATAR_DIR`,页面只引用本站文件,墙内访客也能看。 +- 施舍 / 自报 / 开站 / 绑定 Key / 登录均按 IP 限流。 +- 少付(网关 `underpaid`)也按实付计入——要饭嘛,给多少是多少。 + +## 测试 + +```bash +go vet ./... && go test ./... -count=1 # 单测 + mock 网关(含多账号)全流程 +python3 e2e/real_gateway.py # 真实网关二进制 × mock 币安 × 本站(需 ../BinancePayTool 多账号版) +python3 e2e/real_gateway.py --serve --port=8124 # 同上但不退出,并造一批演示数据供浏览 +``` + +## License + +[Apache License 2.0](LICENSE)。像素字体 [缝合怪像素字体](https://github.com/TakWolf/fusion-pixel-font) 为 OFL 许可(见 `static/OFL-fusion-pixel-font.txt`)。 diff --git a/app.go b/app.go new file mode 100644 index 0000000..f77e55d --- /dev/null +++ b/app.go @@ -0,0 +1,367 @@ +package main + +import ( + "bytes" + "embed" + "html/template" + "log" + "net/http" + "net/url" + "strings" + "time" + + bpaygate "github.com/qianyubtc/BinancePayTool/sdk/go" +) + +//go:embed templates/*.html +var tplFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +// hasBGM 背景音乐是可选的:把你的循环片段放到 static/bgm.m4a(AAC)再编译即可,没有就只有音效。 +var hasBGM = func() bool { + f, err := staticFS.Open("static/bgm.m4a") + if err == nil { + f.Close() + } + return err == nil +}() + +type App struct { + cfg *Config + st *Store + mux *http.ServeMux + tpl map[string]*template.Template + lim *limiter + secure bool + session []byte // 会话签名密钥(首次启动生成,存 meta 表) + gwc *bpaygate.Client // nil = 未配置网关 + xp *xProfileCache + origin string // BaseURL 的 scheme://host,POST 同源校验用 +} + +// Base 每个页面都带的公共字段。 +type Base struct { + SiteTitle string + BaseURL string + RepoURL string + AuthorGitHub string + AuthorX string + SubsitesEnabled bool + IsMobile bool + Me *Site // 已登录的站长,可能为 nil + MeID int64 // Me 的 ID(模板里比较用,未登录为 0) + InApp bool // App 内置浏览器(X/微信等) + HasBGM bool // static/bgm.m4a 存在才渲染背景音乐 + NoIndex bool // 未收录的子站不让搜索引擎收录 + Desc string // 页面描述(站点页用站名 + 口号) + OGImage string // 分享缩略图(站点页用 X 头像) +} + +func newApp(cfg *Config) (*App, error) { + st, err := openStore(cfg.DBPath) + if err != nil { + return nil, err + } + if err := st.EnsureMainSite(cfg, func(h string) bool { return checkPassword(h, cfg.AdminPassword) }, func() string { return hashPassword(cfg.AdminPassword) }); err != nil { + st.Close() + return nil, err + } + sec, err := st.GetMeta("session_secret") + if err != nil { + st.Close() + return nil, err + } + if sec == "" { + sec = randHex(32) + if err := st.SetMeta("session_secret", sec); err != nil { + st.Close() + return nil, err + } + } + a := &App{cfg: cfg, st: st, lim: newLimiter(), secure: strings.HasPrefix(cfg.BaseURL, "https://"), session: []byte(sec), + xp: &xProfileCache{files: map[string]string{}, inflight: map[string]bool{}}} + if u, err := url.Parse(cfg.BaseURL); err == nil { + a.origin = u.Scheme + "://" + u.Host + } + if cfg.BPGURL != "" { + a.gwc = bpaygate.New(cfg.BPGURL, cfg.BPGKey) + } + if err := a.loadTemplates(); err != nil { + st.Close() + return nil, err + } + a.routes() + return a, nil +} + +func (a *App) loadTemplates() error { + funcs := template.FuncMap{ + "fmtAmt": fmtE8, + "ago": ago, + "beggarLevel": beggarLevel, + "donorTier": donorTier, + "rankBadge": rankBadge, + "initial": initial, + "hue": hue, + "level": levelOf, + "satiety": satiety, + "sectTitle": sectTitle, + "statusText": statusText, + "inc": func(i int) int { return i + 1 }, + "div10": func(v int64) string { return fmtE8(v * 10000000) }, + "xav": a.xAvatar, + "handleOf": handleOf, + "lastSeg": xHandle, + "dict": func(kv ...any) map[string]any { + m := map[string]any{} + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + return m + }, + } + a.tpl = map[string]*template.Template{} + for _, p := range []string{"site", "pay", "status", "new", "login", "manage", "rank", "error"} { + t, err := template.New(p).Funcs(funcs).ParseFS(tplFS, "templates/layout.html", "templates/sprite.html", "templates/paypanel.html", "templates/"+p+".html") + if err != nil { + return err + } + a.tpl[p] = t + } + return nil +} + +func (a *App) routes() { + m := http.NewServeMux() + m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + }) + m.HandleFunc("GET /static/{file}", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + if strings.HasSuffix(r.PathValue("file"), ".m4a") { + w.Header().Set("Content-Type", "audio/mp4") // Go 自带的 audio/mp4a-latm 部分浏览器不认 + } + http.ServeFileFS(w, r, staticFS, "static/"+r.PathValue("file")) + }) + m.HandleFunc("GET /a/{file}", a.serveAvatar) + m.HandleFunc("POST /donate", a.handleDonate) + m.HandleFunc("POST /coin", a.handleCoin) + m.HandleFunc("GET /pay/{code}", a.handlePay) + m.HandleFunc("POST /pay/{code}/claim", a.handleClaim) + m.HandleFunc("GET /d/{code}", a.handleDonationPage) + m.HandleFunc("GET /d/{code}/status", a.handleDonationStatus) + m.HandleFunc("POST /d/{code}/claim", a.handleGatewayClaim) + m.HandleFunc("POST /bpg/notify", a.handleNotify) + m.HandleFunc("GET /rank", a.handleRank) + m.HandleFunc("GET /new", a.handleNewGet) + m.HandleFunc("POST /new/verify", a.handleNewVerify) + m.HandleFunc("POST /new", a.handleNewPost) + m.HandleFunc("GET /reset", a.handleResetGet) + m.HandleFunc("POST /reset/verify", a.handleResetVerify) + m.HandleFunc("POST /reset", a.handleResetPost) + m.HandleFunc("GET /login", a.handleLoginGet) + m.HandleFunc("POST /login", a.handleLoginPost) + m.HandleFunc("POST /logout", a.handleLogout) + m.HandleFunc("GET /manage", a.handleManage) + m.HandleFunc("POST /manage/profile", a.handleManageProfile) + m.HandleFunc("POST /manage/payment", a.handleManagePayment) + m.HandleFunc("POST /manage/bind", a.handleManageBind) + m.HandleFunc("POST /manage/unbind", a.handleManageUnbind) + m.HandleFunc("POST /manage/verify", a.handleManageVerify) + m.HandleFunc("POST /manage/password", a.handleManagePassword) + m.HandleFunc("POST /manage/donation/{id}/{action}", a.handleManageDonation) + m.HandleFunc("POST /manage/site/{id}/{action}", a.handleManageSite) + m.HandleFunc("/", a.handleRoot) // 主站 / 与子站 /{slug},以及 404 + a.mux = m +} + +// ServeHTTP 统一加安全响应头、限制请求体、POST 同源校验(网关回调除外)。 +func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "SAMEORIGIN") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + if r.URL.Path != "/bpg/notify" { + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + if r.Method == http.MethodPost && !a.sameOrigin(r) { + a.errorPage(w, r, http.StatusForbidden, "请求来源不对", "请从本站页面操作。") + return + } + } + a.mux.ServeHTTP(w, r) +} + +// sameOrigin 浏览器发的跨站 POST 一律拒绝(Origin 或 Sec-Fetch-Site 判断);没有这些头的(脚本/工具)放行。 +func (a *App) sameOrigin(r *http.Request) bool { + if o := r.Header.Get("Origin"); o != "" { + if o == a.origin { + return true + } + if u, err := url.Parse(o); err == nil && u.Host == r.Host { + return true + } + return false + } + switch r.Header.Get("Sec-Fetch-Site") { + case "", "same-origin", "none": + return true + } + return false +} + +func (a *App) base(r *http.Request) Base { + b := Base{SiteTitle: a.cfg.SiteTitle, BaseURL: a.cfg.BaseURL, RepoURL: a.cfg.RepoURL, AuthorGitHub: a.cfg.AuthorGitHub, AuthorX: a.cfg.AuthorX, SubsitesEnabled: a.cfg.SubsitesEnabled, + IsMobile: isMobile(r), InApp: inAppBrowser(r.UserAgent()), HasBGM: hasBGM, Me: a.currentSite(r)} + if b.Me != nil { + b.MeID = b.Me.ID + } + return b +} + +// renderFragment 只渲染某个 define(弹窗用的 HTML 片段)。 +func (a *App) renderFragment(w http.ResponseWriter, status int, page, name string, data any) { + var buf bytes.Buffer + if err := a.tpl[page].ExecuteTemplate(&buf, name, data); err != nil { + log.Printf("[error] 渲染片段 %s: %v", name, err) + http.Error(w, "页面渲染失败", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + w.Write(buf.Bytes()) +} + +func (a *App) render(w http.ResponseWriter, status int, name string, data any) { + var buf bytes.Buffer + if err := a.tpl[name].ExecuteTemplate(&buf, "layout", data); err != nil { + log.Printf("[error] 渲染 %s: %v", name, err) + http.Error(w, "页面渲染失败", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + w.Write(buf.Bytes()) +} + +type errorPageData struct { + Base + Title string + Msg string +} + +func (a *App) errorPage(w http.ResponseWriter, r *http.Request, status int, title, msg string) { + a.render(w, status, "error", errorPageData{Base: a.base(r), Title: title, Msg: msg}) +} + +func (a *App) fail(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("[error] %s %s: %v", r.Method, r.URL.Path, err) + a.errorPage(w, r, http.StatusInternalServerError, "服务器开小差了", "稍后再试。") +} + +func (a *App) logf(f string, args ...any) { log.Printf(f, args...) } + +func (a *App) setCookie(w http.ResponseWriter, name, val string, maxAge int) { + http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: maxAge, HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode}) +} + +func cookieVal(r *http.Request, name string) string { + c, err := r.Cookie(name) + if err != nil { + return "" + } + return c.Value +} + +// ---- 站长会话 ---- + +func (a *App) currentSite(r *http.Request) *Site { + id, tag := parseSession(a.session, cookieVal(r, "bs")) + if id == 0 { + return nil + } + s, err := a.st.GetSiteByID(id) + if err != nil || s == nil || s.Status != "active" || tag != pwTag(s.PassHash) { + return nil + } + return s +} + +// login 发会话 cookie;会话绑定当前口令,改口令后其它设备自动掉线。 +func (a *App) login(w http.ResponseWriter, s *Site) { + exp := time.Now().Add(30 * 24 * time.Hour).Unix() + a.setCookie(w, "bs", signSession(a.session, s.ID, exp, pwTag(s.PassHash)), 30*24*3600) +} + +func (a *App) logout(w http.ResponseWriter) { a.setCookie(w, "bs", "", -1) } + +// flash 一次性提示(下一次管理页展示后清除)。 +func (a *App) flash(w http.ResponseWriter, msg string) { + a.setCookie(w, "bflash", url.QueryEscape(msg), 120) +} + +func (a *App) takeFlash(w http.ResponseWriter, r *http.Request) string { + v := cookieVal(r, "bflash") + if v == "" { + return "" + } + a.setCookie(w, "bflash", "", -1) + s, _ := url.QueryUnescape(v) + return s +} + +// ---- 施主 ---- + +func (a *App) rememberDonor(w http.ResponseWriter, d *Donation) { + a.setCookie(w, "bd_"+d.Code, d.ClaimToken, 30*24*3600) // 标记「这笔是本浏览器发起的」 + a.setCookie(w, "bnick", url.QueryEscape(d.Nickname), 365*24*3600) + if d.XHandle != "" { + a.setCookie(w, "bx", d.XHandle, 365*24*3600) + } +} + +func savedNick(r *http.Request) string { + v, _ := url.QueryUnescape(cookieVal(r, "bnick")) + return cleanText(v, 20, false) +} + +// visitor 访客标识(钢镚去重用),没有就发一个。 +func (a *App) visitor(w http.ResponseWriter, r *http.Request) string { + v := cookieVal(r, "bv") + if len(v) != 24 { + v = randHex(12) + a.setCookie(w, "bv", v, 365*24*3600) + } + return v +} + +var statusNames = map[string]string{"pending": "未付款", "claimed": "待确认", "paid": "已到账", "expired": "已过期", "closed": "已关闭", "failed": "下单失败", "rejected": "已拒绝"} + +func statusText(s string) string { + if v, ok := statusNames[s]; ok { + return v + } + return s +} + +// handleOf 从榜单分组键里取 X handle("x:name" → name)。 +func handleOf(nickKey string) string { + if strings.HasPrefix(nickKey, "x:") { + return nickKey[2:] + } + return "" +} + +func (a *App) ip(r *http.Request) string { return clientIP(r, a.cfg.TrustProxy, a.cfg.TrustHeader) } + +func (a *App) limited(w http.ResponseWriter, r *http.Request, key string, max int, window time.Duration) bool { + if a.lim.allow(key+":"+a.ip(r), max, window) { + return false + } + a.errorPage(w, r, http.StatusTooManyRequests, "手速太快了", "歇一会儿再来。") + return true +} diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..51979db --- /dev/null +++ b/app_test.go @@ -0,0 +1,760 @@ +package main + +import ( + "bytes" + "crypto/hmac" + "encoding/json" + "fmt" + "image" + "image/color" + "image/png" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + bpaygate "github.com/qianyubtc/BinancePayTool/sdk/go" +) + +// ---------- mock 网关 ---------- + +type mockGW struct { + t *testing.T + secret string + srv *httptest.Server + appBase string + mu sync.Mutex + n int + orders map[string]*bpaygate.Order + accounts map[string]*bpaygate.Account + last *bpaygate.Order +} + +func newMockGW(t *testing.T, secret string) *mockGW { + m := &mockGW{t: t, secret: secret, orders: map[string]*bpaygate.Order{}, accounts: map[string]*bpaygate.Account{}} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v1/orders", m.create) + mux.HandleFunc("GET /api/v1/orders/{id}", m.get) + mux.HandleFunc("POST /api/v1/accounts", m.createAccount) + mux.HandleFunc("GET /api/v1/accounts/{id}", m.getAccount) + mux.HandleFunc("POST /api/v1/accounts/{id}/verify", m.getAccount) + mux.HandleFunc("POST /api/v1/accounts/{id}/disable", m.disableAccount) + mux.HandleFunc("GET /pay/{tok}", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "MOCK CASHIER") }) + mux.HandleFunc("POST /pay/{tok}/claim", m.claim) + m.srv = httptest.NewServer(mux) + t.Cleanup(m.srv.Close) + return m +} + +func (m *mockGW) verify(w http.ResponseWriter, r *http.Request, body []byte) bool { + want := bpaygate.SignRequest(m.secret, r.Header.Get("X-BPG-Timestamp"), r.Header.Get("X-BPG-Nonce"), r.Method, r.URL.Path, body) + if !hmac.Equal([]byte(want), []byte(r.Header.Get("X-BPG-Signature"))) { + w.WriteHeader(401) + fmt.Fprint(w, `{"code":"ERR_AUTH","message":"bad sig"}`) + return false + } + return true +} + +func (m *mockGW) create(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if !m.verify(w, r, body) { + return + } + var req bpaygate.CreateOrderReq + json.Unmarshal(body, &req) + accID := req.AccountID + if accID == "" { + accID = "default" + } + m.mu.Lock() + defer m.mu.Unlock() + if accID != "default" { + if acc := m.accounts[accID]; acc == nil || acc.Status != "active" { + w.WriteHeader(400) + fmt.Fprint(w, `{"code":"ERR_PARAM","message":"account_id 不存在或已停用"}`) + return + } + } + e8, _ := parseAmountE8(req.Amount, 4) + m.n++ + o := &bpaygate.Order{OrderID: fmt.Sprintf("gw-%d", m.n), AccountID: accID, MerchantOrderID: req.MerchantOrderID, Status: "pending", Currency: req.Currency, + BaseAmount: req.Amount, PayAmount: fmtE8(e8 + 370000), NoteCode: "ABC123", PayURL: m.srv.URL + "/pay/tok" + strconv.Itoa(m.n), CreatedAt: ms(), ExpiresAt: ms() + req.Timeout*1000, + ReceiveUID: "90000001", ReceiveLink: "https://app.binance.com/uni-qr/MOCK"} + m.orders[o.OrderID] = o + m.last = o + json.NewEncoder(w).Encode(map[string]any{"code": "OK", "data": o}) +} + +func (m *mockGW) get(w http.ResponseWriter, r *http.Request) { + if !m.verify(w, r, nil) { + return + } + m.mu.Lock() + o := m.orders[r.PathValue("id")] + m.mu.Unlock() + if o == nil { + w.WriteHeader(404) + fmt.Fprint(w, `{"code":"ERR_NOT_FOUND","message":"no"}`) + return + } + json.NewEncoder(w).Encode(map[string]any{"code": "OK", "data": o}) +} + +// claim 收银页回填:订单编号 452100000000000123 视为真实到账。 +func (m *mockGW) claim(w http.ResponseWriter, r *http.Request) { + var req struct { + BinanceOrderID string `json:"binance_order_id"` + } + json.NewDecoder(r.Body).Decode(&req) + n := strings.TrimPrefix(r.PathValue("tok"), "tok") + m.mu.Lock() + defer m.mu.Unlock() + o := m.orders["gw-"+n] + if o == nil { + w.WriteHeader(404) + fmt.Fprint(w, `{"code":"ERR_NOT_FOUND","message":"no"}`) + return + } + if req.BinanceOrderID != "452100000000000123" { + json.NewEncoder(w).Encode(map[string]any{"code": "OK", "data": map[string]any{"code": "NOT_FOUND", "status": o.Status}}) + return + } + o.Status, o.ActualAmount, o.MatchedBy, o.BinanceOrderID, o.PaidAt = "paid", o.PayAmount, "claim", req.BinanceOrderID, ms() + json.NewEncoder(w).Encode(map[string]any{"code": "OK", "data": map[string]any{"code": "OK", "status": "paid"}}) +} + +func (m *mockGW) createAccount(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if !m.verify(w, r, body) { + return + } + var req bpaygate.CreateAccountReq + json.Unmarshal(body, &req) + if req.APIKey == "bad" { + w.WriteHeader(400) + fmt.Fprint(w, `{"code":"ERR_BINANCE","message":"币安校验失败: Invalid API-key, IP, or permissions for action."}`) + return + } + m.mu.Lock() + defer m.mu.Unlock() + acc := &bpaygate.Account{AccountID: "acc-" + req.APIKey, Label: req.Label, UID: req.UID, ReceiveLink: req.ReceiveLink, Status: "active", CreatedAt: ms()} + m.accounts[acc.AccountID] = acc + json.NewEncoder(w).Encode(map[string]any{"code": "OK", "data": acc}) +} + +func (m *mockGW) getAccount(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if !m.verify(w, r, body) { + return + } + m.mu.Lock() + acc := m.accounts[r.PathValue("id")] + m.mu.Unlock() + if r.PathValue("id") == "default" { + acc = &bpaygate.Account{AccountID: "default", UID: "90000001", Status: "active"} + } + if acc == nil { + w.WriteHeader(404) + fmt.Fprint(w, `{"code":"ERR_NOT_FOUND","message":"账号不存在"}`) + return + } + json.NewEncoder(w).Encode(map[string]any{"code": "OK", "data": acc}) +} + +func (m *mockGW) disableAccount(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if !m.verify(w, r, body) { + return + } + m.mu.Lock() + defer m.mu.Unlock() + acc := m.accounts[r.PathValue("id")] + if acc == nil { + w.WriteHeader(404) + fmt.Fprint(w, `{"code":"ERR_NOT_FOUND","message":"账号不存在"}`) + return + } + acc.Status = "disabled" + json.NewEncoder(w).Encode(map[string]any{"code": "OK", "data": acc}) +} + +func (m *mockGW) pay(orderID, actual, boid string, callback bool) int { + m.mu.Lock() + o := m.orders[orderID] + o.Status, o.ActualAmount, o.MatchedBy, o.BinanceOrderID, o.PayerID, o.PaidAt = "paid", actual, "amount", boid, "1163000000", ms() + cb := bpaygate.Callback{Event: "paid", OrderID: o.OrderID, AccountID: o.AccountID, MerchantOrderID: o.MerchantOrderID, Status: "paid", Currency: o.Currency, + BaseAmount: o.BaseAmount, PayAmount: o.PayAmount, ActualAmount: actual, MatchedBy: "amount", BinanceOrderID: boid, PayerID: "1163000000", PaidAt: o.PaidAt, Timestamp: ms()} + m.mu.Unlock() + if !callback { + return 0 + } + return m.sendCallback(cb, m.secret) +} + +func (m *mockGW) sendCallback(cb bpaygate.Callback, secret string) int { + body, _ := json.Marshal(cb) + ts := strconv.FormatInt(ms(), 10) + nonce := randHex(8) + req, _ := http.NewRequest("POST", m.appBase+"/bpg/notify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-BPG-Timestamp", ts) + req.Header.Set("X-BPG-Nonce", nonce) + req.Header.Set("X-BPG-Signature", bpaygate.SignCallback(secret, ts, nonce, body)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + m.t.Fatal(err) + } + resp.Body.Close() + return resp.StatusCode +} + +// ---------- mock X(推文接口 + 头像服务)---------- + +type mockTweet struct{ text, name, handle string } + +type mockX struct { + srv *httptest.Server + mu sync.Mutex + tweets map[string]mockTweet + pngHit int +} + +func testPNG() []byte { + img := image.NewRGBA(image.Rect(0, 0, 16, 16)) + for i := 0; i < 16; i++ { + for j := 0; j < 16; j++ { + img.Set(i, j, color.RGBA{uint8(i * 16), uint8(j * 16), 100, 255}) + } + } + var buf bytes.Buffer + png.Encode(&buf, img) + return buf.Bytes() +} + +func newMockX(t *testing.T) *mockX { + m := &mockX{tweets: map[string]mockTweet{}} + pngData := testPNG() + mux := http.NewServeMux() + mux.HandleFunc("GET /tweet-result", func(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + tw, ok := m.tweets[r.URL.Query().Get("id")] + m.mu.Unlock() + if !ok { + w.WriteHeader(404) + return + } + json.NewEncoder(w).Encode(map[string]any{"__typename": "Tweet", "text": tw.text, + "user": map[string]any{"id_str": "9" + tw.handle, "name": tw.name, "screen_name": tw.handle, "profile_image_url_https": m.srv.URL + "/pic/" + tw.handle + "_normal.jpg"}}) + }) + serve := func(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + m.pngHit++ + m.mu.Unlock() + w.Header().Set("Content-Type", "image/png") + w.Write(pngData) + } + mux.HandleFunc("GET /pic/{f}", serve) + mux.HandleFunc("GET /x/{handle}", serve) + m.srv = httptest.NewServer(mux) + t.Cleanup(m.srv.Close) + return m +} + +func (m *mockX) set(id, text, name, handle string) { + m.mu.Lock() + m.tweets[id] = mockTweet{text, name, handle} + m.mu.Unlock() +} + +// ---------- 测试环境 ---------- + +type env struct { + t *testing.T + app *App + base string + gw *mockGW + x *mockX + cfg *Config +} + +func newEnvWith(t *testing.T, mod func(*Config)) *env { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + base := "http://" + l.Addr().String() + gw := newMockGW(t, "test-secret-0123456789abcdef") + gw.appBase = base + x := newMockX(t) + cfg := &Config{Listen: l.Addr().String(), BaseURL: base, DBPath: filepath.Join(t.TempDir(), "t.db"), AdminPassword: "admin-pass-123456", + SiteTitle: "赛博要饭", RepoURL: "https://example.com/repo", MainName: "芊羽Wing", MainSlogan: "行行好", MainAvatar: "🥣", + BPGURL: gw.srv.URL, BPGKey: gw.secret, Currency: "USDT", PresetAmounts: []string{"0.1", "1"}, + MinAmountE8: 10000000, MaxAmountE8: 1000000000000, OrderTTL: 900, SubsitesEnabled: true, + XTweetAPI: x.srv.URL, XAvatarAPI: x.srv.URL, AvatarDir: filepath.Join(t.TempDir(), "avatars"), CoinsPerDay: 1, CoinsIPCap: 20, CoinExp: 1, MoneyExp: 10} + if mod != nil { + mod(cfg) + } + app, err := newApp(cfg) + if err != nil { + t.Fatal(err) + } + srv := &http.Server{Handler: app} + go srv.Serve(l) + t.Cleanup(func() { srv.Close(); app.st.Close() }) + return &env{t: t, app: app, base: base, gw: gw, x: x, cfg: cfg} +} + +func newEnv(t *testing.T) *env { return newEnvWith(t, nil) } + +type client struct { + t *testing.T + hc *http.Client + e *env +} + +func (e *env) client() *client { + jar, _ := cookiejar.New(nil) + return &client{t: e.t, e: e, hc: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} +} + +func (c *client) get(path string) (int, string, http.Header) { + resp, err := c.hc.Get(c.e.base + path) + if err != nil { + c.t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b), resp.Header +} + +func (c *client) post(path string, form url.Values) (int, string, http.Header) { + resp, err := c.hc.PostForm(c.e.base+path, form) + if err != nil { + c.t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b), resp.Header +} + +var reCode = regexp.MustCompile(`BEG-[A-Z0-9]{5}`) + +// openSite 走完整的 X 发推验证开站流程,返回 slug。 +func (c *client) openSite(handle, name, slug, pw string) (int, string, http.Header) { + st, body, _ := c.get("/new") + must(c.t, st == 200 && strings.Contains(body, "一键去 X 发推"), "开站第一步: %d", st) + code := reCode.FindString(body) + must(c.t, code != "", "应有验证码") + id := strconv.Itoa(int(ms() % 1000000000)) + c.e.x.set(id, "我在赛博要饭摆了个碗 "+code+" #赛博要饭", name, handle) + st, body, _ = c.post("/new/verify", url.Values{"code": {code}, "tweet_url": {"https://x.com/" + handle + "/status/" + id + "?s=20"}}) + must(c.t, st == 200 && strings.Contains(body, "X 验证通过"), "验证: %d %s", st, body[:0]) + return c.post("/new", url.Values{"code": {code}, "slug": {slug}, "slogan": {"给点"}, "password": {pw}, "password2": {pw}}) +} + +func must(t *testing.T, cond bool, msg string, args ...any) { + t.Helper() + if !cond { + t.Fatalf(msg, args...) + } +} + +func TestFullFlow(t *testing.T) { + e := newEnv(t) + c := e.client() + + st, body, _ := c.get("/") + must(t, st == 200 && strings.Contains(body, "芊羽Wing") && strings.Contains(body, "榜上无人") && strings.Contains(body, `href="/login"`) && strings.Contains(body, "人气榜"), "主页: %d", st) + + // 金额 / 垃圾过滤 + st, body, _ = c.post("/donate", url.Values{"site": {""}, "amount": {"0.001"}}) + must(t, st == 400 && strings.Contains(body, "最多 2 位小数"), "小数校验: %d", st) + st, body, _ = c.post("/donate", url.Values{"site": {""}, "amount": {"0.05"}}) + must(t, st == 400 && strings.Contains(body, "最少施舍 0.1"), "下限校验: %d", st) + st, body, _ = c.post("/donate", url.Values{"site": {""}, "amount": {"1"}, "message": {"加微信 abc123 领福利"}}) + must(t, st == 400 && strings.Contains(body, "不接广告"), "留言广告过滤: %d", st) + st, body, _ = c.post("/donate", url.Values{"site": {""}, "amount": {"1"}, "nickname": {"看 www.evil.com"}}) + must(t, st == 400 && strings.Contains(body, "不接广告"), "名号广告过滤: %d", st) + st, body, _ = c.post("/donate", url.Values{"site": {""}, "amount": {"1"}, "x_handle": {"bad name"}}) + must(t, st == 400 && strings.Contains(body, "X 用户名"), "X 用户名校验: %d", st) + + // ① 主站施舍(带 X)→ 跳网关收银页 + st, _, h := c.post("/donate", url.Values{"site": {""}, "nickname": {" 路人 "}, "x_handle": {"@qianyu_fan"}, "amount": {"5"}, "message": {"加油要饭 x"}}) + code := e.gw.last.MerchantOrderID + must(t, st == 302 && h.Get("Location") == "/d/"+code, "下单应留在本站: %d %s", st, h.Get("Location")) + d1, _ := e.app.st.GetDonationByCode(code) + must(t, d1 != nil && d1.XHandle == "qianyu_fan" && d1.NickKey == "x:qianyu_fan", "施主 X 落库: %+v", d1) + st, body, _ = c.get("/d/" + code) + must(t, st == 200 && strings.Contains(body, "等待到账") && strings.Contains(body, `class="paypanel"`) && strings.Contains(body, e.gw.last.PayAmount) && strings.Contains(body, "data:image/png;base64,") && strings.Contains(body, "帮我核对"), "等待页应内嵌付款面板: %d", st) + + bad := bpaygate.Callback{Event: "paid", OrderID: e.gw.last.OrderID, MerchantOrderID: code, Status: "paid", ActualAmount: "999"} + must(t, e.gw.sendCallback(bad, "wrong-secret") == 401, "错签名应 401") + must(t, e.gw.sendCallback(bpaygate.Callback{Event: "paid", MerchantOrderID: "BEGNOPE", Status: "paid"}, e.gw.secret) == 404, "未知订单应 404") + + // ② 回调到账 → 上榜(含 X 头像) + must(t, e.gw.pay(e.gw.last.OrderID, "5.0037", "452100000000000001", true) == 200, "回调应 200") + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if p, _ := e.app.st.GetXProfile("qianyu_fan"); p != nil && p.Avatar != "" { + break + } + time.Sleep(50 * time.Millisecond) + } + p1, _ := e.app.st.GetXProfile("qianyu_fan") + must(t, p1 != nil && p1.Avatar != "", "施主头像应异步抓取: %+v", p1) + st, body, _ = c.get("/d/" + code) + must(t, st == 200 && strings.Contains(body, "谢谢施主 路人") && strings.Contains(body, "+5.0037") && strings.Contains(body, "生成分享卡"), "到账页: %d", st) + must(t, strings.Contains(body, "排第 1 位"), "名次") + st, body, _ = c.get("/") + must(t, strings.Contains(body, "加油要饭 <b>x</b>") && strings.Contains(body, "🥇") && strings.Contains(body, "5.0037"), "主页功德簿/榜") + must(t, strings.Contains(body, "/a/"+p1.Avatar) && strings.Contains(body, "@qianyu_fan") && strings.Contains(body, "🏁"), "主页应显示 X 头像与徽章") + must(t, strings.Contains(body, "三袋弟子") && strings.Contains(body, "总舵"), "主站袋位/总舵") + st, _, h = c.get("/a/" + p1.Avatar) + must(t, st == 200 && strings.HasPrefix(h.Get("Content-Type"), "image/"), "头像文件可访问: %d", st) + must(t, e.gw.pay(e.gw.last.OrderID, "5.0037", "452100000000000001", true) == 200, "重复回调") + stats, _ := e.app.st.SiteStats(1) + must(t, stats.Count == 1 && stats.TotalE8 == 500370000, "幂等: %+v", stats) + + // ③ 回调丢失 → 轮询兜底查单 + c2 := e.client() + st, _, _ = c2.post("/donate", url.Values{"site": {""}, "nickname": {"路人甲"}, "amount": {"1.25"}}) + must(t, st == 302, "第二笔下单") + code2 := e.gw.last.MerchantOrderID + e.gw.pay(e.gw.last.OrderID, "1.2537", "452100000000000002", false) + st, body, _ = c2.get("/d/" + code2 + "/status") + must(t, st == 200 && strings.Contains(body, `"status":"paid"`), "兜底查单: %s", body) + + // ④ X 发推验证开站(不需要先施舍) + st, body, _ = c.get("/new") + vcode := reCode.FindString(body) + st, body, _ = c.get("/new") + must(t, st == 200 && reCode.FindString(body) == vcode, "同一浏览器再进 /new 复用验证码: %s vs %s", reCode.FindString(body), vcode) + must(t, st == 200 && vcode != "", "开站第一步") + e.x.set("1001", "随便发点什么", "芊羽分舵🐸", "qianyuwing") + st, body, _ = c.post("/new/verify", url.Values{"code": {vcode}, "tweet_url": {"https://x.com/qianyuwing/status/1001"}}) + must(t, st == 400 && strings.Contains(body, "没有验证码"), "推文无验证码应拒绝: %d", st) + st, body, _ = c.post("/new/verify", url.Values{"code": {vcode}, "tweet_url": {"not a url"}}) + must(t, st == 400 && strings.Contains(body, "推文链接不对"), "链接格式: %d", st) + e.x.set("1002", "我在赛博要饭 "+strings.ToLower(vcode)+" #赛博要饭", "芊羽分舵🐸", "qianyuwing") + st, body, _ = c.post("/new/verify", url.Values{"code": {vcode}, "tweet_url": {"https://twitter.com/qianyuwing/status/1002?s=20"}}) + must(t, st == 200 && strings.Contains(body, "X 验证通过") && strings.Contains(body, "@qianyuwing") && strings.Contains(body, "/qianyuwing") && strings.Contains(body, `src="/a/`), "验证通过页: %d", st) + st, body, _ = c.post("/new", url.Values{"code": {vcode}, "slug": {"qianyuwing"}, "slogan": {"加 vx 12345"}, "password": {"pass-qianyu"}, "password2": {"pass-qianyu"}}) + must(t, st == 400 && strings.Contains(body, "别放链接"), "口号广告过滤") + st, body, _ = c.post("/new", url.Values{"code": {vcode}, "slug": {"qianyuwing"}, "password": {"pass-qianyu"}, "password2": {"nope"}}) + must(t, st == 400 && strings.Contains(body, "两次口令不一致"), "口令确认") + st, _, h = c.post("/new", url.Values{"code": {vcode}, "slug": {"QianyuWing"}, "slogan": {"给点"}, "password": {"pass-qianyu"}, "password2": {"pass-qianyu"}}) + must(t, st == 302 && h.Get("Location") == "/manage?welcome=1", "建站跳转: %d %s", st, h.Get("Location")) + site, _ := e.app.st.GetSiteBySlug("qianyuwing") + must(t, site != nil && site.XHandle == "qianyuwing" && site.Name == "芊羽分舵🐸" && site.XAvatar != "" && site.Listed, "建站落库: %+v", site) + must(t, inAppBrowser("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Twitter for iPhone") && inAppBrowser("Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36") && !inAppBrowser("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1") && !inAppBrowser("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120 Mobile/15E148 Safari/604.1"), "内置浏览器识别") + must(t, slugForHandle("Admin") == "x_admin" && slugForHandle("_a") == "x__a" && slugForHandle("DemoX") == "demox", "路径 = X 用户名,保留字加前缀") + st, body, _ = c.get("/manage?welcome=1") + must(t, st == 200 && strings.Contains(body, "开张大吉") && strings.Contains(body, "方式一:绑定币安 API Key") && strings.Contains(body, "readonly"), "管理页: %d", st) + // 验证码不能复用;同一 X 不能开第二个 + st, _, h = c.get("/new") + must(t, st == 302 && h.Get("Location") == "/manage", "已登录再开站应跳后台: %d", st) + c.post("/logout", nil) + st, body, _ = c.post("/new", url.Values{"code": {vcode}, "slug": {"qianyuwing2"}, "password": {"pass-qianyu"}, "password2": {"pass-qianyu"}}) + must(t, st == 200 && strings.Contains(body, "验证已失效"), "验证码复用应失效: %d", st) + st, body, _ = c.get("/new") + vcode2 := reCode.FindString(body) + e.x.set("1003", "again "+vcode2, "芊羽分舵🐸", "qianyuwing") + st, body, _ = c.post("/new/verify", url.Values{"code": {vcode2}, "tweet_url": {"https://x.com/qianyuwing/status/1003"}}) + must(t, st == 400 && strings.Contains(body, "已经开过站了"), "同一 X 不能开第二个: %d", st) + + // ⑤ 未配置收款的子站 + st, body, _ = c2.get("/qianyuwing") + must(t, st == 200 && strings.Contains(body, "芊羽分舵🐸") && strings.Contains(body, "给点") && strings.Contains(body, "还没摆碗") && strings.Contains(body, `src="/a/`+site.XAvatar), "子站页: %d", st) + must(t, strings.Contains(body, `href="https://x.com/qianyuwing"`) && strings.Contains(body, "@qianyuwing"), "X 展示") + st, body, _ = c2.post("/donate", url.Values{"site": {"qianyuwing"}, "nickname": {"路人甲"}, "amount": {"2"}}) + must(t, st == 400 && strings.Contains(body, "还没摆碗"), "未配置收款应拒绝: %d", st) + + // ⑥ 钢镚:免费、每人每天 1 个、不能留言 + st, body, _ = c2.post("/coin", url.Values{"site": {"qianyuwing"}}) + must(t, st == 200 && strings.Contains(body, `"added":true`) && strings.Contains(body, `"total":1`), "丢钢镚: %s", body) + st, body, _ = c2.post("/coin", url.Values{"site": {"qianyuwing"}}) + must(t, st == 200 && strings.Contains(body, `"added":false`) && strings.Contains(body, "明天再来"), "同一人再丢: %s", body) + c3 := e.client() + st, body, _ = c3.post("/coin", url.Values{"site": {"qianyuwing"}}) + must(t, st == 403, "没打开过页面(无访客 cookie)不能丢: %d", st) + c3.get("/qianyuwing") + st, body, _ = c3.post("/coin", url.Values{"site": {"qianyuwing"}}) + must(t, st == 200 && strings.Contains(body, `"total":2`), "另一人丢: %s", body) + st, body, _ = c2.get("/qianyuwing") + must(t, strings.Contains(body, `class="pf coinN">2<`) && strings.Contains(body, `class="coinN">2钢镚<`) && strings.Contains(body, "今天丢过了"), "钢镚计数展示") + st, body, _ = c.get("/rank") + must(t, st == 200 && strings.Contains(body, "人气榜") && strings.Contains(body, "🪙 2"), "人气榜") + + // ⑦ 登录制 + 直接转账 → 自报 → 站长确认 → 屏蔽留言 / 屏蔽此人 / 回复 + st, body, _ = c.post("/login", url.Values{"slug": {"qianyuwing"}, "password": {"wrong"}}) + must(t, st == 401 && strings.Contains(body, "X 用户名或口令不对"), "错口令: %d", st) + st, _, h = c.post("/login", url.Values{"slug": {"/QianyuWing/"}, "password": {"pass-qianyu"}}) + must(t, st == 302 && h.Get("Location") == "/manage", "登录: %d", st) + st, _, _ = c.post("/manage/payment", url.Values{"receive_link": {"https://app.binance.com/uni-qr/abc"}, "pay_id": {"UID 123456"}}) + must(t, st == 302, "保存直接转账") + st, _, h = c2.post("/donate", url.Values{"site": {"qianyuwing"}, "nickname": {"路人甲"}, "amount": {"2"}, "message": {"广告:加我微信 xxx"}}) + must(t, st == 400, "广告留言应拒绝") + st, _, h = c2.post("/donate", url.Values{"site": {"qianyuwing"}, "nickname": {"路人甲"}, "amount": {"2"}, "message": {"这是一条正常留言"}}) + must(t, st == 302 && strings.HasPrefix(h.Get("Location"), "/pay/BEG"), "子站下单: %d %s", st, h.Get("Location")) + code3 := strings.TrimPrefix(h.Get("Location"), "/pay/") + st, body, _ = c2.get("/pay/" + code3) + must(t, st == 200 && strings.Contains(body, "UID 123456") && strings.Contains(body, "我已施舍"), "手动付款页: %d", st) + st, _, h = c2.post("/pay/"+code3+"/claim", url.Values{"binance_order_id": {"452100000000000077"}}) + must(t, st == 302 && h.Get("Location") == "/d/"+code3, "自报: %d", st) + st, body, _ = c.get("/manage") + must(t, strings.Contains(body, "待确认的施舍") && strings.Contains(body, "452100000000000077"), "站长看到待确认") + d3, _ := e.app.st.GetDonationByCode(code3) + st, _, _ = c.post(fmt.Sprintf("/manage/donation/%d/confirm", d3.ID), nil) + must(t, st == 302, "确认") + st, body, _ = c2.get("/qianyuwing") + must(t, strings.Contains(body, "路人甲") && strings.Contains(body, "这是一条正常留言") && strings.Contains(body, "本站"), "确认后上榜") + must(t, strings.Contains(body, "帮主") && strings.Contains(body, "榜 #1") && strings.Contains(body, "二袋弟子") && strings.Contains(body, "EXP 22"), "子站职位与袋位(2U=20 + 2 钢镚=2)") + st, _, _ = c.post(fmt.Sprintf("/manage/donation/%d/reply", d3.ID), url.Values{"reply": {"谢谢老铁"}}) + must(t, st == 302, "回复") + st, body, _ = c2.get("/qianyuwing") + must(t, strings.Contains(body, "站长:谢谢老铁"), "回复展示") + st, _, _ = c.post(fmt.Sprintf("/manage/donation/%d/block", d3.ID), nil) + st, body, _ = c2.get("/qianyuwing") + must(t, strings.Contains(body, "***") && !strings.Contains(body, "这是一条正常留言"), "屏蔽留言后") + st, _, _ = c.post(fmt.Sprintf("/manage/donation/%d/blockuser", d3.ID), nil) + must(t, st == 302, "屏蔽此人") + st, body, _ = c2.get("/qianyuwing") + must(t, strings.Contains(body, "已屏蔽的施主") && !strings.Contains(body, "路人甲"), "屏蔽此人后名号隐藏") + e.app.lim = newLimiter() // 测试里所有客户端同一 IP,重置限流 + st, _, h = c2.post("/donate", url.Values{"site": {"qianyuwing"}, "nickname": {"路人甲"}, "amount": {"2"}, "message": {"我又来了"}}) + must(t, st == 302, "被屏蔽的人再投: %d", st) + d4, _ := e.app.st.GetDonationByCode(strings.TrimPrefix(h.Get("Location"), "/pay/")) + must(t, d4 != nil && d4.Blocked, "同名再投自动屏蔽: %+v", d4) + st, _, _ = c.post(fmt.Sprintf("/manage/donation/%d/unblockuser", d3.ID), nil) + st, body, _ = c2.get("/qianyuwing") + must(t, strings.Contains(body, "路人甲"), "恢复此人") + stats, _ = e.app.st.SiteStats(d3.SiteID) + must(t, stats.Count == 1 && stats.TotalE8 == 200000000, "屏蔽不影响记录: %+v", stats) + d1, _ = e.app.st.GetDonationByCode(code) + st, _, _ = c.post(fmt.Sprintf("/manage/donation/%d/block", d1.ID), nil) + must(t, st == 404, "越权屏蔽应 404: %d", st) + + // ⑨ 绑定币安 Key → 网关多账号 → 自动确认 + e.app.lim = newLimiter() + st, body, _ = c.post("/manage/bind", url.Values{"api_key": {"bad"}, "api_secret": {"s"}, "uid": {"90000002"}}) + must(t, st == 400 && strings.Contains(body, "Invalid API-key"), "坏 Key 应显示币安原因: %d", st) + st, _, _ = c.post("/manage/bind", url.Values{"api_key": {"k2"}, "api_secret": {"s2"}, "uid": {"90000002"}, "receive_link": {"https://app.binance.com/uni-qr/sub"}}) + must(t, st == 302, "绑定: %d", st) + site, _ = e.app.st.GetSiteBySlug("qianyuwing") + must(t, site.PayMode == "gateway" && site.BPGAccountID == "acc-k2", "绑定落库: %+v", site) + st, _, h = c2.post("/donate", url.Values{"site": {"qianyuwing"}, "nickname": {"路人乙"}, "amount": {"3"}}) + must(t, st == 302 && e.gw.last.AccountID == "acc-k2" && strings.HasPrefix(h.Get("Location"), "/d/"), "子站网关下单: %+v %s", e.gw.last, h.Get("Location")) + code4 := e.gw.last.MerchantOrderID + must(t, e.gw.pay(e.gw.last.OrderID, "3.0037", "452100000000000004", true) == 200, "子站回调") + st, body, _ = c2.get("/d/" + code4) + must(t, strings.Contains(body, "谢谢施主 路人乙"), "子站网关到账") + st, _, _ = c.post("/manage/unbind", nil) + must(t, st == 302 && e.gw.accounts["acc-k2"].Status == "disabled", "解绑") + + // 改口令后旧会话失效 + stale := e.client() + stale.post("/login", url.Values{"slug": {"qianyuwing"}, "password": {"pass-qianyu"}}) + st, _, _ = stale.get("/manage") + must(t, st == 200, "旧设备登录: %d", st) + st, _, _ = c.post("/manage/password", url.Values{"password": {"pass-qianyu2"}, "password2": {"pass-qianyu2"}}) + must(t, st == 302, "改口令") + st, _, h = stale.get("/manage") + must(t, st == 302 && h.Get("Location") == "/login", "改口令后旧会话应失效: %d", st) + st, _, _ = c.get("/manage") + must(t, st == 200, "改口令的设备仍在线: %d", st) + // 跨站 POST 一律拒绝 + req, _ := http.NewRequest("POST", e.base+"/coin", strings.NewReader("site=qianyuwing")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://evil.example") + resp, err := http.DefaultClient.Do(req) + must(t, err == nil && resp.StatusCode == 403, "跨站 POST 应 403") + resp.Body.Close() + + // ⑩ 忘记口令:X 重新验证 + c.post("/logout", nil) + st, body, _ = c.get("/reset") + rcode := reCode.FindString(body) + must(t, st == 200 && rcode != "", "找回第一步") + e.x.set("1004", "找回口令 "+rcode, "芊羽分舵🐸", "stranger") + st, body, _ = c.post("/reset/verify", url.Values{"code": {rcode}, "tweet_url": {"https://x.com/stranger/status/1004"}}) + must(t, st == 400 && strings.Contains(body, "还没开过要饭站"), "陌生 X 不能重置: %d", st) + e.x.set("1005", "找回口令 "+rcode, "芊羽分舵🐸", "qianyuwing") + st, body, _ = c.post("/reset/verify", url.Values{"code": {rcode}, "tweet_url": {"https://x.com/qianyuwing/status/1005"}}) + must(t, st == 200 && strings.Contains(body, "设置新口令"), "重置第二步: %d", st) + st, _, h = c.post("/reset", url.Values{"code": {rcode}, "password": {"newpass2"}, "password2": {"newpass2"}}) + must(t, st == 302 && h.Get("Location") == "/manage", "重置口令: %d", st) + c.post("/logout", nil) + st, _, _ = c.post("/login", url.Values{"slug": {"qianyuwing"}, "password": {"pass-qianyu2"}}) + must(t, st == 401, "旧口令失效") + st, _, _ = c.post("/login", url.Values{"slug": {"qianyuwing"}, "password": {"newpass2"}}) + must(t, st == 302, "新口令可登录") + + // ⑪ 管理员:巡查 / 除名 / 收录 / 重置口令 + admin := e.client() + st, _, h = admin.post("/login", url.Values{"slug": {""}, "password": {e.cfg.AdminPassword}}) + must(t, st == 302 && h.Get("Location") == "/manage", "管理员登录: %d", st) + st, body, _ = admin.get("/manage") + must(t, st == 200 && strings.Contains(body, "全站最新留言") && strings.Contains(body, "/qianyuwing") && strings.Contains(body, "取消收录"), "管理员页: %d", st) + st, _, _ = admin.post(fmt.Sprintf("/manage/site/%d/unlist", site.ID), nil) + must(t, st == 302, "取消收录") + st, body, _ = admin.get("/") + must(t, !strings.Contains(body, "芊羽分舵🐸"), "未收录不进榜") + st, body, _ = admin.get("/qianyuwing") + must(t, st == 200 && strings.Contains(body, `name="robots" content="noindex"`) && strings.Contains(body, "还没被丐帮收录"), "未收录页 noindex") + st, _, _ = admin.post(fmt.Sprintf("/manage/site/%d/list", site.ID), nil) + st, body, _ = admin.get("/") + must(t, strings.Contains(body, "芊羽分舵🐸"), "收录后进榜") + st, _, _ = admin.post(fmt.Sprintf("/manage/site/%d/disable", site.ID), nil) + st, _, _ = admin.get("/qianyuwing") + must(t, st == 404, "除名后 404: %d", st) + st, _, _ = admin.post(fmt.Sprintf("/manage/site/%d/enable", site.ID), nil) + st, body, _ = admin.post(fmt.Sprintf("/manage/site/%d/resetpw", site.ID), nil) + must(t, st == 200, "重置口令直接渲染: %d", st) + i := strings.Index(body, "的口令为 ") + must(t, i > 0, "应显示新口令") + newpw := strings.TrimSpace(strings.SplitN(body[i+len("的口令为 "):], " ", 2)[0]) + st, _, _ = c.get("/manage") + must(t, st == 302, "管理员重置口令后站长旧会话应失效: %d", st) + st, _, _ = c.post("/login", url.Values{"slug": {"qianyuwing"}, "password": {newpw}}) + must(t, st == 302, "重置后的口令可登录: %q", newpw) + st, _, _ = c.post(fmt.Sprintf("/manage/site/%d/disable", site.ID), nil) + must(t, st == 403, "子站无权除名: %d", st) + + // ⑫ 网关状态机:order_id 不符 → 404;underpaid 按实付记账;expired 后补付复活 + c6 := e.client() + c6.post("/donate", url.Values{"site": {""}, "nickname": {"少付的"}, "amount": {"9"}}) + code6 := e.gw.last.MerchantOrderID + mis := bpaygate.Callback{Event: "paid", OrderID: "gw-nope", MerchantOrderID: code6, Status: "paid", ActualAmount: "9"} + must(t, e.gw.sendCallback(mis, e.gw.secret) == 404, "order_id 不符应 404") + under := bpaygate.Callback{Event: "underpaid", OrderID: e.gw.last.OrderID, MerchantOrderID: code6, Status: "underpaid", ActualAmount: "8.5", MatchedBy: "note", PaidAt: ms(), Timestamp: ms()} + must(t, e.gw.sendCallback(under, e.gw.secret) == 200, "underpaid 回调") + d6, _ := e.app.st.GetDonationByCode(code6) + must(t, d6.Status == "paid" && d6.ActualE8 == 850000000, "少付按实付记账: %+v", d6) + c7 := e.client() + c7.post("/donate", url.Values{"site": {""}, "nickname": {"迟到的"}, "amount": {"4"}}) + code7 := e.gw.last.MerchantOrderID + e.gw.sendCallback(bpaygate.Callback{Event: "expired", OrderID: e.gw.last.OrderID, MerchantOrderID: code7, Status: "expired", Timestamp: ms()}, e.gw.secret) + must(t, e.gw.pay(e.gw.last.OrderID, "4.0037", "452100000000000099", true) == 200, "过期后补付") + d7, _ := e.app.st.GetDonationByCode(code7) + must(t, d7.Status == "paid", "过期后补付应复活: %s", d7.Status) + // 验证码 purpose 串用:/new 的码不能拿去 /reset + cx := e.client() + st, body, _ = cx.get("/new") + xcode := reCode.FindString(body) + e.x.set("2001", "串用 "+xcode, "芊羽分舵🐸", "qianyuwing") + st, body, _ = cx.post("/reset/verify", url.Values{"code": {xcode}, "tweet_url": {"https://x.com/qianyuwing/status/2001"}}) + must(t, st == 200 && strings.Contains(body, "验证码已失效"), "purpose 串用应拒绝: %d", st) + + // ⑬ 弹窗:异步下单返回付款面板;回填订单编号由本站代转网关核对 + c8 := e.client() + st, body, h = c8.post("/donate", url.Values{"site": {""}, "amount": {"0.001"}, "ajax": {"1"}}) + must(t, st == 400 && strings.Contains(body, "最多 2 位小数") && !strings.Contains(body, " 100 || c.CoinsIPCap < c.CoinsPerDay { + return nil, errors.New("COINS_PER_DAY / COINS_IP_CAP 不合理") + } + if c.CoinExp, err = getInt("COIN_EXP", 1); err != nil { + return nil, err + } + if c.MoneyExp, err = getInt("MONEY_EXP", 10); err != nil { + return nil, err + } + if c.CoinExp < 0 || c.CoinExp > 1000 || c.MoneyExp < 1 || c.MoneyExp > 1000 { + return nil, errors.New("COIN_EXP / MONEY_EXP 不合理(COIN_EXP 0–1000,MONEY_EXP 1–1000)") + } + if !strings.HasPrefix(c.BaseURL, "http://") && !strings.HasPrefix(c.BaseURL, "https://") { + return nil, errors.New("BASE_URL 须以 http:// 或 https:// 开头") + } + for _, p := range c.PresetAmounts { + if e8, _ := parseAmountE8(p, 8); e8 < c.MinAmountE8 || e8 > c.MaxAmountE8 { + return nil, fmt.Errorf("PRESET_AMOUNTS 里的 %s 不在 MIN_AMOUNT–MAX_AMOUNT 之间", p) + } + } + if len(c.AdminPassword) < 8 { + return nil, errors.New("ADMIN_PASSWORD 未设置或太短(≥8 字符),可用 -gen-key 生成一个填入 config.env") + } + if c.BPGURL == "" && c.MainReceiveLink == "" && c.MainPayID == "" { + return nil, errors.New("主站至少配置一种收款方式:BPG_URL+BPG_KEY(网关自动确认)或 MAIN_RECEIVE_LINK / MAIN_PAY_ID(直接转账)") + } + if c.BPGURL != "" && c.BPGKey == "" { + return nil, errors.New("配置了 BPG_URL 就必须配置 BPG_KEY") + } + if c.MainReceiveLink != "" { + l, err := validateBinanceLink(c.MainReceiveLink) + if err != nil { + return nil, fmt.Errorf("MAIN_RECEIVE_LINK: %w", err) + } + c.MainReceiveLink = l + } + if c.MainX != "" { + x, err := validateXLink(c.MainX) + if err != nil { + return nil, fmt.Errorf("MAIN_X: %w", err) + } + c.MainX = x + } + for _, p := range strings.Split(get("PRESET_AMOUNTS", "0.1,1"), ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + e8, err := parseAmountE8(p, 2) + if err != nil { + return nil, fmt.Errorf("PRESET_AMOUNTS 含非法金额 %q", p) + } + c.PresetAmounts = append(c.PresetAmounts, fmtE8(e8)) + } + if c.MinAmountE8, err = parseAmountE8(get("MIN_AMOUNT", "0.1"), 8); err != nil { + return nil, fmt.Errorf("MIN_AMOUNT: %w", err) + } + if c.MaxAmountE8, err = parseAmountE8(get("MAX_AMOUNT", "10000"), 8); err != nil { + return nil, fmt.Errorf("MAX_AMOUNT: %w", err) + } + if c.MinAmountE8 <= 0 || c.MaxAmountE8 < c.MinAmountE8 { + return nil, errors.New("MIN_AMOUNT / MAX_AMOUNT 不合理") + } + if c.OrderTTL, err = getInt("ORDER_TTL", 900); err != nil { + return nil, err + } + if c.OrderTTL < 120 || c.OrderTTL > 86400 { + return nil, errors.New("ORDER_TTL 须在 120–86400 秒之间") + } + return c, nil +} diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..b20798f --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,6 @@ +# Caddy 反代片段:追加到 /etc/caddy/Caddyfile 后 sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy +beggar.example.com { + reverse_proxy 127.0.0.1:8124 + encode gzip + header Strict-Transport-Security "max-age=31536000; includeSubDomains" +} diff --git a/deploy/beggar.service b/deploy/beggar.service new file mode 100644 index 0000000..94716d2 --- /dev/null +++ b/deploy/beggar.service @@ -0,0 +1,24 @@ +# systemd 单元:sudo cp deploy/beggar.service /etc/systemd/system/ && sudo systemctl enable --now beggar +[Unit] +Description=newbeggar (赛博要饭) +After=network-online.target +Wants=network-online.target + +[Service] +User=beggar +Group=beggar +WorkingDirectory=/opt/beggar +Environment=TZ=Asia/Shanghai +UMask=0077 +ExecStart=/opt/beggar/beggar -config /opt/beggar/config.env +Restart=always +RestartSec=3 +# 安全加固 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/opt/beggar +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/deploy/reset-data.sh b/deploy/reset-data.sh new file mode 100755 index 0000000..cb5fd39 --- /dev/null +++ b/deploy/reset-data.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# 清空要饭站的测试数据(保留主站资料/头像、口令、会话密钥),上线前跑一次。 +# 用法(在服务器上):sudo bash reset-data.sh [/opt/beggar] +# 会先停服务、把库备份到 beggar.db.bak-<时间>,清完再启动。 +set -euo pipefail +DIR="${1:-/opt/beggar}" +DB="$DIR/beggar.db" +[ -f "$DB" ] || { echo "找不到 $DB"; exit 1; } +systemctl stop beggar +TS=$(date +%Y%m%d-%H%M%S) +python3 - "$DB" "$DIR/avatars" "$TS" <<'PY' +import sqlite3, sys, os, shutil +db, avdir, ts = sys.argv[1], sys.argv[2], sys.argv[3] +c = sqlite3.connect(db); c.execute("PRAGMA wal_checkpoint(TRUNCATE)"); c.close() +bak = f"{db}.bak-{ts}"; shutil.copy2(db, bak); os.chmod(bak, 0o600); print("备份:", bak) +c = sqlite3.connect(db) +for t in ("donations", "coins", "xverify", "blocked_nicks"): + c.execute(f"DELETE FROM {t}") +c.execute("DELETE FROM sites WHERE slug<>''") # 子站全删 +c.execute("UPDATE sites SET coins=0, wishes='' WHERE slug=''") # 主站钢镚/愿望清零,资料与头像保留 +c.execute("DELETE FROM xprofiles WHERE lower(handle) NOT IN (SELECT lower(x_handle) FROM sites WHERE x_handle<>'')") +c.execute("DELETE FROM meta WHERE key='admin_pw_tag'") # 旧版遗留键 +c.execute("DELETE FROM sqlite_sequence WHERE name IN ('donations','sites')") +c.execute("INSERT INTO sqlite_sequence(name,seq) VALUES('sites',1)") +c.commit(); c.execute("VACUUM"); c.close() +c = sqlite3.connect(db) +keep = {r[0] for r in c.execute("SELECT x_avatar FROM sites WHERE x_avatar<>''")} | {r[0] for r in c.execute("SELECT avatar FROM xprofiles WHERE avatar<>''")} +for f in os.listdir(avdir): + if f not in keep: + os.remove(os.path.join(avdir, f)); print("删头像:", f) +for t in ("sites", "donations", "coins", "xverify", "xprofiles", "blocked_nicks", "meta"): + print(t, c.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]) +print(list(c.execute("SELECT id,slug,name,x_handle,coins FROM sites"))) +PY +chown beggar:beggar "$DB"; rm -f "$DB-wal" "$DB-shm" +systemctl start beggar; sleep 2; systemctl is-active beggar diff --git a/e2e/real_gateway.py b/e2e/real_gateway.py new file mode 100644 index 0000000..e3a6518 --- /dev/null +++ b/e2e/real_gateway.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""真实网关端到端:mock 币安 × 真实 BinancePayTool 二进制 × 本站二进制。 + +用法: python3 e2e/real_gateway.py [--serve] [--port=8124] [bpaygate 二进制路径] + 默认二进制 ~/Desktop/BinancePayTool/server/bpaygate(需为含多账号的版本);本站二进制 ./beggar(先 go build -o beggar .)。 + --serve:跑完断言后不退出,留着整套环境(并造一批演示数据)供浏览器查看。 +流程: 起 mock 币安(按 API Key 区分账号)→ 起网关 → 起本站 + ① 主站施舍 → 真实收银页 → 注入到账 → 网关回调 → 上榜 + ② 开子站(设口令,自动登录)→ 直接转账模式 → 施主自报 → 站长确认 → 屏蔽留言 + ③ 子站绑定币安只读 Key → 网关多账号 → 子站施舍 → 该 Key 的流水到账 → 自动确认 +""" +import http.client +import json +import os +import re +import socket +import subprocess +import sys +import tempfile +import threading +import time +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +HERE = os.path.dirname(os.path.abspath(__file__)) +BPT = os.path.expanduser("~/Desktop/BinancePayTool") +sys.path.insert(0, os.path.join(BPT, "sdk", "python")) +from bpaygate import BPayGate # noqa: E402 + +SECRET = "e2e-secret-0123456789abcdef" +ADMIN_PW = "admin-pass-123456" +SUB_PW = "qianyu-pass-1" +TXNS = {} # api key → 流水列表 +_seq = [452100000000000100] + + +TWEETS = {} # id → (text, name, handle) +import struct +import zlib + + +def make_png(size=32): + """生成一张有效的 32x32 渐变 PNG(stdlib 实现,供 mock 头像用)。""" + rows = [] + for y in range(size): + row = bytearray([0]) + for x in range(size): + row += bytes([(x * 8) & 255, (y * 8) & 255, 120, 255]) + rows.append(bytes(row)) + data = zlib.compress(b"".join(rows)) + + def chunk(tag, body): + c = struct.pack(">I", len(body)) + tag + body + return c + struct.pack(">I", zlib.crc32(tag + body) & 0xFFFFFFFF) + + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)) + chunk(b"IDAT", data) + chunk(b"IEND", b"") + + +PNG = make_png() + + +class MockBinance(BaseHTTPRequestHandler): + def do_GET(self): + if self.path.startswith("/mock/pay"): # 演示:模拟一笔到账 /mock/pay?amount=1.0036[¬e=XXXX] + qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + amt = qs.get("amount", [""])[0] + oid = inject_payment("k", amt, qs.get("note", [""])[0]) if amt else "" + body = json.dumps({"ok": bool(oid), "binance_order_id": oid, "amount": amt}).encode() + self.send_response(200 if oid else 400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + return + if self.path.startswith("/tweet-result"): + qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + tw = TWEETS.get(qs.get("id", [""])[0]) + if not tw: + self.send_response(404) + self.end_headers() + return + body = json.dumps({"__typename": "Tweet", "text": tw[0], "user": {"id_str": "9001", "name": tw[1], "screen_name": tw[2], + "profile_image_url_https": f"http://127.0.0.1:{self.server.server_address[1]}/pic/{tw[2]}_normal.jpg"}}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + return + if self.path.startswith("/pic/") or self.path.startswith("/x/"): + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.end_headers() + self.wfile.write(PNG) + return + if self.path.startswith("/sapi/v1/pay/transactions"): + key = self.headers.get("X-MBX-APIKEY", "") + if key == "bad": + body = json.dumps({"code": -2015, "msg": "Invalid API-key, IP, or permissions for action."}).encode() + self.send_response(401) + else: + body = json.dumps({"code": "000000", "message": "success", "data": TXNS.get(key, []), "success": True}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, *a): + pass + + +def free_port(): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + p = s.getsockname()[1] + s.close() + return p + + +def wait_http(url, seconds=15): + for _ in range(seconds * 10): + try: + urllib.request.urlopen(url, timeout=1) + return True + except Exception: + time.sleep(0.1) + return False + + +class Session: + """带 cookie、不跟随跳转的极简客户端。""" + + def __init__(self, base): + u = urllib.parse.urlparse(base) + self.host, self.port = u.hostname, u.port + self.cookies = {} + + def req(self, method, path, form=None): + c = http.client.HTTPConnection(self.host, self.port, timeout=15) + headers = {} + if self.cookies: + headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in self.cookies.items()) + body = None + if form is not None: + body = urllib.parse.urlencode(form).encode() + headers["Content-Type"] = "application/x-www-form-urlencoded" + c.request(method, path, body=body, headers=headers) + r = c.getresponse() + data = r.read().decode() + for sc in r.msg.get_all("Set-Cookie") or []: + kv, attrs = sc.split(";", 1)[0], sc.lower() + k, v = kv.split("=", 1) + if "max-age=-1" in attrs or "max-age=0" in attrs: + self.cookies.pop(k, None) + else: + self.cookies[k] = v + out = (r.status, dict(r.getheaders()), data) + c.close() + return out + + def get(self, path): + return self.req("GET", path) + + def post(self, path, form): + return self.req("POST", path, form or {}) + + def code(self): + return next((k[3:] for k in self.cookies if k.startswith("bd_")), "") + + +def inject_payment(key, amount, note=""): + _seq[0] += 1 + TXNS.setdefault(key, []).append({"orderId": str(_seq[0]), "note": note, "orderType": "C2C", "transactionId": f"P_E2E{_seq[0]}", + "transactionTime": int(time.time() * 1000), "amount": amount, "currency": "USDT", + "counterpartyId": 1163000000, "payerInfo": {"name": "e2e-payer"}}) + return str(_seq[0]) + + +def wait_status(s, code, want, seconds=20): + for _ in range(seconds * 5): + st, _, body = s.get(f"/d/{code}/status") + if st == 200 and json.loads(body)["status"] == want: + return True + time.sleep(0.2) + return False + + +def donate_and_pay(gw, base, key, site, nick, amount, msg): + """网关模式站点施舍一笔并让它到账,返回 (session, code)。""" + s = Session(base) + st, h, _ = s.post("/donate", {"site": site, "nickname": nick, "amount": amount, "message": msg}) + assert st == 302 and h.get("Location", "").startswith("/d/"), (st, h) + code = h["Location"][3:] + st, _, page = s.get(f"/d/{code}") + assert st == 200 and 'class="paypanel"' in page and "等待到账" in page, "状态页应内嵌付款面板" + order = gw.get_order_by_merchant_id(code) + assert order["status"] == "pending" and order["base_amount"] == amount, order + inject_payment(key, order["pay_amount"]) + assert wait_status(s, code, "paid"), f"{code} 未到账" + return s, code, order + + +def main(): + serve = "--serve" in sys.argv + port_arg = next((a.split("=", 1)[1] for a in sys.argv[1:] if a.startswith("--port=")), "") + args = [a for a in sys.argv[1:] if not a.startswith("--")] + binary = os.path.abspath(args[0] if args else os.path.join(BPT, "server", "bpaygate")) + beggar = os.path.abspath(os.path.join(HERE, "..", "beggar")) + for p in (binary, beggar): + if not os.path.isfile(p): + sys.exit(f"找不到二进制: {p}") + + mock = ThreadingHTTPServer(("127.0.0.1", 0), MockBinance) + threading.Thread(target=mock.serve_forever, daemon=True).start() + tmp = tempfile.mkdtemp(prefix="beggar-e2e-") + gw_port, app_port = free_port(), (int(port_arg) if port_arg else free_port()) + gw_base, base = f"http://127.0.0.1:{gw_port}", f"http://127.0.0.1:{app_port}" + + with open(os.path.join(tmp, "gw.env"), "w") as f: + f.write(f"""LISTEN=127.0.0.1:{gw_port} +BASE_URL={gw_base} +API_AUTH_KEY={SECRET} +BINANCE_API_KEY=k +BINANCE_API_SECRET=s +BINANCE_UID=90000001 +BINANCE_API_BASE=http://127.0.0.1:{mock.server_address[1]} +CURRENCIES=USDT,USDC +POLL_INTERVAL=1 +RECEIVE_LINK=https://app.binance.com/uni-qr/E2ETEST +DB_PATH={tmp}/gw.db +""") + with open(os.path.join(tmp, "beggar.env"), "w") as f: + f.write(f"""LISTEN=127.0.0.1:{app_port} +BASE_URL={base} +DB_PATH={tmp}/beggar.db +ADMIN_PASSWORD={ADMIN_PW} +BPG_URL={gw_base} +BPG_KEY={SECRET} +X_TWEET_API=http://127.0.0.1:{mock.server_address[1]} +X_AVATAR_API=http://127.0.0.1:{mock.server_address[1]} +AVATAR_DIR={tmp}/avatars +MAIN_NAME=芊羽Wing +MAIN_SLOGAN=行行好,赏口饭吃,别问,问就是没饭吃 🙏 +MAIN_STORY=在赛博丐帮蹲了三年,碗里只有代码。 +MAIN_X=@qianyuwing +""") + procs = [subprocess.Popen([binary, "-config", os.path.join(tmp, "gw.env")], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)] + try: + assert wait_http(gw_base + "/healthz"), "网关未就绪" + procs.append(subprocess.Popen([beggar, "-config", os.path.join(tmp, "beggar.env")], stdout=None if serve else subprocess.DEVNULL, stderr=subprocess.STDOUT)) + assert wait_http(base + "/healthz"), "本站未就绪" + gw = BPayGate(gw_base, SECRET) + + # ① 主站施舍:跳真实收银页 → 注入到账 → 网关回调 → 上榜 + s1, code1, order1 = donate_and_pay(gw, base, "k", "", "赛博善人", "5", "别买烟了,去吃饭") + st, _, cashier = Session(gw_base).get("/pay/" + order1["pay_url"].rsplit("/", 1)[1]) + assert st == 200 and "币安" in cashier, "网关收银页异常" + st, _, body = s1.get("/") + assert "赛博善人" in body and "别买烟了,去吃饭" in body and "5.00" in body, "主页未展示到账" + st, _, body = s1.get(f"/d/{code1}") + assert "谢谢施主 赛博善人" in body and 'href="/new"' in body and "生成分享卡" in body, "到账页缺开站入口/分享卡" + print("① 主站施舍 → 真实网关 → 回调到账 ... PASS") + + # ② 用 X 发推验证开子站(不需要先施舍)→ 直接转账 → 自报 → 站长确认 → 屏蔽留言 + st, _, body = s1.get("/new") + code = re.search(r"BEG-[A-Z0-9]{5}", body).group(0) + TWEETS["9001"] = (f"我在赛博要饭摆了个碗 {code} #赛博要饭", "芊羽分舵🐸", "qianyufendo") + st, _, body = s1.post("/new/verify", {"code": code, "tweet_url": "https://x.com/qianyufendo/status/9001"}) + assert st == 200 and "X 验证通过" in body and "@qianyufendo" in body, ("verify", st) + st, h, _ = s1.post("/new", {"code": code, "slug": "qianyufendo", "slogan": "分舵也要吃饭", "password": SUB_PW, "password2": SUB_PW}) + assert st == 302 and h["Location"] == "/manage?welcome=1", (st, h) + assert "bs" in s1.cookies, "开站后应自动登录" + st, _, body = s1.get("/manage?welcome=1") + assert st == 200 and "开张大吉" in body and "方式一:绑定币安 API Key" in body and "不用设 IP 白名单" in body, "管理页异常" + st, h, _ = s1.post("/manage/payment", {"receive_link": "https://app.binance.com/uni-qr/SUBTEST", "pay_id": "UID 12345678"}) + assert st == 302, st + s2 = Session(base) + st, h, _ = s2.post("/donate", {"site": "qianyufendo", "nickname": "过路财神", "x_handle": "@caishen", "amount": "8", "message": "分舵铁粉专属留言"}) + assert st == 302 and h["Location"].startswith("/pay/"), (st, h) + code2 = h["Location"][5:] + st, _, body = s2.get(f"/pay/{code2}") + assert "UID 12345678" in body and "data:image/png;base64," in body, "手动付款页异常" + assert s2.post(f"/pay/{code2}/claim", {"binance_order_id": "452100000000000999"})[0] == 302 + st, _, body = s1.get("/manage") + assert "待确认的施舍" in body and "452100000000000999" in body, "站长未看到待确认" + did = re.search(r"/donation/(\d+)/confirm", body).group(1) + assert s1.post(f"/manage/donation/{did}/confirm", {})[0] == 302 + assert wait_status(s2, code2, "paid", 3), "站长确认后应为 paid" + st, _, body = s2.get("/qianyufendo") + assert "过路财神" in body and "分舵铁粉专属留言" in body and "x.com/qianyufendo" in body and "芊羽分舵🐸" in body, "子站未展示" + assert s1.post(f"/manage/donation/{did}/block", {})[0] == 302 + st, _, body = s2.get("/qianyufendo") + assert "过路财神" in body and "***" in body and "专属留言" not in body, "屏蔽后应显示 ***" + # 广告留言直接拒绝;钢镚免费且每人一天一个 + assert s2.post("/donate", {"site": "qianyufendo", "nickname": "广告狗", "amount": "8", "message": "加我微信领福利"})[0] == 400 + st, _, body = s2.post("/coin", {"site": "qianyufendo"}) + assert st == 200 and json.loads(body)["added"] is True, body + assert json.loads(s2.post("/coin", {"site": "qianyufendo"})[2])["added"] is False + # 登录制:退出后凭路径 + 口令登录 + s1.post("/logout", {}) + assert s1.get("/manage")[0] == 302, "退出后应跳登录" + assert s1.post("/login", {"slug": "qianyufendo", "password": "wrong"})[0] == 401 + assert s1.post("/login", {"slug": "qianyufendo", "password": SUB_PW})[0] == 302 + print("② X 发推开站 → 直接转账 → 站长确认 → 屏蔽留言 → 钢镚 ... PASS") + + # ③ 子站绑定币安只读 Key(真实网关校验 mock 币安)→ 子站施舍自动到账 + st, _, body = s1.post("/manage/bind", {"api_key": "bad", "api_secret": "s", "uid": "90000002"}) + assert st == 400 and "Invalid API-key" in body, "坏 Key 应把币安原因带回来" + st, h, _ = s1.post("/manage/bind", {"api_key": "sub-key-0123456789", "api_secret": "sub-secret", "uid": "90000002", + "receive_link": "https://app.binance.com/uni-qr/SUBQR"}) + assert st == 302, (st, h) + st, _, body = s1.get("/manage") + assert "绑定成功" in body and "UID 90000002" in body, "绑定后管理页异常" + s3, code3, order3 = donate_and_pay(gw, base, "sub-key-0123456789", "qianyufendo", "分舵铁粉", "3", "分舵加油") + assert order3["receive_uid"] == "90000002" and order3["account_id"] != "default", order3 + st, _, cashier = Session(gw_base).get("/pay/" + order3["pay_url"].rsplit("/", 1)[1]) + assert "90000002" in cashier and "扫码支付" in cashier, "子账号收银页应展示其 UID 与二维码" + st, _, body = s3.get("/qianyufendo") + assert "分舵铁粉" in body and "分舵加油" in body, "子站自动到账未上榜" + print("③ 子站绑定币安 Key → 网关多账号 → 自动到账 ... PASS") + print("REAL-GATEWAY E2E ALL PASS") + + if serve: + for nick, amt, msg in [("匿名施主", "1", ""), ("老铁666", "10", "看你可怜"), ("V神", "50", "买个域名吧"), ("路过", "0.5", "钢镚儿拿去")]: + donate_and_pay(gw, base, "k", "", nick, amt, msg) + for nick, amt, msg in [("隔壁老王", "20", "拿去吃饭"), ("夜猫子", "2", "凌晨三点还在要饭,敬业")]: + donate_and_pay(gw, base, "sub-key-0123456789", "qianyufendo", nick, amt, msg) + s = Session(base) + st, h, _ = s.post("/donate", {"site": "", "nickname": "犹豫中", "amount": "2", "message": "先看看"}) + if st == 429: # 同一 IP 每分钟 10 笔限流 + time.sleep(61) + st, h, _ = s.post("/donate", {"site": "", "nickname": "犹豫中", "amount": "2", "message": "先看看"}) + mock_base = f"http://127.0.0.1:{mock.server_address[1]}" + print(f"\n主站 {base}/\n子站 {base}/qianyufendo\n排行榜 {base}/rank\n登录 {base}/login (主站:路径留空 口令 {ADMIN_PW};子站:qianyufendo / {SUB_PW})\n" + f"未支付付款页 {base}{h['Location']}\n开站页 {base}/new\n" + f"模拟付款 curl '{mock_base}/mock/pay?amount=<弹窗里的唯一金额>' (主站的单;子站绑的是另一把 Key,用 &key=... 不支持,直接测主站即可)\n\nCtrl-C 退出。", flush=True) + while True: + time.sleep(3600) + finally: + for p in procs: + p.terminate() + for p in procs: + try: + p.wait(timeout=5) + except subprocess.TimeoutExpired: + p.kill() + + +if __name__ == "__main__": + main() diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bbcddaf --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/qianyubtc/newbeggar + +go 1.27.1 + +require ( + github.com/qianyubtc/BinancePayTool/sdk/go v0.0.0-20260904013948-0e0f73e2c78c + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + modernc.org/sqlite v1.57.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d1305d2 --- /dev/null +++ b/go.sum @@ -0,0 +1,54 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/qianyubtc/BinancePayTool/sdk/go v0.0.0-20260904013948-0e0f73e2c78c h1:fZHgMnCWbY8P+BWIdOmGdf8ORBxisYjr05TnwTmvjXY= +github.com/qianyubtc/BinancePayTool/sdk/go v0.0.0-20260904013948-0e0f73e2c78c/go.mod h1:Lj7i4kgk6+PKEAxGLNeq5pMoMH94xN4fZSvx1u5QeJ0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/handlers_site.go b/handlers_site.go new file mode 100644 index 0000000..8cf4a4d --- /dev/null +++ b/handlers_site.go @@ -0,0 +1,559 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + qrcode "github.com/skip2/go-qrcode" +) + +type sitePage struct { + Base + Site *Site + Stats Stats + Presets []string + Rank []DonorRow + RankWeek []DonorRow + Subsites []SiteRow + SubsitesWeek []SiteRow + Popular []SiteRow + Badges map[string]string + Feed []*Donation + Coins int64 + CoinsToday int64 + MyCoins int64 + CoinsPerDay int64 + Ready bool + Exp int64 // 钱 + 钢镚折算,袋位按它算 + Position int // 综合名次 + SectTitle string // 丐帮职位 + CoinExp int64 // 一个钢镚 = 多少 EXP + MoneyExp int64 // 1 U = 多少 EXP + Err string + Nick string + XHandle string + Amount string + Message string + MinAmount string + MaxAmount string +} + +func (a *App) handleRoot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + a.errorPage(w, r, http.StatusMethodNotAllowed, "方法不允许", "") + return + } + p := r.URL.Path + if p == "/" { + site, err := a.st.GetSiteBySlug("") + if err != nil || site == nil { + a.fail(w, r, errors.New("主站不存在")) + return + } + a.renderSite(w, r, http.StatusOK, site, "", savedNick(r), cookieVal(r, "bx"), "", "") + return + } + if slug := strings.ToLower(strings.Trim(p, "/")); validSlug(slug) { + site, err := a.st.GetSiteBySlug(slug) + if err != nil { + a.fail(w, r, err) + return + } + if site == nil || site.Status != "active" { + a.errorPage(w, r, http.StatusNotFound, "这个乞丐不在", "要么还没开张,要么已被丐帮除名。") + return + } + a.renderSite(w, r, http.StatusOK, site, "", savedNick(r), cookieVal(r, "bx"), "", "") + return + } + a.errorPage(w, r, http.StatusNotFound, "页面不存在", "赛博丐帮没有这条路。") +} + +func (a *App) renderSite(w http.ResponseWriter, r *http.Request, status int, site *Site, errMsg, nick, xh, amount, message string) { + visitor := a.visitor(w, r) + p := sitePage{Base: a.base(r), Site: site, Presets: a.cfg.PresetAmounts, + Ready: site.PaymentReady(), Err: errMsg, Nick: nick, XHandle: xh, Amount: amount, Message: message, + MinAmount: fmtE8(a.cfg.MinAmountE8), MaxAmount: fmtE8(a.cfg.MaxAmountE8), CoinsPerDay: a.cfg.CoinsPerDay} + p.NoIndex = !site.Listed + p.Desc = site.Name + ":" + site.Slogan + " · 赛博要饭,行行好" + if site.AvatarURL() != "" { + p.OGImage = a.cfg.BaseURL + site.AvatarURL() + } + var err error + if p.Stats, err = a.st.SiteStats(site.ID); err != nil { + a.fail(w, r, err) + return + } + if p.Rank, err = a.st.SiteDonorRank(site.ID, 20, 0); err != nil { + a.fail(w, r, err) + return + } + if p.RankWeek, err = a.st.SiteDonorRank(site.ID, 20, weekStart()); err != nil { + a.fail(w, r, err) + return + } + if p.Feed, err = a.st.SiteFeed(site.ID, 30); err != nil { + a.fail(w, r, err) + return + } + if p.Subsites, err = a.st.SubsiteRank(20, false, "amount"); err != nil { + a.fail(w, r, err) + return + } + if p.SubsitesWeek, err = a.st.SubsiteRank(20, false, "week"); err != nil { + a.fail(w, r, err) + return + } + if p.Popular, err = a.st.SubsiteRank(20, false, "coins"); err != nil { + a.fail(w, r, err) + return + } + if p.Badges, err = a.st.SiteBadges(site.ID); err != nil { + a.fail(w, r, err) + return + } + if p.Coins, p.CoinsToday, err = a.st.CoinStats(site.ID); err != nil { + a.fail(w, r, err) + return + } + p.MyCoins, _ = a.st.VisitorCoinsToday(site.ID, visitor) + if site.XHandle != "" && site.XAvatar == "" { // 绑了 X 还没头像(主站 MAIN_X / 开站时抓失败) + if xp, _ := a.st.GetXProfile(strings.ToLower(site.XHandle)); xp != nil && xp.Avatar != "" { + a.st.FillSiteAvatarByHandle(site.XHandle, xp.Avatar) // 施主头像缓存里已有,直接用 + site.XAvatar = xp.Avatar + } else { + a.ensureXProfile(site.XHandle) // 后台补抓,下次刷新就有 + } + } + p.Exp = expOf(p.Stats.TotalE8, p.Coins, a.cfg.MoneyExp, a.cfg.CoinExp) + p.CoinExp, p.MoneyExp = a.cfg.CoinExp, a.cfg.MoneyExp + positions, err := a.st.SitePositions(a.cfg.MoneyExp, a.cfg.CoinExp) + if err != nil { + a.fail(w, r, err) + return + } + a.decorate(p.Subsites, positions) + a.decorate(p.SubsitesWeek, positions) + a.decorate(p.Popular, positions) + if site.IsMain() { + p.SectTitle = "总舵" + } else if pos := positions[site.ID]; pos > 0 { + p.Position, p.SectTitle = pos, sectTitle(pos) + } + a.render(w, status, "site", p) +} + +// decorate 给榜单行补上综合 EXP 与丐帮职位。 +func (a *App) decorate(rows []SiteRow, positions map[int64]int) { + for i := range rows { + rows[i].Exp = expOf(rows[i].TotalE8, rows[i].Coins, a.cfg.MoneyExp, a.cfg.CoinExp) + rows[i].Title = sectTitle(positions[rows[i].ID]) + if rows[i].Slug == "" { + rows[i].Title = "总舵" + } + } +} + +func (a *App) handleDonate(w http.ResponseWriter, r *http.Request) { + if a.limited(w, r, "donate", 10, time.Minute) { + return + } + slug := strings.ToLower(strings.TrimSpace(r.FormValue("site"))) + site, err := a.st.GetSiteBySlug(slug) + if err != nil { + a.fail(w, r, err) + return + } + if site == nil || site.Status != "active" { + a.errorPage(w, r, http.StatusNotFound, "这个乞丐不在", "") + return + } + nick := cleanText(r.FormValue("nickname"), 20, false) + if nick == "" { + nick = "匿名施主" + } + xh := normHandle(r.FormValue("x_handle")) + msg := cleanText(r.FormValue("message"), 80, false) + rawAmt := strings.TrimSpace(r.FormValue("amount")) + ajax := r.FormValue("ajax") == "1" + bad := func(m string) { + if ajax { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(m)) + return + } + a.renderSite(w, r, http.StatusBadRequest, site, m, cleanText(r.FormValue("nickname"), 20, false), r.FormValue("x_handle"), rawAmt, msg) + } + if !site.PaymentReady() { + bad("这位乞丐还没摆碗(未配置收款方式),暂时收不了钱") + return + } + if strings.TrimSpace(r.FormValue("x_handle")) != "" && xh == "" { + bad("X 用户名只能是字母、数字、下划线(不带 @ 也行)") + return + } + if hasContact(nick) || hasContact(msg) { + bad("名号和留言里别放链接、联系方式,乞丐不接广告") + return + } + e8, err := parseAmountE8(rawAmt, 2) + if err != nil { + bad("金额" + err.Error() + "(最多两位小数)") + return + } + if e8 < a.cfg.MinAmountE8 { + bad("最少施舍 " + fmtE8(a.cfg.MinAmountE8) + " " + site.Currency) + return + } + if e8 > a.cfg.MaxAmountE8 { + bad("最多施舍 " + fmtE8(a.cfg.MaxAmountE8) + " " + site.Currency + ",真有这么多请分几次") + return + } + key := nickKey(nick) + if xh != "" { + key = "x:" + strings.ToLower(xh) + } + d := &Donation{ + Code: "BEG" + randCode(10), SiteID: site.ID, Nickname: nick, NickKey: key, XHandle: xh, Message: msg, + Amount: fmtE8(e8), AmountE8: e8, Currency: site.Currency, ClaimToken: randHex(16), IP: a.ip(r), CreatedAt: ms(), + } + if blocked, _ := a.st.IsNickBlocked(site.ID, key); blocked { + d.Blocked = true // 被屏蔽的人再投:钱照收,内容不显示 + } + if site.PayMode != "gateway" { + d.NoteCode = randCode(6) + } + if err := a.st.CreateDonation(d); err != nil { + a.fail(w, r, err) + return + } + a.rememberDonor(w, d) + if site.PayMode == "gateway" { + if err := a.createGatewayOrder(site, d); err != nil { + a.st.SetStatusIf(d.ID, []string{"pending"}, "failed") + a.logf("[error] 下单 %s 失败: %v", d.Code, err) + bad("支付网关开小差了:" + gatewayErr(err)) + return + } + } + if ajax { // 弹窗:返回付款面板片段(cookie 刚设置,面板按本浏览器发起处理) + panel := a.buildPanel(r, site, d) + panel.Owned = true + w.Header().Set("X-Donation", d.Code) + a.renderFragment(w, http.StatusOK, "site", "paypanel", panel) + return + } + if site.PayMode == "gateway" { + http.Redirect(w, r, "/d/"+d.Code, http.StatusFound) + return + } + http.Redirect(w, r, "/pay/"+d.Code, http.StatusFound) +} + +// handleCoin 丢钢镚(免费点赞,匿名,不能留言)。 +func (a *App) handleCoin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + reply := func(status int, v map[string]any) { + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) + } + if !a.lim.allow("coin:"+a.ip(r), 60, time.Minute) { + reply(429, map[string]any{"ok": false, "msg": "手速太快了"}) + return + } + slug := strings.ToLower(strings.TrimSpace(r.FormValue("site"))) + site, err := a.st.GetSiteBySlug(slug) + if err != nil || site == nil || site.Status != "active" { + reply(404, map[string]any{"ok": false, "msg": "这个乞丐不在"}) + return + } + visitor := cookieVal(r, "bv") + if len(visitor) != 24 { // 访客标识只在打开页面时发放;跨站脚本发的请求带不上,直接拒 + reply(403, map[string]any{"ok": false, "msg": "请先打开要饭页再丢"}) + return + } + added, msg, err := a.st.AddCoin(site.ID, visitor, a.ip(r), today(), a.cfg.CoinsPerDay, a.cfg.CoinsIPCap) + if err != nil { + a.logf("[error] 丢钢镚: %v", err) + reply(500, map[string]any{"ok": false, "msg": "服务器开小差了"}) + return + } + total, todayN, _ := a.st.CoinStats(site.ID) + mine, _ := a.st.VisitorCoinsToday(site.ID, visitor) + reply(200, map[string]any{"ok": true, "added": added, "msg": msg, "total": total, "today": todayN, "mine": mine, "per_day": a.cfg.CoinsPerDay}) +} + +// payPanel 付款面板(弹窗 / 状态页 / 付款页共用)。 +type payPanel struct { + D *Donation + Site *Site + Mode string // gateway | manual + Amount string // 应付金额:网关模式为唯一尾数金额,直接转账为施主填的金额 + QR string // base64 PNG + AppLink string + PayID string + NoteCode string + IsMobile bool + Owned bool // 当前浏览器发起的(回填/自报要它) + Fallback string // 网关收银页 + Site2 string +} + +func (a *App) buildPanel(r *http.Request, site *Site, d *Donation) payPanel { + p := payPanel{D: d, Site: site, IsMobile: isMobile(r), Owned: cookieVal(r, "bd_"+d.Code) == d.ClaimToken, NoteCode: d.NoteCode} + if d.IsGateway() { + p.Mode, p.Amount, p.AppLink, p.PayID, p.Fallback = "gateway", d.PayAmount, d.PayLink, d.PayUID, d.PayURL + if p.AppLink == "" && site.ReceiveLink != "" { + p.AppLink = site.ReceiveLink + } + } else { + p.Mode, p.Amount, p.AppLink, p.PayID = "manual", d.Amount, site.ReceiveLink, site.PayID + } + if p.AppLink != "" { + if png, err := qrcode.Encode(p.AppLink, qrcode.Medium, 220); err == nil { + p.QR = base64.StdEncoding.EncodeToString(png) + } + } + return p +} + +type payPage struct { + Base + Site *Site + D *Donation + Panel payPanel + Err string +} + +func (a *App) loadDonation(w http.ResponseWriter, r *http.Request) (*Donation, *Site, bool) { + d, err := a.st.GetDonationByCode(r.PathValue("code")) + if err != nil { + a.fail(w, r, err) + return nil, nil, false + } + if d == nil { + a.errorPage(w, r, http.StatusNotFound, "没有这笔施舍", "") + return nil, nil, false + } + site, err := a.st.GetSiteByID(d.SiteID) + if err != nil || site == nil { + a.fail(w, r, errors.New("站点不存在")) + return nil, nil, false + } + return d, site, true +} + +func (a *App) handlePay(w http.ResponseWriter, r *http.Request) { + d, site, ok := a.loadDonation(w, r) + if !ok { + return + } + if d.IsGateway() || d.Status != "pending" { + http.Redirect(w, r, "/d/"+d.Code, http.StatusFound) + return + } + a.renderPay(w, r, http.StatusOK, site, d, "") +} + +func (a *App) renderPay(w http.ResponseWriter, r *http.Request, status int, site *Site, d *Donation, errMsg string) { + a.render(w, status, "pay", payPage{Base: a.base(r), Site: site, D: d, Panel: a.buildPanel(r, site, d), Err: errMsg}) +} + +// replyJSON 弹窗里的异步请求统一回 JSON。 +func replyJSON(w http.ResponseWriter, status int, v map[string]any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +// handleClaim 直接转账模式:施主自报「我已施舍」。 +func (a *App) handleClaim(w http.ResponseWriter, r *http.Request) { + ajax := r.FormValue("ajax") == "1" + if !a.lim.allow("claim:"+a.ip(r), 5, time.Minute) { + if ajax { + replyJSON(w, 429, map[string]any{"ok": false, "msg": "手速太快了,歇一会儿"}) + } else { + a.errorPage(w, r, http.StatusTooManyRequests, "手速太快了", "歇一会儿再来。") + } + return + } + d, site, ok := a.loadDonation(w, r) + if !ok { + return + } + if d.IsGateway() || d.Status != "pending" { + if ajax { + replyJSON(w, 200, map[string]any{"ok": true, "status": d.Status}) + } else { + http.Redirect(w, r, "/d/"+d.Code, http.StatusFound) + } + return + } + fail := func(status int, m string) { + if ajax { + replyJSON(w, status, map[string]any{"ok": false, "msg": m}) + } else { + a.renderPay(w, r, status, site, d, m) + } + } + if cookieVal(r, "bd_"+d.Code) != d.ClaimToken { + fail(http.StatusForbidden, "请在发起施舍的那台设备上点「我已施舍」") + return + } + boid := strings.TrimSpace(r.FormValue("binance_order_id")) + if boid != "" && !reBinanceOrder.MatchString(boid) { + fail(http.StatusBadRequest, "币安订单编号是 18 位数字,不填也行") + return + } + if _, err := a.st.MarkClaimed(d.ID, boid); err != nil { + a.fail(w, r, err) + return + } + a.logf("[info] 施舍 %s 自报已转账 %s %s(站点 %d)", d.Code, d.Amount, d.Currency, site.ID) + if ajax { + replyJSON(w, 200, map[string]any{"ok": true, "status": "claimed", "msg": "已提交,等站长确认"}) + return + } + http.Redirect(w, r, "/d/"+d.Code, http.StatusFound) +} + +// handleGatewayClaim 网关模式:施主回填币安订单编号,本站代转网关核对。 +func (a *App) handleGatewayClaim(w http.ResponseWriter, r *http.Request) { + ajax := r.FormValue("ajax") == "1" + d, _, ok := a.loadDonation(w, r) + if !ok { + return + } + fail := func(status int, m string) { + if ajax { + replyJSON(w, status, map[string]any{"ok": false, "status": d.Status, "msg": m}) + } else { + a.errorPage(w, r, status, "核对失败", m) + } + } + if !d.IsGateway() { + fail(http.StatusBadRequest, "这笔施舍不是网关模式") + return + } + if !a.lim.allow("gclaim:"+a.ip(r), 5, time.Minute) { + fail(http.StatusTooManyRequests, "试得太频繁了,一分钟后再试") + return + } + if cookieVal(r, "bd_"+d.Code) != d.ClaimToken { + fail(http.StatusForbidden, "请在发起施舍的那台设备上回填") + return + } + boid := strings.TrimSpace(r.FormValue("binance_order_id")) + if !reBinanceOrder.MatchString(boid) { + fail(http.StatusBadRequest, "币安订单编号是 18 位数字") + return + } + code, err := a.gatewayClaim(d, boid) + if err != nil { + fail(http.StatusBadGateway, gatewayErr(err)) + return + } + a.refreshFromGateway(d) + msg := claimMsgs[code] + if msg == "" { + msg = "网关返回 " + code + } + if ajax { + replyJSON(w, 200, map[string]any{"ok": d.Status == "paid", "status": d.Status, "msg": msg}) + return + } + http.Redirect(w, r, "/d/"+d.Code, http.StatusFound) +} + +type statusPage struct { + Base + Site *Site + D *Donation + Stale bool + Position int + DonorTotal int64 + ShareURL string + Panel payPanel +} + +func (a *App) handleDonationPage(w http.ResponseWriter, r *http.Request) { + d, site, ok := a.loadDonation(w, r) + if !ok { + return + } + a.syncFromGateway(d) + p := statusPage{Base: a.base(r), Site: site, D: d, ShareURL: a.cfg.BaseURL + site.Path()} + p.Stale = d.Status == "pending" && ms()-d.CreatedAt > (a.cfg.OrderTTL+1800)*1000 + if (d.Status == "pending" && !p.Stale) || d.Status == "claimed" { + p.Panel = a.buildPanel(r, site, d) + } + if d.Status == "paid" { + if pos, total, err := a.st.DonorPosition(site.ID, d.NickKey); err == nil { + p.Position, p.DonorTotal = pos, total + } + } + a.render(w, http.StatusOK, "status", p) +} + +func (a *App) handleDonationStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + d, err := a.st.GetDonationByCode(r.PathValue("code")) + if err != nil || d == nil { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"status":"unknown"}`)) + return + } + a.syncFromGateway(d) + w.Header().Set("Cache-Control", "no-store") + json.NewEncoder(w).Encode(map[string]any{"status": d.Status, "amount": d.ActualAmount, "paid_at": d.PaidAt}) +} + +type rankPage struct { + Base + Donors []DonorRow + DonorsWeek []DonorRow + Sites []SiteRow + SitesWeek []SiteRow + Popular []SiteRow +} + +func (a *App) handleRank(w http.ResponseWriter, r *http.Request) { + p := rankPage{Base: a.base(r)} + var err error + if p.Donors, err = a.st.GlobalDonorRank(100, 0); err != nil { + a.fail(w, r, err) + return + } + if p.DonorsWeek, err = a.st.GlobalDonorRank(100, weekStart()); err != nil { + a.fail(w, r, err) + return + } + if p.Sites, err = a.st.SubsiteRank(100, false, "amount"); err != nil { + a.fail(w, r, err) + return + } + if p.SitesWeek, err = a.st.SubsiteRank(100, false, "week"); err != nil { + a.fail(w, r, err) + return + } + if p.Popular, err = a.st.SubsiteRank(100, false, "coins"); err != nil { + a.fail(w, r, err) + return + } + positions, err := a.st.SitePositions(a.cfg.MoneyExp, a.cfg.CoinExp) + if err != nil { + a.fail(w, r, err) + return + } + a.decorate(p.Sites, positions) + a.decorate(p.SitesWeek, positions) + a.decorate(p.Popular, positions) + a.render(w, http.StatusOK, "rank", p) +} diff --git a/handlers_sub.go b/handlers_sub.go new file mode 100644 index 0000000..995135c --- /dev/null +++ b/handlers_sub.go @@ -0,0 +1,957 @@ +package main + +import ( + "crypto/hmac" + "encoding/json" + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// ---------- 开站:X 发推验证 ---------- + +type newForm struct{ Slug, Slogan, Story string } + +type newPage struct { + Base + Step int // 1 发推验证 / 2 填资料(或设新口令) + Purpose string // new | reset + Code string + Texts []string // 可选的发推话术(含验证码),页面上可编辑 + IntentURL string + TweetURL string + Err string + Prof *xUser + AvatarURL string + F newForm + Site *Site // reset:要重置口令的站 +} + +type verifyProfile struct { + Name string `json:"name"` + Handle string `json:"handle"` + XID string `json:"xid,omitempty"` + AvatarURL string `json:"avatar_url"` + AvatarFile string `json:"avatar_file"` + SiteID int64 `json:"site_id,omitempty"` +} + +// tweetTexts 发推话术模板,第一条为默认;都带验证码和站点链接(顺便传播)。 +func (a *App) tweetTexts(code, purpose string) []string { + site := shortURL(a.cfg.BaseURL) // 推文里只放裸域名,短 + if purpose == "reset" { + return []string{ + "我在「" + a.cfg.SiteTitle + "」要饭的碗口令忘了,发推证明是我本人 🥣 " + code + " " + site, + } + } + return []string{ + "我在「" + a.cfg.SiteTitle + "」摆了个碗 🥣 行行好,赏口饭吃~ " + code + " #赛博丐帮 " + site, + "本人已加入赛博丐帮,正式开丐 🧎 一毛也是爱,投个钢镚也行 🪙 " + code + " #赛博丐帮 " + site, + "不上班了,改行要饭。碗在这,各位大善人行行好 🥣 " + code + " #赛博丐帮 " + site, + "丐帮招新,我先带头要饭 🥣 谁投币谁上功德簿,不投的丢个钢镚也算 " + code + " #赛博丐帮 " + site, + } +} + +func intentFor(text string) string { return "https://x.com/intent/post?text=" + url.QueryEscape(text) } + +func (a *App) intentURL(code string) string { return intentFor(a.tweetTexts(code, "new")[0]) } + +// startVerify 生成验证码并渲染第一步。验证码与浏览器 cookie 里的密钥绑定:推文是公开的,光知道码没用。 +func (a *App) startVerify(w http.ResponseWriter, r *http.Request, purpose, errMsg string) { + if !a.lim.allow("newcode:"+a.ip(r), 30, 10*time.Minute) { + a.errorPage(w, r, http.StatusTooManyRequests, "手速太快了", "歇一会儿再来。") + return + } + if files, err := a.st.PruneVerify(); err == nil { + for _, f := range files { + os.Remove(filepath.Join(a.cfg.AvatarDir, f)) + } + } + // 同一浏览器反复进来(从 X 发完推回来页面重载了)复用没用完的码,别每次刷新换一个把人搞糊涂 + code := "" + if pc := cookieVal(r, "xvp_"+purpose); pc != "" { + if v, err := a.st.GetVerify(pc); err == nil && v != nil && !v.Used && v.Purpose == purpose && ms()-v.CreatedAt < 20*3600*1000 && ownsVerify(r, v) { + code = v.Code + } + } + if code == "" { + code = "BEG-" + randCode(5) + secret := randHex(16) + if err := a.st.CreateVerify(code, purpose, hashToken(secret)); err != nil { + a.fail(w, r, err) + return + } + a.setCookie(w, "xv_"+code, secret, 24*3600) + a.setCookie(w, "xvp_"+purpose, code, 24*3600) + } + texts := a.tweetTexts(code, purpose) + a.render(w, http.StatusOK, "new", newPage{Base: a.base(r), Step: 1, Purpose: purpose, Code: code, Texts: texts, IntentURL: intentFor(texts[0]), Err: errMsg}) +} + +// ownsVerify 当前浏览器是否持有这个验证码的密钥。 +func ownsVerify(r *http.Request, v *Verify) bool { + c := cookieVal(r, "xv_"+v.Code) + return c != "" && v.Secret != "" && hmac.Equal([]byte(hashToken(c)), []byte(v.Secret)) +} + +func (a *App) handleNewGet(w http.ResponseWriter, r *http.Request) { + if !a.cfg.SubsitesEnabled { + a.errorPage(w, r, http.StatusNotFound, "本站未开放开站", "") + return + } + if a.currentSite(r) != nil { + http.Redirect(w, r, "/manage", http.StatusFound) + return + } + a.startVerify(w, r, "new", "") +} + +func (a *App) handleResetGet(w http.ResponseWriter, r *http.Request) { + a.startVerify(w, r, "reset", "") +} + +// verifyTweet 校验「验证码 + 推文链接」:读推文,正文须含验证码,返回作者资料。 +func (a *App) verifyTweet(r *http.Request, purpose string) (*Verify, *xUser, string) { + code := strings.ToUpper(strings.TrimSpace(r.FormValue("code"))) + v, err := a.st.GetVerify(code) + if err != nil || v == nil || v.Used || v.Purpose != purpose || ms()-v.CreatedAt > 24*3600*1000 || !ownsVerify(r, v) { + return nil, nil, "验证码已失效(或不是本浏览器生成的),请重新生成" + } + id := tweetIDFrom(r.FormValue("tweet_url")) + if id == "" { + return v, nil, "推文链接不对,应形如 https://x.com/你的名字/status/1234567890" + } + text, u, created, err := a.fetchTweet(id) + if err != nil { + return v, nil, err.Error() + } + if !strings.Contains(strings.ToUpper(text), code) { + // 页面上的码和推文里的不一致(发推后页面重载换了码):推文里只要有本浏览器生成过的任何一个有效码就认 + found := false + for _, c := range reAnyCode.FindAllString(strings.ToUpper(text), -1) { + if c == code { + continue + } + if v2, err := a.st.GetVerify(c); err == nil && v2 != nil && !v2.Used && v2.Purpose == purpose && ms()-v2.CreatedAt < 24*3600*1000 && ownsVerify(r, v2) { + v, code, found = v2, c, true + break + } + } + if !found { + return v, nil, "这条推文里没有验证码 " + code + ",请检查是否发对了" + } + } + if created > 0 && created < v.CreatedAt-10*60*1000 { + return v, nil, "这条推文比验证码还早,请用生成验证码之后发的推文" + } + return v, &u, "" +} + +func (a *App) handleNewVerify(w http.ResponseWriter, r *http.Request) { + if !a.cfg.SubsitesEnabled { + a.errorPage(w, r, http.StatusNotFound, "本站未开放开站", "") + return + } + if a.limited(w, r, "verify", 10, 10*time.Minute) { + return + } + v, u, msg := a.verifyTweet(r, "new") + if msg != "" { + if v == nil { + a.startVerify(w, r, "new", msg) + return + } + a.render(w, http.StatusBadRequest, "new", newPage{Base: a.base(r), Step: 1, Purpose: "new", Code: v.Code, Texts: a.tweetTexts(v.Code, "new"), IntentURL: a.intentURL(v.Code), TweetURL: r.FormValue("tweet_url"), Err: msg}) + return + } + if exist := a.siteOfX(u); exist != nil { + a.render(w, http.StatusBadRequest, "new", newPage{Base: a.base(r), Step: 1, Purpose: "new", Code: v.Code, Texts: a.tweetTexts(v.Code, "new"), IntentURL: a.intentURL(v.Code), + Err: "@" + u.Handle + " 已经开过站了:" + a.cfg.BaseURL + exist.Path() + "。忘了口令可以在登录页用 X 重置。"}) + return + } + prof := verifyProfile{Name: cleanText(u.Name, 20, false), Handle: u.Handle, XID: u.ID, AvatarURL: u.Avatar} + if prof.Name == "" || hasContact(prof.Name) { + prof.Name = "@" + u.Handle + } + var old verifyProfile + if v.Profile != "" && json.Unmarshal([]byte(v.Profile), &old) == nil && old.AvatarFile != "" && strings.EqualFold(old.Handle, u.Handle) { + prof.AvatarFile = old.AvatarFile // 同一验证码重复验证,不重复下载 + } else if u.Avatar != "" { + if f, err := a.downloadAvatar(u.Handle, strings.Replace(u.Avatar, "_normal", "_200x200", 1)); err == nil { + prof.AvatarFile = f + } else { + a.logf("[warn] 抓取 @%s 头像失败: %v", u.Handle, err) + } + } + pj, _ := json.Marshal(prof) + if err := a.st.SetVerifyProfile(v.Code, string(pj)); err != nil { + a.fail(w, r, err) + return + } + a.render(w, http.StatusOK, "new", newPage{Base: a.base(r), Step: 2, Purpose: "new", Code: v.Code, Prof: &xUser{Name: prof.Name, Handle: prof.Handle}, + AvatarURL: avatarURL(prof.AvatarFile), F: newForm{Slug: slugForHandle(prof.Handle), Slogan: "行行好,赏口饭吃 🙏"}}) +} + +func avatarURL(file string) string { + if file == "" { + return "" + } + return "/a/" + file +} + +// loadVerified 第二步:取回已验证的资料。 +func (a *App) loadVerified(r *http.Request, purpose string) (*Verify, *verifyProfile, string) { + code := strings.ToUpper(strings.TrimSpace(r.FormValue("code"))) + v, err := a.st.GetVerify(code) + if err != nil || v == nil || v.Used || v.Purpose != purpose || v.Profile == "" || ms()-v.CreatedAt > 24*3600*1000 || !ownsVerify(r, v) { + return nil, nil, "验证已失效,请重新验证" + } + var prof verifyProfile + if err := json.Unmarshal([]byte(v.Profile), &prof); err != nil || prof.Handle == "" { + return nil, nil, "验证已失效,请重新验证" + } + return v, &prof, "" +} + +func (a *App) handleNewPost(w http.ResponseWriter, r *http.Request) { + if !a.cfg.SubsitesEnabled { + a.errorPage(w, r, http.StatusNotFound, "本站未开放开站", "") + return + } + if a.limited(w, r, "new", 5, 10*time.Minute) { + return + } + v, prof, msg := a.loadVerified(r, "new") + if msg != "" { + a.startVerify(w, r, "new", msg) + return + } + f := newForm{Slug: slugForHandle(prof.Handle), Slogan: cleanText(r.FormValue("slogan"), 60, false)} // 路径 = X 用户名,不让用户自己填 + p := newPage{Base: a.base(r), Step: 2, Purpose: "new", Code: v.Code, Prof: &xUser{Name: prof.Name, Handle: prof.Handle}, AvatarURL: avatarURL(prof.AvatarFile), F: f} + bad := func(m string) { + p.Err = m + a.render(w, http.StatusBadRequest, "new", p) + } + if hasContact(f.Slogan) || hasContact(f.Story) { + bad("口号和介绍里别放链接、联系方式") + return + } + pw, pw2 := r.FormValue("password"), r.FormValue("password2") + if len(pw) < 6 || len(pw) > 72 { + bad("口令至少 6 位") + return + } + if pw != pw2 { + bad("两次口令不一致") + return + } + if exist := a.siteOfX(&xUser{ID: prof.XID, Handle: prof.Handle}); exist != nil { + bad("@" + prof.Handle + " 已经开过站了") + return + } + site := &Site{Slug: f.Slug, Name: prof.Name, Slogan: f.Slogan, Story: f.Story, Avatar: "🥣", XURL: "https://x.com/" + prof.Handle, + XHandle: prof.Handle, XName: prof.Name, XAvatar: prof.AvatarFile, XID: prof.XID, Listed: !a.cfg.SubsiteReview, PassHash: hashPassword(pw), Currency: a.cfg.Currency} + id, err := a.st.CreateSite(site) + if errors.Is(err, ErrSlugTaken) { + bad("路径 " + f.Slug + " 已被占用(@" + prof.Handle + " 可能已经开过站,忘了口令可在登录页用 X 找回)") + return + } + if errors.Is(err, ErrXTaken) { + bad("@" + prof.Handle + " 已经开过站了") + return + } + if err != nil { + a.fail(w, r, err) + return + } + a.st.UseVerify(v.Code) + a.st.UpsertXProfile(strings.ToLower(prof.Handle), prof.Name, prof.AvatarFile, ms()) + a.xp.mu.Lock() + a.xp.files[strings.ToLower(prof.Handle)] = prof.AvatarFile + a.xp.mu.Unlock() + a.logf("[info] 新要饭站 #%d /%s(X @%s)", id, f.Slug, prof.Handle) + if created, err := a.st.GetSiteByID(id); err == nil && created != nil { + a.login(w, created) + } + http.Redirect(w, r, "/manage?welcome=1", http.StatusFound) +} + +// ---------- 忘记口令:X 重新验证 ---------- + +func (a *App) handleResetVerify(w http.ResponseWriter, r *http.Request) { + if a.limited(w, r, "verify", 10, 10*time.Minute) { + return + } + v, u, msg := a.verifyTweet(r, "reset") + if msg != "" { + if v == nil { + a.startVerify(w, r, "reset", msg) + return + } + a.render(w, http.StatusBadRequest, "new", newPage{Base: a.base(r), Step: 1, Purpose: "reset", Code: v.Code, Texts: a.tweetTexts(v.Code, "reset"), IntentURL: intentFor(a.tweetTexts(v.Code, "reset")[0]), TweetURL: r.FormValue("tweet_url"), Err: msg}) + return + } + site := a.siteOfX(u) // 先按 X 数字 ID 认人(改过用户名也能找回),再按用户名 + if site != nil && site.IsMain() { + a.render(w, http.StatusBadRequest, "new", newPage{Base: a.base(r), Step: 1, Purpose: "reset", Code: v.Code, Texts: a.tweetTexts(v.Code, "reset"), IntentURL: intentFor(a.tweetTexts(v.Code, "reset")[0]), Err: "主站口令在服务器 config.env 里改,不走 X 找回"}) + return + } + if site == nil { + a.render(w, http.StatusBadRequest, "new", newPage{Base: a.base(r), Step: 1, Purpose: "reset", Code: v.Code, Texts: a.tweetTexts(v.Code, "reset"), IntentURL: intentFor(a.tweetTexts(v.Code, "reset")[0]), Err: "@" + u.Handle + " 还没开过要饭站"}) + return + } + if !a.lim.allow("reset:"+strconv.FormatInt(site.ID, 10), 3, 24*time.Hour) { + a.render(w, http.StatusTooManyRequests, "new", newPage{Base: a.base(r), Step: 1, Purpose: "reset", Code: v.Code, Texts: a.tweetTexts(v.Code, "reset"), IntentURL: intentFor(a.tweetTexts(v.Code, "reset")[0]), Err: "这个 X 今天重置得太频繁了,明天再来"}) + return + } + a.syncSiteX(site, u) // X 上改了昵称/用户名/头像:站点资料跟着更新 + pj, _ := json.Marshal(verifyProfile{Name: u.Name, Handle: u.Handle, XID: u.ID, SiteID: site.ID}) + a.st.SetVerifyProfile(v.Code, string(pj)) + a.render(w, http.StatusOK, "new", newPage{Base: a.base(r), Step: 2, Purpose: "reset", Code: v.Code, Prof: &xUser{Name: u.Name, Handle: u.Handle}, AvatarURL: site.AvatarURL(), Site: site}) +} + +// siteOfX 这个 X 用户开的站:先按数字 ID,再按用户名。 +func (a *App) siteOfX(u *xUser) *Site { + if site, _ := a.st.GetSiteByXID(u.ID); site != nil { + return site + } + site, _ := a.st.GetSiteByXHandle(u.Handle) + if site != nil && site.XID != "" && u.ID != "" && site.XID != u.ID { + return nil // 用户名一样但不是同一个 X 账号(原主改名后被别人注册了) + } + return site +} + +// syncSiteX 找回口令时把站点的 X 资料(数字 ID、用户名、昵称→站名、头像)同步成推文里的最新值。 +func (a *App) syncSiteX(site *Site, u *xUser) { + name := cleanText(u.Name, 20, false) + if name == "" || hasContact(name) { + name = "@" + u.Handle + } + file := "" + if u.Avatar != "" { + if f, err := a.downloadAvatar(u.Handle, strings.Replace(u.Avatar, "_normal", "_200x200", 1)); err == nil { + file = f + } else { + a.logf("[warn] 找回口令时重抓 @%s 头像失败: %v", u.Handle, err) + } + } + if err := a.st.SyncSiteX(site.ID, u.ID, u.Handle, name, file); err != nil { + a.logf("[warn] 站点 /%s 同步 X 资料失败: %v", site.Slug, err) + if file != "" { + os.Remove(filepath.Join(a.cfg.AvatarDir, file)) + } + return + } + if file != "" { + a.st.UpsertXProfile(strings.ToLower(u.Handle), name, file, ms()) + a.xp.mu.Lock() + a.xp.files[strings.ToLower(u.Handle)] = file + if !strings.EqualFold(site.XHandle, u.Handle) { + delete(a.xp.files, strings.ToLower(site.XHandle)) + } + a.xp.mu.Unlock() + if site.XAvatar != "" && site.XAvatar != file && !a.st.AvatarInUse(site.XAvatar) { + os.Remove(filepath.Join(a.cfg.AvatarDir, site.XAvatar)) + } + } + if changed := !strings.EqualFold(site.XHandle, u.Handle) || site.Name != name; changed { + a.logf("[info] 站点 /%s 的 X 资料已更新:@%s %q → @%s %q", site.Slug, site.XHandle, site.Name, u.Handle, name) + } + site.XHandle, site.XName, site.Name, site.XURL = u.Handle, name, name, "https://x.com/"+u.Handle + if u.ID != "" { + site.XID = u.ID + } + if file != "" { + site.XAvatar = file + } +} + +func (a *App) handleResetPost(w http.ResponseWriter, r *http.Request) { + v, prof, msg := a.loadVerified(r, "reset") + if msg != "" || prof.SiteID == 0 { + a.startVerify(w, r, "reset", "验证已失效,请重新验证") + return + } + site, err := a.st.GetSiteByID(prof.SiteID) + if err != nil || site == nil { + a.fail(w, r, errors.New("站点不存在")) + return + } + pw, pw2 := r.FormValue("password"), r.FormValue("password2") + if len(pw) < 6 || len(pw) > 72 || pw != pw2 { + a.render(w, http.StatusBadRequest, "new", newPage{Base: a.base(r), Step: 2, Purpose: "reset", Code: v.Code, Prof: &xUser{Name: prof.Name, Handle: prof.Handle}, AvatarURL: site.AvatarURL(), Site: site, Err: "口令至少 6 位,且两次一致"}) + return + } + if ok, _ := a.st.UseVerify(v.Code); !ok { + a.startVerify(w, r, "reset", "验证已失效,请重新验证") + return + } + if err := a.st.SetSitePassword(site.ID, hashPassword(pw)); err != nil { + a.fail(w, r, err) + return + } + a.logf("[info] 站点 /%s 通过 X 重置口令", site.Slug) + if fresh, err := a.st.GetSiteByID(site.ID); err == nil && fresh != nil { + a.login(w, fresh) + } + a.flash(w, "口令已重置,其它设备上的登录已失效") + http.Redirect(w, r, "/manage", http.StatusFound) +} + +// ---------- 登录 ---------- + +type loginPage struct { + Base + Slug string + Err string +} + +func (a *App) handleLoginGet(w http.ResponseWriter, r *http.Request) { + if a.currentSite(r) != nil { + http.Redirect(w, r, "/manage", http.StatusFound) + return + } + a.render(w, http.StatusOK, "login", loginPage{Base: a.base(r), Slug: strings.ToLower(strings.TrimSpace(r.URL.Query().Get("slug")))}) +} + +func normSlugInput(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.Trim(strings.TrimPrefix(s, "@"), "/") + if s == "main" || s == "主站" { + return "" + } + return s +} + +func (a *App) handleLoginPost(w http.ResponseWriter, r *http.Request) { + if a.limited(w, r, "login", 10, time.Minute) { + return + } + slug := normSlugInput(r.FormValue("slug")) + pw := r.FormValue("password") + p := loginPage{Base: a.base(r), Slug: slug, Err: "X 用户名或口令不对"} + if slug != "" && !validSlug(slug) { + a.render(w, http.StatusBadRequest, "login", p) + return + } + if a.lim.count("loginfail:"+slug, 10*time.Minute) >= 30 { + a.errorPage(w, r, http.StatusTooManyRequests, "手速太快了", "这个站点的错误口令太多,歇一会儿。") + return + } + site, err := a.st.GetSiteBySlug(slug) + if err != nil { + a.fail(w, r, err) + return + } + if site == nil || site.Status != "active" || !checkPassword(site.PassHash, pw) { + a.lim.allow("loginfail:"+slug, 1000, 10*time.Minute) + a.render(w, http.StatusUnauthorized, "login", p) + return + } + a.login(w, site) + a.logf("[info] 站长登录 /%s", site.Slug) + http.Redirect(w, r, "/manage", http.StatusFound) +} + +func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) { + a.logout(w) + http.Redirect(w, r, "/", http.StatusFound) +} + +// ---------- 管理页 ---------- + +type managePage struct { + Base + PwErr bool // 改口令出错:页面加载时直接弹开口令弹窗 + Site *Site + Stats Stats + Claims []*Donation + Donations []*Donation + Sites []SiteRow + Messages []AdminMessage + DonPager Pager // 施舍记录分页 + SitePager Pager // 所有要饭站分页(主站) + MsgPager Pager // 全站留言分页(主站) + Welcome bool + Flash string + Err string + HasGateway bool + LoginURL string + Coins int64 + Presets []string +} + +func (a *App) ownerSite(w http.ResponseWriter, r *http.Request) (*Site, bool) { + site := a.currentSite(r) + if site == nil { + if r.Method == http.MethodGet { + http.Redirect(w, r, "/login", http.StatusFound) + } else { + a.errorPage(w, r, http.StatusUnauthorized, "请先登录", "登录状态已失效,请重新登录。") + } + return nil, false + } + return site, true +} + +const managePageSize = 5 + +// Pager 后台列表分页:?dp=2#donations 这种,页码从 1 起。 +type Pager struct { + Param string // 查询参数名 + Anchor string // 跳回的锚点 + Page int + Pages int + Total int + Size int + Query string // 其它分页参数原样带上(各列表独立翻页) +} + +func newPager(r *http.Request, param, anchor string, total, size int) Pager { + pg := Pager{Param: param, Anchor: anchor, Total: total, Size: size, Page: 1} + pg.Pages = (total + size - 1) / size + if pg.Pages < 1 { + pg.Pages = 1 + } + if n, err := strconv.Atoi(r.URL.Query().Get(param)); err == nil && n > 1 { + pg.Page = n + } + if pg.Page > pg.Pages { + pg.Page = pg.Pages + } + q := url.Values{} + for _, k := range []string{"dp", "sp", "mp"} { + if k != param && r.URL.Query().Get(k) != "" { + q.Set(k, r.URL.Query().Get(k)) + } + } + pg.Query = q.Encode() + return pg +} + +func (p Pager) Offset() int { return (p.Page - 1) * p.Size } +func (p Pager) HasPrev() bool { return p.Page > 1 } +func (p Pager) HasNext() bool { return p.Page < p.Pages } +func (p Pager) Prev() int { return p.Page - 1 } +func (p Pager) Next() int { return p.Page + 1 } + +// Slice 对已在内存里的列表取当前页。 +func (p Pager) Slice(rows []SiteRow) []SiteRow { + lo := p.Offset() + if lo >= len(rows) { + return nil + } + hi := lo + p.Size + if hi > len(rows) { + hi = len(rows) + } + return rows[lo:hi] +} + +// URL 第 n 页的链接(保留别的列表的页码)。 +func (p Pager) URL(n int) string { + q := p.Query + if n > 1 { + if q != "" { + q += "&" + } + q += p.Param + "=" + strconv.Itoa(n) + } + if q != "" { + q = "?" + q + } + return "/manage" + q + p.Anchor +} + +// Nums 要显示的页码:当前页前后各 2 页。 +func (p Pager) Nums() []int { + var out []int + for i := p.Page - 2; i <= p.Page+2; i++ { + if i >= 1 && i <= p.Pages { + out = append(out, i) + } + } + return out +} + +// manageBack 操作完回到后台原来的那一页(Referer 里带着 ?dp=3 之类),否则回后台首页 + 锚点。 +func (a *App) manageBack(r *http.Request, anchor string) string { + if ref, err := url.Parse(r.Referer()); err == nil && ref.Path == "/manage" && ref.RawQuery != "" { + return "/manage?" + ref.RawQuery + anchor + } + return "/manage" + anchor +} + +func (a *App) renderManage(w http.ResponseWriter, r *http.Request, site *Site, errMsg string) { + a.renderManageFlash(w, r, site, errMsg, "") +} + +func (a *App) renderManageFlash(w http.ResponseWriter, r *http.Request, site *Site, errMsg, flash string) { + a.renderManageFull(w, r, site, errMsg, flash, false) +} + +func (a *App) renderManageFull(w http.ResponseWriter, r *http.Request, site *Site, errMsg, flash string, pwErr bool) { + p := managePage{Base: a.base(r), PwErr: pwErr, Site: site, Welcome: r.URL.Query().Get("welcome") == "1", Err: errMsg, + HasGateway: a.gwc != nil, LoginURL: a.cfg.BaseURL + "/login?slug=" + site.Slug, Presets: a.cfg.PresetAmounts} + p.Flash = a.takeFlash(w, r) + if flash != "" { + p.Flash = flash + } + var err error + if p.Stats, err = a.st.SiteStats(site.ID); err != nil { + a.fail(w, r, err) + return + } + p.Coins, _, _ = a.st.CoinStats(site.ID) + if site.PayMode != "gateway" { + if p.Claims, err = a.st.SiteClaims(site.ID); err != nil { + a.fail(w, r, err) + return + } + } + p.DonPager = newPager(r, "dp", "#donations", a.st.CountSiteDonations(site.ID), managePageSize) + if p.Donations, err = a.st.SiteDonationsPage(site.ID, p.DonPager.Offset(), managePageSize); err != nil { + a.fail(w, r, err) + return + } + if site.IsMain() { + if p.Sites, err = a.st.SubsiteRank(2000, true, "amount"); err != nil { + a.fail(w, r, err) + return + } + if positions, err := a.st.SitePositions(a.cfg.MoneyExp, a.cfg.CoinExp); err == nil { + a.decorate(p.Sites, positions) + p.SitePager = newPager(r, "sp", "#sites", len(p.Sites), managePageSize) + p.Sites = p.SitePager.Slice(p.Sites) + } + p.MsgPager = newPager(r, "mp", "#messages", a.st.CountMessages(), managePageSize) + if p.Messages, err = a.st.RecentMessages(p.MsgPager.Offset(), managePageSize); err != nil { + a.fail(w, r, err) + return + } + } + status := http.StatusOK + if errMsg != "" { + status = http.StatusBadRequest + } + a.render(w, status, "manage", p) +} + +func (a *App) handleManage(w http.ResponseWriter, r *http.Request) { + site, ok := a.ownerSite(w, r) + if !ok { + return + } + a.renderManage(w, r, site, "") +} + +func (a *App) handleManageProfile(w http.ResponseWriter, r *http.Request) { + site, ok := a.ownerSite(w, r) + if !ok { + return + } + name := site.Name // X 验证过的子站,站名锁定为 X 昵称;主站随便改 + if site.XHandle == "" || site.IsMain() { + if name = cleanText(r.FormValue("name"), 20, false); name == "" { + a.renderManage(w, r, site, "站点标题不能为空") + return + } + } + avatar := cleanText(r.FormValue("avatar"), 12, false) + if avatar == "" { + avatar = "🥣" + } + xURL := site.XURL + if site.XHandle == "" { + var err error + if xURL, err = validateXLink(r.FormValue("x_url")); err != nil { + a.renderManage(w, r, site, err.Error()) + return + } + } + slogan, story := cleanText(r.FormValue("slogan"), 60, false), site.Story // 介绍模块已去掉,只保留口号 + if (site.XHandle == "" && hasContact(name)) || hasContact(slogan) || hasContact(story) { + a.renderManage(w, r, site, "资料里别放链接、联系方式") + return + } + if err := a.st.UpdateSiteProfile(site.ID, name, slogan, story, avatar, xURL); err != nil { + a.fail(w, r, err) + return + } + a.flash(w, "资料已保存") + http.Redirect(w, r, "/manage#profile", http.StatusFound) +} + +// handleManagePayment 直接转账模式:收款码 / Pay ID(会解绑已绑定的币安 Key)。 +func (a *App) handleManagePayment(w http.ResponseWriter, r *http.Request) { + site, ok := a.ownerSite(w, r) + if !ok { + return + } + if site.IsMain() { + a.renderManage(w, r, site, "主站的收款方式在服务器 config.env 里改") + return + } + link, err := validateBinanceLink(r.FormValue("receive_link")) + if err != nil { + a.renderManage(w, r, site, err.Error()) + return + } + payID := cleanText(r.FormValue("pay_id"), 60, false) + if link == "" && payID == "" { + a.renderManage(w, r, site, "收款链接和 Pay ID 至少填一项,不然钱没处去") + return + } + if site.PayMode == "gateway" { + if err := a.unbindAccount(site); err != nil { + a.renderManage(w, r, site, "解绑币安 Key 失败:"+gatewayErr(err)) + return + } + } + if err := a.st.UpdateSitePayment(site.ID, "manual", "", "", link, payID); err != nil { + a.fail(w, r, err) + return + } + a.flash(w, "已切换为直接转账:施主转完自报,你在这里确认") + http.Redirect(w, r, "/manage#payment", http.StatusFound) +} + +// handleManageBind 绑定币安只读 Key:注册到网关成为独立收款账号,之后到账自动确认。 +func (a *App) handleManageBind(w http.ResponseWriter, r *http.Request) { + site, ok := a.ownerSite(w, r) + if !ok { + return + } + if site.IsMain() || a.gwc == nil { + a.renderManage(w, r, site, "本站不支持绑定") + return + } + if a.limited(w, r, "bind", 5, 10*time.Minute) { + return + } + key, secret := strings.TrimSpace(r.FormValue("api_key")), strings.TrimSpace(r.FormValue("api_secret")) + uid := strings.TrimSpace(r.FormValue("uid")) + link, err := validateBinanceLink(r.FormValue("receive_link")) + if err != nil { + a.renderManage(w, r, site, err.Error()) + return + } + switch { + case key == "" || secret == "" || len(key) > 128 || len(secret) > 128: + a.renderManage(w, r, site, "API Key 和 Secret 都要填") + return + case !reUID.MatchString(uid): + a.renderManage(w, r, site, "币安 UID 是纯数字(App 头像页可见)") + return + } + acc, err := a.bindAccount(site, key, secret, uid, link) + if err != nil { + a.logf("[warn] 站点 /%s 绑定 Key 失败: %v", site.Slug, err) + a.renderManage(w, r, site, "绑定失败:"+gatewayErr(err)) + return + } + if err := a.st.UpdateSitePayment(site.ID, "gateway", acc.AccountID, uid, link, site.PayID); err != nil { + a.fail(w, r, err) + return + } + a.logf("[info] 站点 /%s 绑定币安账号 %s uid=%s", site.Slug, acc.AccountID, uid) + a.flash(w, "绑定成功!币安校验通过,之后给你的施舍会自动确认上榜") + http.Redirect(w, r, "/manage#payment", http.StatusFound) +} + +func (a *App) handleManageUnbind(w http.ResponseWriter, r *http.Request) { + site, ok := a.ownerSite(w, r) + if !ok { + return + } + if site.IsMain() || site.PayMode != "gateway" { + http.Redirect(w, r, "/manage#payment", http.StatusFound) + return + } + if err := a.unbindAccount(site); err != nil { + a.renderManage(w, r, site, "解绑失败:"+gatewayErr(err)) + return + } + if err := a.st.UpdateSitePayment(site.ID, "manual", "", "", site.ReceiveLink, site.PayID); err != nil { + a.fail(w, r, err) + return + } + a.logf("[info] 站点 /%s 解绑币安账号", site.Slug) + a.flash(w, "已解绑。现在是直接转账模式,记得填好收款链接或 Pay ID") + http.Redirect(w, r, "/manage#payment", http.StatusFound) +} + +func (a *App) handleManageVerify(w http.ResponseWriter, r *http.Request) { + site, ok := a.ownerSite(w, r) + if !ok { + return + } + if site.PayMode != "gateway" || a.gwc == nil { + http.Redirect(w, r, "/manage#payment", http.StatusFound) + return + } + if a.limited(w, r, "verifykey", 5, time.Minute) { + return + } + if _, err := a.verifyAccount(site); err != nil { + a.flash(w, "校验失败:"+gatewayErr(err)) + } else { + a.flash(w, "校验通过:币安 Key 可用,到账会自动确认") + } + http.Redirect(w, r, "/manage#payment", http.StatusFound) +} + +func (a *App) handleManagePassword(w http.ResponseWriter, r *http.Request) { + site, ok := a.ownerSite(w, r) + if !ok { + return + } + if site.IsMain() { + a.renderManage(w, r, site, "主站口令在服务器 config.env(ADMIN_PASSWORD)里改") + return + } + pw, pw2 := r.FormValue("password"), r.FormValue("password2") + if len(pw) < 6 || len(pw) > 72 { + a.renderManageFull(w, r, site, "口令至少 6 位", "", true) + return + } + if pw != pw2 { + a.renderManageFull(w, r, site, "两次口令不一致", "", true) + return + } + if err := a.st.SetSitePassword(site.ID, hashPassword(pw)); err != nil { + a.fail(w, r, err) + return + } + if fresh, err := a.st.GetSiteByID(site.ID); err == nil && fresh != nil { + a.login(w, fresh) + } + a.flash(w, "口令已修改,其它设备上的登录已失效") + http.Redirect(w, r, "/manage#password", http.StatusFound) +} + +func (a *App) handleManageDonation(w http.ResponseWriter, r *http.Request) { + site, ok := a.ownerSite(w, r) + if !ok { + return + } + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + d, err := a.st.GetDonationByID(id) + if err != nil { + a.fail(w, r, err) + return + } + action := r.PathValue("action") + if d == nil || (d.SiteID != site.ID && !site.IsMain()) { + a.errorPage(w, r, http.StatusNotFound, "没有这笔施舍", "") + return + } + anchor := "#donations" + switch action { + case "confirm", "reject": + if d.SiteID != site.ID || d.IsGateway() || d.Status != "claimed" { + a.renderManage(w, r, site, "这笔施舍当前不能确认/拒绝") + return + } + if action == "confirm" { + _, err = a.st.MarkPaid(d.ID, d.Amount, d.AmountE8, "owner", d.BinanceOrderID, "", ms()) + a.logf("[info] 站长确认到账 %s %s %s(站点 %d)", d.Code, d.Amount, d.Currency, site.ID) + if d.XHandle != "" { + a.ensureXProfile(d.XHandle) + } + } else { + _, err = a.st.SetStatusIf(d.ID, []string{"claimed"}, "rejected") + } + anchor = "#claims" + case "block", "unblock": + err = a.st.SetMsgBlocked(d.ID, action == "block") + if action == "block" { + a.logf("[info] 屏蔽留言 %s(站点 %d): %q", d.Code, d.SiteID, d.Message) + } + case "blockuser", "unblockuser": + err = a.st.BlockNick(d.SiteID, d.NickKey, action == "blockuser") + a.logf("[info] %s %q(站点 %d)", action, d.Nickname, d.SiteID) + case "reply": + if d.SiteID != site.ID { + a.errorPage(w, r, http.StatusForbidden, "只能回复自己站的施舍", "") + return + } + text := cleanText(r.FormValue("reply"), 80, false) + if hasContact(text) { + a.renderManage(w, r, site, "回复里别放链接、联系方式") + return + } + err = a.st.SetReply(d.ID, text) + default: + a.errorPage(w, r, http.StatusNotFound, "未知操作", "") + return + } + if err != nil { + a.fail(w, r, err) + return + } + if d.SiteID != site.ID { + anchor = "#messages" + } + if anchor == "#claims" { + if left, _ := a.st.SiteClaims(site.ID); len(left) == 0 { + anchor = "#donations" + } + } + http.Redirect(w, r, a.manageBack(r, anchor), http.StatusFound) +} + +// handleManageSite 管理员对子站:除名 / 恢复 / 收录 / 取消收录 / 重置口令。 +func (a *App) handleManageSite(w http.ResponseWriter, r *http.Request) { + site, ok := a.ownerSite(w, r) + if !ok { + return + } + if !site.IsMain() { + a.errorPage(w, r, http.StatusForbidden, "无权操作", "") + return + } + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + target, err := a.st.GetSiteByID(id) + if err != nil { + a.fail(w, r, err) + return + } + if target == nil || target.IsMain() { + a.errorPage(w, r, http.StatusNotFound, "没有这个站", "") + return + } + switch r.PathValue("action") { + case "disable", "enable": + status := "disabled" + if r.PathValue("action") == "enable" { + status = "active" + } + err = a.st.SetSiteStatus(target.ID, status) + a.logf("[info] 站点 /%s -> %s", target.Slug, status) + case "list", "unlist": + err = a.st.SetSiteListed(target.ID, r.PathValue("action") == "list") + a.logf("[info] 站点 /%s -> %s", target.Slug, r.PathValue("action")) + case "resetpw": + pw := strings.ToLower(randCode(10)) + if err = a.st.SetSitePassword(target.ID, hashPassword(pw)); err == nil { + a.logf("[info] 重置站点 /%s 口令", target.Slug) + a.renderManageFlash(w, r, site, "", "已重置 /"+target.Slug+" 的口令为 "+pw+" (只显示这一次,请转告站长)") + return + } + default: + a.errorPage(w, r, http.StatusNotFound, "未知操作", "") + return + } + if err != nil { + a.fail(w, r, err) + return + } + http.Redirect(w, r, a.manageBack(r, "#sites"), http.StatusFound) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9b8f702 --- /dev/null +++ b/main.go @@ -0,0 +1,60 @@ +// 赛博要饭:一个可以要饭、施舍、留言、上榜、开分站的小站。收款走 BinancePayTool 网关。 +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +const version = "0.1.0" + +func main() { + cfgPath := flag.String("config", "./config.env", "配置文件路径(KEY=VALUE,环境变量优先)") + genKey := flag.Bool("gen-key", false, "生成一个随机 ADMIN_TOKEN") + showVer := flag.Bool("version", false, "打印版本") + flag.Parse() + + if *showVer { + fmt.Println("newbeggar", version) + return + } + if *genKey { + fmt.Println(randToken()) + return + } + cfg, err := loadConfig(*cfgPath) + if err != nil { + log.Fatalf("[fatal] %v", err) + } + app, err := newApp(cfg) + if err != nil { + log.Fatalf("[fatal] 初始化失败: %v", err) + } + defer app.st.Close() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + srv := &http.Server{Addr: cfg.Listen, Handler: app, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} + go func() { + <-ctx.Done() + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + srv.Shutdown(shutCtx) + }() + mode := "直接转账" + if cfg.BPGURL != "" { + mode = "网关 " + cfg.BPGURL + } + log.Printf("[info] newbeggar %s 监听 %s,对外 %s,主站收款:%s,开站:%v", version, cfg.Listen, cfg.BaseURL, mode, cfg.SubsitesEnabled) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("[fatal] %v", err) + } + log.Printf("[info] 已退出") +} diff --git a/payment.go b/payment.go new file mode 100644 index 0000000..2c7b7a1 --- /dev/null +++ b/payment.go @@ -0,0 +1,230 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + bpaygate "github.com/qianyubtc/BinancePayTool/sdk/go" +) + +var errNoGateway = errors.New("未配置支付网关") + +func (a *App) createGatewayOrder(site *Site, d *Donation) error { + if a.gwc == nil { + return errNoGateway + } + o, err := a.gwc.CreateOrder(bpaygate.CreateOrderReq{ + AccountID: site.BPGAccountID, // 主站为空 = 网关默认账号 + MerchantOrderID: d.Code, + Currency: d.Currency, + Amount: d.Amount, + CallbackURL: a.cfg.BaseURL + "/bpg/notify", + ReturnURL: a.cfg.BaseURL + "/d/" + d.Code, + Timeout: a.cfg.OrderTTL, + }) + if err != nil { + return err + } + d.GwOrderID, d.PayURL, d.PayAmount, d.NoteCode = o.OrderID, o.PayURL, o.PayAmount, o.NoteCode + d.PayLink, d.PayUID, d.ExpiresAt = o.ReceiveLink, o.ReceiveUID, o.ExpiresAt + return a.st.SetDonationGateway(d.ID, o.OrderID, o.PayURL, o.PayAmount, o.NoteCode, o.ReceiveLink, o.ReceiveUID, o.ExpiresAt) +} + +// refreshFromGateway 不受 4 秒节流地向网关查一次单(回填订单编号后用)。 +func (a *App) refreshFromGateway(d *Donation) { + if a.gwc == nil || !d.IsGateway() { + return + } + o, err := a.gwc.GetOrder(d.GwOrderID) + if err != nil { + log.Printf("[warn] 查单 %s: %v", d.Code, err) + return + } + a.applyGatewayStatus(d, o.Status, o.ActualAmount, o.PayAmount, o.MatchedBy, o.BinanceOrderID, o.PayerID, o.PaidAt) + if nd, err := a.st.GetDonationByID(d.ID); err == nil && nd != nil { + *d = *nd + } +} + +var claimMsgs = map[string]string{ + "OK": "核对成功,已到账", + "UNDERPAID": "查到了这笔转账但金额不足,已按实付记账", + "NOT_FOUND": "没查到这笔转账:确认订单编号是否正确、是否转给了正确的账号,币安到账后再试", + "CONSUMED": "这笔转账已经被别的订单用过了", + "CURRENCY": "币种不对", + "STATE": "这笔施舍当前状态不能回填", +} + +// gatewayClaim 把施主填的币安订单编号转给网关收银页的回填接口(公开接口,网关按 token 限流)。 +func (a *App) gatewayClaim(d *Donation, boid string) (string, error) { + if a.gwc == nil || d.PayURL == "" { + return "", errNoGateway + } + token := d.PayURL[strings.LastIndex(d.PayURL, "/")+1:] + body, _ := json.Marshal(map[string]string{"binance_order_id": boid}) + req, _ := http.NewRequest("POST", a.cfg.BPGURL+"/pay/"+token+"/claim", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := a.gwc.HC.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + var env struct { + Code string `json:"code"` + Message string `json:"message"` + Data struct { + Code string `json:"code"` + Status string `json:"status"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return "", fmt.Errorf("网关响应异常 (HTTP %d)", resp.StatusCode) + } + if env.Code != "OK" { + if env.Code == "ERR_RATE_LIMIT" { + return "", errors.New("试得太频繁了,一分钟后再试") + } + return "", errors.New(env.Message) + } + return env.Data.Code, nil +} + +// applyGatewayStatus 把网关侧状态落到本地(幂等)。少付也算施舍:按实付计。 +func (a *App) applyGatewayStatus(d *Donation, status, actual, payAmount, matchedBy, boid, payerID string, paidAt int64) { + switch status { + case "paid", "underpaid": + amt := actual + if amt == "" { + amt = payAmount + } + e8, err := parseAmountE8(amt, 8) + if err != nil || e8 <= 0 { + if status != "paid" { + log.Printf("[error] %s 实付金额无法解析 %q,暂不记账", d.Code, amt) + return + } + amt, e8 = d.Amount, d.AmountE8 + } + if paidAt <= 0 { + paidAt = ms() + } + ok, err := a.st.MarkPaid(d.ID, amt, e8, matchedBy, boid, payerID, paidAt) + if err != nil { + log.Printf("[error] 标记到账 %s: %v", d.Code, err) + } else if ok { + log.Printf("[info] 到账 %s %s %s by %s(站点 %d,%s)", d.Code, amt, d.Currency, matchedBy, d.SiteID, d.Nickname) + if d.XHandle != "" { + a.ensureXProfile(d.XHandle) // 真金白银到账后才抓头像,防止刷接口填盘 + } + } + case "expired", "closed": + if _, err := a.st.SetStatusIf(d.ID, []string{"pending"}, status); err != nil { + log.Printf("[error] 更新状态 %s: %v", d.Code, err) + } + } +} + +// syncFromGateway 轮询兜底:回调没到也能靠主动查单确认(每笔最多 4 秒查一次)。 +func (a *App) syncFromGateway(d *Donation) { + if d.Status != "pending" || !d.IsGateway() || a.gwc == nil { + return + } + if ok, err := a.st.TouchSync(d.ID, ms(), 4000); err != nil || !ok { + return + } + o, err := a.gwc.GetOrder(d.GwOrderID) + if err != nil { + log.Printf("[warn] 查单 %s: %v", d.Code, err) + return + } + a.applyGatewayStatus(d, o.Status, o.ActualAmount, o.PayAmount, o.MatchedBy, o.BinanceOrderID, o.PayerID, o.PaidAt) + if nd, err := a.st.GetDonationByID(d.ID); err == nil && nd != nil { + *d = *nd + } +} + +// handleNotify 网关回调:验签后按 merchant_order_id(= 施舍编号)落状态。 +func (a *App) handleNotify(w http.ResponseWriter, r *http.Request) { + if a.gwc == nil { + http.Error(w, "no gateway", http.StatusNotFound) + return + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) + if err != nil { + http.Error(w, "bad body", http.StatusBadRequest) + return + } + cb, err := bpaygate.VerifyCallback(r.Header, body, a.cfg.BPGKey, 5*time.Minute) + if err != nil { + log.Printf("[warn] 回调验签失败: %v", err) + http.Error(w, "bad signature", http.StatusUnauthorized) + return + } + d, err := a.st.GetDonationByCode(cb.MerchantOrderID) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + if d == nil || !d.IsGateway() || (cb.OrderID != "" && cb.OrderID != d.GwOrderID) { + http.Error(w, "unknown order", http.StatusNotFound) + return + } + a.applyGatewayStatus(d, cb.Status, cb.ActualAmount, cb.PayAmount, cb.MatchedBy, cb.BinanceOrderID, cb.PayerID, cb.PaidAt) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) +} + +// ---- 子站绑定币安只读 Key(网关多账号)---- + +func (a *App) bindAccount(site *Site, key, secret, uid, link string) (*bpaygate.Account, error) { + if a.gwc == nil { + return nil, errNoGateway + } + return a.gwc.CreateAccount(bpaygate.CreateAccountReq{Label: "beggar:" + site.Slug, APIKey: key, APISecret: secret, UID: uid, ReceiveLink: link}) +} + +func (a *App) unbindAccount(site *Site) error { + if a.gwc == nil || site.BPGAccountID == "" { + return nil + } + _, err := a.gwc.DisableAccount(site.BPGAccountID) + var ae *bpaygate.APIError + if errors.As(err, &ae) && ae.Code == "ERR_NOT_FOUND" { + return nil + } + return err +} + +func (a *App) verifyAccount(site *Site) (*bpaygate.Account, error) { + if a.gwc == nil { + return nil, errNoGateway + } + id := site.BPGAccountID + if id == "" { + id = "default" + } + return a.gwc.VerifyAccount(id) +} + +// gatewayErr 把网关错误变成能给站长看的话。 +func gatewayErr(err error) string { + var ae *bpaygate.APIError + if errors.As(err, &ae) { + if ae.Message != "" { + return ae.Message + } + return "网关返回 " + ae.Code + } + if errors.Is(err, errNoGateway) { + return "本站未配置支付网关" + } + return "网关连不上,请稍后再试" +} diff --git a/ratelimit.go b/ratelimit.go new file mode 100644 index 0000000..02a9578 --- /dev/null +++ b/ratelimit.go @@ -0,0 +1,56 @@ +package main + +import ( + "sync" + "time" +) + +// limiter 简单的内存滑动窗口限流(单进程够用)。 +type limiter struct { + mu sync.Mutex + m map[string][]int64 + last time.Time +} + +func newLimiter() *limiter { return &limiter{m: map[string][]int64{}, last: time.Now()} } + +// count 窗口内已记录的次数(不计入)。 +func (l *limiter) count(key string, window time.Duration) int { + cut := time.Now().Add(-window).UnixNano() + l.mu.Lock() + defer l.mu.Unlock() + n := 0 + for _, t := range l.m[key] { + if t >= cut { + n++ + } + } + return n +} + +func (l *limiter) allow(key string, max int, window time.Duration) bool { + now := time.Now() + cut := now.Add(-window).UnixNano() + l.mu.Lock() + defer l.mu.Unlock() + if now.Sub(l.last) > 10*time.Minute || len(l.m) > 50000 { + for k, ts := range l.m { + if len(ts) == 0 || ts[len(ts)-1] < now.Add(-time.Hour).UnixNano() { + delete(l.m, k) + } + } + l.last = now + } + ts := l.m[key] + i := 0 + for i < len(ts) && ts[i] < cut { + i++ + } + ts = ts[i:] + if len(ts) >= max { + l.m[key] = ts + return false + } + l.m[key] = append(ts, now.UnixNano()) + return true +} diff --git a/static/OFL-fusion-pixel-font.txt b/static/OFL-fusion-pixel-font.txt new file mode 100644 index 0000000..0427d1d --- /dev/null +++ b/static/OFL-fusion-pixel-font.txt @@ -0,0 +1,96 @@ +Fusion Pixel Font +https://github.com/TakWolf/fusion-pixel-font + +Copyright (c) 2022, TakWolf (https://takwolf.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/static/pixel.woff2 b/static/pixel.woff2 new file mode 100644 index 0000000..9e4c12b Binary files /dev/null and b/static/pixel.woff2 differ diff --git a/store.go b/store.go new file mode 100644 index 0000000..192267b --- /dev/null +++ b/store.go @@ -0,0 +1,1014 @@ +package main + +import ( + "database/sql" + "encoding/json" + "errors" + "log" + "strconv" + "strings" + + _ "modernc.org/sqlite" +) + +var ( + ErrSlugTaken = errors.New("地址已被占用") + ErrXTaken = errors.New("这个 X 账号已经开过站了") +) + +type Site struct { + ID int64 + Slug string // 主站为空串 + Name string + Slogan string + Story string + Avatar string + XURL string + XHandle string // X 验证过的 handle(子站必有) + XName string // X 昵称(站名锁定为它) + XAvatar string // 本地缓存的头像文件名 + XID string // X 数字用户 ID(改名后仍能认出同一人) + Listed bool // 是否收录进榜单 + Coins int64 // 钢镚总数 + Wishes string // 愿望清单 JSON + PassHash string + PayMode string // gateway | manual + BPGAccountID string // 网关里的收款账号 ID;主站为空 = 网关默认账号 + BinanceUID string + ReceiveLink string + PayID string + Currency string + CreatedFrom int64 + Status string // active | disabled + CreatedAt int64 + UpdatedAt int64 +} + +func (s *Site) IsMain() bool { return s.Slug == "" } +func (s *Site) Path() string { return "/" + s.Slug } + +// PaymentReady 是否已配置可用的收款方式。 +func (s *Site) PaymentReady() bool { + if s.PayMode == "gateway" { + return true + } + return s.ReceiveLink != "" || s.PayID != "" +} + +type Donation struct { + ID int64 + Code string // 公开编号,也是网关的 merchant_order_id + SiteID int64 + Nickname string + NickKey string + XHandle string + Message string + MsgBlocked bool + Blocked bool // 屏蔽此人:名号与留言都隐藏,记录保留 + Reply string // 站长回复 + Amount string // 施主填的金额 + AmountE8 int64 + ActualAmount string // 确认到账的金额 + ActualE8 int64 + Currency string + Status string // pending | claimed | paid | expired | closed | failed | rejected + MatchedBy string + GwOrderID string + PayURL string + PayAmount string + PayLink string // 收款链接(二维码内容 / 唤起 App) + PayUID string // 收款方 UID + ExpiresAt int64 + NoteCode string + BinanceOrderID string + PayerID string + ClaimToken string + IP string + SiteCreated int64 + CreatedAt int64 + PaidAt int64 + LastSync int64 +} + +func (d *Donation) IsGateway() bool { return d.GwOrderID != "" } + +type Stats struct { + TotalE8 int64 + Count int64 + Donors int64 + TodayE8 int64 +} + +type DonorRow struct { + NickKey string + Nickname string + TotalE8 int64 + Count int64 + Sites int64 + LastAt int64 +} + +type SiteRow struct { + ID int64 + Slug string + Name string + Avatar string + XAvatar string + XHandle string + Status string + PayMode string + Listed bool + Coins int64 + CreatedAt int64 + TotalE8 int64 + Count int64 + Donors int64 + Exp int64 // 钱 + 钢镚折算 + Title string // 丐帮职位(按 Exp 综合排名) +} + +func (s SiteRow) Path() string { return "/" + s.Slug } +func (s SiteRow) AvatarURL() string { + if s.XAvatar == "" { + return "" + } + return "/a/" + s.XAvatar +} + +type Wish struct { + Label string `json:"label"` + Amount string `json:"amount"` +} + +type XProfile struct { + Handle string + Name string + Avatar string + FetchedAt int64 +} + +type Verify struct { + Code string + Purpose string // new | reset + Secret string // 绑定发起验证的浏览器(cookie 里同名密钥),别人看到推文里的码也用不了 + Profile string // JSON + Used bool + CreatedAt int64 +} + +type AdminMessage struct { + Donation + SiteName string + SiteSlug string +} + +type Store struct{ db *sql.DB } + +var schema = []string{ + `CREATE TABLE IF NOT EXISTS sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + slogan TEXT NOT NULL DEFAULT '', + story TEXT NOT NULL DEFAULT '', + avatar TEXT NOT NULL DEFAULT '🥣', + x_url TEXT NOT NULL DEFAULT '', + x_handle TEXT NOT NULL DEFAULT '', + x_name TEXT NOT NULL DEFAULT '', + x_avatar TEXT NOT NULL DEFAULT '', + listed INTEGER NOT NULL DEFAULT 1, + coins INTEGER NOT NULL DEFAULT 0, + wishes TEXT NOT NULL DEFAULT '', + pass_hash TEXT NOT NULL DEFAULT '', + pay_mode TEXT NOT NULL DEFAULT 'manual', + bpg_account_id TEXT NOT NULL DEFAULT '', + binance_uid TEXT NOT NULL DEFAULT '', + receive_link TEXT NOT NULL DEFAULT '', + pay_id TEXT NOT NULL DEFAULT '', + currency TEXT NOT NULL DEFAULT 'USDT', + created_from INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS donations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + site_id INTEGER NOT NULL, + nickname TEXT NOT NULL, + nick_key TEXT NOT NULL, + x_handle TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '', + msg_blocked INTEGER NOT NULL DEFAULT 0, + blocked INTEGER NOT NULL DEFAULT 0, + reply TEXT NOT NULL DEFAULT '', + amount TEXT NOT NULL, + amount_e8 INTEGER NOT NULL, + actual_amount TEXT NOT NULL DEFAULT '', + actual_e8 INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + matched_by TEXT NOT NULL DEFAULT '', + gw_order_id TEXT NOT NULL DEFAULT '', + pay_url TEXT NOT NULL DEFAULT '', + pay_amount TEXT NOT NULL DEFAULT '', + pay_link TEXT NOT NULL DEFAULT '', + pay_uid TEXT NOT NULL DEFAULT '', + expires_at INTEGER NOT NULL DEFAULT 0, + note_code TEXT NOT NULL DEFAULT '', + binance_order_id TEXT NOT NULL DEFAULT '', + payer_id TEXT NOT NULL DEFAULT '', + claim_token TEXT NOT NULL, + ip TEXT NOT NULL DEFAULT '', + site_created INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + paid_at INTEGER NOT NULL DEFAULT 0, + last_sync INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE INDEX IF NOT EXISTS idx_don_site ON donations(site_id, status, paid_at)`, + `CREATE INDEX IF NOT EXISTS idx_don_boid ON donations(binance_order_id)`, + `CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS xverify (code TEXT PRIMARY KEY, purpose TEXT NOT NULL DEFAULT 'new', secret TEXT NOT NULL DEFAULT '', profile TEXT NOT NULL DEFAULT '', used INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS xprofiles (handle TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', avatar TEXT NOT NULL DEFAULT '', fetched_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS coins (site_id INTEGER NOT NULL, visitor TEXT NOT NULL, day TEXT NOT NULL, ip TEXT NOT NULL DEFAULT '', n INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, PRIMARY KEY(site_id, visitor, day))`, + `CREATE INDEX IF NOT EXISTS idx_coins_day ON coins(site_id, day)`, + `CREATE TABLE IF NOT EXISTS blocked_nicks (site_id INTEGER NOT NULL, nick_key TEXT NOT NULL, PRIMARY KEY(site_id, nick_key))`, +} + +// migrations 给旧库补列(重复执行报 duplicate column,忽略)。 +var migrations = []string{ + `ALTER TABLE sites ADD COLUMN x_handle TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE sites ADD COLUMN x_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE sites ADD COLUMN x_avatar TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE sites ADD COLUMN x_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE sites ADD COLUMN listed INTEGER NOT NULL DEFAULT 1`, + `ALTER TABLE sites ADD COLUMN coins INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE sites ADD COLUMN wishes TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE donations ADD COLUMN x_handle TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE donations ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE donations ADD COLUMN reply TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE xverify ADD COLUMN secret TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE donations ADD COLUMN pay_link TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE donations ADD COLUMN pay_uid TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE donations ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0`, +} + +// postSchema 依赖新列的索引,必须在迁移之后建。 +var postSchema = []string{ + `CREATE UNIQUE INDEX IF NOT EXISTS idx_sites_xhandle ON sites(lower(x_handle)) WHERE x_handle<>''`, +} + +func openStore(path string) (*Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + for _, q := range []string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA synchronous=NORMAL"} { + if _, err := db.Exec(q); err != nil { + db.Close() + return nil, err + } + } + for _, q := range schema { + if _, err := db.Exec(q); err != nil { + db.Close() + return nil, err + } + } + for _, q := range migrations { + if _, err := db.Exec(q); err != nil && !strings.Contains(err.Error(), "duplicate column") { + db.Close() + return nil, err + } + } + for _, q := range postSchema { + if _, err := db.Exec(q); err != nil { + db.Close() + return nil, err + } + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func isUniqueErr(err error) bool { return err != nil && strings.Contains(err.Error(), "UNIQUE") } + +// ---------- meta ---------- + +func (s *Store) GetMeta(key string) (string, error) { + var v string + err := s.db.QueryRow(`SELECT value FROM meta WHERE key=?`, key).Scan(&v) + if err == sql.ErrNoRows { + return "", nil + } + return v, err +} + +func (s *Store) SetMeta(key, value string) error { + _, err := s.db.Exec(`INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value) + return err +} + +// ---------- sites ---------- + +const siteCols = `id, slug, name, slogan, story, avatar, x_url, x_handle, x_name, x_avatar, x_id, listed, coins, wishes, pass_hash, pay_mode, bpg_account_id, binance_uid, receive_link, pay_id, currency, created_from, status, created_at, updated_at` + +type scanner interface{ Scan(dest ...any) error } + +func scanSite(sc scanner) (*Site, error) { + var s Site + var listed int + err := sc.Scan(&s.ID, &s.Slug, &s.Name, &s.Slogan, &s.Story, &s.Avatar, &s.XURL, &s.XHandle, &s.XName, &s.XAvatar, &s.XID, &listed, &s.Coins, &s.Wishes, + &s.PassHash, &s.PayMode, &s.BPGAccountID, &s.BinanceUID, &s.ReceiveLink, &s.PayID, &s.Currency, &s.CreatedFrom, &s.Status, &s.CreatedAt, &s.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + s.Listed = listed != 0 + return &s, nil +} + +func (s *Store) GetSiteBySlug(slug string) (*Site, error) { + return scanSite(s.db.QueryRow(`SELECT `+siteCols+` FROM sites WHERE slug=?`, slug)) +} + +func (s *Store) GetSiteByID(id int64) (*Site, error) { + return scanSite(s.db.QueryRow(`SELECT `+siteCols+` FROM sites WHERE id=?`, id)) +} + +// GetSiteByXID 按 X 数字 ID 找站(X 改了用户名也认得出来)。 +func (s *Store) GetSiteByXID(xid string) (*Site, error) { + if xid == "" { + return nil, nil + } + return scanSite(s.db.QueryRow(`SELECT `+siteCols+` FROM sites WHERE x_id<>'' AND x_id=?`, xid)) +} + +// SyncSiteX 找回口令时按推文里的最新资料同步:用户名、昵称(站名跟随)、数字 ID、头像。 +// 新用户名被别的站占着(理论上不可能)只回错误,不影响改口令。 +func (s *Store) SyncSiteX(id int64, xid, handle, name, avatarFile string) error { + q := `UPDATE sites SET x_id=CASE WHEN x_id='' THEN ? ELSE x_id END, x_handle=?, x_url=?, x_name=?, name=?, updated_at=?` + args := []any{xid, handle, "https://x.com/" + handle, name, name, ms()} + if avatarFile != "" { + q += `, x_avatar=?` + args = append(args, avatarFile) + } + q += ` WHERE id=?` + args = append(args, id) + _, err := s.db.Exec(q, args...) + return err +} + +func (s *Store) GetSiteByXHandle(handle string) (*Site, error) { + return scanSite(s.db.QueryRow(`SELECT `+siteCols+` FROM sites WHERE x_handle<>'' AND lower(x_handle)=lower(?)`, handle)) +} + +// EnsureMainSite 首次启动用 config 建主站;之后每次启动把收款配置按 config 覆盖(资料不动)。 +// 口令未变则沿用旧哈希(会话绑定口令哈希,避免每次重启管理员掉线)。 +func (s *Store) EnsureMainSite(cfg *Config, matches func(hash string) bool, newHash func() string) error { + mode := "manual" + if cfg.BPGURL != "" { + mode = "gateway" + } + now := ms() + main, err := s.GetSiteBySlug("") + if err != nil { + return err + } + if main == nil { + hash := newHash() + _, err = s.db.Exec(`INSERT INTO sites(slug,name,slogan,story,avatar,x_url,x_handle,pass_hash,pay_mode,bpg_account_id,binance_uid,receive_link,pay_id,currency,created_from,status,created_at,updated_at) + VALUES('',?,?,?,?,?,?,?,?,'','',?,?,?,0,'active',?,?)`, + cfg.MainName, cfg.MainSlogan, cfg.MainStory, cfg.MainAvatar, cfg.MainX, xHandle(cfg.MainX), hash, mode, cfg.MainReceiveLink, cfg.MainPayID, cfg.Currency, now, now) + if err != nil { + return err + } + return s.SetMeta("admin_pw_hash", hash) + } + // 口令:ADMIN_PASSWORD 只在「config 里的值变了」时覆盖。meta 里记的是上次应用的 PBKDF2 哈希 + // (不是快哈希,库泄漏也不能离线爆破),这样主站在后台改的口令重启不会被冲掉;想强制重置就改 config.env 再重启。 + passHash := main.PassHash + applied, _ := s.GetMeta("admin_pw_hash") + if applied == "" || !matches(applied) { + if !matches(passHash) { + passHash = newHash() + } + if err := s.SetMeta("admin_pw_hash", passHash); err != nil { + return err + } + } + _, err = s.db.Exec(`UPDATE sites SET pass_hash=?, pay_mode=?, bpg_account_id='', receive_link=?, pay_id=?, currency=?, status='active', updated_at=? WHERE id=?`, + passHash, mode, cfg.MainReceiveLink, cfg.MainPayID, cfg.Currency, now, main.ID) + if err != nil { + return err + } + // 主站的 X 账号跟随 MAIN_X(x_handle 用来拉 X 头像);被某个子站占了就只记日志,不阻塞启动。 + if h := xHandle(cfg.MainX); cfg.MainX != "" && (!strings.EqualFold(h, main.XHandle) || cfg.MainX != main.XURL) { + if _, err := s.db.Exec(`UPDATE sites SET x_url=?, x_handle=?, x_avatar=CASE WHEN lower(x_handle)=lower(?) THEN x_avatar ELSE '' END, updated_at=? WHERE id=?`, + cfg.MainX, h, h, now, main.ID); err != nil { + log.Printf("[warn] 主站 MAIN_X=%s 未能写入(可能已被子站绑定): %v", cfg.MainX, err) + } + } + return nil +} + +// FillSiteAvatarByHandle 给还没有头像的同 X 账号站点补上头像(主站 MAIN_X 首次抓到头像时用)。 +func (s *Store) FillSiteAvatarByHandle(handle, file string) { + if handle == "" || file == "" { + return + } + s.db.Exec(`UPDATE sites SET x_avatar=?, updated_at=? WHERE x_avatar='' AND x_handle<>'' AND lower(x_handle)=lower(?)`, file, ms(), handle) +} + +// CreateSite 建子站(X 验证后调用)。 +func (s *Store) CreateSite(site *Site) (int64, error) { + now := ms() + listed := 0 + if site.Listed { + listed = 1 + } + res, err := s.db.Exec(`INSERT INTO sites(slug,name,slogan,story,avatar,x_url,x_handle,x_name,x_avatar,x_id,listed,pass_hash,pay_mode,bpg_account_id,binance_uid,receive_link,pay_id,currency,created_from,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,'manual','','','','',?,0,'active',?,?)`, + site.Slug, site.Name, site.Slogan, site.Story, site.Avatar, site.XURL, site.XHandle, site.XName, site.XAvatar, site.XID, listed, site.PassHash, site.Currency, now, now) + if err != nil { + if isUniqueErr(err) { + if strings.Contains(err.Error(), "xhandle") { + return 0, ErrXTaken + } + return 0, ErrSlugTaken + } + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) UpdateSiteProfile(id int64, name, slogan, story, avatar, xURL string) error { + _, err := s.db.Exec(`UPDATE sites SET name=?, slogan=?, story=?, avatar=?, x_url=?, updated_at=? WHERE id=?`, name, slogan, story, avatar, xURL, ms(), id) + return err +} + +func (s *Store) UpdateSitePayment(id int64, mode, accountID, uid, link, payID string) error { + _, err := s.db.Exec(`UPDATE sites SET pay_mode=?, bpg_account_id=?, binance_uid=?, receive_link=?, pay_id=?, updated_at=? WHERE id=?`, + mode, accountID, uid, link, payID, ms(), id) + return err +} + +func (s *Store) SetSitePassword(id int64, hash string) error { + _, err := s.db.Exec(`UPDATE sites SET pass_hash=?, updated_at=? WHERE id=?`, hash, ms(), id) + return err +} + +func (s *Store) SetSiteListed(id int64, listed bool) error { + v := 0 + if listed { + v = 1 + } + _, err := s.db.Exec(`UPDATE sites SET listed=?, updated_at=? WHERE id=? AND slug<>''`, v, ms(), id) + return err +} + +func (s *Store) UpdateSiteWishes(id int64, wishes string) error { + _, err := s.db.Exec(`UPDATE sites SET wishes=?, updated_at=? WHERE id=?`, wishes, ms(), id) + return err +} + +func (s *Store) SetSiteStatus(id int64, status string) error { + _, err := s.db.Exec(`UPDATE sites SET status=?, updated_at=? WHERE id=? AND slug<>''`, status, ms(), id) + return err +} + +// SubsiteRank 子站榜。by: amount(总额)| week(本周金额)| coins(钢镚)。管理员视图含未收录/已除名。 +func (s *Store) SubsiteRank(limit int, admin bool, by string) ([]SiteRow, error) { + cond := `d.site_id=s.id AND d.status='paid'` + if by == "week" { + cond += ` AND d.paid_at>=` + strconv.FormatInt(weekStart(), 10) + } + q := `SELECT s.id, s.slug, s.name, s.avatar, s.x_avatar, s.x_handle, s.status, s.pay_mode, s.listed, s.coins, s.created_at, + COALESCE(SUM(d.actual_e8),0) AS total, COUNT(d.id) AS n, COUNT(DISTINCT d.nick_key) AS donors + FROM sites s LEFT JOIN donations d ON ` + cond + if admin { + q += ` WHERE s.slug<>''` // 管理视图只管子站 + } else { + q += ` WHERE s.status='active' AND (s.listed=1 OR s.slug='')` // 榜上带主站(总舵) + } + order := `total DESC, s.coins DESC` + if by == "coins" { + order = `s.coins DESC, total DESC` + } + q += ` GROUP BY s.id ORDER BY ` + order + `, s.id ASC LIMIT ?` + rows, err := s.db.Query(q, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SiteRow + for rows.Next() { + var r SiteRow + var listed int + if err := rows.Scan(&r.ID, &r.Slug, &r.Name, &r.Avatar, &r.XAvatar, &r.XHandle, &r.Status, &r.PayMode, &listed, &r.Coins, &r.CreatedAt, &r.TotalE8, &r.Count, &r.Donors); err != nil { + return nil, err + } + r.Listed = listed != 0 + out = append(out, r) + } + return out, rows.Err() +} + +// SitePositions 所有已收录子站按综合 EXP(钱×moneyExp + 钢镚×coinExp)的名次,用来授职位(帮主/副帮主/长老…)。 +func (s *Store) SitePositions(moneyExp, coinExp int64) (map[int64]int, error) { + rows, err := s.db.Query(`SELECT s.id FROM sites s LEFT JOIN donations d ON d.site_id=s.id AND d.status='paid' + WHERE s.slug<>'' AND s.status='active' AND s.listed=1 + GROUP BY s.id HAVING COALESCE(SUM(d.actual_e8),0)*?/100000000+s.coins*?>0 + ORDER BY COALESCE(SUM(d.actual_e8),0)*?/100000000+s.coins*? DESC, s.id ASC`, moneyExp, coinExp, moneyExp, coinExp) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]int{} + pos := 0 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + pos++ + out[id] = pos + } + return out, rows.Err() +} + +// ---------- 钢镚 ---------- + +// AddCoin 丢一个钢镚:每人(visitor cookie)每站每天 perDay 个,每 IP 每站每天 ipCap 个。 +func (s *Store) AddCoin(siteID int64, visitor, ip, day string, perDay, ipCap int64) (bool, string, error) { + tx, err := s.db.Begin() + if err != nil { + return false, "", err + } + defer tx.Rollback() + var ipN int64 + if err := tx.QueryRow(`SELECT COALESCE(SUM(n),0) FROM coins WHERE site_id=? AND day=? AND ip=?`, siteID, day, ip).Scan(&ipN); err != nil { + return false, "", err + } + if ipN >= ipCap { + return false, "这个网络今天丢得够多了", nil + } + var n int64 + err = tx.QueryRow(`SELECT n FROM coins WHERE site_id=? AND visitor=? AND day=?`, siteID, visitor, day).Scan(&n) + if err != nil && err != sql.ErrNoRows { + return false, "", err + } + if n >= perDay { + return false, "今天已经丢过了,明天再来", nil + } + if err == sql.ErrNoRows { + _, err = tx.Exec(`INSERT INTO coins(site_id,visitor,day,ip,n,created_at) VALUES(?,?,?,?,1,?)`, siteID, visitor, day, ip, ms()) + } else { + _, err = tx.Exec(`UPDATE coins SET n=n+1 WHERE site_id=? AND visitor=? AND day=?`, siteID, visitor, day) + } + if err != nil { + return false, "", err + } + if _, err := tx.Exec(`UPDATE sites SET coins=coins+1 WHERE id=?`, siteID); err != nil { + return false, "", err + } + return true, "", tx.Commit() +} + +func (s *Store) CoinStats(siteID int64) (total, todayN int64, err error) { + if err = s.db.QueryRow(`SELECT coins FROM sites WHERE id=?`, siteID).Scan(&total); err != nil { + return + } + err = s.db.QueryRow(`SELECT COALESCE(SUM(n),0) FROM coins WHERE site_id=? AND day=?`, siteID, today()).Scan(&todayN) + return +} + +// VisitorCoinsToday 某访客今天在该站丢了几个。 +func (s *Store) VisitorCoinsToday(siteID int64, visitor string) (int64, error) { + var n int64 + err := s.db.QueryRow(`SELECT COALESCE(SUM(n),0) FROM coins WHERE site_id=? AND visitor=? AND day=?`, siteID, visitor, today()).Scan(&n) + return n, err +} + +// ---------- X 验证 / 资料 ---------- + +func (s *Store) CreateVerify(code, purpose, secret string) error { + _, err := s.db.Exec(`INSERT INTO xverify(code,purpose,secret,created_at) VALUES(?,?,?,?)`, code, purpose, secret, ms()) + return err +} + +// PruneVerify 删除过期验证记录,返回其中下载过但没被任何站点使用的头像文件名(供删除)。 +func (s *Store) PruneVerify() ([]string, error) { + rows, err := s.db.Query(`SELECT profile FROM xverify WHERE created_at''`, ms()-24*3600*1000) + if err != nil { + return nil, err + } + var profiles []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + rows.Close() + return nil, err + } + profiles = append(profiles, p) + } + rows.Close() + var files []string + for _, p := range profiles { + var v struct { + AvatarFile string `json:"avatar_file"` + } + if json.Unmarshal([]byte(p), &v) != nil || v.AvatarFile == "" { + continue + } + var n int + s.db.QueryRow(`SELECT COUNT(*) FROM sites WHERE x_avatar=?`, v.AvatarFile).Scan(&n) + if n == 0 { + files = append(files, v.AvatarFile) + } + } + _, err = s.db.Exec(`DELETE FROM xverify WHERE created_at 0, nil +} + +func (s *Store) GetXProfile(handle string) (*XProfile, error) { + var p XProfile + err := s.db.QueryRow(`SELECT handle,name,avatar,fetched_at FROM xprofiles WHERE handle=?`, handle).Scan(&p.Handle, &p.Name, &p.Avatar, &p.FetchedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (s *Store) UpsertXProfile(handle, name, avatar string, fetchedAt int64) error { + _, err := s.db.Exec(`INSERT INTO xprofiles(handle,name,avatar,fetched_at) VALUES(?,?,?,?) + ON CONFLICT(handle) DO UPDATE SET name=CASE WHEN excluded.name<>'' THEN excluded.name ELSE name END, avatar=excluded.avatar, fetched_at=excluded.fetched_at`, handle, name, avatar, fetchedAt) + return err +} + +// AvatarInUse 头像文件是否被某个站点用作站长头像。 +func (s *Store) AvatarInUse(file string) bool { + var n int + s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM sites WHERE x_avatar=?)+(SELECT COUNT(*) FROM xprofiles WHERE avatar=?)`, file, file).Scan(&n) + return n > 0 +} + +// SetSiteAvatarByHandle 同一 X 的站长头像随重抓更新。 +func (s *Store) SetSiteAvatarByHandle(handle, file string) error { + _, err := s.db.Exec(`UPDATE sites SET x_avatar=?, updated_at=? WHERE x_handle<>'' AND lower(x_handle)=lower(?)`, file, ms(), handle) + return err +} + +// ---------- 屏蔽此人 ---------- + +func (s *Store) BlockNick(siteID int64, nickKey string, blocked bool) error { + v := 0 + if blocked { + v = 1 + } + if _, err := s.db.Exec(`UPDATE donations SET blocked=? WHERE site_id=? AND nick_key=?`, v, siteID, nickKey); err != nil { + return err + } + if blocked { + _, err := s.db.Exec(`INSERT OR IGNORE INTO blocked_nicks(site_id,nick_key) VALUES(?,?)`, siteID, nickKey) + return err + } + _, err := s.db.Exec(`DELETE FROM blocked_nicks WHERE site_id=? AND nick_key=?`, siteID, nickKey) + return err +} + +func (s *Store) IsNickBlocked(siteID int64, nickKey string) (bool, error) { + var x int + err := s.db.QueryRow(`SELECT 1 FROM blocked_nicks WHERE site_id=? AND nick_key=?`, siteID, nickKey).Scan(&x) + if err == sql.ErrNoRows { + return false, nil + } + return err == nil, err +} + +func (s *Store) SetReply(id int64, reply string) error { + _, err := s.db.Exec(`UPDATE donations SET reply=? WHERE id=?`, reply, id) + return err +} + +// ---------- 徽章 ---------- + +// SiteBadges 本站施主徽章:🏁 首位施主、💎 单笔最大、🔥 常客(≥3 天投过)。 +func (s *Store) SiteBadges(siteID int64) (map[string]string, error) { + out := map[string]string{} + var k string + if err := s.db.QueryRow(`SELECT nick_key FROM donations WHERE site_id=? AND status='paid' AND blocked=0 ORDER BY paid_at ASC, id ASC LIMIT 1`, siteID).Scan(&k); err == nil { + out[k] += "🏁" + } + if err := s.db.QueryRow(`SELECT nick_key FROM donations WHERE site_id=? AND status='paid' AND blocked=0 ORDER BY actual_e8 DESC, id ASC LIMIT 1`, siteID).Scan(&k); err == nil { + out[k] += "💎" + } + rows, err := s.db.Query(`SELECT nick_key FROM donations WHERE site_id=? AND status='paid' AND blocked=0 GROUP BY nick_key HAVING COUNT(DISTINCT date(paid_at/1000,'unixepoch','localtime'))>=3`, siteID) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + if err := rows.Scan(&k); err != nil { + return out, err + } + out[k] += "🔥" + } + return out, rows.Err() +} + +// ---------- donations ---------- + +const donCols = `id, code, site_id, nickname, nick_key, x_handle, message, msg_blocked, blocked, reply, amount, amount_e8, actual_amount, actual_e8, currency, status, matched_by, gw_order_id, pay_url, pay_amount, pay_link, pay_uid, expires_at, note_code, binance_order_id, payer_id, claim_token, ip, site_created, created_at, paid_at, last_sync` + +var donColsD = "d." + strings.ReplaceAll(donCols, ", ", ", d.") + +func scanDonation(sc scanner, extra ...any) (*Donation, error) { + var d Donation + var blocked, userBlocked int + dest := []any{&d.ID, &d.Code, &d.SiteID, &d.Nickname, &d.NickKey, &d.XHandle, &d.Message, &blocked, &userBlocked, &d.Reply, &d.Amount, &d.AmountE8, &d.ActualAmount, &d.ActualE8, + &d.Currency, &d.Status, &d.MatchedBy, &d.GwOrderID, &d.PayURL, &d.PayAmount, &d.PayLink, &d.PayUID, &d.ExpiresAt, &d.NoteCode, &d.BinanceOrderID, &d.PayerID, &d.ClaimToken, + &d.IP, &d.SiteCreated, &d.CreatedAt, &d.PaidAt, &d.LastSync} + dest = append(dest, extra...) + err := sc.Scan(dest...) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + d.MsgBlocked = blocked != 0 + d.Blocked = userBlocked != 0 + return &d, nil +} + +func (s *Store) queryDonations(q string, args ...any) ([]*Donation, error) { + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Donation + for rows.Next() { + d, err := scanDonation(rows) + if err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +func (s *Store) CreateDonation(d *Donation) error { + blocked := 0 + if d.Blocked { + blocked = 1 + } + res, err := s.db.Exec(`INSERT INTO donations(code,site_id,nickname,nick_key,x_handle,message,blocked,amount,amount_e8,currency,status,note_code,claim_token,ip,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,'pending',?,?,?,?)`, + d.Code, d.SiteID, d.Nickname, d.NickKey, d.XHandle, d.Message, blocked, d.Amount, d.AmountE8, d.Currency, d.NoteCode, d.ClaimToken, d.IP, d.CreatedAt) + if err != nil { + return err + } + d.ID, _ = res.LastInsertId() + d.Status = "pending" + return nil +} + +func (s *Store) GetDonationByCode(code string) (*Donation, error) { + return scanDonation(s.db.QueryRow(`SELECT `+donCols+` FROM donations WHERE code=?`, code)) +} + +func (s *Store) GetDonationByID(id int64) (*Donation, error) { + return scanDonation(s.db.QueryRow(`SELECT `+donCols+` FROM donations WHERE id=?`, id)) +} + +func (s *Store) FindPaidByBinanceOrder(boid string) (*Donation, error) { + return scanDonation(s.db.QueryRow(`SELECT `+donCols+` FROM donations WHERE binance_order_id=? AND status='paid' ORDER BY id DESC LIMIT 1`, boid)) +} + +func (s *Store) SetDonationGateway(id int64, gwOrderID, payURL, payAmount, noteCode, payLink, payUID string, expiresAt int64) error { + _, err := s.db.Exec(`UPDATE donations SET gw_order_id=?, pay_url=?, pay_amount=?, note_code=?, pay_link=?, pay_uid=?, expires_at=? WHERE id=?`, + gwOrderID, payURL, payAmount, noteCode, payLink, payUID, expiresAt, id) + return err +} + +// SetStatusIf 仅当当前状态在 from 中时改为 to。 +func (s *Store) SetStatusIf(id int64, from []string, to string) (bool, error) { + ph := strings.TrimSuffix(strings.Repeat("?,", len(from)), ",") + args := []any{to, id} + for _, f := range from { + args = append(args, f) + } + res, err := s.db.Exec(`UPDATE donations SET status=? WHERE id=? AND status IN (`+ph+`)`, args...) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +func (s *Store) MarkClaimed(id int64, boid string) (bool, error) { + res, err := s.db.Exec(`UPDATE donations SET status='claimed', binance_order_id=? WHERE id=? AND status='pending'`, boid, id) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +func (s *Store) MarkPaid(id int64, actual string, actualE8 int64, matchedBy, boid, payerID string, paidAt int64) (bool, error) { + res, err := s.db.Exec(`UPDATE donations SET status='paid', actual_amount=?, actual_e8=?, matched_by=?, + binance_order_id=CASE WHEN ?<>'' THEN ? ELSE binance_order_id END, payer_id=?, paid_at=? + WHERE id=? AND status IN ('pending','claimed','expired')`, + actual, actualE8, matchedBy, boid, boid, payerID, paidAt, id) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// TouchSync 距上次向网关查单超过 gap 毫秒才允许再查(防止轮询打爆网关)。 +func (s *Store) TouchSync(id int64, now, gap int64) (bool, error) { + res, err := s.db.Exec(`UPDATE donations SET last_sync=? WHERE id=? AND last_sync<=?`, now, id, now-gap) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +func (s *Store) SetMsgBlocked(id int64, blocked bool) error { + v := 0 + if blocked { + v = 1 + } + _, err := s.db.Exec(`UPDATE donations SET msg_blocked=? WHERE id=?`, v, id) + return err +} + +func (s *Store) SiteStats(siteID int64) (Stats, error) { + var st Stats + err := s.db.QueryRow(`SELECT COALESCE(SUM(actual_e8),0), COUNT(*), COUNT(DISTINCT CASE WHEN blocked=0 THEN nick_key END), + COALESCE(SUM(CASE WHEN paid_at>=? THEN actual_e8 ELSE 0 END),0) + FROM donations WHERE site_id=? AND status='paid'`, dayStart(), siteID). + Scan(&st.TotalE8, &st.Count, &st.Donors, &st.TodayE8) + return st, err +} + +func (s *Store) SiteDonorRank(siteID int64, limit int, since int64) ([]DonorRow, error) { + rows, err := s.db.Query(`SELECT d.nick_key, + (SELECT nickname FROM donations x WHERE x.site_id=d.site_id AND x.nick_key=d.nick_key AND x.status='paid' ORDER BY x.id DESC LIMIT 1), + SUM(d.actual_e8) AS total, COUNT(*) AS n, MAX(d.paid_at) AS last_at + FROM donations d WHERE d.site_id=? AND d.status='paid' AND d.paid_at>=? AND d.blocked=0 + GROUP BY d.nick_key ORDER BY total DESC, last_at ASC LIMIT ?`, siteID, since, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []DonorRow + for rows.Next() { + var r DonorRow + if err := rows.Scan(&r.NickKey, &r.Nickname, &r.TotalE8, &r.Count, &r.LastAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) GlobalDonorRank(limit int, since int64) ([]DonorRow, error) { + rows, err := s.db.Query(`SELECT d.nick_key, + (SELECT nickname FROM donations x WHERE x.nick_key=d.nick_key AND x.status='paid' ORDER BY x.id DESC LIMIT 1), + SUM(d.actual_e8) AS total, COUNT(*) AS n, COUNT(DISTINCT d.site_id) AS sites, MAX(d.paid_at) AS last_at + FROM donations d JOIN sites s ON s.id=d.site_id AND s.status='active' AND s.listed=1 + WHERE d.status='paid' AND d.paid_at>=? AND d.blocked=0 + GROUP BY d.nick_key ORDER BY total DESC, last_at ASC LIMIT ?`, since, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []DonorRow + for rows.Next() { + var r DonorRow + if err := rows.Scan(&r.NickKey, &r.Nickname, &r.TotalE8, &r.Count, &r.Sites, &r.LastAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// DonorPosition 某施主在本站施主榜的名次(从 1 起)与其总额。 +func (s *Store) DonorPosition(siteID int64, nickKey string) (int, int64, error) { + var total int64 + if err := s.db.QueryRow(`SELECT COALESCE(SUM(actual_e8),0) FROM donations WHERE site_id=? AND status='paid' AND nick_key=? AND blocked=0`, siteID, nickKey).Scan(&total); err != nil { + return 0, 0, err + } + var above int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM (SELECT SUM(actual_e8) t FROM donations WHERE site_id=? AND status='paid' AND blocked=0 GROUP BY nick_key) WHERE t>?`, siteID, total).Scan(&above); err != nil { + return 0, 0, err + } + return above + 1, total, nil +} + +func (s *Store) SiteFeed(siteID int64, limit int) ([]*Donation, error) { + return s.queryDonations(`SELECT `+donCols+` FROM donations WHERE site_id=? AND status='paid' ORDER BY paid_at DESC, id DESC LIMIT ?`, siteID, limit) +} + +func (s *Store) SiteDonations(siteID int64, limit int) ([]*Donation, error) { + return s.queryDonations(`SELECT `+donCols+` FROM donations WHERE site_id=? ORDER BY id DESC LIMIT ?`, siteID, limit) +} + +// SiteDonationsPage 后台施舍记录分页。 +func (s *Store) SiteDonationsPage(siteID int64, offset, limit int) ([]*Donation, error) { + return s.queryDonations(`SELECT `+donCols+` FROM donations WHERE site_id=? ORDER BY id DESC LIMIT ? OFFSET ?`, siteID, limit, offset) +} + +func (s *Store) CountSiteDonations(siteID int64) int { + var n int + s.db.QueryRow(`SELECT COUNT(*) FROM donations WHERE site_id=?`, siteID).Scan(&n) + return n +} + +func (s *Store) CountMessages() int { + var n int + s.db.QueryRow(`SELECT COUNT(*) FROM donations WHERE status='paid' AND message<>''`).Scan(&n) + return n +} + +func (s *Store) SiteClaims(siteID int64) ([]*Donation, error) { + return s.queryDonations(`SELECT `+donCols+` FROM donations WHERE site_id=? AND status='claimed' ORDER BY id ASC LIMIT 200`, siteID) +} + +func (s *Store) RecentMessages(offset, limit int) ([]AdminMessage, error) { + rows, err := s.db.Query(`SELECT `+donColsD+`, s.name, s.slug FROM donations d JOIN sites s ON s.id=d.site_id + WHERE d.status='paid' AND d.message<>'' ORDER BY d.paid_at DESC, d.id DESC LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminMessage + for rows.Next() { + var m AdminMessage + d, err := scanDonation(rows, &m.SiteName, &m.SiteSlug) + if err != nil { + return nil, err + } + m.Donation = *d + out = append(out, m) + } + return out, rows.Err() +} + +// XLabel X 展示名:优先验证过的 handle,否则从链接里取。 +func (s *Site) XLabel() string { + if s.XHandle != "" { + return s.XHandle + } + return xHandle(s.XURL) +} + +func (s *Site) AvatarURL() string { + if s.XAvatar == "" { + return "" + } + return "/a/" + s.XAvatar +} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..e80b3a0 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,12 @@ +{{define "title"}}{{.Title}} · {{.SiteTitle}}{{end}} +{{define "content"}} +
+
+
{{template "sprite"}}
+
GAME OVER?
+

{{.Title}}

+ {{if .Msg}}

{{.Msg}}

{{end}} + 回到街头 +
+
+{{end}} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..83046ea --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,332 @@ +{{define "layout"}} + + + + + +{{template "title" .}} + + + + +{{if .OGImage}}{{end}} + + +{{if .NoIndex}}{{end}} + + + + +{{if .HasBGM}}{{end}} +
+ + +{{template "content" .}} + +
+ +{{block "scripts" .}}{{end}} + +{{end}} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..a919ba4 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,18 @@ +{{define "title"}}站长登录 · {{.SiteTitle}}{{end}} +{{define "content"}} +
+
+

🔑 站长登录

+

用「X 用户名 + 开站时设的口令」登录后台。主站站长用户名留空。

+
+ + + + + {{if .Err}}
{{.Err}}
{{end}} + +
+
忘了口令?用 X 发推找回。还没有要饭站?用 X 开一个。
+
+
+{{end}} diff --git a/templates/manage.html b/templates/manage.html new file mode 100644 index 0000000..fd9679a --- /dev/null +++ b/templates/manage.html @@ -0,0 +1,184 @@ +{{define "title"}}后台 · {{.Site.Name}} · {{.SiteTitle}}{{end}} +{{define "content"}} +
+{{if .Welcome}}
🎉 开张大吉!你的要饭站 {{.BaseURL}}{{.Site.Path}} 已上线{{if not .Site.Listed}}(待主站站长收录后进榜){{end}}。下一步:在下面「收款方式」里绑定币安 Key 或填收款码,不然施主没法给你钱。以后从 站长登录 进来(X 用户名 {{.Site.Slug}} + 你的口令),忘了口令可以再发推找回。
{{end}} +{{if .Flash}}
{{.Flash}}
{{end}} +{{if .Err}}
{{.Err}}
{{end}} + +
+
+ {{if .Site.AvatarURL}}{{else}}
{{.Site.Avatar}}
{{end}} +

{{.Site.Name}} 后台

+
{{.BaseURL}}{{.Site.Path}} · 碗里 {{fmtAmt .Stats.TotalE8}} {{.Site.Currency}} · {{.Stats.Donors}} 位施主 · {{.Stats.Count}} 次 · 钢镚 {{.Coins}}{{if not .Site.Listed}} · 待收录{{end}}
+
{{if not .Site.IsMain}}{{end}}
+
+ +
+ +{{if .Claims}} +
+

⏳ 待确认的施舍 {{len .Claims}}

+
打开币安 App 核对到账(看金额和备注码),确认后上榜。
+
    + {{range .Claims}}
  • +
    {{initial .Nickname}}
    +
    {{.Nickname}}
    备注码 {{.NoteCode}}{{if .BinanceOrderID}} · 币安订单 {{.BinanceOrderID}}{{end}} · {{ago .CreatedAt}}
    +
    {{.Amount}} {{.Currency}}
    + {{if .Message}}
    {{.Message}}
    {{end}} +
    +
    +
    +
    +
  • {{end}} +
+
+{{end}} + +
+

✏️ 资料

+
+
+
{{$lock := and .Site.XHandle (not .Site.IsMain)}}
+ {{if not .Site.AvatarURL}}
{{end}} +
+ + {{if not .Site.XHandle}}{{end}} + +
+
+ +
+

💰 收款方式

+ {{if .Site.IsMain}} +
主站的收款方式在服务器 config.env 里配置。当前:{{if eq .Site.PayMode "gateway"}}BinancePayTool 网关自动确认(默认账号){{else}}直接转账{{if .Site.PayID}},Pay ID {{.Site.PayID}}{{end}}{{end}}。
+ {{if eq .Site.PayMode "gateway"}}
{{end}} + {{else}} + {{if eq .Site.PayMode "gateway"}} +
✅ 已绑定币安账号(UID {{.Site.BinanceUID}}),施主付款后自动确认上榜。{{if .Site.ReceiveLink}}收银页会展示你的收款码 / 一键唤起。{{else}}建议补一个收款链接,让施主能扫码 / 一键唤起。{{end}}
+
+
+
+
+
换一个 Key / 更新收款链接 + {{template "bindform" .}} +
+ {{else}} + {{if .HasGateway}} +
{{if .Site.PaymentReady}}当前是直接转账模式:施主转完自报,你来确认。{{else}}⚠️ 还没配置收款方式,施主现在没法给你钱。{{end}}推荐绑定币安只读 API Key:到账自动确认、自动上榜,不用你盯着。
+

方式一:绑定币安 API Key(自动确认)

+ {{template "bindform" .}} +

方式二:直接转账(手动确认)

+ {{else}} +
{{if .Site.PaymentReady}}当前是直接转账模式:施主转完自报,你来确认。{{else}}⚠️ 还没配置收款方式,施主现在没法给你钱。{{end}}
+ {{end}} +
+ + + + + +
+ {{end}} + {{end}} +
+ + +
+

📜 施舍记录 共 {{.DonPager.Total}} 条 · 广告可屏蔽留言或屏蔽此人(记录保留)· 可回复

+ {{if .Donations}}
    + {{range .Donations}}
  • +
    {{if .Blocked}}✕{{else}}{{initial .Nickname}}{{end}}
    +
    {{.Nickname}}{{if .XHandle}}@{{.XHandle}}{{end}}{{statusText .Status}}{{if .Blocked}}已屏蔽此人{{end}}
    {{ago .CreatedAt}}
    +
    {{if eq .Status "paid"}}{{fmtAmt .ActualE8}}{{else}}{{.Amount}}{{end}}
    + {{if .Message}}
    {{if .MsgBlocked}}*** (已屏蔽:{{.Message}}){{else}}{{.Message}}{{end}}
    {{end}} + {{if .Reply}}
    🥣 站长:{{.Reply}}
    {{end}} +
    + {{if .Message}}
    {{end}} +
    + {{if eq .Status "paid"}}
    {{end}} +
    +
  • {{end}} +
{{template "pager" .DonPager}}{{else}}
📜
还没有施舍记录
{{end}} +
+ +{{if not .Site.IsMain}} + +{{end}} + +{{if .Site.IsMain}} +
+

🏚 所有要饭站 {{.SitePager.Total}} 个 · 违规可除名 · 可帮站长重置口令

+ {{if .Sites}}
    + {{range .Sites}}
  • + {{if .AvatarURL}}{{else}}
    {{.Avatar}}
    {{end}} +
    {{.Name}}{{if .XHandle}}@{{.XHandle}}{{end}}/{{.Slug}}{{if ne .Status "active"}}已除名{{end}}{{if not .Listed}}未收录{{end}}
    {{if .Title}}{{.Title}} · {{end}}{{if eq .PayMode "gateway"}}已绑 Key 自动确认{{else}}直接转账{{end}} · {{.Donors}} 位施主 · 钢镚 {{.Coins}} · {{ago .CreatedAt}}开张
    +
    {{fmtAmt .TotalE8}}
    +
    +
    +
    +
    +
    +
  • {{end}} +
{{template "pager" .SitePager}}{{else}}
🏚
还没有子站
{{end}} +
+
+

🧹 全站最新留言 共 {{.MsgPager.Total}} 条 · 跨站点巡查广告

+ {{if .Messages}}
    + {{range .Messages}}
  • +
    {{if .Blocked}}✕{{else}}{{initial .Nickname}}{{end}}
    +
    {{.Nickname}}{{if .XHandle}}@{{.XHandle}}{{end}}→ {{.SiteName}}{{if .Blocked}}已屏蔽此人{{end}}
    {{ago .PaidAt}}
    +
    {{fmtAmt .ActualE8}}
    +
    {{if .MsgBlocked}}*** (已屏蔽:{{.Message}}){{else}}{{.Message}}{{end}}
    +
    +
    +
  • {{end}} +
{{template "pager" .MsgPager}}{{else}}
🧹
暂无留言
{{end}} +
+{{end}} +
+{{end}} +{{define "pager"}}{{if gt .Pages 1}}{{end}}{{end}} +{{define "bindform"}} +
+
币安 App → 账户 → API 管理 → 创建 API:只勾「允许读取」,其它权限都不要,不用设 IP 白名单(只读 Key 拿不走钱)。Key 只用来读取你的收款流水,服务端加密存储,永不展示。
+
+
+
+
+
+
+
+
+ +
+{{end}} +{{define "scripts"}}{{end}} diff --git a/templates/new.html b/templates/new.html new file mode 100644 index 0000000..239b0df --- /dev/null +++ b/templates/new.html @@ -0,0 +1,91 @@ +{{define "title"}}{{if eq .Purpose "reset"}}用 X 找回口令{{else}}开通要饭站{{end}} · {{.SiteTitle}}{{end}} +{{define "content"}} +
+{{if eq .Step 1}} +
+

{{if eq .Purpose "reset"}}🔑 用 X 找回口令{{else}}🏚 开通我的要饭站{{end}}

+

{{if eq .Purpose "reset"}}发一条带验证码的推文证明你是站长本人,就能重设口令。{{else}}不用先施舍,用你的 X 账号发一条推就能开:站名用你的 X 昵称、头像用 X 头像,一个 X 账号一个站。发推顺便帮你把碗摆到街上。{{end}}

+ {{if .Err}}
{{.Err}}
{{end}} + {{if .InApp}}
📱 你现在在 App 内置浏览器里,一键发推可能拉不起来、发完回来也可能丢状态。建议点右上角「⋯」选「在浏览器中打开」(或复制链接到 Safari / Chrome 打开)再操作。 +
{{end}} +
+
+

发一条推文,带上验证码 {{.Code}}

+ + +
+ 🐦 一键去 X 发推 + +
+ +
验证码 {{.Code}} 大小写不限,24 小时内有效。文案里带着你的碗的链接,发出去顺便吆喝。
+
+
+

把推文链接贴回来

+
+ + + + +
+
推文发出后,点推文右上角「分享 → 复制链接」即可。
+
+
+
{{if eq .Purpose "reset"}}还没开过站?去开一个{{else}}已经开过站?站长登录{{end}}
+
+{{else if eq .Purpose "reset"}} +
+

🔑 设置新口令

+
{{if .AvatarURL}}{{else}}
🥣
{{end}}
{{.Site.Name}}
@{{.Prof.Handle}} · {{.BaseURL}}{{.Site.Path}}
+ {{if .Err}}
{{.Err}}
{{end}} +
+ +
+
+
+
+ +
+
+{{else}} +
+

🏚 开通我的要饭站

+
✅ X 验证通过
+
{{if .AvatarURL}}{{else}}
🥣
{{end}}
{{.Prof.Name}}
@{{.Prof.Handle}} · 站名和头像来自 X(头像会定期同步)
+
+ + +
{{.BaseURL}}/{{.F.Slug}}
+ + +
登录口令
+
以后用「X 用户名 + 口令」登录后台;忘了可以再发推找回。
+
+
+
+
+ {{if .Err}}
{{.Err}}
{{end}} + +
开通后在后台绑定币安 Key 或填收款码就能收钱。站点内容自负,广告站会被丐帮除名。
+
+
+{{end}} +
+{{end}} +{{define "scripts"}}{{end}} diff --git a/templates/pay.html b/templates/pay.html new file mode 100644 index 0000000..a3a8a56 --- /dev/null +++ b/templates/pay.html @@ -0,0 +1,10 @@ +{{define "title"}}给 {{.Site.Name}} 施舍 {{.D.Amount}} {{.D.Currency}} · {{.SiteTitle}}{{end}} +{{define "content"}} +
+
+ {{if .Err}}
{{.Err}}
{{end}} + {{template "paypanel" .Panel}} + +
+
+{{end}} diff --git a/templates/paypanel.html b/templates/paypanel.html new file mode 100644 index 0000000..88f99a5 --- /dev/null +++ b/templates/paypanel.html @@ -0,0 +1,47 @@ +{{define "paypanel"}}
+
+
给 {{.Site.Name}} 投币
+
{{.Amount}}{{.D.Currency}}
+ {{if eq .Mode "gateway"}}
金额要一分不差,网关靠这个唯一尾数自动认账,转完几秒内到账
{{else}}
用币安 Pay 直接转给站长,转完点下面「我已施舍」
{{end}} +
+ {{if .QR}} + {{if .IsMobile}} + 打开币安 App 转账 +
唤起后手动输入金额 {{.Amount}}{{if .NoteCode}},备注填 {{.NoteCode}}{{end}}
+
用另一台手机扫码收款二维码
+ {{else}} + 收款二维码 +
币安 App → 首页「扫一扫」→ 输入金额 {{.Amount}}{{if .NoteCode}},备注填 {{.NoteCode}}{{end}}
+ {{end}} +
或手动转账
+ {{else}}
转账信息
{{end}} +
+ {{if .PayID}}收款方{{.PayID}}{{end}} + 金额{{.Amount}} {{.D.Currency}} + {{if .NoteCode}}备注{{.NoteCode}}{{end}} +
+
+ {{if eq .D.Status "claimed"}} +
已提交,等站长确认后上榜。本页会自动更新。
+ {{else if eq .Mode "gateway"}} +
等待到账…转完自动确认,本页会自动更新
+ {{if .Owned}}
付了但一直没确认? +
+ + + +
+
{{end}} + {{else if .Owned}} +
+ + + +
+
+ {{else}} +
「我已施舍」要在发起施舍的那台设备上点。
+ {{end}} +
+ {{if .Fallback}}{{end}} +
{{end}} diff --git a/templates/rank.html b/templates/rank.html new file mode 100644 index 0000000..750e9a0 --- /dev/null +++ b/templates/rank.html @@ -0,0 +1,42 @@ +{{define "title"}}排行榜 · {{.SiteTitle}}{{end}} +{{define "content"}} +
+
+

🏆 全站施主榜 HIGH SCORE · 所有要饭站加起来

+
+
{{template "gdonorlist" .Donors}}
+ +
+
+
+

🥣 乞丐榜 要到最多的要饭站

+
+
{{template "gsitelist" (dict "Rows" .Sites "By" "amount" "Me" .MeID)}}
+ +
+
+

🪙 人气榜 收到钢镚最多(免费投)

+ {{template "gsitelist" (dict "Rows" .Popular "By" "coins" "Me" .MeID)}} +
+
+
+{{end}} +{{define "gdonorlist"}}{{if .}}
    + {{range $i, $r := .}}
  • +
    {{rankBadge $i}}
    + {{$h := handleOf $r.NickKey}}{{$f := xav $h}}{{if $f}}{{else}}
    {{initial $r.Nickname}}
    {{end}} +
    {{$r.Nickname}}{{if $h}}@{{$h}}{{end}}
    {{donorTier $r.TotalE8}} · {{$r.Count}} 次 · 施舍过 {{$r.Sites}} 个乞丐
    +
    {{fmtAmt $r.TotalE8}}
    +
  • {{end}} +
{{else}}
👑
榜上无人
{{end}}{{end}} +{{define "gsitelist"}}{{if .Rows}}
    + {{range $i, $s := .Rows}}
  • +
    {{rankBadge $i}}
    + {{if $s.AvatarURL}}{{else}}
    {{$s.Avatar}}
    {{end}} +
    {{$s.Name}}{{if eq $s.ID $.Me}}就是我{{end}}
    {{if $s.Title}}{{$s.Title}} · {{end}}{{(level $s.Exp).Title}} · {{$s.Donors}} 位施主 · 钢镚 {{$s.Coins}}
    +
    {{if eq $.By "coins"}}🪙 {{$s.Coins}}{{else}}{{fmtAmt $s.TotalE8}}{{end}}
    +
  • {{end}} +
{{else}}
🏚
还没有人上榜
{{end}}{{end}} +{{define "scripts"}}{{end}} diff --git a/templates/site.html b/templates/site.html new file mode 100644 index 0000000..1d45696 --- /dev/null +++ b/templates/site.html @@ -0,0 +1,182 @@ +{{define "title"}}{{.Site.Name}} 在要饭 · {{.SiteTitle}}{{end}} +{{define "content"}} +{{$lv := level .Exp}} +
+ + +
+
+
+ + + + +
+
+ {{if .Feed}}
    + {{range .Feed}}
  • + {{template "avatar" .}} +
    {{if .Blocked}}已屏蔽的施主{{else}}{{.Nickname}}{{end}}{{if and .XHandle (not .Blocked)}}@{{.XHandle}}{{end}}
    {{ago .PaidAt}} 投币
    +
    +{{fmtAmt .ActualE8}}
    + {{if .Blocked}}{{else if .MsgBlocked}}
    ***
    {{else if .Message}}
    {{.Message}}
    {{end}} + {{if and .Reply (not .Blocked)}}
    🥣 站长:{{.Reply}}
    {{end}} +
  • {{end}} +
{{else}}
📜
功德簿空空如也,第一枚硬币会刻在这里
{{end}} +
+
+
+
HIGH SCORE · 给 {{.Site.Name}} 投币最多的人 · 🏁 首位施主 💎 单笔最大 🔥 常客
+
{{template "donorlist" (dict "Rows" .Rank "Badges" .Badges)}}
+ +
+
+
+
丐帮排名 · 要到最多的要饭站
+
{{template "sitelist" (dict "Rows" .Subsites "Me" .MeID "Here" .Site.ID "By" "amount")}}
+ +
+ +
+ {{if and .SubsitesEnabled (not .Me)}} +
+

🏚 想自己要饭?

+

用 X 账号发一条推就能开自己的要饭站:独立地址、自己的碗、功德簿和施主榜,钱直接进你的币安账户。

+ 用 X 开通我的要饭站 → +
+ {{end}} +
+
+{{if .Ready}}{{end}} + + +{{end}} +{{define "avatar"}}{{$f := xav .XHandle}}{{if and $f (not .Blocked)}}{{else}}
{{if .Blocked}}✕{{else}}{{initial .Nickname}}{{end}}
{{end}}{{end}} +{{define "donorlist"}}{{if .Rows}}
    + {{range $i, $r := .Rows}}
  • +
    {{rankBadge $i}}
    + {{$h := handleOf $r.NickKey}}{{$f := xav $h}}{{if $f}}{{else}}
    {{initial $r.Nickname}}
    {{end}} +
    {{$r.Nickname}}{{if $h}}@{{$h}}{{end}}{{with index $.Badges $r.NickKey}}{{.}}{{end}}
    {{donorTier $r.TotalE8}} · {{$r.Count}} 次
    +
    {{fmtAmt $r.TotalE8}}
    +
  • {{end}} +
{{else}}
👑
榜上无人,第一个投币的就是首席施主
{{end}}{{end}} +{{define "sitelist"}}{{if .Rows}}
    + {{range $i, $s := .Rows}}
  • +
    {{rankBadge $i}}
    + {{if $s.AvatarURL}}{{else}}
    {{$s.Avatar}}
    {{end}} +
    {{$s.Name}}{{if eq $s.ID $.Me}}就是我{{else if eq $s.ID $.Here}}本站{{end}}
    {{if $s.Title}}{{$s.Title}} · {{end}}{{(level $s.Exp).Title}} · {{$s.Donors}} 位施主 · 钢镚 {{$s.Coins}}
    +
    {{if eq $.By "coins"}}🪙 {{$s.Coins}}{{else}}{{fmtAmt $s.TotalE8}}{{end}}
    +
  • {{end}} +
{{else}}
🏚
还没有人上榜
{{end}}{{end}} +{{define "scripts"}}{{end}} diff --git a/templates/sprite.html b/templates/sprite.html new file mode 100644 index 0000000..870f65c --- /dev/null +++ b/templates/sprite.html @@ -0,0 +1,3 @@ +{{define "sprite"}}{{end}} +{{define "coin"}}{{end}} +{{define "bowlicon"}}{{end}} diff --git a/templates/status.html b/templates/status.html new file mode 100644 index 0000000..e05b9e3 --- /dev/null +++ b/templates/status.html @@ -0,0 +1,71 @@ +{{define "title"}}{{if eq .D.Status "paid"}}功德 +{{fmtAmt .D.ActualE8}}{{else}}施舍进行中{{end}} · {{.SiteTitle}}{{end}} +{{define "content"}} +
+{{if eq .D.Status "paid"}} +
+
{{template "sprite"}}
+
★ THANK YOU ★
+

🙏 谢谢施主 {{.D.Nickname}}

+
+{{fmtAmt .D.ActualE8}}{{.D.Currency}}
+ {{if .D.MsgBlocked}}{{else if .D.Message}}
{{.D.Message}}
{{end}} + {{if and (gt .Position 0) (not .D.Blocked)}}
你在 {{.Site.Name}} 的施主榜排第 {{.Position}} 位 · {{donorTier .DonorTotal}}
{{end}} +
+ 回到 {{.Site.Name}} 的碗 + +
+
+
+

🖼 分享卡片

+

生成一张像素分享图,发到 X 或朋友圈,顺便帮乞丐吆喝。

+ + +
+{{if .SubsitesEnabled}} +
+

🏚 你也可以要饭

+

用 X 账号发一条推就能开自己的要饭站,别人给你的钱直接进你的币安账户。

+ 用 X 开通我的要饭站 → +
+{{end}} +{{else if or (eq .D.Status "claimed") (and (eq .D.Status "pending") (not .Stale))}} +
+ {{template "paypanel" .Panel}} + +
+{{else}} +
+
{{template "sprite"}}
+

{{if eq .D.Status "rejected"}}站长没有确认这笔施舍{{else if eq .D.Status "failed"}}下单失败{{else}}这笔施舍已过期{{end}}

+

{{if eq .D.Status "rejected"}}如果你确实转了,请联系站长核对。{{else}}没付款不扣钱;想施舍请重新来一次。{{end}}

+ 再来一次 +
+{{end}} +
+{{end}} +{{define "scripts"}}{{end}} diff --git a/tools/sprite.py b/tools/sprite.py new file mode 100644 index 0000000..b565ca9 --- /dev/null +++ b/tools/sprite.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""像素画生成器:把字符网格变成内联 SVG(每行同色连续像素合并成一个 rect)。 + +用法: python3 tools/sprite.py > templates/sprite.html +产出 {{define "sprite"}}(端碗小人,分层:body / eyes / tear / bowl,便于 CSS 动画) + {{define "coin"}}(金币){{define "bowlicon"}}(小碗) +""" +PAL = { + "K": "#1e1e2e", "H": "#3b2a1e", "S": "#f7c9a3", "R": "#a86f3d", "P": "#f2c14e", "G": "#556677", + "B": "#ece7d8", "D": "#b9b3a3", "Y": "#ffcc00", "O": "#e09a00", "W": "#ffffff", "T": "#6fb7ff", "M": "#c0392b", +} + +BEGGAR = [ + "........K.KKK.K.........", + ".......KHKHHHKHK........", + "......KHHHHHHHHHK.......", + ".....KHHHHHHHHHHHK......", + ".....KHHHHHHHHHHHK......", + ".....KHSSSSSSSSSHK......", + "....KHSSSSSSSSSSSHK.....", + "....KSSSSSSSSSSSSSK.....", + "....KSSSSSSSSSSSSSK.....", + "....KSSeeSSSSSSeeSK.....", # e = 眼睛(身体层画皮肤,眼睛层再画) + "....KSSeeSSSSSSeeSK.....", + "....KSStSSSSSSSSSSK.....", # t = 眼泪 + "....KSSSSSMMSSSSSSK.....", + "....KSSSSMSSMSSSSSK.....", + ".....KSSSSSSSSSSSK......", + "......KKKSSSSKKK........", + ".....KRRRRKSSKRRRRK.....", + "....KRRRRRRKKRRRRRRK....", + "....KRRRRRRRRRRRRRRK....", + "....KRPPRRRRRRRRRRRK....", + "....KRPbbbbbbbbbbbRRK...", # b = 碗层(碗沿) + "...bbbbbbbbbbbbbbbbbbb..", + "...bbbbbbbbbbbbbbbbbbb..", + "....bbbbbbbbbbbbbbbbb...", + "......KRRbbbbbbbbRRK....", + "......KRRRRKKKKKRRRRK...", + "......KRRRRRRRRRRRRRK...", + "......KRRRRRRRRRRRRRK...", + "......KRKRRRRRRRRRKRK...", + "......KGGKGGGGGGGKGGK...", + "......KGGK.......KGGK...", + "......KSSK.......KSSK...", + "......KKKK.......KKKK...", +] +# 碗 + 双手层(与身体层同坐标系,只画非空像素) +BOWL = { + 20: "......KKKKKKKKKKKKK.....", + 21: "...KSSKDBBBBBBBBBDKSSK..", + 22: "...KSSKDBYOYBBYOYDKSSK..", + 23: "....KKKDBBBBBBBBBDKKK...", + 24: ".........KKDDDDDKK......", +} +EYES = {9: {7: "K", 8: "W", 14: "K", 15: "W"}, 10: {7: "K", 8: "K", 14: "K", 15: "K"}} +TEAR = {11: {7: "T"}} + +COIN = [ + "..KKKK..", + ".KYYYYK.", + "KYWYYYYK", + "KYYKKYYK", + "KYYKKYYK", + "KYYYYOYK", + ".KYOOYK.", + "..KKKK..", +] +BOWLICON = [ + "....KYK.KYK.....", + "...KYOYKYOYK....", + "KKKKKKKKKKKKKKKK", + "KBBBBBBBBBBBBBBK", + ".KDBBBBBBBBBBDK.", + "..KDDDDDDDDDDK..", + "...KKKKKKKKKK...", +] + + +def rects(grid, skip=".", only=None, override=None): + """把网格转成 rect 列表。only: 只画这些字符;override: {char: color}。""" + out = [] + for y, row in enumerate(grid): + x = 0 + while x < len(row): + c = row[x] + if c == skip or (only is not None and c not in only) or c not in PAL and c not in (override or {}): + x += 1 + continue + x2 = x + while x2 + 1 < len(row) and row[x2 + 1] == c: + x2 += 1 + color = (override or {}).get(c, PAL.get(c)) + out.append(f'') + x = x2 + 1 + return "".join(out) + + +def layer(cells): + out = [] + for y, row in cells.items(): + for x, c in row.items(): + out.append(f'') + return "".join(out) + + +def main(): + # 身体层:眼睛 / 眼泪位置画皮肤,碗层位置留空(由碗层画) + body_grid = [r.replace("e", "S").replace("t", "S").replace("b", ".") for r in BEGGAR] + body = rects(body_grid) + bowl = rects([BOWL.get(y, "." * 24) for y in range(len(BEGGAR))]) + h = len(BEGGAR) + print('{{define "sprite"}}' + '%s%s%s%s{{end}}' % (h, h * 4, body, layer(EYES), layer(TEAR), bowl)) + print('{{define "coin"}}{{end}}' % rects(COIN)) + print('{{define "bowlicon"}}{{end}}' % rects(BOWLICON)) + + +if __name__ == "__main__": + main() diff --git a/util.go b/util.go new file mode 100644 index 0000000..19b4ebe --- /dev/null +++ b/util.go @@ -0,0 +1,530 @@ +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 链接,防止有人借本站挂钓鱼链接。 +func validateBinanceLink(s string) (string, error) { + s = strings.TrimSpace(s) + if s == "" { + return "", nil + } + 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 +} + +func hashToken(t string) string { + h := sha256.Sum256([]byte(t)) + return hex.EncodeToString(h[:]) +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..f4f64f6 --- /dev/null +++ b/util_test.go @@ -0,0 +1,191 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAmount(t *testing.T) { + cases := map[string]string{"5": "5", "5.5": "5.5", "05.50": "5.5", ".5": "0.5", "10.0037": "10.0037", "1.00000001": "1.00000001", "0.10": "0.1"} + for in, want := range cases { + e8, err := parseAmountE8(in, 8) + if err != nil { + t.Fatalf("%s: %v", in, err) + } + if got := fmtE8(e8); got != want { + t.Errorf("%s: got %s want %s", in, got, want) + } + } + for _, bad := range []string{"", "abc", "-1", "1.", "1,5", "1e3", "12345678901"} { + if _, err := parseAmountE8(bad, 8); err == nil { + t.Errorf("%q should fail", bad) + } + } + if _, err := parseAmountE8("1.001", 2); err == nil { + t.Error("1.001 with 2 decimals should fail") + } + if fmtE8(0) != "0" { + t.Error("zero") + } +} + +func TestSlug(t *testing.T) { + for _, ok := range []string{"qianyuwing", "a1", "my-site_2", "abcdefghijklmnopqrstuvwx"} { + if !validSlug(ok) { + t.Errorf("%s should be valid", ok) + } + } + for _, bad := range []string{"a", "-ab", "Admin", "admin", "new", "login", "有中文", "abcdefghijklmnopqrstuvwxy", "a b"} { + if validSlug(bad) { + t.Errorf("%s should be invalid", bad) + } + } +} + +func TestCleanText(t *testing.T) { + if got := cleanText(" hello \t\x00world​ ", 20, false); got != "hello world​" { + t.Errorf("got %q", got) + } + if got := cleanText("一二三四五六", 3, false); got != "一二三" { + t.Errorf("got %q", got) + } + if got := cleanText("a\n\n\n\nb\n", 20, true); got != "a\n\nb" { + t.Errorf("got %q", got) + } + if nickKey(" QianyuWing X ") != "qianyuwing x" { + t.Error("nickKey") + } +} + +func TestValidateURLs(t *testing.T) { + if _, err := validateBinanceLink("https://app.binance.com/uni-qr/abc"); err != nil { + t.Error(err) + } + for _, bad := range []string{"http://app.binance.com/x", "https://binance.com.evil.io/x", "https://evil.io/binance.com", "javascript:alert(1)"} { + if _, err := validateBinanceLink(bad); err == nil { + t.Errorf("%s should fail", bad) + } + } +} + +func TestXLink(t *testing.T) { + for in, want := range map[string]string{"@qianyuwing": "https://x.com/qianyuwing", "qianyuwing_1": "https://x.com/qianyuwing_1", "https://x.com/QianyuWing/status/1": "https://x.com/QianyuWing", + "twitter.com/abc?s=1": "https://x.com/abc", "https://www.x.com/abc/": "https://x.com/abc", "": ""} { + if got, err := validateXLink(in); err != nil || got != want { + t.Errorf("%q: got %q %v want %q", in, got, err, want) + } + } + for _, bad := range []string{"https://evil.io/x", "https://x.com/", "https://x.com/this-is-not-valid", "@toolongtoolongtoolong"} { + if _, err := validateXLink(bad); err == nil { + t.Errorf("%q should fail", bad) + } + } + if xHandle("https://x.com/abc") != "abc" { + t.Error("xHandle") + } +} + +func TestSpamAndTweet(t *testing.T) { + for _, bad := range []string{"加微信 abc", "vx: 123", "看 www.evil.com", "https://x.y", "shop.xyz 好物", "QQ 12345678", "电报找我", "打 13812345678", "wechat me"} { + if !hasContact(bad) { + t.Errorf("%q 应判为广告", bad) + } + } + for _, ok := range []string{"别买烟了,去吃饭", "加油要饭", "我的名字 qianyuwing", "5 块钱拿去", "v神牛逼", "devx 团队加油", "avx512"} { + if hasContact(ok) { + t.Errorf("%q 不应判为广告", ok) + } + } + for in, want := range map[string]string{"https://x.com/i/web/status/1234567890": "1234567890", "https://x.com/jack/status/20": "20", "https://twitter.com/a_b/statuses/1234567890123?s=20": "1234567890123", "x.com/jack/status/20": "20", "https://x.com/jack": "", "junk": ""} { + if got := tweetIDFrom(in); got != want { + t.Errorf("tweetIDFrom(%q)=%q want %q", in, got, want) + } + } + if tok := tweetToken("20"); tok == "" || strings.ContainsAny(tok, "0.") { + t.Errorf("token 不应含 0 或小数点: %q", tok) + } + for in, want := range map[string]string{"@qianyuwing": "qianyuwing", "qianyuwing_1": "qianyuwing_1", "https://x.com/QianyuWing/status/1": "QianyuWing", "bad name": "", "": ""} { + if got := normHandle(in); got != want { + t.Errorf("normHandle(%q)=%q want %q", in, got, want) + } + } + if ws := weekStart(); ws <= 0 || ms()-ws > 7*24*3600*1000 || ms() < ws { + t.Errorf("weekStart 不合理: %d", ws) + } +} + +func TestClientIP(t *testing.T) { + mk := func(remote string, hdr map[string]string) *http.Request { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = remote + for k, v := range hdr { + r.Header.Set(k, v) + } + return r + } + // 不信代理:忽略所有头 + if ip := clientIP(mk("1.2.3.4:5", map[string]string{"X-Forwarded-For": "9.9.9.9"}), false, "X-Forwarded-For"); ip != "1.2.3.4" { + t.Errorf("got %s", ip) + } + // 信代理但请求不是从内网来的:头无效 + if ip := clientIP(mk("1.2.3.4:5", map[string]string{"X-Forwarded-For": "9.9.9.9"}), true, "X-Forwarded-For"); ip != "1.2.3.4" { + t.Errorf("got %s", ip) + } + // 反代来的:取最后一跳 + if ip := clientIP(mk("127.0.0.1:5", map[string]string{"X-Forwarded-For": "6.6.6.6, 9.9.9.9"}), true, "X-Forwarded-For"); ip != "9.9.9.9" { + t.Errorf("got %s", ip) + } + // 配置的其它头才生效;伪造的 X-Real-IP 不看 + if ip := clientIP(mk("127.0.0.1:5", map[string]string{"X-Real-IP": "6.6.6.6", "CF-Connecting-IP": "7.7.7.7"}), true, "CF-Connecting-IP"); ip != "7.7.7.7" { + t.Errorf("got %s", ip) + } + if ip := clientIP(mk("127.0.0.1:5", map[string]string{"X-Real-IP": "6.6.6.6"}), true, "X-Forwarded-For"); ip != "127.0.0.1" { + t.Errorf("got %s", ip) + } + // 非法值回退 + if ip := clientIP(mk("127.0.0.1:5", map[string]string{"X-Forwarded-For": "evil"}), true, "X-Forwarded-For"); ip != "127.0.0.1" { + t.Errorf("got %s", ip) + } +} + +func TestCoinCaps(t *testing.T) { + st, err := openStore(t.TempDir() + "/c.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, err := st.db.Exec(`INSERT INTO sites(slug,name,pass_hash,created_at,updated_at) VALUES('s','s','',0,0)`); err != nil { + t.Fatal(err) + } + add := func(v, ip string) (bool, string) { + ok, msg, err := st.AddCoin(1, v, ip, "2000-01-01", 2, 3) + if err != nil { + t.Fatal(err) + } + return ok, msg + } + if ok, _ := add("v1", "ip1"); !ok { + t.Error("第一次应成功") + } + if ok, _ := add("v1", "ip1"); !ok { + t.Error("perDay=2 第二次应成功") + } + if ok, msg := add("v1", "ip1"); ok || !strings.Contains(msg, "明天") { + t.Errorf("第三次应被拦: %v %s", ok, msg) + } + if ok, _ := add("v2", "ip1"); !ok { + t.Error("同 IP 第三个应成功(ipCap=3)") + } + if ok, msg := add("v3", "ip1"); ok || !strings.Contains(msg, "网络") { + t.Errorf("ipCap 应拦: %v %s", ok, msg) + } + if ok, _ := add("v3", "ip2"); !ok { + t.Error("换 IP 应成功") + } + total, today, _ := st.CoinStats(1) + if total != 4 || today != 0 { + t.Errorf("计数: total=%d today=%d(today 按当前日期查,测试用固定日期)", total, today) + } +} diff --git a/xapi.go b/xapi.go new file mode 100644 index 0000000..5df6123 --- /dev/null +++ b/xapi.go @@ -0,0 +1,232 @@ +package main + +import ( + "bytes" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// xUser X 用户资料(来自推文接口或头像服务)。 +type xUser struct { + ID string // 数字用户 ID(id_str) + Name string + Handle string + Avatar string // 原始头像 URL +} + +var xHC = &http.Client{Timeout: 12 * time.Second} + +// avatarHC 抓头像:不跟随跳转(防跳到内网),域名白名单在 downloadAvatar 里校验。 +var avatarHC = &http.Client{Timeout: 12 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + +// fetchTweet 用 X 嵌入组件的公开接口读一条推文(免 API Key),返回正文、作者与发推时间(毫秒,解析失败为 0)。 +func (a *App) fetchTweet(id string) (string, xUser, int64, error) { + req, _ := http.NewRequest("GET", a.cfg.XTweetAPI+"/tweet-result?id="+id+"&token="+tweetToken(id), nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (newbeggar)") + resp, err := xHC.Do(req) + if err != nil { + return "", xUser{}, 0, fmt.Errorf("连不上 X 接口: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode == 404 { + return "", xUser{}, 0, errors.New("找不到这条推文(链接不对,或推文不公开)") + } + if resp.StatusCode != 200 { + return "", xUser{}, 0, fmt.Errorf("X 接口暂时不可用(HTTP %d),稍后再试", resp.StatusCode) + } + var t struct { + Typename string `json:"__typename"` + Text string `json:"text"` + CreatedAt string `json:"created_at"` + User struct { + IDStr string `json:"id_str"` + Name string `json:"name"` + ScreenName string `json:"screen_name"` + Avatar string `json:"profile_image_url_https"` + } `json:"user"` + } + if err := json.Unmarshal(body, &t); err != nil || t.User.ScreenName == "" { + return "", xUser{}, 0, errors.New("推文读不到(可能被删除或账号受保护)") + } + var created int64 + for _, layout := range []string{time.RubyDate, time.RFC3339} { + if ts, err := time.Parse(layout, t.CreatedAt); err == nil { + created = ts.UnixMilli() + break + } + } + return t.Text, xUser{ID: t.User.IDStr, Name: t.User.Name, Handle: t.User.ScreenName, Avatar: t.User.Avatar}, created, nil +} + +// avatarHostOK 头像只从 X 图片域和配置的头像服务抓。 +func (a *App) avatarHostOK(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return false + } + host := strings.ToLower(u.Hostname()) + for _, base := range []string{a.cfg.XTweetAPI, a.cfg.XAvatarAPI} { + if bu, err := url.Parse(base); err == nil && strings.EqualFold(bu.Host, u.Host) { + return true + } + } + return u.Scheme == "https" && (host == "twimg.com" || strings.HasSuffix(host, ".twimg.com")) +} + +// avatarFile 头像文件名:sha1(handle 小写 + 抓取时间) + 扩展名——每次抓取换名字,浏览器缓存不会拿到旧图。 +func avatarFile(handle, contentType string) string { + h := sha1.Sum([]byte(strings.ToLower(handle) + ":" + strconv.FormatInt(ms(), 10))) + ext := ".jpg" + switch { + case strings.Contains(contentType, "png"): + ext = ".png" + case strings.Contains(contentType, "webp"): + ext = ".webp" + case strings.Contains(contentType, "gif"): + ext = ".gif" + } + return hex.EncodeToString(h[:]) + ext +} + +// downloadAvatar 把头像抓到本地(≤2MB,必须是能解码的图片,域名白名单,不跟随跳转),返回文件名。 +func (a *App) downloadAvatar(handle, rawURL string) (string, error) { + if !a.avatarHostOK(rawURL) { + return "", errors.New("头像地址不在允许的域名") + } + req, _ := http.NewRequest("GET", rawURL, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (newbeggar)") + resp, err := avatarHC.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + ct := resp.Header.Get("Content-Type") + if resp.StatusCode != 200 || !strings.HasPrefix(ct, "image/") { + return "", fmt.Errorf("头像不可用 (HTTP %d %s)", resp.StatusCode, ct) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil || len(data) < 32 { + return "", errors.New("头像下载失败") + } + if !strings.Contains(ct, "webp") { + if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil { + return "", errors.New("头像不是有效图片") + } + } + if err := os.MkdirAll(a.cfg.AvatarDir, 0o755); err != nil { + return "", err + } + name := avatarFile(handle, ct) + tmp := filepath.Join(a.cfg.AvatarDir, name+".tmp") + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return "", err + } + return name, os.Rename(tmp, filepath.Join(a.cfg.AvatarDir, name)) +} + +// xProfileCache 施主 X 头像缓存(内存 + xprofiles 表)。 +type xProfileCache struct { + mu sync.Mutex + files map[string]string // handle 小写 → 文件名("" 表示抓过但没有) + inflight map[string]bool +} + +func (a *App) xAvatar(handle string) string { + if handle == "" { + return "" + } + key := strings.ToLower(handle) + a.xp.mu.Lock() + f, ok := a.xp.files[key] + a.xp.mu.Unlock() + if ok { + return f + } + if p, err := a.st.GetXProfile(key); err == nil && p != nil { + a.xp.mu.Lock() + a.xp.files[key] = p.Avatar + a.xp.mu.Unlock() + return p.Avatar + } + return "" +} + +// ensureXProfile 异步抓取施主头像(7 天内抓过的不重复抓)。 +func (a *App) ensureXProfile(handle string) { + key := strings.ToLower(handle) + if key == "" { + return + } + if p, err := a.st.GetXProfile(key); err == nil && p != nil && ms()-p.FetchedAt < 7*24*3600*1000 { + return + } + a.xp.mu.Lock() + if a.xp.inflight[key] { + a.xp.mu.Unlock() + return + } + a.xp.inflight[key] = true + a.xp.mu.Unlock() + go func() { + defer func() { + a.xp.mu.Lock() + delete(a.xp.inflight, key) + a.xp.mu.Unlock() + }() + old, _ := a.st.GetXProfile(key) + fetchedAt := ms() + file, err := a.downloadAvatar(handle, a.cfg.XAvatarAPI+"/x/"+handle+"?fallback=false") + if err != nil { + log.Printf("[warn] 抓取 @%s 头像失败: %v", handle, err) + if old != nil && old.Avatar != "" { + file = old.Avatar // 抓失败沿用旧图 + } else { + fetchedAt = ms() - (7*24-1)*3600*1000 // 没图:1 小时后允许重试 + } + } + if err := a.st.UpsertXProfile(key, "", file, fetchedAt); err != nil { + log.Printf("[error] 保存 X 资料 %s: %v", key, err) + return + } + if old != nil && old.Avatar != "" && old.Avatar != file { + if a.st.AvatarInUse(old.Avatar) { + a.st.SetSiteAvatarByHandle(handle, file) // 站长头像随之更新,再删旧文件 + } + os.Remove(filepath.Join(a.cfg.AvatarDir, old.Avatar)) + } + a.st.FillSiteAvatarByHandle(handle, file) // 主站(MAIN_X)等还没头像的站点补上 + a.xp.mu.Lock() + a.xp.files[key] = file + a.xp.mu.Unlock() + }() +} + +// siteAvatar 站长头像:X 验证过的用 X 头像,否则用 emoji。 +func (a *App) serveAvatar(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("file") + if !reAvatarFile.MatchString(name) { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "public, max-age=604800") + http.ServeFile(w, r, filepath.Join(a.cfg.AvatarDir, name)) +}