Add guarded X gift CLI and paused redemption site with admin code management
This commit is contained in:
commit
ca14496cfc
22 files changed
+3241
No files matched your search
+17
@@ -0,0 +1,17 @@
|
||||
.venv/
|
||||
.private/
|
||||
__pycache__/
|
||||
*.har
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.log
|
||||
*.tar.gz
|
||||
/sqlite/
|
||||
/bin/
|
||||
|
||||
.playwright-cli/
|
||||
.cloudflare/
|
||||
@@ -0,0 +1,84 @@
|
||||
# XGift
|
||||
|
||||
Go CLI + Premium 兑换站。网站:`https://xp.example.com`,后台:`/admin`。
|
||||
|
||||
**当前付款关闭。** 已验证 X 赠送资格查询、固定套餐报价和 Stripe 结账链接生成;此前一次付款确认返回 HTTP 400,原订单处于 `unknown`,不得自动重试。网站目前可生成、管理兑换码,并核实账号是否允许赠送;符合条件时提示充值尚未开放,兑换码不消耗。不能将当前部署当作已经可成功自动充值的服务。
|
||||
|
||||
## 构建
|
||||
|
||||
需要 Go 1.27.1、CGO 与 C 编译器:
|
||||
|
||||
```sh
|
||||
go build -o bin/xgift ./cmd/xgift
|
||||
go build -o bin/xgift-web ./cmd/xgift-web
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
按要求未保留测试文件,不运行测试。静态页面嵌入 Go 二进制,无 Node 运行依赖、外部字体或浏览器自动化依赖。
|
||||
|
||||
## CLI
|
||||
|
||||
```sh
|
||||
./bin/xgift status
|
||||
./bin/xgift import-chrome --profile Default
|
||||
./bin/xgift check
|
||||
./bin/xgift proxy --port 18791
|
||||
./bin/xgift username # 默认 6 个月,只生成/读取结账链接
|
||||
./bin/xgift username --months 3 # 3 个月,只生成/读取结账链接
|
||||
```
|
||||
|
||||
`--pay` 是真实付款入口,当前不要运行。网站的暂停配置只管网站,不会禁用手动执行 CLI 的 `--pay`。
|
||||
|
||||
- 仅允许 3 个月恰好 300 BDT、6 个月恰好 600 BDT。校验 X 报价和 Stripe 最终总额、币种、商品、数量、一次性模式及商户身份,任何不符都停止。
|
||||
- 内嵌 sing-box AnyTLS,只监听本机端口,不修改系统代理。代理配置从加密库读取。
|
||||
- 固定 X API / Stripe 入口,无网页识别或 ChatGPT Chrome 扩展。
|
||||
- 私有 API 可能变化;目前 Stripe 确认请求仍未跑通,不能保证链接或支付长期可用。
|
||||
- 按 X 固定用户 ID 保存订单。`creating`、`submitting`、`unknown`、`requires_action` 等不明/待处理状态阻止再次提交。不会为了重试重建订单或改变幂等键。
|
||||
- 同一收件人已有记录时,不能用另一枚兑换码或另一套餐重复付款。当前没有自动追加时长、自动退款或人工“强制成功”入口。
|
||||
|
||||
## 兑换站
|
||||
|
||||
用户填写兑换码和用户名,系统先以只读请求核实 X 是否允许赠送。X 不允许时明确提示,兑换码不使用;服务暂停或查询失败也不消耗兑换码。付款开放后的实现会原子锁定兑换码和收件人,使用同一订单校验流程;只有 Stripe 确认成功后才显示完成。付款结果不明时锁定为待核实,不自动重试,进程重启同样不重试。
|
||||
|
||||
后台使用 HTTPS Basic Auth,用户名 `admin`,随机密码从权限为 `0600` 的文件读取。支持:
|
||||
|
||||
- 每批 1–500 枚兑换码,绑定 3 或 6 个月,可自定义批次名称。
|
||||
- 兑换码明文只在生成响应中出现一次,可下载 TXT;数据库仅保存 SHA-256 摘要和末 8 位。
|
||||
- 分页浏览全部兑换码,查看账号和状态,停用未使用的码。
|
||||
- 同源 POST 校验、请求限流、禁止缓存与页面嵌入;用户接口不返回卡信息、Cookie 或 Stripe 付款链接。
|
||||
|
||||
浏览器关闭/刷新不会保存兑换码明文。若生成响应丢失,请先核实批次,必要时停用失去明文的码;没有自动重复生成。
|
||||
|
||||
## 本地数据
|
||||
|
||||
`sqlite/vault.db` 的敏感记录采用 AES-256-GCM 加密,密钥通过 scrypt 派生;SQLite 结构和记录名称不是密文,不是 SQLCipher 全库加密。Cookie、卡、代理与订单记录分别加密。仅读取 Chrome 的 X `auth_token` / `ct0`。
|
||||
|
||||
`sqlite/password-path` 保存密码文件位置,默认密码文件在 `/tmp/xgift-password-*`。请将原密码安全保存,`/tmp` 清理后不能恢复。也可通过 `XGIFT_PASSWORD_FILE` 或 CLI `--password-file` 指定持久路径。目录权限 `0700`,秘密文件 `0600`。
|
||||
|
||||
`.private/`、`sqlite/`、数据库、日志、构建产物和环境秘密均不提交 Git。服务器密钥与本地密钥独立。本地 `.private/export/admin-password` 保存本次生成的后台密码,勿提交或分享。
|
||||
|
||||
## 服务器部署
|
||||
|
||||
目标服务器 `example-server`。使用专用无登录用户 `xgift`、systemd 服务和 Caddy HTTPS:
|
||||
|
||||
| 路径 | 用途 |
|
||||
|---|---|
|
||||
| `/opt/xgift/bin/xgift-web` | 站点程序 |
|
||||
| `/opt/xgift/bin/xgift` | CLI,维护用 |
|
||||
| `/var/lib/xgift/vault.db` | 加密凭据及订单 |
|
||||
| `/var/lib/xgift/site.db` | 兑换码哈希、用户名、状态;访问权限保护,不是加密库 |
|
||||
| `/etc/xgift/vault-password` | 服务器独立密钥 |
|
||||
| `/etc/xgift/admin-password` | 后台密码 |
|
||||
| `/etc/xgift/site.env` | 服务配置,`XGIFT_PAYMENTS_ENABLED=false` |
|
||||
| `/etc/caddy/conf.d/xp.example.com.caddy` | 本站反向代理 |
|
||||
|
||||
配置模板在 `deploy/`。程序仅允许绑定回环 IP;Caddy 必须覆盖 `X-Real-IP`,不能透传客户端提供的值。DNS 使用直连 A 记录,Caddy 自动签发 HTTPS 证书。现有站点配置不改动。
|
||||
|
||||
```sh
|
||||
ssh example-server 'sudo systemctl status xgift --no-pager'
|
||||
ssh example-server 'sudo journalctl -u xgift --since "1 hour ago" --no-pager'
|
||||
```
|
||||
|
||||
更新时先在服务器构建两个二进制,停止 `xgift`,备份数据及密钥,再替换程序并启动。不要覆盖线上 `site.db` 或用旧的本地 vault 覆盖线上订单。备份必须包含 `site.db`(停服或用 SQLite backup API,不能忽略 WAL)、`vault.db` 及独立密钥;所有备份同样限制权限。
|
||||
|
||||
**开启付款前**:解决 Stripe HTTP 400、人工核实已有 `unknown` 订单,完成独立安全复查并取得新的实际付款指令。不要仅为试错打开环境开关。银行验证、风控和 X 赠送限制仍可能阻止自动完成。
|
||||
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"xgift/internal/site"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
if err := site.Run(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"xgift/internal/checkout"
|
||||
"xgift/internal/chrome"
|
||||
"xgift/internal/proxy"
|
||||
"xgift/internal/vault"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "xgift:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Permit the requested `xgift username --pay` spelling as well as global flags.
|
||||
args := os.Args[1:]
|
||||
command := ""
|
||||
rest := []string{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if strings.HasPrefix(a, "-") {
|
||||
rest = append(rest, a)
|
||||
if a == "--db" || a == "--password-file" || a == "--profile" || a == "--port" || a == "--name" || a == "--months" {
|
||||
i++
|
||||
if i >= len(args) {
|
||||
return errors.New("missing flag value")
|
||||
}
|
||||
rest = append(rest, args[i])
|
||||
}
|
||||
} else if command == "" {
|
||||
command = a
|
||||
} else {
|
||||
return errors.New("unexpected argument")
|
||||
}
|
||||
}
|
||||
f := flag.NewFlagSet("xgift", flag.ContinueOnError)
|
||||
defaultDB := "sqlite/vault.db"
|
||||
if exe, e := os.Executable(); e == nil {
|
||||
if resolved, e := filepath.EvalSymlinks(exe); e == nil {
|
||||
candidate := filepath.Join(filepath.Dir(resolved), "..", "sqlite", "vault.db")
|
||||
if _, e = os.Stat(candidate); e == nil {
|
||||
defaultDB = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
db := f.String("db", defaultDB, "encrypted SQLite record store")
|
||||
key := f.String("password-file", os.Getenv("XGIFT_PASSWORD_FILE"), "owner-only password file")
|
||||
profile := f.String("profile", "Default", "Chrome directory name")
|
||||
port := f.Int("port", 0, "local proxy port; default automatic (proxy command: 18791)")
|
||||
months := f.Int("months", 6, "Premium gift duration: 3 (300 BDT) or 6 (600 BDT)")
|
||||
pay := f.Bool("pay", false, "pay only at the exact allowed BDT total")
|
||||
name := f.String("name", "", "secret name for put")
|
||||
f.Usage = func() {
|
||||
fmt.Fprintln(f.Output(), "Usage: xgift <init|status|billing|import-chrome|put|proxy|check|username> [flags]\ninit reads a JSON object from stdin; put reads one JSON value from stdin.")
|
||||
f.PrintDefaults()
|
||||
}
|
||||
if err := f.Parse(rest); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if command == "" {
|
||||
f.Usage()
|
||||
return nil
|
||||
}
|
||||
if *port != 0 && (*port < 1024 || *port > 65535) {
|
||||
return errors.New("port must be 1024..65535")
|
||||
}
|
||||
if command == "init" {
|
||||
input, err := io.ReadAll(io.LimitReader(os.Stdin, 1024*1024))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clear(input)
|
||||
var records map[string]json.RawMessage
|
||||
if err = json.Unmarshal(input, &records); err != nil {
|
||||
return errors.New("stdin must contain a JSON object of secrets")
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return errors.New("no secrets supplied")
|
||||
}
|
||||
if _, err = os.Stat(*db); !os.IsNotExist(err) {
|
||||
return errors.New("vault already exists or path is inaccessible")
|
||||
}
|
||||
if *key == "" {
|
||||
*key, err = vault.NewPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
v, err := vault.Open(*db, *key, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer v.Close()
|
||||
for n, b := range records {
|
||||
if n == "vault-check" {
|
||||
return errors.New("reserved secret name")
|
||||
}
|
||||
if err = v.Put(n, b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Save only the password's path, never the password, next to the vault.
|
||||
if err = os.WriteFile(filepath.Join(filepath.Dir(*db), "password-path"), []byte(*key+"\n"), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Encrypted vault created: %s\nPassword file: %s\n", *db, *key)
|
||||
return nil
|
||||
}
|
||||
if *key == "" {
|
||||
p, err := os.ReadFile(filepath.Join(filepath.Dir(*db), "password-path"))
|
||||
if err != nil {
|
||||
return errors.New("set --password-file or XGIFT_PASSWORD_FILE")
|
||||
}
|
||||
*key = strings.TrimSpace(string(p))
|
||||
}
|
||||
v, err := vault.Open(*db, *key, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer v.Close()
|
||||
switch command {
|
||||
case "billing":
|
||||
raw, e := v.Get("card")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer clear(raw)
|
||||
var c map[string]any
|
||||
if e = json.Unmarshal(raw, &c); e != nil {
|
||||
return e
|
||||
}
|
||||
var fields map[string]string
|
||||
if e = json.NewDecoder(io.LimitReader(os.Stdin, 65536)).Decode(&fields); e != nil {
|
||||
return errors.New("billing expects a JSON object on stdin")
|
||||
}
|
||||
for n, value := range fields {
|
||||
switch n {
|
||||
case "billing_name", "email", "billing_country", "billing_postal_code", "billing_address_line1", "billing_city", "billing_state":
|
||||
c[n] = value
|
||||
default:
|
||||
return errors.New("unsupported billing field")
|
||||
}
|
||||
}
|
||||
raw, e = json.Marshal(c)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer clear(raw)
|
||||
if e = v.Put("card", raw); e != nil {
|
||||
return e
|
||||
}
|
||||
fmt.Println("Billing information saved in encrypted SQLite")
|
||||
return nil
|
||||
case "status":
|
||||
for _, n := range []string{"cookies", "card", "proxy"} {
|
||||
b, e := v.Get(n)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !json.Valid(b) {
|
||||
return fmt.Errorf("invalid %s JSON", n)
|
||||
}
|
||||
clear(b)
|
||||
fmt.Printf("%s: encrypted record verified\n", n)
|
||||
}
|
||||
return nil
|
||||
case "import-chrome":
|
||||
b, e := chrome.Extract(*profile)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer clear(b)
|
||||
if e = v.Put("cookies", b); e != nil {
|
||||
return e
|
||||
}
|
||||
fmt.Println("X cookies refreshed in encrypted SQLite vault")
|
||||
return nil
|
||||
case "put":
|
||||
if *name != "proxy" && *name != "card" && *name != "cookies" {
|
||||
return errors.New("--name must be proxy, card or cookies")
|
||||
}
|
||||
b, e := io.ReadAll(io.LimitReader(os.Stdin, 1024*1024))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer clear(b)
|
||||
if !json.Valid(b) {
|
||||
return errors.New("stdin must be valid JSON")
|
||||
}
|
||||
return v.Put(*name, b)
|
||||
}
|
||||
if command != "proxy" && command != "check" && !regexp.MustCompile(`^@?[A-Za-z0-9_]{1,15}$`).MatchString(command) {
|
||||
return errors.New("invalid command or X username")
|
||||
}
|
||||
if command != "proxy" && command != "check" {
|
||||
lock, e := os.OpenFile(filepath.Join(filepath.Dir(*db), "checkout.lock"), os.O_CREATE|os.O_RDWR, 0600)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer lock.Close()
|
||||
if e = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); e != nil {
|
||||
return errors.New("another checkout command is running")
|
||||
}
|
||||
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
|
||||
}
|
||||
if *port == 0 {
|
||||
if command == "proxy" {
|
||||
*port = 18791
|
||||
} else {
|
||||
l, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, p, _ := net.SplitHostPort(l.Addr().String())
|
||||
*port, _ = strconv.Atoi(p)
|
||||
l.Close()
|
||||
}
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
config, err := v.Get("proxy")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clear(config)
|
||||
instance, err := proxy.Start(ctx, config, *port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer instance.Close()
|
||||
fmt.Fprintf(os.Stderr, "Embedded sing-box AnyTLS listening on 127.0.0.1:%d\n", *port)
|
||||
if command == "proxy" {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
if command == "check" {
|
||||
return proxy.Check(ctx, *port)
|
||||
}
|
||||
result, e := checkout.Run(ctx, v, command, *pay, *port, *months)
|
||||
if result != nil {
|
||||
if e == nil || result.Status == "requires_action" || result.Status == "unknown" || result.Status == "submitting" {
|
||||
fmt.Println(result.URL)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "Checkout status:", result.Status)
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
xp.example.com {
|
||||
encode zstd gzip
|
||||
header Strict-Transport-Security "max-age=31536000"
|
||||
reverse_proxy 127.0.0.1:8787 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
XGIFT_ORIGIN=https://xp.example.com
|
||||
XGIFT_LISTEN=127.0.0.1:8787
|
||||
XGIFT_DATA_DIR=/var/lib/xgift
|
||||
XGIFT_PASSWORD_FILE=/etc/xgift/vault-password
|
||||
XGIFT_ADMIN_PASSWORD_FILE=/etc/xgift/admin-password
|
||||
XGIFT_PAYMENTS_ENABLED=false
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=XGift redemption site
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=xgift
|
||||
Group=xgift
|
||||
WorkingDirectory=/opt/xgift
|
||||
EnvironmentFile=/etc/xgift/site.env
|
||||
ExecStart=/opt/xgift/bin/xgift-web
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=65
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
ReadWritePaths=/var/lib/xgift
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
LimitCORE=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,67 @@
|
||||
module xgift
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/sagernet/sing v0.9.6-0.20260922013354-87c33f17688f
|
||||
github.com/sagernet/sing-box v1.14.2
|
||||
golang.org/x/crypto v0.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/anytls/sing-anytls v0.0.11 // indirect
|
||||
github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6 // indirect
|
||||
github.com/caddyserver/zerossl v0.1.5 // indirect
|
||||
github.com/database64128/netx-go v0.1.1 // indirect
|
||||
github.com/database64128/tfo-go/v2 v2.3.2 // indirect
|
||||
github.com/florianl/go-nfqueue/v2 v2.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gofrs/uuid/v5 v5.5.1 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.1 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/libdns/acmedns v0.5.0 // indirect
|
||||
github.com/libdns/alidns v1.0.6 // indirect
|
||||
github.com/libdns/cloudflare v0.2.2 // indirect
|
||||
github.com/libdns/libdns v1.1.1 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/mdlayher/netlink v1.11.2 // indirect
|
||||
github.com/mdlayher/socket v0.6.0 // indirect
|
||||
github.com/metacubex/utls v1.8.7 // indirect
|
||||
github.com/mholt/acmez/v3 v3.1.6 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832 // indirect
|
||||
github.com/sagernet/fswatch v0.1.2 // indirect
|
||||
github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1 // indirect
|
||||
github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf // indirect
|
||||
github.com/sagernet/nftables v0.3.0-mod.4 // indirect
|
||||
github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 // indirect
|
||||
github.com/sagernet/sing-mux v0.3.8 // indirect
|
||||
github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b // indirect
|
||||
github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca // indirect
|
||||
github.com/sagernet/sing-vmess v0.2.8 // indirect
|
||||
github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
|
||||
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc=
|
||||
github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8=
|
||||
github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6 h1:LYSB6VgWzKtNrcxElw3c97BP40Oc7bizKxA9K1Vi/5k=
|
||||
github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg=
|
||||
github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
|
||||
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
||||
github.com/cilium/ebpf v0.12.3 h1:8ht6F9MquybnY97at+VDZb3eQQr8ev79RueWeVaEcG4=
|
||||
github.com/cilium/ebpf v0.12.3/go.mod h1:TctK1ivibvI3znr66ljgi4hqOT8EYQjz1KWBfb1UVgM=
|
||||
github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM=
|
||||
github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc=
|
||||
github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw=
|
||||
github.com/database64128/tfo-go/v2 v2.3.2/go.mod h1:GC3uB5oa4beGpCUbRb2ZOWP73bJJFmMyAVgQSO7r724=
|
||||
github.com/florianl/go-nfqueue/v2 v2.1.0 h1:Fywt30TY/evxyDySpXjxQ1jsRW7nQbLpOhELqpr4068=
|
||||
github.com/florianl/go-nfqueue/v2 v2.1.0/go.mod h1:8PKUM5rYoVFO5IZV1bifx4/b0jHAglKkHXr9PRwzi4Y=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gofrs/uuid/v5 v5.5.1 h1:z1Ce19/JwNidXpy3tOQc3241lnJLKdKyq/xlNvlD4Ng=
|
||||
github.com/gofrs/uuid/v5 v5.5.1/go.mod h1:bbAA98EoIlxyRHIVg6ektCSsZ5n8mSbwgEhvhMYlZgg=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
|
||||
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
|
||||
github.com/jsimonetti/rtnetlink v1.4.1 h1:JfD4jthWBqZMEffc5RjgmlzpYttAVw1sdnmiNaPO3hE=
|
||||
github.com/jsimonetti/rtnetlink v1.4.1/go.mod h1:xJjT7t59UIZ62GLZbv6PLLo8VFrostJMPBAheR6OM8w=
|
||||
github.com/jsimonetti/rtnetlink/v2 v2.2.0 h1:/KfZ310gOAFrXXol5VwnFEt+ucldD/0dsSRZwpHCP9w=
|
||||
github.com/jsimonetti/rtnetlink/v2 v2.2.0/go.mod h1:lbjDHxC+5RJ08lzPeA90Ls2pEoId3F08MoEMlhfHxeI=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
|
||||
github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
|
||||
github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U=
|
||||
github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
|
||||
github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE=
|
||||
github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ=
|
||||
github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM=
|
||||
github.com/libdns/alidns v1.0.6/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec=
|
||||
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
|
||||
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
|
||||
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
||||
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
|
||||
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
|
||||
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
|
||||
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
|
||||
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
|
||||
github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs=
|
||||
github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
|
||||
github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk=
|
||||
github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832 h1:KDXMGbzqLmDdKcld0T3wuMuqf1iezZq+DemgtZ/vhtQ=
|
||||
github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832/go.mod h1:7/qFPFH5fqK5XQeY8EA1Qpaly08Av77p38Zt50ieQww=
|
||||
github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk=
|
||||
github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880=
|
||||
github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1 h1:IdQ7yTKkB2wv8txwshxUroPlO4npOYAV71xb7xQ7Lys=
|
||||
github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1/go.mod h1:9O3SQskYuCfdHNvHEsWuEAgoyKEF74PiWp4NsNUia8g=
|
||||
github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf h1:b/3rm+KvxzuYRcnMdLq/hmcqX6/2B8k24l6SDHL2Piw=
|
||||
github.com/sagernet/netlink v0.0.0-20260814022025-64455d367bbf/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
|
||||
github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4ICS2nhlDSo=
|
||||
github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ=
|
||||
github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 h1:sW2O+DoNF+my1PGlSZ3zG2N+fdxLR3DC0l4sXCM0lBE=
|
||||
github.com/sagernet/quic-go v0.61.0-sing-box-mod.7/go.mod h1:hmLC8GJPp+BrpBgrgJnacvI4fZGIhasuy1ROJJ8GR4E=
|
||||
github.com/sagernet/sing v0.9.6-0.20260922013354-87c33f17688f h1:t2lyXQFJmCPsybxKqYE8YlfDIvxK9Z343HOvAX7pIKA=
|
||||
github.com/sagernet/sing v0.9.6-0.20260922013354-87c33f17688f/go.mod h1:K3Owt3xPhHugvlnlPPxZJ/exXdaJfEPOTNorGk4AXjo=
|
||||
github.com/sagernet/sing-box v1.14.2 h1:uZX7iz70o7lzoltR6sElMYP7kI7PVf23r4X0HQSozXM=
|
||||
github.com/sagernet/sing-box v1.14.2/go.mod h1:O4q0spxb+xwV/p9HYR+Es1cMjK3bBvYl6k08V9rSpwU=
|
||||
github.com/sagernet/sing-mux v0.3.8 h1:mM0TLUkr36aEhgUHi9Qh60qcAbD2fZcmnJ30937xxrY=
|
||||
github.com/sagernet/sing-mux v0.3.8/go.mod h1:jricpwCOndDsTEM1TM3piJvhL/2Mm7OhLkm+JadJJ1Q=
|
||||
github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b h1:tjQUA4fQi0OWDMMYfRigzvNlBTYyP/01e+P9BT/RZ4g=
|
||||
github.com/sagernet/sing-tun v0.9.6-0.20260924001923-ddaa4ca25e3b/go.mod h1:3EgPst7agntRO7D6GOsiZ1l9FoqdLeuWmKT5TnWkmf0=
|
||||
github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca h1:5wA+IE0Fq1CGVLOgSpm0gKKZ03HzugcJ2JyhmDqeX6A=
|
||||
github.com/sagernet/sing-usbip v0.0.0-20260817040617-28bd42667eca/go.mod h1:ADAZZU85MnM91XNhd2TdZRTaIbA7RjTZuQ1KCHcUbNg=
|
||||
github.com/sagernet/sing-vmess v0.2.8 h1:xd5nnDOMlC76RgrLksS4jlk3eMt3c3CvQY3NsjWPWeI=
|
||||
github.com/sagernet/sing-vmess v0.2.8/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
|
||||
github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478=
|
||||
github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8=
|
||||
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
|
||||
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,195 @@
|
||||
package checkout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"xgift/internal/vault"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
Username string `json:"username"`
|
||||
RecipientID string `json:"recipient_id"`
|
||||
Months int `json:"months"`
|
||||
Amount int `json:"amount_minor"`
|
||||
Currency string `json:"currency"`
|
||||
ProductID string `json:"product_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
var sessionPattern = regexp.MustCompile(`^cs_live_[A-Za-z0-9]+$`)
|
||||
|
||||
func sessionURL(link, id string) bool {
|
||||
u, e := url.Parse(link)
|
||||
return e == nil && sessionPattern.MatchString(id) && u.Scheme == "https" && u.Host == "checkout.stripe.com" && u.User == nil && u.RawQuery == "" && (u.Path == "/g/pay/"+id || u.Path == "/c/pay/"+id)
|
||||
}
|
||||
func save(v *vault.Vault, r *Record) error {
|
||||
b, e := json.Marshal(r)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return v.Put("checkout:"+r.RecipientID, b)
|
||||
}
|
||||
func Run(ctx context.Context, v *vault.Vault, user string, pay bool, port, months int) (*Record, error) {
|
||||
return run(ctx, v, user, "", pay, port, months)
|
||||
}
|
||||
|
||||
// RunForRecipient pins a redemption to the identity checked before reserving its code.
|
||||
func RunForRecipient(ctx context.Context, v *vault.Vault, user, expectedRecipient string, pay bool, port, months int) (*Record, error) {
|
||||
if expectedRecipient == "" {
|
||||
return nil, errors.New("expected recipient is required")
|
||||
}
|
||||
return run(ctx, v, user, expectedRecipient, pay, port, months)
|
||||
}
|
||||
|
||||
func run(ctx context.Context, v *vault.Vault, user, expectedRecipient string, pay bool, port, months int) (*Record, error) {
|
||||
user = strings.ToLower(strings.TrimPrefix(user, "@"))
|
||||
if !regexp.MustCompile(`^[a-z0-9_]{1,15}$`).MatchString(user) {
|
||||
return nil, errors.New("invalid username")
|
||||
}
|
||||
plan, e := planFor(months)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
x, e := newXClient(v, port)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer x.close()
|
||||
recipient, e := x.recipient(ctx, user)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if expectedRecipient != "" && recipient != expectedRecipient {
|
||||
return nil, errors.New("recipient identity changed; refusing to create or pay an order")
|
||||
}
|
||||
// Fail closed on legacy records rather than silently bypassing an earlier attempt.
|
||||
if _, e = v.Get("checkout:" + user); e == nil {
|
||||
return nil, errors.New("legacy checkout requires migration before creating another order")
|
||||
} else if !errors.Is(e, sql.ErrNoRows) {
|
||||
return nil, e
|
||||
}
|
||||
var r Record
|
||||
raw, e := v.Get("checkout:" + recipient)
|
||||
if e == nil {
|
||||
if json.Unmarshal(raw, &r) != nil || r.RecipientID != recipient || r.Months != plan.Months || r.Amount != plan.Minor || r.Currency != "BDT" || r.ProductID != plan.ProductID || !sessionURL(r.URL, r.SessionID) {
|
||||
return nil, errors.New("existing checkout differs from this recipient or plan; refusing another order")
|
||||
}
|
||||
} else if !errors.Is(e, sql.ErrNoRows) {
|
||||
return nil, e
|
||||
}
|
||||
if r.Status == "succeeded" {
|
||||
return &r, nil
|
||||
}
|
||||
if r.Status != "" && r.Status != "created" && r.Status != "creating" {
|
||||
return &r, fmt.Errorf("existing checkout status is %s; no payment will be resubmitted", r.Status)
|
||||
}
|
||||
if r.Status == "creating" {
|
||||
return &r, errors.New("previous checkout creation outcome is unknown; inspect it before creating another")
|
||||
}
|
||||
if e = x.quote(ctx, user, plan); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.SessionID == "" {
|
||||
r = Record{Username: user, RecipientID: recipient, Months: plan.Months, Amount: plan.Minor, Currency: "BDT", ProductID: plan.ProductID, Status: "creating", Created: time.Now().Unix()}
|
||||
// Persist before creating the external session. A crash must not silently create a second session.
|
||||
if e = save(v, &r); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
r.SessionID, r.URL, e = x.create(ctx, user, recipient, plan)
|
||||
if e != nil {
|
||||
return &r, e
|
||||
}
|
||||
r.Status = "created"
|
||||
if e = save(v, &r); e != nil {
|
||||
return &r, e
|
||||
}
|
||||
}
|
||||
s, e := newStripe(v, port)
|
||||
if e != nil {
|
||||
return &r, e
|
||||
}
|
||||
defer s.close()
|
||||
page, e := s.page(ctx, &r, true)
|
||||
if e != nil {
|
||||
return &r, e
|
||||
}
|
||||
if e = page.guard(&r, plan, false); e != nil {
|
||||
return &r, e
|
||||
}
|
||||
if page.PaymentStatus == "paid" && page.Status == "complete" {
|
||||
r.Status = "succeeded"
|
||||
return &r, save(v, &r)
|
||||
}
|
||||
if e = page.guard(&r, plan, true); e != nil {
|
||||
return &r, e
|
||||
}
|
||||
if !pay {
|
||||
return &r, nil
|
||||
}
|
||||
c, e := readCard(v)
|
||||
if e != nil {
|
||||
return &r, e
|
||||
}
|
||||
method, e := s.tokenize(ctx, &r, c)
|
||||
if e != nil {
|
||||
return &r, e
|
||||
}
|
||||
// Refresh after card creation; confirmation also pins the expected amount server-side.
|
||||
page, e = s.page(ctx, &r, true)
|
||||
if e != nil {
|
||||
return &r, e
|
||||
}
|
||||
if e = page.guard(&r, plan, true); e != nil {
|
||||
return &r, e
|
||||
}
|
||||
r.Status = "submitting"
|
||||
if e = save(v, &r); e != nil {
|
||||
return &r, e
|
||||
}
|
||||
_, confirmErr := s.confirm(ctx, &r, page, method, plan)
|
||||
// Never issue confirm twice, even if the response is lost or a validation error occurs.
|
||||
for i := 0; i < 6; i++ {
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
current, readErr := s.page(ctx, &r, true)
|
||||
if readErr == nil && current.guard(&r, plan, false) == nil {
|
||||
if current.Status == "complete" && current.PaymentStatus == "paid" {
|
||||
r.Status = "succeeded"
|
||||
return &r, save(v, &r)
|
||||
}
|
||||
if current.Intent != nil && current.Intent.Status == "requires_action" {
|
||||
r.Status = "requires_action"
|
||||
if e = save(v, &r); e != nil {
|
||||
return &r, e
|
||||
}
|
||||
return &r, errors.New("bank authentication is required; use this existing checkout")
|
||||
}
|
||||
}
|
||||
if confirmErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
r.Status = "unknown"
|
||||
if e = save(v, &r); e != nil {
|
||||
return &r, e
|
||||
}
|
||||
if confirmErr != nil {
|
||||
return &r, confirmErr
|
||||
}
|
||||
return &r, errors.New("Stripe has not confirmed payment; automatic retry is blocked")
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package checkout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xgift/internal/vault"
|
||||
)
|
||||
|
||||
const xMerchant = "acct_EXAMPLE"
|
||||
|
||||
type stripeClient struct {
|
||||
http *http.Client
|
||||
key string
|
||||
}
|
||||
type stripeError struct {
|
||||
Code, Type string
|
||||
HTTP int
|
||||
}
|
||||
|
||||
func (e *stripeError) Error() string {
|
||||
return fmt.Sprintf("Stripe rejected the request (HTTP %d, type=%s, code=%s)", e.HTTP, e.Type, e.Code)
|
||||
}
|
||||
func newStripe(v *vault.Vault, port int) (*stripeClient, error) {
|
||||
key, e := v.Get("stripe-key")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !regexp.MustCompile(`^pk_live_[A-Za-z0-9]+$`).Match(key) {
|
||||
return nil, errors.New("invalid Stripe merchant publishable key")
|
||||
}
|
||||
p, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", port))
|
||||
return &stripeClient{http: &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(p), TLSHandshakeTimeout: 15 * time.Second}, Timeout: 35 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("unexpected Stripe API redirect") }}, key: string(key)}, nil
|
||||
}
|
||||
func (s *stripeClient) close() { s.http.CloseIdleConnections() }
|
||||
func (s *stripeClient) call(ctx context.Context, method, path string, form url.Values, idempotency string, out any) error {
|
||||
form.Set("key", s.key)
|
||||
target := "https://api.stripe.com/v1/" + path
|
||||
var input io.Reader
|
||||
if method == "GET" {
|
||||
target += "?" + form.Encode()
|
||||
} else {
|
||||
input = strings.NewReader(form.Encode())
|
||||
}
|
||||
req, e := http.NewRequestWithContext(ctx, method, target, input)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if idempotency != "" {
|
||||
req.Header.Set("Idempotency-Key", idempotency)
|
||||
}
|
||||
res, e := s.http.Do(req)
|
||||
if e != nil {
|
||||
return errors.New("Stripe transport failed; request outcome may be unknown")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
raw, e := io.ReadAll(io.LimitReader(res.Body, 2<<20))
|
||||
if e != nil {
|
||||
return errors.New("Stripe response could not be read")
|
||||
}
|
||||
defer clear(raw)
|
||||
var envelope struct {
|
||||
Error *struct{ Type, Code string } `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(raw, &envelope) != nil {
|
||||
return errors.New("Stripe returned non-JSON data")
|
||||
}
|
||||
if envelope.Error != nil {
|
||||
return &stripeError{Code: envelope.Error.Code, Type: envelope.Error.Type, HTTP: res.StatusCode}
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
return &stripeError{HTTP: res.StatusCode}
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
type paymentPage struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Currency string `json:"currency"`
|
||||
Mode string `json:"mode"`
|
||||
Live bool `json:"livemode"`
|
||||
Status string `json:"status"`
|
||||
PaymentStatus string `json:"payment_status"`
|
||||
Checksum string `json:"init_checksum"`
|
||||
SuccessURL string `json:"success_url"`
|
||||
CancelURL string `json:"cancel_url"`
|
||||
Account struct {
|
||||
ID string `json:"account_id"`
|
||||
} `json:"account_settings"`
|
||||
SetupFuture json.RawMessage `json:"setup_future_usage"`
|
||||
Subscription json.RawMessage `json:"subscription_data"`
|
||||
SetupIntent json.RawMessage `json:"setup_intent"`
|
||||
Total struct{ Due, Subtotal, Total int } `json:"total_summary"`
|
||||
Group struct {
|
||||
Currency string `json:"currency"`
|
||||
Due, Subtotal, Total int
|
||||
Items []struct {
|
||||
Name string `json:"name"`
|
||||
Quantity, Subtotal, Total int
|
||||
Price struct {
|
||||
Currency, Type string
|
||||
UnitAmount int `json:"unit_amount"`
|
||||
Recurring json.RawMessage `json:"recurring"`
|
||||
Product struct {
|
||||
ID, Name string
|
||||
Live bool `json:"livemode"`
|
||||
} `json:"product"`
|
||||
} `json:"price"`
|
||||
} `json:"line_items"`
|
||||
} `json:"line_item_group"`
|
||||
Intent *struct {
|
||||
ID, Status, Currency string
|
||||
Amount int
|
||||
AmountReceived int `json:"amount_received"`
|
||||
} `json:"payment_intent"`
|
||||
}
|
||||
|
||||
func nullJSON(v json.RawMessage) bool { return len(v) == 0 || string(v) == "null" }
|
||||
func (p *paymentPage) guard(r *Record, plan Plan, before bool) error {
|
||||
if p.SessionID != r.SessionID || p.Account.ID != xMerchant || !p.Live || p.Mode != "payment" || p.Currency != "bdt" || p.Group.Currency != "bdt" || p.SuccessURL != "https://x.com/"+r.Username+"/gift-premium/success" || p.CancelURL != "https://x.com/"+r.Username+"/gift-premium" {
|
||||
return errors.New("Stripe merchant, session, recipient return URLs, currency or payment mode mismatch")
|
||||
}
|
||||
if !nullJSON(p.SetupFuture) || !nullJSON(p.Subscription) || !nullJSON(p.SetupIntent) {
|
||||
return errors.New("recurring payments or saved-card setup are not allowed")
|
||||
}
|
||||
if p.Total.Total != plan.Minor || p.Total.Subtotal != plan.Minor || p.Group.Total != plan.Minor || p.Group.Subtotal != plan.Minor || len(p.Group.Items) != 1 {
|
||||
return errors.New("Stripe final total or line item count does not match the exact allowed price")
|
||||
}
|
||||
item := p.Group.Items[0]
|
||||
if item.Name != plan.Name() || item.Quantity != 1 || item.Subtotal != plan.Minor || item.Total != plan.Minor || item.Price.Currency != "bdt" || item.Price.Type != "one_time" || item.Price.UnitAmount != plan.Minor || !nullJSON(item.Price.Recurring) || item.Price.Product.ID != plan.ProductID || item.Price.Product.Name != plan.Name() || !item.Price.Product.Live {
|
||||
return errors.New("Stripe product, duration, quantity or unit amount mismatch")
|
||||
}
|
||||
if p.Intent != nil {
|
||||
if p.Intent.Currency != "bdt" || p.Intent.Amount != plan.Minor {
|
||||
return errors.New("Stripe payment intent amount or currency mismatch")
|
||||
}
|
||||
if before {
|
||||
return errors.New("an existing payment intent requires inspection before another submission")
|
||||
}
|
||||
if p.PaymentStatus == "paid" && (p.Intent.Status != "succeeded" || p.Intent.AmountReceived != plan.Minor) {
|
||||
return errors.New("Stripe payment intent does not confirm the exact received amount")
|
||||
}
|
||||
}
|
||||
if before && (p.Status != "open" || p.PaymentStatus != "unpaid" || p.Total.Due != plan.Minor || p.Group.Due != plan.Minor || p.Checksum == "") {
|
||||
return errors.New("Stripe checkout is not open and unpaid at the exact authorized amount")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *stripeClient) page(ctx context.Context, r *Record, init bool) (*paymentPage, error) {
|
||||
method, path := "GET", "payment_pages/"+r.SessionID
|
||||
form := url.Values{}
|
||||
if init {
|
||||
method = "POST"
|
||||
path += "/init"
|
||||
form.Set("browser_locale", "en")
|
||||
form.Set("redirect_type", "url")
|
||||
}
|
||||
var page paymentPage
|
||||
e := s.call(ctx, method, path, form, "", &page)
|
||||
return &page, e
|
||||
}
|
||||
|
||||
type card struct {
|
||||
Number string `json:"number"`
|
||||
Month string `json:"exp_month"`
|
||||
Year string `json:"exp_year"`
|
||||
CVC string `json:"cvc"`
|
||||
Name string `json:"billing_name"`
|
||||
Email string `json:"email"`
|
||||
Country string `json:"billing_country"`
|
||||
Postal string `json:"billing_postal_code"`
|
||||
}
|
||||
|
||||
func readCard(v *vault.Vault) (card, error) {
|
||||
raw, e := v.Get("card")
|
||||
if e != nil {
|
||||
return card{}, e
|
||||
}
|
||||
defer clear(raw)
|
||||
var c card
|
||||
if json.Unmarshal(raw, &c) != nil {
|
||||
return c, errors.New("invalid card record")
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9]{12,19}$`).MatchString(c.Number) || !regexp.MustCompile(`^(0[1-9]|1[0-2])$`).MatchString(c.Month) || !regexp.MustCompile(`^[0-9]{4}$`).MatchString(c.Year) || !regexp.MustCompile(`^[0-9]{3,4}$`).MatchString(c.CVC) || strings.TrimSpace(c.Name) == "" || !strings.Contains(c.Email, "@") {
|
||||
return c, errors.New("card, cardholder name or email is incomplete")
|
||||
}
|
||||
sum := 0
|
||||
for i, n := range c.Number {
|
||||
d := int(n - '0')
|
||||
if (len(c.Number)-i)%2 == 0 {
|
||||
d *= 2
|
||||
if d > 9 {
|
||||
d -= 9
|
||||
}
|
||||
}
|
||||
sum += d
|
||||
}
|
||||
if sum%10 != 0 {
|
||||
return c, errors.New("card number checksum is invalid")
|
||||
}
|
||||
year, _ := strconv.Atoi(c.Year)
|
||||
month, _ := strconv.Atoi(c.Month)
|
||||
if !time.Now().Before(time.Date(year, time.Month(month)+1, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
return c, errors.New("card has expired")
|
||||
}
|
||||
if c.Country != "" && !regexp.MustCompile(`^[A-Z]{2}$`).MatchString(c.Country) {
|
||||
return c, errors.New("invalid supplied billing country")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func (s *stripeClient) tokenize(ctx context.Context, r *Record, c card) (string, error) {
|
||||
form := url.Values{"type": {"card"}, "card[number]": {c.Number}, "card[exp_month]": {c.Month}, "card[exp_year]": {c.Year}, "card[cvc]": {c.CVC}, "billing_details[name]": {c.Name}, "billing_details[email]": {c.Email}}
|
||||
if c.Country != "" {
|
||||
form.Set("billing_details[address][country]", c.Country)
|
||||
}
|
||||
if c.Postal != "" {
|
||||
form.Set("billing_details[address][postal_code]", c.Postal)
|
||||
}
|
||||
var pm struct {
|
||||
ID, Type string
|
||||
Live bool `json:"livemode"`
|
||||
}
|
||||
e := s.call(ctx, "POST", "payment_methods", form, idempotency(r, "method"), &pm)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if !pm.Live || pm.Type != "card" || !regexp.MustCompile(`^pm_[A-Za-z0-9]+$`).MatchString(pm.ID) {
|
||||
return "", errors.New("invalid Stripe card token")
|
||||
}
|
||||
return pm.ID, nil
|
||||
}
|
||||
func idempotency(r *Record, operation string) string {
|
||||
sum := sha256.Sum256([]byte("xgift-v1:" + operation + ":" + r.SessionID + ":" + r.RecipientID + ":" + strconv.Itoa(r.Months)))
|
||||
return "xgift-" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
func (s *stripeClient) confirm(ctx context.Context, r *Record, p *paymentPage, method string, plan Plan) (*paymentPage, error) {
|
||||
form := url.Values{"payment_method": {method}, "expected_amount": {strconv.Itoa(plan.Minor)}, "expected_payment_method_type": {"card"}, "init_checksum": {p.Checksum}, "return_url": {"https://x.com/" + r.Username + "/gift-premium/success"}, "save_payment_method": {"false"}}
|
||||
var result paymentPage
|
||||
e := s.call(ctx, "POST", "payment_pages/"+r.SessionID+"/confirm", form, idempotency(r, "confirm"), &result)
|
||||
return &result, e
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package checkout
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"xgift/internal/vault"
|
||||
)
|
||||
|
||||
type Plan struct {
|
||||
Months, Minor int
|
||||
ProductID string
|
||||
}
|
||||
|
||||
var ErrNotEligible = errors.New("recipient cannot receive Premium gifts")
|
||||
var ErrUserNotFound = errors.New("recipient was not found")
|
||||
|
||||
// Eligibility is read-only: it neither creates a checkout nor submits a payment.
|
||||
func Eligibility(ctx context.Context, v *vault.Vault, user string, port int) (string, error) {
|
||||
c, err := newXClient(v, port)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer c.close()
|
||||
return c.recipient(ctx, user)
|
||||
}
|
||||
|
||||
func planFor(months int) (Plan, error) {
|
||||
switch months {
|
||||
case 3:
|
||||
return Plan{3, 30000, "prod_EXAMPLE3MO"}, nil
|
||||
case 6:
|
||||
return Plan{6, 60000, "prod_EXAMPLE6MO"}, nil
|
||||
}
|
||||
return Plan{}, errors.New("only 3 months / 300 BDT or 6 months / 600 BDT are allowed")
|
||||
}
|
||||
func (p Plan) Name() string { return fmt.Sprintf("Premium Gift - %d months", p.Months) }
|
||||
|
||||
type xClient struct {
|
||||
http *http.Client
|
||||
headers http.Header
|
||||
}
|
||||
|
||||
func newXClient(v *vault.Vault, port int) (*xClient, error) {
|
||||
raw, e := v.Get("api-auth")
|
||||
if e != nil {
|
||||
return nil, errors.New("X API authentication metadata is missing or unreadable")
|
||||
}
|
||||
defer clear(raw)
|
||||
var auth struct{ Authorization, UserAgent string }
|
||||
if json.Unmarshal(raw, &auth) != nil || !strings.HasPrefix(auth.Authorization, "Bearer ") {
|
||||
return nil, errors.New("invalid X API authentication metadata")
|
||||
}
|
||||
raw, e = v.Get("cookies")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer clear(raw)
|
||||
var state struct {
|
||||
Cookies []struct{ Name, Value, Domain string }
|
||||
}
|
||||
if e = json.Unmarshal(raw, &state); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
h := http.Header{"Authorization": {auth.Authorization}, "User-Agent": {auth.UserAgent}, "Content-Type": {"application/json"}, "Origin": {"https://x.com"}, "X-Twitter-Auth-Type": {"OAuth2Session"}, "X-Twitter-Active-User": {"yes"}, "X-Twitter-Client-Language": {"en"}}
|
||||
found := map[string]bool{}
|
||||
for _, c := range state.Cookies {
|
||||
if c.Domain != ".x.com" && c.Domain != "x.com" {
|
||||
return nil, errors.New("unexpected cookie domain")
|
||||
}
|
||||
if c.Name != "auth_token" && c.Name != "ct0" {
|
||||
return nil, errors.New("unexpected cookie name")
|
||||
}
|
||||
if found[c.Name] || c.Value == "" || strings.ContainsAny(c.Value, "\r\n;") {
|
||||
return nil, errors.New("invalid X cookie")
|
||||
}
|
||||
found[c.Name] = true
|
||||
h.Add("Cookie", c.Name+"="+c.Value)
|
||||
if c.Name == "ct0" {
|
||||
h.Set("X-Csrf-Token", c.Value)
|
||||
}
|
||||
}
|
||||
if !found["ct0"] || !found["auth_token"] {
|
||||
return nil, errors.New("required X cookies missing")
|
||||
}
|
||||
p, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", port))
|
||||
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(p), TLSHandshakeTimeout: 15 * time.Second}, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("unexpected X API redirect") }}
|
||||
return &xClient{client, h}, nil
|
||||
}
|
||||
func (c *xClient) close() { c.http.CloseIdleConnections() }
|
||||
func (c *xClient) call(ctx context.Context, user, name, id string, variables any, mutation bool, out any) error {
|
||||
target := "https://x.com/i/api/graphql/" + id + "/" + name
|
||||
method := "GET"
|
||||
var body []byte
|
||||
if mutation {
|
||||
method = "POST"
|
||||
body, _ = json.Marshal(map[string]any{"variables": variables, "queryId": id})
|
||||
} else {
|
||||
b, _ := json.Marshal(variables)
|
||||
q := url.Values{"variables": {string(b)}}
|
||||
if name == "useSubscriptionProductDetailsByRestIdQuery" {
|
||||
q.Set("features", `{"subscriptions_marketing_page_fetch_promotions":true}`)
|
||||
}
|
||||
target += "?" + q.Encode()
|
||||
}
|
||||
req, e := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
req.Header = c.headers.Clone()
|
||||
req.Header.Set("Referer", "https://x.com/"+user+"/gift-premium")
|
||||
res, e := c.http.Do(req)
|
||||
if e != nil {
|
||||
return fmt.Errorf("X %s request failed", name)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
return fmt.Errorf("X %s returned HTTP %d; no payment attempted", name, res.StatusCode)
|
||||
}
|
||||
raw, e := io.ReadAll(io.LimitReader(res.Body, 2<<20))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
var envelope struct {
|
||||
Errors []json.RawMessage `json:"errors"`
|
||||
}
|
||||
if e = json.Unmarshal(raw, &envelope); e != nil {
|
||||
return errors.New("X returned non-JSON data")
|
||||
}
|
||||
if len(envelope.Errors) > 0 {
|
||||
return fmt.Errorf("X %s rejected the operation", name)
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
func (c *xClient) recipient(ctx context.Context, user string) (string, error) {
|
||||
var r struct {
|
||||
Data struct {
|
||||
User struct {
|
||||
Result struct {
|
||||
ID string `json:"rest_id"`
|
||||
Eligible bool `json:"premium_gifting_eligible"`
|
||||
Core struct {
|
||||
Screen string `json:"screen_name"`
|
||||
} `json:"core"`
|
||||
} `json:"result"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if e := c.call(ctx, user, "PremiumGiftingQuery", "kn8hCE6bHstQV2MtfYDTKg", map[string]string{"screenName": user}, false, &r); e != nil {
|
||||
return "", e
|
||||
}
|
||||
u := r.Data.User.Result
|
||||
if u.ID == "" {
|
||||
return "", ErrUserNotFound
|
||||
}
|
||||
if !strings.EqualFold(u.Core.Screen, user) {
|
||||
return "", errors.New("recipient identity or gift eligibility could not be verified")
|
||||
}
|
||||
if !u.Eligible {
|
||||
return "", ErrNotEligible
|
||||
}
|
||||
return u.ID, nil
|
||||
}
|
||||
func (c *xClient) quote(ctx context.Context, user string, p Plan) error {
|
||||
var r struct {
|
||||
Data struct {
|
||||
Product struct {
|
||||
ID string `json:"rest_id"`
|
||||
Prices []struct {
|
||||
Amount int64 `json:"amount_local_micro"`
|
||||
Currency string `json:"currency_code"`
|
||||
Type string `json:"price_type"`
|
||||
} `json:"prices"`
|
||||
} `json:"web_subscription_product_details_by_rest_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if e := c.call(ctx, user, "useSubscriptionProductDetailsByRestIdQuery", "Se1Bp6zcNnuXYXRecV2qLA", map[string]string{"stripeId": p.ProductID}, false, &r); e != nil {
|
||||
return e
|
||||
}
|
||||
product := r.Data.Product
|
||||
if product.ID != p.ProductID || len(product.Prices) != 1 {
|
||||
return errors.New("unexpected X product or price list")
|
||||
}
|
||||
price := product.Prices[0]
|
||||
if price.Type != "OneTime" || !strings.EqualFold(price.Currency, "bdt") || price.Amount != int64(p.Minor)*10000 {
|
||||
return errors.New("X price is not exactly the allowed one-time BDT amount")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *xClient) create(ctx context.Context, user, recipient string, p Plan) (string, string, error) {
|
||||
var r struct {
|
||||
Data struct {
|
||||
Gift struct {
|
||||
ID string `json:"session_id"`
|
||||
URL string `json:"session_url"`
|
||||
Status string `json:"session_status"`
|
||||
} `json:"onetimepurchase_gift"`
|
||||
} `json:"data"`
|
||||
}
|
||||
variables := map[string]string{"cancel_url": "https://x.com/" + user + "/gift-premium", "success_url": "https://x.com/" + user + "/gift-premium/success", "external_product_id": p.ProductID, "gift_recipient": recipient}
|
||||
if e := c.call(ctx, user, "useOneTimePurchaseGiftMutation", "GqTVJ4S1526tLkxj69xIZw", variables, true, &r); e != nil {
|
||||
return "", "", e
|
||||
}
|
||||
s := r.Data.Gift
|
||||
if s.Status != "Unpaid" || !sessionURL(s.URL, s.ID) {
|
||||
return "", "", errors.New("X did not return an unpaid matching Stripe checkout")
|
||||
}
|
||||
return s.ID, s.URL, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package chrome
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Extract only the two X authentication cookies. No other site's credentials are read.
|
||||
func Extract(profile string) ([]byte, error) {
|
||||
if profile != "Default" && !regexp.MustCompile(`^Profile [0-9]+$`).MatchString(profile) {
|
||||
return nil, errors.New("invalid Chrome profile directory")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(home, "Library/Application Support/Google/Chrome", profile, "Cookies")
|
||||
if _, err = os.Stat(path); err != nil {
|
||||
path = filepath.Join(filepath.Dir(path), "Network", "Cookies")
|
||||
}
|
||||
u := url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro"}
|
||||
db, err := sql.Open("sqlite3", u.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
var versionText string
|
||||
if err = db.QueryRow("SELECT value FROM meta WHERE key='version'").Scan(&versionText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version, err := strconv.Atoi(versionText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := exec.Command("security", "find-generic-password", "-w", "-s", "Chrome Safe Storage").Output()
|
||||
if err != nil {
|
||||
return nil, errors.New("Chrome Safe Storage unavailable")
|
||||
}
|
||||
defer clear(secret)
|
||||
key := pbkdf2.Key(bytes.TrimSuffix(secret, []byte("\n")), []byte("saltysalt"), 1003, 16, sha1.New)
|
||||
defer clear(key)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := db.Query("SELECT host_key,name,value,encrypted_value,path,is_secure,is_httponly,expires_utc,samesite FROM cookies WHERE host_key IN ('.x.com','x.com') AND name IN ('auth_token','ct0')")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
cookies := []map[string]any{}
|
||||
found := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var host, name, value, path string
|
||||
var encrypted []byte
|
||||
var secure, httpOnly, sameSite int
|
||||
var expires int64
|
||||
if err = rows.Scan(&host, &name, &value, &encrypted, &path, &secure, &httpOnly, &expires, &sameSite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(encrypted) > 0 {
|
||||
if !bytes.HasPrefix(encrypted, []byte("v10")) || (len(encrypted)-3)%aes.BlockSize != 0 || len(encrypted) < 19 {
|
||||
return nil, errors.New("unsupported cookie encryption")
|
||||
}
|
||||
raw := make([]byte, len(encrypted)-3)
|
||||
cipher.NewCBCDecrypter(block, bytes.Repeat([]byte(" "), 16)).CryptBlocks(raw, encrypted[3:])
|
||||
pad := int(raw[len(raw)-1])
|
||||
if pad < 1 || pad > 16 || !bytes.Equal(raw[len(raw)-pad:], bytes.Repeat([]byte{byte(pad)}, pad)) {
|
||||
return nil, errors.New("cookie decryption failed")
|
||||
}
|
||||
raw = raw[:len(raw)-pad]
|
||||
if version >= 24 {
|
||||
digest := sha256.Sum256([]byte(host))
|
||||
if len(raw) < 32 || !bytes.Equal(raw[:32], digest[:]) {
|
||||
return nil, errors.New("cookie integrity check failed")
|
||||
}
|
||||
raw = raw[32:]
|
||||
}
|
||||
value = string(raw)
|
||||
clear(raw)
|
||||
}
|
||||
expiry := float64(-1)
|
||||
if expires > 0 {
|
||||
expiry = float64(expires)/1e6 - 11644473600
|
||||
if expiry < float64(time.Now().Unix()) {
|
||||
return nil, fmt.Errorf("X cookie %s expired", name)
|
||||
}
|
||||
}
|
||||
same := "Lax"
|
||||
if sameSite == 0 {
|
||||
same = "None"
|
||||
}
|
||||
if sameSite == 2 {
|
||||
same = "Strict"
|
||||
}
|
||||
cookies = append(cookies, map[string]any{"name": name, "value": value, "domain": host, "path": path, "secure": secure != 0, "httpOnly": httpOnly != 0, "expires": expiry, "sameSite": same})
|
||||
found[name] = true
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found["auth_token"] || !found["ct0"] {
|
||||
return nil, errors.New("required X cookies not found")
|
||||
}
|
||||
return json.Marshal(map[string]any{"cookies": cookies, "origins": []any{}})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdjson "encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
box "github.com/sagernet/sing-box"
|
||||
"github.com/sagernet/sing-box/adapter/certificate"
|
||||
"github.com/sagernet/sing-box/adapter/endpoint"
|
||||
"github.com/sagernet/sing-box/adapter/inbound"
|
||||
"github.com/sagernet/sing-box/adapter/outbound"
|
||||
boxservice "github.com/sagernet/sing-box/adapter/service"
|
||||
"github.com/sagernet/sing-box/dns"
|
||||
"github.com/sagernet/sing-box/dns/transport/local"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-box/protocol/anytls"
|
||||
"github.com/sagernet/sing-box/protocol/mixed"
|
||||
"github.com/sagernet/sing/common/json"
|
||||
)
|
||||
|
||||
func Start(ctx context.Context, config []byte, port int) (*box.Box, error) {
|
||||
in := inbound.NewRegistry()
|
||||
mixed.RegisterInbound(in)
|
||||
out := outbound.NewRegistry()
|
||||
anytls.RegisterOutbound(out)
|
||||
d := dns.NewTransportRegistry()
|
||||
local.RegisterTransport(d)
|
||||
ctx = box.Context(ctx, in, out, endpoint.NewRegistry(), d, boxservice.NewRegistry(), certificate.NewRegistry())
|
||||
var raw map[string]any
|
||||
if err := stdjson.Unmarshal(config, &raw); err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy configuration")
|
||||
}
|
||||
raw["log"] = map[string]any{"disabled": true}
|
||||
raw["inbounds"] = []any{map[string]any{"type": "mixed", "listen": "127.0.0.1", "listen_port": port}}
|
||||
data, err := stdjson.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts, err := json.UnmarshalExtendedContext[option.Options](ctx, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid embedded sing-box options")
|
||||
}
|
||||
instance, err := box.New(box.Options{Context: ctx, Options: opts})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize embedded sing-box: %w", err)
|
||||
}
|
||||
if err = instance.Start(); err != nil {
|
||||
instance.Close()
|
||||
return nil, fmt.Errorf("start embedded sing-box: %w", err)
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func Check(ctx context.Context, port int) error {
|
||||
p, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", port))
|
||||
tr := &http.Transport{Proxy: http.ProxyURL(p), TLSHandshakeTimeout: 15 * time.Second}
|
||||
defer tr.CloseIdleConnections()
|
||||
client := &http.Client{Transport: tr, Timeout: 25 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://x.com", nil)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("local proxy is running, but HTTPS to X failed: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
fmt.Fprintf(os.Stderr, "X HTTPS reachable, HTTP status %d\n", res.StatusCode)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>XGift · 管理后台</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<script src="/admin.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav">
|
||||
<a class="brand" href="/">X<span class="brand-dot">✦</span>GIFT</a
|
||||
><span class="eyebrow">ADMIN CONSOLE</span>
|
||||
</header>
|
||||
<main class="admin">
|
||||
<div class="admin-heading">
|
||||
<div>
|
||||
<span class="step">CONTROL ROOM</span>
|
||||
<h1>兑换码管理</h1>
|
||||
</div>
|
||||
<span id="service-status" class="pill">正在读取状态</span>
|
||||
</div>
|
||||
<div class="admin-grid">
|
||||
<section class="card">
|
||||
<h2>批量生成</h2>
|
||||
<p class="muted">兑换码明文只在生成时显示,请立即下载保存。</p>
|
||||
<form id="generate-form">
|
||||
<label for="months">套餐</label
|
||||
><select id="months">
|
||||
<option value="6">6 个月 Premium · 600 BDT</option>
|
||||
<option value="3">3 个月 Premium · 300 BDT</option></select
|
||||
><label for="count">数量</label
|
||||
><input
|
||||
id="count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
value="10"
|
||||
required
|
||||
/><label for="batch">批次名称 <span>选填</span></label
|
||||
><input
|
||||
id="batch"
|
||||
maxlength="40"
|
||||
placeholder="例如:十月赠礼"
|
||||
/><button id="generate-button" type="submit">
|
||||
生成兑换码 <span>+</span>
|
||||
</button>
|
||||
</form>
|
||||
<div id="admin-result" class="result" role="status" hidden></div>
|
||||
<div id="generated" hidden>
|
||||
<label for="new-codes">本次生成的兑换码</label
|
||||
><textarea id="new-codes" rows="9" readonly></textarea
|
||||
><button id="download" class="secondary" type="button">
|
||||
下载兑换码 TXT</button
|
||||
><button id="clear-codes" class="secondary" type="button">
|
||||
已保存,清除本页明文
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card admin-info">
|
||||
<span class="tiny-star">✦</span>
|
||||
<h2>每一次兑换,<br />都有明确的状态。</h2>
|
||||
<dl>
|
||||
<dt>可使用</dt>
|
||||
<dd>用户可以提交,停用后不可再兑换。</dd>
|
||||
<dt>处理中</dt>
|
||||
<dd>兑换码已锁定,请等待结果。</dd>
|
||||
<dt>已完成</dt>
|
||||
<dd>仅在支付服务确认成功后显示。</dd>
|
||||
<dt>待核实</dt>
|
||||
<dd>结果不确定或需要额外验证,必须人工核对,不会自动再次付款。</dd>
|
||||
</dl>
|
||||
<p class="muted">
|
||||
付款限额:3 个月恰好 300 BDT;6 个月恰好 600
|
||||
BDT。金额或币种不符时停止。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<section class="card table-card">
|
||||
<div class="table-heading">
|
||||
<h2>兑换码列表</h2>
|
||||
<button id="refresh" class="secondary compact" type="button">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted">
|
||||
每页 100 条,展示末 8 位识别码。没有自动重试或强制标记成功入口。
|
||||
</p>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>识别码 / 批次</th>
|
||||
<th>套餐</th>
|
||||
<th>状态</th>
|
||||
<th>用户名</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="codes-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button id="prev" class="secondary compact" type="button">
|
||||
上一页</button
|
||||
><span id="page-label"></span
|
||||
><button id="next" class="secondary compact" type="button">
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
<span>XGift · 管理后台</span
|
||||
><span>请勿分享管理员密码或未使用的兑换码</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,177 @@
|
||||
"use strict";
|
||||
const statusNames = {
|
||||
active: "可使用",
|
||||
processing: "处理中",
|
||||
succeeded: "已完成",
|
||||
review: "待核实",
|
||||
revoked: "已停用",
|
||||
};
|
||||
const result = document.querySelector("#admin-result"),
|
||||
form = document.querySelector("#generate-form");
|
||||
let downloadText = "",
|
||||
batchName = "codes",
|
||||
page = 0;
|
||||
function show(text, error = false) {
|
||||
result.hidden = false;
|
||||
result.textContent = text;
|
||||
result.classList.toggle("error", error);
|
||||
}
|
||||
async function api(path, body) {
|
||||
const response = await fetch(
|
||||
path,
|
||||
body
|
||||
? {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
: {},
|
||||
);
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
throw new Error("服务暂时不可用。");
|
||||
}
|
||||
if (!response.ok) throw new Error(data.message || "请求失败");
|
||||
return data;
|
||||
}
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await api("/api/admin/codes?page=" + page);
|
||||
document.querySelector("#prev").disabled = page === 0;
|
||||
document.querySelector("#next").disabled = !data.has_more;
|
||||
document.querySelector("#page-label").textContent =
|
||||
"第 " + (page + 1) + " 页";
|
||||
document.querySelector("#service-status").textContent =
|
||||
data.payments_enabled ? "充值已开放" : "充值暂停 · 不会付款";
|
||||
const body = document.querySelector("#codes-body");
|
||||
body.replaceChildren();
|
||||
for (const c of data.codes) {
|
||||
const tr = document.createElement("tr");
|
||||
const id = document.createElement("td");
|
||||
id.textContent = "…" + c.hint;
|
||||
const batch = document.createElement("small");
|
||||
batch.textContent = c.batch;
|
||||
id.append(batch);
|
||||
tr.append(id);
|
||||
const months = document.createElement("td");
|
||||
months.textContent = c.months + " 个月";
|
||||
tr.append(months);
|
||||
const state = document.createElement("td");
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "badge " + c.status;
|
||||
badge.textContent = statusNames[c.status] || c.status;
|
||||
badge.title = c.message;
|
||||
state.append(badge);
|
||||
tr.append(state);
|
||||
const user = document.createElement("td");
|
||||
user.textContent = c.username ? "@" + c.username : "—";
|
||||
tr.append(user);
|
||||
const date = document.createElement("td");
|
||||
date.textContent = new Date(c.created * 1000).toLocaleString();
|
||||
tr.append(date);
|
||||
const action = document.createElement("td");
|
||||
if (c.status === "active") {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "secondary compact";
|
||||
b.textContent = "停用";
|
||||
b.addEventListener("click", async () => {
|
||||
if (!confirm("停用尾号 " + c.hint + " 的兑换码?此操作不可撤销。"))
|
||||
return;
|
||||
b.disabled = true;
|
||||
try {
|
||||
await api("/api/admin/revoke", { id: c.id });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
show(e.message, true);
|
||||
b.disabled = false;
|
||||
}
|
||||
});
|
||||
action.append(b);
|
||||
} else {
|
||||
action.textContent = "—";
|
||||
}
|
||||
tr.append(action);
|
||||
body.append(tr);
|
||||
}
|
||||
if (!data.codes.length) {
|
||||
const tr = document.createElement("tr"),
|
||||
td = document.createElement("td");
|
||||
td.colSpan = 6;
|
||||
td.textContent = "还没有兑换码,在上方生成第一批。";
|
||||
tr.append(td);
|
||||
body.append(tr);
|
||||
}
|
||||
} catch (e) {
|
||||
show(e.message, true);
|
||||
}
|
||||
}
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
downloadText &&
|
||||
!confirm("上一批兑换码已下载保存?生成下一批会替换当前显示。")
|
||||
)
|
||||
return;
|
||||
const button = document.querySelector("#generate-button");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const data = await api("/api/admin/codes", {
|
||||
months: Number(document.querySelector("#months").value),
|
||||
count: Number(document.querySelector("#count").value),
|
||||
batch: document.querySelector("#batch").value,
|
||||
});
|
||||
downloadText = data.codes.join("\n") + "\n";
|
||||
batchName = "xgift-" + data.months + "mo-" + Date.now();
|
||||
document.querySelector("#new-codes").value = downloadText;
|
||||
document.querySelector("#generated").hidden = false;
|
||||
show(
|
||||
"已生成 " +
|
||||
data.codes.length +
|
||||
" 枚兑换码,请立即下载。本页刷新后无法恢复明文。",
|
||||
);
|
||||
page = 0;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
show(e.message + " 若连接中断,请先核实批次列表,不要立即重复生成。", true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
document.querySelector("#download").addEventListener("click", () => {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([downloadText], { type: "text/plain;charset=utf-8" }),
|
||||
);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = batchName + ".txt";
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
});
|
||||
document.querySelector("#clear-codes").addEventListener("click", () => {
|
||||
if (!confirm("确认已保存兑换码?清除后无法恢复明文。")) return;
|
||||
downloadText = "";
|
||||
document.querySelector("#new-codes").value = "";
|
||||
document.querySelector("#generated").hidden = true;
|
||||
});
|
||||
document.querySelector("#refresh").addEventListener("click", refresh);
|
||||
refresh();
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (downloadText) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#prev").addEventListener("click", () => {
|
||||
if (page > 0) {
|
||||
page--;
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
document.querySelector("#next").addEventListener("click", () => {
|
||||
page++;
|
||||
refresh();
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
"use strict";
|
||||
const form = document.querySelector("#redeem-form"),
|
||||
result = document.querySelector("#result");
|
||||
const buttons = [
|
||||
document.querySelector("#redeem-button"),
|
||||
document.querySelector("#status-button"),
|
||||
];
|
||||
let timer = null,
|
||||
attempt = 0,
|
||||
current = null;
|
||||
function show(text, error = false) {
|
||||
result.hidden = false;
|
||||
result.textContent = text;
|
||||
result.classList.toggle("error", error);
|
||||
}
|
||||
function busy(value) {
|
||||
buttons.forEach((b) => (b.disabled = value));
|
||||
}
|
||||
function input() {
|
||||
return {
|
||||
code: document.querySelector("#code").value.trim(),
|
||||
username: document.querySelector("#username").value.trim(),
|
||||
};
|
||||
}
|
||||
async function request(path, payload) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
throw new Error("服务暂时不可用,请稍后查询兑换进度。");
|
||||
}
|
||||
return { response, data };
|
||||
}
|
||||
async function query() {
|
||||
try {
|
||||
const { response, data } = await request("/api/status", current);
|
||||
show(
|
||||
data.message ||
|
||||
{ active: "兑换码尚未使用。", revoked: "兑换码已停用。" }[
|
||||
data.status
|
||||
] ||
|
||||
"请联系管理员核实。",
|
||||
!response.ok || data.status === "review",
|
||||
);
|
||||
if (data.status === "processing" && attempt++ < 36) {
|
||||
timer = setTimeout(query, 5000);
|
||||
} else {
|
||||
busy(false);
|
||||
}
|
||||
} catch (e) {
|
||||
show(e.message, true);
|
||||
busy(false);
|
||||
}
|
||||
}
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(timer);
|
||||
busy(true);
|
||||
show("正在核实 X 账号和赠送资格…");
|
||||
current = input();
|
||||
attempt = 0;
|
||||
try {
|
||||
const { response, data } = await request("/api/redeem", current);
|
||||
show(data.message, !response.ok || data.status === "review");
|
||||
if (data.status === "processing") {
|
||||
timer = setTimeout(query, 5000);
|
||||
} else {
|
||||
busy(false);
|
||||
}
|
||||
} catch {
|
||||
show(
|
||||
"连接中断,结果尚不确定。请点击「查询兑换进度」,不要更换兑换码重复提交。",
|
||||
true,
|
||||
);
|
||||
busy(false);
|
||||
}
|
||||
});
|
||||
buttons[1].addEventListener("click", () => {
|
||||
if (!form.reportValidity()) return;
|
||||
clearTimeout(timer);
|
||||
current = input();
|
||||
attempt = 0;
|
||||
busy(true);
|
||||
query();
|
||||
});
|
||||
|
||||
fetch("/healthz")
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error();
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (!data.payments_enabled) {
|
||||
const notice = document.querySelector("#service-notice");
|
||||
notice.textContent =
|
||||
"充值暂未开放。可以先核实账号赠送资格,兑换码不会被使用。";
|
||||
notice.hidden = false;
|
||||
buttons[0].firstChild.textContent = "核实赠送资格 ";
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="15" fill="#20231f"/><path d="M32 10Q35 27 54 32Q36 36 32 54Q28 36 10 32Q28 28 32 10Z" fill="#bfd67c"/></svg>
|
||||
|
After Width: | Height: | Size: 202 B |
@@ -0,0 +1,100 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>XGift · Premium 兑换</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<script src="/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav">
|
||||
<a class="brand" href="/">X<span class="brand-dot">✦</span>GIFT</a
|
||||
><span class="eyebrow">A LITTLE MORE PREMIUM</span>
|
||||
</header>
|
||||
<main class="landing">
|
||||
<section class="intro">
|
||||
<div class="pill"><span class="dot"></span> PREMIUM GIFT</div>
|
||||
<h1>让你的 X,<br />多一点<span class="accent">可能。</span></h1>
|
||||
<p class="lede">
|
||||
一枚兑换码,一份 Premium。<br />输入你的用户名,接收属于你的赠礼。
|
||||
</p>
|
||||
<div class="benefits">
|
||||
<div>
|
||||
<span class="benefit-icon">✓</span
|
||||
><span>只需用户名<small>无需提供 X 密码</small></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="benefit-icon">↗</span
|
||||
><span>进度随时可查<small>保留兑换码即可查询</small></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="art" aria-hidden="true">
|
||||
<span class="art-star">✦</span><span class="art-word">PREMIUM</span
|
||||
><span class="art-tag">A GIFT FOR YOU ↗</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card redeem-card">
|
||||
<div class="card-top">
|
||||
<span class="step">01 / REDEEM</span><span class="tiny-star">✦</span>
|
||||
</div>
|
||||
<h2>兑换你的 Premium</h2>
|
||||
<p class="muted">套餐时长以兑换码为准。</p>
|
||||
<div id="service-notice" class="result" role="status" hidden></div>
|
||||
<form id="redeem-form">
|
||||
<label for="code">兑换码</label
|
||||
><textarea
|
||||
id="code"
|
||||
name="code"
|
||||
rows="2"
|
||||
placeholder="粘贴 XG- 开头的完整兑换码"
|
||||
maxlength="80"
|
||||
required
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
></textarea
|
||||
><label for="username">X 用户名 <span>不是显示名称</span></label>
|
||||
<div class="username-input">
|
||||
<span>@</span
|
||||
><input
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="your_username"
|
||||
maxlength="16"
|
||||
pattern="@?[A-Za-z0-9_]{1,15}"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p class="field-help">请核对用户名,赠送成功后无法更换账号。</p>
|
||||
<button id="redeem-button" type="submit">
|
||||
兑换 Premium <span>↗</span></button
|
||||
><button id="status-button" class="secondary" type="button">
|
||||
查询兑换进度
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
id="result"
|
||||
class="result"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
></div>
|
||||
<div class="card-note">
|
||||
<span>ⓘ</span>
|
||||
<p>
|
||||
我们会先检查账号能否接收赠送。若 X
|
||||
暂不允许赠送,会在这里告诉你,兑换码不会因此被使用。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
<span>XGift · 把 Premium 赠予你</span
|
||||
><span>独立兑换服务,与 X 无隶属关系</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,517 @@
|
||||
:root {
|
||||
--ink: #20231f;
|
||||
--muted: #777c70;
|
||||
--bg: #f5f5ee;
|
||||
--line: #dedfd4;
|
||||
--accent: #bfd67c;
|
||||
--deep: #536b29;
|
||||
--paper: #fffefa;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.nav {
|
||||
height: 108px;
|
||||
margin: 0 auto;
|
||||
max-width: 1240px;
|
||||
padding: 0 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand {
|
||||
font-size: 25px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -1.4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.brand-dot {
|
||||
font-size: 22px;
|
||||
color: var(--deep);
|
||||
margin: 0 3px;
|
||||
}
|
||||
.eyebrow,
|
||||
.step {
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.7px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
.landing {
|
||||
max-width: 1152px;
|
||||
margin: 68px auto 76px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr;
|
||||
gap: 76px;
|
||||
align-items: start;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 30px;
|
||||
padding: 9px 14px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
.dot {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
background: #658031;
|
||||
border-radius: 50%;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(38px, 4.6vw, 60px);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -2px;
|
||||
font-weight: 650;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.accent {
|
||||
color: var(--deep);
|
||||
}
|
||||
.lede {
|
||||
font-size: 16px;
|
||||
line-height: 1.9;
|
||||
color: #777b70;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.benefits {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.benefits > div {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.benefit-icon {
|
||||
border: 1px solid var(--line);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.benefits small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-top: 5px;
|
||||
}
|
||||
.art {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #e3e9d2;
|
||||
height: 171px;
|
||||
border-radius: 14px;
|
||||
margin-top: 36px;
|
||||
border: 1px solid #d8dfc7;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
.art-star {
|
||||
position: absolute;
|
||||
font-size: 220px;
|
||||
line-height: 1;
|
||||
right: 8px;
|
||||
top: -25px;
|
||||
color: #c1ce9c;
|
||||
transform: rotate(12deg);
|
||||
}
|
||||
.art-word {
|
||||
position: absolute;
|
||||
font-size: 36px;
|
||||
letter-spacing: -1.5px;
|
||||
font-weight: 700;
|
||||
left: 26px;
|
||||
top: 46px;
|
||||
}
|
||||
.art-tag {
|
||||
position: absolute;
|
||||
bottom: 25px;
|
||||
left: 28px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.card {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 34px;
|
||||
box-shadow: 0 12px 32px #313b1910;
|
||||
}
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
.tiny-star {
|
||||
font-size: 30px;
|
||||
color: var(--deep);
|
||||
line-height: 1;
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.7px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin: 23px 0 10px;
|
||||
}
|
||||
label span {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
margin-left: 5px;
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: #fbfbf6;
|
||||
color: var(--ink);
|
||||
padding: 13px 14px;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 72px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: #8fa960;
|
||||
box-shadow: 0 0 0 3px #bfd67c30;
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #aaada1;
|
||||
}
|
||||
.username-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
.username-input > span {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
.username-input input {
|
||||
padding-left: 36px;
|
||||
}
|
||||
.field-help {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin: 10px 0 24px;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--ink);
|
||||
color: white;
|
||||
padding: 15px 18px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
background: #414933;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
button span {
|
||||
font-size: 18px;
|
||||
}
|
||||
.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
justify-content: center;
|
||||
padding: 11px 14px;
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.secondary:hover {
|
||||
background: #e9eddf;
|
||||
}
|
||||
.card-note {
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: 22px;
|
||||
padding-top: 18px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.card-note > span {
|
||||
font-size: 15px;
|
||||
}
|
||||
.card-note p {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.result {
|
||||
padding: 14px;
|
||||
border: 1px solid #d3dfb8;
|
||||
border-radius: 9px;
|
||||
background: #f1f5e6;
|
||||
color: #485933;
|
||||
margin-top: 18px;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.result.error {
|
||||
background: #fff4e9;
|
||||
border-color: #ebd9c4;
|
||||
color: #7c5936;
|
||||
}
|
||||
footer {
|
||||
max-width: 1152px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 25px 24px 35px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.admin {
|
||||
max-width: 1152px;
|
||||
margin: 40px auto 60px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.admin-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.admin-heading h1 {
|
||||
font-size: 36px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
.admin-grid button[type="submit"] {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.admin-info {
|
||||
background: #e8eddc;
|
||||
box-shadow: none;
|
||||
}
|
||||
.admin-info h2 {
|
||||
margin: 25px 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
dl {
|
||||
font-size: 12px;
|
||||
}
|
||||
dt {
|
||||
font-weight: 600;
|
||||
margin: 16px 0 6px;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
color: #727b62;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.admin-info .muted {
|
||||
border-top: 1px solid #cdd6bc;
|
||||
margin-top: 24px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
.table-card {
|
||||
margin-top: 25px;
|
||||
}
|
||||
.table-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.compact {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
th {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
td {
|
||||
padding: 15px 12px;
|
||||
border-bottom: 1px solid #edeee5;
|
||||
}
|
||||
td small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
border-radius: 5px;
|
||||
background: #edf1e4;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.badge.review {
|
||||
background: #f7e4c7;
|
||||
}
|
||||
.badge.revoked {
|
||||
background: #e9e9e9;
|
||||
color: #888;
|
||||
}
|
||||
.badge.succeeded {
|
||||
background: #dceacc;
|
||||
color: #416425;
|
||||
}
|
||||
#new-codes {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
.error-label {
|
||||
color: #946125;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.nav {
|
||||
height: 80px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.landing {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 30px;
|
||||
margin: 35px auto 40px;
|
||||
max-width: 530px;
|
||||
}
|
||||
.intro h1 {
|
||||
font-size: 45px;
|
||||
}
|
||||
.intro .lede {
|
||||
font-size: 14px;
|
||||
}
|
||||
.art {
|
||||
display: none;
|
||||
}
|
||||
.card {
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.benefits {
|
||||
gap: 25px;
|
||||
margin-top: 25px;
|
||||
}
|
||||
.admin-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.admin-heading {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.admin-heading h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
.admin-heading .pill {
|
||||
font-size: 9px;
|
||||
}
|
||||
footer {
|
||||
gap: 15px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.admin {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.table-card {
|
||||
padding: 20px;
|
||||
}
|
||||
.admin-info h2 {
|
||||
font-size: 25px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"xgift/internal/checkout"
|
||||
"xgift/internal/proxy"
|
||||
"xgift/internal/vault"
|
||||
)
|
||||
|
||||
//go:embed assets/*
|
||||
var assets embed.FS
|
||||
var usernamePattern = regexp.MustCompile(`^[a-z0-9_]{1,15}$`)
|
||||
var codePattern = regexp.MustCompile(`^XG-[A-F0-9]{48}$`)
|
||||
|
||||
type server struct {
|
||||
db *sql.DB
|
||||
vault *vault.Vault
|
||||
origin string
|
||||
adminHash [32]byte
|
||||
payments bool
|
||||
port int
|
||||
lockPath string
|
||||
work chan struct{}
|
||||
jobs sync.WaitGroup
|
||||
ctx context.Context
|
||||
limitsMu sync.Mutex
|
||||
limits map[string]limit
|
||||
}
|
||||
type limit struct {
|
||||
start time.Time
|
||||
count int
|
||||
}
|
||||
type codeRow struct {
|
||||
ID string `json:"id"`
|
||||
Hint string `json:"hint"`
|
||||
Batch string `json:"batch"`
|
||||
Months int `json:"months"`
|
||||
Status string `json:"status"`
|
||||
Username string `json:"username"`
|
||||
Message string `json:"message"`
|
||||
Created int64 `json:"created"`
|
||||
Updated int64 `json:"updated"`
|
||||
}
|
||||
|
||||
func Run(ctx context.Context) error {
|
||||
origin := os.Getenv("XGIFT_ORIGIN")
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
|
||||
return errors.New("XGIFT_ORIGIN must be an HTTPS origin")
|
||||
}
|
||||
dir := os.Getenv("XGIFT_DATA_DIR")
|
||||
if dir == "" {
|
||||
return errors.New("XGIFT_DATA_DIR is required")
|
||||
}
|
||||
if err = os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Chmod(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
instance, err := os.OpenFile(filepath.Join(dir, "site.lock"), os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer instance.Close()
|
||||
if err = syscall.Flock(int(instance.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
return errors.New("another site instance is using this data directory")
|
||||
}
|
||||
admin, err := privateFile(os.Getenv("XGIFT_ADMIN_PASSWORD_FILE"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
admin = []byte(strings.TrimSpace(string(admin)))
|
||||
if len(admin) < 32 {
|
||||
return errors.New("admin password must contain at least 32 characters")
|
||||
}
|
||||
s := &server{origin: origin, adminHash: sha256.Sum256(admin), payments: os.Getenv("XGIFT_PAYMENTS_ENABLED") == "true", lockPath: filepath.Join(dir, "checkout.lock"), work: make(chan struct{}, 1), ctx: ctx, limits: map[string]limit{}}
|
||||
clear(admin)
|
||||
v, err := vault.Open(filepath.Join(dir, "vault.db"), os.Getenv("XGIFT_PASSWORD_FILE"), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.vault = v
|
||||
defer v.Close()
|
||||
dbpath := filepath.Join(dir, "site.db")
|
||||
f, err := os.OpenFile(dbpath, os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
if err = os.Chmod(dbpath, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("sqlite3", dbpath+"?_busy_timeout=5000&_journal_mode=WAL&_synchronous=FULL&_foreign_keys=on")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.db = db
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS codes (
|
||||
id TEXT PRIMARY KEY, hash TEXT NOT NULL UNIQUE, hint TEXT NOT NULL, batch TEXT NOT NULL,
|
||||
months INTEGER NOT NULL CHECK(months IN (3,6)),
|
||||
status TEXT NOT NULL CHECK(status IN ('active','processing','succeeded','review','revoked')),
|
||||
username TEXT NOT NULL DEFAULT '', recipient_id TEXT UNIQUE,
|
||||
message TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL, updated INTEGER NOT NULL
|
||||
); CREATE INDEX IF NOT EXISTS codes_created ON codes(created);`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A crash is never interpreted as permission to submit the same payment again.
|
||||
if _, err = db.Exec("UPDATE codes SET status='review',message=?,updated=? WHERE status='processing'", "订单需要人工核实,请联系管理员;请勿重复兑换。", time.Now().Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := v.Get("proxy")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.port = listener.Addr().(*net.TCPAddr).Port
|
||||
listener.Close()
|
||||
box, err := proxy.Start(ctx, raw, s.port)
|
||||
clear(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer box.Close()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", s.asset("index.html", "text/html; charset=utf-8"))
|
||||
mux.HandleFunc("GET /favicon.svg", s.asset("favicon.svg", "image/svg+xml"))
|
||||
mux.HandleFunc("GET /style.css", s.asset("style.css", "text/css; charset=utf-8"))
|
||||
mux.HandleFunc("GET /app.js", s.asset("app.js", "application/javascript; charset=utf-8"))
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.db.PingContext(r.Context()) != nil {
|
||||
reply(w, 503, map[string]any{"ok": false})
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]any{"ok": true, "payments_enabled": s.payments})
|
||||
})
|
||||
mux.HandleFunc("POST /api/redeem", s.redeem)
|
||||
mux.HandleFunc("POST /api/status", s.status)
|
||||
mux.HandleFunc("GET /admin", s.admin(s.asset("admin.html", "text/html; charset=utf-8")))
|
||||
mux.HandleFunc("GET /admin.js", s.admin(s.asset("admin.js", "application/javascript; charset=utf-8")))
|
||||
mux.HandleFunc("GET /api/admin/codes", s.admin(s.list))
|
||||
mux.HandleFunc("POST /api/admin/codes", s.admin(s.generate))
|
||||
mux.HandleFunc("POST /api/admin/revoke", s.admin(s.revoke))
|
||||
addr := os.Getenv("XGIFT_LISTEN")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:8787"
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() {
|
||||
return errors.New("listen address must use a loopback IP")
|
||||
}
|
||||
h := &http.Server{Addr: addr, Handler: s.middleware(mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 50 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- h.ListenAndServe() }()
|
||||
log.Printf("xgift-web listening on %s; payments enabled=%t", addr, s.payments)
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 55*time.Second)
|
||||
defer cancel()
|
||||
if e := h.Shutdown(shutdown); e != nil {
|
||||
h.Close()
|
||||
}
|
||||
s.jobs.Wait()
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
func privateFile(path string) ([]byte, error) {
|
||||
i, e := os.Lstat(path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !i.Mode().IsRegular() || i.Mode().Perm()&0077 != 0 {
|
||||
return nil, errors.New("secret file must be regular and owner-only")
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
func token(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, e := rand.Read(b); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
func hash(code string) string { b := sha256.Sum256([]byte(code)); return hex.EncodeToString(b[:]) }
|
||||
func reply(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
func message(w http.ResponseWriter, status int, msg string) {
|
||||
reply(w, status, map[string]any{"message": msg})
|
||||
}
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
message(w, 415, "请使用 JSON 请求。")
|
||||
return false
|
||||
}
|
||||
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(v) != nil {
|
||||
message(w, 400, "请求格式不正确。")
|
||||
return false
|
||||
}
|
||||
if d.Decode(new(any)) != io.EOF {
|
||||
message(w, 400, "请求格式不正确。")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *server) asset(name, kind string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := assets.ReadFile("assets/" + name)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", kind)
|
||||
w.Write(b)
|
||||
}
|
||||
}
|
||||
func (s *server) allow(key string, max int) bool {
|
||||
s.limitsMu.Lock()
|
||||
defer s.limitsMu.Unlock()
|
||||
now := time.Now()
|
||||
if len(s.limits) > 10000 {
|
||||
for k, v := range s.limits {
|
||||
if now.Sub(v.start) > time.Minute {
|
||||
delete(s.limits, k)
|
||||
}
|
||||
}
|
||||
if len(s.limits) > 10000 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
l := s.limits[key]
|
||||
if now.Sub(l.start) > time.Minute {
|
||||
l = limit{start: now}
|
||||
}
|
||||
l.count++
|
||||
s.limits[key] = l
|
||||
return l.count <= max
|
||||
}
|
||||
func (s *server) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
if r.Method == "POST" && r.Header.Get("Origin") != s.origin {
|
||||
message(w, 403, "请求来源不正确,请从本站页面重试。")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/admin") {
|
||||
// Caddy overwrites X-Real-IP, and this server accepts loopback traffic only.
|
||||
ip := r.Header.Get("X-Real-IP")
|
||||
if net.ParseIP(ip) == nil {
|
||||
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
|
||||
}
|
||||
max := 60
|
||||
bucket := "api:"
|
||||
if r.URL.Path == "/api/redeem" {
|
||||
max = 8
|
||||
bucket = "redeem:"
|
||||
}
|
||||
if !s.allow(bucket+ip, max) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
message(w, 429, "操作太频繁,请稍后重试。")
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
func (s *server) admin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, password, ok := r.BasicAuth()
|
||||
sum := sha256.Sum256([]byte(password))
|
||||
if !ok || subtle.ConstantTimeCompare(sum[:], s.adminHash[:]) != 1 || user != "admin" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="XGift Admin", charset="UTF-8"`)
|
||||
message(w, 401, "需要管理员登录。")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
func (s *server) find(code string) (codeRow, error) {
|
||||
var c codeRow
|
||||
e := s.db.QueryRow("SELECT id,hint,batch,months,status,username,message,created,updated FROM codes WHERE hash=?", hash(code)).Scan(&c.ID, &c.Hint, &c.Batch, &c.Months, &c.Status, &c.Username, &c.Message, &c.Created, &c.Updated)
|
||||
return c, e
|
||||
}
|
||||
func readInput(w http.ResponseWriter, r *http.Request) (string, string, bool) {
|
||||
var q struct {
|
||||
Code string `json:"code"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if !decode(w, r, &q) {
|
||||
return "", "", false
|
||||
}
|
||||
q.Code = strings.ToUpper(strings.TrimSpace(q.Code))
|
||||
q.Username = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(q.Username), "@"))
|
||||
if !codePattern.MatchString(q.Code) || !usernamePattern.MatchString(q.Username) {
|
||||
message(w, 400, "请填写完整兑换码和正确的 X 用户名(不是显示名称)。")
|
||||
return "", "", false
|
||||
}
|
||||
return q.Code, q.Username, true
|
||||
}
|
||||
func (s *server) status(w http.ResponseWriter, r *http.Request) {
|
||||
code, user, ok := readInput(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c, e := s.find(code)
|
||||
if e != nil || c.Username != "" && c.Username != user {
|
||||
message(w, 404, "兑换码或用户名不匹配。")
|
||||
return
|
||||
}
|
||||
reply(w, 200, map[string]any{"status": c.Status, "months": c.Months, "message": c.Message})
|
||||
}
|
||||
func (s *server) redeem(w http.ResponseWriter, r *http.Request) {
|
||||
code, user, ok := readInput(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c, e := s.find(code)
|
||||
if errors.Is(e, sql.ErrNoRows) {
|
||||
message(w, 404, "兑换码不存在,请检查后重试。")
|
||||
return
|
||||
}
|
||||
if e != nil {
|
||||
message(w, 503, "服务暂时不可用,兑换码未使用。")
|
||||
return
|
||||
}
|
||||
if c.Status != "active" {
|
||||
if c.Username == user && (c.Status == "processing" || c.Status == "review" || c.Status == "succeeded") {
|
||||
reply(w, 200, map[string]any{"status": c.Status, "message": c.Message, "months": c.Months})
|
||||
return
|
||||
}
|
||||
message(w, 409, "兑换码已使用或已停用,请联系提供方。")
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.work <- struct{}{}:
|
||||
default:
|
||||
message(w, 503, "正在处理其他请求,请稍后重试。兑换码未使用。")
|
||||
return
|
||||
}
|
||||
handedOff := false
|
||||
defer func() {
|
||||
if !handedOff {
|
||||
<-s.work
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 35*time.Second)
|
||||
defer cancel()
|
||||
recipient, e := checkout.Eligibility(ctx, s.vault, user, s.port)
|
||||
if e != nil {
|
||||
switch {
|
||||
case errors.Is(e, checkout.ErrNotEligible):
|
||||
message(w, 422, "X 当前不允许向这个账号赠送 Premium。兑换码未使用,可换一个符合条件的账号。")
|
||||
case errors.Is(e, checkout.ErrUserNotFound):
|
||||
message(w, 422, "未能找到这个 X 账号,请检查用户名。兑换码未使用。")
|
||||
default:
|
||||
message(w, 503, "暂时无法向 X 核实赠送资格,请稍后重试。兑换码未使用。")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !s.payments {
|
||||
reply(w, 503, map[string]any{"status": "paused", "eligible": true, "months": c.Months, "message": "这个账号可以接收赠送,但充值服务暂未开放。兑换码未使用,请稍后再来。"})
|
||||
return
|
||||
}
|
||||
// The same lock is used by the CLI. Keep it until the final database write.
|
||||
lock, e := os.OpenFile(s.lockPath, os.O_CREATE|os.O_RDWR, 0600)
|
||||
if e != nil {
|
||||
message(w, 503, "订单服务暂时不可用,兑换码未使用。")
|
||||
return
|
||||
}
|
||||
if e = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); e != nil {
|
||||
lock.Close()
|
||||
message(w, 503, "订单处理中,请稍后重试。兑换码未使用。")
|
||||
return
|
||||
}
|
||||
release := func() { syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); lock.Close() }
|
||||
// A prior CLI order must not be counted as fulfillment of a new redemption code.
|
||||
for _, key := range []string{"checkout:" + recipient, "checkout:" + user} {
|
||||
if _, e = s.vault.Get(key); !errors.Is(e, sql.ErrNoRows) {
|
||||
release()
|
||||
message(w, 409, "这个账号已有订单记录,需要管理员核实后处理。兑换码未使用。")
|
||||
return
|
||||
}
|
||||
}
|
||||
result, e := s.db.Exec("UPDATE codes SET status='processing',username=?,recipient_id=?,message=?,updated=? WHERE id=? AND status='active'", user, recipient, "正在处理,请不要重复提交。", time.Now().Unix(), c.ID)
|
||||
if e != nil {
|
||||
release()
|
||||
message(w, 409, "无法创建订单;该账号可能已有兑换记录。兑换码未使用。")
|
||||
return
|
||||
}
|
||||
n, e := result.RowsAffected()
|
||||
if e != nil || n != 1 {
|
||||
release()
|
||||
message(w, 409, "兑换码状态已改变,请刷新后查询。")
|
||||
return
|
||||
}
|
||||
handedOff = true
|
||||
s.jobs.Add(1)
|
||||
go func() {
|
||||
defer s.jobs.Done()
|
||||
defer func() { <-s.work }()
|
||||
defer release()
|
||||
ctx, cancel := context.WithTimeout(s.ctx, 150*time.Second)
|
||||
defer cancel()
|
||||
record, err := checkout.RunForRecipient(ctx, s.vault, user, recipient, true, s.port, c.Months)
|
||||
status, msg := "review", "订单需要人工核实,请联系管理员;请勿重复兑换。"
|
||||
if err == nil && record != nil && record.Status == "succeeded" && record.RecipientID == recipient && record.Months == c.Months && record.Amount == c.Months*10000 && record.Currency == "BDT" {
|
||||
status, msg = "succeeded", fmt.Sprintf("已为 @%s 完成 %d 个月 Premium 赠送。", user, c.Months)
|
||||
}
|
||||
updated, e := s.db.Exec("UPDATE codes SET status=?,message=?,updated=? WHERE id=? AND status='processing'", status, msg, time.Now().Unix(), c.ID)
|
||||
if e != nil {
|
||||
log.Printf("order %s requires database reconciliation", c.ID)
|
||||
return
|
||||
}
|
||||
n, e := updated.RowsAffected()
|
||||
if e != nil || n != 1 {
|
||||
log.Printf("order %s requires database reconciliation", c.ID)
|
||||
return
|
||||
}
|
||||
// Never log the checkout URL, credentials or upstream payloads.
|
||||
log.Printf("order %s finished: %s", c.ID, status)
|
||||
}()
|
||||
reply(w, 202, map[string]any{"status": "processing", "months": c.Months, "message": "正在处理,请保留本页并等待结果。"})
|
||||
}
|
||||
func (s *server) generate(w http.ResponseWriter, r *http.Request) {
|
||||
var q struct {
|
||||
Months int `json:"months"`
|
||||
Count int `json:"count"`
|
||||
Batch string `json:"batch"`
|
||||
}
|
||||
if !decode(w, r, &q) {
|
||||
return
|
||||
}
|
||||
q.Batch = strings.TrimSpace(q.Batch)
|
||||
if (q.Months != 3 && q.Months != 6) || q.Count < 1 || q.Count > 500 || len(q.Batch) > 120 {
|
||||
message(w, 400, "请选择 3 或 6 个月,数量 1–500,批次名称不超过 120 字节。")
|
||||
return
|
||||
}
|
||||
if q.Batch == "" {
|
||||
q.Batch = time.Now().UTC().Format("20060102-150405") + "-" + token(3)
|
||||
}
|
||||
tx, e := s.db.BeginTx(r.Context(), nil)
|
||||
if e != nil {
|
||||
message(w, 503, "暂时无法生成兑换码。")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
codes := make([]string, 0, q.Count)
|
||||
now := time.Now().Unix()
|
||||
for i := 0; i < q.Count; i++ {
|
||||
code := "XG-" + strings.ToUpper(token(24))
|
||||
_, e = tx.Exec("INSERT INTO codes(id,hash,hint,batch,months,status,created,updated) VALUES(?,?,?,?,?,'active',?,?)", token(16), hash(code), code[len(code)-8:], q.Batch, q.Months, now, now)
|
||||
if e != nil {
|
||||
message(w, 503, "生成失败,没有保存本批兑换码。")
|
||||
return
|
||||
}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
if e = tx.Commit(); e != nil {
|
||||
message(w, 503, "保存结果不确定,请在后台核实批次后再操作。")
|
||||
return
|
||||
}
|
||||
reply(w, 201, map[string]any{"codes": codes, "batch": q.Batch, "months": q.Months})
|
||||
}
|
||||
func (s *server) list(w http.ResponseWriter, r *http.Request) {
|
||||
page := 0
|
||||
if raw := r.URL.Query().Get("page"); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 || n > 100000 {
|
||||
message(w, 400, "页码不正确。")
|
||||
return
|
||||
}
|
||||
page = n
|
||||
}
|
||||
rows, e := s.db.Query("SELECT id,hint,batch,months,status,username,message,created,updated FROM codes ORDER BY created DESC,rowid DESC LIMIT 101 OFFSET ?", page*100)
|
||||
if e != nil {
|
||||
message(w, 503, "无法读取兑换码。")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
codes := []codeRow{}
|
||||
for rows.Next() {
|
||||
var c codeRow
|
||||
if e = rows.Scan(&c.ID, &c.Hint, &c.Batch, &c.Months, &c.Status, &c.Username, &c.Message, &c.Created, &c.Updated); e != nil {
|
||||
message(w, 503, "无法读取兑换码。")
|
||||
return
|
||||
}
|
||||
codes = append(codes, c)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
message(w, 503, "无法读取兑换码。")
|
||||
return
|
||||
}
|
||||
hasMore := len(codes) > 100
|
||||
if hasMore {
|
||||
codes = codes[:100]
|
||||
}
|
||||
reply(w, 200, map[string]any{"codes": codes, "payments_enabled": s.payments, "page": page, "has_more": hasMore})
|
||||
}
|
||||
func (s *server) revoke(w http.ResponseWriter, r *http.Request) {
|
||||
var q struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if !decode(w, r, &q) {
|
||||
return
|
||||
}
|
||||
res, e := s.db.Exec("UPDATE codes SET status='revoked',message='兑换码已停用',updated=? WHERE id=? AND status='active'", time.Now().Unix(), q.ID)
|
||||
if e != nil {
|
||||
message(w, 503, "停用失败。")
|
||||
return
|
||||
}
|
||||
n, e := res.RowsAffected()
|
||||
if e != nil || n != 1 {
|
||||
message(w, 409, "只能停用尚未使用的兑换码。")
|
||||
return
|
||||
}
|
||||
message(w, 200, "兑换码已停用。")
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
type Vault struct {
|
||||
db *sql.DB
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
func NewPassword() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.CreateTemp("/tmp", "xgift-password-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := f.Name()
|
||||
if _, err = f.WriteString(base64.RawURLEncoding.EncodeToString(b) + "\n"); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
return "", err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func Open(path, passwordFile string, create bool) (*Vault, error) {
|
||||
info, err := os.Lstat(passwordFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 {
|
||||
return nil, errors.New("password file must be a regular owner-only file (0600)")
|
||||
}
|
||||
password, err := os.ReadFile(passwordFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password = []byte(strings.TrimSpace(string(password)))
|
||||
defer clear(password)
|
||||
if len(password) < 32 {
|
||||
return nil, errors.New("password is too short")
|
||||
}
|
||||
if create {
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.Chmod(filepath.Dir(path), 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, e := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
info, err = os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 {
|
||||
return nil, errors.New("database must be a regular owner-only file")
|
||||
}
|
||||
db, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_journal_mode=DELETE&_secure_delete=on&_synchronous=FULL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
db.Close()
|
||||
}
|
||||
}()
|
||||
var salt []byte
|
||||
if create {
|
||||
_, err = db.Exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value BLOB NOT NULL); CREATE TABLE secrets (name TEXT PRIMARY KEY, payload BLOB NOT NULL);`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
salt = make([]byte, 32)
|
||||
if _, err = rand.Read(salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = db.Exec("INSERT INTO metadata VALUES ('salt', ?)", salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err = db.QueryRow("SELECT value FROM metadata WHERE key='salt'").Scan(&salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(salt) != 32 {
|
||||
return nil, errors.New("invalid vault salt")
|
||||
}
|
||||
key, err := scrypt.Key(password, salt, 32768, 8, 1, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer clear(key)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := &Vault{db, aead}
|
||||
if create {
|
||||
err = v.Put("vault-check", []byte("xgift-v1"))
|
||||
} else {
|
||||
var b []byte
|
||||
b, err = v.Get("vault-check")
|
||||
if err == nil && string(b) != "xgift-v1" {
|
||||
err = errors.New("invalid vault version")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.New("vault authentication failed (wrong password or corrupted database)")
|
||||
}
|
||||
ok = true
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (v *Vault) Put(name string, plain []byte) error {
|
||||
nonce := make([]byte, v.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
encrypted := v.aead.Seal(nonce, nonce, plain, []byte("xgift-v1:"+name))
|
||||
_, err := v.db.Exec("INSERT INTO secrets(name,payload) VALUES (?,?) ON CONFLICT(name) DO UPDATE SET payload=excluded.payload", name, encrypted)
|
||||
return err
|
||||
}
|
||||
func (v *Vault) Get(name string) ([]byte, error) {
|
||||
var b []byte
|
||||
if err := v.db.QueryRow("SELECT payload FROM secrets WHERE name=?", name).Scan(&b); err != nil {
|
||||
return nil, fmt.Errorf("secret %s unavailable: %w", name, err)
|
||||
}
|
||||
n := v.aead.NonceSize()
|
||||
if len(b) < n {
|
||||
return nil, errors.New("invalid encrypted record")
|
||||
}
|
||||
return v.aead.Open(nil, b[:n], b[n:], []byte("xgift-v1:"+name))
|
||||
}
|
||||
func (v *Vault) Close() error { return v.db.Close() }
|
||||
Reference in new issue
Block a user