Add guarded X gift CLI and paused redemption site with admin code management

This commit is contained in:
mizorewww committed 2026-10-02 15:30:51 +08:00
commit ca14496cfc
22 files changed
+3241

No files matched your search

+17
View File
@@ -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/
+84
View File
@@ -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 赠送限制仍可能阻止自动完成。
+18
View File
@@ -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)
}
}
+268
View File
@@ -0,0 +1,268 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"os"
"os/signal"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"xgift/internal/checkout"
"xgift/internal/chrome"
"xgift/internal/proxy"
"xgift/internal/vault"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "xgift:", err)
os.Exit(1)
}
}
func run() error {
// Permit the requested `xgift username --pay` spelling as well as global flags.
args := os.Args[1:]
command := ""
rest := []string{}
for i := 0; i < len(args); i++ {
a := args[i]
if strings.HasPrefix(a, "-") {
rest = append(rest, a)
if a == "--db" || a == "--password-file" || a == "--profile" || a == "--port" || a == "--name" || a == "--months" {
i++
if i >= len(args) {
return errors.New("missing flag value")
}
rest = append(rest, args[i])
}
} else if command == "" {
command = a
} else {
return errors.New("unexpected argument")
}
}
f := flag.NewFlagSet("xgift", flag.ContinueOnError)
defaultDB := "sqlite/vault.db"
if exe, e := os.Executable(); e == nil {
if resolved, e := filepath.EvalSymlinks(exe); e == nil {
candidate := filepath.Join(filepath.Dir(resolved), "..", "sqlite", "vault.db")
if _, e = os.Stat(candidate); e == nil {
defaultDB = candidate
}
}
}
db := f.String("db", defaultDB, "encrypted SQLite record store")
key := f.String("password-file", os.Getenv("XGIFT_PASSWORD_FILE"), "owner-only password file")
profile := f.String("profile", "Default", "Chrome directory name")
port := f.Int("port", 0, "local proxy port; default automatic (proxy command: 18791)")
months := f.Int("months", 6, "Premium gift duration: 3 (300 BDT) or 6 (600 BDT)")
pay := f.Bool("pay", false, "pay only at the exact allowed BDT total")
name := f.String("name", "", "secret name for put")
f.Usage = func() {
fmt.Fprintln(f.Output(), "Usage: xgift <init|status|billing|import-chrome|put|proxy|check|username> [flags]\ninit reads a JSON object from stdin; put reads one JSON value from stdin.")
f.PrintDefaults()
}
if err := f.Parse(rest); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if command == "" {
f.Usage()
return nil
}
if *port != 0 && (*port < 1024 || *port > 65535) {
return errors.New("port must be 1024..65535")
}
if command == "init" {
input, err := io.ReadAll(io.LimitReader(os.Stdin, 1024*1024))
if err != nil {
return err
}
defer clear(input)
var records map[string]json.RawMessage
if err = json.Unmarshal(input, &records); err != nil {
return errors.New("stdin must contain a JSON object of secrets")
}
if len(records) == 0 {
return errors.New("no secrets supplied")
}
if _, err = os.Stat(*db); !os.IsNotExist(err) {
return errors.New("vault already exists or path is inaccessible")
}
if *key == "" {
*key, err = vault.NewPassword()
if err != nil {
return err
}
}
v, err := vault.Open(*db, *key, true)
if err != nil {
return err
}
defer v.Close()
for n, b := range records {
if n == "vault-check" {
return errors.New("reserved secret name")
}
if err = v.Put(n, b); err != nil {
return err
}
}
// Save only the password's path, never the password, next to the vault.
if err = os.WriteFile(filepath.Join(filepath.Dir(*db), "password-path"), []byte(*key+"\n"), 0600); err != nil {
return err
}
fmt.Printf("Encrypted vault created: %s\nPassword file: %s\n", *db, *key)
return nil
}
if *key == "" {
p, err := os.ReadFile(filepath.Join(filepath.Dir(*db), "password-path"))
if err != nil {
return errors.New("set --password-file or XGIFT_PASSWORD_FILE")
}
*key = strings.TrimSpace(string(p))
}
v, err := vault.Open(*db, *key, false)
if err != nil {
return err
}
defer v.Close()
switch command {
case "billing":
raw, e := v.Get("card")
if e != nil {
return e
}
defer clear(raw)
var c map[string]any
if e = json.Unmarshal(raw, &c); e != nil {
return e
}
var fields map[string]string
if e = json.NewDecoder(io.LimitReader(os.Stdin, 65536)).Decode(&fields); e != nil {
return errors.New("billing expects a JSON object on stdin")
}
for n, value := range fields {
switch n {
case "billing_name", "email", "billing_country", "billing_postal_code", "billing_address_line1", "billing_city", "billing_state":
c[n] = value
default:
return errors.New("unsupported billing field")
}
}
raw, e = json.Marshal(c)
if e != nil {
return e
}
defer clear(raw)
if e = v.Put("card", raw); e != nil {
return e
}
fmt.Println("Billing information saved in encrypted SQLite")
return nil
case "status":
for _, n := range []string{"cookies", "card", "proxy"} {
b, e := v.Get(n)
if e != nil {
return e
}
if !json.Valid(b) {
return fmt.Errorf("invalid %s JSON", n)
}
clear(b)
fmt.Printf("%s: encrypted record verified\n", n)
}
return nil
case "import-chrome":
b, e := chrome.Extract(*profile)
if e != nil {
return e
}
defer clear(b)
if e = v.Put("cookies", b); e != nil {
return e
}
fmt.Println("X cookies refreshed in encrypted SQLite vault")
return nil
case "put":
if *name != "proxy" && *name != "card" && *name != "cookies" {
return errors.New("--name must be proxy, card or cookies")
}
b, e := io.ReadAll(io.LimitReader(os.Stdin, 1024*1024))
if e != nil {
return e
}
defer clear(b)
if !json.Valid(b) {
return errors.New("stdin must be valid JSON")
}
return v.Put(*name, b)
}
if command != "proxy" && command != "check" && !regexp.MustCompile(`^@?[A-Za-z0-9_]{1,15}$`).MatchString(command) {
return errors.New("invalid command or X username")
}
if command != "proxy" && command != "check" {
lock, e := os.OpenFile(filepath.Join(filepath.Dir(*db), "checkout.lock"), os.O_CREATE|os.O_RDWR, 0600)
if e != nil {
return e
}
defer lock.Close()
if e = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); e != nil {
return errors.New("another checkout command is running")
}
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
}
if *port == 0 {
if command == "proxy" {
*port = 18791
} else {
l, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
return e
}
_, p, _ := net.SplitHostPort(l.Addr().String())
*port, _ = strconv.Atoi(p)
l.Close()
}
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
config, err := v.Get("proxy")
if err != nil {
return err
}
defer clear(config)
instance, err := proxy.Start(ctx, config, *port)
if err != nil {
return err
}
defer instance.Close()
fmt.Fprintf(os.Stderr, "Embedded sing-box AnyTLS listening on 127.0.0.1:%d\n", *port)
if command == "proxy" {
<-ctx.Done()
return nil
}
if command == "check" {
return proxy.Check(ctx, *port)
}
result, e := checkout.Run(ctx, v, command, *pay, *port, *months)
if result != nil {
if e == nil || result.Status == "requires_action" || result.Status == "unknown" || result.Status == "submitting" {
fmt.Println(result.URL)
}
fmt.Fprintln(os.Stderr, "Checkout status:", result.Status)
}
return e
}
+7
View File
@@ -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}
}
}
+6
View File
@@ -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
+31
View File
@@ -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
+67
View File
@@ -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
)
+141
View File
@@ -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=
+195
View File
@@ -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")
}
+252
View File
@@ -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
}
+216
View File
@@ -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
}
+122
View File
@@ -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{}})
}
+72
View File
@@ -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
}
+120
View File
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>XGift · 管理后台</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/style.css" />
<script src="/admin.js" defer></script>
</head>
<body>
<header class="nav">
<a class="brand" href="/">X<span class="brand-dot">✦</span>GIFT</a
><span class="eyebrow">ADMIN CONSOLE</span>
</header>
<main class="admin">
<div class="admin-heading">
<div>
<span class="step">CONTROL ROOM</span>
<h1>兑换码管理</h1>
</div>
<span id="service-status" class="pill">正在读取状态</span>
</div>
<div class="admin-grid">
<section class="card">
<h2>批量生成</h2>
<p class="muted">兑换码明文只在生成时显示,请立即下载保存。</p>
<form id="generate-form">
<label for="months">套餐</label
><select id="months">
<option value="6">6 个月 Premium · 600 BDT</option>
<option value="3">3 个月 Premium · 300 BDT</option></select
><label for="count">数量</label
><input
id="count"
type="number"
min="1"
max="500"
value="10"
required
/><label for="batch">批次名称 <span>选填</span></label
><input
id="batch"
maxlength="40"
placeholder="例如:十月赠礼"
/><button id="generate-button" type="submit">
生成兑换码 <span>+</span>
</button>
</form>
<div id="admin-result" class="result" role="status" hidden></div>
<div id="generated" hidden>
<label for="new-codes">本次生成的兑换码</label
><textarea id="new-codes" rows="9" readonly></textarea
><button id="download" class="secondary" type="button">
下载兑换码 TXT</button
><button id="clear-codes" class="secondary" type="button">
已保存,清除本页明文
</button>
</div>
</section>
<section class="card admin-info">
<span class="tiny-star">✦</span>
<h2>每一次兑换,<br />都有明确的状态。</h2>
<dl>
<dt>可使用</dt>
<dd>用户可以提交,停用后不可再兑换。</dd>
<dt>处理中</dt>
<dd>兑换码已锁定,请等待结果。</dd>
<dt>已完成</dt>
<dd>仅在支付服务确认成功后显示。</dd>
<dt>待核实</dt>
<dd>结果不确定或需要额外验证,必须人工核对,不会自动再次付款。</dd>
</dl>
<p class="muted">
付款限额:3 个月恰好 300 BDT;6 个月恰好 600
BDT。金额或币种不符时停止。
</p>
</section>
</div>
<section class="card table-card">
<div class="table-heading">
<h2>兑换码列表</h2>
<button id="refresh" class="secondary compact" type="button">
刷新
</button>
</div>
<p class="muted">
每页 100 条,展示末 8 位识别码。没有自动重试或强制标记成功入口。
</p>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>识别码 / 批次</th>
<th>套餐</th>
<th>状态</th>
<th>用户名</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="codes-body"></tbody>
</table>
</div>
<div class="pagination">
<button id="prev" class="secondary compact" type="button">
上一页</button
><span id="page-label"></span
><button id="next" class="secondary compact" type="button">
下一页
</button>
</div>
</section>
</main>
<footer>
<span>XGift · 管理后台</span
><span>请勿分享管理员密码或未使用的兑换码</span>
</footer>
</body>
</html>
+177
View File
@@ -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();
});
+106
View File
@@ -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(() => {});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="15" fill="#20231f"/><path d="M32 10Q35 27 54 32Q36 36 32 54Q28 36 10 32Q28 28 32 10Z" fill="#bfd67c"/></svg>

After

Width:  |  Height:  |  Size: 202 B

+100
View File
@@ -0,0 +1,100 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light" />
<title>XGift · Premium 兑换</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/style.css" />
<script src="/app.js" defer></script>
</head>
<body>
<header class="nav">
<a class="brand" href="/">X<span class="brand-dot">✦</span>GIFT</a
><span class="eyebrow">A LITTLE MORE PREMIUM</span>
</header>
<main class="landing">
<section class="intro">
<div class="pill"><span class="dot"></span> PREMIUM GIFT</div>
<h1>让你的 X,<br />多一点<span class="accent">可能。</span></h1>
<p class="lede">
一枚兑换码,一份 Premium。<br />输入你的用户名,接收属于你的赠礼。
</p>
<div class="benefits">
<div>
<span class="benefit-icon">✓</span
><span>只需用户名<small>无需提供 X 密码</small></span>
</div>
<div>
<span class="benefit-icon">↗</span
><span>进度随时可查<small>保留兑换码即可查询</small></span>
</div>
</div>
<div class="art" aria-hidden="true">
<span class="art-star">✦</span><span class="art-word">PREMIUM</span
><span class="art-tag">A GIFT FOR YOU ↗</span>
</div>
</section>
<section class="card redeem-card">
<div class="card-top">
<span class="step">01 / REDEEM</span><span class="tiny-star">✦</span>
</div>
<h2>兑换你的 Premium</h2>
<p class="muted">套餐时长以兑换码为准。</p>
<div id="service-notice" class="result" role="status" hidden></div>
<form id="redeem-form">
<label for="code">兑换码</label
><textarea
id="code"
name="code"
rows="2"
placeholder="粘贴 XG- 开头的完整兑换码"
maxlength="80"
required
spellcheck="false"
autocomplete="off"
></textarea
><label for="username">X 用户名 <span>不是显示名称</span></label>
<div class="username-input">
<span>@</span
><input
id="username"
name="username"
placeholder="your_username"
maxlength="16"
pattern="@?[A-Za-z0-9_]{1,15}"
autocomplete="off"
spellcheck="false"
required
/>
</div>
<p class="field-help">请核对用户名,赠送成功后无法更换账号。</p>
<button id="redeem-button" type="submit">
兑换 Premium <span>↗</span></button
><button id="status-button" class="secondary" type="button">
查询兑换进度
</button>
</form>
<div
id="result"
class="result"
role="status"
aria-live="polite"
hidden
></div>
<div class="card-note">
<span>ⓘ</span>
<p>
我们会先检查账号能否接收赠送。若 X
暂不允许赠送,会在这里告诉你,兑换码不会因此被使用。
</p>
</div>
</section>
</main>
<footer>
<span>XGift · 把 Premium 赠予你</span
><span>独立兑换服务,与 X 无隶属关系</span>
</footer>
</body>
</html>
+517
View File
@@ -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);
}
+559
View File
@@ -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, "兑换码已停用。")
}
+165
View File
@@ -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() }