diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4025edfa..d9803dc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,6 @@ jobs: env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - VITE_KLIPY_API_KEY: ${{ secrets.VITE_KLIPY_API_KEY }} run: npm run tauri build - name: Stage Windows release assets @@ -103,7 +102,6 @@ jobs: env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - VITE_KLIPY_API_KEY: ${{ secrets.VITE_KLIPY_API_KEY }} run: npm run tauri build -- --bundles appimage,deb - name: Stage Linux release assets @@ -232,7 +230,6 @@ jobs: env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - VITE_KLIPY_API_KEY: ${{ secrets.VITE_KLIPY_API_KEY }} run: npm run tauri build -- --bundles appimage,deb - name: Stage Linux ARM64 release assets 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?.(); + }); + }); }); diff --git a/Server/api/constants.go b/Server/api/constants.go index 2ca0973a..b46bb4f3 100644 --- a/Server/api/constants.go +++ b/Server/api/constants.go @@ -34,6 +34,11 @@ const ( // clientUpdateRateLimitPerMinute is the maximum client-update checks per IP per minute. clientUpdateRateLimitPerMinute = 30 + // gifRateLimitPerMinute is the maximum GIF proxy requests per IP per minute. + // The picker debounces at 300ms, so a user typing continuously for a minute + // stays under this; it exists to bound abuse of the operator's Klipy quota. + gifRateLimitPerMinute = 30 + // loginFailureThreshold is the number of failed login attempts (within // loginFailureWindow) before the IP is locked out. loginFailureThreshold = 9 diff --git a/Server/api/export_test.go b/Server/api/export_test.go index 90229552..37c6db1d 100644 --- a/Server/api/export_test.go +++ b/Server/api/export_test.go @@ -35,3 +35,12 @@ func HandleLiveKitHealthForTest(healthCheck func(context.Context) (bool, error)) // IsPrivateIPForTest exposes isPrivateIP for use in external tests. var IsPrivateIPForTest = isPrivateIP + +// SetGIFUpstreamForTest points the GIF proxy at a stub upstream and returns a +// restore func. The production transport uses the SSRF-guarded dialer, which +// refuses loopback addresses, so tests must supply their own client too. +func SetGIFUpstreamForTest(baseURL string, client *http.Client) func() { + prevBase, prevClient := gifAPIBase, gifClient + gifAPIBase, gifClient = baseURL, client + return func() { gifAPIBase, gifClient = prevBase, prevClient } +} diff --git a/Server/api/gif_handler.go b/Server/api/gif_handler.go new file mode 100644 index 00000000..36ae4766 --- /dev/null +++ b/Server/api/gif_handler.go @@ -0,0 +1,229 @@ +// gif_handler.go — server-side proxy for the Klipy GIF API. +// +// The Klipy API key lives in server config and never leaves the server: the +// client asks its own server for GIFs and the server does the upstream call. +// This closes the "secret in the client bundle" hole — a VITE_ variable is +// inlined into the shipped bundle by design and can never hold a credential. +// +// Default-off contract: with no gif.api_key configured both endpoints answer +// 503 with error code GIF_DISABLED, which the client uses to hide/disable the +// GIF picker instead of showing a broken one. + +package api + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" + "github.com/owncord/server/plugin" +) + +// gifAPIBase is the upstream Klipy API root. It is a var only so tests can +// point it at a local stub; production never reassigns it. +var gifAPIBase = "https://api.klipy.com/v2" + +const ( + // gifDefaultLimit / gifMaxLimit bound the number of results requested. + gifDefaultLimit = 20 + gifMaxLimit = 50 + + // gifMaxQueryLen caps the search term length before it is forwarded. + gifMaxQueryLen = 100 + + // gifUpstreamTimeout is the total budget for one upstream call. + gifUpstreamTimeout = 10 * time.Second + + // gifMaxResponseBytes caps the upstream body we are willing to read so a + // hostile or oversized response cannot exhaust server memory. + gifMaxResponseBytes = 2 << 20 // 2 MiB +) + +// gifClient performs the upstream call. It reuses the same SSRF-guarded dialer +// as the plugin host_http capability (resolve once, reject private/loopback/ +// link-local/CGN addresses, dial only vetted IPs) rather than a bare +// http.Get, and refuses to follow redirects — the upstream host is fixed. +var gifClient = &http.Client{ + Timeout: gifUpstreamTimeout, + Transport: &http.Transport{DialContext: plugin.GuardedDialContext()}, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, +} + +// gifMediaFormat is a single renderable variant of a GIF. +type gifMediaFormat struct { + URL string `json:"url"` +} + +// gifResult is one GIF. Decoding the upstream body into this struct and +// re-encoding it IS the field allowlist: anything Klipy returns that is not +// declared here (including any echo of our API key) is dropped on the floor +// and never reaches the client. +type gifResult struct { + ID string `json:"id"` + Title string `json:"title"` + MediaFormats struct { + TinyGif *gifMediaFormat `json:"tinygif,omitempty"` + Gif *gifMediaFormat `json:"gif,omitempty"` + } `json:"media_formats"` +} + +// gifResponse is the JSON envelope returned by both GIF endpoints. +type gifResponse struct { + Results []gifResult `json:"results"` +} + +// MountGIFRoutes registers the authenticated GIF proxy endpoints. +// +// Both routes require a session (same as sibling content endpoints) and share +// a dedicated per-IP rate-limit bucket — the picker searches on every debounced +// keystroke, so it must not share the empty-prefix bucket used by password and +// TOTP endpoints. +func MountGIFRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, cfg *config.Config) { + r.Route("/api/v1/gif", func(r chi.Router) { + r.Use(AuthMiddleware(database)) + r.Use(rateLimitMiddlewareWithPrefix(limiter, "gif:", gifRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies)) + + r.Get("/search", handleGIFProxy(cfg.GIF.APIKey, "/search", true)) + r.Get("/trending", handleGIFProxy(cfg.GIF.APIKey, "/featured", false)) + }) +} + +// handleGIFProxy returns a handler that forwards a GIF request upstream with +// the server-held API key. requireQuery marks the endpoints that take a `q` +// search term (search) versus those that do not (trending). +func handleGIFProxy(apiKey, upstreamPath string, requireQuery bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if apiKey == "" { + writeJSON(w, http.StatusServiceUnavailable, errorResponse{ + Error: "GIF_DISABLED", + Message: "GIF search is not configured on this server", + }) + return + } + + params := url.Values{ + "key": {apiKey}, + "media_filter": {"gif,tinygif"}, + } + + limit, ok := parseGIFLimit(r.URL.Query().Get("limit")) + if !ok { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "limit must be an integer between 1 and " + strconv.Itoa(gifMaxLimit), + }) + return + } + params.Set("limit", strconv.Itoa(limit)) + + if requireQuery { + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "q is required", + }) + return + } + if len(q) > gifMaxQueryLen { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "q must be at most " + strconv.Itoa(gifMaxQueryLen) + " characters", + }) + return + } + params.Set("q", q) + } + + results, err := fetchGIFs(r, gifAPIBase+upstreamPath+"?"+params.Encode(), apiKey, limit) + if err != nil { + writeJSON(w, http.StatusBadGateway, errorResponse{ + Error: "BAD_GATEWAY", + Message: "GIF provider is unavailable", + }) + return + } + + writeJSON(w, http.StatusOK, gifResponse{Results: results}) + } +} + +// fetchGIFs performs the upstream request and returns the allowlisted results. +// It never returns the upstream error to the caller and never logs the request +// URL, because that URL carries the API key. +func fetchGIFs(r *http.Request, upstreamURL, apiKey string, limit int) ([]gifResult, error) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil) + if err != nil { + slog.Warn("gif proxy: building upstream request failed", "error", redactKey(err.Error(), apiKey)) + return nil, err + } + + resp, err := gifClient.Do(req) + if err != nil { + // url.Error embeds the request URL, which contains the API key. + slog.Warn("gif proxy: upstream request failed", "error", redactKey(err.Error(), apiKey)) + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + slog.Warn("gif proxy: upstream returned non-200", "status", resp.StatusCode) + return nil, errGIFUpstream + } + + var upstream gifResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, gifMaxResponseBytes)).Decode(&upstream); err != nil { + slog.Warn("gif proxy: decoding upstream response failed", "error", redactKey(err.Error(), apiKey)) + return nil, err + } + + // Drop entries missing either renderable format and honour our own limit + // even if upstream ignored it. Non-nil so the JSON is [] and never null. + results := make([]gifResult, 0, len(upstream.Results)) + for _, g := range upstream.Results { + if g.MediaFormats.TinyGif == nil || g.MediaFormats.Gif == nil { + continue + } + if len(results) >= limit { + break + } + results = append(results, g) + } + return results, nil +} + +// errGIFUpstream marks a non-200 upstream response. +var errGIFUpstream = errors.New("gif proxy: upstream error") + +// redactKey removes the API key from a string destined for the logs. +func redactKey(s, apiKey string) string { + if apiKey == "" { + return s + } + return strings.ReplaceAll(s, apiKey, "[REDACTED]") +} + +// parseGIFLimit parses and validates the `limit` query param. An empty value +// yields the default; anything non-numeric or out of range is rejected. +func parseGIFLimit(raw string) (int, bool) { + if raw == "" { + return gifDefaultLimit, true + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > gifMaxLimit { + return 0, false + } + return n, true +} diff --git a/Server/api/gif_handler_test.go b/Server/api/gif_handler_test.go new file mode 100644 index 00000000..f3e118cb --- /dev/null +++ b/Server/api/gif_handler_test.go @@ -0,0 +1,353 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// buildGIFRouter mounts the GIF proxy with the given upstream API key. +func buildGIFRouter(database *db.DB, apiKey string) http.Handler { + r := chi.NewRouter() + limiter := auth.NewRateLimiter() + cfg := &config.Config{} + cfg.GIF.APIKey = apiKey + api.MountGIFRoutes(r, database, limiter, cfg) + return r +} + +// stubKlipy starts a fake upstream and points the GIF proxy at it. The +// returned recorder captures the query of the last upstream request. +func stubKlipy(t *testing.T, body string, status int) *lastRequest { + t.Helper() + rec := &lastRequest{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.path = r.URL.Path + rec.query = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + // The production transport uses the SSRF-guarded dialer, which refuses the + // loopback address httptest binds to — supply a plain client for the stub. + restore := api.SetGIFUpstreamForTest(srv.URL, srv.Client()) + t.Cleanup(restore) + return rec +} + +type lastRequest struct { + path string + query map[string][]string +} + +func (l *lastRequest) get(key string) string { + if v := l.query[key]; len(v) > 0 { + return v[0] + } + return "" +} + +// gifGET issues an authenticated GET against the GIF router. +func gifGET(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +const gifUpstreamBody = `{"results":[ + {"id":"a","title":"Cat","media_formats":{"tinygif":{"url":"https://media.klipy.com/a_tiny.gif"},"gif":{"url":"https://media.klipy.com/a.gif"}},"secret_echo":"leak"}, + {"id":"b","title":"NoTiny","media_formats":{"gif":{"url":"https://media.klipy.com/b.gif"}}} +]}` + +func decodeGIFResults(t *testing.T, rr *httptest.ResponseRecorder) []map[string]any { + t.Helper() + var body struct { + Results []map[string]any `json:"results"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v (body=%s)", err, rr.Body.String()) + } + return body.Results +} + +func decodeGIFError(t *testing.T, rr *httptest.ResponseRecorder) string { + t.Helper() + var body struct { + Error string `json:"error"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error body: %v (body=%s)", err, rr.Body.String()) + } + return body.Error +} + +// ─── Key configured: proxies upstream ──────────────────────────────────────── + +func TestGIFSearchProxiesUpstream(t *testing.T) { + database := newAuthTestDB(t) + token := profileCreateToken(t, database, "gifuser", 4) + up := stubKlipy(t, gifUpstreamBody, http.StatusOK) + router := buildGIFRouter(database, "server-side-key") + + rr := gifGET(t, router, "/api/v1/gif/search?q=cats&limit=5", token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + + if up.path != "/search" { + t.Errorf("upstream path = %q, want /search", up.path) + } + if got := up.get("q"); got != "cats" { + t.Errorf("upstream q = %q, want cats", got) + } + if got := up.get("limit"); got != "5" { + t.Errorf("upstream limit = %q, want 5", got) + } + if got := up.get("key"); got != "server-side-key" { + t.Errorf("upstream key = %q, want the server-held key", got) + } + + results := decodeGIFResults(t, rr) + if len(results) != 1 { + t.Fatalf("results = %d, want 1 (entries missing a format are dropped)", len(results)) + } + if results[0]["id"] != "a" { + t.Errorf("result id = %v, want a", results[0]["id"]) + } +} + +func TestGIFTrendingProxiesUpstream(t *testing.T) { + database := newAuthTestDB(t) + token := profileCreateToken(t, database, "gifuser", 4) + up := stubKlipy(t, gifUpstreamBody, http.StatusOK) + router := buildGIFRouter(database, "server-side-key") + + rr := gifGET(t, router, "/api/v1/gif/trending", token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + if up.path != "/featured" { + t.Errorf("upstream path = %q, want /featured", up.path) + } + if up.get("q") != "" { + t.Errorf("trending must not send q, got %q", up.get("q")) + } + if got := up.get("limit"); got != "20" { + t.Errorf("default limit = %q, want 20", got) + } +} + +// The API key must never reach the client, directly or via an upstream echo. +func TestGIFResponseNeverLeaksAPIKey(t *testing.T) { + database := newAuthTestDB(t) + token := profileCreateToken(t, database, "gifuser", 4) + stubKlipy(t, `{"results":[{"id":"a","title":"t","key":"server-side-key","media_formats":{"tinygif":{"url":"https://media.klipy.com/a_tiny.gif"},"gif":{"url":"https://media.klipy.com/a.gif"}}}],"echoed_key":"server-side-key"}`, http.StatusOK) + router := buildGIFRouter(database, "server-side-key") + + rr := gifGET(t, router, "/api/v1/gif/search?q=cats", token) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if strings.Contains(rr.Body.String(), "server-side-key") { + t.Fatalf("response leaked the API key: %s", rr.Body.String()) + } +} + +// ─── Default-off contract ──────────────────────────────────────────────────── + +func TestGIFDisabledWhenNoKeyConfigured(t *testing.T) { + database := newAuthTestDB(t) + token := profileCreateToken(t, database, "gifuser", 4) + router := buildGIFRouter(database, "") + + for _, path := range []string{"/api/v1/gif/search?q=cats", "/api/v1/gif/trending"} { + rr := gifGET(t, router, path, token) + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("%s status = %d, want 503", path, rr.Code) + } + if code := decodeGIFError(t, rr); code != "GIF_DISABLED" { + t.Errorf("%s error code = %q, want GIF_DISABLED", path, code) + } + } +} + +// A disabled server must not make an outbound call at all. +func TestGIFDisabledMakesNoUpstreamCall(t *testing.T) { + database := newAuthTestDB(t) + token := profileCreateToken(t, database, "gifuser", 4) + up := stubKlipy(t, gifUpstreamBody, http.StatusOK) + router := buildGIFRouter(database, "") + + gifGET(t, router, "/api/v1/gif/search?q=cats", token) + if up.path != "" { + t.Errorf("upstream was called (%q) despite the feature being disabled", up.path) + } +} + +// ─── Auth ──────────────────────────────────────────────────────────────────── + +func TestGIFRequiresAuth(t *testing.T) { + database := newAuthTestDB(t) + stubKlipy(t, gifUpstreamBody, http.StatusOK) + router := buildGIFRouter(database, "server-side-key") + + for _, path := range []string{"/api/v1/gif/search?q=cats", "/api/v1/gif/trending"} { + rr := gifGET(t, router, path, "") + if rr.Code != http.StatusUnauthorized { + t.Errorf("%s without a token: status = %d, want 401", path, rr.Code) + } + } +} + +func TestGIFRejectsInvalidToken(t *testing.T) { + database := newAuthTestDB(t) + stubKlipy(t, gifUpstreamBody, http.StatusOK) + router := buildGIFRouter(database, "server-side-key") + + rr := gifGET(t, router, "/api/v1/gif/search?q=cats", "not-a-real-token") + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +// Auth is checked before the disabled check, so an anonymous caller cannot +// probe whether the operator configured a GIF key. +func TestGIFAuthCheckedBeforeDisabledCheck(t *testing.T) { + database := newAuthTestDB(t) + router := buildGIFRouter(database, "") + + rr := gifGET(t, router, "/api/v1/gif/search?q=cats", "") + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401 (not 503)", rr.Code) + } +} + +// ─── Input validation ──────────────────────────────────────────────────────── + +func TestGIFSearchValidatesInput(t *testing.T) { + database := newAuthTestDB(t) + token := profileCreateToken(t, database, "gifuser", 4) + stubKlipy(t, gifUpstreamBody, http.StatusOK) + router := buildGIFRouter(database, "server-side-key") + + tests := []struct { + name string + path string + }{ + {"missing q", "/api/v1/gif/search"}, + {"blank q", "/api/v1/gif/search?q=%20%20"}, + {"q too long", "/api/v1/gif/search?q=" + strings.Repeat("a", 101)}, + {"limit not a number", "/api/v1/gif/search?q=cats&limit=abc"}, + {"limit zero", "/api/v1/gif/search?q=cats&limit=0"}, + {"limit over max", "/api/v1/gif/search?q=cats&limit=51"}, + {"limit negative", "/api/v1/gif/search?q=cats&limit=-1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rr := gifGET(t, router, tt.path, token) + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (body=%s)", rr.Code, rr.Body.String()) + } + }) + } +} + +// ─── Upstream failure ──────────────────────────────────────────────────────── + +func TestGIFUpstreamErrorBecomesBadGateway(t *testing.T) { + database := newAuthTestDB(t) + token := profileCreateToken(t, database, "gifuser", 4) + stubKlipy(t, `{"error":"quota exceeded for key server-side-key"}`, http.StatusPaymentRequired) + router := buildGIFRouter(database, "server-side-key") + + rr := gifGET(t, router, "/api/v1/gif/search?q=cats", token) + if rr.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502", rr.Code) + } + if strings.Contains(rr.Body.String(), "quota exceeded") { + t.Errorf("upstream error body was passed through to the client: %s", rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "server-side-key") { + t.Errorf("response leaked the API key: %s", rr.Body.String()) + } +} + +func TestGIFMalformedUpstreamBecomesBadGateway(t *testing.T) { + database := newAuthTestDB(t) + token := profileCreateToken(t, database, "gifuser", 4) + stubKlipy(t, `not json`, http.StatusOK) + router := buildGIFRouter(database, "server-side-key") + + rr := gifGET(t, router, "/api/v1/gif/search?q=cats", token) + if rr.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502", rr.Code) + } +} + +// ─── Rate limiting ─────────────────────────────────────────────────────────── + +func TestGIFRateLimited(t *testing.T) { + database := newAuthTestDB(t) + token := profileCreateToken(t, database, "gifuser", 4) + stubKlipy(t, `{"results":[]}`, http.StatusOK) + router := buildGIFRouter(database, "server-side-key") + + // The limiter allows 30/minute per IP; the 31st must be refused. + for i := range 30 { + rr := gifGET(t, router, "/api/v1/gif/trending", token) + if rr.Code != http.StatusOK { + t.Fatalf("request %d: status = %d, want 200", i+1, rr.Code) + } + } + rr := gifGET(t, router, "/api/v1/gif/trending", token) + if rr.Code != http.StatusTooManyRequests { + t.Fatalf("request 31: status = %d, want 429", rr.Code) + } + if code := decodeGIFError(t, rr); code != "RATE_LIMITED" { + t.Errorf("error code = %q, want RATE_LIMITED", code) + } + if rr.Header().Get("Retry-After") == "" { + t.Error("429 response is missing the Retry-After header") + } +} + +// The GIF bucket must be separate from the shared empty-prefix bucket, so a +// user hammering the picker cannot rate-limit their own password change. +func TestGIFRateLimitBucketIsSeparate(t *testing.T) { + database := newAuthTestDB(t) + token := profileCreateToken(t, database, "gifuser", 4) + stubKlipy(t, `{"results":[]}`, http.StatusOK) + + limiter := auth.NewRateLimiter() + r := chi.NewRouter() + cfg := &config.Config{} + cfg.GIF.APIKey = "server-side-key" + api.MountGIFRoutes(r, database, limiter, cfg) + + for range 31 { + gifGET(t, r, "/api/v1/gif/trending", token) + } + // The shared (empty-prefix) bucket used by sensitive endpoints must be + // untouched by the GIF traffic above. + if !limiter.Allow("127.0.0.1", 5, time.Minute) { + t.Error("GIF traffic consumed the shared rate-limit bucket") + } +} diff --git a/Server/api/router.go b/Server/api/router.go index 6f26d3f2..12227d81 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -109,6 +109,14 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Channel and message REST routes. MountChannelRoutes(r, database, svc, limiter, cfg.Server.TrustedProxies) + // GIF proxy — keeps the Klipy API key server-side. Mounted unconditionally; + // with no key configured the endpoints answer 503 GIF_DISABLED so the + // client can hide the picker rather than discover a 404. + MountGIFRoutes(r, database, limiter, cfg) + if cfg.GIF.APIKey == "" { + slog.Info("gif.api_key not set — GIF picker disabled (clients will hide it)") + } + // DM REST routes are mounted after hub creation (below) so the hub can // be passed as a DMBroadcaster for real-time close events. diff --git a/Server/config/config.go b/Server/config/config.go index 9dd03548..86fe2f13 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -29,6 +29,17 @@ type Config struct { EventPersistence EventPersistenceConfig `koanf:"event_persistence"` Telemetry TelemetryConfig `koanf:"telemetry"` Plugins PluginsConfig `koanf:"plugins"` + GIF GIFConfig `koanf:"gif"` +} + +// GIFConfig holds the credentials for the server-side GIF (Klipy) proxy. +// +// The API key is deliberately server-only: the client never receives it and +// never talks to api.klipy.com directly, it calls /api/v1/gif/* on its own +// server instead. An empty APIKey means the feature is OFF — the proxy +// endpoints answer 503 GIF_DISABLED and the client hides the picker. +type GIFConfig struct { + APIKey string `koanf:"api_key"` } // EventPersistenceConfig (Phase B Step 7) controls the tiered event log used @@ -287,6 +298,13 @@ voice: # max_memory_mb: 64 # per-plugin memory cap # cpu_budget_ms: 100 # per-invocation CPU budget # http_allowlist: [] # hostnames plugins may reach via the http capability + +# GIF picker (Klipy). Disabled by default: with no api_key the /api/v1/gif/* +# endpoints answer 503 GIF_DISABLED and the client hides its GIF button. The +# key stays on the server — it is never sent to clients. +# Get a key at https://partner.klipy.com +# gif: +# api_key: "" ` // Load reads configuration from the given YAML file path, merging with diff --git a/Server/plugin/host_http.go b/Server/plugin/host_http.go index 319a9278..1695a0f7 100644 --- a/Server/plugin/host_http.go +++ b/Server/plugin/host_http.go @@ -83,7 +83,7 @@ func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) // hostname. This closes the DNS-rebinding TOCTOU window where a second // lookup (the one net.Dialer would perform on a hostname) could return an // internal IP after an earlier check had approved the name. - transport := &http.Transport{DialContext: guardedDialContext()} + transport := &http.Transport{DialContext: GuardedDialContext()} client := &http.Client{ Timeout: httpTimeout, Transport: transport, @@ -169,13 +169,17 @@ var ( } ) -// guardedDialContext returns the SSRF-guarded dial used by HTTPDo's -// transport: resolve once, validate every returned address, then dial vetted +// GuardedDialContext returns the SSRF-guarded dial used by HTTPDo's +// transport. It is exported so every other outbound-HTTP call site in the +// server (e.g. the GIF proxy in package api) shares one vetted dialer instead +// of reaching for a bare net.Dialer. +// +// Behaviour: resolve once, validate every returned address, then dial vetted // concrete IPs. All addresses are validated before any dial (one poisoned // record among them refuses the whole request), and every vetted address is // tried in order — a dual-stack or round-robin host whose first record is // down must still connect via the next one. -func guardedDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) { +func GuardedDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) { return func(ctx context.Context, network, addr string) (net.Conn, error) { h, port, splitErr := net.SplitHostPort(addr) if splitErr != nil { diff --git a/Server/plugin/host_http_test.go b/Server/plugin/host_http_test.go index 77793617..c304dff7 100644 --- a/Server/plugin/host_http_test.go +++ b/Server/plugin/host_http_test.go @@ -140,7 +140,7 @@ func TestGuardedDial_FallsBackAcrossVettedIPs(t *testing.T) { return c1, nil } - conn, err := guardedDialContext()(context.Background(), "tcp", "api.example.com:443") + conn, err := GuardedDialContext()(context.Background(), "tcp", "api.example.com:443") if err != nil { t.Fatalf("guarded dial should fall back to the next vetted IP: %v", err) } @@ -171,7 +171,7 @@ func TestGuardedDial_PrivateRecordRefusesBeforeAnyDial(t *testing.T) { return nil, errors.New("must not be reached") } - _, err := guardedDialContext()(context.Background(), "tcp", "api.example.com:443") + _, err := GuardedDialContext()(context.Background(), "tcp", "api.example.com:443") if !errors.Is(err, ErrHTTPHostDenied) { t.Fatalf("want ErrHTTPHostDenied, got %v", err) } diff --git a/docs/api.md b/docs/api.md index c2f1fffa..76c811d0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -48,7 +48,8 @@ All error responses use this JSON envelope: | `CONFLICT` | 409 | Duplicate username on register, or server already up-to-date on update | | `TOO_LARGE` | 413 | File exceeds upload size limit | | `SERVER_ERROR` / `INTERNAL` | 500 | Internal server error | -| `BAD_GATEWAY` | 502 | Upstream failure (GitHub API, LiveKit, asset download) | +| `BAD_GATEWAY` | 502 | Upstream failure (GitHub API, LiveKit, GIF provider, asset download) | +| `GIF_DISABLED` | 503 | GIF proxy is not configured on this server (no `gif.api_key`) | --- @@ -621,6 +622,74 @@ Full-text search across messages in channels the user can read. Uses SQLite FTS5 --- +## GIFs + +The server proxies the Klipy GIF API so the provider API key stays server-side. +Clients never contact `api.klipy.com` — a key shipped in the desktop bundle +would be public by construction. The key is configured as `gif.api_key` +(see [Server Configuration](server-configuration.md#gif-picker-gif)). + +**Default-off contract:** with no key configured, both endpoints return +`503` with error code `GIF_DISABLED`. Clients must treat that as "this server +does not have GIFs" and hide/disable the GIF affordance — not retry. + +The media URLs in the response point at Klipy's CDN; the client still validates +them against its `klipy.com` CDN allowlist before rendering. + +### GET /api/v1/gif/search + +**Auth:** Required +**Rate limit:** 30 requests/minute (dedicated per-IP bucket) + +#### Query Parameters + +| Param | Type | Default | Range | Description | +| ----- | ---- | ------- | ----- | ----------- | +| `q` | string | (required) | 1-100 chars | Search term | +| `limit` | int | 20 | 1-50 | Maximum results to return | + +#### Response 200 OK + +```json +{ + "results": [ + { + "id": "abc123", + "title": "happy cat", + "media_formats": { + "tinygif": { "url": "https://media.klipy.com/abc123_tiny.gif" }, + "gif": { "url": "https://media.klipy.com/abc123.gif" } + } + } + ] +} +``` + +Only `id`, `title`, and the two `media_formats` URLs are forwarded. Every other +field the upstream returns is dropped, so an upstream that echoed the API key +could not leak it to clients. Results missing either format are omitted. + +#### Errors + +| Status | Code | When | +| ------ | ---- | ---- | +| 400 | `INVALID_INPUT` | Missing/blank `q`, `q` over 100 chars, or `limit` outside 1-50 | +| 401 | `UNAUTHORIZED` | No valid session (checked before the disabled check) | +| 429 | `RATE_LIMITED` | Over 30 requests/minute | +| 502 | `BAD_GATEWAY` | Upstream error, timeout, or unparseable response | +| 503 | `GIF_DISABLED` | `gif.api_key` is not configured | + +### GET /api/v1/gif/trending + +Same auth, rate limit, response shape, and error codes as +`/api/v1/gif/search`, minus the `q` parameter. + +| Param | Type | Default | Range | Description | +| ----- | ---- | ------- | ----- | ----------- | +| `limit` | int | 20 | 1-50 | Maximum results to return | + +--- + ## Direct Messages DM channels use participant-based authorization rather than role-based permissions. diff --git a/docs/architecture/client.md b/docs/architecture/client.md index 5fe67a6e..768aaf61 100644 --- a/docs/architecture/client.md +++ b/docs/architecture/client.md @@ -99,6 +99,7 @@ other (auth→voice→members), and the Solid beachhead is dead weight. | Updates | `src/lib/updater.ts` + `update_commands.rs` | Endpoint derived from the connected server URL, https-only, TLS pinned to TOFU fingerprint, minisign-verified | | Settings | `commands.rs` + `src/lib/preferences.ts` | Split persistence: Rust store (`settings.json`, key-allowlisted) *and* raw `localStorage` for UI prefs/themes | | Theming | `src/lib/themes.ts` + `styles/tokens.css` | CSS custom properties; 4 built-in themes + custom overrides | +| GIF picker | `src/lib/gifProvider.ts` + `components/GifPicker.ts` | Calls the user's own server (`/api/v1/gif/*`) through `api.ts` — no provider API key in the bundle. Server answers `503 GIF_DISABLED` when unconfigured: the picker shows "GIFs are not enabled on this server" and `onUnavailable` disables the composer's GIF button (with a `title`/`aria-label` reason) instead of failing silently. Returned media URLs are still pinned to the `klipy.com` CDN. | ### Quality tooling diff --git a/docs/security.md b/docs/security.md index b54cfe7d..5349f620 100644 --- a/docs/security.md +++ b/docs/security.md @@ -75,7 +75,7 @@ The Tauri desktop client implements the following security measures: - URLs are validated via `isSafeUrl` (rejects `javascript:`, `data:`, `vbscript:`) - YouTube embeds use `sandbox` attribute on iframes - `image/svg+xml` is excluded from safe MIME types for data URIs -- Tenor GIF URLs are validated against trusted CDN origins +- GIF media URLs are validated against the trusted Klipy CDN origins - Linkified URLs strip trailing punctuation to prevent misleading destinations ### Search and Rate Limiting diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 73cd38ad..b8369bbc 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -114,6 +114,23 @@ Controls the Wazero WASM plugin runtime. Requires building with `-tags wazero`. | `plugins.cpu_budget_ms` | int | `100` | Maximum CPU time per plugin invocation (milliseconds) | | `plugins.http_allowlist` | string[] | `[]` | Host suffixes plugins may reach via the `host_http` capability (e.g. `["api.steampowered.com"]`). Empty = no outbound HTTP. | +### GIF Picker (`gif`) + +Powers the client's GIF picker. The server proxies the Klipy API so the key +never ships in the desktop bundle — the client only ever calls +`/api/v1/gif/*` on its own server. + +**Disabled by default.** With no `gif.api_key` set, `/api/v1/gif/*` returns +`503 GIF_DISABLED` and clients hide their GIF button. Nothing else changes. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `gif.api_key` | string | `""` | Klipy API key. Get one at [partner.klipy.com](https://partner.klipy.com). Empty = feature off. | + +> **Treat this as a credential.** Prefer `OWNCORD_GIF_API_KEY` (or a secrets +> manager) over writing it into `config.yaml`, and rotate it if it has ever +> been exposed to a client build. + ## Environment Variable Overrides Every config key can be overridden via environment variables using the prefix `OWNCORD_`. @@ -146,6 +163,7 @@ Every config key can be overridden via environment variables using the prefix `O | `OWNCORD_TELEMETRY_SERVICE_NAME` | `telemetry.service_name` | | `OWNCORD_PLUGINS_ENABLED` | `plugins.enabled` | | `OWNCORD_PLUGINS_DIRECTORY` | `plugins.directory` | +| `OWNCORD_GIF_API_KEY` | `gif.api_key` | ## Example config.yaml @@ -214,6 +232,11 @@ plugins: max_memory_mb: 64 cpu_budget_ms: 100 http_allowlist: [] # host suffixes plugins may reach, e.g. ["api.steampowered.com"] + +# GIF picker (server-side Klipy proxy). Empty key = feature off. +# Prefer OWNCORD_GIF_API_KEY over storing the key in this file. +gif: + api_key: "" ``` ## See Also