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:
@@ -0,0 +1,95 @@
|
||||
import { useAppStore } from "../store";
|
||||
import { refreshTokens } from "../features/auth/auth";
|
||||
import { readInjectedApiBase, readInjectedDeviceType } from "../store/helpers";
|
||||
import { toAsciiDeviceName } from "../utils/format";
|
||||
import { t } from "../i18n";
|
||||
|
||||
// /api 请求的来源前缀:桌面 Windows 注入 loopback 代理地址(绕开会缓冲 SSE 的
|
||||
// custom scheme),macOS / 浏览器为空(同源相对 URL)。读一次注入值即定。
|
||||
const API_BASE = readInjectedApiBase();
|
||||
|
||||
// 客户端类型(browser / macos / windows / linux),随每个请求发给后端登记设备。
|
||||
const DEVICE_TYPE = readInjectedDeviceType();
|
||||
|
||||
function withBase(input: RequestInfo): RequestInfo
|
||||
{
|
||||
if (typeof input === "string" && API_BASE && input.startsWith("/"))
|
||||
{
|
||||
return API_BASE + input;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
// Centralised fetch wrapper:
|
||||
// - in dev, attaches Authorization: Bearer <VITE_CDROP_DEV_TOKEN> + X-Dev-User
|
||||
// - in prod, attaches Authorization: Bearer <accessToken from store>
|
||||
// - always sets X-Device-Name from the store
|
||||
// - in prod, on a 401 response transparently refreshes the access token via
|
||||
// /api/auth/refresh and retries once
|
||||
//
|
||||
// Returns the fetch Response untouched so callers can drive streaming bodies
|
||||
// (SSE, relay GET stream) themselves.
|
||||
export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
|
||||
{
|
||||
let r = await doFetch(input, init);
|
||||
if (r.status === 401 && useAppStore.getState().authMode === "prod")
|
||||
{
|
||||
const ok = await refreshTokens();
|
||||
if (ok)
|
||||
{
|
||||
r = await doFetch(input, init);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
async function doFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
|
||||
{
|
||||
const state = useAppStore.getState();
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
|
||||
if (state.authMode === "dev")
|
||||
{
|
||||
const devToken = import.meta.env.VITE_CDROP_DEV_TOKEN;
|
||||
if (!devToken)
|
||||
{
|
||||
throw new Error(t("errors.devTokenMissing"));
|
||||
}
|
||||
headers.set("Authorization", `Bearer ${devToken}`);
|
||||
if (state.user?.id)
|
||||
{
|
||||
headers.set("X-Dev-User", state.user.id);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!state.accessToken)
|
||||
{
|
||||
throw new Error(t("errors.noAccessToken"));
|
||||
}
|
||||
headers.set("Authorization", `Bearer ${state.accessToken}`);
|
||||
}
|
||||
|
||||
if (state.selfDeviceName)
|
||||
{
|
||||
// Device names are ASCII-only; coerce here so a legacy non-ASCII name
|
||||
// can't make Headers.set throw (the server sanitises identically).
|
||||
const safeName = toAsciiDeviceName(state.selfDeviceName);
|
||||
if (safeName) { headers.set("X-Device-Name", safeName); }
|
||||
}
|
||||
headers.set("X-Device-Type", DEVICE_TYPE);
|
||||
|
||||
return fetch(withBase(input), { ...init, headers });
|
||||
}
|
||||
|
||||
export async function apiJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T>
|
||||
{
|
||||
const r = await apiFetch(input, init);
|
||||
if (!r.ok)
|
||||
{
|
||||
const text = await r.text().catch(() => r.statusText);
|
||||
throw new Error(`${r.status}: ${text}`);
|
||||
}
|
||||
if (r.status === 204) { return undefined as T; }
|
||||
return ( await r.json() ) as T;
|
||||
}
|
||||
Reference in New Issue
Block a user