服务器与官网:自托管的账号同步服务、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:
gentpan committed 2026-09-15 03:37:24 +05:00
1 parent f7e88fa06c
commit 480a7b551d
21 files changed
+1830 -7

No files matched your search

+160
View File
@@ -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() }