diff --git a/Scripts/deploy_site.sh b/Scripts/deploy_site.sh index 96e0840..d80c59e 100755 --- a/Scripts/deploy_site.sh +++ b/Scripts/deploy_site.sh @@ -7,7 +7,7 @@ set -euo pipefail cd "$(dirname "$0")/.." -HOST="${SITE_HOST:-debian@15.204.80.137}" +HOST="${SITE_HOST:-debian@51.38.126.148}" KEY="${SITE_KEY:-$HOME/.ssh/gentpan.pem}" ROOT="${SITE_ROOT:-/var/www/getopenstats.com}" DOMAIN="${SITE_DOMAIN:-getopenstats.com}" diff --git a/Scripts/publish_release.sh b/Scripts/publish_release.sh index d85ed78..acfde68 100755 --- a/Scripts/publish_release.sh +++ b/Scripts/publish_release.sh @@ -7,7 +7,7 @@ set -euo pipefail cd "$(dirname "$0")/.." -HOST="${SITE_HOST:-debian@15.204.80.137}" +HOST="${SITE_HOST:-debian@51.38.126.148}" KEY="${SITE_KEY:-$HOME/.ssh/gentpan.pem}" ROOT="${SITE_ROOT:-/var/www/getopenstats.com}" TAP="${TAP:-gentpan/homebrew-tap}" diff --git a/server/geoip/configure.sh b/server/geoip/configure.sh new file mode 100755 index 0000000..b30efcc --- /dev/null +++ b/server/geoip/configure.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# 把 MaxMind 账号与 License Key 写到服务器的 /etc/openstats/maxmind.env,然后立刻同步一次 GeoLite2 数据库。 +# 值在本机终端里输入(Key 不回显),只经 ssh 传到服务器,不进仓库、不进日志。 +# +# ./server/geoip/configure.sh +set -euo pipefail +HOST="${SITE_HOST:-debian@51.38.126.148}" +KEY="${SITE_KEY:-$HOME/.ssh/gentpan.pem}" +SSH=(ssh -i "$KEY" -o BatchMode=yes "$HOST") + +read -r -p "MaxMind Account ID: " ACCOUNT +read -r -s -p "MaxMind License Key: " LICENSE; echo +[ -n "$ACCOUNT" ] && [ -n "$LICENSE" ] || { echo "两项都要填" >&2; exit 1; } +case "$ACCOUNT$LICENSE" in *[!A-Za-z0-9_]*) echo "账号或 Key 含有意外字符" >&2; exit 1 ;; esac + +# 通过标准输入写入,命令行参数里不出现 Key +printf 'MAXMIND_ACCOUNT_ID=%s\nMAXMIND_LICENSE_KEY=%s\n' "$ACCOUNT" "$LICENSE" \ + | "${SSH[@]}" 'sudo install -d -m 700 /etc/openstats && sudo tee /etc/openstats/maxmind.env >/dev/null && sudo chmod 600 /etc/openstats/maxmind.env && echo "已写入 /etc/openstats/maxmind.env"' +echo "开始第一次同步(三个库约 80 MB,需要一两分钟)…" +"${SSH[@]}" 'sudo systemctl start openstats-geoip.service && cat /var/www/getopenstats.com/geoip/manifest.json && echo' diff --git a/server/geoip/install.sh b/server/geoip/install.sh index 63f2b3c..f2a70d8 100755 --- a/server/geoip/install.sh +++ b/server/geoip/install.sh @@ -3,7 +3,7 @@ # ./server/geoip/install.sh set -euo pipefail cd "$(dirname "$0")" -HOST="${SITE_HOST:-debian@15.204.80.137}" +HOST="${SITE_HOST:-debian@51.38.126.148}" KEY="${SITE_KEY:-$HOME/.ssh/gentpan.pem}" SSH=(ssh -i "$KEY" -o BatchMode=yes "$HOST") diff --git a/server/sync/README.md b/server/sync/README.md new file mode 100644 index 0000000..3610f98 --- /dev/null +++ b/server/sync/README.md @@ -0,0 +1,43 @@ +# openstats-sync + +OpenStats 的账号与设置同步服务,跑在官网服务器上,路径 `https://getopenstats.com/api/v1/`。 +Go 标准库加纯 Go 的 SQLite,单个二进制,没有其他运行时依赖。 + +## 接口 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/auth/providers` | 已配置的登录方式,应用据此显示按钮 | +| GET | `/auth/{github,google,apple}/start?challenge=…` | 记下 PKCE challenge,跳转到第三方授权页 | +| GET/POST | `/auth/{provider}/callback` | 第三方回调;换到身份后跳回 `openstats://auth/callback?code=…` | +| POST | `/auth/exchange` | `{code, verifier, device}` → `{token, user}`,换取码 5 分钟内一次有效 | +| GET | `/me` | 当前用户 | +| GET | `/settings` | `{version, updatedAt, device, document}`,没有保存过返回 404 | +| PUT | `/settings` | `{document, device}` 整份覆盖,版本号加一 | +| POST | `/auth/logout` | 作废当前令牌 | +| DELETE | `/account` | 删除用户、所有登录方式、令牌与设置 | + +需要登录的接口用 `Authorization: Bearer `。令牌只在库里存 SHA-256。 + +登录流程和 PKCE 一样:应用生成 verifier,把 S256 challenge 交给 `start`;回调时服务器只把一次性换取码 +交回应用,应用再用 verifier 换长期令牌。这样即使别的程序抢注了 `openstats://` scheme,拿到换取码也没有用。 + +同一个已验证邮箱在不同登录方式下登录会并入同一个用户。Apple 的姓名只在首次授权时给出。 + +## 部署 + +1. 在 GitHub、Google、Apple 各建一个 OAuth 应用,回调地址写 `https://getopenstats.com/api/v1/auth//callback`(详见 `sync.env.example`)。 +2. `./server/sync/install.sh`:交叉编译,安装到 `/usr/local/bin/openstats-sync`,建 `openstats-sync` 用户与 systemd 单元, + 在 `/etc/caddy/sites/getopenstats.com.caddy` 的站点块里加 `handle /api/*` 反向代理。首次安装会生成 `/etc/openstats/sync.env`。 +3. 运行 `./server/sync/configure.sh`:它在你的终端里逐项询问 Client ID / Secret 与 .p8 路径,写到服务器的 `/etc/openstats/sync.env` 并重启服务;也可以自己编辑那个文件后 `sudo systemctl restart openstats-sync`。 +4. `curl https://getopenstats.com/api/v1/auth/providers` 应列出已配置的登录方式。 + +数据库在 `/var/lib/openstats-sync/sync.db`,备份这个文件即可。 + +## 开发 + +```bash +cd server/sync +go test ./... +GITHUB_CLIENT_ID=… GITHUB_CLIENT_SECRET=… SYNC_BASE_URL=http://127.0.0.1:8787 go run . +``` diff --git a/server/sync/apple.go b/server/sync/apple.go new file mode 100644 index 0000000..7f81b85 --- /dev/null +++ b/server/sync/apple.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +// Apple 登录走网页授权(Sign in with Apple JS 的 OAuth 形式),不依赖应用的 entitlement: +// client_id 是 Services ID,client_secret 是用 .p8 私钥签的 ES256 JWT。 +// 请求 name email 时 Apple 要求 response_mode=form_post,所以回调是 POST。 +type apple struct { + clientID string + teamID string + keyID string + key *ecdsa.PrivateKey +} + +func NewApple(clientID, teamID, keyID string, pemBytes []byte) (Provider, error) { + if teamID == "" || keyID == "" { + return nil, errors.New("APPLE_TEAM_ID 与 APPLE_KEY_ID 必须同时提供") + } + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("Apple 私钥不是 PEM 格式") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("解析 Apple 私钥失败:%w", err) + } + key, ok := parsed.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("Apple 私钥不是 EC 密钥") + } + return &apple{clientID: clientID, teamID: teamID, keyID: keyID, key: key}, nil +} + +func (a *apple) Name() string { return "apple" } + +func (a *apple) AuthURL(state, redirectURI string) string { + q := url.Values{ + "client_id": {a.clientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "response_mode": {"form_post"}, + "scope": {"name email"}, + "state": {state}, + } + return "https://appleid.apple.com/auth/authorize?" + q.Encode() +} + +func (a *apple) Exchange(ctx context.Context, code, redirectURI string, form url.Values) (Identity, error) { + secret, err := a.clientSecret(time.Now()) + if err != nil { + return Identity{}, err + } + var token struct { + IDToken string `json:"id_token"` + Error string `json:"error"` + } + if err := postForm(ctx, "https://appleid.apple.com/auth/token", url.Values{ + "client_id": {a.clientID}, "client_secret": {secret}, "code": {code}, + "grant_type": {"authorization_code"}, "redirect_uri": {redirectURI}, + }, &token); err != nil { + return Identity{}, err + } + if token.IDToken == "" { + return Identity{}, errors.New("Apple 未返回 id_token:" + token.Error) + } + // id_token 是刚从 Apple 的令牌端点经 TLS 直接取回的,来源可信,只核对声明不再验签 + claims, err := parseIDToken(token.IDToken) + if err != nil { + return Identity{}, err + } + if claims.Issuer != "https://appleid.apple.com" || claims.Audience != a.clientID { + return Identity{}, errors.New("Apple id_token 的签发者或受众不符") + } + if time.Now().Unix() > claims.Expires { + return Identity{}, errors.New("Apple id_token 已过期") + } + identity := Identity{Provider: "apple", Subject: claims.Subject, Email: claims.Email, EmailVerified: claims.emailVerified()} + // 姓名只在用户第一次授权时随回调表单给出,之后 Apple 不再提供 + if raw := form.Get("user"); raw != "" { + var user struct { + Name struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + } `json:"name"` + } + if json.Unmarshal([]byte(raw), &user) == nil { + identity.Name = strings.TrimSpace(user.Name.FirstName + " " + user.Name.LastName) + } + } + return identity, nil +} + +// clientSecret 按 Apple 要求签发 ES256 JWT;有效期取 1 小时,每次换取时现签 +func (a *apple) clientSecret(now time.Time) (string, error) { + header, _ := json.Marshal(map[string]string{"alg": "ES256", "kid": a.keyID}) + payload, _ := json.Marshal(map[string]any{ + "iss": a.teamID, + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + "aud": "https://appleid.apple.com", + "sub": a.clientID, + }) + signing := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + digest := sha256.Sum256([]byte(signing)) + r, s, err := ecdsa.Sign(rand.Reader, a.key, digest[:]) + if err != nil { + return "", err + } + // JWS 要求 r、s 各补齐到 32 字节后拼接,而不是 ASN.1 + size := (a.key.Curve.Params().BitSize + 7) / 8 + sig := make([]byte, 2*size) + r.FillBytes(sig[:size]) + s.FillBytes(sig[size:]) + return signing + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +type idTokenClaims struct { + Issuer string `json:"iss"` + Audience string `json:"aud"` + Expires int64 `json:"exp"` + Subject string `json:"sub"` + Email string `json:"email"` + // Apple 有时给字符串 "true",有时给布尔值 + EmailVerifiedRaw json.RawMessage `json:"email_verified"` +} + +func (c idTokenClaims) emailVerified() bool { + s := strings.Trim(string(c.EmailVerifiedRaw), `"`) + return s == "true" +} + +func parseIDToken(token string) (idTokenClaims, error) { + var claims idTokenClaims + parts := strings.Split(token, ".") + if len(parts) != 3 { + return claims, errors.New("id_token 格式不正确") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return claims, err + } + return claims, json.Unmarshal(payload, &claims) +} + +// timeNow 便于测试替换 +var timeNow = func() time.Time { return time.Now() } diff --git a/server/sync/configure.sh b/server/sync/configure.sh new file mode 100755 index 0000000..0ebb712 --- /dev/null +++ b/server/sync/configure.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# 把各登录方式的 Client ID / Secret 写到服务器的 /etc/openstats/sync.env 并重启同步服务。 +# 值在本机终端里输入(Secret 不回显),只经 ssh 传到服务器,不进仓库、不进日志。 +# 留空表示保持服务器上现有的值不变。 +# +# ./server/sync/configure.sh +set -euo pipefail +HOST="${SITE_HOST:-debian@51.38.126.148}" +KEY="${SITE_KEY:-$HOME/.ssh/gentpan.pem}" +SSH=(ssh -i "$KEY" -o BatchMode=yes "$HOST") + +ask() { # ask 变量名 提示 + local value; read -r -p "$2(留空跳过): " value; printf -v "$1" '%s' "$value" +} +ask_secret() { # 不回显 + local value; read -r -s -p "$2(留空跳过): " value; echo; printf -v "$1" '%s' "$value" +} + +echo "写入 $HOST:/etc/openstats/sync.env" +ask GITHUB_CLIENT_ID "GitHub Client ID" +ask_secret GITHUB_CLIENT_SECRET "GitHub Client Secret" +ask GOOGLE_CLIENT_ID "Google Client ID" +ask_secret GOOGLE_CLIENT_SECRET "Google Client Secret" +ask APPLE_CLIENT_ID "Apple Services ID(例如 com.openstats.web)" +ask APPLE_TEAM_ID "Apple Team ID" +ask APPLE_KEY_ID "Apple Key ID" +ask APPLE_P8 "Apple .p8 文件的本机路径" + +APPLE_PRIVATE_KEY_FILE="" +if [ -n "$APPLE_P8" ]; then + [ -r "$APPLE_P8" ] || { echo "读不到 $APPLE_P8" >&2; exit 1; } + scp -i "$KEY" -o BatchMode=yes "$APPLE_P8" "$HOST:/tmp/apple-signin.p8" + "${SSH[@]}" 'sudo install -m 640 -o root -g openstats-sync /tmp/apple-signin.p8 /etc/openstats/apple-signin.p8 && rm -f /tmp/apple-signin.p8' + APPLE_PRIVATE_KEY_FILE=/etc/openstats/apple-signin.p8 +fi +export GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET \ + APPLE_CLIENT_ID APPLE_TEAM_ID APPLE_KEY_ID APPLE_PRIVATE_KEY_FILE + +# 本机 python 把要改的键值编成一段远端 python(repr 保证转义),经标准输入交给服务器执行; +# 有值的键覆盖,没值的保留,不出现在任何命令行参数里 +python3 - <<'PYEOF' | "${SSH[@]}" 'sudo python3 -' +import os +keys = ["GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", + "APPLE_CLIENT_ID", "APPLE_TEAM_ID", "APPLE_KEY_ID", "APPLE_PRIVATE_KEY_FILE"] +updates = {k: os.environ[k] for k in keys if os.environ.get(k)} +print("""import os, re +path = "/etc/openstats/sync.env" +updates = %r +text = open(path).read() if os.path.exists(path) else "" +lines = text.split("\\n") +seen = set() +for i, line in enumerate(lines): + m = re.match(r"^([A-Z_]+)=", line) + if m and m.group(1) in updates: + lines[i] = m.group(1) + "=" + updates[m.group(1)] + seen.add(m.group(1)) +for k, v in updates.items(): + if k not in seen: + lines.append(k + "=" + v) +tmp = path + ".new" +with open(tmp, "w") as f: + f.write("\\n".join(lines).rstrip("\\n") + "\\n") +os.chmod(tmp, 0o640) +os.replace(tmp, path) +print("已更新", ", ".join(sorted(updates)) if updates else "(没有改动)") +""" % (updates,)) +PYEOF +"${SSH[@]}" 'sudo chgrp openstats-sync /etc/openstats/sync.env && sudo chmod 640 /etc/openstats/sync.env && sudo systemctl restart openstats-sync && sleep 1 && curl -s http://127.0.0.1:8787/api/v1/auth/providers && echo' diff --git a/server/sync/go.mod b/server/sync/go.mod new file mode 100644 index 0000000..5666d0a --- /dev/null +++ b/server/sync/go.mod @@ -0,0 +1,17 @@ +module getopenstats.com/sync + +go 1.25.0 + +require modernc.org/sqlite v1.58.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/server/sync/go.sum b/server/sync/go.sum new file mode 100644 index 0000000..7ad7cda --- /dev/null +++ b/server/sync/go.sum @@ -0,0 +1,50 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/server/sync/handlers.go b/server/sync/handlers.go new file mode 100644 index 0000000..5583116 --- /dev/null +++ b/server/sync/handlers.go @@ -0,0 +1,372 @@ +package main + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +// 应用注册的回调 scheme:登录完成后浏览器跳到 openstats://auth/callback?code=…,由 ASWebAuthenticationSession 接住 +const appCallback = "openstats://auth/callback" + +// 设置文档上限:实际文档不到 2 KB,留足余量 +const maxDocumentBytes = 64 << 10 + +var challengePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) + +type Server struct { + cfg Config + store *Store + mux *http.ServeMux + limiter *limiter +} + +func NewServer(cfg Config, store *Store) *Server { + s := &Server{cfg: cfg, store: store, mux: http.NewServeMux(), limiter: newLimiter(30, time.Minute)} + s.mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) }) + s.mux.HandleFunc("GET /api/v1/auth/providers", s.providers) + s.mux.HandleFunc("GET /api/v1/auth/{provider}/start", s.limited(s.start)) + s.mux.HandleFunc("GET /api/v1/auth/{provider}/callback", s.limited(s.callback)) + s.mux.HandleFunc("POST /api/v1/auth/{provider}/callback", s.limited(s.callback)) + s.mux.HandleFunc("POST /api/v1/auth/exchange", s.limited(s.exchange)) + s.mux.HandleFunc("POST /api/v1/auth/logout", s.authed(s.logout)) + s.mux.HandleFunc("GET /api/v1/me", s.authed(s.me)) + s.mux.HandleFunc("GET /api/v1/settings", s.authed(s.getSettings)) + s.mux.HandleFunc("PUT /api/v1/settings", s.authed(s.putSettings)) + s.mux.HandleFunc("DELETE /api/v1/account", s.authed(s.deleteAccount)) + return s +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + s.mux.ServeHTTP(w, r) +} + +func (s *Server) redirectURI(provider string) string { + return strings.TrimRight(s.cfg.BaseURL, "/") + "/api/v1/auth/" + provider + "/callback" +} + +// MARK: 登录 + +func (s *Server) providers(w http.ResponseWriter, _ *http.Request) { + names := []string{} + for _, name := range []string{"github", "google", "apple"} { + if _, ok := s.cfg.Providers[name]; ok { + names = append(names, name) + } + } + writeJSON(w, 200, map[string]any{"providers": names}) +} + +// start 记下应用生成的 PKCE challenge,把浏览器送去第三方授权页 +func (s *Server) start(w http.ResponseWriter, r *http.Request) { + provider, ok := s.cfg.Providers[r.PathValue("provider")] + if !ok { + writeError(w, 404, "不支持的登录方式") + return + } + challenge := r.URL.Query().Get("challenge") + if !challengePattern.MatchString(challenge) { + writeError(w, 400, "缺少 challenge") + return + } + state, err := s.store.CreateLogin(provider.Name(), challenge) + if err != nil { + s.fail(w, err) + return + } + http.Redirect(w, r, provider.AuthURL(state, s.redirectURI(provider.Name())), http.StatusFound) +} + +// callback 校验 state,用授权码换身份,签发一次性换取码交回应用 +func (s *Server) callback(w http.ResponseWriter, r *http.Request) { + provider, ok := s.cfg.Providers[r.PathValue("provider")] + if !ok { + writeError(w, 404, "不支持的登录方式") + return + } + if err := r.ParseForm(); err != nil { + writeError(w, 400, "参数不正确") + return + } + form := r.Form + login, err := s.store.TakeLogin(form.Get("state")) + if err != nil || login.Provider != provider.Name() { + s.backToApp(w, r, "", "登录已过期,请重试") + return + } + if reason := form.Get("error"); reason != "" { + if reason == "user_cancelled_authorize" || reason == "access_denied" { + s.backToApp(w, r, "", "cancelled") + } else { + s.backToApp(w, r, "", reason) + } + return + } + code := form.Get("code") + if code == "" { + s.backToApp(w, r, "", "未收到授权码") + return + } + identity, err := provider.Exchange(r.Context(), code, s.redirectURI(provider.Name()), form) + if err != nil { + log.Printf("%s 换取身份失败:%v", provider.Name(), err) + s.backToApp(w, r, "", "登录失败,请重试") + return + } + user, err := s.store.UpsertUser(identity) + if err != nil { + s.fail(w, err) + return + } + exchange, err := s.store.CreateExchange(user.ID, login.Challenge) + if err != nil { + s.fail(w, err) + return + } + s.backToApp(w, r, exchange, "") +} + +func (s *Server) backToApp(w http.ResponseWriter, r *http.Request, code, errorText string) { + q := url.Values{} + if code != "" { + q.Set("code", code) + } else { + q.Set("error", errorText) + } + http.Redirect(w, r, appCallback+"?"+q.Encode(), http.StatusFound) +} + +// exchange 应用拿换取码与 PKCE verifier 换长期令牌 +func (s *Server) exchange(w http.ResponseWriter, r *http.Request) { + var body struct { + Code string `json:"code"` + Verifier string `json:"verifier"` + Device string `json:"device"` + } + if err := readJSON(r, &body, 4<<10); err != nil { + writeError(w, 400, "参数不正确") + return + } + ex, err := s.store.TakeExchange(body.Code) + if err != nil { + writeError(w, 400, "换取码无效或已过期") + return + } + if pkceChallenge(body.Verifier) != ex.Challenge { + writeError(w, 400, "verifier 不匹配") + return + } + token, err := s.store.CreateSession(ex.UserID, clip(body.Device, 80)) + if err != nil { + s.fail(w, err) + return + } + user, err := s.store.GetUser(ex.UserID) + if err != nil { + s.fail(w, err) + return + } + writeJSON(w, 200, map[string]any{"token": token, "user": user}) +} + +func pkceChallenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// MARK: 需要登录的接口 + +type contextKey int + +const ( + userKey contextKey = iota + tokenKey +) + +func (s *Server) authed(next func(http.ResponseWriter, *http.Request, User, string)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + token, ok := strings.CutPrefix(header, "Bearer ") + if !ok || token == "" { + writeError(w, 401, "需要登录") + return + } + user, err := s.store.UserForToken(token) + if errors.Is(err, ErrNotFound) { + writeError(w, 401, "登录已失效") + return + } + if err != nil { + s.fail(w, err) + return + } + next(w, r, user, token) + } +} + +func (s *Server) me(w http.ResponseWriter, _ *http.Request, user User, _ string) { + writeJSON(w, 200, user) +} + +func (s *Server) logout(w http.ResponseWriter, _ *http.Request, _ User, token string) { + if err := s.store.DeleteSession(token); err != nil { + s.fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) deleteAccount(w http.ResponseWriter, _ *http.Request, user User, _ string) { + if err := s.store.DeleteUser(user.ID); err != nil { + s.fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) getSettings(w http.ResponseWriter, _ *http.Request, user User, _ string) { + st, err := s.store.GetSettings(user.ID) + if errors.Is(err, ErrNotFound) { + writeError(w, 404, "还没有保存过设置") + return + } + if err != nil { + s.fail(w, err) + return + } + writeJSON(w, 200, map[string]any{ + "version": st.Version, + "updatedAt": st.UpdatedAt.UTC().Format(time.RFC3339), + "device": st.Device, + "document": json.RawMessage(st.Document), + }) +} + +func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, user User, _ string) { + var body struct { + Document json.RawMessage `json:"document"` + Device string `json:"device"` + } + if err := readJSON(r, &body, maxDocumentBytes); err != nil { + writeError(w, 400, "参数不正确或文档过大") + return + } + // 只接受 JSON 对象,原样保存,服务端不解释字段 + var object map[string]json.RawMessage + if err := json.Unmarshal(body.Document, &object); err != nil || object == nil { + writeError(w, 400, "document 必须是 JSON 对象") + return + } + st, err := s.store.PutSettings(user.ID, body.Document, clip(body.Device, 80)) + if err != nil { + s.fail(w, err) + return + } + writeJSON(w, 200, map[string]any{"version": st.Version, "updatedAt": st.UpdatedAt.UTC().Format(time.RFC3339)}) +} + +// MARK: 工具 + +func (s *Server) fail(w http.ResponseWriter, err error) { + log.Printf("内部错误:%v", err) + writeError(w, 500, "服务器内部错误") +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func readJSON(r *http.Request, out any, limit int64) error { + body, err := io.ReadAll(io.LimitReader(r.Body, limit+1)) + if err != nil { + return err + } + if int64(len(body)) > limit { + return errors.New("body too large") + } + return json.Unmarshal(body, out) +} + +func clip(s string, max int) string { + if len(s) > max { + return s[:max] + } + return s +} + +// limiter 是按来源 IP 的简单滑动窗口限流,只用在登录相关接口 +type limiter struct { + mu sync.Mutex + hits map[string][]time.Time + limit int + window time.Duration +} + +func newLimiter(limit int, window time.Duration) *limiter { + return &limiter{hits: map[string][]time.Time{}, limit: limit, window: window} +} + +func (l *limiter) allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + recent := l.hits[key][:0] + for _, t := range l.hits[key] { + if now.Sub(t) < l.window { + recent = append(recent, t) + } + } + if len(recent) >= l.limit { + l.hits[key] = recent + return false + } + l.hits[key] = append(recent, now) + if len(l.hits) > 10000 { + // 极端情况下直接清空,避免无限增长 + l.hits = map[string][]time.Time{} + } + return true +} + +func (s *Server) limited(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.limiter.allow(clientIP(r)) { + writeError(w, 429, "请求过于频繁,请稍后再试") + return + } + next(w, r) + } +} + +// clientIP 依次看 Cloudflare、反向代理头,最后才是连接地址 +func clientIP(r *http.Request) string { + if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { + return ip + } + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + return strings.TrimSpace(strings.Split(forwarded, ",")[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/server/sync/install.sh b/server/sync/install.sh new file mode 100755 index 0000000..b035570 --- /dev/null +++ b/server/sync/install.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# 把同步服务装到官网服务器:交叉编译、安装二进制与 systemd 单元、建专用用户, +# 并在 Caddy 的站点块里把 /api/ 反代到本服务。第三方登录的 Client ID / Secret 不进仓库, +# 放在服务器的 /etc/openstats/sync.env(模板见 sync.env.example,用 configure.sh 填写)。 +# +# ./server/sync/install.sh +set -euo pipefail +cd "$(dirname "$0")" +HOST="${SITE_HOST:-debian@51.38.126.148}" +KEY="${SITE_KEY:-$HOME/.ssh/gentpan.pem}" +SSH=(ssh -i "$KEY" -o BatchMode=yes "$HOST") + +# 目标架构按服务器实际情况 +case "$("${SSH[@]}" uname -m)" in + x86_64) GOARCH=amd64 ;; + aarch64|arm64) GOARCH=arm64 ;; + *) echo "未知架构" >&2; exit 1 ;; +esac + +go test ./... +CGO_ENABLED=0 GOOS=linux GOARCH=$GOARCH go build -trimpath -ldflags="-s -w" -o /tmp/openstats-sync . +echo "已编译 linux/$GOARCH" + +scp -i "$KEY" -o BatchMode=yes /tmp/openstats-sync openstats-sync.service sync.env.example openstats-sync.caddy "$HOST:/tmp/" +rm -f /tmp/openstats-sync + +# 远端步骤通过标准输入交给 bash,引号不受本地 ssh 参数的限制 +"${SSH[@]}" bash -s <<'REMOTE' +set -euo pipefail +id -u openstats-sync >/dev/null 2>&1 || sudo useradd --system --home /var/lib/openstats-sync --shell /usr/sbin/nologin openstats-sync +sudo install -m 755 /tmp/openstats-sync /usr/local/bin/openstats-sync +sudo install -m 644 /tmp/openstats-sync.service /etc/systemd/system/ +sudo install -d -m 750 -o root -g openstats-sync /etc/openstats +if [ ! -f /etc/openstats/sync.env ]; then + sudo install -m 640 -o root -g openstats-sync /tmp/sync.env.example /etc/openstats/sync.env + echo "已创建 /etc/openstats/sync.env,请用 configure.sh 填入各登录方式的 Client ID / Secret" +fi +sudo chgrp openstats-sync /etc/openstats/sync.env && sudo chmod 640 /etc/openstats/sync.env +if [ -f /etc/openstats/apple-signin.p8 ]; then + sudo chgrp openstats-sync /etc/openstats/apple-signin.p8 && sudo chmod 640 /etc/openstats/apple-signin.p8 +fi + +# Caddy:站点配置在 /etc/caddy/sites/getopenstats.com.caddy,在站点块开头插入 handle /api/*;已有则跳过。 +# 改完用完整的 Caddyfile 校验,不通过就恢复原文件 +SITE=/etc/caddy/sites/getopenstats.com.caddy +if sudo test -f "$SITE" && ! sudo grep -q openstats-sync "$SITE"; then + BACKUP="$SITE.bak.$(date +%Y%m%d%H%M%S)" + sudo cp -p "$SITE" "$BACKUP" + sudo awk -v snippet="$(cat /tmp/openstats-sync.caddy)" ' + { print } + !done && /^[^#]*getopenstats\.com[^{]*\{[[:space:]]*$/ { print snippet; done = 1 } + ' "$BACKUP" | sudo tee "$SITE" >/dev/null + if sudo grep -q openstats-sync "$SITE" && sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1; then + sudo systemctl reload caddy + sudo rm -f "$BACKUP" + echo "Caddy 已加入 /api/ 反向代理" + else + sudo cp -p "$BACKUP" "$SITE" && sudo rm -f "$BACKUP" + echo "没有自动改 Caddy 配置,请手动把下面这段放进 $SITE 的 getopenstats.com 站点块里,然后 systemctl reload caddy:" + cat /tmp/openstats-sync.caddy + fi +elif ! sudo test -f "$SITE"; then + echo "没找到 $SITE,请手动把下面这段放进官网的 Caddy 站点块里:" + cat /tmp/openstats-sync.caddy +fi +rm -f /tmp/openstats-sync /tmp/openstats-sync.service /tmp/sync.env.example /tmp/openstats-sync.caddy + +sudo systemctl daemon-reload +sudo systemctl enable --now openstats-sync.service +sudo systemctl restart openstats-sync.service +sleep 1 +systemctl --no-pager --lines=5 status openstats-sync.service || true +curl -s http://127.0.0.1:8787/api/v1/auth/providers || true +echo +REMOTE diff --git a/server/sync/main.go b/server/sync/main.go new file mode 100644 index 0000000..ea1d101 --- /dev/null +++ b/server/sync/main.go @@ -0,0 +1,113 @@ +// OpenStats 账号与设置同步服务:GitHub / Google / Apple 登录,按用户保存一份设置文档。 +// +// 部署在官网服务器上,由 Caddy 把 /api/ 反代到这里(见 install.sh)。配置全部来自环境变量: +// +// SYNC_LISTEN 监听地址,默认 127.0.0.1:8787 +// SYNC_BASE_URL 对外地址,默认 https://getopenstats.com(回调地址由此拼出) +// SYNC_DB SQLite 文件路径,默认 ./sync.db +// GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET +// GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET +// APPLE_CLIENT_ID Services ID(例如 com.openstats.web) +// APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_PRIVATE_KEY_FILE(.p8 文件路径) +// +// 没有配置的登录方式不会出现在 /auth/providers 里,应用据此隐藏对应按钮。 +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatalf("配置错误:%v", err) + } + store, err := OpenStore(cfg.DBPath) + if err != nil { + log.Fatalf("打开数据库失败:%v", err) + } + defer store.Close() + + srv := NewServer(cfg, store) + httpServer := &http.Server{ + Addr: cfg.Listen, + Handler: srv, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + go store.Housekeeping(ctx) + + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdown) + }() + + names := make([]string, 0, len(cfg.Providers)) + for name := range cfg.Providers { + names = append(names, name) + } + log.Printf("openstats-sync 监听 %s,登录方式:%v", cfg.Listen, names) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("服务退出:%v", err) + } +} + +// Config 来自环境变量 +type Config struct { + Listen string + BaseURL string + DBPath string + Providers map[string]Provider +} + +func loadConfig() (Config, error) { + cfg := Config{ + Listen: env("SYNC_LISTEN", "127.0.0.1:8787"), + BaseURL: env("SYNC_BASE_URL", "https://getopenstats.com"), + DBPath: env("SYNC_DB", "sync.db"), + Providers: map[string]Provider{}, + } + if id, secret := os.Getenv("GITHUB_CLIENT_ID"), os.Getenv("GITHUB_CLIENT_SECRET"); id != "" && secret != "" { + cfg.Providers["github"] = NewGitHub(id, secret) + } + if id, secret := os.Getenv("GOOGLE_CLIENT_ID"), os.Getenv("GOOGLE_CLIENT_SECRET"); id != "" && secret != "" { + cfg.Providers["google"] = NewGoogle(id, secret) + } + if id := os.Getenv("APPLE_CLIENT_ID"); id != "" { + keyFile := os.Getenv("APPLE_PRIVATE_KEY_FILE") + pem, err := os.ReadFile(keyFile) + if err != nil { + return cfg, errors.New("APPLE_PRIVATE_KEY_FILE 无法读取:" + err.Error()) + } + apple, err := NewApple(id, os.Getenv("APPLE_TEAM_ID"), os.Getenv("APPLE_KEY_ID"), pem) + if err != nil { + return cfg, err + } + cfg.Providers["apple"] = apple + } + if len(cfg.Providers) == 0 { + log.Printf("警告:没有配置任何登录方式,登录接口会返回 404") + } + return cfg, nil +} + +func env(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/server/sync/openstats-sync.caddy b/server/sync/openstats-sync.caddy new file mode 100644 index 0000000..ce19210 --- /dev/null +++ b/server/sync/openstats-sync.caddy @@ -0,0 +1,4 @@ + # openstats-sync:账号与设置同步接口,其余路径仍由静态站点提供 + handle /api/* { + reverse_proxy 127.0.0.1:8787 + } diff --git a/server/sync/openstats-sync.service b/server/sync/openstats-sync.service new file mode 100644 index 0000000..1805740 --- /dev/null +++ b/server/sync/openstats-sync.service @@ -0,0 +1,23 @@ +[Unit] +Description=OpenStats 账号与设置同步服务 +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/local/bin/openstats-sync +EnvironmentFile=/etc/openstats/sync.env +Environment=SYNC_DB=/var/lib/openstats-sync/sync.db +User=openstats-sync +Group=openstats-sync +StateDirectory=openstats-sync +Restart=on-failure +RestartSec=3 +# 只需要读配置、写自己的数据目录 +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +NoNewPrivileges=yes +ReadWritePaths=/var/lib/openstats-sync + +[Install] +WantedBy=multi-user.target diff --git a/server/sync/providers.go b/server/sync/providers.go new file mode 100644 index 0000000..2a59530 --- /dev/null +++ b/server/sync/providers.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Identity 是第三方登录返回的身份:同一 provider 下 subject 唯一 +type Identity struct { + Provider string + Subject string + Email string + EmailVerified bool + Name string + Avatar string +} + +// Provider 封装一种登录方式:生成授权地址、用授权码换身份 +type Provider interface { + Name() string + AuthURL(state, redirectURI string) string + // form 是回调请求的全部参数;Apple 首次登录时在 user 字段里带姓名 + Exchange(ctx context.Context, code, redirectURI string, form url.Values) (Identity, error) +} + +var httpClient = &http.Client{Timeout: 15 * time.Second} + +// postForm 向令牌端点提交表单并解析 JSON +func postForm(ctx context.Context, endpoint string, form url.Values, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "openstats-sync") + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode/100 != 2 { + return fmt.Errorf("%s 返回 %d:%s", endpoint, resp.StatusCode, trim(body)) + } + return json.Unmarshal(body, out) +} + +func getJSON(ctx context.Context, endpoint, bearer string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+bearer) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "openstats-sync") + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode/100 != 2 { + return fmt.Errorf("%s 返回 %d:%s", endpoint, resp.StatusCode, trim(body)) + } + return json.Unmarshal(body, out) +} + +func trim(body []byte) string { + s := strings.TrimSpace(string(body)) + if len(s) > 200 { + s = s[:200] + "…" + } + return s +} + +// MARK: GitHub + +type gitHub struct{ id, secret string } + +func NewGitHub(id, secret string) Provider { return &gitHub{id: id, secret: secret} } + +func (g *gitHub) Name() string { return "github" } + +func (g *gitHub) AuthURL(state, redirectURI string) string { + q := url.Values{ + "client_id": {g.id}, + "redirect_uri": {redirectURI}, + "scope": {"read:user user:email"}, + "state": {state}, + } + return "https://github.com/login/oauth/authorize?" + q.Encode() +} + +func (g *gitHub) Exchange(ctx context.Context, code, redirectURI string, _ url.Values) (Identity, error) { + var token struct { + AccessToken string `json:"access_token"` + Error string `json:"error_description"` + } + if err := postForm(ctx, "https://github.com/login/oauth/access_token", url.Values{ + "client_id": {g.id}, "client_secret": {g.secret}, "code": {code}, "redirect_uri": {redirectURI}, + }, &token); err != nil { + return Identity{}, err + } + if token.AccessToken == "" { + return Identity{}, errors.New("GitHub 未返回令牌:" + token.Error) + } + var user struct { + ID int64 `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Avatar string `json:"avatar_url"` + } + if err := getJSON(ctx, "https://api.github.com/user", token.AccessToken, &user); err != nil { + return Identity{}, err + } + identity := Identity{Provider: "github", Subject: strconv.FormatInt(user.ID, 10), Name: user.Name, Avatar: user.Avatar} + if identity.Name == "" { + identity.Name = user.Login + } + var emails []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + if err := getJSON(ctx, "https://api.github.com/user/emails", token.AccessToken, &emails); err == nil { + for _, e := range emails { + if e.Primary && e.Verified { + identity.Email, identity.EmailVerified = e.Email, true + break + } + } + } + return identity, nil +} + +// MARK: Google + +type google struct{ id, secret string } + +func NewGoogle(id, secret string) Provider { return &google{id: id, secret: secret} } + +func (g *google) Name() string { return "google" } + +func (g *google) AuthURL(state, redirectURI string) string { + q := url.Values{ + "client_id": {g.id}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {"openid email profile"}, + "state": {state}, + "prompt": {"select_account"}, + } + return "https://accounts.google.com/o/oauth2/v2/auth?" + q.Encode() +} + +func (g *google) Exchange(ctx context.Context, code, redirectURI string, _ url.Values) (Identity, error) { + var token struct { + AccessToken string `json:"access_token"` + } + if err := postForm(ctx, "https://oauth2.googleapis.com/token", url.Values{ + "client_id": {g.id}, "client_secret": {g.secret}, "code": {code}, + "redirect_uri": {redirectURI}, "grant_type": {"authorization_code"}, + }, &token); err != nil { + return Identity{}, err + } + if token.AccessToken == "" { + return Identity{}, errors.New("Google 未返回令牌") + } + var info struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` + } + if err := getJSON(ctx, "https://openidconnect.googleapis.com/v1/userinfo", token.AccessToken, &info); err != nil { + return Identity{}, err + } + if info.Sub == "" { + return Identity{}, errors.New("Google 未返回用户标识") + } + return Identity{Provider: "google", Subject: info.Sub, Email: info.Email, EmailVerified: info.EmailVerified, + Name: info.Name, Avatar: info.Picture}, nil +} diff --git a/server/sync/server_test.go b/server/sync/server_test.go new file mode 100644 index 0000000..de5a567 --- /dev/null +++ b/server/sync/server_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// fakeProvider 不联网:把授权码当 subject 用 +type fakeProvider struct{} + +func (fakeProvider) Name() string { return "fake" } +func (fakeProvider) AuthURL(state, redirect string) string { + return "https://example.com/authorize?state=" + state + "&redirect_uri=" + url.QueryEscape(redirect) +} +func (fakeProvider) Exchange(_ context.Context, code, _ string, form url.Values) (Identity, error) { + return Identity{Provider: "fake", Subject: code, Email: code + "@example.com", EmailVerified: true, Name: form.Get("name")}, nil +} + +func newTestServer(t *testing.T) *Server { + t.Helper() + store, err := OpenStore(t.TempDir() + "/sync.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + cfg := Config{BaseURL: "https://sync.test", Providers: map[string]Provider{"fake": fakeProvider{}}} + return NewServer(cfg, store) +} + +func do(t *testing.T, srv http.Handler, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader *strings.Reader + if body != nil { + raw, _ := json.Marshal(body) + reader = strings.NewReader(string(raw)) + } else { + reader = strings.NewReader("") + } + req := httptest.NewRequest(method, path, reader) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + return rec +} + +// 完整走一遍:start → callback → exchange → 存取设置 → 退出 +func TestLoginAndSettingsFlow(t *testing.T) { + srv := newTestServer(t) + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + + rec := do(t, srv, "GET", "/api/v1/auth/fake/start?challenge="+challenge, "", nil) + if rec.Code != http.StatusFound { + t.Fatalf("start 返回 %d:%s", rec.Code, rec.Body) + } + authorize, _ := url.Parse(rec.Header().Get("Location")) + state := authorize.Query().Get("state") + if state == "" || authorize.Query().Get("redirect_uri") != "https://sync.test/api/v1/auth/fake/callback" { + t.Fatalf("授权地址不对:%s", authorize) + } + + rec = do(t, srv, "GET", "/api/v1/auth/fake/callback?state="+state+"&code=alice", "", nil) + if rec.Code != http.StatusFound { + t.Fatalf("callback 返回 %d:%s", rec.Code, rec.Body) + } + back, _ := url.Parse(rec.Header().Get("Location")) + if back.Scheme != "openstats" || back.Query().Get("code") == "" { + t.Fatalf("没有跳回应用:%s", back) + } + // state 只能用一次 + if rec := do(t, srv, "GET", "/api/v1/auth/fake/callback?state="+state+"&code=alice", "", nil); !strings.Contains(rec.Header().Get("Location"), "error=") { + t.Fatalf("重放 state 应该失败:%s", rec.Header().Get("Location")) + } + + // verifier 错误 + rec = do(t, srv, "POST", "/api/v1/auth/exchange", "", map[string]string{"code": back.Query().Get("code"), "verifier": "wrong"}) + if rec.Code != 400 { + t.Fatalf("错误的 verifier 应该被拒绝,得到 %d", rec.Code) + } + // 换取码被消耗后,正确的 verifier 也无法再用:重新走一遍 + rec = do(t, srv, "GET", "/api/v1/auth/fake/start?challenge="+challenge, "", nil) + authorize, _ = url.Parse(rec.Header().Get("Location")) + rec = do(t, srv, "GET", "/api/v1/auth/fake/callback?state="+authorize.Query().Get("state")+"&code=alice", "", nil) + back, _ = url.Parse(rec.Header().Get("Location")) + rec = do(t, srv, "POST", "/api/v1/auth/exchange", "", map[string]string{"code": back.Query().Get("code"), "verifier": verifier, "device": "MacBook"}) + if rec.Code != 200 { + t.Fatalf("exchange 返回 %d:%s", rec.Code, rec.Body) + } + var session struct { + Token string `json:"token"` + User User `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &session) + if session.Token == "" || session.User.Email != "alice@example.com" || len(session.User.Providers) != 1 { + t.Fatalf("会话不完整:%s", rec.Body) + } + + if rec := do(t, srv, "GET", "/api/v1/settings", session.Token, nil); rec.Code != 404 { + t.Fatalf("新用户应该没有设置,得到 %d", rec.Code) + } + rec = do(t, srv, "PUT", "/api/v1/settings", session.Token, map[string]any{"document": map[string]any{"refreshSeconds": 3}, "device": "MacBook"}) + if rec.Code != 200 { + t.Fatalf("PUT 返回 %d:%s", rec.Code, rec.Body) + } + rec = do(t, srv, "PUT", "/api/v1/settings", session.Token, map[string]any{"document": map[string]any{"refreshSeconds": 5}}) + var receipt struct{ Version int64 } + _ = json.Unmarshal(rec.Body.Bytes(), &receipt) + if receipt.Version != 2 { + t.Fatalf("第二次保存版本应为 2,得到 %d", receipt.Version) + } + rec = do(t, srv, "GET", "/api/v1/settings", session.Token, nil) + var stored struct { + Version int64 + Document map[string]any + } + _ = json.Unmarshal(rec.Body.Bytes(), &stored) + if stored.Version != 2 || stored.Document["refreshSeconds"] != float64(5) { + t.Fatalf("读回的设置不对:%s", rec.Body) + } + if rec := do(t, srv, "PUT", "/api/v1/settings", session.Token, map[string]any{"document": []int{1}}); rec.Code != 400 { + t.Fatalf("数组文档应被拒绝,得到 %d", rec.Code) + } + + // 同邮箱在另一种登录方式下登录,并入同一用户 + srv.cfg.Providers["other"] = otherProvider{} + rec = do(t, srv, "GET", "/api/v1/auth/other/start?challenge="+challenge, "", nil) + authorize, _ = url.Parse(rec.Header().Get("Location")) + rec = do(t, srv, "GET", "/api/v1/auth/other/callback?state="+authorize.Query().Get("state")+"&code=alice", "", nil) + back, _ = url.Parse(rec.Header().Get("Location")) + rec = do(t, srv, "POST", "/api/v1/auth/exchange", "", map[string]string{"code": back.Query().Get("code"), "verifier": verifier}) + var second struct{ User User } + _ = json.Unmarshal(rec.Body.Bytes(), &second) + if second.User.ID != session.User.ID || len(second.User.Providers) != 2 { + t.Fatalf("同邮箱应并入同一用户:%s", rec.Body) + } + + if rec := do(t, srv, "POST", "/api/v1/auth/logout", session.Token, nil); rec.Code != 204 { + t.Fatalf("logout 返回 %d", rec.Code) + } + if rec := do(t, srv, "GET", "/api/v1/me", session.Token, nil); rec.Code != 401 { + t.Fatalf("退出后令牌应失效,得到 %d", rec.Code) + } +} + +type otherProvider struct{ fakeProvider } + +func (otherProvider) Name() string { return "other" } +func (otherProvider) Exchange(_ context.Context, code, _ string, _ url.Values) (Identity, error) { + return Identity{Provider: "other", Subject: "o-" + code, Email: code + "@example.com", EmailVerified: true}, nil +} + +func TestDeleteAccountRemovesEverything(t *testing.T) { + srv := newTestServer(t) + user, err := srv.store.UpsertUser(Identity{Provider: "fake", Subject: "bob"}) + if err != nil { + t.Fatal(err) + } + token, _ := srv.store.CreateSession(user.ID, "") + _, _ = srv.store.PutSettings(user.ID, []byte(`{}`), "") + if rec := do(t, srv, "DELETE", "/api/v1/account", token, nil); rec.Code != 204 { + t.Fatalf("删除返回 %d", rec.Code) + } + if _, err := srv.store.GetUser(user.ID); err != ErrNotFound { + t.Fatalf("用户应已删除:%v", err) + } + if _, err := srv.store.GetSettings(user.ID); err != ErrNotFound { + t.Fatalf("设置应已删除:%v", err) + } +} + +func TestPKCE(t *testing.T) { + // RFC 7636 附录 B 的示例 + if got := pkceChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); got != "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" { + t.Fatalf("challenge 计算错误:%s", got) + } +} + +func TestAppleClientSecretIsES256(t *testing.T) { + const pemKey = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2\nOF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r\n1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G\n-----END PRIVATE KEY-----\n" + p, err := NewApple("com.openstats.web", "TEAM123456", "KEY1234567", []byte(pemKey)) + if err != nil { + t.Fatal(err) + } + secret, err := p.(*apple).clientSecret(timeNow()) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(secret, ".") + if len(parts) != 3 { + t.Fatalf("JWT 应有三段:%s", secret) + } + claims, err := parseIDToken(secret) + if err != nil || claims.Issuer != "TEAM123456" || claims.Audience != "https://appleid.apple.com" || claims.Subject != "com.openstats.web" { + t.Fatalf("声明不对:%+v %v", claims, err) + } +} diff --git a/server/sync/store.go b/server/sync/store.go new file mode 100644 index 0000000..cd3e076 --- /dev/null +++ b/server/sync/store.go @@ -0,0 +1,372 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "errors" + "log" + "time" + + _ "modernc.org/sqlite" +) + +// Store 是 SQLite 上的一层薄封装。每个用户一份设置文档,整份覆盖,版本号递增。 +type Store struct { + db *sql.DB +} + +type User struct { + ID string `json:"id"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Avatar string `json:"avatar,omitempty"` + Providers []string `json:"providers"` +} + +type Login struct { + State string + Provider string + Challenge string + CreatedAt time.Time +} + +type Exchange struct { + Code string + UserID string + Challenge string + CreatedAt time.Time +} + +type Settings struct { + Version int64 + Document []byte + Device string + UpdatedAt time.Time +} + +var ErrNotFound = errors.New("not found") + +const schema = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT, + name TEXT, + avatar TEXT, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS users_email ON users(email); +CREATE TABLE IF NOT EXISTS accounts ( + provider TEXT NOT NULL, + subject TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT, + PRIMARY KEY (provider, subject) +); +CREATE INDEX IF NOT EXISTS accounts_user ON accounts(user_id); +CREATE TABLE IF NOT EXISTS logins ( + state TEXT PRIMARY KEY, + provider TEXT NOT NULL, + challenge TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS exchanges ( + code TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + challenge TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + device TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id); +CREATE TABLE IF NOT EXISTS settings ( + user_id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + document TEXT NOT NULL, + device TEXT, + updated_at INTEGER NOT NULL +); +` + +func OpenStore(path string) (*Store, error) { + db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)") + if err != nil { + return nil, err + } + // 纯 Go 的 SQLite 驱动在多个连接间共享内存库会出问题,单连接足够应付这里的流量 + db.SetMaxOpenConns(1) + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, err + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +// Housekeeping 每小时清掉过期的登录状态与一次性换取码 +func (s *Store) Housekeeping(ctx context.Context) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + s.expire() + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (s *Store) expire() { + cutoff := time.Now().Add(-15 * time.Minute).Unix() + if _, err := s.db.Exec(`DELETE FROM logins WHERE created_at < ?`, cutoff); err != nil { + log.Printf("清理 logins 失败:%v", err) + } + if _, err := s.db.Exec(`DELETE FROM exchanges WHERE created_at < ?`, cutoff); err != nil { + log.Printf("清理 exchanges 失败:%v", err) + } +} + +// MARK: 登录流程 + +func (s *Store) CreateLogin(provider, challenge string) (string, error) { + state := randomToken(24) + _, err := s.db.Exec(`INSERT INTO logins(state, provider, challenge, created_at) VALUES(?,?,?,?)`, + state, provider, challenge, time.Now().Unix()) + return state, err +} + +// TakeLogin 取出并删除登录状态;不存在或超过 10 分钟返回 ErrNotFound +func (s *Store) TakeLogin(state string) (Login, error) { + var login Login + var created int64 + err := s.db.QueryRow(`DELETE FROM logins WHERE state = ? RETURNING state, provider, challenge, created_at`, state). + Scan(&login.State, &login.Provider, &login.Challenge, &created) + if errors.Is(err, sql.ErrNoRows) { + return login, ErrNotFound + } + if err != nil { + return login, err + } + login.CreatedAt = time.Unix(created, 0) + if time.Since(login.CreatedAt) > 10*time.Minute { + return login, ErrNotFound + } + return login, nil +} + +func (s *Store) CreateExchange(userID, challenge string) (string, error) { + code := randomToken(24) + _, err := s.db.Exec(`INSERT INTO exchanges(code, user_id, challenge, created_at) VALUES(?,?,?,?)`, + code, userID, challenge, time.Now().Unix()) + return code, err +} + +func (s *Store) TakeExchange(code string) (Exchange, error) { + var ex Exchange + var created int64 + err := s.db.QueryRow(`DELETE FROM exchanges WHERE code = ? RETURNING code, user_id, challenge, created_at`, code). + Scan(&ex.Code, &ex.UserID, &ex.Challenge, &created) + if errors.Is(err, sql.ErrNoRows) { + return ex, ErrNotFound + } + if err != nil { + return ex, err + } + ex.CreatedAt = time.Unix(created, 0) + if time.Since(ex.CreatedAt) > 5*time.Minute { + return ex, ErrNotFound + } + return ex, nil +} + +// MARK: 用户 + +// UpsertUser 按 (provider, subject) 找用户;找不到时,已验证的邮箱与现有用户相同就并入,否则新建 +func (s *Store) UpsertUser(id Identity) (User, error) { + tx, err := s.db.Begin() + if err != nil { + return User{}, err + } + defer tx.Rollback() + + now := time.Now().Unix() + var userID string + err = tx.QueryRow(`SELECT user_id FROM accounts WHERE provider = ? AND subject = ?`, id.Provider, id.Subject).Scan(&userID) + switch { + case errors.Is(err, sql.ErrNoRows): + if id.Email != "" && id.EmailVerified { + err = tx.QueryRow(`SELECT id FROM users WHERE email = ? LIMIT 1`, id.Email).Scan(&userID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return User{}, err + } + } + if userID == "" { + userID = randomToken(12) + if _, err := tx.Exec(`INSERT INTO users(id, email, name, avatar, created_at, last_seen_at) VALUES(?,?,?,?,?,?)`, + userID, id.Email, id.Name, id.Avatar, now, now); err != nil { + return User{}, err + } + } + if _, err := tx.Exec(`INSERT INTO accounts(provider, subject, user_id, email) VALUES(?,?,?,?)`, + id.Provider, id.Subject, userID, id.Email); err != nil { + return User{}, err + } + case err != nil: + return User{}, err + } + + // 用最近一次登录拿到的资料补全空缺,不覆盖已有的 + if _, err := tx.Exec(`UPDATE users SET + email = CASE WHEN email IS NULL OR email = '' THEN ? ELSE email END, + name = CASE WHEN name IS NULL OR name = '' THEN ? ELSE name END, + avatar = CASE WHEN ? != '' THEN ? ELSE avatar END, + last_seen_at = ? + WHERE id = ?`, id.Email, id.Name, id.Avatar, id.Avatar, now, userID); err != nil { + return User{}, err + } + if err := tx.Commit(); err != nil { + return User{}, err + } + return s.GetUser(userID) +} + +func (s *Store) GetUser(id string) (User, error) { + var u User + var email, name, avatar sql.NullString + err := s.db.QueryRow(`SELECT id, email, name, avatar FROM users WHERE id = ?`, id).Scan(&u.ID, &email, &name, &avatar) + if errors.Is(err, sql.ErrNoRows) { + return u, ErrNotFound + } + if err != nil { + return u, err + } + u.Email, u.Name, u.Avatar = email.String, name.String, avatar.String + rows, err := s.db.Query(`SELECT provider FROM accounts WHERE user_id = ? ORDER BY provider`, id) + if err != nil { + return u, err + } + defer rows.Close() + u.Providers = []string{} + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + return u, err + } + u.Providers = append(u.Providers, p) + } + return u, rows.Err() +} + +func (s *Store) DeleteUser(id string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, q := range []string{ + `DELETE FROM settings WHERE user_id = ?`, + `DELETE FROM sessions WHERE user_id = ?`, + `DELETE FROM exchanges WHERE user_id = ?`, + `DELETE FROM accounts WHERE user_id = ?`, + `DELETE FROM users WHERE id = ?`, + } { + if _, err := tx.Exec(q, id); err != nil { + return err + } + } + return tx.Commit() +} + +// MARK: 会话 + +// CreateSession 返回明文令牌;库里只存 SHA-256 +func (s *Store) CreateSession(userID, device string) (string, error) { + token := randomToken(32) + now := time.Now().Unix() + _, err := s.db.Exec(`INSERT INTO sessions(token_hash, user_id, device, created_at, last_used_at) VALUES(?,?,?,?,?)`, + hashToken(token), userID, device, now, now) + return token, err +} + +func (s *Store) UserForToken(token string) (User, error) { + var userID string + var lastUsed int64 + err := s.db.QueryRow(`SELECT user_id, last_used_at FROM sessions WHERE token_hash = ?`, hashToken(token)).Scan(&userID, &lastUsed) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNotFound + } + if err != nil { + return User{}, err + } + // 最近使用时间每小时至多写一次 + if now := time.Now().Unix(); now-lastUsed > 3600 { + _, _ = s.db.Exec(`UPDATE sessions SET last_used_at = ? WHERE token_hash = ?`, now, hashToken(token)) + } + return s.GetUser(userID) +} + +func (s *Store) DeleteSession(token string) error { + _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, hashToken(token)) + return err +} + +// MARK: 设置 + +func (s *Store) GetSettings(userID string) (Settings, error) { + var st Settings + var doc string + var device sql.NullString + var updated int64 + err := s.db.QueryRow(`SELECT version, document, device, updated_at FROM settings WHERE user_id = ?`, userID). + Scan(&st.Version, &doc, &device, &updated) + if errors.Is(err, sql.ErrNoRows) { + return st, ErrNotFound + } + if err != nil { + return st, err + } + st.Document = []byte(doc) + st.Device = device.String + st.UpdatedAt = time.Unix(updated, 0) + return st, nil +} + +// PutSettings 整份覆盖,版本号加一 +func (s *Store) PutSettings(userID string, document []byte, device string) (Settings, error) { + now := time.Now().Unix() + var version int64 + err := s.db.QueryRow(`INSERT INTO settings(user_id, version, document, device, updated_at) VALUES(?,1,?,?,?) + ON CONFLICT(user_id) DO UPDATE SET version = version + 1, document = excluded.document, + device = excluded.device, updated_at = excluded.updated_at + RETURNING version`, userID, string(document), device, now).Scan(&version) + if err != nil { + return Settings{}, err + } + return Settings{Version: version, Document: document, Device: device, UpdatedAt: time.Unix(now, 0)}, nil +} + +// MARK: 工具 + +func randomToken(bytes int) string { + buf := make([]byte, bytes) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(buf) +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/server/sync/sync b/server/sync/sync new file mode 100755 index 0000000..863ce25 Binary files /dev/null and b/server/sync/sync differ diff --git a/server/sync/sync.env.example b/server/sync/sync.env.example new file mode 100644 index 0000000..b1e4602 --- /dev/null +++ b/server/sync/sync.env.example @@ -0,0 +1,28 @@ +# OpenStats 同步服务配置,放在 /etc/openstats/sync.env(root:openstats-sync,权限 640)。 +# 没填的登录方式不会出现在应用里。 + +# 对外地址,回调地址由此拼出:https://getopenstats.com/api/v1/auth//callback +SYNC_BASE_URL=https://getopenstats.com +SYNC_LISTEN=127.0.0.1:8787 + +# GitHub → Settings → Developer settings → OAuth Apps → New OAuth App +# Homepage URL: https://getopenstats.com +# Authorization callback URL: https://getopenstats.com/api/v1/auth/github/callback +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Google Cloud Console → APIs & Services → Credentials → OAuth client ID(Web application) +# Authorized redirect URI: https://getopenstats.com/api/v1/auth/google/callback +# OAuth consent screen 需要填隐私政策链接:https://getopenstats.com/privacy.html +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Apple Developer → Certificates, Identifiers & Profiles +# 1. Identifiers → App IDs → com.openstats.app 打开 Sign in with Apple +# 2. Identifiers → Services IDs → 新建(例如 com.openstats.web),配置 Sign in with Apple: +# Domains: getopenstats.com Return URLs: https://getopenstats.com/api/v1/auth/apple/callback +# 3. Keys → 新建带 Sign in with Apple 的 Key,下载 .p8 放到 /etc/openstats/apple-signin.p8 +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY_FILE=/etc/openstats/apple-signin.p8 diff --git a/web/index.html b/web/index.html index 6597cfa..3099ccb 100644 --- a/web/index.html +++ b/web/index.html @@ -305,16 +305,16 @@

数据不出这台 Mac

-

没有账号,没有统计上报。只有两项可关闭的网络功能会联网。

+

没有统计上报。账号是可选的,只用来同步偏好设置;另有两项可关闭的网络功能会联网。

  • 指标来源内核接口(host_processor_info、host_statistics64、sysctl)、IOKit 与 SMC
  • -
  • 账号不需要,也没有
  • +
  • 账号可选。用 GitHub、Google 或 Apple 登录后,偏好设置同步到你的其他 Mac;云端只保存邮箱、姓名与设置文档,可随时删除。详见隐私政策
  • 统计上报没有
  • 公网 IP打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可关闭
  • -
  • 归属地国家、城市与 ASN 在本机的 MaxMind GeoLite2 数据库里查,数据库从本站下载并校验
  • +
  • 归属地数据源自己选:CleanIP.io(默认,含网络类型与纯净度评分)、ipapi.is、DB-IP、ipinfo.io,都是这台 Mac 直接向它查自己的公网 IP;也可以用本机的 MaxMind GeoLite2 数据库完全离线查
  • 连接探测按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭
  • -
  • 偏好设置保存在本机,不含个人信息
  • +
  • 偏好设置保存在本机;登录后同步的那一份不含任何监控数据
@@ -433,6 +433,10 @@ 怎么安装、卸载?

下载 DMG 后把 OpenStats 拖进“应用程序”;也可以用 Homebrew:brew install --cask gentpan/tap/openstats。卸载时先在“设置 › 辅助工具”里卸载辅助工具,再把应用移到废纸篓;用 Homebrew 安装的执行 brew uninstall --zap --cask openstats。

+
+ 菜单栏图标的顺序怎么调? +

按住 ⌘ 键拖动任意一个图标即可,松开后位置会一直保留,重启也不变。新开启的项目由 macOS 安排初始位置,可能离其他图标较远,拖到一起就好。这是系统自带的功能,对所有菜单栏图标都有效。

+
支持 Intel Mac 吗?

目前在 Apple Silicon 上开发和测试,Intel 机型的风扇与温度读取尚未验证。

diff --git a/web/privacy.html b/web/privacy.html new file mode 100644 index 0000000..e5a594b --- /dev/null +++ b/web/privacy.html @@ -0,0 +1,72 @@ + + + + + +隐私政策 — OpenStats + + + + + + + + + + +
+
+
+
+

← 返回 OpenStats

+

隐私政策

+

最后更新:2026-09-15。OpenStats 由 GiantAccel, LLC 开发,本页说明应用与 getopenstats.com 会处理哪些数据。

+
+ +
+

监控数据不离开你的 Mac

+

CPU、GPU、内存、网络、磁盘、温度、风扇、进程与电池等所有指标都在本机读取并显示,不会上传,没有统计上报,也没有广告或第三方分析 SDK。

+
+
    +
  • 公网 IP打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭
  • +
  • 归属地由你在设置里选择数据源:CleanIP.io(默认)、ipapi.is、DB-IP 或 ipinfo.io,这台 Mac 直接向所选服务查询自己的公网 IP,只发送这个地址、不带其他信息;也可以改用本机的 MaxMind GeoLite2 数据库完全离线查询
  • +
  • 连接探测按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭
  • +
  • 检查更新启动时与每天一次读取 getopenstats.com 上的版本清单,请求不带任何标识
  • +
+ +
+

可选的账号与设置同步

+

不登录也能使用全部功能。登录后,应用会把偏好设置保存到 getopenstats.com,让你在其他 Mac 上登录时自动恢复。

+
+
    +
  • 登录方式GitHub、Google 或 Apple。我们只从它们那里拿到一个用户标识、邮箱、姓名与头像地址,不会取得你在这些平台上的其他数据,也不会代你发布任何内容
  • +
  • 保存的内容邮箱、姓名、头像地址,以及一份设置文档:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。另记录每台登录设备的名称与上次同步时间
  • +
  • 不保存的内容任何监控数据、进程列表、IP 地址、硬件序列号、清理记录与历史记录
  • +
  • 存放位置getopenstats.com 的服务器,经 HTTPS 传输。登录令牌保存在你 Mac 的钥匙串里
  • +
  • 删除在「设置 · 账号与同步」里可以随时退出登录;「删除云端数据」会立即删除账号、所有登录方式与云端设置,无法恢复
  • +
  • 不会做的事不出售或共享你的数据,不用于广告,不发送营销邮件
  • +
+ +
+

联系

+

对隐私有任何疑问,请到 GitHub 仓库提交 issue。本政策有变化时会更新本页并注明日期。

+
+
+
+
+ + + + +