Files
gentpan 480a7b551d 服务器与官网:自托管的账号同步服务、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
2026-09-15 03:37:24 +05:00

200 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}