赛博要饭 newbeggar:像素风要饭站,币安 Pay 收款 + X 发推开分舵

This commit is contained in:
qianyu committed 2026-09-04 12:45:26 +08:00
1 parent ee00e020cc
commit 253087b2a8
36 files changed
+7073

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# 私有配置与数据
config.env
*.db
*.db-wal
*.db-shm
# 构建产物
/beggar
/beggar-linux-amd64
/dist/
# 本机工具链 / 编辑器
.toolchain/
.claude/
.DS_Store
# 背景音乐(可选,自备 AAC 循环片段;仓库不附带)
/static/bgm.m4a
+1
View File
@@ -1,3 +1,4 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
+114
View File
@@ -0,0 +1,114 @@
# 赛博要饭 · newbeggar
> 一个可以**要饭**的网站:你摆一只碗,别人用币安 Pay 施舍、留一句话、上施主榜;
> 任何人发一条推就能开自己的要饭站(`域名/X用户名`),钱直接进他自己的币安账户。
> 单二进制 + SQLite,收款走开源网关 [BinancePayTool](https://github.com/qianyubtc/BinancePayTool)(币安官方只读 API,秒级到账)。
**体验站:<https://newbeggar.com>** —— 作者本人的碗,欢迎来投币、丢钢镚,或者发条推开你自己的分舵。
## 玩法
- **主站**:站长的碗。展示碗里总额、施主数、今日进账;施主填名号 + 金额 + 一句话,**不离开本站**:弹窗里显示唯一金额、收款二维码 / 一键唤起币安 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`)。
+367
View File
@@ -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
}
+760
View File
@@ -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": {"加油要饭 <b>x</b>"}})
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, "排第 <b>1</b> 位"), "名次")
st, body, _ = c.get("/")
must(t, strings.Contains(body, "加油要饭 &lt;b&gt;x&lt;/b&gt;") && 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</div>") && 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</b><small>钢镚<`) && 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 <b>22</b>"), "子站职位与袋位(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, "路人甲</span>"), "屏蔽此人后名号隐藏")
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, "<html"), "异步下单错误应为纯文本: %d", st)
st, body, h = c8.post("/donate", url.Values{"site": {""}, "nickname": {"弹窗党"}, "amount": {"2"}, "ajax": {"1"}})
code8 := h.Get("X-Donation")
must(t, st == 200 && strings.HasPrefix(code8, "BEG") && strings.Contains(body, `class="paypanel"`) && strings.Contains(body, "帮我核对") && !strings.Contains(body, "<html"), "异步下单应返回面板片段: %d", st)
st, body, _ = c8.post("/d/"+code8+"/claim", url.Values{"binance_order_id": {"452100000000000999"}, "ajax": {"1"}})
must(t, st == 200 && strings.Contains(body, `"ok":false`) && strings.Contains(body, "没查到"), "回填未命中: %s", body)
st, body, _ = c8.post("/d/"+code8+"/claim", url.Values{"binance_order_id": {"452100000000000123"}, "ajax": {"1"}})
must(t, st == 200 && strings.Contains(body, `"status":"paid"`), "回填命中应到账: %s", body)
st, body, _ = c8.get("/d/" + code8)
must(t, strings.Contains(body, "谢谢施主 弹窗党"), "回填后感谢页")
st, body, _ = e.client().post("/d/"+code8+"/claim", url.Values{"binance_order_id": {"452100000000000123"}, "ajax": {"1"}})
must(t, st == 403, "别的浏览器不能替人回填: %d", st)
// ⑭ 过期回调
c5 := e.client()
c5.post("/donate", url.Values{"site": {""}, "amount": {"7"}})
code5 := e.gw.last.MerchantOrderID
exp := bpaygate.Callback{Event: "expired", OrderID: e.gw.last.OrderID, MerchantOrderID: code5, Status: "expired", Timestamp: ms()}
must(t, e.gw.sendCallback(exp, e.gw.secret) == 200, "过期回调")
st, body, _ = c5.get("/d/" + code5)
must(t, strings.Contains(body, "已过期"), "过期页")
}
func TestReviewGate(t *testing.T) {
e := newEnvWith(t, func(c *Config) { c.SubsiteReview = true })
c := e.client()
st, _, h := c.openSite("newbie", "新丐", "newbie", "pass-newbie")
must(t, st == 302 && h.Get("Location") == "/manage?welcome=1", "开站: %d", st)
site, _ := e.app.st.GetSiteBySlug("newbie")
must(t, site != nil && !site.Listed, "审核模式下新站未收录")
st, body, _ := c.get("/manage?welcome=1")
must(t, st == 200 && strings.Contains(body, "待主站站长收录"), "欢迎语提示待收录")
st, body, _ = e.client().get("/")
must(t, !strings.Contains(body, "新丐"), "未收录不进榜")
admin := e.client()
admin.post("/login", url.Values{"slug": {""}, "password": {e.cfg.AdminPassword}})
st, _, _ = admin.post(fmt.Sprintf("/manage/site/%d/list", site.ID), nil)
must(t, st == 302, "收录")
st, body, _ = e.client().get("/")
must(t, strings.Contains(body, "新丐"), "收录后进榜")
}
func TestPassword(t *testing.T) {
h := hashPassword("secret-1")
must(t, checkPassword(h, "secret-1") && !checkPassword(h, "secret-2") && !checkPassword("garbage", "x"), "口令哈希")
sec := []byte("k")
v := signSession(sec, 7, ms()/1000+60, "abcd1234")
id, tag := parseSession(sec, v)
must(t, id == 7 && tag == "abcd1234", "会话签名")
id, _ = parseSession([]byte("other"), v)
must(t, id == 0, "错密钥")
id, _ = parseSession(sec, v+"x")
must(t, id == 0, "篡改")
id, _ = parseSession(sec, signSession(sec, 7, ms()/1000-1, "abcd1234"))
must(t, id == 0, "过期会话")
}
// TestAdminPasswordSticky 主站在后台改的口令重启不被 ADMIN_PASSWORD 冲掉;config 值变了才覆盖。
func TestAdminPasswordSticky(t *testing.T) {
st, err := openStore(filepath.Join(t.TempDir(), "s.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
ensure := func(pw string) {
cfg := &Config{AdminPassword: pw, MainName: "主站", MainAvatar: "🥣", Currency: "USDT"}
if err := st.EnsureMainSite(cfg, func(h string) bool { return checkPassword(h, pw) }, func() string { return hashPassword(pw) }); err != nil {
t.Fatal(err)
}
}
ensure("config-pw-1")
main, _ := st.GetSiteBySlug("")
must(t, checkPassword(main.PassHash, "config-pw-1"), "首次按 config 设口令")
st.SetSitePassword(main.ID, hashPassword("ui-pw-2"))
ensure("config-pw-1")
main, _ = st.GetSiteBySlug("")
must(t, checkPassword(main.PassHash, "ui-pw-2"), "config 没变,后台改的口令要保留")
ensure("config-pw-3")
main, _ = st.GetSiteBySlug("")
must(t, checkPassword(main.PassHash, "config-pw-3"), "config 变了才覆盖")
}
// TestResetIdentity 找回口令只认同一个 X 数字 ID;旧用户名被别人注册不能接管;主站不走 X 找回。
func TestResetIdentity(t *testing.T) {
st, err := openStore(filepath.Join(t.TempDir(), "s.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.CreateSite(&Site{Slug: "alice", Name: "Alice", XURL: "https://x.com/alice", XHandle: "alice", XName: "Alice", XID: "9alice", PassHash: hashPassword("alice-pass-1"), Currency: "USDT"}); err != nil {
t.Fatal(err)
}
a := &App{st: st}
must(t, a.siteOfX(&xUser{ID: "9alice", Handle: "alice_new"}) != nil, "改了用户名,按数字 ID 仍能认出")
must(t, a.siteOfX(&xUser{ID: "777", Handle: "alice"}) == nil, "同名但不是同一个 X 账号,不能认")
must(t, a.siteOfX(&xUser{ID: "", Handle: "alice"}) != nil, "接口没给 id_str 时退回按用户名")
must(t, st.SyncSiteX(1, "999", "alice2", "Alice2", "") == nil, "sync")
s, _ := st.GetSiteByID(1)
must(t, s.XID == "9alice" && s.XHandle == "alice2" && s.Name == "Alice2", "sync 不覆盖已有 x_id,只更新用户名/站名: %+v", s)
}
+73
View File
@@ -0,0 +1,73 @@
# ========= 赛博要饭 配置示例 =========
# 复制为 config.env 后填写。config.env 已被 .gitignore 排除,勿提交。同名环境变量优先于本文件。
# 监听地址与对外基础 URL(回调地址、子站地址、登录链接都用它拼接)
LISTEN=127.0.0.1:8124
BASE_URL=https://beggar.example.com
# 主站站长口令:在 BASE_URL/login 里「路径留空 + 此口令」登录后台。生成方法: ./beggar -gen-key
ADMIN_PASSWORD=
# 页脚作者链接(可留空不显示)
AUTHOR_GITHUB=https://github.com/qianyubtc
AUTHOR_X=https://x.com/qianyuwing
# 说明:ADMIN_PASSWORD 只在首次建站或这里的值改变时写入;主站在后台改过口令后重启不会被冲掉
# 站点标题
SITE_TITLE=赛博要饭
# ---- 主站资料(首次启动写入数据库,之后在后台改;改这里不会覆盖库里的)----
MAIN_NAME=芊羽Wing
MAIN_SLOGAN=行行好,赏口饭吃 🙏
MAIN_STORY=
MAIN_AVATAR=🥣
# 可选:绑定 X 主页(@名字 或 https://x.com/名字)
MAIN_X=
# ---- 收款(每次启动按这里覆盖主站配置)----
# 方式一(推荐):BinancePayTool 网关(须为支持多账号的版本),主站到账自动确认,
# 子站站长也能在后台绑定自己的币安只读 Key(本站把 Key 注册到网关成为独立收款账号)。
# BPG_KEY = 网关的 API_AUTH_KEY。网关与本站同机时可直接填网关的内网监听地址,如 http://127.0.0.1:8123
BPG_URL=
BPG_KEY=
# 方式二:不接网关,主站走直接转账(施主自报、站长在后台确认)。填收款二维码解码链接和/或 Pay ID。
MAIN_RECEIVE_LINK=
MAIN_PAY_ID=
# ---- 施舍 ----
CURRENCY=USDT
# 金额档位(页面上再加一个「自定义」)与上下限
PRESET_AMOUNTS=0.1,1
MIN_AMOUNT=0.1
MAX_AMOUNT=10000
# 网关订单有效期(秒),120–86400
ORDER_TTL=900
# ---- 子站 ----
# 是否开放开站(用 X 发推验证,BASE_URL/路径)
SUBSITES_ENABLED=true
# 新子站是否默认「未收录」(不进榜、noindex),由主站站长在后台收录。建议开,防广告站蹭曝光
SUBSITE_REVIEW=true
# 钢镚(免费点赞币):每人每站每天几个;每 IP 每站每天上限
COINS_PER_DAY=1
COINS_IP_CAP=20
# 经验:EXP = 碗里的钱(U) × MONEY_EXP + 钢镚 × COIN_EXP。默认 0.1 U = 1 EXP、1 钢镚 = 1 EXP。
# 袋位门槛(EXP):一袋 1 / 二袋 10 / 三袋 50 / 四袋 200 / 五袋 500 / 六袋 1000 / 七袋 3000 / 八袋 10000 / 九袋 50000
COIN_EXP=1
MONEY_EXP=10
# ---- X(推特)----
# 发推验证与施主头像用到的公开接口,一般不用改;测试时可指向 mock
X_TWEET_API=https://cdn.syndication.twimg.com
X_AVATAR_API=https://unavatar.io
# 头像缓存目录(需可写)
AVATAR_DIR=./avatars
# ---- 反向代理 ----
# 放在反向代理之后时开启:只在请求来自内网/回环地址时才信下面这个头,限流按真实访客 IP
TRUST_PROXY=true
# 直接 Caddy/Nginx 反代:X-Forwarded-For(取最后一跳,反代自己追加的那个);
# 前面还有 Cloudflare:填 CF-Connecting-IP(并建议防火墙只放行 Cloudflare 回源 IP,防止绕过 CF 直连伪造)
TRUST_PROXY_HEADER=X-Forwarded-For
# ---- 存储 ----
DB_PATH=./beggar.db
+213
View File
@@ -0,0 +1,213 @@
package main
import (
"bufio"
"errors"
"fmt"
"os"
"strconv"
"strings"
)
// Config 全部来自 config.env(KEY=VALUE)或同名环境变量(环境变量优先)。
type Config struct {
Listen string
BaseURL string
DBPath string
AdminPassword string // 主站登录口令(路径留空 + 此口令)
SiteTitle string
RepoURL string
AuthorGitHub string // 页脚作者 GitHub 链接
AuthorX string // 页脚作者 X 链接
// 主站资料(首次启动写入数据库,之后在管理页改)
MainName string
MainSlogan string
MainStory string
MainAvatar string
MainX string
// 收款:配置 BPG_URL 则主站走 BinancePayTool 网关自动确认,子站也能绑定自己的币安 Key;否则主站走直接转账
BPGURL string
BPGKey string
MainReceiveLink string
MainPayID string
Currency string
PresetAmounts []string
MinAmountE8 int64
MaxAmountE8 int64
OrderTTL int64 // 秒
SubsitesEnabled bool
SubsiteReview bool // 新子站默认未收录(不进榜、noindex),主站后台收录
TrustProxy bool
TrustHeader string // 反代传真实 IP 的头:X-Forwarded-For(取最后一跳)或 CF-Connecting-IP
// X(推特):发推验证 / 头像抓取的接口基址,测试时可指向 mock
XTweetAPI string
XAvatarAPI string
AvatarDir string
CoinsPerDay int64 // 每人每站每天可丢的钢镚数
CoinsIPCap int64 // 每 IP 每站每天上限(防脚本)
CoinExp int64 // 一个钢镚 = 多少 EXP
MoneyExp int64 // 1 个币 = 多少 EXP(默认 10,即 0.1 U = 1 EXP)
}
func loadConfig(path string) (*Config, error) {
vals := map[string]string{}
if f, err := os.Open(path); err == nil {
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
v = strings.TrimSpace(v)
if len(v) >= 2 && (v[0] == '"' && v[len(v)-1] == '"' || v[0] == '\'' && v[len(v)-1] == '\'') {
v = v[1 : len(v)-1]
}
vals[strings.TrimSpace(k)] = v
}
f.Close()
} else if !os.IsNotExist(err) {
return nil, err
}
get := func(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
if v, ok := vals[k]; ok && v != "" {
return v
}
return def
}
getBool := func(k string, def bool) bool {
v := strings.ToLower(get(k, ""))
if v == "" {
return def
}
return v == "1" || v == "true" || v == "yes" || v == "on"
}
getInt := func(k string, def int64) (int64, error) {
v := get(k, "")
if v == "" {
return def, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("%s 须为整数: %q", k, v)
}
return n, nil
}
var err error
c := &Config{
Listen: get("LISTEN", "127.0.0.1:8124"),
BaseURL: strings.TrimRight(get("BASE_URL", "http://127.0.0.1:8124"), "/"),
DBPath: get("DB_PATH", "./beggar.db"),
AdminPassword: get("ADMIN_PASSWORD", get("ADMIN_TOKEN", "")),
SiteTitle: get("SITE_TITLE", "赛博要饭"),
RepoURL: get("REPO_URL", "https://github.com/qianyubtc/BinancePayTool"),
AuthorGitHub: get("AUTHOR_GITHUB", "https://github.com/qianyubtc"),
AuthorX: get("AUTHOR_X", "https://x.com/qianyuwing"),
MainName: get("MAIN_NAME", "站长"),
MainSlogan: get("MAIN_SLOGAN", "行行好,赏口饭吃 🙏"),
MainStory: get("MAIN_STORY", ""),
MainAvatar: get("MAIN_AVATAR", "🥣"),
MainX: get("MAIN_X", ""),
BPGURL: strings.TrimRight(get("BPG_URL", ""), "/"),
BPGKey: get("BPG_KEY", ""),
MainReceiveLink: get("MAIN_RECEIVE_LINK", ""),
MainPayID: get("MAIN_PAY_ID", ""),
Currency: strings.ToUpper(get("CURRENCY", "USDT")),
SubsitesEnabled: getBool("SUBSITES_ENABLED", true),
SubsiteReview: getBool("SUBSITE_REVIEW", false),
TrustProxy: getBool("TRUST_PROXY", false),
TrustHeader: get("TRUST_PROXY_HEADER", "X-Forwarded-For"),
XTweetAPI: strings.TrimRight(get("X_TWEET_API", "https://cdn.syndication.twimg.com"), "/"),
XAvatarAPI: strings.TrimRight(get("X_AVATAR_API", "https://unavatar.io"), "/"),
AvatarDir: get("AVATAR_DIR", "./avatars"),
}
if c.CoinsPerDay, err = getInt("COINS_PER_DAY", 1); err != nil {
return nil, err
}
if c.CoinsIPCap, err = getInt("COINS_IP_CAP", 20); err != nil {
return nil, err
}
if c.CoinsPerDay < 1 || c.CoinsPerDay > 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
}
+6
View File
@@ -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"
}
+24
View File
@@ -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
+36
View File
@@ -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
+352
View File
@@ -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[&note=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()
+21
View File
@@ -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
)
+54
View File
@@ -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=
+559
View File
@@ -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)
}
+957
View File
@@ -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)
}
+60
View File
@@ -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] 已退出")
}
+230
View File
@@ -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 "网关连不上,请稍后再试"
}
+56
View File
@@ -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
}
+96
View File
@@ -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.
Binary file not shown.
+1014
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
{{define "title"}}{{.Title}} · {{.SiteTitle}}{{end}}
{{define "content"}}
<div class="narrow">
<section class="card px center">
<div class="sprite-wrap" style="margin:0 auto 10px">{{template "sprite"}}</div>
<div class="insert">GAME OVER?</div>
<h1>{{.Title}}</h1>
{{if .Msg}}<p class="hint" style="font-size:14px">{{.Msg}}</p>{{end}}
<a class="btn sec" href="/">回到街头</a>
</section>
</div>
{{end}}
+332
View File
@@ -0,0 +1,332 @@
{{define "layout"}}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#f4eedd">
<title>{{template "title" .}}</title>
<meta name="description" content="{{if .Desc}}{{.Desc}}{{else}}赛博要饭:行行好,赏口饭吃。用币安 Pay 投币施舍,留一句话上功德簿;不花钱也能丢钢镚;用 X 发一条推就能开自己的要饭站。{{end}}">
<meta property="og:title" content="{{template "title" .}}">
<meta property="og:description" content="{{if .Desc}}{{.Desc}}{{else}}行行好,赏口饭吃。投币上功德簿,丢钢镚也算爱。{{end}}">
<meta property="og:type" content="website">
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}">{{end}}
<meta name="twitter:card" content="summary">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E🥣%3C/text%3E%3C/svg%3E">
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
<link rel="preload" href="/static/pixel.woff2" as="font" type="font/woff2" crossorigin>
<style>
@font-face{font-family:"Pixel";src:url(/static/pixel.woff2) format("woff2");font-display:swap}
:root{--ink:#1e1e2e;--paper:#f4eedd;--paper-2:#e9e1ca;--panel:#fffbf0;--panel-2:#fff3cc;--gold:#ffcc00;--gold-2:#e0a800;--red:#ff4b4b;--green:#3ec46d;--blue:#3d8bfd;--purple:#8e5cff;--muted:#4a4954;--muted-2:#6f6d66;--pf:"Pixel","PingFang SC","Noto Sans SC","Microsoft YaHei",sans-serif;--line:#d9d0b8;--frame:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6' shape-rendering='crispEdges'%3E%3Cpath fill='%231e1e2e' d='M1 0h4v1H1zM0 1h6v1H0zM0 2h2v2H0zM4 2h2v2H4zM0 4h6v1H0zM1 5h4v1H1z'/%3E%3C/svg%3E")}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%;overflow-x:hidden}
html,body{max-width:100%;overscroll-behavior-x:none}
@media(max-width:899px){body{overflow-x:hidden;position:relative}}
body{margin:0;color:var(--ink);background-color:var(--paper);background-image:linear-gradient(45deg,var(--paper-2) 25%,transparent 25%,transparent 75%,var(--paper-2) 75%),linear-gradient(45deg,var(--paper-2) 25%,transparent 25%,transparent 75%,var(--paper-2) 75%);background-size:16px 16px;background-position:0 0,8px 8px;font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Noto Sans SC","Hiragino Sans GB","Microsoft YaHei","Helvetica Neue",Arial,sans-serif;min-height:100vh}
.pf,h1,h2,.brand,.ticker,.lv,.pname,.stat .v,.stat .k,.amt-btn,.btn,.tab,.amtv,.rank,.insert,.barlabel,.tag,.nav a,.name,.av,.section-title,.copy,.anchors a,.step::before,.big,.dialog,.me,.count,.card h2 .sub,.kv .k,.empty .ico{font-family:var(--pf)}
.pf12{font-size:12px}
a{color:var(--blue)}
.num{font-variant-numeric:tabular-nums}
.accent{color:var(--gold-2)}
.page{max-width:1080px;margin:0 auto;padding:0 16px 96px}
@media(min-width:900px){.page{padding-bottom:40px}}
.px{border:4px solid transparent;border-image:var(--frame) 2 / 4px stretch;image-rendering:pixelated;background-clip:padding-box}
.nav{display:flex;align-items:center;gap:6px;height:64px}
.sfx{background:none;border:0;font-size:18px;line-height:1;padding:8px 6px;cursor:pointer;min-width:36px;min-height:36px;opacity:.85;flex:none}.sfx.off{opacity:.45}
.sfx.idle{animation:sfxnudge 1.2s ease-in-out infinite}@keyframes sfxnudge{0%,100%{transform:scale(1)}50%{transform:scale(1.18)}}@media(prefers-reduced-motion:reduce){.sfx.idle{animation:none}}
.nav>*{flex:none}
@media(max-width:430px){.nav{gap:4px}.nav .brand{font-size:22px;margin-right:2px}.sfx{min-width:28px;padding:6px 2px;font-size:16px}.nav a.link{padding:8px 5px;font-size:15px}.nav a.pill{padding:6px 10px;font-size:15px}
.nav a.navrank span{display:none}.nav a.navrank::before{content:"🏆";font-size:16px}}
.brand{display:flex;align-items:center;gap:8px;font-size:24px;line-height:1;color:var(--ink);text-decoration:none;white-space:nowrap;margin-right:6px}
.brand .px-coin{width:28px;height:28px;flex:none}
.nav .sp{flex:1}
.nav a.link{color:var(--ink);text-decoration:none;font-size:16px;padding:8px 8px;white-space:nowrap}
.nav a.link:hover{background:var(--panel-2)}
.nav a.pill{color:var(--ink);background:var(--gold);font-size:16px;padding:6px 12px;text-decoration:none;white-space:nowrap;box-shadow:3px 3px 0 var(--ink)}
.nav a.pill:active{transform:translate(2px,2px);box-shadow:1px 1px 0 var(--ink)}
@media(max-width:420px){.hide-sm{display:none}.nav a.pill{font-size:12px;padding:6px 10px}.nav a.link{font-size:12px;padding:8px 5px}}
.ticker{overflow:hidden;contain:paint;touch-action:pan-y;background:var(--ink);color:var(--gold);font-size:12px;line-height:1.5;padding:6px 0;margin:0 0 16px;white-space:nowrap;box-shadow:3px 3px 0 rgba(30,30,46,.25)}
.ticker-in{display:inline-block;animation:tick 34s linear infinite}
.ticker-in span{padding:0 10px}
@keyframes tick{to{transform:translateX(-50%)}}
@media(prefers-reduced-motion:reduce){.ticker-in,.sprite .bowl,.sprite .eyes,.sprite .tear,.blink,.jump .sprite,.dialog .cursor,.flycoin{animation:none!important}}
.layout{display:grid;gap:18px}
@media(min-width:900px){.layout{grid-template-columns:400px minmax(0,1fr);gap:24px;align-items:start}.col-l{position:sticky;top:16px}.layout.cols2{grid-template-columns:1fr 1fr}}
.col-l,.col-r{display:grid;gap:18px;min-width:0}
.narrow{max-width:560px;margin:0 auto}
.card{background:var(--panel);padding:18px;box-shadow:4px 4px 0 var(--ink)}
.card h2{font-size:18px;margin:0 0 12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;line-height:1.3}
.card h2 .sub{color:var(--muted);font-size:12px}
h1{font-size:24px;margin:0 0 8px;line-height:1.3}
/* 角色卡 */
.char-top{display:flex;gap:16px;align-items:flex-start}
.sprite-wrap{flex:none;width:96px;display:grid;place-items:end center}
.sprite{width:96px;height:auto;image-rendering:pixelated}
@media(min-width:900px){.char-top .sprite-wrap,.char-top .sprite{width:120px}}
.sprite .bowl{animation:shake 1s steps(1) infinite}
@keyframes shake{0%,100%{transform:translateY(0)}50%{transform:translateY(-1px)}}
.sprite .eyes{animation:blink 4s steps(1) infinite}
@keyframes blink{0%,90%{opacity:1}91%,96%{opacity:0}97%,100%{opacity:1}}
.sprite .tear{animation:tear 2.4s steps(1) infinite}
@keyframes tear{0%{transform:translateY(0)}33%{transform:translateY(1px)}66%{transform:translateY(2px)}}
.jump .sprite{animation:jump .9s steps(1) infinite}
@keyframes jump{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
.char-info{flex:1;min-width:0}
.lvrow{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
.lv{display:inline-block;background:var(--ink);color:var(--gold);font-size:12px;line-height:1.6;padding:2px 8px}
.lv b{color:#fff;font-weight:normal;margin-left:6px}
.lv.gold{background:var(--gold);color:var(--ink);border:2px solid var(--ink);padding:0 8px}
.pname{font-size:24px;margin:0 0 8px;line-height:1.25;word-break:break-word}
.barlabel{display:flex;justify-content:space-between;align-items:baseline;font-size:12px;color:var(--muted);margin-top:8px;line-height:1.4}
.barlabel b{color:var(--ink);font-weight:normal;margin-left:4px}
.barlabel span{font-size:12px;color:var(--muted)}
.bar{position:relative;height:14px;border:3px solid var(--ink);background:#fff;margin:2px 0 0;box-shadow:2px 2px 0 var(--ink)}
.bar span{display:block;height:100%;background:var(--green);background-image:repeating-linear-gradient(90deg,transparent 0 6px,rgba(0,0,0,.14) 6px 8px);transition:width .6s steps(12)}
.bar.hp span{background-color:var(--red)}
.xpill{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:700;padding:3px 8px;border:3px solid var(--ink);background:#fff;color:var(--ink);text-decoration:none;box-shadow:2px 2px 0 var(--ink);margin-top:6px}
.dialog{position:relative;margin-top:16px;background:#fff;padding:10px 26px 10px 12px;font-size:16px;line-height:1.6;box-shadow:3px 3px 0 var(--ink);min-height:44px;word-break:break-word}
.dialog::before{content:"▶";position:absolute;left:-2px;top:-14px;background:var(--gold);padding:0 6px;font-size:10px;border:3px solid var(--ink)}
.dialog .cursor{position:absolute;right:8px;bottom:6px;font-size:10px;animation:blinkc .8s steps(1) infinite}
@keyframes blinkc{50%{opacity:0}}
.more{background:none;border:0;color:var(--blue);font-size:12px;cursor:pointer;padding:6px 0 0;font-family:inherit}
/* HUD */
.stats{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.stat{background:var(--panel);padding:10px 12px;min-width:0;box-shadow:3px 3px 0 var(--ink)}
.stat.main{grid-column:1/-1;background:var(--gold);display:flex;align-items:center;gap:12px}
.stat .k{font-size:12px;color:var(--muted);line-height:1.6}
.stat.main .k{color:var(--ink)}
.stat .v{font-size:24px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.2}
.stat.main .v{font-size:36px;line-height:1.1}
.stat .v small{font-size:12px;color:var(--muted);margin-left:5px}
.stat.main .v small{color:var(--ink)}
.stat.main .bowlv{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 14px;white-space:nowrap;overflow:visible}
.stat.main .bowlv>span:first-child{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis}
.stat.main .bowlcoins{font-size:22px;display:inline-flex;align-items:center;gap:5px}
.stat.main .bowlcoins .px-coin{width:18px;height:18px;flex:none}
.stat.main .bowlcoins small{margin-left:0}
.stat .px-bowl{width:48px;height:auto;flex:none}
/* 表单 */
label{display:block;font-size:13px;color:var(--ink);font-weight:600;margin:14px 0 6px}
form>label:first-child,.grid2>div>label:first-child{margin-top:0}
input,textarea,select{width:100%;padding:10px 12px;border:3px solid var(--ink);background:#fff;color:var(--ink);font-size:16px;font-family:inherit;line-height:1.4;border-radius:0;box-shadow:inset 3px 3px 0 var(--paper-2)}
input::placeholder,textarea::placeholder{color:#8f8a7c}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--blue);box-shadow:3px 3px 0 var(--blue)}
textarea{resize:vertical;min-height:70px}
.hint{font-size:13px;color:var(--muted);margin-top:6px;line-height:1.6}
.err{color:var(--red);font-size:13px;margin-top:10px}
.ok{color:var(--green);font-size:13px}
.insert{text-align:center;font-size:12px;letter-spacing:.1em;color:var(--red);margin:-4px 0 12px;line-height:1.6}
.blink{animation:blinkc 1s steps(1) infinite}
.amounts{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
.amt-btn{display:flex;align-items:center;justify-content:center;gap:5px;padding:10px 4px;border:3px solid var(--ink);background:#fff;color:var(--ink);font-size:16px;white-space:nowrap;cursor:pointer;font-family:inherit;min-height:46px;box-shadow:3px 3px 0 var(--ink)}
.amt-btn .px-coin{width:16px;height:16px}
.amt-btn.on{background:var(--gold);transform:translate(2px,2px);box-shadow:1px 1px 0 var(--ink)}
.count{float:right;font-size:12px;color:var(--muted-2)}
.btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;min-height:50px;border:4px solid transparent;border-image:var(--frame) 2 / 4px stretch;image-rendering:pixelated;background-clip:padding-box;padding:10px 16px;font-size:18px;cursor:pointer;background-color:var(--gold);color:var(--ink);text-decoration:none;box-shadow:4px 4px 0 var(--ink)}
.btn:active{transform:translate(3px,3px);box-shadow:1px 1px 0 var(--ink)}
.btn.sec{background-color:#fff}
.btn.ghost{background-color:transparent;box-shadow:none;color:var(--muted)}
.btn.danger{background-color:#fff;color:var(--red)}
.btn.sm{display:inline-flex;width:auto;min-height:40px;padding:6px 12px;font-size:14px;box-shadow:3px 3px 0 var(--ink)}
.btns{display:flex;gap:12px;flex-wrap:wrap;margin-top:14px}
.btns .btn{flex:1 1 auto;white-space:nowrap}
/* Tab */
.tabs{display:grid;grid-template-columns:repeat(3,1fr);border:3px solid var(--ink);margin-bottom:14px;background:#fff;box-shadow:3px 3px 0 var(--ink)}
.tab{border:0;border-right:3px solid var(--ink);background:transparent;color:var(--muted);font-size:16px;padding:10px 4px;cursor:pointer;white-space:nowrap}
.tab:last-child{border-right:0}
.tab.on{background:var(--gold);color:var(--ink)}
.panel{display:none}.panel.on{display:block}
/* 榜单 / 功德簿 */
.list{list-style:none;margin:0;padding:0}
.item{display:flex;align-items:center;gap:10px;padding:10px 0;border-top:3px dotted var(--line)}
.item:first-child{border-top:0;padding-top:2px}
.av{width:36px;height:36px;flex:none;display:grid;place-items:center;font-size:16px;background:hsl(var(--h) 75% 86%);color:hsl(var(--h) 55% 28%);border:3px solid var(--ink);box-shadow:2px 2px 0 var(--ink)}
.av.sq{background:#fff;font-size:20px;color:var(--ink)}
.rank{width:30px;flex:none;text-align:center;font-size:16px;color:var(--muted)}
.rank.m{font-size:20px}
.body{flex:1;min-width:0}
.name{display:flex;align-items:center;gap:6px;min-width:0;font-size:16px}
.name>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.name a{color:inherit;text-decoration:none}
.sub{font-size:13px;color:var(--muted);margin-top:1px}
.amtv{white-space:nowrap;font-size:18px}
.feed .item{flex-wrap:wrap}
.bubble{flex-basis:100%;margin:8px 0 0 46px;background:#fff;border:3px solid var(--ink);padding:6px 10px;font-size:14px;line-height:1.6;word-break:break-word;position:relative;box-shadow:2px 2px 0 var(--ink)}
.bubble::before{content:"";position:absolute;left:-9px;top:8px;border:3px solid transparent;border-right-color:var(--ink);border-width:4px 6px 4px 0}
.bubble.blocked{color:var(--muted-2);letter-spacing:2px}
.time{font-size:12px;color:var(--muted);white-space:nowrap}
.me{font-size:10px;color:var(--ink);background:var(--gold);border:2px solid var(--ink);padding:0 5px;flex:none}
.empty{text-align:center;color:var(--muted);padding:24px 12px;font-size:14px}
.pager{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:12px;font-family:var(--pf);font-size:14px}
.pager a,.pager span.cur,.pager span.dis{display:inline-flex;align-items:center;justify-content:center;min-width:36px;min-height:36px;padding:0 10px;border:3px solid var(--ink);background:#fff;color:var(--ink);text-decoration:none;box-shadow:2px 2px 0 var(--ink)}
.pager span.cur{background:var(--gold)}.pager span.dis{opacity:.4;box-shadow:none}
.empty .ico{font-size:32px;margin-bottom:6px}
.empty .sprite{width:72px}
.cta{text-align:center}
.cta p{color:var(--ink);margin:0 0 12px;font-size:14px}
.fab{position:fixed;left:16px;right:16px;bottom:calc(16px + env(safe-area-inset-bottom));z-index:20;display:none}
.fab.show{display:block}
@media(min-width:900px){.fab{display:none!important}}
.notice{background:var(--panel-2);border:3px solid var(--ink);padding:10px 12px;font-size:14px;line-height:1.6;box-shadow:3px 3px 0 var(--ink)}
.notice.ok{background:#dff7e8}
.notice.bad{background:#ffe1e1}
.code{background:#fff;border:2px solid var(--ink);padding:1px 6px;font-size:13px;word-break:break-all}
.copy{cursor:pointer;border:3px solid var(--ink);background:#fff;color:var(--ink);padding:6px 10px;min-height:36px;font-size:12px;flex:none;box-shadow:2px 2px 0 var(--ink)}
.kv{display:grid;grid-template-columns:auto 1fr;gap:10px 14px;font-size:14px;align-items:center}
.kv .k{color:var(--muted)}
.kv .v{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.qr{display:block;margin:8px auto;background:#fff;padding:8px;width:240px;height:240px;border:3px solid var(--ink);box-shadow:3px 3px 0 var(--ink);image-rendering:pixelated}
.steps{counter-reset:s;display:grid;gap:18px}
.step{position:relative;padding-left:44px}
.step::before{counter-increment:s;content:counter(s);position:absolute;left:0;top:-2px;width:30px;height:30px;background:var(--gold);color:var(--ink);border:3px solid var(--ink);font-size:14px;display:grid;place-items:center;box-shadow:2px 2px 0 var(--ink)}
.step h2{margin-bottom:8px}
.big{font-size:36px;line-height:1.2}
.big small{font-size:12px;color:var(--muted);margin-left:6px}
.spin{width:44px;height:44px;border:4px solid var(--line);border-top-color:var(--ink);margin:12px auto 16px;animation:spin 1s steps(8) infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.center{text-align:center}
.tag{display:inline-block;padding:0 6px;font-size:12px;line-height:1.6;border:2px solid var(--ink);color:var(--ink);white-space:nowrap;background:#fff}
.tag.paid{background:#dff7e8}.tag.claimed{background:var(--panel-2)}.tag.bad{background:#ffe1e1;color:var(--red);border-color:var(--red)}
.grid2{display:grid;gap:0 14px}@media(min-width:600px){.grid2{grid-template-columns:1fr 1fr}}
.emojis{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.emojis button{font-size:20px;background:#fff;border:3px solid var(--ink);padding:3px 7px;cursor:pointer;line-height:1.3;box-shadow:2px 2px 0 var(--ink)}
.anchors{display:flex;gap:10px;overflow-x:auto;padding:2px 2px 6px;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.anchors::-webkit-scrollbar{display:none}
.anchors a{flex:none;font-size:12px;line-height:1.8;color:var(--ink);text-decoration:none;border:3px solid var(--ink);padding:4px 10px;background:#fff;box-shadow:2px 2px 0 var(--ink)}
.actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:10px;flex-basis:100%;padding-left:46px}
.actions form,.inline form{display:inline}
.section-title{font-size:12px;color:var(--muted);letter-spacing:.1em;margin:22px 0 8px;line-height:1.6}
.xavatar{width:40px;height:40px;border:3px solid var(--ink);vertical-align:middle;margin-right:6px;image-rendering:auto;object-fit:cover;background:#fff}
.av.img{object-fit:cover;background:#fff}
.xh{font-size:12px;color:var(--muted);font-family:var(--pf);flex:none}
.badge{font-size:14px;flex:none;letter-spacing:-2px}
.coinrow{display:flex;align-items:center;gap:10px;margin-top:14px;flex-wrap:wrap}
.js .col-l>.donate-card{display:none}
#modalForm .donate-card{box-shadow:none;border:0;padding:0;background:transparent}
#modalForm .donate-card h2{padding-right:44px}
.coinrow .coins b{color:var(--ink);font-size:16px}
.coinbtn .px-coin{width:16px;height:16px}
.coinbtn:disabled{opacity:.6;cursor:default}
.flycoin{position:fixed;z-index:50;font-size:28px;animation:fly .9s ease-out forwards;pointer-events:none}
@keyframes fly{0%{transform:translate(-50%,0) scale(1);opacity:1}60%{transform:translate(-50%,-90px) scale(1.3);opacity:1}100%{transform:translate(-50%,-140px) scale(.6);opacity:0}}
.hdr{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.hdr .body{flex:1 1 220px;min-width:0}
.hdr-actions{display:flex;gap:8px;margin-left:auto;flex:none}.hdr-actions form{display:contents}
@media(max-width:599px){.hdr-actions{flex-basis:100%;margin-left:0}.hdr-actions .btn{flex:1}}
.toast{position:fixed;left:50%;bottom:calc(84px + env(safe-area-inset-bottom));transform:translateX(-50%);background:var(--ink);color:var(--gold);padding:8px 14px;font-family:var(--pf);font-size:14px;z-index:60;box-shadow:3px 3px 0 rgba(0,0,0,.25)}
.tabs.four{grid-template-columns:repeat(4,1fr)}
.tabs.four .tab{font-size:14px;padding:10px 2px}
.tab.on::before{content:"▶";font-size:8px;margin-right:3px}
.toggle{display:inline-flex;border:3px solid var(--ink);margin-bottom:10px;background:#fff}
.toggle button{border:0;background:transparent;font-family:var(--pf);font-size:12px;padding:8px 12px;min-height:36px;cursor:pointer;color:var(--muted)}
.toggle button.on{background:var(--ink);color:var(--gold)}
.bubble.reply{background:var(--panel-2);margin-top:6px}
.profrow{display:flex;align-items:center;gap:12px;margin:12px 0}
.profrow .xavatar{width:56px;height:56px;margin:0}
.codebox{display:flex;align-items:center;gap:10px;margin:8px 0 12px}
.codebox .code.big{font-size:24px;padding:6px 12px;font-family:var(--pf)}
.sharebox{margin-top:14px}
.sharebox img{display:block;max-width:100%;border:3px solid var(--ink);box-shadow:4px 4px 0 var(--ink)}
.modal{position:fixed;inset:0;background:rgba(30,30,46,.6);z-index:100;display:flex;align-items:flex-end;justify-content:center}
.modal[hidden]{display:none}
.modal-box{position:relative;width:100%;max-height:92vh;overflow:auto;background:var(--panel);padding:18px 16px calc(18px + env(safe-area-inset-bottom));border-top:4px solid var(--ink);box-shadow:0 -6px 0 var(--ink)}
@media(min-width:600px){.modal{align-items:center;padding:20px}.modal-box{width:520px;max-height:90vh;border:4px solid var(--ink);box-shadow:6px 6px 0 var(--ink)}}
.modal-x{position:absolute;right:10px;top:8px;width:36px;height:36px;border:3px solid var(--ink);background:#fff;font-family:var(--pf);font-size:16px;cursor:pointer;box-shadow:2px 2px 0 var(--ink);z-index:1}
.spin-inline{display:inline-block;width:14px;height:14px;border:3px solid var(--line);border-top-color:var(--ink);border-radius:50%;animation:spin 1s steps(8) infinite;vertical-align:-2px;margin-right:4px}
.paypanel .qr{width:200px;height:200px}
.paypanel .big{font-size:32px}
footer{color:var(--muted);font-size:13px;text-align:center;margin-top:32px;line-height:1.9}
footer a{color:var(--muted)}
</style>
</head>
<body>
{{if .HasBGM}}<audio id="bgm" src="/static/bgm.m4a" loop preload="none" playsinline></audio>{{end}}
<div class="page">
<nav class="nav">
<a class="brand" href="/">{{template "coin"}}<span>{{.SiteTitle}}</span></a>
<span class="sp"></span>
<button class="sfx" id="sfxBtn" type="button" aria-label="声音开关" title="声音">🔊</button>
<a class="link navrank" href="/rank" aria-label="排行榜" title="排行榜"><span>排行榜</span></a>
{{if .Me}}<a class="link" href="/manage">我的后台</a>{{else}}<a class="link" href="/login"><span class="hide-sm">站长</span>登录</a>{{end}}
{{if .Me}}<a class="pill px" href="/{{.Me.Slug}}">我的站</a>{{else if .SubsitesEnabled}}<a class="pill px" href="/new">开通要饭站</a>{{end}}
</nav>
<div class="ticker" aria-hidden="true"><div class="ticker-in"><span>🙏 行行好,赏口饭吃</span><span>🪙 一毛也是爱</span><span>💸 投币就上榜</span><span>📜 留一句话刻功德簿</span><span>🏚 发条推就能开自己的要饭站</span><span>👾 赛博丐帮,人人有碗</span><span>🙏 行行好,赏口饭吃</span><span>🪙 一毛也是爱</span><span>💸 投币就上榜</span><span>📜 留一句话刻功德簿</span><span>🏚 发条推就能开自己的要饭站</span><span>👾 赛博丐帮,人人有碗</span></div></div>
{{template "content" .}}
<footer>
{{.SiteTitle}} · 赛博丐帮,行行好<br>
{{if or .AuthorX .AuthorGitHub}}作者{{if .AuthorX}} <a href="{{.AuthorX}}" target="_blank" rel="noopener">𝕏 @{{lastSeg .AuthorX}}</a>{{end}}{{if .AuthorGitHub}} · <a href="{{.AuthorGitHub}}" target="_blank" rel="noopener">GitHub {{lastSeg .AuthorGitHub}}</a>{{end}}<br>{{end}}
收款由 <a href="{{.RepoURL}}" target="_blank" rel="noopener">BinancePayTool</a> 驱动 · 字体 <a href="https://github.com/TakWolf/fusion-pixel-font" target="_blank" rel="noopener">缝合怪像素字体</a>
</footer>
</div>
<script>
// 声音:背景音乐(/static/bgm.m4a 循环)+ 8-bit 音效(Web Audio 现场合成,不加载文件)。一个开关管全部,记 localStorage(sfx),默认开。
// 浏览器不让页面一打开就出声:先尝试自动播放,被拦就等用户第一次触屏/点击/按键再起。音效首次出声也必须在用户手势里创建 AudioContext(iOS 限制)。
// 背景音乐也解码进同一个 AudioContext 里循环播放(AudioBufferSourceNode,无缝循环):和 <audio> 元素分开走的话,iOS 上音效一起音频会话就会把音乐打断重来。
// 解码不了(极少数浏览器没有 AAC 解码器)才退回 <audio> 元素。
var BGM_VOL=0.45;
window.sfx=(function(){var ctx,on=true,C=window.AudioContext||window.webkitAudioContext,buf=null,src=null,el=null,bytes=null,elMode=false,loading=false,unlocked=false;try{on=localStorage.getItem('sfx')!=='0'}catch(e){}
function ac(){if(!C)return null;if(!ctx){try{ctx=new C()}catch(e){return null}}if(ctx.state==='suspended'){try{ctx.resume()}catch(e){}}return ctx}
// 在用户手势里解锁:resume + 放一个空 buffer(老 iOS 的解锁套路)
function unlock(){var c=ac();if(!c)return;try{var b=c.createBuffer(1,1,22050),s=c.createBufferSource();s.buffer=b;s.connect(c.destination);s.start(0);unlocked=true}catch(e){}}
var paint=function(){};
// 下载一次:字节既喂给 <audio>(手势里能立刻响)也喂给 decodeAudioData(解码完切到 Web Audio 无缝循环)
function loadBgm(){if(bytes||loading)return;el=el||document.getElementById('bgm');if(!el)return;loading=true;
fetch(el.getAttribute('src')).then(function(r){if(!r.ok)throw 0;return r.arrayBuffer()}).then(function(ab){bytes=ab;
try{el.src=URL.createObjectURL(new Blob([ab],{type:'audio/mp4'}))}catch(e){}
var c=C&&ac();if(!c){elMode=true;loading=false;startBgm();return}
return new Promise(function(res,rej){var p=c.decodeAudioData(ab.slice(0),res,rej);if(p&&p.then)p.then(res,rej)}).then(function(b){buf=b;loading=false;startBgm()})})
.catch(function(){loading=false;elMode=true;startBgm()})}
function elPlay(){if(!el||!el.paused)return;el.volume=BGM_VOL;var p=el.play();if(p&&p.then)p.then(paint,function(){})}
function startBgm(){if(!on)return;
if(elMode||!buf){ // 还没解码好(或解码不了):先用 <audio> 顶上,只有在手势里才播得出来
if(bytes)elPlay();return}
var c=ac();if(!c)return;
if(c.state!=='running'){var r=c.resume&&c.resume();if(r&&r.then)r.then(function(){if(c.state==='running'&&!src)startBgm();paint()},function(){});return}
if(src)return;
var off=0;if(el&&!el.paused){off=el.currentTime%buf.duration;el.pause()} // 从 <audio> 接力,不从头来
var s=c.createBufferSource(),g=c.createGain();s.buffer=buf;s.loop=true;g.gain.value=BGM_VOL;s.connect(g);g.connect(c.destination);s.start(0,off);src=s;paint()}
function stopBgm(){if(src){try{src.stop()}catch(e){}src=null}if(el)el.pause();paint()}
function playingNow(){if(src)return ctx&&ctx.state==='running';return !!(el&&!el.paused)}
function armBgm(){['pointerdown','touchend','click','keydown'].forEach(function(t){document.addEventListener(t,function(){if(!on)return;unlock();loadBgm();startBgm()},{passive:true})})}
function tone(f,t0,dur,type,vol){var c=ac();if(!c)return;var o=c.createOscillator(),g=c.createGain(),t=c.currentTime+t0;o.type=type||'square';o.frequency.setValueAtTime(f,t);
g.gain.setValueAtTime(0.0001,t);g.gain.exponentialRampToValueAtTime(vol||0.22,t+0.012);g.gain.exponentialRampToValueAtTime(0.0001,t+dur);o.connect(g);g.connect(c.destination);o.start(t);o.stop(t+dur+0.03)}
function play(seq){if(!on)return;try{seq.forEach(function(n){tone(n[0],n[1],n[2],n[3],n[4])})}catch(e){}}
var S={
warm:function(){if(on)ac()},
click:function(){play([[740,0,0.045,'square',0.12]])},
coin:function(){play([[988,0,0.07,'square',0.25],[1319,0.07,0.32,'square',0.25]])},
ok:function(){play([[523,0,0.09],[659,0.09,0.09],[784,0.18,0.09],[1047,0.27,0.38,'square',0.26]])},
fail:function(){play([[196,0,0.1,'sawtooth',0.16],[147,0.1,0.26,'sawtooth',0.16]])},
levelup:function(){play([[659,0,0.08],[784,0.08,0.08],[988,0.16,0.08],[1319,0.24,0.08],[1568,0.32,0.4,'square',0.26]])},
enabled:function(){return on},
playing:function(){return src?'webaudio':(el&&!el.paused?'element':'')},
toggle:function(){on=!on;try{localStorage.setItem('sfx',on?'1':'0')}catch(e){}if(on){S.coin();loadBgm();startBgm()}else{stopBgm()}paint();return on}};
document.addEventListener('DOMContentLoaded',function(){if(on)loadBgm();armBgm();
var b=document.getElementById('sfxBtn');if(!b)return;
// 三态:🔊 在放 / 🔈 开着但还没放(浏览器拦了自动播放,点一下就放)/ 🔇 静音
var hasBgm=!!document.getElementById('bgm'); // 没放音乐文件时只有音效,喇叭只有开/关两态
paint=function(){var p=on&&(!hasBgm||playingNow());b.textContent=!on?'🔇':(p?'🔊':'🔈');b.classList.toggle('off',!on);b.classList.toggle('idle',on&&!p);b.title=!on?'声音:关':(p?'声音:开(点击静音)':'点一下开始播放')};paint();
b.addEventListener('click',function(){if(on&&!playingNow()){loadBgm();startBgm();return}S.toggle()})});
document.addEventListener('click',function(ev){var b=ev.target.closest('.btn,.tab,.amt-btn,.copy,.pill,.toggle button,.link');if(!b||b.disabled)return;ac();if(b.id!=='coinbtn')S.click()},true);
return S})();
(function(){
// 复制按钮(全站委托)
document.addEventListener('click',function(ev){var b=ev.target.closest('.copy');if(!b||b.id==='nextText')return;var src=b.dataset.copyFrom;var t=src?document.getElementById(src).value:b.dataset.copy;var o=b.textContent;
function done(){b.textContent='已复制';setTimeout(function(){b.textContent=o},1500)}
(navigator.clipboard?navigator.clipboard.writeText(t):Promise.reject()).then(done,function(){var i=document.createElement('input');i.value=t;document.body.appendChild(i);i.select();try{document.execCommand('copy')}catch(e){}i.remove();done()})});
// 付款面板:轮询到账、回填/自报、成功态
window.initPayPanel=function(pp,onDone){
var code=pp.dataset.code,site=pp.dataset.site,amount=pp.dataset.amount,status=pp.dataset.status,stop=false,fails=0;
function success(amt){stop=true;if(window.sfx)sfx.ok();var sp=document.querySelector('.sprite');pp.innerHTML='<div class="center"><div class="sprite-wrap jump" style="margin:0 auto 8px">'+(sp?sp.outerHTML:'🥣')+'</div><div class="insert">★ THANK YOU ★</div><h2 class="center" style="justify-content:center">🙏 功德 +'+(amt||amount)+'</h2><p class="hint">你的名号和留言已刻上功德簿</p><div class="btns"><a class="btn" href="'+site+'#feed">看看功德簿</a><a class="btn sec" href="/d/'+code+'">分享卡片</a></div></div>';if(onDone)onDone('paid')}
function setStatus(html){var st=pp.querySelector('#ppStatus');if(st)st.innerHTML=html}
function apply(j){if(!j||!j.status)return;if(j.status==='paid'){success(j.amount)}else if(j.status==='claimed'&&status!=='claimed'){status='claimed';setStatus('<div class="notice" style="margin-top:12px"><span class="spin-inline"></span> 已提交,等站长确认后上榜。本页会自动更新。</div>')}else if(['expired','failed','rejected','closed'].indexOf(j.status)>=0){stop=true;setStatus('<div class="notice bad" style="margin-top:12px">这笔施舍已'+({expired:'过期',failed:'下单失败',rejected:'被站长拒绝',closed:'关闭'})[j.status]+',请回到页面重新投币。</div>')}}
function tick(){if(stop)return;fetch('/d/'+code+'/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(j){fails=0;apply(j)}).catch(function(){if(++fails>=20)stop=true})}
var timer=setInterval(function(){if(stop){clearInterval(timer);return}tick()},3000);
pp.addEventListener('submit',function(ev){var f=ev.target.closest('.claimf');if(!f)return;ev.preventDefault();var fd=new FormData(f);fd.append('ajax','1');var btn=f.querySelector('button');btn.disabled=true;var err=f.querySelector('.claim-err');err.textContent='';
fetch(f.getAttribute('action'),{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(j){btn.disabled=false;if(j.status==='paid'){success()}else if(j.ok&&j.status==='claimed'){apply({status:'claimed'})}else{if(window.sfx)sfx.fail();err.textContent=j.msg||'没成功,再试试'}}).catch(function(){btn.disabled=false;if(window.sfx)sfx.fail();err.textContent='网络开小差了'})});
return function(){stop=true;clearInterval(timer)}
};
var pp=document.getElementById('paypanel');if(pp)window.initPayPanel(pp);
})();
</script>
{{block "scripts" .}}{{end}}
</body>
</html>{{end}}
+18
View File
@@ -0,0 +1,18 @@
{{define "title"}}站长登录 · {{.SiteTitle}}{{end}}
{{define "content"}}
<div class="narrow">
<section class="card px">
<h1>🔑 站长登录</h1>
<p class="hint" style="font-size:14px;margin:0 0 6px">用「X 用户名 + 开站时设的口令」登录后台。主站站长用户名留空。</p>
<form method="post" action="/login">
<label for="slugIn">X 用户名</label>
<input id="slugIn" name="slug" placeholder="例如 qianyuwing(主站留空)" value="{{.Slug}}" autocapitalize="off" autocomplete="username">
<label for="pwIn">口令</label>
<input id="pwIn" name="password" type="password" required autocomplete="current-password">
{{if .Err}}<div class="err">{{.Err}}</div>{{end}}
<button class="btn" type="submit" style="margin-top:16px">登录</button>
</form>
<div class="hint" style="margin-top:12px">忘了口令?<a href="/reset">用 X 发推找回</a>。还没有要饭站?<a href="/new">用 X 开一个</a>。</div>
</section>
</div>
{{end}}
+184
View File
@@ -0,0 +1,184 @@
{{define "title"}}后台 · {{.Site.Name}} · {{.SiteTitle}}{{end}}
{{define "content"}}
<div class="narrow" style="max-width:720px">
{{if .Welcome}}<div class="notice ok" style="margin-bottom:12px">🎉 开张大吉!你的要饭站 <a href="{{.Site.Path}}">{{.BaseURL}}{{.Site.Path}}</a> 已上线{{if not .Site.Listed}}(待主站站长收录后进榜){{end}}。<b>下一步:</b>在下面「收款方式」里绑定币安 Key 或填收款码,不然施主没法给你钱。以后从 <a href="{{.LoginURL}}">站长登录</a> 进来(X 用户名 {{.Site.Slug}} + 你的口令),忘了口令可以再发推找回。</div>{{end}}
{{if .Flash}}<div class="notice ok" style="margin-bottom:12px">{{.Flash}}</div><div class="toast" id="flashToast" aria-hidden="true">✅ {{.Flash}}</div>{{end}}
{{if .Err}}<div class="notice bad" style="margin-bottom:12px">{{.Err}}</div>{{end}}
<section class="card px" style="margin-bottom:12px">
<div class="hdr">
{{if .Site.AvatarURL}}<img class="av img" style="width:48px;height:48px" src="{{.Site.AvatarURL}}" alt="">{{else}}<div class="av sq" style="width:48px;height:48px;font-size:26px">{{.Site.Avatar}}</div>{{end}}
<div class="body"><h1 style="font-size:20px;margin:0">{{.Site.Name}} <span class="hint" style="display:inline">后台</span></h1>
<div class="hint"><a href="{{.Site.Path}}">{{.BaseURL}}{{.Site.Path}}</a> · 碗里 <b class="num accent">{{fmtAmt .Stats.TotalE8}}</b> {{.Site.Currency}} · {{.Stats.Donors}} 位施主 · {{.Stats.Count}} 次 · 钢镚 {{.Coins}}{{if not .Site.Listed}} · <span class="tag claimed">待收录</span>{{end}}</div></div>
<div class="hdr-actions">{{if not .Site.IsMain}}<button class="btn sec sm" type="button" id="pwBtn">🔑 改口令</button>{{end}}<form method="post" action="/logout"><button class="btn ghost sm" type="submit">退出</button></form></div>
</div>
<div class="anchors" style="margin-top:14px">
{{if .Claims}}<a href="#claims">⏳ 待确认 {{len .Claims}}</a>{{end}}
<a href="#profile">✏️ 资料</a><a href="#payment">💰 收款方式</a><a href="#donations">📜 施舍记录</a>
{{if .Site.IsMain}}<a href="#sites">🏚 所有站点</a><a href="#messages">🧹 留言巡查</a>{{end}}
</div>
</section>
{{if .Claims}}
<section class="card px" id="claims" style="margin-bottom:12px">
<h2>⏳ 待确认的施舍 <span class="tag claimed">{{len .Claims}}</span></h2>
<div class="hint" style="margin:-6px 0 8px">打开币安 App 核对到账(看金额和备注码),确认后上榜。</div>
<ul class="list feed">
{{range .Claims}}<li class="item">
<div class="av" style="--h:{{hue .NickKey}}">{{initial .Nickname}}</div>
<div class="body"><div class="name"><span>{{.Nickname}}</span></div><div class="sub">备注码 <span class="code">{{.NoteCode}}</span>{{if .BinanceOrderID}} · 币安订单 <span class="code">{{.BinanceOrderID}}</span>{{end}} · {{ago .CreatedAt}}</div></div>
<div class="amtv num">{{.Amount}} {{.Currency}}</div>
{{if .Message}}<div class="bubble">{{.Message}}</div>{{end}}
<div class="actions">
<form method="post" action="/manage/donation/{{.ID}}/confirm"><button class="btn sm">✓ 确认到账</button></form>
<form method="post" action="/manage/donation/{{.ID}}/reject"><button class="btn sm danger">✗ 没收到</button></form>
</div>
</li>{{end}}
</ul>
</section>
{{end}}
<section class="card px" id="profile" style="margin-bottom:12px">
<h2>✏️ 资料</h2>
<form method="post" action="/manage/profile">
<div class="grid2">
<div>{{$lock := and .Site.XHandle (not .Site.IsMain)}}<label for="nameIn">站点标题{{if $lock}} <span class="hint" style="display:inline">(来自 X @{{.Site.XHandle}},锁定)</span>{{end}}</label><input id="nameIn" name="name" maxlength="20" value="{{.Site.Name}}" {{if $lock}}readonly{{else}}required{{end}}></div>
{{if not .Site.AvatarURL}}<div><label for="avatarIn">头像 emoji</label><input id="avatarIn" name="avatar" maxlength="12" value="{{.Site.Avatar}}"></div>{{end}}
</div>
<label for="sloganIn">一句话口号</label><input id="sloganIn" name="slogan" maxlength="60" value="{{.Site.Slogan}}">
{{if not .Site.XHandle}}<label>绑定 X <span class="hint" style="display:inline">(主页链接或 @名字,留空不显示)</span></label><input name="x_url" maxlength="100" value="{{.Site.XURL}}" placeholder="https://x.com/你的名字">{{end}}
<button class="btn sec" type="submit" style="margin-top:14px">保存资料</button>
</form>
</section>
<section class="card px" id="payment" style="margin-bottom:12px">
<h2>💰 收款方式</h2>
{{if .Site.IsMain}}
<div class="hint">主站的收款方式在服务器 config.env 里配置。当前:{{if eq .Site.PayMode "gateway"}}BinancePayTool 网关自动确认(默认账号){{else}}直接转账{{if .Site.PayID}},Pay ID {{.Site.PayID}}{{end}}{{end}}。</div>
{{if eq .Site.PayMode "gateway"}}<form method="post" action="/manage/verify" style="margin-top:10px"><button class="btn sec sm" type="submit">校验网关默认账号</button></form>{{end}}
{{else}}
{{if eq .Site.PayMode "gateway"}}
<div class="notice ok">✅ 已绑定币安账号(UID {{.Site.BinanceUID}}),施主付款后<b>自动确认上榜</b>。{{if .Site.ReceiveLink}}收银页会展示你的收款码 / 一键唤起。{{else}}建议补一个收款链接,让施主能扫码 / 一键唤起。{{end}}</div>
<div class="btns">
<form method="post" action="/manage/verify" style="flex:1"><button class="btn sec" type="submit">重新校验 Key</button></form>
<form method="post" action="/manage/unbind" style="flex:1" onsubmit="return confirm('解绑后改为直接转账(需你手动确认到账),确定?')"><button class="btn danger" type="submit">解绑</button></form>
</div>
<details style="margin-top:12px"><summary class="hint" style="cursor:pointer">换一个 Key / 更新收款链接</summary>
{{template "bindform" .}}
</details>
{{else}}
{{if .HasGateway}}
<div class="notice" style="margin-bottom:12px">{{if .Site.PaymentReady}}当前是<b>直接转账</b>模式:施主转完自报,你来确认。{{else}}⚠️ 还没配置收款方式,施主现在没法给你钱。{{end}}推荐绑定币安<b>只读</b> API Key:到账自动确认、自动上榜,不用你盯着。</div>
<h2 style="font-size:15px">方式一:绑定币安 API Key(自动确认)</h2>
{{template "bindform" .}}
<h2 style="font-size:15px;margin-top:22px">方式二:直接转账(手动确认)</h2>
{{else}}
<div class="hint" style="margin-bottom:10px">{{if .Site.PaymentReady}}当前是直接转账模式:施主转完自报,你来确认。{{else}}⚠️ 还没配置收款方式,施主现在没法给你钱。{{end}}</div>
{{end}}
<form method="post" action="/manage/payment">
<label>币安收款链接 <span class="hint" style="display:inline">(收款二维码解码出的 https://app.binance.com/… 链接,施主可扫码 / 一键唤起)</span></label>
<input name="receive_link" maxlength="300" value="{{.Site.ReceiveLink}}" placeholder="https://app.binance.com/uni-qr/xxxx">
<label>币安 Pay ID / UID / 邮箱 <span class="hint" style="display:inline">(施主手动转账用;和收款链接至少填一项)</span></label>
<input name="pay_id" maxlength="60" value="{{.Site.PayID}}" placeholder="例如 UID 12345678">
<button class="btn sec" type="submit" style="margin-top:14px">保存直接转账设置</button>
</form>
{{end}}
{{end}}
</section>
<section class="card px" id="donations" style="margin-bottom:12px">
<h2>📜 施舍记录 <span class="sub">共 {{.DonPager.Total}} 条 · 广告可屏蔽留言或屏蔽此人(记录保留)· 可回复</span></h2>
{{if .Donations}}<ul class="list feed">
{{range .Donations}}<li class="item">
<div class="av" style="--h:{{hue .NickKey}}">{{if .Blocked}}✕{{else}}{{initial .Nickname}}{{end}}</div>
<div class="body"><div class="name"><span>{{.Nickname}}</span>{{if .XHandle}}<span class="xh">@{{.XHandle}}</span>{{end}}<span class="tag{{if eq .Status "paid"}} paid{{else if eq .Status "claimed"}} claimed{{else if ne .Status "pending"}} bad{{end}}">{{statusText .Status}}</span>{{if .Blocked}}<span class="tag bad">已屏蔽此人</span>{{end}}</div><div class="sub">{{ago .CreatedAt}}</div></div>
<div class="amtv num{{if eq .Status "paid"}} accent{{end}}">{{if eq .Status "paid"}}{{fmtAmt .ActualE8}}{{else}}{{.Amount}}{{end}}</div>
{{if .Message}}<div class="bubble{{if .MsgBlocked}} blocked{{end}}" style="letter-spacing:0">{{if .MsgBlocked}}*** <span class="hint" style="display:inline">(已屏蔽:{{.Message}})</span>{{else}}{{.Message}}{{end}}</div>{{end}}
{{if .Reply}}<div class="bubble reply">🥣 站长:{{.Reply}}</div>{{end}}
<div class="actions">
{{if .Message}}<form method="post" action="/manage/donation/{{.ID}}/{{if .MsgBlocked}}unblock{{else}}block{{end}}"><button class="btn sm{{if .MsgBlocked}} sec{{else}} danger{{end}}">{{if .MsgBlocked}}恢复留言{{else}}屏蔽留言{{end}}</button></form>{{end}}
<form method="post" action="/manage/donation/{{.ID}}/{{if .Blocked}}unblockuser{{else}}blockuser{{end}}"><button class="btn sm{{if .Blocked}} sec{{else}} danger{{end}}">{{if .Blocked}}恢复此人{{else}}屏蔽此人{{end}}</button></form>
{{if eq .Status "paid"}}<form method="post" action="/manage/donation/{{.ID}}/reply" class="replyf"><input name="reply" maxlength="80" placeholder="回一句…" value="{{.Reply}}"><button class="btn sm sec">回复</button></form>{{end}}
</div>
</li>{{end}}
</ul>{{template "pager" .DonPager}}{{else}}<div class="empty"><div class="ico">📜</div>还没有施舍记录</div>{{end}}
</section>
{{if not .Site.IsMain}}
<div class="modal" id="pwModal" hidden role="dialog" aria-modal="true" aria-labelledby="pwTitle"><div class="modal-box">
<button class="modal-x" type="button" id="pwX" aria-label="关闭">✕</button>
<h2 id="pwTitle">🔑 修改口令</h2>
{{if .PwErr}}<div class="notice bad" style="margin-bottom:10px">{{.Err}}</div>{{end}}
<form method="post" action="/manage/password">
<div class="grid2">
<div><label for="pw1">新口令(至少 6 位)</label><input id="pw1" name="password" type="password" minlength="6" maxlength="72" required autocomplete="new-password"></div>
<div><label for="pw2">再输一遍</label><input id="pw2" name="password2" type="password" minlength="6" maxlength="72" required autocomplete="new-password"></div>
</div>
<div class="hint">改完后其它设备上的登录会失效。</div>
<button class="btn" type="submit" style="margin-top:14px">确认修改</button>
</form>
</div></div>
{{end}}
{{if .Site.IsMain}}
<section class="card px" id="sites" style="margin-bottom:12px">
<h2>🏚 所有要饭站 <span class="sub">{{.SitePager.Total}} 个 · 违规可除名 · 可帮站长重置口令</span></h2>
{{if .Sites}}<ul class="list feed">
{{range .Sites}}<li class="item">
{{if .AvatarURL}}<img class="av img" src="{{.AvatarURL}}" alt="">{{else}}<div class="av sq">{{.Avatar}}</div>{{end}}
<div class="body"><div class="name"><span><a href="{{.Path}}">{{.Name}}</a></span>{{if .XHandle}}<span class="xh">@{{.XHandle}}</span>{{end}}<span class="hint" style="display:inline">/{{.Slug}}</span>{{if ne .Status "active"}}<span class="tag bad">已除名</span>{{end}}{{if not .Listed}}<span class="tag claimed">未收录</span>{{end}}</div><div class="sub">{{if .Title}}{{.Title}} · {{end}}{{if eq .PayMode "gateway"}}已绑 Key 自动确认{{else}}直接转账{{end}} · {{.Donors}} 位施主 · 钢镚 {{.Coins}} · {{ago .CreatedAt}}开张</div></div>
<div class="amtv num">{{fmtAmt .TotalE8}}</div>
<div class="actions">
<form method="post" action="/manage/site/{{.ID}}/{{if .Listed}}unlist{{else}}list{{end}}"><button class="btn sm{{if .Listed}} ghost{{end}}">{{if .Listed}}取消收录{{else}}收录上榜{{end}}</button></form>
<form method="post" action="/manage/site/{{.ID}}/{{if eq .Status "active"}}disable{{else}}enable{{end}}"><button class="btn sm{{if eq .Status "active"}} danger{{else}} sec{{end}}">{{if eq .Status "active"}}除名{{else}}恢复{{end}}</button></form>
<form method="post" action="/manage/site/{{.ID}}/resetpw" onsubmit="return confirm('重置 /{{.Slug}} 的口令?新口令只显示一次。')"><button class="btn sm ghost">重置口令</button></form>
</div>
</li>{{end}}
</ul>{{template "pager" .SitePager}}{{else}}<div class="empty"><div class="ico">🏚</div>还没有子站</div>{{end}}
</section>
<section class="card px" id="messages">
<h2>🧹 全站最新留言 <span class="sub">共 {{.MsgPager.Total}} 条 · 跨站点巡查广告</span></h2>
{{if .Messages}}<ul class="list feed">
{{range .Messages}}<li class="item">
<div class="av" style="--h:{{hue .NickKey}}">{{if .Blocked}}✕{{else}}{{initial .Nickname}}{{end}}</div>
<div class="body"><div class="name"><span>{{.Nickname}}</span>{{if .XHandle}}<span class="xh">@{{.XHandle}}</span>{{end}}<span class="hint" style="display:inline">→ {{.SiteName}}</span>{{if .Blocked}}<span class="tag bad">已屏蔽此人</span>{{end}}</div><div class="sub">{{ago .PaidAt}}</div></div>
<div class="amtv num">{{fmtAmt .ActualE8}}</div>
<div class="bubble{{if .MsgBlocked}} blocked{{end}}" style="letter-spacing:0">{{if .MsgBlocked}}*** <span class="hint" style="display:inline">(已屏蔽:{{.Message}})</span>{{else}}{{.Message}}{{end}}</div>
<div class="actions"><form method="post" action="/manage/donation/{{.ID}}/{{if .MsgBlocked}}unblock{{else}}block{{end}}"><button class="btn sm{{if .MsgBlocked}} sec{{else}} danger{{end}}">{{if .MsgBlocked}}恢复留言{{else}}屏蔽留言{{end}}</button></form>
<form method="post" action="/manage/donation/{{.ID}}/{{if .Blocked}}unblockuser{{else}}blockuser{{end}}"><button class="btn sm{{if .Blocked}} sec{{else}} danger{{end}}">{{if .Blocked}}恢复此人{{else}}屏蔽此人{{end}}</button></form></div>
</li>{{end}}
</ul>{{template "pager" .MsgPager}}{{else}}<div class="empty"><div class="ico">🧹</div>暂无留言</div>{{end}}
</section>
{{end}}
</div>
{{end}}
{{define "pager"}}{{if gt .Pages 1}}<nav class="pager" aria-label="分页">
{{if .HasPrev}}<a href="{{.URL .Prev}}">‹ 上一页</a>{{else}}<span class="dis">‹ 上一页</span>{{end}}
{{range .Nums}}{{if eq . $.Page}}<span class="cur">{{.}}</span>{{else}}<a href="{{$.URL .}}">{{.}}</a>{{end}}{{end}}
{{if .HasNext}}<a href="{{.URL .Next}}">下一页 ›</a>{{else}}<span class="dis">下一页 ›</span>{{end}}
<span class="hint" style="margin-left:auto">{{.Page}}/{{.Pages}} 页</span>
</nav>{{end}}{{end}}
{{define "bindform"}}
<form method="post" action="/manage/bind" autocomplete="off">
<div class="hint" style="margin-bottom:8px">币安 App → 账户 → API 管理 → 创建 API:<b>只勾「允许读取」</b>,其它权限都不要,<b>不用设 IP 白名单</b>(只读 Key 拿不走钱)。Key 只用来读取你的收款流水,服务端加密存储,永不展示。</div>
<div class="grid2">
<div><label>API Key</label><input name="api_key" maxlength="128" required autocomplete="off"></div>
<div><label>Secret Key</label><input name="api_secret" type="password" maxlength="128" required autocomplete="new-password"></div>
</div>
<div class="grid2">
<div><label>币安 UID <span class="hint" style="display:inline">(App 头像页可见)</span></label><input name="uid" inputmode="numeric" maxlength="20" value="{{.Site.BinanceUID}}" required></div>
<div><label>收款链接 <span class="hint" style="display:inline">(可选,收款码解码内容)</span></label><input name="receive_link" maxlength="300" value="{{.Site.ReceiveLink}}" placeholder="https://app.binance.com/uni-qr/xxxx"></div>
</div>
<button class="btn" type="submit" style="margin-top:14px">绑定并校验</button>
</form>
{{end}}
{{define "scripts"}}<script>
(function(){var t=document.getElementById('flashToast');if(!t)return;setTimeout(function(){t.style.transition='opacity .4s';t.style.opacity='0';setTimeout(function(){t.remove()},450)},3500)})();
(function(){var m=document.getElementById('pwModal'),b=document.getElementById('pwBtn'),x=document.getElementById('pwX');if(!m)return;
function open(){m.hidden=false;var i=document.getElementById('pw1');if(i)setTimeout(function(){i.focus()},50)}function close(){m.hidden=true}
if(b)b.addEventListener('click',open);if(x)x.addEventListener('click',close);
m.addEventListener('click',function(e){if(e.target===m)close()});document.addEventListener('keydown',function(e){if(e.key==='Escape'&&!m.hidden)close()});
if({{.PwErr}})open()})();
</script>{{end}}
+91
View File
@@ -0,0 +1,91 @@
{{define "title"}}{{if eq .Purpose "reset"}}用 X 找回口令{{else}}开通要饭站{{end}} · {{.SiteTitle}}{{end}}
{{define "content"}}
<div class="narrow">
{{if eq .Step 1}}
<section class="card px">
<h1>{{if eq .Purpose "reset"}}🔑 用 X 找回口令{{else}}🏚 开通我的要饭站{{end}}</h1>
<p class="hint" style="font-size:14px;margin:0 0 12px">{{if eq .Purpose "reset"}}发一条带验证码的推文证明你是站长本人,就能重设口令。{{else}}不用先施舍,用你的 X 账号发一条推就能开:站名用你的 X 昵称、头像用 X 头像,一个 X 账号一个站。发推顺便帮你把碗摆到街上。{{end}}</p>
{{if .Err}}<div class="notice bad" style="margin-bottom:12px">{{.Err}}</div>{{end}}
{{if .InApp}}<div class="notice" style="margin-bottom:12px">📱 你现在在 App 内置浏览器里,一键发推可能拉不起来、发完回来也可能丢状态。<b>建议点右上角「⋯」选「在浏览器中打开」</b>(或复制链接到 Safari / Chrome 打开)再操作。
<div class="btns" style="margin-top:8px"><button type="button" class="btn sec sm copy" data-copy="{{.BaseURL}}{{if eq .Purpose "reset"}}/reset{{else}}/new{{end}}">复制本页链接</button></div></div>{{end}}
<div class="steps">
<div class="step">
<h2>发一条推文,带上验证码 <span class="code">{{.Code}}</span></h2>
<label>推文内容(可以随便改,只要保留验证码)<span class="count"><button type="button" class="copy" id="nextText">换一条</button></span></label>
<textarea id="tweetText" rows="4" maxlength="280">{{index .Texts 0}}</textarea>
<div class="btns">
<a class="btn" id="intent" href="{{.IntentURL}}" target="_blank" rel="noopener">🐦 一键去 X 发推</a>
<button type="button" class="btn sec copy" data-copy-from="tweetText">复制文案</button>
</div>
<a class="hint" id="intentAlt" href="{{.IntentURL}}" target="_blank" rel="noopener" hidden style="display:block;margin-top:6px;text-decoration:underline">没拉起 X App?点这里用网页版发 →</a>
<div class="hint">验证码 <b>{{.Code}}</b> 大小写不限,24 小时内有效。文案里带着你的碗的链接,发出去顺便吆喝。</div>
</div>
<div class="step">
<h2>把推文链接贴回来</h2>
<form method="post" action="{{if eq .Purpose "reset"}}/reset/verify{{else}}/new/verify{{end}}">
<input type="hidden" name="code" value="{{.Code}}">
<label for="tweetUrl">推文链接</label>
<input id="tweetUrl" name="tweet_url" placeholder="https://x.com/你的名字/status/1234567890" value="{{.TweetURL}}" required autocapitalize="off">
<button class="btn" type="submit" style="margin-top:14px">验证</button>
</form>
<div class="hint">推文发出后,点推文右上角「分享 → 复制链接」即可。</div>
</div>
</div>
<div class="hint" style="margin-top:14px">{{if eq .Purpose "reset"}}还没开过站?<a href="/new">去开一个</a>{{else}}已经开过站?<a href="/login">站长登录</a>{{end}}</div>
</section>
{{else if eq .Purpose "reset"}}
<section class="card px">
<h1>🔑 设置新口令</h1>
<div class="profrow">{{if .AvatarURL}}<img class="xavatar" src="{{.AvatarURL}}" alt="">{{else}}<div class="av sq">🥣</div>{{end}}<div><b>{{.Site.Name}}</b><div class="hint" style="margin:0">@{{.Prof.Handle}} · {{.BaseURL}}{{.Site.Path}}</div></div></div>
{{if .Err}}<div class="notice bad" style="margin:12px 0">{{.Err}}</div>{{end}}
<form method="post" action="/reset">
<input type="hidden" name="code" value="{{.Code}}">
<div class="grid2">
<div><label for="pw1">新口令(至少 6 位)</label><input id="pw1" name="password" type="password" minlength="6" maxlength="72" required autocomplete="new-password"></div>
<div><label for="pw2">再输一遍</label><input id="pw2" name="password2" type="password" minlength="6" maxlength="72" required autocomplete="new-password"></div>
</div>
<button class="btn" type="submit" style="margin-top:16px">保存并登录</button>
</form>
</section>
{{else}}
<section class="card px">
<h1>🏚 开通我的要饭站</h1>
<div class="notice ok">✅ X 验证通过</div>
<div class="profrow">{{if .AvatarURL}}<img class="xavatar" src="{{.AvatarURL}}" alt="">{{else}}<div class="av sq">🥣</div>{{end}}<div><b>{{.Prof.Name}}</b><div class="hint" style="margin:0">@{{.Prof.Handle}} · 站名和头像来自 X(头像会定期同步)</div></div></div>
<form method="post" action="/new" id="nf">
<input type="hidden" name="code" value="{{.Code}}">
<label>站点地址 <span class="hint" style="display:inline">(就是你的 X 用户名,不用填)</span></label>
<div class="code" style="display:inline-block;margin-bottom:10px">{{.BaseURL}}/{{.F.Slug}}</div>
<label for="sloganIn">一句话口号</label>
<input id="sloganIn" name="slogan" maxlength="60" placeholder="行行好,赏口饭吃 🙏" value="{{.F.Slogan}}">
<div class="section-title">登录口令</div>
<div class="hint" style="margin-bottom:6px">以后用「X 用户名 + 口令」登录后台;忘了可以再发推找回。</div>
<div class="grid2">
<div><label for="pw1">口令(至少 6 位)</label><input id="pw1" name="password" type="password" minlength="6" maxlength="72" required autocomplete="new-password"></div>
<div><label for="pw2">再输一遍</label><input id="pw2" name="password2" type="password" minlength="6" maxlength="72" required autocomplete="new-password"></div>
</div>
{{if .Err}}<div class="err">{{.Err}}</div>{{end}}
<button class="btn" type="submit" style="margin-top:18px">开张 🏚</button>
<div class="hint" style="margin-top:10px">开通后在后台绑定币安 Key 或填收款码就能收钱。站点内容自负,广告站会被丐帮除名。</div>
</form>
</section>
{{end}}
</div>
{{end}}
{{define "scripts"}}<script>
var TEXTS={{.Texts}};
(function(){var ta=document.getElementById('tweetText'),link=document.getElementById('intent'),nx=document.getElementById('nextText');if(!ta||!link)return;var i=0;
var INAPP={{.InApp}},ua=navigator.userAgent,isAndroid=!INAPP&&/Android/i.test(ua),isIOS=!INAPP&&/iPhone|iPad|iPod/i.test(ua),alt=document.getElementById('intentAlt');
function web(){return 'https://x.com/intent/post?text='+encodeURIComponent(ta.value)}
function sync(){var w=web();if(alt)alt.href=w;
// Android:intent:// 直接拉起 X App 的编辑器,没装则由 Chrome 回退到网页版;其它平台:网页版 intent(新标签,本页保留)
link.href=isAndroid?'intent://post?message='+encodeURIComponent(ta.value)+'#Intent;scheme=twitter;package=com.twitter.android;S.browser_fallback_url='+encodeURIComponent(w)+';end':w}
ta.addEventListener('input',sync);sync();
// iOS:x.com 通用链接会在 X App 里套一层网页,改用 twitter://post 打开原生编辑器;本页不跳走,2 秒还没切到 App 就露出网页版备用链接
if(isIOS)link.addEventListener('click',function(e){e.preventDefault();var gone=false;
function onHide(){gone=true}document.addEventListener('visibilitychange',onHide,{once:true});window.addEventListener('pagehide',onHide,{once:true});
location.href='twitter://post?message='+encodeURIComponent(ta.value);
setTimeout(function(){if(!gone&&!document.hidden&&alt)alt.hidden=false},2000)});
if(nx)nx.addEventListener('click',function(){i=(i+1)%TEXTS.length;ta.value=TEXTS[i];sync()});
})();
</script>{{end}}
+10
View File
@@ -0,0 +1,10 @@
{{define "title"}}给 {{.Site.Name}} 施舍 {{.D.Amount}} {{.D.Currency}} · {{.SiteTitle}}{{end}}
{{define "content"}}
<div class="narrow">
<section class="card px">
{{if .Err}}<div class="notice bad" style="margin-bottom:12px">{{.Err}}</div>{{end}}
{{template "paypanel" .Panel}}
<div class="hint center" style="margin-top:12px"><a href="{{.Site.Path}}">先回去看看</a></div>
</section>
</div>
{{end}}
+47
View File
@@ -0,0 +1,47 @@
{{define "paypanel"}}<div class="paypanel" id="paypanel" data-code="{{.D.Code}}" data-status="{{.D.Status}}" data-mode="{{.Mode}}" data-site="{{.Site.Path}}" data-amount="{{.Amount}}">
<div class="center">
<div class="hint">给 {{.Site.Name}} 投币</div>
<div class="big num accent">{{.Amount}}<small>{{.D.Currency}}</small></div>
{{if eq .Mode "gateway"}}<div class="hint">金额要<b>一分不差</b>,网关靠这个唯一尾数自动认账,转完几秒内到账</div>{{else}}<div class="hint">用币安 Pay 直接转给站长,转完点下面「我已施舍」</div>{{end}}
</div>
{{if .QR}}
{{if .IsMobile}}
<a class="btn" href="{{.AppLink}}" style="margin-top:12px">打开币安 App 转账</a>
<div class="hint center">唤起后手动输入金额 <b class="num">{{.Amount}}</b>{{if .NoteCode}},备注填 <b class="num">{{.NoteCode}}</b>{{end}}</div>
<details style="margin-top:8px"><summary class="hint" style="cursor:pointer">用另一台手机扫码</summary><img class="qr" src="data:image/png;base64,{{.QR}}" alt="收款二维码"></details>
{{else}}
<img class="qr" src="data:image/png;base64,{{.QR}}" alt="收款二维码">
<div class="hint center">币安 App → 首页「扫一扫」→ 输入金额 <b class="num">{{.Amount}}</b>{{if .NoteCode}},备注填 <b class="num">{{.NoteCode}}</b>{{end}}</div>
{{end}}
<div class="section-title">或手动转账</div>
{{else}}<div class="section-title" style="margin-top:12px">转账信息</div>{{end}}
<div class="kv">
{{if .PayID}}<span class="k">收款方</span><span class="v"><span class="code">{{.PayID}}</span><button type="button" class="copy" data-copy="{{.PayID}}">复制</button></span>{{end}}
<span class="k">金额</span><span class="v"><span class="code">{{.Amount}} {{.D.Currency}}</span><button type="button" class="copy" data-copy="{{.Amount}}">复制</button></span>
{{if .NoteCode}}<span class="k">备注</span><span class="v"><span class="code">{{.NoteCode}}</span><button type="button" class="copy" data-copy="{{.NoteCode}}">复制</button></span>{{end}}
</div>
<div class="pp-status" id="ppStatus">
{{if eq .D.Status "claimed"}}
<div class="notice" style="margin-top:12px"><span class="spin-inline"></span> 已提交,等站长确认后上榜。本页会自动更新。</div>
{{else if eq .Mode "gateway"}}
<div class="notice" style="margin-top:12px"><span class="spin-inline"></span> 等待到账…转完自动确认,本页会自动更新</div>
{{if .Owned}}<details style="margin-top:10px"><summary class="hint" style="cursor:pointer">付了但一直没确认?</summary>
<form class="claimf" method="post" action="/d/{{.D.Code}}/claim">
<label>币安订单编号(18 位数字,转账详情里有)</label>
<input name="binance_order_id" inputmode="numeric" maxlength="18" placeholder="4520……" required>
<button class="btn sec" type="submit" style="margin-top:10px">我付过了,帮我核对</button>
<div class="err claim-err"></div>
</form></details>{{end}}
{{else if .Owned}}
<form class="claimf" method="post" action="/pay/{{.D.Code}}/claim" style="margin-top:12px">
<label>币安订单编号(可选,18 位数字,填了站长确认更快)</label>
<input name="binance_order_id" inputmode="numeric" maxlength="18" placeholder="4520……">
<button class="btn" type="submit" style="margin-top:10px">我已施舍,等站长确认</button>
<div class="err claim-err"></div>
</form>
{{else}}
<div class="hint" style="margin-top:12px">「我已施舍」要在发起施舍的那台设备上点。</div>
{{end}}
</div>
{{if .Fallback}}<div class="hint center" style="margin-top:12px">打不开?<a href="{{.Fallback}}" target="_blank" rel="noopener">用网关收银页支付</a></div>{{end}}
</div>{{end}}
+42
View File
@@ -0,0 +1,42 @@
{{define "title"}}排行榜 · {{.SiteTitle}}{{end}}
{{define "content"}}
<div class="layout cols2">
<section class="card px">
<h2>🏆 全站施主榜 <span class="sub">HIGH SCORE · 所有要饭站加起来</span></h2>
<div class="toggle" data-for="donors"><button type="button" class="on" data-w="all">总榜</button><button type="button" data-w="week">本周</button></div>
<div class="wk donors-all">{{template "gdonorlist" .Donors}}</div>
<div class="wk donors-week" hidden>{{template "gdonorlist" .DonorsWeek}}</div>
</section>
<div style="display:grid;gap:18px">
<section class="card px">
<h2>🥣 乞丐榜 <span class="sub">要到最多的要饭站</span></h2>
<div class="toggle" data-for="beggars"><button type="button" class="on" data-w="all">总榜</button><button type="button" data-w="week">本周</button></div>
<div class="wk beggars-all">{{template "gsitelist" (dict "Rows" .Sites "By" "amount" "Me" .MeID)}}</div>
<div class="wk beggars-week" hidden>{{template "gsitelist" (dict "Rows" .SitesWeek "By" "amount" "Me" .MeID)}}</div>
</section>
<section class="card px">
<h2>🪙 人气榜 <span class="sub">收到钢镚最多(免费投)</span></h2>
{{template "gsitelist" (dict "Rows" .Popular "By" "coins" "Me" .MeID)}}
</section>
</div>
</div>
{{end}}
{{define "gdonorlist"}}{{if .}}<ul class="list">
{{range $i, $r := .}}<li class="item">
<div class="rank{{if lt $i 3}} m{{end}}">{{rankBadge $i}}</div>
{{$h := handleOf $r.NickKey}}{{$f := xav $h}}{{if $f}}<img class="av img" src="/a/{{$f}}" alt="">{{else}}<div class="av" style="--h:{{hue $r.NickKey}}">{{initial $r.Nickname}}</div>{{end}}
<div class="body"><div class="name"><span>{{$r.Nickname}}</span>{{if $h}}<span class="xh" title="施主自己填的 X,未经验证">@{{$h}}</span>{{end}}</div><div class="sub">{{donorTier $r.TotalE8}} · {{$r.Count}} 次 · 施舍过 {{$r.Sites}} 个乞丐</div></div>
<div class="amtv num accent">{{fmtAmt $r.TotalE8}}</div>
</li>{{end}}
</ul>{{else}}<div class="empty"><div class="ico">👑</div>榜上无人</div>{{end}}{{end}}
{{define "gsitelist"}}{{if .Rows}}<ul class="list">
{{range $i, $s := .Rows}}<li class="item">
<div class="rank{{if lt $i 3}} m{{end}}">{{rankBadge $i}}</div>
{{if $s.AvatarURL}}<img class="av img" src="{{$s.AvatarURL}}" alt="">{{else}}<div class="av sq">{{$s.Avatar}}</div>{{end}}
<div class="body"><div class="name"><span><a href="{{$s.Path}}">{{$s.Name}}</a></span>{{if eq $s.ID $.Me}}<span class="me">就是我</span>{{end}}</div><div class="sub">{{if $s.Title}}{{$s.Title}} · {{end}}{{(level $s.Exp).Title}} · {{$s.Donors}} 位施主 · 钢镚 {{$s.Coins}}</div></div>
<div class="amtv num{{if ne $.By "coins"}} accent{{end}}">{{if eq $.By "coins"}}🪙 {{$s.Coins}}{{else}}{{fmtAmt $s.TotalE8}}{{end}}</div>
</li>{{end}}
</ul>{{else}}<div class="empty"><div class="ico">🏚</div>还没有人上榜</div>{{end}}{{end}}
{{define "scripts"}}<script>
document.querySelectorAll('.toggle').forEach(function(tg){var k=tg.dataset.for;tg.querySelectorAll('button').forEach(function(b){b.addEventListener('click',function(){tg.querySelectorAll('button').forEach(function(x){x.classList.toggle('on',x===b)});document.querySelector('.'+k+'-all').hidden=b.dataset.w!=='all';document.querySelector('.'+k+'-week').hidden=b.dataset.w!=='week'})})});
</script>{{end}}
+182
View File
@@ -0,0 +1,182 @@
{{define "title"}}{{.Site.Name}} 在要饭 · {{.SiteTitle}}{{end}}
{{define "content"}}
{{$lv := level .Exp}}
<div class="layout">
<aside class="col-l">
{{if not .Site.Listed}}<div class="notice">🕵️ 这个站还没被丐帮收录,暂不进榜。{{if and .Me (eq .Me.ID .Site.ID)}}主站站长审核后会收录。{{end}}</div>{{end}}
<section class="card px" id="topcard">
<div class="char-top">
<div class="sprite-wrap">{{template "sprite"}}</div>
<div class="char-info">
<div class="lvrow"><span class="lv">Lv.{{$lv.Lv}}<b>{{$lv.Title}}</b></span>{{if .SectTitle}}<span class="lv gold">{{.SectTitle}}{{if .Position}} · 榜 #{{.Position}}{{end}}</span>{{end}}</div>
<h1 class="pname">{{if .Site.AvatarURL}}<img class="xavatar" src="{{.Site.AvatarURL}}" alt="">{{else}}{{.Site.Avatar}} {{end}}{{.Site.Name}}</h1>
<div class="barlabel">EXP <b>{{.Exp}}</b>{{if $lv.Next}}<span>升{{$lv.NextT}}要 {{$lv.Next}}</span>{{else}}<span>满级</span>{{end}}</div>
<div class="bar xp"><span style="width:{{$lv.Pct}}%"></span></div>
<div class="barlabel">饱食度 <b>{{satiety .Stats.TodayE8}}%</b><span>{{if eq (satiety .Stats.TodayE8) 0}}今天还没吃饭{{else}}今天有人投币了{{end}}</span></div>
<div class="bar hp"><span style="width:{{satiety .Stats.TodayE8}}%"></span></div>
{{if .Site.XLabel}}<a class="xpill" href="https://x.com/{{.Site.XLabel}}" target="_blank" rel="noopener">𝕏 @{{.Site.XLabel}}</a>{{end}}
</div>
</div>
{{if .Site.Slogan}}<div class="dialog"><span class="typer" data-text="{{.Site.Slogan}}">{{.Site.Slogan}}</span><span class="cursor">▼</span></div>{{end}}
<div class="coinrow">
<button type="button" class="btn sec sm coinbtn" id="coinbtn" data-site="{{.Site.Slug}}" {{if ge .MyCoins .CoinsPerDay}}disabled{{end}}>{{template "coin"}}<span id="coinTxt">{{if ge .MyCoins .CoinsPerDay}}今天丢过了{{else}}丢个钢镚(免费){{end}}</span></button>
{{if .Ready}}<a class="btn sm" id="donateBtn" href="#donate">💸 投币施舍</a>{{else}}<span class="hint" style="margin:0">还没摆碗,暂不能投币{{if and .Me (eq .Me.ID .Site.ID)}},<a href="/manage#payment">去配置</a>{{end}}</span>{{end}}
</div>
<div class="coins hint" style="margin-top:8px">钢镚 <b class="pf coinN">{{.Coins}}</b> · 今日 <b id="coinT" class="pf">{{.CoinsToday}}</b> · 每 0.1 U = {{.MoneyExp | div10}} EXP · 每个钢镚 = {{.CoinExp}} EXP</div>
</section>
<section class="stats">
<div class="stat main px">{{template "bowlicon"}}<div style="min-width:0"><div class="k">碗里已有</div><div class="v num bowlv"><span>{{fmtAmt .Stats.TotalE8}}<small>{{.Site.Currency}}</small></span><span class="bowlcoins">{{template "coin"}}<b class="coinN">{{.Coins}}</b><small>钢镚</small></span></div></div></div>
<div class="stat px"><div class="k">施主</div><div class="v num">{{.Stats.Donors}}<small>人</small></div></div>
<div class="stat px"><div class="k">今日进账</div><div class="v num">+{{fmtAmt .Stats.TodayE8}}</div></div>
</section>
<section class="card px donate-card" id="donate">
<h2>🕹️ 投币施舍</h2>
{{if .Ready}}
<div class="insert blink">▶ INSERT COIN ◀</div>
<form method="post" action="/donate" id="f">
<input type="hidden" name="site" value="{{.Site.Slug}}">
<div class="grid2">
<div><label for="nick">玩家名(上榜用,可留空)</label><input id="nick" name="nickname" maxlength="20" placeholder="匿名施主" value="{{.Nick}}" autocomplete="nickname"></div>
<div><label for="xh">你的 X(可选,显示头像)</label><input id="xh" name="x_handle" maxlength="16" placeholder="@name" value="{{.XHandle}}" autocapitalize="off"></div>
</div>
<label>投多少 {{.Site.Currency}}</label>
<div class="amounts" id="amounts">
{{range .Presets}}<button type="button" class="amt-btn" data-v="{{.}}">{{template "coin"}}{{.}}</button>{{end}}
<button type="button" class="amt-btn" data-v="">✏️ 自定义</button>
</div>
<input name="amount" id="amount" inputmode="decimal" autocomplete="off" placeholder="输入金额,最多两位小数" value="{{.Amount}}" required style="margin-top:10px">
<label for="msg">对乞丐喊话 <span class="count" id="cnt">0/80</span></label>
<textarea id="msg" name="message" maxlength="80" rows="2" placeholder="例如:别买烟了,去吃饭">{{.Message}}</textarea>
{{if .Err}}<div class="err">{{.Err}}</div>{{end}}
<button class="btn" type="submit" id="cta" style="margin-top:16px">投币 💸</button>
<div class="hint" style="margin-top:10px">{{if eq .Site.PayMode "gateway"}}币安 App 扫码 / 一键唤起支付,到账后自动刻上功德簿。{{else}}通过币安 Pay 直接转给站长,提交后由站长确认上榜。{{end}}最少 {{.MinAmount}},最多 {{.MaxAmount}}。留言里不能放链接和联系方式。</div>
</form>
{{else}}
<div class="notice">这位乞丐还没摆碗(未配置收款方式),暂时收不了钱。{{if and .Me (eq .Me.ID .Site.ID)}}<a href="/manage#payment">去后台配置 →</a>{{end}}</div>
{{end}}
</section>
</aside>
<main class="col-r">
<section class="card px">
<div class="tabs four" role="tablist">
<button class="tab on" type="button" role="tab" id="t-popular" aria-controls="p-popular" aria-selected="true" data-t="popular">人气榜</button>
<button class="tab" type="button" role="tab" id="t-feed" aria-controls="p-feed" aria-selected="false" data-t="feed">功德簿</button>
<button class="tab" type="button" role="tab" id="t-donors" aria-controls="p-donors" aria-selected="false" data-t="donors">施主榜</button>
<button class="tab" type="button" role="tab" id="t-beggars" aria-controls="p-beggars" aria-selected="false" data-t="beggars">乞丐榜</button>
</div>
<div class="panel" id="p-feed" role="tabpanel" aria-labelledby="t-feed">
{{if .Feed}}<ul class="list feed">
{{range .Feed}}<li class="item">
{{template "avatar" .}}
<div class="body"><div class="name"><span>{{if .Blocked}}已屏蔽的施主{{else}}{{.Nickname}}{{end}}</span>{{if and .XHandle (not .Blocked)}}<span class="xh" title="施主自己填的 X,未经验证">@{{.XHandle}}</span>{{end}}</div><div class="sub">{{ago .PaidAt}} 投币</div></div>
<div class="amtv num accent">+{{fmtAmt .ActualE8}}</div>
{{if .Blocked}}{{else if .MsgBlocked}}<div class="bubble blocked">***</div>{{else if .Message}}<div class="bubble">{{.Message}}</div>{{end}}
{{if and .Reply (not .Blocked)}}<div class="bubble reply">🥣 站长:{{.Reply}}</div>{{end}}
</li>{{end}}
</ul>{{else}}<div class="empty"><div class="ico">📜</div>功德簿空空如也,第一枚硬币会刻在这里</div>{{end}}
</div>
<div class="panel" id="p-donors" role="tabpanel" aria-labelledby="t-donors">
<div class="toggle" data-for="donors"><button type="button" class="on" data-w="all">总榜</button><button type="button" data-w="week">本周</button></div>
<div class="hint" style="margin:-4px 0 8px">HIGH SCORE · 给 {{.Site.Name}} 投币最多的人 · 🏁 首位施主 💎 单笔最大 🔥 常客</div>
<div class="wk donors-all">{{template "donorlist" (dict "Rows" .Rank "Badges" .Badges)}}</div>
<div class="wk donors-week" hidden>{{template "donorlist" (dict "Rows" .RankWeek "Badges" .Badges)}}</div>
</div>
<div class="panel" id="p-beggars" role="tabpanel" aria-labelledby="t-beggars">
<div class="toggle" data-for="beggars"><button type="button" class="on" data-w="all">总榜</button><button type="button" data-w="week">本周</button></div>
<div class="hint" style="margin:-4px 0 8px">丐帮排名 · 要到最多的要饭站</div>
<div class="wk beggars-all">{{template "sitelist" (dict "Rows" .Subsites "Me" .MeID "Here" .Site.ID "By" "amount")}}</div>
<div class="wk beggars-week" hidden>{{template "sitelist" (dict "Rows" .SubsitesWeek "Me" .MeID "Here" .Site.ID "By" "amount")}}</div>
</div>
<div class="panel on" id="p-popular" role="tabpanel" aria-labelledby="t-popular">
<div class="hint" style="margin:-4px 0 8px">人气榜 · 收到钢镚最多的要饭站(钢镚免费,不用花钱)</div>
{{template "sitelist" (dict "Rows" .Popular "Me" .MeID "Here" .Site.ID "By" "coins")}}
</div>
</section>
{{if and .SubsitesEnabled (not .Me)}}
<section class="card px cta">
<h2 style="justify-content:center">🏚 想自己要饭?</h2>
<p>用 X 账号发一条推就能开自己的要饭站:独立地址、自己的碗、功德簿和施主榜,钱直接进你的币安账户。</p>
<a class="btn sec" href="/new">用 X 开通我的要饭站 →</a>
</section>
{{end}}
</main>
</div>
{{if .Ready}}<div class="fab" id="fab"><a class="btn" href="#donate" id="fabBtn">💸 投币施舍</a></div>{{end}}
<div id="toast" class="toast" hidden></div>
<div class="modal" id="modal" hidden role="dialog" aria-modal="true"><div class="modal-box"><button class="modal-x" type="button" id="modalX" aria-label="关闭">✕</button><div id="modalForm" hidden></div><div id="modalBody" hidden></div></div></div>
{{end}}
{{define "avatar"}}{{$f := xav .XHandle}}{{if and $f (not .Blocked)}}<img class="av img" src="/a/{{$f}}" alt="">{{else}}<div class="av" style="--h:{{hue .NickKey}}">{{if .Blocked}}✕{{else}}{{initial .Nickname}}{{end}}</div>{{end}}{{end}}
{{define "donorlist"}}{{if .Rows}}<ul class="list">
{{range $i, $r := .Rows}}<li class="item">
<div class="rank{{if lt $i 3}} m{{end}}">{{rankBadge $i}}</div>
{{$h := handleOf $r.NickKey}}{{$f := xav $h}}{{if $f}}<img class="av img" src="/a/{{$f}}" alt="">{{else}}<div class="av" style="--h:{{hue $r.NickKey}}">{{initial $r.Nickname}}</div>{{end}}
<div class="body"><div class="name"><span>{{$r.Nickname}}</span>{{if $h}}<span class="xh" title="施主自己填的 X,未经验证">@{{$h}}</span>{{end}}{{with index $.Badges $r.NickKey}}<span class="badge">{{.}}</span>{{end}}</div><div class="sub">{{donorTier $r.TotalE8}} · {{$r.Count}} 次</div></div>
<div class="amtv num accent">{{fmtAmt $r.TotalE8}}</div>
</li>{{end}}
</ul>{{else}}<div class="empty"><div class="ico">👑</div>榜上无人,第一个投币的就是首席施主</div>{{end}}{{end}}
{{define "sitelist"}}{{if .Rows}}<ul class="list">
{{range $i, $s := .Rows}}<li class="item">
<div class="rank{{if lt $i 3}} m{{end}}">{{rankBadge $i}}</div>
{{if $s.AvatarURL}}<img class="av img" src="{{$s.AvatarURL}}" alt="">{{else}}<div class="av sq">{{$s.Avatar}}</div>{{end}}
<div class="body"><div class="name"><span><a href="{{$s.Path}}">{{$s.Name}}</a></span>{{if eq $s.ID $.Me}}<span class="me">就是我</span>{{else if eq $s.ID $.Here}}<span class="me here">本站</span>{{end}}</div><div class="sub">{{if $s.Title}}{{$s.Title}} · {{end}}{{(level $s.Exp).Title}} · {{$s.Donors}} 位施主 · 钢镚 {{$s.Coins}}</div></div>
<div class="amtv num{{if ne $.By "coins"}} accent{{end}}">{{if eq $.By "coins"}}🪙 {{$s.Coins}}{{else}}{{fmtAmt $s.TotalE8}}{{end}}</div>
</li>{{end}}
</ul>{{else}}<div class="empty"><div class="ico">🏚</div>还没有人上榜</div>{{end}}{{end}}
{{define "scripts"}}<script>
(function(){
var tabs=document.querySelectorAll('.tab'),panels=document.querySelectorAll('.panel');
function show(t){tabs.forEach(function(x){var on=x.dataset.t===t;x.classList.toggle('on',on);x.setAttribute('aria-selected',on?'true':'false')});panels.forEach(function(p){p.classList.toggle('on',p.id==='p-'+t)})}
tabs.forEach(function(t){t.addEventListener('click',function(){show(t.dataset.t);try{history.replaceState(null,'','#'+t.dataset.t)}catch(e){}})});
var h=location.hash.replace('#','');if(['donors','beggars','feed','popular'].indexOf(h)>=0)show(h);
document.querySelectorAll('.toggle').forEach(function(tg){var k=tg.dataset.for;tg.querySelectorAll('button').forEach(function(b){b.addEventListener('click',function(){tg.querySelectorAll('button').forEach(function(x){x.classList.toggle('on',x===b)});document.querySelector('.'+k+'-all').hidden=b.dataset.w!=='all';document.querySelector('.'+k+'-week').hidden=b.dataset.w!=='week'})})});
var ty=document.querySelector('.typer');
if(ty&&!matchMedia('(prefers-reduced-motion: reduce)').matches){var full=ty.dataset.text,i=0;ty.textContent='';(function step(){ty.textContent=full.slice(0,++i);if(i<full.length)setTimeout(step,55)})()}
var toast=document.getElementById('toast'),tt;
function say(m){toast.textContent=m;toast.hidden=false;clearTimeout(tt);tt=setTimeout(function(){toast.hidden=true},2200)}
var cb=document.getElementById('coinbtn');
if(cb){cb.addEventListener('click',function(){cb.disabled=true;var fd=new FormData();fd.append('site',cb.dataset.site);
fetch('/coin',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(j){
if(j.ok&&j.added){document.querySelectorAll('.coinN').forEach(function(el){el.textContent=j.total});document.getElementById('coinT').textContent=j.today;
var c=document.createElement('div');c.className='flycoin';c.textContent='🪙';var r=cb.getBoundingClientRect();c.style.left=(r.left+r.width/2)+'px';c.style.top=r.top+'px';document.body.appendChild(c);setTimeout(function(){c.remove()},900);
var sw=document.querySelector('.char-top .sprite-wrap');if(sw){sw.classList.add('jump');setTimeout(function(){sw.classList.remove('jump')},900)}
if(window.sfx)sfx.coin();say('叮!钢镚 +1');if(j.mine>=j.per_day){document.getElementById('coinTxt').textContent='今天丢过了'}else{cb.disabled=false}}
else{if(window.sfx)sfx.fail();say(j.msg||'没丢进去');if(j.mine>=j.per_day){document.getElementById('coinTxt').textContent='今天丢过了'}else{cb.disabled=false}}
}).catch(function(){if(window.sfx)sfx.fail();say('网络开小差了');cb.disabled=false})})}
var f=document.getElementById('f');if(!f)return;
var btns=f.querySelectorAll('.amt-btn'),amt=document.getElementById('amount'),cta=document.getElementById('cta'),msg=document.getElementById('msg'),cnt=document.getElementById('cnt');
var cur={{.Site.Currency}};
function sel(b){btns.forEach(function(x){x.classList.remove('on')});if(b)b.classList.add('on')}
function custom(){return f.querySelector('.amt-btn[data-v=""]')}
function label(){cta.textContent=amt.value?'投币 '+amt.value+' '+cur+' 💸':'投币 💸'}
function sync(){var hit=null;btns.forEach(function(b){if(b.dataset.v&&b.dataset.v===amt.value)hit=b});sel(hit||(amt.value?custom():null));amt.style.display=(hit&&hit.dataset.v)?'none':'';label()}
btns.forEach(function(b){b.addEventListener('click',function(){if(b.dataset.v){amt.value=b.dataset.v;sync()}else{amt.value='';sel(b);amt.style.display='';amt.focus();label()}})});
amt.addEventListener('input',function(){var hit=null;btns.forEach(function(b){if(b.dataset.v&&b.dataset.v===amt.value)hit=b});sel(hit||custom());label()});
function count(){cnt.textContent=msg.value.length+'/80'}msg.addEventListener('input',count);count();
if(amt.value){sync()}else if(btns.length>1){btns[Math.min(1,btns.length-2)].click()}
// 投币:按钮打开弹窗(表单从页面移进弹窗),异步下单后同一弹窗切到付款面板,不离开本站
document.documentElement.classList.add('js');
var modal=document.getElementById('modal'),mform=document.getElementById('modalForm'),mbody=document.getElementById('modalBody'),donateSec=document.getElementById('donate'),stopPanel=null,paidDone=false;
function openDonate(ev){if(ev)ev.preventDefault();if(donateSec.parentNode!==mform)mform.appendChild(donateSec);mform.hidden=false;mbody.hidden=true;modal.hidden=false;var n=document.getElementById('nick');if(n&&!n.value&&!matchMedia('(max-width:899px)').matches)n.focus()}
function closeModal(){modal.hidden=true;if(stopPanel){stopPanel();stopPanel=null}if(paidDone){location.href=location.pathname+'#feed';location.reload()}}
var db=document.getElementById('donateBtn'),fb=document.getElementById('fabBtn');if(db)db.addEventListener('click',openDonate);if(fb)fb.addEventListener('click',openDonate);
if(location.hash==='#donate')openDonate();
document.getElementById('modalX').addEventListener('click',closeModal);
modal.addEventListener('click',function(ev){if(ev.target===modal)closeModal()});
f.addEventListener('submit',function(ev){if(!window.fetch)return;ev.preventDefault();var fd=new FormData(f);fd.append('ajax','1');cta.disabled=true;var old=cta.textContent;cta.textContent='下单中…';
var errEl=f.querySelector('.err');if(!errEl){errEl=document.createElement('div');errEl.className='err';cta.parentNode.insertBefore(errEl,cta)}errEl.textContent='';
fetch('/donate',{method:'POST',body:fd}).then(function(r){return r.text().then(function(t){return {ok:r.ok,text:t}})}).then(function(res){cta.disabled=false;cta.textContent=old;
if(!res.ok){errEl.textContent=res.text;return}
mbody.innerHTML=res.text;mform.hidden=true;mbody.hidden=false;modal.hidden=false;modal.querySelector('.modal-box').scrollTop=0;var pp=mbody.querySelector('.paypanel');if(pp){stopPanel=window.initPayPanel(pp,function(){paidDone=true})}
}).catch(function(){cta.disabled=false;cta.textContent=old;errEl.textContent='网络开小差了,再试一次'})});
var fab=document.getElementById('fab'),top=document.getElementById('topcard');
if(fab&&top&&'IntersectionObserver' in window){new IntersectionObserver(function(es){fab.classList.toggle('show',!es[0].isIntersecting&&es[0].boundingClientRect.top<0)},{threshold:0}).observe(top)}
})();
</script>{{end}}
+3
View File
@@ -0,0 +1,3 @@
{{define "sprite"}}<svg class="sprite" viewBox="0 0 24 33" width="96" height="132" shape-rendering="crispEdges" aria-label="端着碗的赛博乞丐" role="img"><g class="body"><rect x="8" y="0" width="1" height="1" fill="#1e1e2e"/><rect x="10" y="0" width="3" height="1" fill="#1e1e2e"/><rect x="14" y="0" width="1" height="1" fill="#1e1e2e"/><rect x="7" y="1" width="1" height="1" fill="#1e1e2e"/><rect x="8" y="1" width="1" height="1" fill="#3b2a1e"/><rect x="9" y="1" width="1" height="1" fill="#1e1e2e"/><rect x="10" y="1" width="3" height="1" fill="#3b2a1e"/><rect x="13" y="1" width="1" height="1" fill="#1e1e2e"/><rect x="14" y="1" width="1" height="1" fill="#3b2a1e"/><rect x="15" y="1" width="1" height="1" fill="#1e1e2e"/><rect x="6" y="2" width="1" height="1" fill="#1e1e2e"/><rect x="7" y="2" width="9" height="1" fill="#3b2a1e"/><rect x="16" y="2" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="3" width="1" height="1" fill="#1e1e2e"/><rect x="6" y="3" width="11" height="1" fill="#3b2a1e"/><rect x="17" y="3" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="4" width="1" height="1" fill="#1e1e2e"/><rect x="6" y="4" width="11" height="1" fill="#3b2a1e"/><rect x="17" y="4" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="5" width="1" height="1" fill="#1e1e2e"/><rect x="6" y="5" width="1" height="1" fill="#3b2a1e"/><rect x="7" y="5" width="9" height="1" fill="#f7c9a3"/><rect x="16" y="5" width="1" height="1" fill="#3b2a1e"/><rect x="17" y="5" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="6" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="6" width="1" height="1" fill="#3b2a1e"/><rect x="6" y="6" width="11" height="1" fill="#f7c9a3"/><rect x="17" y="6" width="1" height="1" fill="#3b2a1e"/><rect x="18" y="6" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="7" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="7" width="13" height="1" fill="#f7c9a3"/><rect x="18" y="7" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="8" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="8" width="13" height="1" fill="#f7c9a3"/><rect x="18" y="8" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="9" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="9" width="13" height="1" fill="#f7c9a3"/><rect x="18" y="9" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="10" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="10" width="13" height="1" fill="#f7c9a3"/><rect x="18" y="10" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="11" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="11" width="13" height="1" fill="#f7c9a3"/><rect x="18" y="11" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="12" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="12" width="5" height="1" fill="#f7c9a3"/><rect x="10" y="12" width="2" height="1" fill="#c0392b"/><rect x="12" y="12" width="6" height="1" fill="#f7c9a3"/><rect x="18" y="12" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="13" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="13" width="4" height="1" fill="#f7c9a3"/><rect x="9" y="13" width="1" height="1" fill="#c0392b"/><rect x="10" y="13" width="2" height="1" fill="#f7c9a3"/><rect x="12" y="13" width="1" height="1" fill="#c0392b"/><rect x="13" y="13" width="5" height="1" fill="#f7c9a3"/><rect x="18" y="13" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="14" width="1" height="1" fill="#1e1e2e"/><rect x="6" y="14" width="11" height="1" fill="#f7c9a3"/><rect x="17" y="14" width="1" height="1" fill="#1e1e2e"/><rect x="6" y="15" width="3" height="1" fill="#1e1e2e"/><rect x="9" y="15" width="4" height="1" fill="#f7c9a3"/><rect x="13" y="15" width="3" height="1" fill="#1e1e2e"/><rect x="5" y="16" width="1" height="1" fill="#1e1e2e"/><rect x="6" y="16" width="4" height="1" fill="#a86f3d"/><rect x="10" y="16" width="1" height="1" fill="#1e1e2e"/><rect x="11" y="16" width="2" height="1" fill="#f7c9a3"/><rect x="13" y="16" width="1" height="1" fill="#1e1e2e"/><rect x="14" y="16" width="4" height="1" fill="#a86f3d"/><rect x="18" y="16" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="17" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="17" width="6" height="1" fill="#a86f3d"/><rect x="11" y="17" width="2" height="1" fill="#1e1e2e"/><rect x="13" y="17" width="6" height="1" fill="#a86f3d"/><rect x="19" y="17" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="18" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="18" width="14" height="1" fill="#a86f3d"/><rect x="19" y="18" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="19" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="19" width="1" height="1" fill="#a86f3d"/><rect x="6" y="19" width="2" height="1" fill="#f2c14e"/><rect x="8" y="19" width="11" height="1" fill="#a86f3d"/><rect x="19" y="19" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="20" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="20" width="1" height="1" fill="#a86f3d"/><rect x="6" y="20" width="1" height="1" fill="#f2c14e"/><rect x="18" y="20" width="2" height="1" fill="#a86fLine truncated
{{define "coin"}}<svg class="px-coin" viewBox="0 0 8 8" width="16" height="16" shape-rendering="crispEdges" aria-hidden="true"><rect x="2" y="0" width="4" height="1" fill="#1e1e2e"/><rect x="1" y="1" width="1" height="1" fill="#1e1e2e"/><rect x="2" y="1" width="4" height="1" fill="#ffcc00"/><rect x="6" y="1" width="1" height="1" fill="#1e1e2e"/><rect x="0" y="2" width="1" height="1" fill="#1e1e2e"/><rect x="1" y="2" width="1" height="1" fill="#ffcc00"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="4" height="1" fill="#ffcc00"/><rect x="7" y="2" width="1" height="1" fill="#1e1e2e"/><rect x="0" y="3" width="1" height="1" fill="#1e1e2e"/><rect x="1" y="3" width="2" height="1" fill="#ffcc00"/><rect x="3" y="3" width="2" height="1" fill="#1e1e2e"/><rect x="5" y="3" width="2" height="1" fill="#ffcc00"/><rect x="7" y="3" width="1" height="1" fill="#1e1e2e"/><rect x="0" y="4" width="1" height="1" fill="#1e1e2e"/><rect x="1" y="4" width="2" height="1" fill="#ffcc00"/><rect x="3" y="4" width="2" height="1" fill="#1e1e2e"/><rect x="5" y="4" width="2" height="1" fill="#ffcc00"/><rect x="7" y="4" width="1" height="1" fill="#1e1e2e"/><rect x="0" y="5" width="1" height="1" fill="#1e1e2e"/><rect x="1" y="5" width="4" height="1" fill="#ffcc00"/><rect x="5" y="5" width="1" height="1" fill="#e09a00"/><rect x="6" y="5" width="1" height="1" fill="#ffcc00"/><rect x="7" y="5" width="1" height="1" fill="#1e1e2e"/><rect x="1" y="6" width="1" height="1" fill="#1e1e2e"/><rect x="2" y="6" width="1" height="1" fill="#ffcc00"/><rect x="3" y="6" width="2" height="1" fill="#e09a00"/><rect x="5" y="6" width="1" height="1" fill="#ffcc00"/><rect x="6" y="6" width="1" height="1" fill="#1e1e2e"/><rect x="2" y="7" width="4" height="1" fill="#1e1e2e"/></svg>{{end}}
{{define "bowlicon"}}<svg class="px-bowl" viewBox="0 0 16 7" width="32" height="14" shape-rendering="crispEdges" aria-hidden="true"><rect x="4" y="0" width="1" height="1" fill="#1e1e2e"/><rect x="5" y="0" width="1" height="1" fill="#ffcc00"/><rect x="6" y="0" width="1" height="1" fill="#1e1e2e"/><rect x="8" y="0" width="1" height="1" fill="#1e1e2e"/><rect x="9" y="0" width="1" height="1" fill="#ffcc00"/><rect x="10" y="0" width="1" height="1" fill="#1e1e2e"/><rect x="3" y="1" width="1" height="1" fill="#1e1e2e"/><rect x="4" y="1" width="1" height="1" fill="#ffcc00"/><rect x="5" y="1" width="1" height="1" fill="#e09a00"/><rect x="6" y="1" width="1" height="1" fill="#ffcc00"/><rect x="7" y="1" width="1" height="1" fill="#1e1e2e"/><rect x="8" y="1" width="1" height="1" fill="#ffcc00"/><rect x="9" y="1" width="1" height="1" fill="#e09a00"/><rect x="10" y="1" width="1" height="1" fill="#ffcc00"/><rect x="11" y="1" width="1" height="1" fill="#1e1e2e"/><rect x="0" y="2" width="16" height="1" fill="#1e1e2e"/><rect x="0" y="3" width="1" height="1" fill="#1e1e2e"/><rect x="1" y="3" width="14" height="1" fill="#ece7d8"/><rect x="15" y="3" width="1" height="1" fill="#1e1e2e"/><rect x="1" y="4" width="1" height="1" fill="#1e1e2e"/><rect x="2" y="4" width="1" height="1" fill="#b9b3a3"/><rect x="3" y="4" width="10" height="1" fill="#ece7d8"/><rect x="13" y="4" width="1" height="1" fill="#b9b3a3"/><rect x="14" y="4" width="1" height="1" fill="#1e1e2e"/><rect x="2" y="5" width="1" height="1" fill="#1e1e2e"/><rect x="3" y="5" width="10" height="1" fill="#b9b3a3"/><rect x="13" y="5" width="1" height="1" fill="#1e1e2e"/><rect x="3" y="6" width="10" height="1" fill="#1e1e2e"/></svg>{{end}}
+71
View File
@@ -0,0 +1,71 @@
{{define "title"}}{{if eq .D.Status "paid"}}功德 +{{fmtAmt .D.ActualE8}}{{else}}施舍进行中{{end}} · {{.SiteTitle}}{{end}}
{{define "content"}}
<div class="narrow">
{{if eq .D.Status "paid"}}
<section class="card px center">
<div class="sprite-wrap jump" style="margin:0 auto 10px">{{template "sprite"}}</div>
<div class="insert">★ THANK YOU ★</div>
<h1>🙏 谢谢施主 {{.D.Nickname}}</h1>
<div class="big num accent">+{{fmtAmt .D.ActualE8}}<small>{{.D.Currency}}</small></div>
{{if .D.MsgBlocked}}{{else if .D.Message}}<div class="bubble" style="margin:14px 0 0;text-align:left">{{.D.Message}}</div>{{end}}
{{if and (gt .Position 0) (not .D.Blocked)}}<div class="notice ok" style="margin-top:14px">你在 {{.Site.Name}} 的施主榜排第 <b>{{.Position}}</b> 位 · {{donorTier .DonorTotal}}</div>{{end}}
<div class="btns">
<a class="btn sec" href="{{.Site.Path}}#donors">回到 {{.Site.Name}} 的碗</a>
<button class="btn ghost copy" type="button" data-copy="{{.ShareURL}}">复制链接分享</button>
</div>
</section>
<section class="card px" style="margin-top:16px">
<h2>🖼 分享卡片</h2>
<p class="hint" style="margin:0 0 10px">生成一张像素分享图,发到 X 或朋友圈,顺便帮乞丐吆喝。</p>
<button class="btn sec" type="button" id="shareBtn">生成分享卡</button>
<div class="sharebox" id="sharebox" hidden><img id="shareImg" alt="分享卡"><div class="hint">长按图片保存,或 <a id="shareDl" download="beggar-card.png">下载</a></div></div>
</section>
{{if .SubsitesEnabled}}
<section class="card px center" style="margin-top:16px">
<h2 style="justify-content:center">🏚 你也可以要饭</h2>
<p class="hint" style="font-size:14px">用 X 账号发一条推就能开自己的要饭站,别人给你的钱直接进你的币安账户。</p>
<a class="btn" href="/new">用 X 开通我的要饭站 →</a>
</section>
{{end}}
{{else if or (eq .D.Status "claimed") (and (eq .D.Status "pending") (not .Stale))}}
<section class="card px">
{{template "paypanel" .Panel}}
<div class="hint center" style="margin-top:12px"><a href="{{.Site.Path}}">回到 {{.Site.Name}} 的碗</a></div>
</section>
{{else}}
<section class="card px center">
<div class="sprite-wrap" style="margin:0 auto 10px">{{template "sprite"}}</div>
<h1>{{if eq .D.Status "rejected"}}站长没有确认这笔施舍{{else if eq .D.Status "failed"}}下单失败{{else}}这笔施舍已过期{{end}}</h1>
<p class="hint" style="font-size:14px">{{if eq .D.Status "rejected"}}如果你确实转了,请联系站长核对。{{else}}没付款不扣钱;想施舍请重新来一次。{{end}}</p>
<a class="btn" href="{{.Site.Path}}#donate">再来一次</a>
</section>
{{end}}
</div>
{{end}}
{{define "scripts"}}<script>
(function(){var b=document.getElementById('shareBtn');if(!b)return;b.addEventListener('click',function(){b.disabled=true;b.textContent='生成中…';
var data={site:{{.Site.Name}},nick:{{.D.Nickname}},amt:'+'+{{fmtAmt .D.ActualE8}}+' '+{{.D.Currency}},msg:{{if .D.MsgBlocked}}''{{else}}{{.D.Message}}{{end}},pos:{{.Position}},url:{{.ShareURL}},title:{{.SiteTitle}}};
var svg=document.querySelector('.sprite');var xml=new XMLSerializer().serializeToString(svg);var img=new Image();img.onload=function(){draw(img)};img.onerror=function(){draw(null)};img.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(xml);
function px(c,x,y,w,h,fill){c.fillStyle=fill;c.fillRect(x,y,w,h)}
function frame(c,x,y,w,h){px(c,x,y,w,h,'#1e1e2e');px(c,x+4,y+4,w-8,h-8,'#fffbf0')}
function fit(c,text,maxW,px,family){var size=px;c.font=size+'px '+family;while(c.measureText(text).width>maxW&&size>12){size--;c.font=size+'px '+family}while(c.measureText(text).width>maxW&&text.length>1){text=text.slice(0,-2)+'…'}return text}
function wrap(c,text,x,y,maxW,lh){var line='',out=[];for(var i=0;i<text.length;i++){var t=line+text[i];if(c.measureText(t).width>maxW&&line){out.push(line);line=text[i]}else line=t}if(line)out.push(line);out.slice(0,3).forEach(function(l,i){c.fillText(l,x,y+i*lh)})}
function draw(sp){var W=640,H=880,cv=document.createElement('canvas');cv.width=W;cv.height=H;var c=cv.getContext('2d');
(document.fonts&&document.fonts.load?document.fonts.load('24px Pixel'):Promise.resolve()).then(function(){
px(c,0,0,W,H,'#f4eedd');for(var y=0;y<H;y+=32)for(var x=(y/32%2)*16;x<W;x+=32)px(c,x,y,16,16,'#e9e1ca');
px(c,0,0,W,56,'#1e1e2e');c.fillStyle='#ffcc00';c.font='24px Pixel, sans-serif';c.textBaseline='middle';c.fillText('🥣 '+data.title,24,28);
frame(c,32,88,W-64,H-160);
if(sp)c.drawImage(sp,64,120,144,198);
c.fillStyle='#1e1e2e';c.textBaseline='top';c.fillText(fit(c,data.site,360,24,'Pixel, sans-serif'),232,128);
c.fillStyle='#b8820a';c.fillText(fit(c,data.amt,360,36,'Pixel, sans-serif'),232,176);
c.fillStyle='#1e1e2e';c.fillText(fit(c,'施主 '+data.nick,360,22,'bold -apple-system, PingFang SC, sans-serif'),232,236);
if(data.pos>0){c.fillText(fit(c,'施主榜第 '+data.pos+' 位',360,18,'-apple-system, PingFang SC, sans-serif'),232,270)}
if(data.msg){px(c,64,352,W-128,150,'#1e1e2e');px(c,68,356,W-136,142,'#ffffff');c.font='22px -apple-system, PingFang SC, sans-serif';c.fillStyle='#1e1e2e';wrap(c,'「'+data.msg+'」',88,378,W-176,36)}
c.font='20px Pixel, sans-serif';c.fillStyle='#1e1e2e';c.fillText('★ THANK YOU ★',64,540);
c.font='18px -apple-system, PingFang SC, sans-serif';c.fillStyle='#4a4954';c.fillText('行行好,赏口饭吃。用币安 Pay 投币施舍,留一句话上功德簿。',64,586);
px(c,64,640,W-128,60,'#1e1e2e');px(c,68,644,W-136,52,'#ffcc00');c.fillStyle='#1e1e2e';c.textBaseline='middle';c.fillText(fit(c,data.url.replace(/^https?:\/\//,''),W-176,22,'Pixel, sans-serif'),84,670);
c.textBaseline='top';c.font='16px Pixel, sans-serif';c.fillStyle='#4a4954';c.fillText('#赛博丐帮',64,740);
var u=cv.toDataURL('image/png');document.getElementById('shareImg').src=u;document.getElementById('shareDl').href=u;document.getElementById('sharebox').hidden=false;b.textContent='重新生成';b.disabled=false;
})}
})})();
</script>{{end}}
+120
View File
@@ -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'<rect x="{x}" y="{y}" width="{x2 - x + 1}" height="1" fill="{color}"/>')
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'<rect x="{x}" y="{y}" width="1" height="1" fill="{PAL[c]}"/>')
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"}}<svg class="sprite" viewBox="0 0 24 %d" width="96" height="%d" shape-rendering="crispEdges" aria-label="端着碗的赛博乞丐" role="img">'
'<g class="body">%s</g><g class="eyes">%s</g><g class="tear">%s</g><g class="bowl">%s</g></svg>{{end}}' % (h, h * 4, body, layer(EYES), layer(TEAR), bowl))
print('{{define "coin"}}<svg class="px-coin" viewBox="0 0 8 8" width="16" height="16" shape-rendering="crispEdges" aria-hidden="true">%s</svg>{{end}}' % rects(COIN))
print('{{define "bowlicon"}}<svg class="px-bowl" viewBox="0 0 16 7" width="32" height="14" shape-rendering="crispEdges" aria-hidden="true">%s</svg>{{end}}' % rects(BOWLICON))
if __name__ == "__main__":
main()
+530
View File
@@ -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[:])
}
+191
View File
@@ -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)
}
}
+232
View File
@@ -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))
}