Files
admin f21fa5b5e8 cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。

- 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。
- 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。
- 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。
- 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。

架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

105 lines
3.2 KiB
Go

package platform
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
)
// UserInfo is the display identity the WebView store needs (mirrors the web
// app's User shape: id / name / avatar).
type UserInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
}
// LoginResult is the full result Go keeps internally: tokens + identity. The
// refresh_token never crosses into the WebView — see SessionView and the
// credential policy in session.go.
type LoginResult struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
User UserInfo `json:"user"`
}
// SessionView is the refresh-token-free projection handed to the WebView (login
// emit, refresh return, boot injection). The refresh_token — the long-lived
// secret — is deliberately omitted: it lives only in the Go process and the
// on-disk session, never in JS or the injected HTML.
type SessionView struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
User UserInfo `json:"user"`
}
// View projects a LoginResult to the refresh-free SessionView.
func (r LoginResult) View() SessionView {
return SessionView{AccessToken: r.AccessToken, ExpiresIn: r.ExpiresIn, User: r.User}
}
// UserFromToken extracts the display identity from OIDC tokens WITHOUT verifying
// the signature. This mirrors the backend's extractUser (internal/httpapi/auth.go)
// exactly: prefer the id_token's richer claims, fall back to the access_token;
// name priority preferred_username → name → email → sub; avatar avatar → picture.
//
// Skipping verification is safe for the same reason it is on the backend: the
// auth middleware verifies the access_token on every API call via JWKS, so any
// tamper surfaces there. These claims only drive local display.
func UserFromToken(idToken, accessToken string) (UserInfo, error) {
pick := idToken
if pick == "" {
pick = accessToken
}
claims, err := decodeJWTClaims(pick)
if err != nil {
return UserInfo{}, err
}
u := UserInfo{ID: claimString(claims, "sub")}
for _, k := range []string{"preferred_username", "name", "email"} {
if v := claimString(claims, k); v != "" {
u.Name = v
break
}
}
if u.Name == "" {
u.Name = u.ID
}
for _, k := range []string{"avatar", "picture"} {
if v := claimString(claims, k); v != "" {
u.Avatar = v
break
}
}
return u, nil
}
// decodeJWTClaims base64url-decodes the JWT payload segment and unmarshals it.
// No signature check (see UserFromToken).
func decodeJWTClaims(token string) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil, errors.New("oauth: token is not a JWT")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("oauth: decode jwt payload: %w", err)
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("oauth: parse jwt claims: %w", err)
}
return claims, nil
}
func claimString(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}