diff --git a/src/klipy_proxy.js b/src/klipy_proxy.js new file mode 100644 index 0000000..e4c4b6b --- /dev/null +++ b/src/klipy_proxy.js @@ -0,0 +1,214 @@ +const { json_response, error_response, invalid_request } = require('./server_helpers') +const { get_user_uuid } = require('./user_management') + +const KLIPY_GIF_SEARCH_DEFAULT_FORMAT_FILTER = 'gif,webp,jpg,mp4,webm' +const KLIPY_FEATURED_DEFAULT_MEDIA_FILTER = 'gif,tinygif,mp4,tinymp4,webm,tinywebm' +const KLIPY_ALLOWED_CONTENT_FILTERS = new Set(['off', 'low', 'medium', 'high']) +const KLIPY_REQUEST_TIMEOUT_MS = 10000 + +function parse_integer_query_param(value, fallback) { + if (value === undefined || value === null || value === '') { + return fallback + } + + const string_value = String(value) + if (!/^[+-]?\d+$/.test(string_value)) { + return null + } + + const parsed = Number.parseInt(string_value, 10) + return Number.isNaN(parsed) ? null : parsed +} + +function append_if_present(search_params, key, value) { + if (value === undefined || value === null || value === '') { + return + } + + search_params.append(key, String(value)) +} + +function get_klipy_base_url() { + return process.env.KLIPY_API_BASE_URL || 'https://api.klipy.com' +} + +function get_klipy_app_key(res) { + const app_key = process.env.KLIPY_APP_KEY + if (!app_key) { + error_response(res, 'KLIPY_APP_KEY is not configured') + return null + } + + return app_key +} + +async function proxy_klipy_request(res, upstream_url) { + const controller = new AbortController() + const timeout_id = setTimeout(() => { + controller.abort() + }, KLIPY_REQUEST_TIMEOUT_MS) + + try { + const upstream_response = await fetch(upstream_url, { + method: 'GET', + headers: { + 'Accept': 'application/json' + }, + signal: controller.signal + }) + + clearTimeout(timeout_id) + + const response_text = await upstream_response.text() + let response_payload = null + + if (response_text.length > 0) { + try { + response_payload = JSON.parse(response_text) + } + catch (parse_error) { + error_response(res, 'KLIPY returned a non-JSON response', 502) + return + } + } + + if (!upstream_response.ok) { + json_response(res, response_payload || { error: 'KLIPY request failed' }, upstream_response.status) + return + } + + json_response(res, response_payload || {}, upstream_response.status) + } + catch (request_error) { + clearTimeout(timeout_id) + + if (request_error.name === 'AbortError') { + error_response(res, 'KLIPY request timed out', 504) + return + } + + error_response(res, `Failed to reach KLIPY: ${request_error.message}`, 502) + } +} + +async function proxy_klipy_gif_search(app, req, res) { + const app_key = get_klipy_app_key(res) + if (!app_key) { + return + } + + const page = parse_integer_query_param(req.query.page, 1) + if (page === null || page < 1) { + invalid_request(res, 'Invalid page. Expected an integer >= 1') + return + } + + const per_page = parse_integer_query_param(req.query.per_page, 24) + if (per_page === null || per_page < 8 || per_page > 50) { + invalid_request(res, 'Invalid per_page. Expected an integer between 8 and 50') + return + } + + const query = req.query.q + if (query !== undefined && typeof query !== 'string') { + invalid_request(res, 'Invalid q. Expected a string') + return + } + + const locale = req.query.locale + if (locale !== undefined && typeof locale !== 'string') { + invalid_request(res, 'Invalid locale. Expected a string') + return + } + + const content_filter = req.query.content_filter + if (content_filter !== undefined) { + if (typeof content_filter !== 'string' || !KLIPY_ALLOWED_CONTENT_FILTERS.has(content_filter)) { + invalid_request(res, 'Invalid content_filter. Expected one of: off, low, medium, high') + return + } + } + + const format_filter = req.query.format_filter + if (format_filter !== undefined && typeof format_filter !== 'string') { + invalid_request(res, 'Invalid format_filter. Expected a comma-separated string') + return + } + + const customer_id = get_user_uuid(app, req.authorized_pubkey) + const base_url = get_klipy_base_url() + const search_params = new URLSearchParams() + search_params.set('page', String(page)) + search_params.set('per_page', String(per_page)) + search_params.set('customer_id', customer_id) + search_params.set('format_filter', format_filter || KLIPY_GIF_SEARCH_DEFAULT_FORMAT_FILTER) + append_if_present(search_params, 'q', query) + append_if_present(search_params, 'locale', locale) + append_if_present(search_params, 'content_filter', content_filter) + + const upstream_url = `${base_url}/api/v1/${encodeURIComponent(app_key)}/gifs/search?${search_params.toString()}` + await proxy_klipy_request(res, upstream_url) +} + +async function proxy_klipy_gif_featured(req, res) { + const app_key = get_klipy_app_key(res) + if (!app_key) { + return + } + + const limit = parse_integer_query_param(req.query.limit, 20) + if (limit === null || limit < 1 || limit > 50) { + invalid_request(res, 'Invalid limit. Expected an integer between 1 and 50') + return + } + + const pos = req.query.pos + if (pos !== undefined && typeof pos !== 'string') { + invalid_request(res, 'Invalid pos. Expected a string') + return + } + + const locale = req.query.locale + if (locale !== undefined && typeof locale !== 'string') { + invalid_request(res, 'Invalid locale. Expected a string') + return + } + + const country = req.query.country + if (country !== undefined && typeof country !== 'string') { + invalid_request(res, 'Invalid country. Expected a string') + return + } + + const content_filter = req.query.contentfilter + if (content_filter !== undefined) { + if (typeof content_filter !== 'string' || !KLIPY_ALLOWED_CONTENT_FILTERS.has(content_filter)) { + invalid_request(res, 'Invalid contentfilter. Expected one of: off, low, medium, high') + return + } + } + + const media_filter = req.query.media_filter + if (media_filter !== undefined && typeof media_filter !== 'string') { + invalid_request(res, 'Invalid media_filter. Expected a comma-separated string') + return + } + + const base_url = get_klipy_base_url() + const search_params = new URLSearchParams() + search_params.set('key', app_key) + search_params.set('limit', String(limit)) + search_params.set('media_filter', media_filter || KLIPY_FEATURED_DEFAULT_MEDIA_FILTER) + append_if_present(search_params, 'pos', pos) + append_if_present(search_params, 'locale', locale) + append_if_present(search_params, 'country', country) + append_if_present(search_params, 'contentfilter', content_filter) + + const upstream_url = `${base_url}/v2/featured?${search_params.toString()}` + await proxy_klipy_request(res, upstream_url) +} + +module.exports = { + proxy_klipy_gif_search, + proxy_klipy_gif_featured, +} diff --git a/src/router_config.js b/src/router_config.js index ec430a1..e364b26 100644 --- a/src/router_config.js +++ b/src/router_config.js @@ -9,9 +9,20 @@ const { nip19 } = require('nostr-tools') const { PURPLE_ONE_MONTH } = require('./invoicing') const error = require("debug")("api:error") const { update_iap_history_with_apple_if_needed_and_return_updated_user } = require('./iap_refresh_management') +const { proxy_klipy_gif_search, proxy_klipy_gif_featured } = require('./klipy_proxy') const fs = require('fs'); const path = require('path'); +function require_active_purple_user(app, req, res) { + const check_account_result = check_account(app, req.authorized_pubkey) + if (!check_account_result.ok) { + unauthorized_response(res, check_account_result.message) + return false + } + + return true +} + function config_router(app) { const router = app.router @@ -29,12 +40,26 @@ function config_router(app) { // MARK: Translation routes router.get('/translate', required_nip98_auth, async (req, res) => { - const check_account_result = check_account(app, req.authorized_pubkey) - if (!check_account_result.ok) { - unauthorized_response(res, check_account_result.message) + if (!require_active_purple_user(app, req, res)) return + handle_translate(app, req, res) + }) + + // MARK: GIF routes + + router.get('/gifs/search', required_nip98_auth, async (req, res) => { + if (!require_active_purple_user(app, req, res)) { return } - handle_translate(app, req, res) + + await proxy_klipy_gif_search(app, req, res) + }) + + router.get('/gifs/featured', required_nip98_auth, async (req, res) => { + if (!require_active_purple_user(app, req, res)) { + return + } + + await proxy_klipy_gif_featured(req, res) }) // MARK: Account management routes diff --git a/test/controllers/purple_test_client.js b/test/controllers/purple_test_client.js index d180551..c3dcb3e 100644 --- a/test/controllers/purple_test_client.js +++ b/test/controllers/purple_test_client.js @@ -58,6 +58,44 @@ class PurpleTestClient { return await this.get('/products', options) } + /** + * Searches GIFs through the server-side KLIPY proxy. + * + * @param {Record} query - Query parameters + * @param {PurpleTestClientRequestOptions} options - The request options + * @returns {Promise} The response + */ + async search_gifs(query = {}, options = {}) { + options = PurpleTestClient.patch_options({ nip98_authenticated: true }, options) + const search_params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + search_params.append(key, String(value)) + } + } + const suffix = search_params.toString().length > 0 ? `?${search_params.toString()}` : '' + return await this.get(`/gifs/search${suffix}`, options) + } + + /** + * Fetches featured GIFs through the server-side KLIPY proxy. + * + * @param {Record} query - Query parameters + * @param {PurpleTestClientRequestOptions} options - The request options + * @returns {Promise} The response + */ + async featured_gifs(query = {}, options = {}) { + options = PurpleTestClient.patch_options({ nip98_authenticated: true }, options) + const search_params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + search_params.append(key, String(value)) + } + } + const suffix = search_params.toString().length > 0 ? `?${search_params.toString()}` : '' + return await this.get(`/gifs/featured${suffix}`, options) + } + /** * Creates a new checkout. * diff --git a/test/controllers/purple_test_controller.js b/test/controllers/purple_test_controller.js index a678451..d944b34 100644 --- a/test/controllers/purple_test_controller.js +++ b/test/controllers/purple_test_controller.js @@ -60,6 +60,8 @@ class PurpleTestController { process.env.NOTEDECK_INSTALL_PREMIUM_MD = "./notedeck-install-instructions-premium.md" process.env.DAMUS_ANDROID_INSTALL_MD = "./damus-android-install-instructions.md" process.env.DAMUS_ANDROID_INSTALL_PREMIUM_MD = "./damus-android-install-instructions-premium.md" + process.env.KLIPY_APP_KEY = "test-klipy-app-key" + process.env.KLIPY_API_BASE_URL = "https://api.klipy.test" this.env = process.env } diff --git a/test/router_config.test.js b/test/router_config.test.js index ea262c6..3d629dc 100644 --- a/test/router_config.test.js +++ b/test/router_config.test.js @@ -5,6 +5,7 @@ const nostr = require('nostr'); const current_time = require('../src/utils.js').current_time; const { supertest_client } = require('./controllers/utils.js'); const { v4: uuidv4 } = require('uuid') +const { PurpleTestController } = require('./controllers/purple_test_controller.js') test('config_router - Account management routes', async (t) => { const account_info = { @@ -101,3 +102,159 @@ test('config_router - Account management routes', async (t) => { t.end(); }); + +test('config_router - GIF search proxy requires active Purple and proxies search params', async (t) => { + const purple_api_controller = await PurpleTestController.new(t) + const pubkey = purple_api_controller.new_client() + + purple_api_controller.set_account_uuid(pubkey, 'TEST-UUID-123') + await purple_api_controller.ln_flow_buy_subscription(pubkey, 'purple_one_month') + + const original_fetch = global.fetch + global.fetch = async (url, options) => { + t.equal(url, 'https://api.klipy.test/api/v1/test-klipy-app-key/gifs/search?page=2&per_page=8&customer_id=TEST-UUID-123&format_filter=gif%2Cmp4&q=hello&locale=us&content_filter=low') + t.equal(options.method, 'GET') + t.same(options.headers, { + 'Accept': 'application/json' + }) + t.ok(options.signal, 'includes abort signal') + + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + result: true, + data: { + data: [ + { id: 1, slug: 'hello-hi-662', title: 'Hello' } + ], + current_page: 2, + per_page: 8, + has_next: false + } + }) + } + } + + t.teardown(() => { + global.fetch = original_fetch + }) + + const response = await purple_api_controller.clients[pubkey].search_gifs({ + q: 'hello', + page: 2, + per_page: 8, + locale: 'us', + content_filter: 'low', + format_filter: 'gif,mp4' + }) + + t.equal(response.statusCode, 200) + t.same(response.body, { + result: true, + data: { + data: [ + { id: 1, slug: 'hello-hi-662', title: 'Hello' } + ], + current_page: 2, + per_page: 8, + has_next: false + } + }) + +}) + +test('config_router - GIF search proxy rejects inactive Purple users', async (t) => { + const purple_api_controller = await PurpleTestController.new(t) + const pubkey = purple_api_controller.new_client() + + const response = await purple_api_controller.clients[pubkey].search_gifs({ q: 'hello' }) + + t.equal(response.statusCode, 401) + t.same(response.body, { error: 'Account not found' }) +}) + +test('config_router - GIF search proxy requires NIP-98 auth', async (t) => { + const purple_api_controller = await PurpleTestController.new(t) + const pubkey = purple_api_controller.new_client() + + await purple_api_controller.ln_flow_buy_subscription(pubkey, 'purple_one_month') + + const response = await purple_api_controller.clients[pubkey].search_gifs({ q: 'hello' }, { nip98_authenticated: false }) + + t.equal(response.statusCode, 401) + t.same(response.body, { error: 'Nostr authorization header missing' }) +}) + +test('config_router - GIF featured proxy requires active Purple and proxies params', async (t) => { + const purple_api_controller = await PurpleTestController.new(t) + const pubkey = purple_api_controller.new_client() + + await purple_api_controller.ln_flow_buy_subscription(pubkey, 'purple_one_month') + + const original_fetch = global.fetch + global.fetch = async (url, options) => { + t.equal(url, 'https://api.klipy.test/v2/featured?key=test-klipy-app-key&limit=10&media_filter=tinygif%2Ctinymp4&pos=next-cursor&locale=en_US&country=US&contentfilter=medium') + t.equal(options.method, 'GET') + t.same(options.headers, { + 'Accept': 'application/json' + }) + t.ok(options.signal, 'includes abort signal') + + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ + locale: 'en', + results: [ + { id: '5985319072776906', title: 'Spongebob Squarepants Christmas Dance' } + ], + next: 'Mg==' + }) + } + } + + t.teardown(() => { + global.fetch = original_fetch + }) + + const response = await purple_api_controller.clients[pubkey].featured_gifs({ + limit: 10, + pos: 'next-cursor', + locale: 'en_US', + country: 'US', + contentfilter: 'medium', + media_filter: 'tinygif,tinymp4' + }) + + t.equal(response.statusCode, 200) + t.same(response.body, { + locale: 'en', + results: [ + { id: '5985319072776906', title: 'Spongebob Squarepants Christmas Dance' } + ], + next: 'Mg==' + }) +}) + +test('config_router - GIF featured proxy rejects inactive Purple users', async (t) => { + const purple_api_controller = await PurpleTestController.new(t) + const pubkey = purple_api_controller.new_client() + + const response = await purple_api_controller.clients[pubkey].featured_gifs() + + t.equal(response.statusCode, 401) + t.same(response.body, { error: 'Account not found' }) +}) + +test('config_router - GIF featured proxy requires NIP-98 auth', async (t) => { + const purple_api_controller = await PurpleTestController.new(t) + const pubkey = purple_api_controller.new_client() + + await purple_api_controller.ln_flow_buy_subscription(pubkey, 'purple_one_month') + + const response = await purple_api_controller.clients[pubkey].featured_gifs({}, { nip98_authenticated: false }) + + t.equal(response.statusCode, 401) + t.same(response.body, { error: 'Nostr authorization header missing' }) +})