Files
newbeggar/handlers_site.go
T

602 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"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
PgFeed, PgDonors, PgDonorsWeek, PgBeggars, PgBeggarsWeek, PgPopular Pager
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)
// 记住「现在在逛哪个碗」:之后点左上角站名、看完排行榜都回到这里,而不是一律跳回总舵
if site.IsMain() {
a.setCookie(w, "bhome", "", -1)
} else {
a.setCookie(w, "bhome", site.Slug, 24*3600)
}
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 = a.T(r, "%s:%s · 赛博要饭,行行好", 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, 2000, 0); err != nil {
a.fail(w, r, err)
return
}
if p.RankWeek, err = a.st.SiteDonorRank(site.ID, 2000, weekStart()); err != nil {
a.fail(w, r, err)
return
}
if p.Feed, err = a.st.SiteFeed(site.ID, 2000); err != nil {
a.fail(w, r, err)
return
}
if p.Subsites, err = a.st.SubsiteRank(2000, false, "amount"); err != nil {
a.fail(w, r, err)
return
}
if p.SubsitesWeek, err = a.st.SubsiteRank(2000, false, "week"); err != nil {
a.fail(w, r, err)
return
}
if p.Popular, err = a.st.SubsiteRank(2000, 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
}
// 各 tab 分页(每个榜一个参数,翻页保留其它 tab 的页码)
sp := func(param, anchor string, n int) Pager {
return newPagerAt(r, site.Path(), []string{"fp", "dp", "dw", "bp", "bw", "pp"}, param, anchor, n, rankPageSize)
}
p.PgFeed, p.PgDonors, p.PgDonorsWeek = sp("fp", "#feed", len(p.Feed)), sp("dp", "#donors", len(p.Rank)), sp("dw", "#donors-week", len(p.RankWeek))
p.PgBeggars, p.PgBeggarsWeek, p.PgPopular = sp("bp", "#beggars", len(p.Subsites)), sp("bw", "#beggars-week", len(p.SubsitesWeek)), sp("pp", "#popular", len(p.Popular))
p.Feed = pageOf(p.PgFeed, p.Feed)
p.Rank, p.RankWeek = pageOf(p.PgDonors, p.Rank), pageOf(p.PgDonorsWeek, p.RankWeek)
p.Subsites, p.SubsitesWeek, p.Popular = pageOf(p.PgBeggars, p.Subsites), pageOf(p.PgBeggarsWeek, p.SubsitesWeek), pageOf(p.PgPopular, p.Popular)
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(a.T(r, "金额格式不对(最多两位小数)"))
return
}
if e8 < a.cfg.MinAmountE8 {
bad(a.T(r, "最少施舍 %s %s", fmtE8(a.cfg.MinAmountE8), site.Currency))
return
}
if e8 > a.cfg.MaxAmountE8 {
bad(a.T(r, "最多施舍 %s %s,真有这么多请分几次", 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(a.T(r, "支付网关开小差了:%s", gatewayErrL(a.lang(r), 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), 15, time.Minute) {
reply(429, map[string]any{"ok": false, "msg": a.T(r, "手速太快了")})
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": a.T(r, "这个乞丐不在")})
return
}
visitor := cookieVal(r, "bv")
if parseVisitor(a.session, visitor) == 0 { // 访客标识由本站页面签发;伪造或跨站脚本发的请求直接拒
reply(403, map[string]any{"ok": false, "msg": a.T(r, "请先打开要饭页再丢")})
return
}
added, msg, err := a.st.AddCoin(site.ID, visitor, a.ip(r), today(), a.cfg.CoinsPerDay, a.cfg.CoinsIPCap, a.cfg.CoinsIPTotal)
if err != nil {
a.logf("[error] 丢钢镚: %v", err)
reply(500, map[string]any{"ok": false, "msg": a.T(r, "服务器开小差了")})
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": a.T(r, 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": a.T(r, "手速太快了,歇一会儿")})
} 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) {
m = a.T(r, m)
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": a.T(r, "已提交,等站长确认")})
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) {
m = a.T(r, m)
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, gatewayErrL(a.lang(r), err))
return
}
a.refreshFromGateway(d)
msg := a.T(r, claimMsgs[code])
if msg == "" {
msg = a.T(r, "网关返回 %s", code)
}
if ajax {
replyJSON(w, 200, map[string]any{"ok": d.Status == "paid", "status": d.Status, "msg": a.T(r, 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
PgDonors, PgDonorsWeek, PgSites, PgSitesWeek, PgPopular Pager
}
const rankPageSize = 10
var rankParams = []string{"da", "dw", "ba", "bw", "pp"}
func pageOf[T any](p Pager, rows []T) []T {
lo := p.Offset()
if lo >= len(rows) {
return nil
}
hi := lo + p.Size
if hi > len(rows) {
hi = len(rows)
}
return rows[lo:hi]
}
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(2000, 0); err != nil {
a.fail(w, r, err)
return
}
if p.DonorsWeek, err = a.st.GlobalDonorRank(2000, weekStart()); err != nil {
a.fail(w, r, err)
return
}
if p.Sites, err = a.st.SubsiteRank(2000, false, "amount"); err != nil {
a.fail(w, r, err)
return
}
if p.SitesWeek, err = a.st.SubsiteRank(2000, false, "week"); err != nil {
a.fail(w, r, err)
return
}
if p.Popular, err = a.st.SubsiteRank(2000, 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)
pg := func(param, anchor string, n int) Pager {
return newPagerAt(r, "/rank", rankParams, param, anchor, n, rankPageSize)
}
p.PgDonors, p.PgDonorsWeek = pg("da", "#donors", len(p.Donors)), pg("dw", "#donors-week", len(p.DonorsWeek))
p.PgSites, p.PgSitesWeek, p.PgPopular = pg("ba", "#beggars", len(p.Sites)), pg("bw", "#beggars-week", len(p.SitesWeek)), pg("pp", "#popular", len(p.Popular))
p.Donors, p.DonorsWeek = pageOf(p.PgDonors, p.Donors), pageOf(p.PgDonorsWeek, p.DonorsWeek)
p.Sites, p.SitesWeek, p.Popular = pageOf(p.PgSites, p.Sites), pageOf(p.PgSitesWeek, p.SitesWeek), pageOf(p.PgPopular, p.Popular)
a.render(w, http.StatusOK, "rank", p)
}