服务器与官网:自托管的账号同步服务、GeoLite2 下载配置脚本、隐私页
- server/sync:Go 写的同步服务(GitHub / Google / Apple 登录、PKCE、设置文档存 SQLite),附 systemd 单元、Caddy 片段、install.sh 部署到官网源站、configure.sh 填写各平台密钥 - server/geoip:configure.sh 填 MaxMind 账号与许可证;install.sh 的主机改为官网源站 - 官网新增隐私说明页,首页补隐私条目与 ⌘ 拖动排序的常见问题;部署与发布脚本的默认主机改为 51.38.126.148
This commit is contained in:
1 parent
f7e88fa06c
commit
480a7b551d
21 files changed
+1830
-7
No files matched your search
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Executable
+20
@@ -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'
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 <token>`。令牌只在库里存 SHA-256。
|
||||
|
||||
登录流程和 PKCE 一样:应用生成 verifier,把 S256 challenge 交给 `start`;回调时服务器只把一次性换取码
|
||||
交回应用,应用再用 verifier 换长期令牌。这样即使别的程序抢注了 `openstats://` scheme,拿到换取码也没有用。
|
||||
|
||||
同一个已验证邮箱在不同登录方式下登录会并入同一个用户。Apple 的姓名只在首次授权时给出。
|
||||
|
||||
## 部署
|
||||
|
||||
1. 在 GitHub、Google、Apple 各建一个 OAuth 应用,回调地址写 `https://getopenstats.com/api/v1/auth/<provider>/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 .
|
||||
```
|
||||
@@ -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() }
|
||||
Executable
+68
@@ -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'
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
Executable
+75
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# openstats-sync:账号与设置同步接口,其余路径仍由静态站点提供
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8787
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
# OpenStats 同步服务配置,放在 /etc/openstats/sync.env(root:openstats-sync,权限 640)。
|
||||
# 没填的登录方式不会出现在应用里。
|
||||
|
||||
# 对外地址,回调地址由此拼出:https://getopenstats.com/api/v1/auth/<provider>/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
|
||||
+8
-4
@@ -305,16 +305,16 @@
|
||||
<div class="shell narrow">
|
||||
<div class="section-head">
|
||||
<h2>数据不出这台 Mac</h2>
|
||||
<p>没有账号,没有统计上报。只有两项可关闭的网络功能会联网。</p>
|
||||
<p>没有统计上报。账号是可选的,只用来同步偏好设置;另有两项可关闭的网络功能会联网。</p>
|
||||
</div>
|
||||
<ul class="fact-list">
|
||||
<li><b>指标来源</b><span>内核接口(host_processor_info、host_statistics64、sysctl)、IOKit 与 SMC</span></li>
|
||||
<li><b>账号</b><span>不需要,也没有</span></li>
|
||||
<li><b>账号</b><span>可选。用 GitHub、Google 或 Apple 登录后,偏好设置同步到你的其他 Mac;云端只保存邮箱、姓名与设置文档,可随时删除。详见<a href="privacy.html">隐私政策</a></span></li>
|
||||
<li><b>统计上报</b><span>没有</span></li>
|
||||
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可关闭</span></li>
|
||||
<li><b>归属地</b><span>国家、城市与 ASN 在本机的 MaxMind GeoLite2 数据库里查,数据库从本站下载并校验</span></li>
|
||||
<li><b>归属地</b><span>数据源自己选:CleanIP.io(默认,含网络类型与纯净度评分)、ipapi.is、DB-IP、ipinfo.io,都是这台 Mac 直接向它查自己的公网 IP;也可以用本机的 MaxMind GeoLite2 数据库完全离线查</span></li>
|
||||
<li><b>连接探测</b><span>按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭</span></li>
|
||||
<li><b>偏好设置</b><span>保存在本机,不含个人信息</span></li>
|
||||
<li><b>偏好设置</b><span>保存在本机;登录后同步的那一份不含任何监控数据</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -433,6 +433,10 @@
|
||||
<summary>怎么安装、卸载?</summary>
|
||||
<p>下载 DMG 后把 OpenStats 拖进“应用程序”;也可以用 Homebrew:<code>brew install --cask gentpan/tap/openstats</code>。卸载时先在“设置 › 辅助工具”里卸载辅助工具,再把应用移到废纸篓;用 Homebrew 安装的执行 <code>brew uninstall --zap --cask openstats</code>。</p>
|
||||
</details>
|
||||
<details class="faq">
|
||||
<summary>菜单栏图标的顺序怎么调?</summary>
|
||||
<p>按住 ⌘ 键拖动任意一个图标即可,松开后位置会一直保留,重启也不变。新开启的项目由 macOS 安排初始位置,可能离其他图标较远,拖到一起就好。这是系统自带的功能,对所有菜单栏图标都有效。</p>
|
||||
</details>
|
||||
<details class="faq">
|
||||
<summary>支持 Intel Mac 吗?</summary>
|
||||
<p>目前在 Apple Silicon 上开发和测试,Intel 机型的风扇与温度读取尚未验证。</p>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>隐私政策 — OpenStats</title>
|
||||
<meta name="description" content="OpenStats 的隐私政策:监控数据不离开你的 Mac;可选的账号只用来同步偏好设置。">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0b0b0d" media="(prefers-color-scheme: dark)">
|
||||
<link rel="canonical" href="https://getopenstats.com/privacy.html">
|
||||
<link rel="icon" href="assets/favicon.ico" sizes="48x48">
|
||||
<script>
|
||||
try {
|
||||
const saved = localStorage.getItem("os-theme");
|
||||
document.documentElement.dataset.theme = saved || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
||||
} catch { document.documentElement.dataset.theme = "light"; }
|
||||
</script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<main>
|
||||
<section class="band" id="privacy">
|
||||
<div class="shell narrow">
|
||||
<div class="section-head">
|
||||
<p><a href="./">← 返回 OpenStats</a></p>
|
||||
<h2>隐私政策</h2>
|
||||
<p>最后更新:2026-09-15。OpenStats 由 GiantAccel, LLC 开发,本页说明应用与 getopenstats.com 会处理哪些数据。</p>
|
||||
</div>
|
||||
|
||||
<div class="section-head">
|
||||
<h2>监控数据不离开你的 Mac</h2>
|
||||
<p>CPU、GPU、内存、网络、磁盘、温度、风扇、进程与电池等所有指标都在本机读取并显示,不会上传,没有统计上报,也没有广告或第三方分析 SDK。</p>
|
||||
</div>
|
||||
<ul class="fact-list">
|
||||
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭</span></li>
|
||||
<li><b>归属地</b><span>由你在设置里选择数据源:CleanIP.io(默认)、ipapi.is、DB-IP 或 ipinfo.io,这台 Mac 直接向所选服务查询自己的公网 IP,只发送这个地址、不带其他信息;也可以改用本机的 MaxMind GeoLite2 数据库完全离线查询</span></li>
|
||||
<li><b>连接探测</b><span>按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭</span></li>
|
||||
<li><b>检查更新</b><span>启动时与每天一次读取 getopenstats.com 上的版本清单,请求不带任何标识</span></li>
|
||||
</ul>
|
||||
|
||||
<div class="section-head" style="margin-top: var(--space-16)">
|
||||
<h2>可选的账号与设置同步</h2>
|
||||
<p>不登录也能使用全部功能。登录后,应用会把偏好设置保存到 getopenstats.com,让你在其他 Mac 上登录时自动恢复。</p>
|
||||
</div>
|
||||
<ul class="fact-list">
|
||||
<li><b>登录方式</b><span>GitHub、Google 或 Apple。我们只从它们那里拿到一个用户标识、邮箱、姓名与头像地址,不会取得你在这些平台上的其他数据,也不会代你发布任何内容</span></li>
|
||||
<li><b>保存的内容</b><span>邮箱、姓名、头像地址,以及一份设置文档:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。另记录每台登录设备的名称与上次同步时间</span></li>
|
||||
<li><b>不保存的内容</b><span>任何监控数据、进程列表、IP 地址、硬件序列号、清理记录与历史记录</span></li>
|
||||
<li><b>存放位置</b><span>getopenstats.com 的服务器,经 HTTPS 传输。登录令牌保存在你 Mac 的钥匙串里</span></li>
|
||||
<li><b>删除</b><span>在「设置 · 账号与同步」里可以随时退出登录;「删除云端数据」会立即删除账号、所有登录方式与云端设置,无法恢复</span></li>
|
||||
<li><b>不会做的事</b><span>不出售或共享你的数据,不用于广告,不发送营销邮件</span></li>
|
||||
</ul>
|
||||
|
||||
<div class="section-head" style="margin-top: var(--space-16)">
|
||||
<h2>联系</h2>
|
||||
<p>对隐私有任何疑问,请到 <a href="https://github.com/gentpan/OpenStats/issues">GitHub 仓库</a>提交 issue。本政策有变化时会更新本页并注明日期。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="shell footer__inner">
|
||||
<span class="footer__brand"><img src="assets/icon.png" alt="" width="20" height="20">OpenStats</span>
|
||||
<span class="footer__fine">© 2026 GiantAccel, LLC</span>
|
||||
<span class="footer__fine">OpenStats 是独立的第三方应用,与 Apple 没有隶属关系。</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user