diff --git a/.changeset/configurable-gif-provider.md b/.changeset/configurable-gif-provider.md new file mode 100644 index 000000000..624efe76e --- /dev/null +++ b/.changeset/configurable-gif-provider.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add a configurable gif provider (Tenor, Giphy or Klipy), selectable in settings, and send picked gifs as uploaded images again diff --git a/config.json b/config.json index 1260284a0..7b997a09f 100644 --- a/config.json +++ b/config.json @@ -46,6 +46,9 @@ }, "gifs": { - "klipyApiKey": "IfeIBlDMvq0av2BcKPDuxwRqbnYRbS90yNqFHEkK2Ja207tkR5nssh3NIlJRCr76" + "provider": "tenor", + "tenorApiKey": "AIzaSyCZt6SSh5VgVPzD9fhyzG1DprdPRhtoaR4", + "klipyApiKey": "pmpZyPifwSulBfELHCbpaOllUfgsjqt9yeImc2XWIcHSWjnAUBw9oueRf4kD5r25", + "giphyApiKey": "Gc7131jiJuvI7IdN0HZ1D7nh0ow5BU6g" } } diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 12671e418..1817d6b6a 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -64,7 +64,7 @@ import { useGifSearch } from './useGifSearch'; import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; import * as css from './components/styles.css'; import { useMobileSheetClose } from '$components/MobileSwipeDownModal'; -import { isAllowedKlipyMediaUrl } from '$utils/externalGif'; +import { isAllowedGifMediaUrl } from '$utils/gifProviders'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; @@ -176,8 +176,8 @@ const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean) const previewUrl = gif.preview_url; const gifUrl = - (previewUrl && isAllowedKlipyMediaUrl(previewUrl) ? previewUrl : undefined) ?? - (isAllowedKlipyMediaUrl(gif.mediaUrl) + (previewUrl && isAllowedGifMediaUrl(previewUrl) ? previewUrl : undefined) ?? + (isAllowedGifMediaUrl(gif.mediaUrl) ? gif.mediaUrl : gif.mediaUrl.startsWith('mxc://') ? (mxcUrlToHttp(mx, gif.mediaUrl, useAuthentication) ?? '') diff --git a/src/app/components/emoji-board/components/SearchInput.tsx b/src/app/components/emoji-board/components/SearchInput.tsx index bcf072dc7..6eb79548e 100644 --- a/src/app/components/emoji-board/components/SearchInput.tsx +++ b/src/app/components/emoji-board/components/SearchInput.tsx @@ -3,6 +3,10 @@ import { useRef } from 'react'; import { Input, Chip, Text } from 'folds'; import { isMobileOrTablet } from '$utils/platform'; import { ArrowRight, sizedIcon, MagnifyingGlass } from '$components/icons/phosphor'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { getGifProvider } from '$utils/gifProviders'; import { EmojiBoardTab } from '../types'; type SearchInputProps = { @@ -22,6 +26,9 @@ export function SearchInput({ tab, }: SearchInputProps) { const inputRef = useRef(null); + const clientConfig = useClientConfig(); + const [gifProviderSetting] = useSetting(settingsAtom, 'gifProvider'); + const gifProvider = getGifProvider(clientConfig.gifs, gifProviderSetting); const handleReact = () => { const textEmoji = inputRef.current?.value.trim(); @@ -36,7 +43,7 @@ export function SearchInput({ size="400" placeholder={ tab === EmojiBoardTab.Gif - ? 'Search KLIPY' + ? `Search ${gifProvider.label}` : allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search' diff --git a/src/app/components/emoji-board/useGifSearch.test.tsx b/src/app/components/emoji-board/useGifSearch.test.tsx index cd4508745..f59f1929a 100644 --- a/src/app/components/emoji-board/useGifSearch.test.tsx +++ b/src/app/components/emoji-board/useGifSearch.test.tsx @@ -8,7 +8,7 @@ const { fetchMock } = vi.hoisted(() => ({ vi.mock('$utils/fetch', () => ({ fetch: fetchMock })); vi.mock('$hooks/useClientConfig', () => ({ - useClientConfig: () => ({ gifs: { klipyApiKey: 'test-key' } }), + useClientConfig: () => ({ gifs: { provider: 'klipy', klipyApiKey: 'test-key' } }), })); type Deferred = { diff --git a/src/app/components/emoji-board/useGifSearch.ts b/src/app/components/emoji-board/useGifSearch.ts index 3eb4ae1d7..d1d9ae020 100644 --- a/src/app/components/emoji-board/useGifSearch.ts +++ b/src/app/components/emoji-board/useGifSearch.ts @@ -2,55 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AsyncSearchHandler } from '$utils/AsyncSearch'; import { fetch } from '$utils/fetch'; import { useClientConfig } from '$hooks/useClientConfig'; +import { getGifProvider } from '$utils/gifProviders'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; import type { GifData } from './types'; -const SIZE_LIMIT = 3 * 1024 * 1024; - -type KlipyFile = { - url?: string; - width?: number; - height?: number; - size?: number; -}; - -/** Klipy serves each size as a bag of encodings; we only ever want the gif. */ -type KlipyFormat = { gif?: KlipyFile }; - -type KlipyResult = { - id?: string | number; - slug?: string; - title?: string; - file?: Partial>; -}; - -type KlipySearchResponse = { data?: { data?: KlipyResult[] } }; - -const parseKlipyResult = (klipyResult: KlipyResult): GifData => { - const formats = klipyResult.file ?? {}; - const preview = formats.xs?.gif ?? formats.sm?.gif ?? formats.md?.gif; - - // Full resolution, dropped to medium when it would be too large to send. - let fullRes = formats.hd?.gif; - if (fullRes?.size && fullRes.size > SIZE_LIMIT && formats.md?.gif) { - fullRes = formats.md.gif; - } - fullRes ??= formats.md?.gif ?? preview; - - return { - id: klipyResult.id === undefined ? '' : String(klipyResult.id), - title: klipyResult.title || 'GIF', - shareUrl: klipyResult.slug - ? `https://klipy.com/gifs/${encodeURIComponent(klipyResult.slug)}` - : (fullRes?.url ?? ''), - mediaUrl: fullRes?.url ?? '', - preview_url: preview?.url ?? fullRes?.url ?? '', - width: fullRes?.width ?? preview?.width ?? 0, - height: fullRes?.height ?? preview?.height ?? 0, - size: fullRes?.size ?? preview?.size ?? 0, - mimetype: 'image/gif', - }; -}; - export function useGifSearch( favoriteGifs: GifData[], showGifPicker: boolean, @@ -60,7 +16,9 @@ export function useGifSearch( const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const clientConfig = useClientConfig(); - const klipyApiKey = clientConfig.gifs?.klipyApiKey ?? ''; + const [gifProvider] = useSetting(settingsAtom, 'gifProvider'); + const provider = getGifProvider(clientConfig.gifs, gifProvider); + const apiKey = provider.getApiKey(clientConfig.gifs ?? {}) ?? ''; const requestGenerationRef = useRef(0); const abortControllerRef = useRef(undefined); const mountedRef = useRef(true); @@ -96,19 +54,14 @@ export function useGifSearch( gifSearch(trimmedQuery); try { - const url = new URL('https://api.klipy.com'); - url.pathname = `/api/v1/${klipyApiKey}/gifs/search`; - url.searchParams.set('q', trimmedQuery); - url.searchParams.set('per_page', '50'); // TODO: infinite scroll? - - const response = await fetch(url.toString(), { signal: controller.signal }); + const url = provider.buildSearchUrl(apiKey, trimmedQuery); + const response = await fetch(url, { signal: controller.signal }); if (response.status === 200) { - const data = (await response.json()) as KlipySearchResponse; - const results = data.data?.data; + const results = provider.parseResults(await response.json()); if (generation === requestGenerationRef.current && mountedRef.current) { - setSearchResults(results ? results.map(parseKlipyResult) : []); + setSearchResults(results); } } else { throw new Error(`HTTP ${response.status}`); @@ -125,7 +78,7 @@ export function useGifSearch( } } }, - [cancelRequest, klipyApiKey, gifSearch] + [cancelRequest, provider, apiKey, gifSearch] ); useEffect(() => { diff --git a/src/app/features/room/RoomInput.test.tsx b/src/app/features/room/RoomInput.test.tsx index c46490859..37e26e2b3 100644 --- a/src/app/features/room/RoomInput.test.tsx +++ b/src/app/features/room/RoomInput.test.tsx @@ -255,16 +255,11 @@ vi.mock('$components/upload-card', () => ({ })); vi.mock('./msgContent', async (importOriginal) => ({ ...(await importOriginal()), - getGifMsgContent: (gif: { title: string }) => ({ - msgtype: 'm.text', - body: gif.title, - 'pet.plz.gif': { - v: 1, - provider: 'klipy', - media_url: 'https://static.klipy.com/ii/gif.gif', - w: 320, - h: 240, - }, + getGifMsgContent: (_mx: unknown, gif: { title: string }) => ({ + msgtype: 'm.image', + body: `${gif.title}.gif`, + url: 'mxc://server/gif', + info: { w: 320, h: 240, mimetype: 'image/gif' }, }), })); vi.mock('$components/attachment-sheet/AttachmentSheet', () => ({ AttachmentSheet: () => null })); @@ -280,8 +275,8 @@ vi.mock('$components/emoji-board', () => ({ onGifSelect({ id: 'gif-id', title: 'gif', - shareUrl: 'https://klipy.com/gif/gif-id', - mediaUrl: 'https://static.klipy.com/ii/gif.gif', + shareUrl: 'https://tenor.com/view/gif-id', + mediaUrl: 'https://media.tenor.com/gif-id/gif.gif', width: 320, height: 240, mimetype: 'image/gif', diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 04a375c95..a8b89f9e1 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -185,7 +185,6 @@ import { AttachmentContent } from '$components/attachment-sheet/AttachmentConten import { MobileSwipeDownModal } from '$components/MobileSwipeDownModal'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; -import { getKlipyGifBlurhash } from '$utils/klipy'; import { getAudioMsgContent, getFileMsgContent, @@ -1901,8 +1900,10 @@ export const RoomInput = forwardRef( const submission = takeSubmission({ clearEditor: false }); return composerControllerRef.current?.enqueue(async (isLive) => { try { - const blurhash = gif.blurhash ?? (await getKlipyGifBlurhash(gif)); - const content = getGifMsgContent(blurhash ? { ...gif, blurhash } : gif, spoiler); + const content = await getGifMsgContent(mx, gif, { + encrypt: room.hasEncryptionStateEvent(), + spoiler, + }); if (!content) throw new Error('Unsendable GIF content'); const sent = await handleSendContents({ contents: [content], submission, isLive }); diff --git a/src/app/features/room/msgContent.test.ts b/src/app/features/room/msgContent.test.ts index ad12c57fe..f77175439 100644 --- a/src/app/features/room/msgContent.test.ts +++ b/src/app/features/room/msgContent.test.ts @@ -1,9 +1,30 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as MatrixUtils from '$utils/matrix'; import { MsgType, type MatrixClient } from '$types/matrix-sdk'; import type { TUploadItem } from '$state/room/roomInputDrafts'; import { TGS_MIMETYPE } from '$utils/mimeTypes'; +import { MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME } from '$unstable/prefixes'; import { getGalleryItemContent, getGifMsgContent, getImageMsgContent } from './msgContent'; +const { fetchMock, uploadMock, encryptFileMock } = vi.hoisted(() => ({ + fetchMock: vi.fn<(url: string) => Promise>(), + uploadMock: vi.fn<(mx: unknown, file: File) => Promise<{ content_uri?: string }>>(), + encryptFileMock: vi.fn<(file: File) => Promise<{ file: File; encInfo: object }>>(), +})); + +vi.mock('$utils/fetch', () => ({ fetch: fetchMock })); +vi.mock('$utils/matrix', async (importOriginal) => ({ + ...(await importOriginal()), + uploadContentToServer: uploadMock, + encryptFile: encryptFileMock, +})); + +beforeEach(() => { + fetchMock.mockReset(); + uploadMock.mockReset(); + encryptFileMock.mockReset(); +}); + vi.mock('$utils/dom', () => ({ getImageFileUrl: vi.fn<(file: File | Blob) => string>(() => 'blob:test'), loadImageElement: vi @@ -56,54 +77,75 @@ describe('TGS message content', () => { }); }); -describe('KLIPY message content', () => { - it('sends a direct-link text event without fetching media', () => { - expect( - getGifMsgContent({ - id: 'gif-id', - title: 'Reaction', - shareUrl: 'https://klipy.com/gif/gif-id', - mediaUrl: 'https://static.klipy.com/ii/reaction.gif', - width: 480, - height: 270, - mimetype: 'image/gif', - }) - ).toEqual({ - msgtype: MsgType.Text, - body: 'https://klipy.com/gif/gif-id', - 'pet.plz.gif': { - v: 1, - provider: 'klipy', - media_url: 'https://static.klipy.com/ii/reaction.gif', - w: 480, - h: 270, - mimetype: 'image/gif', - id: 'gif-id', - title: 'Reaction', - }, +describe('GIF message content', () => { + const searchResult = { + id: 'gif-id', + title: 'Reaction', + shareUrl: 'https://tenor.com/view/gif-id', + mediaUrl: 'https://media.tenor.com/gif-id/reaction.gif', + width: 480, + height: 270, + mimetype: 'image/gif', + }; + + it('uploads the gif and sends it as an image event', async () => { + fetchMock.mockResolvedValue(new Response('gif-bytes', { status: 200 })); + uploadMock.mockResolvedValue({ content_uri: 'mxc://server/uploaded' }); + + const content = await getGifMsgContent({} as MatrixClient, searchResult, { encrypt: false }); + + expect(fetchMock).toHaveBeenCalledWith(searchResult.mediaUrl); + expect(encryptFileMock).not.toHaveBeenCalled(); + expect(content).toEqual({ + msgtype: MsgType.Image, + body: 'Reaction.gif', + url: 'mxc://server/uploaded', + info: { w: 480, h: 270, mimetype: 'image/gif', size: 9 }, }); }); - it('keeps Matrix MXC favorites on the standard image path', () => { - expect( - getGifMsgContent({ - id: 'matrix-gif', - title: 'Favorite', - shareUrl: 'mxc://matrix.example/media-id', - mediaUrl: 'mxc://matrix.example/media-id', - width: 320, - height: 240, - mimetype: 'image/gif', - }) - ).toMatchObject({ + it('encrypts the upload for encrypted rooms', async () => { + fetchMock.mockResolvedValue(new Response('gif-bytes', { status: 200 })); + uploadMock.mockResolvedValue({ content_uri: 'mxc://server/encrypted' }); + encryptFileMock.mockImplementation(async (file: File) => ({ + file, + encInfo: { key: { k: 'secret' } }, + })); + + const content = await getGifMsgContent({} as MatrixClient, searchResult, { + encrypt: true, + spoiler: true, + }); + + expect(content?.url).toBeUndefined(); + expect(content?.file).toEqual({ key: { k: 'secret' }, url: 'mxc://server/encrypted' }); + expect(content?.[MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME]).toBe(true); + }); + + it('sends favorited homeserver gifs without re-uploading', async () => { + const content = await getGifMsgContent( + {} as MatrixClient, + { ...searchResult, mediaUrl: 'mxc://matrix.example/media-id' }, + { encrypt: false } + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(content).toMatchObject({ msgtype: MsgType.Image, - body: 'Favorite', + body: 'Reaction.gif', url: 'mxc://matrix.example/media-id', - info: { - w: 320, - h: 240, - mimetype: 'image/gif', - }, + info: { w: 480, h: 270, mimetype: 'image/gif' }, }); }); + + it('refuses media URLs outside the configured providers', async () => { + await expect( + getGifMsgContent( + {} as MatrixClient, + { ...searchResult, mediaUrl: 'https://media.tenor.com.attacker.example/a.gif' }, + { encrypt: false } + ) + ).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 2dd2f1e3d..27288034c 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -18,13 +18,14 @@ import { getVideoInfo, uploadContentToServer, } from '$utils/matrix'; -import { isImageMimeType } from '$utils/mimeTypes'; +import { isImageMimeType, mimeTypeToExt } from '$utils/mimeTypes'; import type { TUploadItem } from '$state/room/roomInputDrafts'; import type { GifData } from '$components/emoji-board/types'; import { encodeBlurHashAsync } from '$utils/blurHash'; import { scaleYDimension } from '$utils/common'; import { createLogger } from '$utils/debug'; -import { getKlipyGifMetadata } from '$utils/klipy'; +import { isAllowedGifMediaUrl } from '$utils/gifProviders'; +import { fetch } from '$utils/fetch'; import { MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME, MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME, @@ -269,30 +270,78 @@ export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => { return content; }; -export const getGifMsgContent = (gif: GifData, spoiler?: boolean): IContent | undefined => { - const metadata = getKlipyGifMetadata(gif); - if (!metadata) { - if (!gif.mediaUrl.startsWith('mxc://')) return undefined; +export const getGifMsgContent = async ( + mx: MatrixClient, + gif: GifData, + options: { encrypt: boolean; spoiler?: boolean } +): Promise => { + const mimetype = gif.mimetype ?? 'image/gif'; + const ext = mimeTypeToExt(mimetype); + const body = gif.title.endsWith(`.${ext}`) ? gif.title : `${gif.title}.${ext}`; + const spoiler = options.spoiler ? { [MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME]: true } : undefined; + + // Favorites saved from a sent message already live on a homeserver. + if (gif.mediaUrl.startsWith('mxc://')) { return { msgtype: MsgType.Image, - body: gif.title, + body, url: gif.mediaUrl, info: { w: gif.width, h: gif.height, - mimetype: gif.mimetype ?? 'image/gif', - ...(gif.size !== undefined ? { size: gif.size } : {}), + mimetype, + ...(gif.size ? { size: gif.size } : {}), }, - ...(spoiler ? { [MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME]: true } : {}), + ...spoiler, }; } - return { - msgtype: MsgType.Text, - body: gif.shareUrl || metadata.media_url, - 'pet.plz.gif': metadata, - ...(spoiler ? { [MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME]: true } : {}), + if (!isAllowedGifMediaUrl(gif.mediaUrl)) return undefined; + + const response = await fetch(gif.mediaUrl); + if (!response.ok) throw new Error(`Failed to fetch GIF: HTTP ${response.status}`); + const blob = await response.blob(); + const file = new File([blob], body, { type: mimetype }); + + const encData = options.encrypt ? await encryptFile(file) : undefined; + const uploadData = await uploadContentToServer(mx, encData?.file ?? file); + const mxc = uploadData?.content_uri; + if (!mxc) throw new Error('Failed when uploading GIF!'); + + const objectUrl = URL.createObjectURL(blob); + let imgEl: HTMLImageElement | undefined; + try { + imgEl = await loadImageElement(objectUrl); + } catch (e) { + log.warn('Failed to load GIF for blurhash, falling back to basic metadata:', e); + } finally { + URL.revokeObjectURL(objectUrl); + } + + const blurHash = imgEl + ? await encodeBlurHashAsync(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height)) + : undefined; + + const content: IContent = { + msgtype: MsgType.Image, + body, + info: { + w: imgEl?.width ?? gif.width, + h: imgEl?.height ?? gif.height, + mimetype, + size: blob.size, + ...(blurHash ? { [MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME]: blurHash } : {}), + }, + ...spoiler, }; + + if (encData?.encInfo) { + content.file = { ...encData.encInfo, url: mxc }; + } else { + content.url = mxc; + } + + return content; }; const swapMsgTypeToItemType = ( diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 0664464de..1e0ed551d 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -66,6 +66,8 @@ import { sanitizeDiagnosticsLogs } from '$utils/sentryScrubbers'; import { diagnosticCaptureActiveAtom } from '$state/debugLogger'; import { exportSettingsAsJson, importSettingsFromJson } from '$utils/settingsSync'; import { downloadJsonFile, saveFileToDevice } from '$utils/download'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { getGifProvider, getGifProviderOptions } from '$utils/gifProviders'; import { CallSoundSettings } from './CallSoundSettings'; type DateHintProps = { @@ -453,6 +455,9 @@ function Editor() { const [editorMicButton, setEditorMicButton] = useSetting(settingsAtom, 'editorMicButton'); const [editorEmojiButton, setEditorEmojiButton] = useSetting(settingsAtom, 'editorEmojiButton'); const [editorGifButton, setEditorGifButton] = useSetting(settingsAtom, 'editorGifButton'); + const gifs = useClientConfig().gifs; + const [gifProviderSetting] = useSetting(settingsAtom, 'gifProvider'); + const gifProvider = getGifProvider(gifs, gifProviderSetting); const [editorStickerButton, setEditorStickerButton] = useSetting( settingsAtom, 'editorStickerButton' @@ -528,10 +533,18 @@ function Editor() { + + } + /> + + ); +} + function SelectMessageLayout() { const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout'); @@ -1046,6 +1072,8 @@ function Embeds() { 'externalGifAutoLoadEncrypted' ); const [enableGifPicker, setEnableGifPicker] = useSetting(settingsAtom, 'enableGifPicker'); + const [gifPickerProvider] = useSetting(settingsAtom, 'gifProvider'); + const gifPickerHost = getGifProvider(useClientConfig().gifs, gifPickerProvider).searchHost; return ( Embeds @@ -1128,7 +1156,7 @@ function Embeds() { { + it('falls back to Tenor for a missing or unknown provider', () => { + expect(getGifProvider(undefined).id).toBe('tenor'); + expect(getGifProvider({ provider: 'giphy' }).id).toBe('giphy'); + expect(getGifProvider({ provider: 'nope' } as never).id).toBe('tenor'); + }); + + it('honors a manual override that has a key and ignores one that does not', () => { + const config = { provider: 'tenor' as const, tenorApiKey: 't', giphyApiKey: 'g' }; + expect(getGifProvider(config, 'giphy').id).toBe('giphy'); + expect(getGifProvider(config, 'klipy').id).toBe('tenor'); + expect(getGifProvider(config, 'default').id).toBe('tenor'); + }); + + it('marks providers without a key as unselectable', () => { + const options = getGifProviderOptions({ provider: 'giphy', giphyApiKey: 'g' }); + expect(options[0]).toEqual({ value: 'default', label: 'Client Default (Giphy)' }); + expect(options.find((option) => option.value === 'giphy')?.disabled).toBe(false); + expect(options.find((option) => option.value === 'tenor')).toMatchObject({ + label: 'Tenor (no API key)', + disabled: true, + }); + }); + + it('reads the key belonging to the selected provider', () => { + const config = { klipyApiKey: 'k', tenorApiKey: 't', giphyApiKey: 'g' }; + expect(GIF_PROVIDERS.klipy.getApiKey(config)).toBe('k'); + expect(GIF_PROVIDERS.tenor.getApiKey(config)).toBe('t'); + expect(GIF_PROVIDERS.giphy.getApiKey(config)).toBe('g'); + }); +}); + +describe('gif media URL allowlist', () => { + it('accepts each provider CDN', () => { + expect(isAllowedGifMediaUrl('https://static.klipy.com/ii/a.gif')).toBe(true); + expect(isAllowedGifMediaUrl('https://media.tenor.com/abc/a.gif')).toBe(true); + expect(isAllowedGifMediaUrl('https://media1.tenor.com/abc/a.gif')).toBe(true); + expect(isAllowedGifMediaUrl('https://media0.giphy.com/media/abc/giphy.gif')).toBe(true); + }); + + it('rejects lookalike hosts, ports, credentials and plain http', () => { + expect(isAllowedGifMediaUrl('https://media.tenor.com.attacker.example/a.gif')).toBe(false); + expect(isAllowedGifMediaUrl('https://media.tenor.com:8443/a.gif')).toBe(false); + expect(isAllowedGifMediaUrl('https://user:pass@media.giphy.com/a.gif')).toBe(false); + expect(isAllowedGifMediaUrl('http://media.tenor.com/a.gif')).toBe(false); + expect(isAllowedGifMediaUrl('not a url')).toBe(false); + }); +}); + +describe('search request building', () => { + it('sends the Tenor client key alongside the api key', () => { + const url = new URL(GIF_PROVIDERS.tenor.buildSearchUrl('tenor-key', 'happy cat')); + expect(url.origin + url.pathname).toBe('https://tenor.googleapis.com/v2/search'); + expect(url.searchParams.get('key')).toBe('tenor-key'); + expect(url.searchParams.get('client_key')).toBe('tenor_web'); + expect(url.searchParams.get('q')).toBe('happy cat'); + }); + + it('puts the Klipy key in the path and the Giphy key in the query', () => { + expect(GIF_PROVIDERS.klipy.buildSearchUrl('klipy-key', 'cat')).toContain( + '/api/v1/klipy-key/gifs/search' + ); + expect( + new URL(GIF_PROVIDERS.giphy.buildSearchUrl('giphy-key', 'cat')).searchParams.get('api_key') + ).toBe('giphy-key'); + }); +}); + +describe('search response parsing', () => { + it('parses Tenor results and prefers a sendable rendition', () => { + const [gif] = GIF_PROVIDERS.tenor.parseResults({ + results: [ + { + id: 'tenor-1', + content_description: 'a happy cat', + itemurl: 'https://tenor.com/view/tenor-1', + media_formats: { + gif: { + url: 'https://media.tenor.com/full.gif', + dims: [800, 600], + size: 8 * 1024 * 1024, + }, + mediumgif: { + url: 'https://media.tenor.com/medium.gif', + dims: [400, 300], + size: 900_000, + }, + tinygif: { url: 'https://media.tenor.com/tiny.gif', dims: [100, 75], size: 20_000 }, + }, + }, + ], + }); + + expect(gif).toEqual({ + id: 'tenor-1', + title: 'a happy cat', + shareUrl: 'https://tenor.com/view/tenor-1', + mediaUrl: 'https://media.tenor.com/medium.gif', + preview_url: 'https://media.tenor.com/tiny.gif', + width: 400, + height: 300, + size: 900_000, + mimetype: 'image/gif', + }); + }); + + it('parses Giphy string dimensions into numbers', () => { + const [gif] = GIF_PROVIDERS.giphy.parseResults({ + data: [ + { + id: 'giphy-1', + title: 'dancing', + url: 'https://giphy.com/gifs/giphy-1', + images: { + original: { + url: 'https://media0.giphy.com/original.gif', + width: '480', + height: '270', + size: '512000', + }, + fixed_width: { + url: 'https://media0.giphy.com/preview.gif', + width: '200', + height: '113', + size: '40000', + }, + }, + }, + ], + }); + + expect(gif).toMatchObject({ + id: 'giphy-1', + mediaUrl: 'https://media0.giphy.com/original.gif', + preview_url: 'https://media0.giphy.com/preview.gif', + width: 480, + height: 270, + size: 512_000, + }); + }); + + it('parses Klipy results and builds a share URL from the slug', () => { + const [gif] = GIF_PROVIDERS.klipy.parseResults({ + data: { + data: [ + { + id: 9_188_075_299_582_436, + title: 'Reaction', + slug: 'reaction-gif', + file: { + hd: { + gif: { + url: 'https://static.klipy.com/ii/hd.gif', + width: 800, + height: 600, + size: 400_000, + }, + }, + xs: { + gif: { + url: 'https://static.klipy.com/ii/xs.gif', + width: 80, + height: 60, + size: 5000, + }, + }, + }, + }, + ], + }, + }); + + expect(gif).toMatchObject({ + id: '9188075299582436', + shareUrl: 'https://klipy.com/gifs/reaction-gif', + mediaUrl: 'https://static.klipy.com/ii/hd.gif', + preview_url: 'https://static.klipy.com/ii/xs.gif', + width: 800, + }); + }); + + it('returns nothing for malformed payloads', () => { + expect(GIF_PROVIDERS.tenor.parseResults({})).toEqual([]); + expect(GIF_PROVIDERS.giphy.parseResults(null)).toEqual([]); + expect(GIF_PROVIDERS.klipy.parseResults({ data: {} })).toEqual([]); + }); +}); diff --git a/src/app/utils/gifProviders.ts b/src/app/utils/gifProviders.ts new file mode 100644 index 000000000..d85c0267a --- /dev/null +++ b/src/app/utils/gifProviders.ts @@ -0,0 +1,281 @@ +import type { GifData } from '$components/emoji-board/types'; + +export const GIF_PROVIDER_IDS = ['klipy', 'tenor', 'giphy'] as const; + +export type GifProviderId = (typeof GIF_PROVIDER_IDS)[number]; + +export type GifsConfig = { + provider?: GifProviderId; + klipyApiKey?: string; + tenorApiKey?: string; + giphyApiKey?: string; +}; + +export type GifProvider = { + id: GifProviderId; + label: string; + searchHost: string; + getApiKey: (config: GifsConfig) => string | undefined; + buildSearchUrl: (apiKey: string, query: string) => string; + parseResults: (payload: unknown) => GifData[]; + isMediaUrlAllowed: (url: URL) => boolean; +}; + +const RESULT_LIMIT = 50; // TODO: infinite scroll? + +const SIZE_LIMIT = 3 * 1024 * 1024; + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const toPositiveInt = (value: unknown): number | undefined => { + const parsed = typeof value === 'string' ? Number(value) : value; + return typeof parsed === 'number' && Number.isSafeInteger(parsed) && parsed > 0 + ? parsed + : undefined; +}; + +const isPlainHttpsUrl = (url: URL): boolean => + url.protocol === 'https:' && url.port === '' && url.username === '' && url.password === ''; + +type GifFile = { + url: string; + width?: number; + height?: number; + size?: number; +}; + +const toGifFile = ( + url: unknown, + width: unknown, + height: unknown, + size: unknown +): GifFile | undefined => + typeof url === 'string' && url + ? { + url, + width: toPositiveInt(width), + height: toPositiveInt(height), + size: toPositiveInt(size), + } + : undefined; + +/** Full resolution, dropped to a smaller rendition when it would be too large to send. */ +const pickFullRes = (candidates: (GifFile | undefined)[]): GifFile | undefined => { + const available = candidates.filter((file): file is GifFile => !!file); + return ( + available.find((file) => !file.size || file.size <= SIZE_LIMIT) ?? + available[available.length - 1] + ); +}; + +const toGifData = ( + id: string, + title: string, + shareUrl: string, + fullRes: GifFile | undefined, + preview: GifFile | undefined +): GifData => ({ + id, + title: title || 'GIF', + shareUrl: shareUrl || fullRes?.url || '', + mediaUrl: fullRes?.url ?? '', + preview_url: preview?.url ?? fullRes?.url ?? '', + width: fullRes?.width ?? preview?.width ?? 0, + height: fullRes?.height ?? preview?.height ?? 0, + size: fullRes?.size ?? preview?.size ?? 0, + mimetype: 'image/gif', +}); + +/** Klipy serves each size as a bag of encodings; we only ever want the gif. */ +const parseKlipyFormat = (format: unknown): GifFile | undefined => { + if (!isRecord(format) || !isRecord(format.gif)) return undefined; + const { gif } = format; + return toGifFile(gif.url, gif.width, gif.height, gif.size); +}; + +const parseKlipyResults = (payload: unknown): GifData[] => { + const outer = isRecord(payload) ? payload.data : undefined; + const results = isRecord(outer) ? outer.data : undefined; + if (!Array.isArray(results)) return []; + + return results.filter(isRecord).map((result) => { + const formats = isRecord(result.file) ? result.file : {}; + const preview = + parseKlipyFormat(formats.xs) ?? parseKlipyFormat(formats.sm) ?? parseKlipyFormat(formats.md); + const fullRes = pickFullRes([ + parseKlipyFormat(formats.hd), + parseKlipyFormat(formats.md), + preview, + ]); + const id = + typeof result.id === 'string' || typeof result.id === 'number' ? String(result.id) : ''; + const shareUrl = + typeof result.slug === 'string' && result.slug + ? `https://klipy.com/gifs/${encodeURIComponent(result.slug)}` + : ''; + + return toGifData( + id, + typeof result.title === 'string' ? result.title : '', + shareUrl, + fullRes, + preview + ); + }); +}; + +const parseTenorFormat = (formats: Record, key: string): GifFile | undefined => { + const format = formats[key]; + if (!isRecord(format)) return undefined; + const dims = Array.isArray(format.dims) ? format.dims : []; + return toGifFile(format.url, dims[0], dims[1], format.size); +}; + +const parseTenorResults = (payload: unknown): GifData[] => { + const results = isRecord(payload) ? payload.results : undefined; + if (!Array.isArray(results)) return []; + + return results.filter(isRecord).map((result) => { + const formats = isRecord(result.media_formats) ? result.media_formats : {}; + const preview = parseTenorFormat(formats, 'tinygif') ?? parseTenorFormat(formats, 'nanogif'); + const fullRes = pickFullRes([ + parseTenorFormat(formats, 'gif'), + parseTenorFormat(formats, 'mediumgif'), + preview, + ]); + const title = + (typeof result.content_description === 'string' && result.content_description) || + (typeof result.title === 'string' ? result.title : ''); + + return toGifData( + typeof result.id === 'string' ? result.id : '', + title, + typeof result.itemurl === 'string' ? result.itemurl : '', + fullRes, + preview + ); + }); +}; + +const parseGiphyRendition = (images: Record, key: string): GifFile | undefined => { + const rendition = images[key]; + if (!isRecord(rendition)) return undefined; + return toGifFile(rendition.url, rendition.width, rendition.height, rendition.size); +}; + +const parseGiphyResults = (payload: unknown): GifData[] => { + const results = isRecord(payload) ? payload.data : undefined; + if (!Array.isArray(results)) return []; + + return results.filter(isRecord).map((result) => { + const images = isRecord(result.images) ? result.images : {}; + const preview = + parseGiphyRendition(images, 'fixed_width') ?? parseGiphyRendition(images, 'preview_gif'); + const fullRes = pickFullRes([ + parseGiphyRendition(images, 'original'), + parseGiphyRendition(images, 'downsized'), + preview, + ]); + + return toGifData( + typeof result.id === 'string' ? result.id : '', + typeof result.title === 'string' ? result.title : '', + typeof result.url === 'string' ? result.url : '', + fullRes, + preview + ); + }); +}; + +export const GIF_PROVIDERS: Record = { + klipy: { + id: 'klipy', + label: 'Klipy', + searchHost: 'klipy.com', + getApiKey: (config) => config.klipyApiKey, + buildSearchUrl: (apiKey, query) => { + const url = new URL(`https://api.klipy.com/api/v1/${encodeURIComponent(apiKey)}/gifs/search`); + url.searchParams.set('q', query); + url.searchParams.set('per_page', String(RESULT_LIMIT)); + return url.toString(); + }, + parseResults: parseKlipyResults, + isMediaUrlAllowed: (url) => + isPlainHttpsUrl(url) && url.hostname === 'static.klipy.com' && /^\/ii\/.+/.test(url.pathname), + }, + tenor: { + id: 'tenor', + label: 'Tenor', + searchHost: 'tenor.googleapis.com', + getApiKey: (config) => config.tenorApiKey, + buildSearchUrl: (apiKey, query) => { + const url = new URL('https://tenor.googleapis.com/v2/search'); + url.searchParams.set('key', apiKey); + url.searchParams.set('client_key', 'tenor_web'); + url.searchParams.set('q', query); + url.searchParams.set('limit', String(RESULT_LIMIT)); + url.searchParams.set('media_filter', 'gif,mediumgif,tinygif,nanogif'); + return url.toString(); + }, + parseResults: parseTenorResults, + isMediaUrlAllowed: (url) => + isPlainHttpsUrl(url) && /^(?:c|media\d*)\.tenor\.com$/.test(url.hostname), + }, + giphy: { + id: 'giphy', + label: 'Giphy', + searchHost: 'giphy.com', + getApiKey: (config) => config.giphyApiKey, + buildSearchUrl: (apiKey, query) => { + const url = new URL('https://api.giphy.com/v1/gifs/search'); + url.searchParams.set('api_key', apiKey); + url.searchParams.set('q', query); + url.searchParams.set('limit', String(RESULT_LIMIT)); + return url.toString(); + }, + parseResults: parseGiphyResults, + isMediaUrlAllowed: (url) => + isPlainHttpsUrl(url) && /^(?:i|media\d*)\.giphy\.com$/.test(url.hostname), + }, +}; + +export const DEFAULT_GIF_PROVIDER_ID: GifProviderId = 'tenor'; + +export type GifProviderSetting = GifProviderId | 'default'; + +const configuredProvider = (config: GifsConfig | undefined): GifProvider => { + const id = config?.provider; + return (id && GIF_PROVIDERS[id]) || GIF_PROVIDERS[DEFAULT_GIF_PROVIDER_ID]; +}; + +export const getGifProvider = ( + config: GifsConfig | undefined, + override: GifProviderSetting = 'default' +): GifProvider => { + const picked = override === 'default' ? undefined : GIF_PROVIDERS[override]; + // A provider without a key cannot search, so keep the configured one. + return picked?.getApiKey(config ?? {}) ? picked : configuredProvider(config); +}; + +export const getGifProviderOptions = ( + config: GifsConfig | undefined +): { value: GifProviderSetting; label: string; disabled?: boolean }[] => [ + { value: 'default', label: `Client Default (${configuredProvider(config).label})` }, + ...GIF_PROVIDER_IDS.map((id) => ({ + value: id, + label: GIF_PROVIDERS[id].getApiKey(config ?? {}) + ? GIF_PROVIDERS[id].label + : `${GIF_PROVIDERS[id].label} (no API key)`, + disabled: !GIF_PROVIDERS[id].getApiKey(config ?? {}), + })), +]; + +export const isAllowedGifMediaUrl = (value: string | URL): boolean => { + try { + const url = typeof value === 'string' ? new URL(value) : value; + return Object.values(GIF_PROVIDERS).some((provider) => provider.isMediaUrlAllowed(url)); + } catch { + return false; + } +}; diff --git a/src/app/utils/klipy.test.ts b/src/app/utils/klipy.test.ts index 518027009..ca8c3e1ef 100644 --- a/src/app/utils/klipy.test.ts +++ b/src/app/utils/klipy.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { getKlipyGifMetadata, isAllowedKlipyMediaUrl, parseLegacyKlipyGif } from './klipy'; +import { isAllowedKlipyMediaUrl, parseLegacyKlipyGif } from './klipy'; -describe('Klipy external GIF metadata', () => { +describe('legacy Klipy external GIF rendering', () => { const gifUrl = 'https://static.klipy.com/ii/example.gif'; it('accepts only approved media URLs', () => { @@ -13,39 +13,6 @@ describe('Klipy external GIF metadata', () => { expect(isAllowedKlipyMediaUrl('https://user:pass@static.klipy.com/ii/a.gif')).toBe(false); }); - it('builds direct-link metadata without an MXC', () => { - expect( - getKlipyGifMetadata({ - id: 'id', - title: 'Reaction', - mediaUrl: gifUrl, - shareUrl: 'https://klipy.com/gif/id', - width: 480, - height: 270, - mimetype: 'image/gif', - }) - ).toMatchObject({ - v: 1, - provider: 'klipy', - media_url: gifUrl, - w: 480, - h: 270, - }); - }); - - it('serializes large provider IDs as strings', () => { - const metadata = getKlipyGifMetadata({ - id: 9188075299582436, - title: 'Reaction', - mediaUrl: gifUrl, - shareUrl: gifUrl, - width: 480, - height: 270, - } as unknown as Parameters[0]); - - expect(metadata?.id).toBe('9188075299582436'); - }); - it('decodes historical Soliditas MXC events to external GIF metadata', () => { expect( parseLegacyKlipyGif({ @@ -62,17 +29,4 @@ describe('Klipy external GIF metadata', () => { title: 'Reaction', }); }); - - it('shares the inbound dimension limit on outgoing metadata', () => { - expect( - getKlipyGifMetadata({ - id: 'id', - title: 'Too large', - mediaUrl: gifUrl, - shareUrl: gifUrl, - width: 8193, - height: 270, - }) - ).toBeUndefined(); - }); }); diff --git a/src/app/utils/klipy.ts b/src/app/utils/klipy.ts index 1e123d913..27a7679f7 100644 --- a/src/app/utils/klipy.ts +++ b/src/app/utils/klipy.ts @@ -1,8 +1,5 @@ -import type { GifData } from '$components/emoji-board/types'; import type { ExternalGifContent } from './externalGif'; import { isAllowedKlipyMediaUrl, isValidExternalGifDimension } from './externalGif'; -import { encodeBlurHashAsync } from './blurHash'; -import { loadImageElement } from './dom'; import { MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME } from '$unstable/prefixes'; const LEGACY_MEDIA_PREFIX = 'klipy_'; @@ -82,56 +79,4 @@ export function parseLegacyKlipyGif(content: unknown): ExternalGifContent | unde }; } -export async function getKlipyGifBlurhash(gif: GifData): Promise { - const source = - gif.preview_url && isAllowedKlipyMediaUrl(gif.preview_url) ? gif.preview_url : gif.mediaUrl; - if ( - !isAllowedKlipyMediaUrl(source) || - !isValidExternalGifDimension(gif.width) || - !isValidExternalGifDimension(gif.height) - ) { - return undefined; - } - - try { - const image = await loadImageElement(source, 'anonymous'); - const width = 32; - const height = Math.max(1, Math.min(32, Math.round((width * gif.height) / gif.width))); - return await encodeBlurHashAsync(image, width, height); - } catch { - return undefined; - } -} - -export function getKlipyGifMetadata(gif: GifData): ExternalGifContent | undefined { - const mediaUrl = gif.mediaUrl; - const width = gif.width; - const height = gif.height; - const idValue: unknown = gif.id; - const id = - typeof idValue === 'string' || typeof idValue === 'number' ? String(idValue) : undefined; - if ( - !isAllowedKlipyMediaUrl(mediaUrl) || - !isValidExternalGifDimension(width) || - !isValidExternalGifDimension(height) - ) { - return undefined; - } - - return { - v: 1, - provider: 'klipy', - media_url: mediaUrl, - w: width, - h: height, - ...(gif.mimetype ? { mimetype: gif.mimetype } : {}), - ...(typeof gif.size === 'number' && Number.isSafeInteger(gif.size) && gif.size > 0 - ? { size: gif.size } - : {}), - ...(id ? { id } : {}), - ...(gif.title ? { title: gif.title } : {}), - ...(gif.blurhash ? { blurhash: gif.blurhash } : {}), - }; -} - export { isAllowedKlipyMediaUrl };