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>
This commit is contained in:
J3vb
2026-07-20 13:29:57 +02:00
co-authored by Claude Fable 5
parent 01f7795557
commit 89401c64a6
9 changed files with 463 additions and 261 deletions
@@ -1,19 +1,32 @@
// GifPicker — searchable GIF selector powered by Klipy API.
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
// GifPicker — searchable GIF selector, served by the user's own OwnCord server
// (which proxies Klipy). Uses @lib/dom helpers exclusively. Never sets
// innerHTML with user content.
import { createElement, setText, clearChildren } from "@lib/dom";
import { ApiClientError } from "@lib/api";
import { searchGifs, getTrendingGifs } from "@lib/gifProvider";
import type { GifResult } from "@lib/gifProvider";
import type { GifApi, GifResult } from "@lib/gifProvider";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface GifPickerOptions {
/** GIF endpoints on the user's own server. */
readonly api: GifApi;
readonly onSelect: (gifUrl: string) => void;
readonly onClose: () => void;
/**
* Called when the server reports GIFs are not configured (503 GIF_DISABLED).
* The caller uses this to disable its GIF affordance so the user is not
* offered a feature this server does not have.
*/
readonly onUnavailable?: (reason: string) => void;
}
/** Shown in-picker and passed to onUnavailable when the server has no key. */
export const GIF_UNAVAILABLE_MESSAGE = "GIFs are not enabled on this server";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -114,18 +127,28 @@ export function createGifPicker(options: GifPickerOptions): {
try {
const gifs =
query.length > 0 ? await searchGifs(query, GIF_LIMIT) : await getTrendingGifs(GIF_LIMIT);
query.length > 0
? await searchGifs(options.api, query, GIF_LIMIT)
: await getTrendingGifs(options.api, GIF_LIMIT);
// Only render if this is still the latest request
if (requestId === currentRequestId) {
renderGifs(gifs);
}
} catch (err) {
// The server has no GIF key configured — degrade calmly and tell the
// caller so it can disable its GIF button, rather than looking broken.
const disabled = err instanceof ApiClientError && err.code === "GIF_DISABLED";
if (disabled) {
root.classList.add("gp-unavailable");
searchInput.disabled = true;
options.onUnavailable?.(GIF_UNAVAILABLE_MESSAGE);
}
if (requestId === currentRequestId) {
clearChildren(gridArea);
const errEl = createElement("div", { class: "gp-empty" });
const msg = err instanceof Error ? err.message : "Failed to load GIFs";
setText(errEl, msg);
const fallback = err instanceof Error ? err.message : "Failed to load GIFs";
setText(errEl, disabled ? GIF_UNAVAILABLE_MESSAGE : fallback);
gridArea.appendChild(errEl);
}
}
@@ -8,10 +8,17 @@ import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import { createEmojiPicker } from "@components/EmojiPicker";
import { createGifPicker } from "@components/GifPicker";
import type { GifApi } from "@lib/gifProvider";
export interface MessageInputOptions {
readonly channelId: number;
readonly channelName: string;
/**
* GIF endpoints on the user's own server. Omit to hide the GIF affordance
* entirely — the button is rendered disabled rather than offering a picker
* that cannot load.
*/
readonly gifApi?: GifApi;
readonly onSend: (
content: string,
replyTo: number | null,
@@ -52,6 +59,13 @@ const ALLOWED_TYPES = [
"application/json",
];
/** Disable the GIF button and say why, instead of silently doing nothing. */
function markGifUnavailable(gifBtn: HTMLButtonElement, reason: string): void {
gifBtn.setAttribute("disabled", "true");
gifBtn.title = reason;
gifBtn.setAttribute("aria-label", `GIF — ${reason}`);
}
export function createMessageInput(options: MessageInputOptions): MessageInputComponent {
const ac = new AbortController();
const signal = ac.signal;
@@ -68,6 +82,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
let replyText: HTMLSpanElement | null = null;
let editBar: HTMLDivElement | null = null;
let disabledReason: string | null = options.disabledReason ?? null;
/** True once the server has told us GIFs are off, or if no GIF api was wired. */
let gifUnavailable = options.gifApi === undefined;
const controlButtons: HTMLButtonElement[] = [];
let attachmentPreviewBar: HTMLDivElement | null = null;
@@ -150,6 +166,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
} else {
// Don't re-enable the attach button when uploads aren't wired.
if (btn.classList.contains("attach-btn") && options.onUploadFile === undefined) continue;
// Likewise for GIFs when this server has no GIF provider configured.
if (btn.classList.contains("gif-btn") && gifUnavailable) continue;
btn.removeAttribute("disabled");
}
}
@@ -428,6 +446,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
{ class: "input-btn gif-btn", "aria-label": "GIF" },
"GIF",
);
if (gifUnavailable) {
markGifUnavailable(gifBtn, "GIFs are not enabled on this server");
}
const sendBtn = createElement("button", {
class: "input-btn send-btn",
"aria-label": "Send message",
@@ -573,6 +594,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
}
function toggleGifPicker(): void {
const gifApi = options.gifApi;
if (gifApi === undefined) return;
// Close emoji picker if open
if (emojiPicker !== null) {
closeEmojiPicker();
@@ -582,6 +605,11 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
return;
}
gifPicker = createGifPicker({
api: gifApi,
onUnavailable: (reason: string) => {
gifUnavailable = true;
markGifUnavailable(gifBtn, reason);
},
onSelect: (gifUrl: string) => {
if (textarea !== null) {
textarea.value = gifUrl;
+27
View File
@@ -23,6 +23,7 @@ import type {
DmChannelsResponse,
CreateDmResponse,
BlockedUsersResponse,
GifSearchResponse,
} from "./types";
/** Configuration for the API client. */
@@ -358,6 +359,32 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
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> {
+21 -57
View File
@@ -1,14 +1,20 @@
// Klipy GIF API client — provides GIF search and trending.
// Drop-in replacement for Tenor (EOL June 30, 2026).
// Register at partner.klipy.com to get a production API key.
// Override via VITE_KLIPY_API_KEY at build time.
const KLIPY_API_KEY = import.meta.env.VITE_KLIPY_API_KEY ?? "";
const GIF_API_BASE = "https://api.klipy.com/v2";
// GIF search and trending, proxied by the user's own OwnCord server.
//
// The client NEVER talks to api.klipy.com and never holds the provider API
// key: a VITE_ build variable is inlined into the shipped bundle by design, so
// it can never hold a secret. The key lives in the server's `gif.api_key`
// config and the server makes the upstream call — see Server/api/gif_handler.go.
//
// The returned media URLs still point at Klipy's CDN, so they are validated
// against the CDN allowlist below before anything is rendered.
import type { ApiClient } from "./api";
import type { GifSearchResponse } from "./types";
const DEFAULT_LIMIT = 20;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** The slice of the API client the GIF picker needs. */
export type GifApi = Pick<ApiClient, "gifSearch" | "gifTrending">;
export interface GifResult {
readonly id: string;
@@ -19,23 +25,6 @@ export interface GifResult {
readonly fullUrl: string;
}
interface GifMediaFormat {
readonly url: string;
}
interface GifApiResult {
readonly id: string;
readonly title: string;
readonly media_formats: {
readonly tinygif?: GifMediaFormat;
readonly gif?: GifMediaFormat;
};
}
interface GifSearchResponse {
readonly results: readonly GifApiResult[];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -72,44 +61,19 @@ function parseResults(data: GifSearchResponse): readonly GifResult[] {
// Public API
// ---------------------------------------------------------------------------
/**
* Search Klipy for GIFs matching the given query.
*/
/** Search the server's GIF proxy for GIFs matching the given query. */
export async function searchGifs(
api: GifApi,
query: string,
limit: number = DEFAULT_LIMIT,
): Promise<readonly GifResult[]> {
const params = new URLSearchParams({
q: query,
key: KLIPY_API_KEY,
limit: String(limit),
media_filter: "gif,tinygif",
});
const res = await fetch(`${GIF_API_BASE}/search?${params.toString()}`);
if (!res.ok) {
throw new Error(`GIF search failed: ${res.status} ${res.statusText}`);
}
const data: GifSearchResponse = await res.json();
return parseResults(data);
return parseResults(await api.gifSearch(query, limit));
}
/**
* Fetch currently trending GIFs from Klipy.
*/
/** Fetch currently trending GIFs via the server's GIF proxy. */
export async function getTrendingGifs(
api: GifApi,
limit: number = DEFAULT_LIMIT,
): Promise<readonly GifResult[]> {
const params = new URLSearchParams({
key: KLIPY_API_KEY,
limit: String(limit),
media_filter: "gif,tinygif",
});
const res = await fetch(`${GIF_API_BASE}/featured?${params.toString()}`);
if (!res.ok) {
throw new Error(`GIF trending failed: ${res.status} ${res.statusText}`);
}
const data: GifSearchResponse = await res.json();
return parseResults(data);
return parseResults(await api.gifTrending(limit));
}
+23
View File
@@ -42,6 +42,8 @@ export type ApiErrorCode =
| "CONFLICT"
| "TOO_LARGE"
| "SERVER_ERROR"
/** GIF proxy is not configured on this server (no gif.api_key). */
| "GIF_DISABLED"
| "UNKNOWN";
// -----------------------------------------------------------------------------
@@ -638,6 +640,27 @@ export interface UploadResponse {
readonly url: string;
}
/**
* GET /api/v1/gif/{search,trending} response.
*
* The GIF provider key lives on the server; the client only ever talks to its
* own server here. The media URLs still point at Klipy's CDN and are validated
* against the CDN allowlist in gifProvider before being rendered.
*/
export interface GifApiResult {
readonly id: string;
readonly title: string;
readonly media_formats: {
readonly tinygif?: { readonly url: string };
readonly gif?: { readonly url: string };
};
}
/** Envelope for both GIF endpoints. */
export interface GifSearchResponse {
readonly results: readonly GifApiResult[];
}
/** GET /api/v1/dms response. */
export interface DmChannelsResponse {
readonly dm_channels: readonly DmChannelPayload[];
@@ -272,6 +272,7 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
messageInput = createMessageInput({
channelId,
channelName,
gifApi: api,
onSend: (content: string, replyTo: number | null, attachments: readonly string[]) => {
performSend(content, replyTo, attachments);
},
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createGifPicker } from "@components/GifPicker";
import { createGifPicker, GIF_UNAVAILABLE_MESSAGE } from "@components/GifPicker";
import { ApiClientError } from "@lib/api";
import type { GifPickerOptions } from "@components/GifPicker";
import type { GifResult } from "@lib/gifProvider";
import type { GifApi, GifResult } from "@lib/gifProvider";
// ---------------------------------------------------------------------------
// Module mock — must be hoisted before imports in vitest
@@ -36,10 +37,17 @@ const SEARCH_GIFS: readonly GifResult[] = [makeGif("s1"), makeGif("s2")];
// Helpers
// ---------------------------------------------------------------------------
const stubApi: GifApi = {
gifSearch: vi.fn(),
gifTrending: vi.fn(),
};
function makePicker(overrides?: Partial<GifPickerOptions>) {
const options: GifPickerOptions = {
api: overrides?.api ?? stubApi,
onSelect: overrides?.onSelect ?? vi.fn(),
onClose: overrides?.onClose ?? vi.fn(),
onUnavailable: overrides?.onUnavailable,
};
const picker = createGifPicker(options);
return { picker, options };
@@ -131,7 +139,7 @@ describe("GifPicker", () => {
vi.advanceTimersByTime(300);
await Promise.resolve(); // flush microtasks
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith("cats", 20);
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith(stubApi, "cats", 20);
picker.destroy();
});
@@ -152,7 +160,7 @@ describe("GifPicker", () => {
// Only one call — the second one after the full debounce window
expect(vi.mocked(searchGifs)).toHaveBeenCalledTimes(1);
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith("cats", 20);
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith(stubApi, "cats", 20);
picker.destroy();
});
@@ -167,7 +175,7 @@ describe("GifPicker", () => {
vi.advanceTimersByTime(300);
await Promise.resolve();
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith("dogs", 20);
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith(stubApi, "dogs", 20);
picker.destroy();
});
@@ -201,7 +209,7 @@ describe("GifPicker", () => {
it("calls getTrendingGifs on creation", () => {
const { picker } = makePicker();
// getTrendingGifs is called synchronously (no timer needed) at init
expect(vi.mocked(getTrendingGifs)).toHaveBeenCalledWith(20);
expect(vi.mocked(getTrendingGifs)).toHaveBeenCalledWith(stubApi, 20);
picker.destroy();
});
@@ -523,6 +531,81 @@ describe("GifPicker", () => {
});
});
// ── Server has no GIF key configured (503 GIF_DISABLED) ───────────────────
describe("graceful degradation when the server has no GIF key", () => {
const disabledError = new ApiClientError(
503,
"GIF_DISABLED",
"GIF search is not configured on this server",
);
it("shows a calm reason instead of a raw error", async () => {
vi.mocked(getTrendingGifs).mockRejectedValue(disabledError);
const { picker } = makePicker();
container.appendChild(picker.element);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
const el = picker.element.querySelector(".gp-empty");
expect(el).not.toBeNull();
expect(el!.textContent).toBe(GIF_UNAVAILABLE_MESSAGE);
picker.destroy();
});
it("marks the picker unavailable and disables the search input", async () => {
vi.mocked(getTrendingGifs).mockRejectedValue(disabledError);
const { picker } = makePicker();
container.appendChild(picker.element);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(picker.element.classList.contains("gp-unavailable")).toBe(true);
const input = picker.element.querySelector(".gp-search") as HTMLInputElement;
expect(input.disabled).toBe(true);
picker.destroy();
});
it("notifies the caller via onUnavailable so it can hide its GIF button", async () => {
vi.mocked(getTrendingGifs).mockRejectedValue(disabledError);
const onUnavailable = vi.fn();
const { picker } = makePicker({ onUnavailable });
container.appendChild(picker.element);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(onUnavailable).toHaveBeenCalledWith(GIF_UNAVAILABLE_MESSAGE);
picker.destroy();
});
it("does not treat other API errors as unavailable", async () => {
vi.mocked(getTrendingGifs).mockRejectedValue(
new ApiClientError(502, "BAD_GATEWAY", "GIF provider is unavailable"),
);
const onUnavailable = vi.fn();
const { picker } = makePicker({ onUnavailable });
container.appendChild(picker.element);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(onUnavailable).not.toHaveBeenCalled();
expect(picker.element.classList.contains("gp-unavailable")).toBe(false);
picker.destroy();
});
});
// ── Stale request cancellation ────────────────────────────────────────────
describe("stale request cancellation", () => {
@@ -1,32 +1,36 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { searchGifs, getTrendingGifs } from "../../src/lib/gifProvider";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { searchGifs, getTrendingGifs, type GifApi } from "../../src/lib/gifProvider";
import type { GifSearchResponse } from "../../src/lib/types";
// ---------------------------------------------------------------------------
// Fetch mock — global fetch used by gifProvider.ts (no plugin wrapper)
// The GIF provider must go through the user's OWN server (api.ts, which is
// TOFU-pinned via the Rust http proxy) — never api.klipy.com, and never with
// an API key in this bundle.
// ---------------------------------------------------------------------------
const mockFetch = vi.fn();
const gifSearch = vi.fn<GifApi["gifSearch"]>();
const gifTrending = vi.fn<GifApi["gifTrending"]>();
const api: GifApi = { gifSearch, gifTrending };
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
gifSearch.mockReset();
gifTrending.mockReset();
// A real fetch must never happen from this module.
vi.stubGlobal(
"fetch",
vi.fn(() => {
throw new Error("gifProvider must not call fetch directly");
}),
);
});
// ---------------------------------------------------------------------------
// Helpers
// Fixtures
// ---------------------------------------------------------------------------
function gifResult(
id: string,
overrides: {
tinygif?: string | null;
gif?: string | null;
title?: string;
} = {},
overrides: { tinygif?: string | null; gif?: string | null; title?: string } = {},
) {
const {
tinygif = `https://media.klipy.com/${id}_tiny.gif`,
@@ -41,32 +45,8 @@ function gifResult(
return { id, title, media_formats };
}
function okResponse(results: unknown[], status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: "OK",
json: () => Promise.resolve({ results }),
} as unknown as Response;
}
function errorResponse(status: number, statusText: string): Response {
return {
ok: false,
status,
statusText,
json: () => Promise.resolve({}),
} as unknown as Response;
}
// Extracts a URLSearchParams object from the URL string passed to fetch.
function capturedParams(): URLSearchParams {
const url = mockFetch.mock.calls[0]?.[0] as string;
return new URLSearchParams(url.split("?")[1] ?? "");
}
function capturedUrl(): string {
return mockFetch.mock.calls[0]?.[0] as string;
function response(results: unknown[]): GifSearchResponse {
return { results } as GifSearchResponse;
}
// ---------------------------------------------------------------------------
@@ -74,61 +54,53 @@ function capturedUrl(): string {
// ---------------------------------------------------------------------------
describe("searchGifs", () => {
describe("URL construction", () => {
it("calls the Klipy search endpoint", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await searchGifs("cats");
expect(capturedUrl()).toMatch(/^https:\/\/api\.klipy\.com\/v2\/search/);
describe("transport", () => {
it("calls the server's GIF search endpoint via the api client", async () => {
gifSearch.mockResolvedValue(response([]));
await searchGifs(api, "cats");
expect(gifSearch).toHaveBeenCalledTimes(1);
});
it("includes the query param q", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await searchGifs("dogs");
expect(capturedParams().get("q")).toBe("dogs");
});
it("includes the API key param", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await searchGifs("dogs");
expect(capturedParams().has("key")).toBe(true);
});
it("includes media_filter param", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await searchGifs("dogs");
expect(capturedParams().get("media_filter")).toBe("gif,tinygif");
it("passes the query through", async () => {
gifSearch.mockResolvedValue(response([]));
await searchGifs(api, "dogs");
expect(gifSearch).toHaveBeenCalledWith("dogs", 20);
});
it("defaults limit to 20", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await searchGifs("cats");
expect(capturedParams().get("limit")).toBe("20");
gifSearch.mockResolvedValue(response([]));
await searchGifs(api, "cats");
expect(gifSearch.mock.calls[0]?.[1]).toBe(20);
});
it("passes an explicit limit override", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await searchGifs("cats", 5);
expect(capturedParams().get("limit")).toBe("5");
gifSearch.mockResolvedValue(response([]));
await searchGifs(api, "cats", 5);
expect(gifSearch.mock.calls[0]?.[1]).toBe(5);
});
it("URL-encodes special characters in the query", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await searchGifs("hello world & more");
const q = capturedParams().get("q");
expect(q).toBe("hello world & more");
it("never calls global fetch (no direct api.klipy.com traffic)", async () => {
gifSearch.mockResolvedValue(response([]));
await searchGifs(api, "cats");
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("does not use the trending endpoint", async () => {
gifSearch.mockResolvedValue(response([]));
await searchGifs(api, "cats");
expect(gifTrending).not.toHaveBeenCalled();
});
});
describe("result parsing", () => {
it("returns an empty array when results are empty", async () => {
mockFetch.mockResolvedValue(okResponse([]));
const gifs = await searchGifs("nothing");
expect(gifs).toEqual([]);
gifSearch.mockResolvedValue(response([]));
expect(await searchGifs(api, "nothing")).toEqual([]);
});
it("maps id, title, url (tinygif), and fullUrl (gif) correctly", async () => {
mockFetch.mockResolvedValue(okResponse([gifResult("abc123")]));
const gifs = await searchGifs("cats");
gifSearch.mockResolvedValue(response([gifResult("abc123")]));
const gifs = await searchGifs(api, "cats");
expect(gifs).toHaveLength(1);
expect(gifs[0]).toEqual({
id: "abc123",
@@ -139,49 +111,39 @@ describe("searchGifs", () => {
});
it("maps multiple results in order", async () => {
mockFetch.mockResolvedValue(okResponse([gifResult("a"), gifResult("b"), gifResult("c")]));
const gifs = await searchGifs("cats");
gifSearch.mockResolvedValue(response([gifResult("a"), gifResult("b"), gifResult("c")]));
const gifs = await searchGifs(api, "cats");
expect(gifs.map((g) => g.id)).toEqual(["a", "b", "c"]);
});
it("filters out results with no tinygif format", async () => {
mockFetch.mockResolvedValue(
okResponse([gifResult("keep"), gifResult("drop", { tinygif: null })]),
gifSearch.mockResolvedValue(
response([gifResult("keep"), gifResult("drop", { tinygif: null })]),
);
const gifs = await searchGifs("cats");
expect(gifs).toHaveLength(1);
expect(gifs[0]?.id).toBe("keep");
const gifs = await searchGifs(api, "cats");
expect(gifs.map((g) => g.id)).toEqual(["keep"]);
});
it("filters out results with no gif format", async () => {
mockFetch.mockResolvedValue(
okResponse([gifResult("keep"), gifResult("drop", { gif: null })]),
);
const gifs = await searchGifs("cats");
expect(gifs).toHaveLength(1);
expect(gifs[0]?.id).toBe("keep");
});
it("filters out results missing both formats", async () => {
mockFetch.mockResolvedValue(
okResponse([gifResult("drop", { tinygif: null, gif: null }), gifResult("keep")]),
);
const gifs = await searchGifs("cats");
expect(gifs).toHaveLength(1);
expect(gifs[0]?.id).toBe("keep");
gifSearch.mockResolvedValue(response([gifResult("keep"), gifResult("drop", { gif: null })]));
const gifs = await searchGifs(api, "cats");
expect(gifs.map((g) => g.id)).toEqual(["keep"]);
});
it("returns an empty array when all results lack required formats", async () => {
mockFetch.mockResolvedValue(
okResponse([gifResult("x", { tinygif: null }), gifResult("y", { gif: null })]),
gifSearch.mockResolvedValue(
response([gifResult("x", { tinygif: null }), gifResult("y", { gif: null })]),
);
const gifs = await searchGifs("cats");
expect(gifs).toEqual([]);
expect(await searchGifs(api, "cats")).toEqual([]);
});
});
// The server is trusted to hold the key, but not to dictate what the client
// renders — media URLs are still pinned to the Klipy CDN.
describe("CDN allowlist", () => {
it("filters out results with non-Klipy CDN URLs", async () => {
mockFetch.mockResolvedValue(
okResponse([
gifSearch.mockResolvedValue(
response([
gifResult("drop", {
tinygif: "https://media.tenor.com/drop_tiny.gif",
gif: "https://media.tenor.com/drop.gif",
@@ -192,31 +154,58 @@ describe("searchGifs", () => {
}),
]),
);
const gifs = await searchGifs("cats");
expect(gifs).toHaveLength(1);
expect(gifs[0]?.id).toBe("keep");
const gifs = await searchGifs(api, "cats");
expect(gifs.map((g) => g.id)).toEqual(["keep"]);
});
it("rejects http:// URLs on the allowed host", async () => {
gifSearch.mockResolvedValue(
response([
gifResult("drop", {
tinygif: "http://media.klipy.com/a_tiny.gif",
gif: "http://media.klipy.com/a.gif",
}),
]),
);
expect(await searchGifs(api, "cats")).toEqual([]);
});
it("rejects lookalike hosts that merely end in the allowed name", async () => {
gifSearch.mockResolvedValue(
response([
gifResult("drop", {
tinygif: "https://evilklipy.com/a_tiny.gif",
gif: "https://evilklipy.com/a.gif",
}),
]),
);
expect(await searchGifs(api, "cats")).toEqual([]);
});
it("rejects a klipy.com path on an attacker host", async () => {
gifSearch.mockResolvedValue(
response([
gifResult("drop", {
tinygif: "https://evil.example.com/klipy.com/a_tiny.gif",
gif: "https://evil.example.com/klipy.com/a.gif",
}),
]),
);
expect(await searchGifs(api, "cats")).toEqual([]);
});
it("rejects malformed URLs", async () => {
gifSearch.mockResolvedValue(
response([gifResult("drop", { tinygif: "not a url", gif: "also not a url" })]),
);
expect(await searchGifs(api, "cats")).toEqual([]);
});
});
describe("HTTP error handling", () => {
it("throws when the response is not ok", async () => {
mockFetch.mockResolvedValue(errorResponse(429, "Too Many Requests"));
await expect(searchGifs("cats")).rejects.toThrow();
});
it("error message includes the HTTP status code", async () => {
mockFetch.mockResolvedValue(errorResponse(403, "Forbidden"));
await expect(searchGifs("cats")).rejects.toThrow("403");
});
it("error message includes the status text", async () => {
mockFetch.mockResolvedValue(errorResponse(403, "Forbidden"));
await expect(searchGifs("cats")).rejects.toThrow("Forbidden");
});
it("throws when fetch itself rejects (network error)", async () => {
mockFetch.mockRejectedValue(new Error("Network failure"));
await expect(searchGifs("cats")).rejects.toThrow("Network failure");
describe("error propagation", () => {
it("propagates the api client's error so the picker can degrade", async () => {
gifSearch.mockRejectedValue(new Error("Service Unavailable"));
await expect(searchGifs(api, "cats")).rejects.toThrow("Service Unavailable");
});
});
});
@@ -226,54 +215,47 @@ describe("searchGifs", () => {
// ---------------------------------------------------------------------------
describe("getTrendingGifs", () => {
describe("URL construction", () => {
it("calls the Klipy featured endpoint", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await getTrendingGifs();
expect(capturedUrl()).toMatch(/^https:\/\/api\.klipy\.com\/v2\/featured/);
});
it("does not include a q param", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await getTrendingGifs();
expect(capturedParams().has("q")).toBe(false);
});
it("includes the API key param", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await getTrendingGifs();
expect(capturedParams().has("key")).toBe(true);
});
it("includes media_filter param", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await getTrendingGifs();
expect(capturedParams().get("media_filter")).toBe("gif,tinygif");
describe("transport", () => {
it("calls the server's trending endpoint via the api client", async () => {
gifTrending.mockResolvedValue(response([]));
await getTrendingGifs(api);
expect(gifTrending).toHaveBeenCalledTimes(1);
});
it("defaults limit to 20", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await getTrendingGifs();
expect(capturedParams().get("limit")).toBe("20");
gifTrending.mockResolvedValue(response([]));
await getTrendingGifs(api);
expect(gifTrending).toHaveBeenCalledWith(20);
});
it("passes an explicit limit override", async () => {
mockFetch.mockResolvedValue(okResponse([]));
await getTrendingGifs(10);
expect(capturedParams().get("limit")).toBe("10");
gifTrending.mockResolvedValue(response([]));
await getTrendingGifs(api, 10);
expect(gifTrending).toHaveBeenCalledWith(10);
});
it("never calls global fetch (no direct api.klipy.com traffic)", async () => {
gifTrending.mockResolvedValue(response([]));
await getTrendingGifs(api);
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("does not use the search endpoint", async () => {
gifTrending.mockResolvedValue(response([]));
await getTrendingGifs(api);
expect(gifSearch).not.toHaveBeenCalled();
});
});
describe("result parsing", () => {
it("returns an empty array when results are empty", async () => {
mockFetch.mockResolvedValue(okResponse([]));
const gifs = await getTrendingGifs();
expect(gifs).toEqual([]);
gifTrending.mockResolvedValue(response([]));
expect(await getTrendingGifs(api)).toEqual([]);
});
it("maps fields correctly", async () => {
mockFetch.mockResolvedValue(okResponse([gifResult("trend1")]));
const gifs = await getTrendingGifs();
gifTrending.mockResolvedValue(response([gifResult("trend1")]));
const gifs = await getTrendingGifs(api);
expect(gifs[0]).toEqual({
id: "trend1",
title: "Title trend1",
@@ -283,41 +265,36 @@ describe("getTrendingGifs", () => {
});
it("filters out results with missing tinygif", async () => {
mockFetch.mockResolvedValue(
okResponse([gifResult("keep"), gifResult("drop", { tinygif: null })]),
gifTrending.mockResolvedValue(
response([gifResult("keep"), gifResult("drop", { tinygif: null })]),
);
const gifs = await getTrendingGifs();
expect(gifs.map((g) => g.id)).toEqual(["keep"]);
expect((await getTrendingGifs(api)).map((g) => g.id)).toEqual(["keep"]);
});
it("filters out results with missing gif", async () => {
mockFetch.mockResolvedValue(
okResponse([gifResult("keep"), gifResult("drop", { gif: null })]),
gifTrending.mockResolvedValue(
response([gifResult("keep"), gifResult("drop", { gif: null })]),
);
const gifs = await getTrendingGifs();
expect(gifs.map((g) => g.id)).toEqual(["keep"]);
expect((await getTrendingGifs(api)).map((g) => g.id)).toEqual(["keep"]);
});
it("enforces the CDN allowlist on trending results too", async () => {
gifTrending.mockResolvedValue(
response([
gifResult("drop", {
tinygif: "https://media.tenor.com/drop_tiny.gif",
gif: "https://media.tenor.com/drop.gif",
}),
]),
);
expect(await getTrendingGifs(api)).toEqual([]);
});
});
describe("HTTP error handling", () => {
it("throws when the response is not ok", async () => {
mockFetch.mockResolvedValue(errorResponse(500, "Internal Server Error"));
await expect(getTrendingGifs()).rejects.toThrow();
});
it("error message includes the HTTP status code", async () => {
mockFetch.mockResolvedValue(errorResponse(503, "Service Unavailable"));
await expect(getTrendingGifs()).rejects.toThrow("503");
});
it("error message includes the status text", async () => {
mockFetch.mockResolvedValue(errorResponse(503, "Service Unavailable"));
await expect(getTrendingGifs()).rejects.toThrow("Service Unavailable");
});
it("throws when fetch itself rejects (network error)", async () => {
mockFetch.mockRejectedValue(new Error("DNS lookup failed"));
await expect(getTrendingGifs()).rejects.toThrow("DNS lookup failed");
describe("error propagation", () => {
it("propagates the api client's error so the picker can degrade", async () => {
gifTrending.mockRejectedValue(new Error("DNS lookup failed"));
await expect(getTrendingGifs(api)).rejects.toThrow("DNS lookup failed");
});
});
});
@@ -14,10 +14,15 @@ vi.mock("@components/EmojiPicker", () => ({
}));
/** Captured GIF picker callbacks so tests can simulate selection. */
let lastGifPickerOptions: { onSelect: (url: string) => void; onClose: () => void } | null = null;
type CapturedGifPickerOptions = {
onSelect: (url: string) => void;
onClose: () => void;
onUnavailable?: (reason: string) => void;
};
let lastGifPickerOptions: CapturedGifPickerOptions | null = null;
vi.mock("@components/GifPicker", () => ({
createGifPicker: (opts: { onSelect: (url: string) => void; onClose: () => void }) => {
createGifPicker: (opts: CapturedGifPickerOptions) => {
lastGifPickerOptions = opts;
const element = document.createElement("div");
element.classList.add("gif-picker");
@@ -26,11 +31,19 @@ vi.mock("@components/GifPicker", () => ({
}));
import { createMessageInput, type MessageInputOptions } from "@components/MessageInput";
import type { GifApi } from "@lib/gifProvider";
/** GIF endpoints on the user's own server (never api.klipy.com). */
const stubGifApi: GifApi = {
gifSearch: vi.fn(async () => ({ results: [] })),
gifTrending: vi.fn(async () => ({ results: [] })),
};
function makeOptions(overrides: Partial<MessageInputOptions> = {}): MessageInputOptions {
return {
channelId: 1,
channelName: "general",
gifApi: stubGifApi,
onSend: vi.fn(),
onTyping: vi.fn(),
onEditMessage: vi.fn(),
@@ -896,4 +909,67 @@ describe("MessageInput", () => {
comp.destroy?.();
});
// ── GIF affordance degrades when the server has no GIF provider ───────────
describe("GIF button degradation", () => {
it("is enabled when a gifApi is wired", () => {
const comp = createMessageInput(makeOptions());
comp.mount(container);
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
expect(gifBtn.hasAttribute("disabled")).toBe(false);
comp.destroy?.();
});
it("renders disabled with a visible reason when no gifApi is wired", () => {
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
comp.mount(container);
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
expect(gifBtn.hasAttribute("disabled")).toBe(true);
expect(gifBtn.title).toBe("GIFs are not enabled on this server");
expect(gifBtn.getAttribute("aria-label")).toContain("not enabled");
comp.destroy?.();
});
it("does not open a picker when no gifApi is wired", () => {
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
comp.mount(container);
(container.querySelector(".gif-btn") as HTMLElement).click();
expect(container.querySelector(".gif-picker")).toBeNull();
comp.destroy?.();
});
it("disables the GIF button when the picker reports the server has GIFs off", () => {
const comp = createMessageInput(makeOptions());
comp.mount(container);
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
gifBtn.click();
lastGifPickerOptions!.onUnavailable!("GIFs are not enabled on this server");
expect(gifBtn.hasAttribute("disabled")).toBe(true);
expect(gifBtn.title).toBe("GIFs are not enabled on this server");
comp.destroy?.();
});
it("keeps the GIF button disabled after the composer is re-enabled", () => {
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
comp.mount(container);
comp.setDisabled("Read-only channel");
comp.setDisabled(null);
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
expect(gifBtn.hasAttribute("disabled")).toBe(true);
comp.destroy?.();
});
});
});