commit ca14496cfcc05f9e04b7400a71e3d478898f326c Author: mizorewww Date: Fri Oct 2 15:30:51 2026 +0800 Add guarded X gift CLI and paused redemption site with admin code management diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f77b940 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..73bbf4e --- /dev/null +++ b/README.md @@ -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 赠送限制仍可能阻止自动完成。 diff --git a/cmd/xgift-web/main.go b/cmd/xgift-web/main.go new file mode 100644 index 0000000..26cb7ab --- /dev/null +++ b/cmd/xgift-web/main.go @@ -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) + } +} diff --git a/cmd/xgift/main.go b/cmd/xgift/main.go new file mode 100644 index 0000000..c50e6d9 --- /dev/null +++ b/cmd/xgift/main.go @@ -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 [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 +} diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..c0fe6f6 --- /dev/null +++ b/deploy/Caddyfile @@ -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} + } +} diff --git a/deploy/site.env.example b/deploy/site.env.example new file mode 100644 index 0000000..ced37b1 --- /dev/null +++ b/deploy/site.env.example @@ -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 diff --git a/deploy/xgift.service b/deploy/xgift.service new file mode 100644 index 0000000..c148383 --- /dev/null +++ b/deploy/xgift.service @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f645d1c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a8bfed4 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/checkout/checkout.go b/internal/checkout/checkout.go new file mode 100644 index 0000000..b98d3ae --- /dev/null +++ b/internal/checkout/checkout.go @@ -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") +} diff --git a/internal/checkout/stripe.go b/internal/checkout/stripe.go new file mode 100644 index 0000000..62420d6 --- /dev/null +++ b/internal/checkout/stripe.go @@ -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 +} diff --git a/internal/checkout/xapi.go b/internal/checkout/xapi.go new file mode 100644 index 0000000..b59798c --- /dev/null +++ b/internal/checkout/xapi.go @@ -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 +} diff --git a/internal/chrome/chrome.go b/internal/chrome/chrome.go new file mode 100644 index 0000000..42fb8ed --- /dev/null +++ b/internal/chrome/chrome.go @@ -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{}}) +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..fdd5b9a --- /dev/null +++ b/internal/proxy/proxy.go @@ -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 +} diff --git a/internal/site/assets/admin.html b/internal/site/assets/admin.html new file mode 100644 index 0000000..241ebca --- /dev/null +++ b/internal/site/assets/admin.html @@ -0,0 +1,120 @@ + + + + + + XGift · 管理后台 + + + + + + +
+
+
+ CONTROL ROOM +

兑换码管理

+
+ 正在读取状态 +
+
+
+

批量生成

+

兑换码明文只在生成时显示,请立即下载保存。

+
+ +
+ + +
+
+ ✦ +

每一次兑换,
都有明确的状态。

+
+
可使用
+
用户可以提交,停用后不可再兑换。
+
处理中
+
兑换码已锁定,请等待结果。
+
已完成
+
仅在支付服务确认成功后显示。
+
待核实
+
结果不确定或需要额外验证,必须人工核对,不会自动再次付款。
+
+

+ 付款限额:3 个月恰好 300 BDT;6 个月恰好 600 + BDT。金额或币种不符时停止。 +

+
+
+
+
+

兑换码列表

+ +
+

+ 每页 100 条,展示末 8 位识别码。没有自动重试或强制标记成功入口。 +

+
+ + + + + + + + + + + + +
识别码 / 批次套餐状态用户名创建时间操作
+
+ +
+
+
+ XGift · 管理后台请勿分享管理员密码或未使用的兑换码 +
+ + diff --git a/internal/site/assets/admin.js b/internal/site/assets/admin.js new file mode 100644 index 0000000..c0238cd --- /dev/null +++ b/internal/site/assets/admin.js @@ -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(); +}); diff --git a/internal/site/assets/app.js b/internal/site/assets/app.js new file mode 100644 index 0000000..02a856b --- /dev/null +++ b/internal/site/assets/app.js @@ -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(() => {}); diff --git a/internal/site/assets/favicon.svg b/internal/site/assets/favicon.svg new file mode 100644 index 0000000..f7b99da --- /dev/null +++ b/internal/site/assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/internal/site/assets/index.html b/internal/site/assets/index.html new file mode 100644 index 0000000..c2d4b9d --- /dev/null +++ b/internal/site/assets/index.html @@ -0,0 +1,100 @@ + + + + + + + XGift · Premium 兑换 + + + + + + +
+
+
PREMIUM GIFT
+

让你的 X,
多一点可能。

+

+ 一枚兑换码,一份 Premium。
输入你的用户名,接收属于你的赠礼。 +

+
+
+ ✓只需用户名无需提供 X 密码 +
+
+ ↗进度随时可查保留兑换码即可查询 +
+
+ +
+
+
+ 01 / REDEEM✦ +
+

兑换你的 Premium

+

套餐时长以兑换码为准。

+ +
+ +
+ @ +
+

请核对用户名,赠送成功后无法更换账号。

+ +
+ +
+ ⓘ +

+ 我们会先检查账号能否接收赠送。若 X + 暂不允许赠送,会在这里告诉你,兑换码不会因此被使用。 +

+
+
+
+ + + diff --git a/internal/site/assets/style.css b/internal/site/assets/style.css new file mode 100644 index 0000000..9fb6f16 --- /dev/null +++ b/internal/site/assets/style.css @@ -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); +} diff --git a/internal/site/site.go b/internal/site/site.go new file mode 100644 index 0000000..b701917 --- /dev/null +++ b/internal/site/site.go @@ -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, "兑换码已停用。") +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go new file mode 100644 index 0000000..732c763 --- /dev/null +++ b/internal/vault/vault.go @@ -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() }