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)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
2026-06-15 21:38:28 +08:00
commit f21fa5b5e8
239 changed files with 29010 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
import { useAppStore, type User } from "../../store";
import { t } from "../../i18n";
import { apiFetch } from "../../net/api";
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
// ---- dev mode -------------------------------------------------------------
// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage),
// stamp the store with a synthetic User and the build-time dev token.
// This bypasses Casdoor entirely. The middleware on the backend accepts
// any X-Dev-User as long as the bearer matches CDROP_DEV_TOKEN.
export function loginDev(searchParams: URLSearchParams): User
{
const devUser = searchParams.get("dev_user")
?? window.localStorage.getItem("cdrop.dev_user")
?? "dev-user";
window.localStorage.setItem("cdrop.dev_user", devUser);
const token = import.meta.env.VITE_CDROP_DEV_TOKEN;
if (!token)
{
throw new Error(t("errors.devTokenMissing"));
}
const user: User = { id: devUser, name: devUser };
useAppStore.getState().setAuth({ accessToken: token, user });
return user;
}
export function logout()
{
// Fire-and-forget 通知后端立刻把本设备从 Hub 踢掉 + 广播 presence,让对端
// 不必等 30s 宽限期。apiFetch 同步抓取 access_token 后才让出(fetch 已发出),
// 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略
// ——只是体验降级回宽限期路径,不影响登出本身。
void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
useAppStore.getState().clearAuth();
// 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。
if (isDesktop()) { clearDesktopSession(); }
}
// ---- prod (OIDC PKCE) -----------------------------------------------------
interface AuthConfig
{
auth_mode: "dev" | "prod";
authorize_url: string;
client_id: string;
redirect_uri: string;
scopes: string;
}
let cachedConfig: AuthConfig | null = null;
export async function fetchAuthConfig(): Promise<AuthConfig>
{
if (cachedConfig) { return cachedConfig; }
const r = await fetch("/api/auth/config");
if (!r.ok) { throw new Error(`auth config ${r.status}`); }
cachedConfig = await r.json() as AuthConfig;
return cachedConfig;
}
const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier";
const OAUTH_STATE_KEY = "cdrop.oauth_state";
// loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash the
// secrets in sessionStorage, navigate the browser to the provider's
// /authorize endpoint. The callback page completes the flow.
export async function loginProd(): Promise<void>
{
const cfg = await fetchAuthConfig();
if (cfg.auth_mode !== "prod")
{
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
}
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
{
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
}
const verifier = generateRandomBase64url(32);
const challenge = await sha256Base64url(verifier);
const state = generateRandomBase64url(16);
sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier);
sessionStorage.setItem(OAUTH_STATE_KEY, state);
const params = new URLSearchParams({
client_id: cfg.client_id,
redirect_uri: cfg.redirect_uri,
response_type: "code",
scope: cfg.scopes || "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.href = `${cfg.authorize_url}?${params}`;
}
interface ExchangeResp
{
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string; name: string; avatar?: string };
}
// completeOAuthLogin runs on /oauth/callback after the provider redirects back.
// Verifies state (CSRF), trades code+verifier for tokens via the backend
// proxy, and stamps the store with the user identity.
//
// Idempotent:若 store 中已有 user + accessToken(典型场景:useEffect 在 React
// 渲染管线中重入,首次已成功 setAuth + 消耗 sessionStorage,二次入场不能再被
// "PKCE verifier missing" / "state mismatch" 当作失败处理)→ 当作 noop 直接
// resolve,由上层 navigate 接管。
export async function completeOAuthLogin(code: string, state: string): Promise<void>
{
const cur = useAppStore.getState();
if (cur.user && cur.accessToken)
{
return;
}
const expectedState = sessionStorage.getItem(OAUTH_STATE_KEY);
if (!expectedState || state !== expectedState)
{
throw new Error("OAuth state mismatch (possible CSRF or stale tab)");
}
const verifier = sessionStorage.getItem(PKCE_VERIFIER_KEY);
if (!verifier)
{
throw new Error("PKCE verifier missing — restart the login flow");
}
const r = await fetch("/api/auth/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, code_verifier: verifier }),
});
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`token exchange ${r.status}: ${text}`);
}
const data = await r.json() as ExchangeResp;
sessionStorage.removeItem(PKCE_VERIFIER_KEY);
sessionStorage.removeItem(OAUTH_STATE_KEY);
useAppStore.getState().setAuth({
accessToken: data.access_token,
refreshToken: data.refresh_token,
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
});
}
// refreshTokens proxies to /api/auth/refresh. Returns true on success.
// api.ts calls this lazily when a request comes back 401.
export async function refreshTokens(): Promise<boolean>
{
const store = useAppStore.getState();
if (store.authMode !== "prod") { return false; }
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 GoJS 不持有),故
// 无需 store.refreshToken,直接无参调用。
if (isDesktop())
{
const res = await desktopRefresh();
if (!res?.access_token) { return false; }
const cur = useAppStore.getState();
if (!cur.user) { return false; }
cur.setAuth({ accessToken: res.access_token, user: cur.user });
return true;
}
const refreshToken = store.refreshToken;
if (!refreshToken) { return false; }
const r = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!r.ok) { return false; }
const data = await r.json() as { access_token: string; refresh_token?: string };
if (!data.access_token) { return false; }
const cur = useAppStore.getState();
if (!cur.user) { return false; }
cur.setAuth({
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
user: cur.user,
});
return true;
}
// ---- PKCE helpers ---------------------------------------------------------
function generateRandomBase64url(numBytes: number): string
{
const arr = new Uint8Array(numBytes);
crypto.getRandomValues(arr);
return base64urlEncode(arr);
}
async function sha256Base64url(input: string): Promise<string>
{
const buf = new TextEncoder().encode(input);
const hash = await crypto.subtle.digest("SHA-256", buf);
return base64urlEncode(new Uint8Array(hash));
}
function base64urlEncode(bytes: Uint8Array): string
{
let s = "";
for (let i = 0; i < bytes.length; i += 1) { s += String.fromCharCode(bytes[i]); }
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}