From 89401c64a6f6769dc29286de4793a347afbf0754 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:29:57 +0200 Subject: [PATCH] feat(client): call own server for GIFs, drop VITE_KLIPY_API_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tauri-client/src/components/GifPicker.ts | 35 +- .../src/components/MessageInput.ts | 28 ++ Client/tauri-client/src/lib/api.ts | 27 ++ Client/tauri-client/src/lib/gifProvider.ts | 78 ++-- Client/tauri-client/src/lib/types.ts | 23 ++ .../src/pages/main-page/ChannelController.ts | 1 + .../tests/unit/gif-picker.test.ts | 95 ++++- .../tests/unit/gif-provider.test.ts | 357 ++++++++---------- .../tests/unit/message-input.test.ts | 80 +++- 9 files changed, 463 insertions(+), 261 deletions(-) diff --git a/Client/tauri-client/src/components/GifPicker.ts b/Client/tauri-client/src/components/GifPicker.ts index f73445e7..6420cb93 100644 --- a/Client/tauri-client/src/components/GifPicker.ts +++ b/Client/tauri-client/src/components/GifPicker.ts @@ -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); } } diff --git a/Client/tauri-client/src/components/MessageInput.ts b/Client/tauri-client/src/components/MessageInput.ts index a55032d6..75798d1c 100644 --- a/Client/tauri-client/src/components/MessageInput.ts +++ b/Client/tauri-client/src/components/MessageInput.ts @@ -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; diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index f0a24f85..fade98e7 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -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("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 { + const params = new URLSearchParams({ q: query, limit: String(limit) }); + return request( + "GET", + `/gif/search?${params.toString()}`, + undefined, + signal, + ); + }, + + gifTrending(limit: number, signal?: AbortSignal): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + return request( + "GET", + `/gif/trending?${params.toString()}`, + undefined, + signal, + ); + }, + // ── File Uploads ────────────────────────────────────── async uploadFile(file: File, signal?: AbortSignal): Promise { diff --git a/Client/tauri-client/src/lib/gifProvider.ts b/Client/tauri-client/src/lib/gifProvider.ts index 82f54a28..d37ce627 100644 --- a/Client/tauri-client/src/lib/gifProvider.ts +++ b/Client/tauri-client/src/lib/gifProvider.ts @@ -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; 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 { - 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 { - 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)); } diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index eb3d52ed..7383e30d 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -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[]; diff --git a/Client/tauri-client/src/pages/main-page/ChannelController.ts b/Client/tauri-client/src/pages/main-page/ChannelController.ts index 8f955636..e2b263fd 100644 --- a/Client/tauri-client/src/pages/main-page/ChannelController.ts +++ b/Client/tauri-client/src/pages/main-page/ChannelController.ts @@ -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); }, diff --git a/Client/tauri-client/tests/unit/gif-picker.test.ts b/Client/tauri-client/tests/unit/gif-picker.test.ts index 0352d310..4bb202a5 100644 --- a/Client/tauri-client/tests/unit/gif-picker.test.ts +++ b/Client/tauri-client/tests/unit/gif-picker.test.ts @@ -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) { 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", () => { diff --git a/Client/tauri-client/tests/unit/gif-provider.test.ts b/Client/tauri-client/tests/unit/gif-provider.test.ts index 0ecb593b..5eb49b00 100644 --- a/Client/tauri-client/tests/unit/gif-provider.test.ts +++ b/Client/tauri-client/tests/unit/gif-provider.test.ts @@ -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(); +const gifTrending = vi.fn(); +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"); }); }); }); diff --git a/Client/tauri-client/tests/unit/message-input.test.ts b/Client/tauri-client/tests/unit/message-input.test.ts index 6a4c6be0..9f0cf376 100644 --- a/Client/tauri-client/tests/unit/message-input.test.ts +++ b/Client/tauri-client/tests/unit/message-input.test.ts @@ -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 { 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?.(); + }); + }); });