赛博要饭 newbeggar:像素风要饭站,币安 Pay 收款 + X 发推开分舵
This commit is contained in:
1 parent
ee00e020cc
commit
253087b2a8
36 files changed
+7073
No files matched your search
+15
@@ -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,3 +1,4 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
@@ -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`)。
|
||||
@@ -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
@@ -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, "加油要饭 <b>x</b>") && strings.Contains(body, "🥇") && strings.Contains(body, "5.0037"), "主页功德簿/榜")
|
||||
must(t, strings.Contains(body, "/a/"+p1.Avatar) && strings.Contains(body, "@qianyu_fan") && strings.Contains(body, "🏁"), "主页应显示 X 头像与徽章")
|
||||
must(t, strings.Contains(body, "三袋弟子") && strings.Contains(body, "总舵"), "主站袋位/总舵")
|
||||
st, _, h = c.get("/a/" + p1.Avatar)
|
||||
must(t, st == 200 && strings.HasPrefix(h.Get("Content-Type"), "image/"), "头像文件可访问: %d", st)
|
||||
must(t, e.gw.pay(e.gw.last.OrderID, "5.0037", "452100000000000001", true) == 200, "重复回调")
|
||||
stats, _ := e.app.st.SiteStats(1)
|
||||
must(t, stats.Count == 1 && stats.TotalE8 == 500370000, "幂等: %+v", stats)
|
||||
|
||||
// ③ 回调丢失 → 轮询兜底查单
|
||||
c2 := e.client()
|
||||
st, _, _ = c2.post("/donate", url.Values{"site": {""}, "nickname": {"路人甲"}, "amount": {"1.25"}})
|
||||
must(t, st == 302, "第二笔下单")
|
||||
code2 := e.gw.last.MerchantOrderID
|
||||
e.gw.pay(e.gw.last.OrderID, "1.2537", "452100000000000002", false)
|
||||
st, body, _ = c2.get("/d/" + code2 + "/status")
|
||||
must(t, st == 200 && strings.Contains(body, `"status":"paid"`), "兜底查单: %s", body)
|
||||
|
||||
// ④ X 发推验证开站(不需要先施舍)
|
||||
st, body, _ = c.get("/new")
|
||||
vcode := reCode.FindString(body)
|
||||
st, body, _ = c.get("/new")
|
||||
must(t, st == 200 && reCode.FindString(body) == vcode, "同一浏览器再进 /new 复用验证码: %s vs %s", reCode.FindString(body), vcode)
|
||||
must(t, st == 200 && vcode != "", "开站第一步")
|
||||
e.x.set("1001", "随便发点什么", "芊羽分舵🐸", "qianyuwing")
|
||||
st, body, _ = c.post("/new/verify", url.Values{"code": {vcode}, "tweet_url": {"https://x.com/qianyuwing/status/1001"}})
|
||||
must(t, st == 400 && strings.Contains(body, "没有验证码"), "推文无验证码应拒绝: %d", st)
|
||||
st, body, _ = c.post("/new/verify", url.Values{"code": {vcode}, "tweet_url": {"not a url"}})
|
||||
must(t, st == 400 && strings.Contains(body, "推文链接不对"), "链接格式: %d", st)
|
||||
e.x.set("1002", "我在赛博要饭 "+strings.ToLower(vcode)+" #赛博要饭", "芊羽分舵🐸", "qianyuwing")
|
||||
st, body, _ = c.post("/new/verify", url.Values{"code": {vcode}, "tweet_url": {"https://twitter.com/qianyuwing/status/1002?s=20"}})
|
||||
must(t, st == 200 && strings.Contains(body, "X 验证通过") && strings.Contains(body, "@qianyuwing") && strings.Contains(body, "/qianyuwing</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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
Executable
+36
@@ -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
|
||||
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env python3
|
||||
"""真实网关端到端:mock 币安 × 真实 BinancePayTool 二进制 × 本站二进制。
|
||||
|
||||
用法: python3 e2e/real_gateway.py [--serve] [--port=8124] [bpaygate 二进制路径]
|
||||
默认二进制 ~/Desktop/BinancePayTool/server/bpaygate(需为含多账号的版本);本站二进制 ./beggar(先 go build -o beggar .)。
|
||||
--serve:跑完断言后不退出,留着整套环境(并造一批演示数据)供浏览器查看。
|
||||
流程: 起 mock 币安(按 API Key 区分账号)→ 起网关 → 起本站
|
||||
① 主站施舍 → 真实收银页 → 注入到账 → 网关回调 → 上榜
|
||||
② 开子站(设口令,自动登录)→ 直接转账模式 → 施主自报 → 站长确认 → 屏蔽留言
|
||||
③ 子站绑定币安只读 Key → 网关多账号 → 子站施舍 → 该 Key 的流水到账 → 自动确认
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
BPT = os.path.expanduser("~/Desktop/BinancePayTool")
|
||||
sys.path.insert(0, os.path.join(BPT, "sdk", "python"))
|
||||
from bpaygate import BPayGate # noqa: E402
|
||||
|
||||
SECRET = "e2e-secret-0123456789abcdef"
|
||||
ADMIN_PW = "admin-pass-123456"
|
||||
SUB_PW = "qianyu-pass-1"
|
||||
TXNS = {} # api key → 流水列表
|
||||
_seq = [452100000000000100]
|
||||
|
||||
|
||||
TWEETS = {} # id → (text, name, handle)
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
|
||||
def make_png(size=32):
|
||||
"""生成一张有效的 32x32 渐变 PNG(stdlib 实现,供 mock 头像用)。"""
|
||||
rows = []
|
||||
for y in range(size):
|
||||
row = bytearray([0])
|
||||
for x in range(size):
|
||||
row += bytes([(x * 8) & 255, (y * 8) & 255, 120, 255])
|
||||
rows.append(bytes(row))
|
||||
data = zlib.compress(b"".join(rows))
|
||||
|
||||
def chunk(tag, body):
|
||||
c = struct.pack(">I", len(body)) + tag + body
|
||||
return c + struct.pack(">I", zlib.crc32(tag + body) & 0xFFFFFFFF)
|
||||
|
||||
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)) + chunk(b"IDAT", data) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
PNG = make_png()
|
||||
|
||||
|
||||
class MockBinance(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/mock/pay"): # 演示:模拟一笔到账 /mock/pay?amount=1.0036[¬e=XXXX]
|
||||
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
amt = qs.get("amount", [""])[0]
|
||||
oid = inject_payment("k", amt, qs.get("note", [""])[0]) if amt else ""
|
||||
body = json.dumps({"ok": bool(oid), "binance_order_id": oid, "amount": amt}).encode()
|
||||
self.send_response(200 if oid else 400)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if self.path.startswith("/tweet-result"):
|
||||
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
tw = TWEETS.get(qs.get("id", [""])[0])
|
||||
if not tw:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
body = json.dumps({"__typename": "Tweet", "text": tw[0], "user": {"id_str": "9001", "name": tw[1], "screen_name": tw[2],
|
||||
"profile_image_url_https": f"http://127.0.0.1:{self.server.server_address[1]}/pic/{tw[2]}_normal.jpg"}}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if self.path.startswith("/pic/") or self.path.startswith("/x/"):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/png")
|
||||
self.end_headers()
|
||||
self.wfile.write(PNG)
|
||||
return
|
||||
if self.path.startswith("/sapi/v1/pay/transactions"):
|
||||
key = self.headers.get("X-MBX-APIKEY", "")
|
||||
if key == "bad":
|
||||
body = json.dumps({"code": -2015, "msg": "Invalid API-key, IP, or permissions for action."}).encode()
|
||||
self.send_response(401)
|
||||
else:
|
||||
body = json.dumps({"code": "000000", "message": "success", "data": TXNS.get(key, []), "success": True}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def free_port():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
p = s.getsockname()[1]
|
||||
s.close()
|
||||
return p
|
||||
|
||||
|
||||
def wait_http(url, seconds=15):
|
||||
for _ in range(seconds * 10):
|
||||
try:
|
||||
urllib.request.urlopen(url, timeout=1)
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
class Session:
|
||||
"""带 cookie、不跟随跳转的极简客户端。"""
|
||||
|
||||
def __init__(self, base):
|
||||
u = urllib.parse.urlparse(base)
|
||||
self.host, self.port = u.hostname, u.port
|
||||
self.cookies = {}
|
||||
|
||||
def req(self, method, path, form=None):
|
||||
c = http.client.HTTPConnection(self.host, self.port, timeout=15)
|
||||
headers = {}
|
||||
if self.cookies:
|
||||
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in self.cookies.items())
|
||||
body = None
|
||||
if form is not None:
|
||||
body = urllib.parse.urlencode(form).encode()
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
c.request(method, path, body=body, headers=headers)
|
||||
r = c.getresponse()
|
||||
data = r.read().decode()
|
||||
for sc in r.msg.get_all("Set-Cookie") or []:
|
||||
kv, attrs = sc.split(";", 1)[0], sc.lower()
|
||||
k, v = kv.split("=", 1)
|
||||
if "max-age=-1" in attrs or "max-age=0" in attrs:
|
||||
self.cookies.pop(k, None)
|
||||
else:
|
||||
self.cookies[k] = v
|
||||
out = (r.status, dict(r.getheaders()), data)
|
||||
c.close()
|
||||
return out
|
||||
|
||||
def get(self, path):
|
||||
return self.req("GET", path)
|
||||
|
||||
def post(self, path, form):
|
||||
return self.req("POST", path, form or {})
|
||||
|
||||
def code(self):
|
||||
return next((k[3:] for k in self.cookies if k.startswith("bd_")), "")
|
||||
|
||||
|
||||
def inject_payment(key, amount, note=""):
|
||||
_seq[0] += 1
|
||||
TXNS.setdefault(key, []).append({"orderId": str(_seq[0]), "note": note, "orderType": "C2C", "transactionId": f"P_E2E{_seq[0]}",
|
||||
"transactionTime": int(time.time() * 1000), "amount": amount, "currency": "USDT",
|
||||
"counterpartyId": 1163000000, "payerInfo": {"name": "e2e-payer"}})
|
||||
return str(_seq[0])
|
||||
|
||||
|
||||
def wait_status(s, code, want, seconds=20):
|
||||
for _ in range(seconds * 5):
|
||||
st, _, body = s.get(f"/d/{code}/status")
|
||||
if st == 200 and json.loads(body)["status"] == want:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def donate_and_pay(gw, base, key, site, nick, amount, msg):
|
||||
"""网关模式站点施舍一笔并让它到账,返回 (session, code)。"""
|
||||
s = Session(base)
|
||||
st, h, _ = s.post("/donate", {"site": site, "nickname": nick, "amount": amount, "message": msg})
|
||||
assert st == 302 and h.get("Location", "").startswith("/d/"), (st, h)
|
||||
code = h["Location"][3:]
|
||||
st, _, page = s.get(f"/d/{code}")
|
||||
assert st == 200 and 'class="paypanel"' in page and "等待到账" in page, "状态页应内嵌付款面板"
|
||||
order = gw.get_order_by_merchant_id(code)
|
||||
assert order["status"] == "pending" and order["base_amount"] == amount, order
|
||||
inject_payment(key, order["pay_amount"])
|
||||
assert wait_status(s, code, "paid"), f"{code} 未到账"
|
||||
return s, code, order
|
||||
|
||||
|
||||
def main():
|
||||
serve = "--serve" in sys.argv
|
||||
port_arg = next((a.split("=", 1)[1] for a in sys.argv[1:] if a.startswith("--port=")), "")
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
binary = os.path.abspath(args[0] if args else os.path.join(BPT, "server", "bpaygate"))
|
||||
beggar = os.path.abspath(os.path.join(HERE, "..", "beggar"))
|
||||
for p in (binary, beggar):
|
||||
if not os.path.isfile(p):
|
||||
sys.exit(f"找不到二进制: {p}")
|
||||
|
||||
mock = ThreadingHTTPServer(("127.0.0.1", 0), MockBinance)
|
||||
threading.Thread(target=mock.serve_forever, daemon=True).start()
|
||||
tmp = tempfile.mkdtemp(prefix="beggar-e2e-")
|
||||
gw_port, app_port = free_port(), (int(port_arg) if port_arg else free_port())
|
||||
gw_base, base = f"http://127.0.0.1:{gw_port}", f"http://127.0.0.1:{app_port}"
|
||||
|
||||
with open(os.path.join(tmp, "gw.env"), "w") as f:
|
||||
f.write(f"""LISTEN=127.0.0.1:{gw_port}
|
||||
BASE_URL={gw_base}
|
||||
API_AUTH_KEY={SECRET}
|
||||
BINANCE_API_KEY=k
|
||||
BINANCE_API_SECRET=s
|
||||
BINANCE_UID=90000001
|
||||
BINANCE_API_BASE=http://127.0.0.1:{mock.server_address[1]}
|
||||
CURRENCIES=USDT,USDC
|
||||
POLL_INTERVAL=1
|
||||
RECEIVE_LINK=https://app.binance.com/uni-qr/E2ETEST
|
||||
DB_PATH={tmp}/gw.db
|
||||
""")
|
||||
with open(os.path.join(tmp, "beggar.env"), "w") as f:
|
||||
f.write(f"""LISTEN=127.0.0.1:{app_port}
|
||||
BASE_URL={base}
|
||||
DB_PATH={tmp}/beggar.db
|
||||
ADMIN_PASSWORD={ADMIN_PW}
|
||||
BPG_URL={gw_base}
|
||||
BPG_KEY={SECRET}
|
||||
X_TWEET_API=http://127.0.0.1:{mock.server_address[1]}
|
||||
X_AVATAR_API=http://127.0.0.1:{mock.server_address[1]}
|
||||
AVATAR_DIR={tmp}/avatars
|
||||
MAIN_NAME=芊羽Wing
|
||||
MAIN_SLOGAN=行行好,赏口饭吃,别问,问就是没饭吃 🙏
|
||||
MAIN_STORY=在赛博丐帮蹲了三年,碗里只有代码。
|
||||
MAIN_X=@qianyuwing
|
||||
""")
|
||||
procs = [subprocess.Popen([binary, "-config", os.path.join(tmp, "gw.env")], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)]
|
||||
try:
|
||||
assert wait_http(gw_base + "/healthz"), "网关未就绪"
|
||||
procs.append(subprocess.Popen([beggar, "-config", os.path.join(tmp, "beggar.env")], stdout=None if serve else subprocess.DEVNULL, stderr=subprocess.STDOUT))
|
||||
assert wait_http(base + "/healthz"), "本站未就绪"
|
||||
gw = BPayGate(gw_base, SECRET)
|
||||
|
||||
# ① 主站施舍:跳真实收银页 → 注入到账 → 网关回调 → 上榜
|
||||
s1, code1, order1 = donate_and_pay(gw, base, "k", "", "赛博善人", "5", "别买烟了,去吃饭")
|
||||
st, _, cashier = Session(gw_base).get("/pay/" + order1["pay_url"].rsplit("/", 1)[1])
|
||||
assert st == 200 and "币安" in cashier, "网关收银页异常"
|
||||
st, _, body = s1.get("/")
|
||||
assert "赛博善人" in body and "别买烟了,去吃饭" in body and "5.00" in body, "主页未展示到账"
|
||||
st, _, body = s1.get(f"/d/{code1}")
|
||||
assert "谢谢施主 赛博善人" in body and 'href="/new"' in body and "生成分享卡" in body, "到账页缺开站入口/分享卡"
|
||||
print("① 主站施舍 → 真实网关 → 回调到账 ... PASS")
|
||||
|
||||
# ② 用 X 发推验证开子站(不需要先施舍)→ 直接转账 → 自报 → 站长确认 → 屏蔽留言
|
||||
st, _, body = s1.get("/new")
|
||||
code = re.search(r"BEG-[A-Z0-9]{5}", body).group(0)
|
||||
TWEETS["9001"] = (f"我在赛博要饭摆了个碗 {code} #赛博要饭", "芊羽分舵🐸", "qianyufendo")
|
||||
st, _, body = s1.post("/new/verify", {"code": code, "tweet_url": "https://x.com/qianyufendo/status/9001"})
|
||||
assert st == 200 and "X 验证通过" in body and "@qianyufendo" in body, ("verify", st)
|
||||
st, h, _ = s1.post("/new", {"code": code, "slug": "qianyufendo", "slogan": "分舵也要吃饭", "password": SUB_PW, "password2": SUB_PW})
|
||||
assert st == 302 and h["Location"] == "/manage?welcome=1", (st, h)
|
||||
assert "bs" in s1.cookies, "开站后应自动登录"
|
||||
st, _, body = s1.get("/manage?welcome=1")
|
||||
assert st == 200 and "开张大吉" in body and "方式一:绑定币安 API Key" in body and "不用设 IP 白名单" in body, "管理页异常"
|
||||
st, h, _ = s1.post("/manage/payment", {"receive_link": "https://app.binance.com/uni-qr/SUBTEST", "pay_id": "UID 12345678"})
|
||||
assert st == 302, st
|
||||
s2 = Session(base)
|
||||
st, h, _ = s2.post("/donate", {"site": "qianyufendo", "nickname": "过路财神", "x_handle": "@caishen", "amount": "8", "message": "分舵铁粉专属留言"})
|
||||
assert st == 302 and h["Location"].startswith("/pay/"), (st, h)
|
||||
code2 = h["Location"][5:]
|
||||
st, _, body = s2.get(f"/pay/{code2}")
|
||||
assert "UID 12345678" in body and "data:image/png;base64," in body, "手动付款页异常"
|
||||
assert s2.post(f"/pay/{code2}/claim", {"binance_order_id": "452100000000000999"})[0] == 302
|
||||
st, _, body = s1.get("/manage")
|
||||
assert "待确认的施舍" in body and "452100000000000999" in body, "站长未看到待确认"
|
||||
did = re.search(r"/donation/(\d+)/confirm", body).group(1)
|
||||
assert s1.post(f"/manage/donation/{did}/confirm", {})[0] == 302
|
||||
assert wait_status(s2, code2, "paid", 3), "站长确认后应为 paid"
|
||||
st, _, body = s2.get("/qianyufendo")
|
||||
assert "过路财神" in body and "分舵铁粉专属留言" in body and "x.com/qianyufendo" in body and "芊羽分舵🐸" in body, "子站未展示"
|
||||
assert s1.post(f"/manage/donation/{did}/block", {})[0] == 302
|
||||
st, _, body = s2.get("/qianyufendo")
|
||||
assert "过路财神" in body and "***" in body and "专属留言" not in body, "屏蔽后应显示 ***"
|
||||
# 广告留言直接拒绝;钢镚免费且每人一天一个
|
||||
assert s2.post("/donate", {"site": "qianyufendo", "nickname": "广告狗", "amount": "8", "message": "加我微信领福利"})[0] == 400
|
||||
st, _, body = s2.post("/coin", {"site": "qianyufendo"})
|
||||
assert st == 200 and json.loads(body)["added"] is True, body
|
||||
assert json.loads(s2.post("/coin", {"site": "qianyufendo"})[2])["added"] is False
|
||||
# 登录制:退出后凭路径 + 口令登录
|
||||
s1.post("/logout", {})
|
||||
assert s1.get("/manage")[0] == 302, "退出后应跳登录"
|
||||
assert s1.post("/login", {"slug": "qianyufendo", "password": "wrong"})[0] == 401
|
||||
assert s1.post("/login", {"slug": "qianyufendo", "password": SUB_PW})[0] == 302
|
||||
print("② X 发推开站 → 直接转账 → 站长确认 → 屏蔽留言 → 钢镚 ... PASS")
|
||||
|
||||
# ③ 子站绑定币安只读 Key(真实网关校验 mock 币安)→ 子站施舍自动到账
|
||||
st, _, body = s1.post("/manage/bind", {"api_key": "bad", "api_secret": "s", "uid": "90000002"})
|
||||
assert st == 400 and "Invalid API-key" in body, "坏 Key 应把币安原因带回来"
|
||||
st, h, _ = s1.post("/manage/bind", {"api_key": "sub-key-0123456789", "api_secret": "sub-secret", "uid": "90000002",
|
||||
"receive_link": "https://app.binance.com/uni-qr/SUBQR"})
|
||||
assert st == 302, (st, h)
|
||||
st, _, body = s1.get("/manage")
|
||||
assert "绑定成功" in body and "UID 90000002" in body, "绑定后管理页异常"
|
||||
s3, code3, order3 = donate_and_pay(gw, base, "sub-key-0123456789", "qianyufendo", "分舵铁粉", "3", "分舵加油")
|
||||
assert order3["receive_uid"] == "90000002" and order3["account_id"] != "default", order3
|
||||
st, _, cashier = Session(gw_base).get("/pay/" + order3["pay_url"].rsplit("/", 1)[1])
|
||||
assert "90000002" in cashier and "扫码支付" in cashier, "子账号收银页应展示其 UID 与二维码"
|
||||
st, _, body = s3.get("/qianyufendo")
|
||||
assert "分舵铁粉" in body and "分舵加油" in body, "子站自动到账未上榜"
|
||||
print("③ 子站绑定币安 Key → 网关多账号 → 自动到账 ... PASS")
|
||||
print("REAL-GATEWAY E2E ALL PASS")
|
||||
|
||||
if serve:
|
||||
for nick, amt, msg in [("匿名施主", "1", ""), ("老铁666", "10", "看你可怜"), ("V神", "50", "买个域名吧"), ("路过", "0.5", "钢镚儿拿去")]:
|
||||
donate_and_pay(gw, base, "k", "", nick, amt, msg)
|
||||
for nick, amt, msg in [("隔壁老王", "20", "拿去吃饭"), ("夜猫子", "2", "凌晨三点还在要饭,敬业")]:
|
||||
donate_and_pay(gw, base, "sub-key-0123456789", "qianyufendo", nick, amt, msg)
|
||||
s = Session(base)
|
||||
st, h, _ = s.post("/donate", {"site": "", "nickname": "犹豫中", "amount": "2", "message": "先看看"})
|
||||
if st == 429: # 同一 IP 每分钟 10 笔限流
|
||||
time.sleep(61)
|
||||
st, h, _ = s.post("/donate", {"site": "", "nickname": "犹豫中", "amount": "2", "message": "先看看"})
|
||||
mock_base = f"http://127.0.0.1:{mock.server_address[1]}"
|
||||
print(f"\n主站 {base}/\n子站 {base}/qianyufendo\n排行榜 {base}/rank\n登录 {base}/login (主站:路径留空 口令 {ADMIN_PW};子站:qianyufendo / {SUB_PW})\n"
|
||||
f"未支付付款页 {base}{h['Location']}\n开站页 {base}/new\n"
|
||||
f"模拟付款 curl '{mock_base}/mock/pay?amount=<弹窗里的唯一金额>' (主站的单;子站绑的是另一把 Key,用 &key=... 不支持,直接测主站即可)\n\nCtrl-C 退出。", flush=True)
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
finally:
|
||||
for p in procs:
|
||||
p.terminate()
|
||||
for p in procs:
|
||||
try:
|
||||
p.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 "网关连不上,请稍后再试"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user