384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
/*
|
|
* (c) Copyright Ascensio System SIA 2010-2024
|
|
*
|
|
* This program is a free software product. You can redistribute it and/or
|
|
* modify it under the terms of the GNU Affero General Public License (AGPL)
|
|
* version 3 as published by the Free Software Foundation. In accordance with
|
|
* Section 7(a) of the GNU AGPL its Section 15 shall be amended to the effect
|
|
* that Ascensio System SIA expressly excludes the warranty of non-infringement
|
|
* of any third-party rights.
|
|
*
|
|
* This program is distributed WITHOUT ANY WARRANTY; without even the implied
|
|
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. For
|
|
* details, see the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html
|
|
*
|
|
* You can contact Ascensio System SIA at 20A-6 Ernesta Birznieka-Upish
|
|
* street, Riga, Latvia, EU, LV-1050.
|
|
*
|
|
* The interactive user interfaces in modified source and object code versions
|
|
* of the Program must display Appropriate Legal Notices, as required under
|
|
* Section 5 of the GNU AGPL version 3.
|
|
*
|
|
* Pursuant to Section 7(b) of the License you must retain the original Product
|
|
* logo when distributing the program. Pursuant to Section 7(e) we decline to
|
|
* grant you any rights under trademark law for use of our trademarks.
|
|
*
|
|
* All the Product's GUI elements, including illustrations and icon sets, as
|
|
* well as technical writing content are licensed under the terms of the
|
|
* Creative Commons Attribution-ShareAlike 4.0 International. See the License
|
|
* terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode
|
|
*
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const {pipeline} = require('stream/promises');
|
|
const {URL} = require('url');
|
|
const config = require('config');
|
|
const utils = require('./../../../Common/sources/utils');
|
|
const operationContext = require('./../../../Common/sources/operationContext');
|
|
const commonDefines = require('./../../../Common/sources/commondefines');
|
|
const tenantManager = require('./../../../Common/sources/tenantManager');
|
|
const docsCoServer = require('./../DocsCoServer');
|
|
const statsDClient = require('./../../../Common/sources/statsdclient');
|
|
|
|
const cfgAiApiAllowedOrigins = config.get('aiSettings.allowedCorsOrigins');
|
|
const cfgAiApiProxy = config.get('aiSettings.proxy');
|
|
const cfgAiApiTimeout = config.get('aiSettings.timeout');
|
|
const cfgTokenEnableOutbox = config.get('services.CoAuthoring.token.enable.request.outbox');
|
|
const cfgTokenOutboxHeader = config.get('services.CoAuthoring.token.outbox.header');
|
|
const cfgTokenOutboxPrefix = config.get('services.CoAuthoring.token.outbox.prefix');
|
|
const cfgAiSettings = config.get('aiSettings');
|
|
|
|
const clientStatsD = statsDClient.getClient();
|
|
/**
|
|
* Helper function to set CORS headers if the request origin is allowed
|
|
*
|
|
* @param {object} req - Express request object
|
|
* @param {object} res - Express response object
|
|
* @param {operationContext.Context} ctx - Operation context for logging
|
|
* @param {boolean} handleOptions - Whether to handle OPTIONS requests (default: true)
|
|
* @returns {boolean} - True if this was an OPTIONS request that was handled
|
|
*/
|
|
function handleCorsHeaders(req, res, ctx, handleOptions = true) {
|
|
const requestOrigin = req.headers.origin;
|
|
|
|
const tenAiApiAllowedOrigins = ctx.getCfg('aiSettings.allowedCorsOrigins', cfgAiApiAllowedOrigins);
|
|
|
|
// If no origin in request or allowed origins list is empty, do nothing
|
|
if (!requestOrigin || tenAiApiAllowedOrigins.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// If the origin is in our allowed list
|
|
if (tenAiApiAllowedOrigins.includes(requestOrigin)) {
|
|
res.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
res.setHeader('Vary', 'Origin'); // Important when using dynamic origin
|
|
|
|
// If debug logging is available
|
|
if (ctx && ctx.logger) {
|
|
ctx.logger.debug('CORS headers set for origin: %s (matched allowed list)', requestOrigin);
|
|
}
|
|
|
|
// Handle preflight OPTIONS requests if requested
|
|
if (handleOptions && req.method === 'OPTIONS') {
|
|
res.setHeader('Access-Control-Allow-Methods', 'DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT');
|
|
// Allow all headers with wildcard
|
|
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
|
|
// For preflight request, we should also set non-CORS headers to match the API
|
|
res.setHeader('Allow', 'OPTIONS, HEAD, GET, POST, PUT, DELETE, PATCH');
|
|
res.setHeader('Content-Length', '0');
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
|
|
// Return 204 which is standard for OPTIONS preflight
|
|
res.sendStatus(204); // No Content response for OPTIONS
|
|
return true; // Signal that we handled an OPTIONS request
|
|
}
|
|
}
|
|
|
|
return false; // Not an OPTIONS request or origin not allowed
|
|
}
|
|
|
|
/**
|
|
* Detects provider type and generates appropriate authentication headers based on URL patterns
|
|
*
|
|
* @param {operationContext.Context} ctx - Operation context for logging
|
|
* @param {string} providerUrl - Provider base URL to detect type
|
|
* @param {string} providerKey - API key for the provider
|
|
* @param {string} uri - Full target request URI
|
|
* @param {object} providerHeaders - Optional provider headers from customProviders
|
|
* @returns {object} Headers object with authentication added
|
|
*/
|
|
function insertKeyToProvider(ctx, providerUrl, providerKey, uri, providerHeaders) {
|
|
if (!providerKey) {
|
|
return uri;
|
|
}
|
|
const urlLower = providerUrl.toLowerCase();
|
|
|
|
if (urlLower.includes('anthropic.com')) {
|
|
// Anthropic uses x-api-key header
|
|
providerHeaders['x-api-key'] = providerKey;
|
|
} else if (urlLower.includes('generativelanguage.googleapis.com')) {
|
|
// Google Gemini uses API key as query parameter (already in URI)
|
|
if (uri.includes('?')) uri += `&key=${providerKey}`;
|
|
else uri += `?key=${providerKey}`;
|
|
} else {
|
|
// Default: Bearer Authorization (OpenAI, Deepseek, Groq, xAI, Mistral, Together.ai, etc.)
|
|
providerHeaders['Authorization'] = `Bearer ${providerKey}`;
|
|
}
|
|
|
|
return uri;
|
|
}
|
|
|
|
/**
|
|
* Makes an HTTP request to an AI API endpoint using the provided request and response objects
|
|
*
|
|
* @param {object} req - Express request object
|
|
* @param {object} res - Express response object
|
|
* @returns {Promise<void>} - Promise resolving when the request is complete
|
|
*/
|
|
async function proxyRequest(req, res) {
|
|
// Create operation context for logging
|
|
const ctx = new operationContext.Context();
|
|
ctx.initFromRequest(req);
|
|
const startDate = new Date();
|
|
let success = false;
|
|
|
|
try {
|
|
ctx.logger.info('Start proxyRequest');
|
|
await ctx.initTenantCache();
|
|
const tenAiApiTimeout = ctx.getCfg('aiSettings.timeout', cfgAiApiTimeout);
|
|
const tenAiApi = ctx.getCfg('aiSettings', cfgAiSettings);
|
|
const tenAiApiProxy = ctx.getCfg('aiSettings.proxy', cfgAiApiProxy);
|
|
|
|
// 1. Handle CORS preflight (OPTIONS) requests if necessary
|
|
if (handleCorsHeaders(req, res, ctx) === true) {
|
|
return; // OPTIONS request handled, stop further processing
|
|
}
|
|
|
|
let docId = '';
|
|
let userId = '';
|
|
let userName = '';
|
|
let userCustomerId = '';
|
|
const checkJwtRes = await docsCoServer.checkJwtHeader(ctx, req, 'Authorization', 'Bearer ', commonDefines.c_oAscSecretType.Session);
|
|
if (!checkJwtRes || checkJwtRes.err) {
|
|
ctx.logger.error('proxyRequest: checkJwtHeader error: %s', checkJwtRes?.err);
|
|
res.status(403).json({
|
|
error: {
|
|
message: 'proxyRequest: checkJwtHeader error',
|
|
code: '403'
|
|
}
|
|
});
|
|
return;
|
|
} else {
|
|
docId = checkJwtRes?.decoded?.document?.key;
|
|
userId = checkJwtRes?.decoded?.editorConfig?.user?.id;
|
|
userName = checkJwtRes?.decoded?.editorConfig?.user?.name;
|
|
userCustomerId = checkJwtRes?.decoded?.editorConfig?.user?.customerId;
|
|
|
|
ctx.setDocId(docId);
|
|
ctx.setUserId(userId);
|
|
}
|
|
|
|
if (!tenAiApi?.providers) {
|
|
ctx.logger.error('proxyRequest: No providers configured');
|
|
res.status(403).json({
|
|
error: {
|
|
message: 'proxyRequest: No providers configured',
|
|
code: '403'
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
const body = JSON.parse(req.body);
|
|
let uri = body.target;
|
|
|
|
const providerHeaders = {};
|
|
let providerMatched = false;
|
|
// Determine which API key to use based on the target URL
|
|
if (uri) {
|
|
for (const providerName in tenAiApi.providers) {
|
|
const tenProvider = tenAiApi.providers[providerName];
|
|
if (uri.startsWith(tenProvider.url)) {
|
|
providerMatched = true;
|
|
|
|
// Generate appropriate headers based on provider type
|
|
uri = insertKeyToProvider(ctx, tenProvider.url, tenProvider.key, uri, providerHeaders);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If body.target was provided but no provider was matched, return 403
|
|
if (!providerMatched) {
|
|
ctx.logger.warn(`proxyRequest: target '${uri}' does not match any configured AI provider. Denying access.`);
|
|
res.status(403).json({
|
|
error: {
|
|
message: 'proxyRequest: target does not match any configured AI provider',
|
|
code: '403'
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Merge key in headers
|
|
const headers = {...body.headers, ...providerHeaders};
|
|
|
|
// Preserve Accept-Encoding from original request if not explicitly provided
|
|
if (!headers['accept-encoding'] && req.headers['accept-encoding']) {
|
|
headers['accept-encoding'] = req.headers['accept-encoding'];
|
|
}
|
|
|
|
// use proxy instead of direct request
|
|
if (tenAiApiProxy) {
|
|
const tenTokenEnableOutbox = ctx.getCfg('services.CoAuthoring.token.enable.request.outbox', cfgTokenEnableOutbox);
|
|
if (tenTokenEnableOutbox) {
|
|
const tenTokenOutboxHeader = ctx.getCfg('services.CoAuthoring.token.outbox.header', cfgTokenOutboxHeader);
|
|
const tenTokenOutboxPrefix = ctx.getCfg('services.CoAuthoring.token.outbox.prefix', cfgTokenOutboxPrefix);
|
|
const [licenseInfo] = await tenantManager.getTenantLicense(ctx);
|
|
|
|
const dataObject = {
|
|
key: docId,
|
|
user: {
|
|
id: userId,
|
|
name: userName,
|
|
customerId: userCustomerId || licenseInfo.customerId
|
|
}
|
|
};
|
|
|
|
const secret = await tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Outbox);
|
|
const auth = utils.fillJwtForRequest(ctx, dataObject, secret, false);
|
|
headers[tenTokenOutboxHeader] = tenTokenOutboxPrefix + auth;
|
|
}
|
|
// Replace protocol, host and port in URI with proxy URL
|
|
const proxyUrl = new URL(tenAiApiProxy);
|
|
const targetUrl = new URL(uri);
|
|
targetUrl.protocol = proxyUrl.protocol;
|
|
targetUrl.host = proxyUrl.host;
|
|
targetUrl.port = proxyUrl.port || targetUrl.port;
|
|
uri = targetUrl.toString();
|
|
ctx.logger.debug(`proxyRequest: Updated URI to use proxy host: ${tenAiApiProxy}`);
|
|
}
|
|
|
|
// Configure timeout options for the request
|
|
const timeoutOptions = {
|
|
connectionAndInactivity: tenAiApiTimeout,
|
|
wholeCycle: tenAiApiTimeout
|
|
};
|
|
// Create request parameters object
|
|
const requestParams = {
|
|
method: body.method,
|
|
uri,
|
|
headers,
|
|
body: body.data,
|
|
timeout: timeoutOptions,
|
|
limit: null,
|
|
isInJwtToken: providerMatched //true because it passed provider's filter
|
|
};
|
|
|
|
// Log the sanitized request parameters
|
|
ctx.logger.debug(`proxyRequest request: %j`, requestParams);
|
|
|
|
// Use utils.httpRequest to make the request
|
|
const result = await utils.httpRequest(
|
|
ctx, // Operation context
|
|
requestParams.method, // HTTP method
|
|
requestParams.uri, // Target URL
|
|
requestParams.headers, // Request headers
|
|
requestParams.body, // Request body
|
|
requestParams.timeout, // Timeout configuration
|
|
requestParams.limit, // Size limit
|
|
requestParams.isInJwtToken, // Filter private requests
|
|
{
|
|
decompress: false
|
|
}
|
|
);
|
|
|
|
// Set the response headers to match the target response
|
|
res.set(result.response.headers);
|
|
|
|
// Use pipeline to pipe the response data to the client
|
|
await pipeline(result.stream, res);
|
|
success = true;
|
|
} catch (error) {
|
|
ctx.logger.error(`proxyRequest: AI API request error: %s`, error);
|
|
if (error.response) {
|
|
// Set the response headers to match the target response
|
|
res.set(error.response.headers);
|
|
|
|
// Use pipeline to pipe the response data to the client
|
|
await pipeline(error.response.data, res);
|
|
} else {
|
|
res.status(500).json({
|
|
error: {
|
|
message: 'proxyRequest: AI API request error',
|
|
code: '500'
|
|
}
|
|
});
|
|
}
|
|
} finally {
|
|
// Record the time taken for the proxyRequest in StatsD (skip cors requests and errors)
|
|
if (clientStatsD && success) {
|
|
clientStatsD.timing('coauth.aiProxy', new Date() - startDate);
|
|
}
|
|
ctx.logger.info('End proxyRequest');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves all AI models from the configuration and dynamically from providers
|
|
*
|
|
* @param {operationContext.Context} ctx - Operation context
|
|
* @returns {Promise<Object>} Object containing providers and their models along with action configurations
|
|
*/
|
|
async function getPluginSettings(ctx) {
|
|
return {
|
|
version: 3,
|
|
actions: ctx.getCfg('aiSettings.actions', cfgAiSettings.actions),
|
|
providers: ctx.getCfg('aiSettings.providers', cfgAiSettings.providers),
|
|
customProviders: ctx.getCfg('aiSettings.customProviders', cfgAiSettings.customProviders),
|
|
models: ctx.getCfg('aiSettings.models', cfgAiSettings.models)
|
|
};
|
|
}
|
|
|
|
async function getPluginSettingsForInterface(ctx) {
|
|
let pluginSettings = await getPluginSettings(ctx);
|
|
|
|
// Create deep copy to avoid modifying cached config
|
|
if (pluginSettings) {
|
|
pluginSettings = JSON.parse(JSON.stringify(pluginSettings));
|
|
}
|
|
|
|
//check empty settings
|
|
if (pluginSettings && pluginSettings.actions) {
|
|
let isEmptySettings = true;
|
|
for (const key in pluginSettings.actions) {
|
|
if (pluginSettings.actions[key].model) {
|
|
isEmptySettings = false;
|
|
}
|
|
}
|
|
if (isEmptySettings) {
|
|
pluginSettings = undefined;
|
|
}
|
|
}
|
|
|
|
//remove keys from providers
|
|
if (pluginSettings?.providers) {
|
|
for (const key in pluginSettings.providers) {
|
|
if (pluginSettings.providers[key]?.key) {
|
|
pluginSettings.providers[key].key = '';
|
|
}
|
|
}
|
|
}
|
|
return pluginSettings;
|
|
}
|
|
|
|
module.exports = {
|
|
proxyRequest,
|
|
getPluginSettings,
|
|
getPluginSettingsForInterface
|
|
};
|