284 lines
6.9 KiB
TypeScript
284 lines
6.9 KiB
TypeScript
import { Router } from 'express';
|
|
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
|
|
|
|
const router = Router();
|
|
const KLIPY_API_BASE_URL = 'https://api.klipy.com/api/v1';
|
|
const REQUEST_TIMEOUT_MS = 8000;
|
|
const DEFAULT_PAGE = 1;
|
|
const DEFAULT_PER_PAGE = 24;
|
|
const MAX_PER_PAGE = 50;
|
|
|
|
interface NormalizedMediaMeta {
|
|
url: string;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
interface NormalizedKlipyGif {
|
|
id: string;
|
|
slug: string;
|
|
title?: string;
|
|
url: string;
|
|
previewUrl: string;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface KlipyGifVariants {
|
|
md?: unknown;
|
|
sm?: unknown;
|
|
xs?: unknown;
|
|
hd?: unknown;
|
|
}
|
|
|
|
interface KlipyGifItem {
|
|
type?: unknown;
|
|
slug?: unknown;
|
|
id?: unknown;
|
|
title?: unknown;
|
|
file?: KlipyGifVariants;
|
|
}
|
|
|
|
interface KlipyApiResponse {
|
|
data?: {
|
|
data?: unknown;
|
|
has_next?: unknown;
|
|
};
|
|
}
|
|
|
|
interface ResolvedGifMedia {
|
|
previewMeta: NormalizedMediaMeta | null;
|
|
sourceMeta: NormalizedMediaMeta;
|
|
}
|
|
|
|
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
|
|
for (const value of values) {
|
|
if (value != null)
|
|
return value;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function sanitizeString(value: unknown): string | undefined {
|
|
if (typeof value !== 'string')
|
|
return undefined;
|
|
|
|
const trimmed = value.trim();
|
|
|
|
return trimmed || undefined;
|
|
}
|
|
|
|
function toPositiveNumber(value: unknown): number | undefined {
|
|
const parsed = Number(value);
|
|
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
}
|
|
|
|
function clampPositiveInt(value: unknown, fallback: number, max = Number.MAX_SAFE_INTEGER): number {
|
|
const parsed = Number(value);
|
|
|
|
if (!Number.isFinite(parsed) || parsed < 1)
|
|
return fallback;
|
|
|
|
return Math.min(Math.floor(parsed), max);
|
|
}
|
|
|
|
function normalizeMediaMeta(candidate: unknown): NormalizedMediaMeta | null {
|
|
if (!candidate)
|
|
return null;
|
|
|
|
if (typeof candidate === 'string') {
|
|
return { url: candidate };
|
|
}
|
|
|
|
if (typeof candidate === 'object' && candidate !== null) {
|
|
const url = sanitizeString((candidate as { url?: unknown }).url);
|
|
|
|
if (!url)
|
|
return null;
|
|
|
|
return {
|
|
url,
|
|
width: toPositiveNumber((candidate as { width?: unknown }).width),
|
|
height: toPositiveNumber((candidate as { height?: unknown }).height)
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function pickGifMeta(sizeVariant: unknown): NormalizedMediaMeta | null {
|
|
const candidate = sizeVariant as {
|
|
gif?: unknown;
|
|
webp?: unknown;
|
|
} | undefined;
|
|
|
|
return normalizeMediaMeta(candidate?.gif) ?? normalizeMediaMeta(candidate?.webp);
|
|
}
|
|
|
|
function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext: boolean } {
|
|
if (typeof payload !== 'object' || payload === null) {
|
|
return {
|
|
items: [],
|
|
hasNext: false
|
|
};
|
|
}
|
|
|
|
const response = payload as KlipyApiResponse;
|
|
const items = Array.isArray(response.data?.data) ? response.data.data : [];
|
|
|
|
return {
|
|
items,
|
|
hasNext: response.data?.has_next === true
|
|
};
|
|
}
|
|
|
|
function resolveGifMedia(file?: KlipyGifVariants): ResolvedGifMedia | null {
|
|
const previewVariant = pickFirst(file?.md, file?.sm, file?.xs, file?.hd);
|
|
const sourceVariant = pickFirst(file?.hd, file?.md, file?.sm, file?.xs);
|
|
const previewMeta = pickGifMeta(previewVariant);
|
|
const sourceMeta = pickGifMeta(sourceVariant) ?? previewMeta;
|
|
|
|
if (!sourceMeta?.url)
|
|
return null;
|
|
|
|
return {
|
|
previewMeta,
|
|
sourceMeta
|
|
};
|
|
}
|
|
|
|
function resolveGifSlug(gifItem: KlipyGifItem): string | undefined {
|
|
return sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
|
|
}
|
|
|
|
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
|
|
if (!item || typeof item !== 'object')
|
|
return null;
|
|
|
|
const gifItem = item as KlipyGifItem;
|
|
const resolvedMedia = resolveGifMedia(gifItem.file);
|
|
const slug = resolveGifSlug(gifItem);
|
|
|
|
if (gifItem.type === 'ad')
|
|
return null;
|
|
|
|
if (!slug || !resolvedMedia)
|
|
return null;
|
|
|
|
const { previewMeta, sourceMeta } = resolvedMedia;
|
|
|
|
return {
|
|
id: slug,
|
|
slug,
|
|
title: sanitizeString(gifItem.title),
|
|
url: sourceMeta.url,
|
|
previewUrl: previewMeta?.url ?? sourceMeta.url,
|
|
width: sourceMeta.width ?? previewMeta?.width ?? 0,
|
|
height: sourceMeta.height ?? previewMeta?.height ?? 0
|
|
};
|
|
}
|
|
|
|
function extractErrorMessage(payload: unknown): string | null {
|
|
if (!payload)
|
|
return null;
|
|
|
|
if (typeof payload === 'string')
|
|
return payload.slice(0, 240);
|
|
|
|
if (typeof payload === 'object' && payload !== null) {
|
|
const data = payload as { error?: unknown; message?: unknown };
|
|
|
|
if (typeof data.error === 'string')
|
|
return data.error;
|
|
|
|
if (typeof data.message === 'string')
|
|
return data.message;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
router.get('/klipy/config', (_req, res) => {
|
|
res.json({ enabled: hasKlipyApiKey() });
|
|
});
|
|
|
|
router.get('/klipy/gifs', async (req, res) => {
|
|
if (!hasKlipyApiKey()) {
|
|
return res.status(503).json({ error: 'KLIPY is not configured on this server.' });
|
|
}
|
|
|
|
try {
|
|
const query = sanitizeString(req.query.q) ?? '';
|
|
const page = clampPositiveInt(req.query.page, DEFAULT_PAGE);
|
|
const perPage = clampPositiveInt(req.query.per_page, DEFAULT_PER_PAGE, MAX_PER_PAGE);
|
|
const customerId = sanitizeString(req.query.customer_id);
|
|
const locale = sanitizeString(req.query.locale);
|
|
const params = new URLSearchParams({
|
|
page: String(page),
|
|
per_page: String(perPage)
|
|
});
|
|
|
|
if (query)
|
|
params.set('q', query);
|
|
|
|
if (customerId)
|
|
params.set('customer_id', customerId);
|
|
|
|
if (locale)
|
|
params.set('locale', locale);
|
|
|
|
const endpoint = query ? 'search' : 'trending';
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
const response = await fetch(
|
|
`${KLIPY_API_BASE_URL}/${encodeURIComponent(getKlipyApiKey())}/gifs/${endpoint}?${params.toString()}`,
|
|
{
|
|
headers: { accept: 'application/json' },
|
|
signal: controller.signal
|
|
}
|
|
);
|
|
|
|
clearTimeout(timeout);
|
|
|
|
const text = await response.text();
|
|
|
|
let payload: unknown = null;
|
|
|
|
if (text) {
|
|
try {
|
|
payload = JSON.parse(text) as unknown;
|
|
} catch {
|
|
payload = text;
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
return res.status(response.status).json({
|
|
error: extractErrorMessage(payload) || 'Failed to fetch GIFs from KLIPY.'
|
|
});
|
|
}
|
|
|
|
const { items: rawItems, hasNext } = extractKlipyResponseData(payload);
|
|
const results = rawItems
|
|
.map((item: unknown) => normalizeGifItem(item))
|
|
.filter((item: NormalizedKlipyGif | null): item is NormalizedKlipyGif => !!item);
|
|
|
|
res.json({
|
|
enabled: true,
|
|
results,
|
|
hasNext
|
|
});
|
|
} catch (error) {
|
|
if ((error as { name?: string })?.name === 'AbortError') {
|
|
return res.status(504).json({ error: 'KLIPY request timed out.' });
|
|
}
|
|
|
|
console.error('KLIPY GIF route error:', error);
|
|
res.status(502).json({ error: 'Failed to fetch GIFs from KLIPY.' });
|
|
}
|
|
});
|
|
|
|
export default router;
|