Merge pull request #1198 from J3vb/feat/gif-server-proxy

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