Files
OwnCord/Client/tauri-client/src/lib/api.ts
T
J3vbandClaude Fable 5 89401c64a6 feat(client): call own server for GIFs, drop VITE_KLIPY_API_KEY
gifProvider.ts now goes through api.ts (TOFU-pinned via the Rust http proxy)
instead of api.klipy.com, and the VITE_KLIPY_API_KEY path is deleted outright.
The built bundle greps clean of the key name and of api.klipy.com.

The klipy.com CDN allowlist stays: media URLs still load from Klipy's CDN, and
the server is trusted to hold the key but not to dictate what the client
renders.

Degradation: on 503 GIF_DISABLED the picker shows "GIFs are not enabled on
this server" and calls onUnavailable, which disables the composer's GIF button
with a title/aria-label reason — mirroring the existing attach-button rule so
re-enabling the composer does not resurrect it. A MessageInput with no gifApi
wired renders the button disabled from the start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:29:57 +02:00

572 lines
19 KiB
TypeScript

// Step 2.13 — REST API Client
// Uses Tauri's HTTP plugin fetch to bypass self-signed cert rejection in webview.
import { fetch } from "@tauri-apps/plugin-http";
import { createLogger } from "./logger";
import { ensureHttpProxy } from "./httpProxy";
import type {
AuthResponse,
RegisterResponse,
HealthResponse,
MessagesResponse,
SearchResponse,
ApiError,
ChannelType,
ChannelResponse,
EmojiResponse,
SoundResponse,
InviteResponse,
SessionResponse,
UploadResponse,
VoiceCredentialsResponse,
MemberResponse,
DmChannelsResponse,
CreateDmResponse,
BlockedUsersResponse,
GifSearchResponse,
} from "./types";
/** Configuration for the API client. */
export interface ApiClientConfig {
readonly host: string;
readonly token?: string;
}
/** API client error with parsed error body. */
export class ApiClientError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "ApiClientError";
this.status = status;
this.code = code;
}
}
export type OnUnauthorized = () => void;
const log = createLogger("api");
/** Create the REST API client. */
export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: OnUnauthorized) {
// oxlint-disable-next-line consistent-function-scoping -- co-located with createApiClient for encapsulation
function isValidHost(host: string): boolean {
return /^[\w.-]+(:\d+)?$/.test(host) && host.length <= 253;
}
let config = { ...initialConfig };
// REST traffic is tunneled through the Rust HTTP TOFU proxy: instead of
// hitting https://{host} directly (which used to require accepting invalid
// certs), we hit http://127.0.0.1:{port} where the proxy pins the server
// certificate to the same trust-on-first-use fingerprint as the WS proxy.
async function baseUrl(): Promise<string> {
return `${await ensureHttpProxy(config.host)}/api/v1`;
}
async function adminBaseUrl(): Promise<string> {
return `${await ensureHttpProxy(config.host)}/admin/api`;
}
function headers(): Record<string, string> {
const h: Record<string, string> = {
"Content-Type": "application/json",
};
if (config.token) {
h["Authorization"] = `Bearer ${config.token}`;
}
return h;
}
async function doFetch<T>(
label: string,
urlBase: string,
method: string,
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
const url = `${urlBase}${path}`;
const init: RequestInit = {
method,
headers: headers(),
signal,
};
if (body !== undefined) {
init.body = JSON.stringify(body);
}
log.debug(`${label} →`, { method, path });
let res: Response;
try {
res = await fetch(url, init);
} catch (fetchErr) {
log.error(`${label} fetch failed`, { method, path, error: String(fetchErr) });
if (fetchErr instanceof Error) {
throw fetchErr;
}
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr), {
cause: fetchErr,
});
}
log.debug(`${label} ←`, { method, path, status: res.status });
if (res.status === 401) {
onUnauthorized?.();
const err = await parseError(res);
throw new ApiClientError(401, err.error, err.message);
}
if (!res.ok) {
const err = await parseError(res);
log.warn(`${label} error`, {
method,
path,
status: res.status,
code: err.error,
message: err.message,
});
throw new ApiClientError(res.status, err.error, err.message);
}
// 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
async function request<T>(
method: string,
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
return doFetch<T>("API", await baseUrl(), method, path, body, signal);
}
async function adminRequest<T>(
method: string,
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
return doFetch<T>("Admin API", await adminBaseUrl(), method, path, body, signal);
}
// oxlint-disable-next-line consistent-function-scoping -- co-located with doFetch for encapsulation
async function parseError(res: Response): Promise<ApiError> {
try {
const body = await res.json();
return {
error: body.error ?? "UNKNOWN",
message: body.message ?? res.statusText,
};
} catch {
return {
error: "UNKNOWN",
message: res.statusText,
};
}
}
return {
/** Update the client config (e.g., after login). */
setConfig(newConfig: Partial<ApiClientConfig>): void {
if (newConfig.host !== undefined && !isValidHost(newConfig.host)) {
log.error("setConfig rejected invalid host", { host: newConfig.host });
throw new Error("Invalid host format");
}
config = { ...config, ...newConfig };
},
/** Get current config (for debugging). Token is redacted. */
getConfig(): Readonly<ApiClientConfig> {
return { ...config, token: config.token ? "[redacted]" : undefined };
},
// ── Auth ──────────────────────────────────────────────
login(username: string, password: string, signal?: AbortSignal): Promise<AuthResponse> {
return request<AuthResponse>("POST", "/auth/login", { username, password }, signal);
},
register(
username: string,
password: string,
inviteCode: string,
signal?: AbortSignal,
): Promise<RegisterResponse> {
return request<RegisterResponse>(
"POST",
"/auth/register",
{ username, password, invite_code: inviteCode },
signal,
);
},
logout(signal?: AbortSignal): Promise<void> {
return request<void>("POST", "/auth/logout", undefined, signal);
},
async verifyTotp(
code: string,
partialToken: string,
signal?: AbortSignal,
): Promise<AuthResponse> {
// Don't mutate shared config — make direct fetch with the partial token
const url = `${await baseUrl()}/auth/verify-totp`;
const init: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${partialToken}`,
},
body: JSON.stringify({ code }),
signal,
};
let res: Response;
try {
res = await fetch(url, init);
} catch (fetchErr) {
log.error("API fetch failed", {
method: "POST",
path: "/auth/verify-totp",
error: String(fetchErr),
});
if (fetchErr instanceof Error) {
throw fetchErr;
}
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr), {
cause: fetchErr,
});
}
if (res.status === 401) {
onUnauthorized?.();
const err = await parseError(res);
throw new ApiClientError(401, err.error, err.message);
}
if (!res.ok) {
const err = await parseError(res);
throw new ApiClientError(res.status, err.error, err.message);
}
return res.json() as Promise<AuthResponse>;
},
deleteAccount(password: string, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", "/auth/account", { password }, signal);
},
// ── Users ─────────────────────────────────────────────
getMe(signal?: AbortSignal): Promise<MemberResponse> {
return request<MemberResponse>("GET", "/users/me", undefined, signal);
},
updateProfile(
data: { username?: string; avatar?: string },
signal?: AbortSignal,
): Promise<MemberResponse> {
return request<MemberResponse>("PATCH", "/users/me", data, signal);
},
changePassword(
currentPassword: string,
newPassword: string,
signal?: AbortSignal,
): Promise<void> {
return request<void>(
"PUT",
"/users/me/password",
{ current_password: currentPassword, new_password: newPassword },
signal,
);
},
enableTotp(
password: string,
signal?: AbortSignal,
): Promise<{ qr_uri: string; backup_codes: string[] }> {
return request("POST", "/users/me/totp/enable", { password }, signal);
},
confirmTotp(password: string, code: string, signal?: AbortSignal): Promise<void> {
return request<void>("POST", "/users/me/totp/confirm", { password, code }, signal);
},
disableTotp(password: string, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", "/users/me/totp", { password }, signal);
},
getSessions(signal?: AbortSignal): Promise<SessionResponse[]> {
return request<SessionResponse[]>("GET", "/users/me/sessions", undefined, signal);
},
revokeSession(sessionId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/users/me/sessions/${sessionId}`, undefined, signal);
},
// ── Channels ──────────────────────────────────────────
getMessages(
channelId: number,
options?: { before?: number; limit?: number },
signal?: AbortSignal,
): Promise<MessagesResponse> {
const params = new URLSearchParams();
if (options?.before !== undefined) params.set("before", String(options.before));
if (options?.limit !== undefined) params.set("limit", String(options.limit));
const qs = params.toString();
return request<MessagesResponse>(
"GET",
`/channels/${channelId}/messages${qs ? `?${qs}` : ""}`,
undefined,
signal,
);
},
getPins(channelId: number, signal?: AbortSignal): Promise<MessagesResponse> {
return request<MessagesResponse>("GET", `/channels/${channelId}/pins`, undefined, signal);
},
pinMessage(channelId: number, messageId: number, signal?: AbortSignal): Promise<void> {
return request<void>("POST", `/channels/${channelId}/pins/${messageId}`, undefined, signal);
},
unpinMessage(channelId: number, messageId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/channels/${channelId}/pins/${messageId}`, undefined, signal);
},
// ── Search ────────────────────────────────────────────
search(
query: string,
options?: { channelId?: number; limit?: number },
signal?: AbortSignal,
): Promise<SearchResponse> {
const params = new URLSearchParams({ q: query });
if (options?.channelId !== undefined) params.set("channel_id", String(options.channelId));
if (options?.limit !== undefined) params.set("limit", String(options.limit));
return request<SearchResponse>("GET", `/search?${params.toString()}`, undefined, signal);
},
// ── GIFs ──────────────────────────────────────────────
//
// Proxied by the user's own server so the GIF provider API key never
// ships in this bundle. A 503 GIF_DISABLED means the operator has not
// configured a key — callers must degrade, not retry.
gifSearch(query: string, limit: number, signal?: AbortSignal): Promise<GifSearchResponse> {
const params = new URLSearchParams({ q: query, limit: String(limit) });
return request<GifSearchResponse>(
"GET",
`/gif/search?${params.toString()}`,
undefined,
signal,
);
},
gifTrending(limit: number, signal?: AbortSignal): Promise<GifSearchResponse> {
const params = new URLSearchParams({ limit: String(limit) });
return request<GifSearchResponse>(
"GET",
`/gif/trending?${params.toString()}`,
undefined,
signal,
);
},
// ── File Uploads ──────────────────────────────────────
async uploadFile(file: File, signal?: AbortSignal): Promise<UploadResponse> {
const formData = new FormData();
formData.append("file", file);
const url = `${await baseUrl()}/uploads`;
const h: Record<string, string> = {};
if (config.token) {
h["Authorization"] = `Bearer ${config.token}`;
}
// Don't set Content-Type — browser sets multipart boundary
const res = await fetch(url, {
method: "POST",
headers: h,
body: formData,
signal,
});
if (res.status === 401) {
onUnauthorized?.();
const err = await parseError(res);
throw new ApiClientError(401, err.error, err.message);
}
if (!res.ok) {
const err = await parseError(res);
throw new ApiClientError(res.status, err.error, err.message);
}
return res.json() as Promise<UploadResponse>;
},
// ── Invites ───────────────────────────────────────────
getInvites(signal?: AbortSignal): Promise<InviteResponse[]> {
return request<InviteResponse[]>("GET", "/invites", undefined, signal);
},
createInvite(
data: { max_uses?: number; expires_in_hours?: number },
signal?: AbortSignal,
): Promise<InviteResponse> {
return request<InviteResponse>("POST", "/invites", data, signal);
},
revokeInvite(code: string, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/invites/${code}`, undefined, signal);
},
// ── Emoji ─────────────────────────────────────────────
getEmoji(signal?: AbortSignal): Promise<EmojiResponse[]> {
return request<EmojiResponse[]>("GET", "/emoji", undefined, signal);
},
deleteEmoji(emojiId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/emoji/${emojiId}`, undefined, signal);
},
// ── Sounds ────────────────────────────────────────────
getSounds(signal?: AbortSignal): Promise<SoundResponse[]> {
return request<SoundResponse[]>("GET", "/sounds", undefined, signal);
},
deleteSound(soundId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/sounds/${soundId}`, undefined, signal);
},
// ── Direct Messages ─────────────────────────────────────
/** List user's open DM channels. */
getDmChannels(signal?: AbortSignal): Promise<DmChannelsResponse> {
return request<DmChannelsResponse>("GET", "/dms", undefined, signal);
},
/** Create or get a DM channel with a user. */
createDm(recipientId: number, signal?: AbortSignal): Promise<CreateDmResponse> {
return request<CreateDmResponse>("POST", "/dms", { recipient_id: recipientId }, signal);
},
/** Close a DM (hide from sidebar). */
closeDm(channelId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/dms/${channelId}`, undefined, signal);
},
/** List recipient user IDs the current user has blocked. */
listBlocks(signal?: AbortSignal): Promise<BlockedUsersResponse> {
return request<BlockedUsersResponse>("GET", "/blocks", undefined, signal);
},
// ── Voice ─────────────────────────────────────────────
getVoiceCredentials(signal?: AbortSignal): Promise<VoiceCredentialsResponse> {
return request<VoiceCredentialsResponse>("GET", "/voice/credentials", undefined, signal);
},
// ── Health ────────────────────────────────────────────
async getHealth(host?: string, timeoutMs = 3000): Promise<HealthResponse> {
const targetHost = host ?? config.host;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const origin = await ensureHttpProxy(targetHost);
const res = await fetch(`${origin}/api/v1/health`, {
signal: controller.signal,
});
if (!res.ok) {
throw new ApiClientError(res.status, "HEALTH_CHECK_FAILED", "Health check failed");
}
return res.json() as Promise<HealthResponse>;
} finally {
clearTimeout(timer);
}
},
// ── Admin: Channels ──────────────────────────────────────
adminCreateChannel(
data: {
name: string;
type: ChannelType;
category: string;
topic?: string;
position?: number;
},
signal?: AbortSignal,
): Promise<ChannelResponse> {
return adminRequest<ChannelResponse>("POST", "/channels", data, signal);
},
adminUpdateChannel(
id: number,
data: {
name?: string;
topic?: string;
slow_mode?: number;
position?: number;
archived?: boolean;
},
signal?: AbortSignal,
): Promise<ChannelResponse> {
return adminRequest<ChannelResponse>("PATCH", `/channels/${id}`, data, signal);
},
adminDeleteChannel(id: number, signal?: AbortSignal): Promise<void> {
return adminRequest<void>("DELETE", `/channels/${id}`, undefined, signal);
},
// ── Admin: Members ──────────────────────────────────────
adminKickMember(userId: number, signal?: AbortSignal): Promise<void> {
return adminRequest<void>("DELETE", `/users/${userId}/sessions`, undefined, signal);
},
adminBanMember(userId: number, reason?: string, signal?: AbortSignal): Promise<void> {
return adminRequest<void>(
"PATCH",
`/users/${userId}`,
{
banned: true,
ban_reason: reason ?? "",
},
signal,
);
},
adminChangeRole(userId: number, roleId: number, signal?: AbortSignal): Promise<void> {
return adminRequest<void>(
"PATCH",
`/users/${userId}`,
{
role_id: roleId,
},
signal,
);
},
};
}
export type ApiClient = ReturnType<typeof createApiClient>;