Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions src/klipy_proxy.js
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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,
}
33 changes: 29 additions & 4 deletions src/router_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
38 changes: 38 additions & 0 deletions test/controllers/purple_test_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ class PurpleTestClient {
return await this.get('/products', options)
}

/**
* Searches GIFs through the server-side KLIPY proxy.
*
* @param {Record<string, string | number | boolean | undefined>} query - Query parameters
* @param {PurpleTestClientRequestOptions} options - The request options
* @returns {Promise<Object>} 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<string, string | number | boolean | undefined>} query - Query parameters
* @param {PurpleTestClientRequestOptions} options - The request options
* @returns {Promise<Object>} 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.
*
Expand Down
2 changes: 2 additions & 0 deletions test/controllers/purple_test_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading
Loading