mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user