Merge remote-tracking branch 'remotes/origin/release/v9.0.0' into develop

This commit is contained in:
Sergey Konovalov
2025-06-19 02:07:56 +03:00
596 changed files with 88337 additions and 1328 deletions

View File

@ -23,7 +23,7 @@
- axios 2.88.0 ([MIT](https://raw.githubusercontent.com/axios/axios/v1.x/LICENSE))
- request-filtering-agent 1.0.5 ([MIT](https://raw.githubusercontent.com/azu/request-filtering-agent/master/LICENSE))
- rhea 3.0.3 ([Apache-2.0](https://raw.githubusercontent.com/amqp/rhea/main/LICENSE))
- uri-js 4.4.1 ([BSD-2-Clause](https://raw.githubusercontent.com/garycourt/uri-js/master/LICENSE))
- uri-js-replace 1.0.1 ([MIT](https://github.com/andreinwald/uri-js-replace?tab=MIT-0-1-ov-file#readme))
- win-ca 3.5.1 ([MIT](https://raw.githubusercontent.com/ukoloff/win-ca/master/LICENSE))
- apicache 1.6.3 ([MIT](https://raw.githubusercontent.com/kwhitley/apicache/master/LICENSE))
- body-parser 1.20.3 ([MIT](https://raw.githubusercontent.com/expressjs/body-parser/master/LICENSE))
@ -32,7 +32,7 @@
- co 4.6.0 ([MIT](https://raw.githubusercontent.com/tj/co/master/LICENSE))
- config 3.3.12 ([MIT](https://raw.githubusercontent.com/node-config/node-config/master/LICENSE))
- cron 1.5.0 ([MIT](https://raw.githubusercontent.com/kelektiv/node-cron/main/LICENSE))
- dmdb 1.0.33801 ([none](https://www.npmjs.com/package/dmdb))
- dmdb 1.0.36002 ([none](https://www.npmjs.com/package/dmdb))
- ejs 3.1.10 ([Apache-2.0](https://raw.githubusercontent.com/mde/ejs/main/LICENSE))
- exif-parser 0.1.12 ([MIT](https://raw.githubusercontent.com/bwindels/exif-parser/master/LICENSE.md))
- express 4.21.2 ([MIT](https://raw.githubusercontent.com/expressjs/express/master/LICENSE))
@ -43,7 +43,7 @@
- mime 2.3.1 ([MIT](https://raw.githubusercontent.com/broofa/mime/main/LICENSE))
- mime-db 1.53.0 ([MIT](https://raw.githubusercontent.com/jshttp/mime-db/master/LICENSE))
- ms 2.1.3 ([MIT](https://raw.githubusercontent.com/vercel/ms/master/license.md))
- mssql 9.1.1 ([MIT](https://raw.githubusercontent.com/tediousjs/node-mssql/master/LICENSE.md))
- mssql 11.0.1 ([MIT](https://raw.githubusercontent.com/tediousjs/node-mssql/master/LICENSE.md))
- multer 1.4.4 ([MIT](https://raw.githubusercontent.com/expressjs/multer/master/LICENSE))
- multi-integer-range 5.2.0 ([MIT](https://raw.githubusercontent.com/smikitky/node-multi-integer-range/master/LICENSE))
- multiparty 4.2.3 ([MIT](https://raw.githubusercontent.com/pillarjs/multiparty/master/LICENSE))

View File

@ -5,12 +5,34 @@
"port": "8125",
"prefix": "ds."
},
"aiSettings": {
"actions": {
},
"models": [
],
"providers": {
},
"version": 3,
"timeout": "30s",
"allowedCorsOrigins": [
"https://onlyoffice.github.io", "https://onlyoffice-plugins.github.io"
],
"pluginDir" : "../branding/info/ai"
},
"log": {
"filePath": "",
"options": {
"replaceConsole": true
}
},
"runtimeConfig": {
"filePath": "",
"cache": {
"stdTTL": 300,
"checkperiod": 60,
"useClones": false
}
},
"queue": {
"type": "rabbitmq",
"visibilityTimeout": 300,
@ -107,7 +129,8 @@
"secretAccessKey": "",
"sslEnabled": false,
"s3ForcePathStyle": true,
"externalHost": ""
"externalHost": "",
"useDirectStorageUrls": true
},
"persistentStorage": {
},

View File

@ -2,6 +2,12 @@
"log": {
"filePath": "../Common/config/log4js/development.json"
},
"runtimeConfig": {
"filePath": "./../runtime.json"
},
"queue": {
"visibilityTimeout": 900
},
"storage": {
"fs": {
"folderPath": "../App_Data"
@ -63,10 +69,39 @@
},
"FileConverter": {
"converter": {
"maxDownloadBytes": 1073741824,
"fontDir": "/usr/share/fonts",
"presentationThemesDir": "../../sdkjs/slide/themes",
"x2tPath": "../FileConverter/bin/x2t",
"docbuilderPath": "../FileConverter/bin/docbuilder"
"docbuilderPath": "../FileConverter/bin/docbuilder",
"spawnOptions": {
"env": {
"X2T_MEMORY_LIMIT": "16GB"
}
},
"inputLimits": [
{
"type": "docx;dotx;docm;dotm",
"zip": {
"uncompressed": "1GB",
"template": "*.xml"
}
},
{
"type": "xlsx;xltx;xlsm;xltm",
"zip": {
"uncompressed": "1GB",
"template": "*.xml"
}
},
{
"type": "pptx;ppsx;potx;pptm;ppsm;potm",
"zip": {
"uncompressed": "1GB",
"template": "*.xml"
}
}
]
}
},
"SpellChecker": {

View File

@ -2,6 +2,12 @@
"log": {
"filePath": "../Common/config/log4js/development.json"
},
"runtimeConfig": {
"filePath": "./../runtime.json"
},
"queue": {
"visibilityTimeout": 900
},
"storage": {
"fs": {
"folderPath": "../App_Data"
@ -69,11 +75,40 @@
},
"FileConverter": {
"converter": {
"maxDownloadBytes": 1073741824,
"fontDir": "",
"presentationThemesDir": "../../sdkjs/slide/themes",
"x2tPath": "../FileConverter/bin/x2t",
"docbuilderPath": "../FileConverter/Bin/docbuilder",
"errorfiles": "error"
"spawnOptions": {
"env": {
"X2T_MEMORY_LIMIT": "16GB"
}
},
"errorfiles": "error",
"inputLimits": [
{
"type": "docx;dotx;docm;dotm",
"zip": {
"uncompressed": "1GB",
"template": "*.xml"
}
},
{
"type": "xlsx;xltx;xlsm;xltm",
"zip": {
"uncompressed": "1GB",
"template": "*.xml"
}
},
{
"type": "pptx;ppsx;potx;pptm;ppsm;potm",
"zip": {
"uncompressed": "1GB",
"template": "*.xml"
}
}
]
}
},
"SpellChecker": {

View File

@ -2,6 +2,12 @@
"log": {
"filePath": "../Common/config/log4js/development.json"
},
"runtimeConfig": {
"filePath": "./../runtime.json"
},
"queue": {
"visibilityTimeout": 900
},
"storage": {
"fs": {
"folderPath": "../App_Data"
@ -69,11 +75,40 @@
},
"FileConverter": {
"converter": {
"maxDownloadBytes": 1073741824,
"fontDir": "",
"presentationThemesDir": "../../sdkjs/slide/themes",
"x2tPath": "../FileConverter/Bin/x2t.exe",
"docbuilderPath": "../FileConverter/Bin/docbuilder.exe",
"errorfiles": "error"
"spawnOptions": {
"env": {
"X2T_MEMORY_LIMIT": "16GB"
}
},
"errorfiles": "error",
"inputLimits": [
{
"type": "docx;dotx;docm;dotm",
"zip": {
"uncompressed": "1GB",
"template": "*.xml"
}
},
{
"type": "xlsx;xltx;xlsm;xltm",
"zip": {
"uncompressed": "1GB",
"template": "*.xml"
}
},
{
"type": "pptx;ppsx;potx;pptm;ppsm;potm",
"zip": {
"uncompressed": "1GB",
"template": "*.xml"
}
}
]
}
},
"SpellChecker": {

View File

@ -2,6 +2,12 @@
"log": {
"filePath": "/etc/onlyoffice/documentserver/log4js/production.json"
},
"aiSettings": {
"pluginDir" : "/var/www/onlyoffice/documentserver/server/info/ai"
},
"runtimeConfig": {
"filePath": "/var/www/onlyoffice/documentserver/../Data/runtime.json"
},
"storage": {
"fs": {
"folderPath": "/var/lib/onlyoffice/documentserver/App_Data/cache/files"

View File

@ -2,6 +2,12 @@
"log": {
"filePath": "../../config/log4js/production.json"
},
"runtimeConfig": {
"filePath": "./../runtime.json"
},
"aiSettings": {
"pluginDir" : "../info/ai"
},
"storage": {
"fs": {
"folderPath": "../App_Data/cache/files"

View File

@ -5317,7 +5317,7 @@
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axios": {
"version": "1.8.3",
@ -5423,7 +5423,7 @@
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"dnscache": {
"version": "1.0.2",
@ -5484,7 +5484,7 @@
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="
},
"events": {
"version": "3.3.0",
@ -5814,11 +5814,6 @@
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
},
"querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@ -5905,13 +5900,10 @@
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="
},
"uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"requires": {
"punycode": "^2.1.0"
}
"uri-js-replace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="
},
"url-parse": {
"version": "1.5.10",

View File

@ -26,7 +26,7 @@
"nodemailer": "6.10.0",
"request-filtering-agent": "1.0.5",
"rhea": "3.0.3",
"uri-js": "4.4.1",
"uri-js-replace": "1.0.1",
"win-ca": "3.5.1"
}
}

View File

@ -32,10 +32,12 @@
'use strict';
const config = require('config');
const utils = require('./utils');
const logger = require('./logger');
const constants = require('./constants');
const tenantManager = require('./tenantManager');
const runtimeConfigManager = require('./runtimeConfigManager');
function Context(){
this.logger = logger.getLogger('nodeJS');
@ -51,6 +53,8 @@ Context.prototype.init = function(tenant, docId, userId, opt_shardKey, opt_WopiS
this.config = null;
this.secret = null;
this.license = null;
//cache
this.taskResultCache = null;
};
Context.prototype.initDefault = function() {
this.init(tenantManager.getDefautTenant(), constants.DEFAULT_DOC_ID, constants.DEFAULT_USER_ID, undefined);
@ -85,7 +89,10 @@ Context.prototype.initFromPubSub = function(data) {
this.init(ctx.tenant, ctx.docId, ctx.userId, ctx.shardKey, ctx.wopiSrc);
};
Context.prototype.initTenantCache = async function() {
this.config = await tenantManager.getTenantConfig(this);
const runtimeConfig = await runtimeConfigManager.getConfig(this);
const tenantConfig = await tenantManager.getTenantConfig(this);
this.config = utils.deepMergeObjects({}, runtimeConfig, tenantConfig);
//todo license and secret
};
@ -122,6 +129,13 @@ Context.prototype.getCfg = function(property, defaultValue) {
}
return defaultValue;
};
/**
* Get the full configuration by combining system config with context config
* @returns {object} The merged configuration object
*/
Context.prototype.getFullCfg = function() {
return utils.deepMergeObjects(config.util.toObject(), this.config);
};
/**
* Underlying get mechanism

View File

@ -0,0 +1,135 @@
/*
* (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 fs = require('fs/promises');
const fsWatch = require('fs');
const path = require('path');
const config = require('config');
const NodeCache = require("node-cache");
const operationContext = require('./operationContext');
const utils = require('./utils');
const cfgRuntimeConfig = config.get('runtimeConfig');
const configFilePath = cfgRuntimeConfig.filePath;
const configFileName = path.basename(configFilePath);
// Initialize cache with TTL and check for expired keys every minute
const nodeCache = new NodeCache(cfgRuntimeConfig.cache);
let isInitConfigWatcher = false;
/**
* Get runtime configuration for the current context
* @param {operationContext} ctx - Operation context
* @returns {Object} Runtime configuration object
*/
async function getConfigFromFile(ctx) {
if (!isInitConfigWatcher) {
isInitConfigWatcher = true;
initConfigWatcher(ctx);
}
try {
const configData = await fs.readFile(configFilePath, 'utf8');
return JSON.parse(configData);
} catch (err) {
if (err.code !== 'ENOENT') {
ctx.logger.error('getConfigFromFile error: %s', err.stack);
}
return null;
}
}
/**
* Get cached configuration for the current context
* @param {operationContext} ctx - Operation context
* @returns {Object} Cached configuration object
*/
async function getConfig(ctx) {
let config = nodeCache.get(configFileName);
if (undefined === config) {
config = await getConfigFromFile(ctx);
nodeCache.set(configFileName, config);
}
return config;
}
/**
* Save runtime configuration for the current context
* @param {operationContext} ctx - Operation context
* @param {Object} config - Configuration data to save
* @returns {Object} Saved configuration object
*/
async function saveConfig(ctx, config) {
await fs.mkdir(path.dirname(configFilePath), { recursive: true });
let newConfig = await getConfig(ctx);
newConfig = utils.deepMergeObjects(newConfig || {}, config);
await fs.writeFile(configFilePath, JSON.stringify(newConfig, null, 2), 'utf8');
nodeCache.set(configFileName, newConfig);
return newConfig;
}
/**
* Handle configuration file change events
* @param {string} eventType - Type of file system event (change, rename)
* @param {string} filename - Name of the file that triggered the event
*/
function handleConfigFileChange(eventType, filename) {
try {
if (configFileName === filename) {
nodeCache.del(configFileName);
operationContext.global.logger.info(`handleConfigFileChange configuration ${eventType}: ${configFileName}`);
}
} catch (err) {
operationContext.global.logger.error(`handleConfigFileChange error: ${err.message}`);
}
}
/**
* Initialize the configuration directory watcher
*/
function initConfigWatcher(ctx) {
try {
const configDir = path.dirname(configFilePath);
const watcher = fsWatch.watch(configDir, handleConfigFileChange);
watcher.on('error', (err) => {
ctx.logger.error(`initConfigWatcher error: ${err.message}`);
});
ctx.logger.info(`initConfigWatcherWatching for changes in: ${configDir}`);
} catch (watchErr) {
ctx.logger.error(`initConfigWatcher error: ${watchErr.message}`);
}
}
module.exports = {
getConfig,
saveConfig
};

View File

@ -4,12 +4,12 @@ const path = require('path');
const { BlobServiceClient, StorageSharedKeyCredential, generateBlobSASQueryParameters, BlobSASPermissions } = require('@azure/storage-blob');
const mime = require('mime');
const config = require('config');
const { Readable } = require('stream');
const utils = require('../utils');
const ms = require('ms');
const commonDefines = require('../commondefines');
const cfgExpSessionAbsolute = ms(config.get('services.CoAuthoring.expire.sessionabsolute'));
const cfgCacheStorage = config.get('storage');
const MAX_DELETE_OBJECTS = 1000;
const blobServiceClients = {};
@ -20,22 +20,22 @@ const blobServiceClients = {};
* @returns {BlobServiceClient} The Azure Blob Service client
*/
function getBlobServiceClient(storageCfg) {
const configKey = `${storageCfg.accessKeyId}_${storageCfg.bucketName}`;
if (!blobServiceClients[configKey]) {
const credential = new StorageSharedKeyCredential(
storageCfg.accessKeyId,
storageCfg.secretAccessKey
);
if (storageCfg.endpoint.includes(storageCfg.accessKeyId)) {
blobServiceClients[configKey] = new BlobServiceClient(storageCfg.endpoint, credential);
} else {
const endpointUrl = new URL(storageCfg.endpoint.replace(/\/+$/, ''));
blobServiceClients[configKey] = new BlobServiceClient(
`${endpointUrl.protocol}//${storageCfg.accessKeyId}.${endpointUrl.host}`,
credential);
}
const configKey = `${storageCfg.accessKeyId}_${storageCfg.bucketName}`;
if (!blobServiceClients[configKey]) {
const credential = new StorageSharedKeyCredential(
storageCfg.accessKeyId,
storageCfg.secretAccessKey
);
if (storageCfg.endpoint.includes(storageCfg.accessKeyId)) {
blobServiceClients[configKey] = new BlobServiceClient(storageCfg.endpoint, credential);
} else {
const endpointUrl = new URL(storageCfg.endpoint.replace(/\/+$/, ''));
blobServiceClients[configKey] = new BlobServiceClient(
`${endpointUrl.protocol}//${storageCfg.accessKeyId}.${endpointUrl.host}`,
credential);
}
return blobServiceClients[configKey];
}
return blobServiceClients[configKey];
}
/**
@ -45,8 +45,8 @@ function getBlobServiceClient(storageCfg) {
* @returns {ContainerClient} The Azure Container client
*/
function getContainerClient(storageCfg) {
const blobServiceClient = getBlobServiceClient(storageCfg);
return blobServiceClient.getContainerClient(storageCfg.bucketName);
const blobServiceClient = getBlobServiceClient(storageCfg);
return blobServiceClient.getContainerClient(storageCfg.bucketName);
}
/**
@ -69,162 +69,157 @@ function getBlobClient(storageCfg, blobName) {
* @returns {string} The full file path
*/
function getFilePath(storageCfg, strPath) {
const storageFolderName = storageCfg.storageFolderName;
return `${storageFolderName}/${strPath}`;
const storageFolderName = storageCfg.storageFolderName;
return `${storageFolderName}/${strPath}`;
}
async function listObjectsExec(storageCfg, prefix, output = []) {
const containerClient = getContainerClient(storageCfg);
const storageFolderName = storageCfg.storageFolderName;
const prefixWithFolder = storageFolderName ? `${storageFolderName}/${prefix}` : prefix;
const containerClient = getContainerClient(storageCfg);
const storageFolderName = storageCfg.storageFolderName;
const prefixWithFolder = storageFolderName ? `${storageFolderName}/${prefix}` : prefix;
for await (const blob of containerClient.listBlobsFlat({ prefix: prefixWithFolder })) {
const relativePath = storageFolderName ?
blob.name.substring(storageFolderName.length + 1) : blob.name;
output.push(relativePath);
}
return output;
for await (const blob of containerClient.listBlobsFlat({prefix: prefixWithFolder})) {
const relativePath = storageFolderName ?
blob.name.substring(storageFolderName.length + 1) : blob.name;
output.push(relativePath);
}
return output;
}
async function deleteObjectsHelp(storageCfg, aKeys) {
const containerClient = getContainerClient(storageCfg);
await Promise.all(
aKeys.map(key => containerClient.deleteBlob(key.Key))
);
const containerClient = getContainerClient(storageCfg);
await Promise.all(
aKeys.map(key => containerClient.deleteBlob(key.Key))
);
}
async function headObject(storageCfg, strPath) {
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const properties = await blobClient.getProperties();
return { ContentLength: properties.contentLength };
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const properties = await blobClient.getProperties();
return {ContentLength: properties.contentLength};
}
async function getObject(storageCfg, strPath) {
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const response = await blobClient.download();
return await utils.stream2Buffer(response.readableStreamBody);
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const response = await blobClient.download();
return await utils.stream2Buffer(response.readableStreamBody);
}
async function createReadStream(storageCfg, strPath) {
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const response = await blobClient.download();
return {
contentLength: response.contentLength,
readStream: response.readableStreamBody
};
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const response = await blobClient.download();
return {
contentLength: response.contentLength,
readStream: response.readableStreamBody
};
}
async function putObject(storageCfg, strPath, buffer, contentLength) {
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const uploadOptions = {
blobHTTPHeaders: {
contentType: mime.getType(strPath),
contentDisposition: utils.getContentDisposition(path.basename(strPath))
}
};
if (buffer instanceof Buffer) {
// Handle Buffer upload
await blobClient.uploadData(buffer, uploadOptions);
} else if (typeof buffer.pipe === 'function') {
// Handle Stream upload
await blobClient.uploadStream(buffer, undefined, undefined, uploadOptions);
} else {
throw new TypeError('Input must be Buffer or Readable stream');
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const uploadOptions = {
blobHTTPHeaders: {
contentType: mime.getType(strPath),
contentDisposition: utils.getContentDisposition(path.basename(strPath))
}
};
if (buffer instanceof Buffer) {
// Handle Buffer upload
await blobClient.uploadData(buffer, uploadOptions);
} else if (typeof buffer.pipe === 'function') {
// Handle Stream upload
await blobClient.uploadStream(buffer, undefined, undefined, uploadOptions);
} else {
throw new TypeError('Input must be Buffer or Readable stream');
}
}
async function uploadObject(storageCfg, strPath, filePath) {
const blockBlobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const uploadStream = fs.createReadStream(filePath);
await blockBlobClient.uploadStream(
uploadStream,
undefined,
undefined,
{
blobHTTPHeaders: {
contentType: mime.getType(strPath),
contentDisposition: utils.getContentDisposition(path.basename(strPath))
}
}
);
const blockBlobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const uploadStream = fs.createReadStream(filePath);
await blockBlobClient.uploadStream(
uploadStream,
undefined,
undefined,
{
blobHTTPHeaders: {
contentType: mime.getType(strPath),
contentDisposition: utils.getContentDisposition(path.basename(strPath))
}
}
);
}
async function copyObject(storageCfgSrc, storageCfgDst, sourceKey, destinationKey) {
const sourceBlobClient = getBlobClient(storageCfgSrc, getFilePath(storageCfgSrc, sourceKey));
const destBlobClient = getBlobClient(storageCfgDst, getFilePath(storageCfgDst, destinationKey));
const sasToken = generateBlobSASQueryParameters({
containerName: storageCfgSrc.bucketName,
blobName: getFilePath(storageCfgSrc, sourceKey),
permissions: BlobSASPermissions.parse("r"),
startsOn: new Date(),
expiresOn: new Date(Date.now() + 3600 * 1000)
}, new StorageSharedKeyCredential(storageCfgSrc.accessKeyId, storageCfgSrc.secretAccessKey)).toString();
const sourceBlobClient = getBlobClient(storageCfgSrc, getFilePath(storageCfgSrc, sourceKey));
const destBlobClient = getBlobClient(storageCfgDst, getFilePath(storageCfgDst, destinationKey));
const sasToken = generateBlobSASQueryParameters({
containerName: storageCfgSrc.bucketName,
blobName: getFilePath(storageCfgSrc, sourceKey),
permissions: BlobSASPermissions.parse("r"),
startsOn: new Date(),
expiresOn: new Date(Date.now() + 3600 * 1000)
}, new StorageSharedKeyCredential(storageCfgSrc.accessKeyId, storageCfgSrc.secretAccessKey)).toString();
await destBlobClient.syncCopyFromURL(`${sourceBlobClient.url}?${sasToken}`);
await destBlobClient.syncCopyFromURL(`${sourceBlobClient.url}?${sasToken}`);
}
async function listObjects(storageCfg, strPath) {
return await listObjectsExec(storageCfg, strPath);
return await listObjectsExec(storageCfg, strPath);
}
async function deleteObject(storageCfg, strPath) {
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
await blobClient.delete();
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
await blobClient.delete();
}
async function deleteObjects(storageCfg, strPaths) {
let aKeys = strPaths.map(path => ({ Key: getFilePath(storageCfg, path) }));
for (let i = 0; i < aKeys.length; i += MAX_DELETE_OBJECTS) {
await deleteObjectsHelp(storageCfg, aKeys.slice(i, i + MAX_DELETE_OBJECTS));
}
let aKeys = strPaths.map(path => ({Key: getFilePath(storageCfg, path)}));
for (let i = 0; i < aKeys.length; i += MAX_DELETE_OBJECTS) {
await deleteObjectsHelp(storageCfg, aKeys.slice(i, i + MAX_DELETE_OBJECTS));
}
}
async function deletePath(storageCfg, strPath) {
let list = await listObjects(storageCfg, strPath);
await deleteObjects(storageCfg, list);
let list = await listObjects(storageCfg, strPath);
await deleteObjects(storageCfg, list);
}
async function getSignedUrlWrapper(ctx, storageCfg, baseUrl, strPath, urlType, optFilename, opt_creationDate) {
const storageUrlExpires = storageCfg.fs.urlExpires;
let expires = (commonDefines.c_oAscUrlTypes.Session === urlType ? cfgExpSessionAbsolute / 1000 : storageUrlExpires) || 31536000;
expires = Math.min(expires, 604800);
const userFriendlyName = optFilename ? optFilename.replace(/\//g, "%2f") : path.basename(strPath);
const contentDisposition = utils.getContentDisposition(userFriendlyName, null, null);
async function getDirectSignedUrl(ctx, storageCfg, baseUrl, strPath, urlType, optFilename, opt_creationDate) {
const storageUrlExpires = storageCfg.fs.urlExpires;
let expires = (commonDefines.c_oAscUrlTypes.Session === urlType ? cfgExpSessionAbsolute / 1000 : storageUrlExpires) || 31536000;
expires = Math.min(expires, 604800);
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
const userFriendlyName = optFilename ? optFilename.replace(/\//g, "%2f") : path.basename(strPath);
const contentDisposition = utils.getContentDisposition(userFriendlyName, null, null);
const sasOptions = {
permissions: BlobSASPermissions.parse("r"),
expiresOn: new Date(Date.now() + expires * 1000),
contentDisposition,
contentType: mime.getType(strPath)
};
const blobClient = getBlobClient(storageCfg, getFilePath(storageCfg, strPath));
return await blobClient.generateSasUrl(sasOptions);
const sasOptions = {
permissions: BlobSASPermissions.parse("r"),
expiresOn: new Date(Date.now() + expires * 1000),
contentDisposition,
contentType: mime.getType(strPath)
};
return await blobClient.generateSasUrl(sasOptions);
}
/**
* Determines if static routs is needed for cacheFolder
*
* @returns {boolean} Always returns false for Azure Blob Storage
*/
function needServeStatic() {
return false;
return !cfgCacheStorage.useDirectStorageUrls;
}
module.exports = {
headObject,
getObject,
createReadStream,
putObject,
uploadObject,
copyObject,
listObjects,
deleteObject,
deletePath,
getSignedUrl: getSignedUrlWrapper,
needServeStatic
headObject,
getObject,
createReadStream,
putObject,
uploadObject,
copyObject,
listObjects,
deleteObject,
deletePath,
getDirectSignedUrl,
needServeStatic
};

View File

@ -33,12 +33,21 @@
'use strict';
const os = require('os');
const cluster = require('cluster');
const path = require('path');
const crypto = require('crypto');
var config = require('config');
var utils = require('../utils');
const commonDefines = require('../commondefines');
const constants = require('../constants');
const ms = require('ms');
const cfgExpSessionAbsolute = ms(config.get('services.CoAuthoring.expire.sessionabsolute'));
const cfgCacheStorage = config.get('storage');
const cfgPersistentStorage = utils.deepMergeObjects({}, cfgCacheStorage, config.get('persistentStorage'));
// Stubs are needed until integrators pass these parameters to all requests
let shardKeyCached;
let wopiSrcCached;
const cacheStorage = require('./' + cfgCacheStorage.name);
const persistentStorage = require('./' + cfgPersistentStorage.name);
const tenantManager = require('../tenantManager');
@ -50,10 +59,10 @@ function getStoragePath(ctx, strPath, opt_specialDir) {
return opt_specialDir + '/' + tenantManager.getTenantPathPrefix(ctx) + strPath.replace(/\\/g, '/');
}
function getStorage(opt_specialDir) {
return opt_specialDir ? persistentStorage : cacheStorage;
return (opt_specialDir && opt_specialDir !== cfgCacheStorage.cacheFolderName) ? persistentStorage : cacheStorage;
}
function getStorageCfg(ctx, opt_specialDir) {
return opt_specialDir ? cfgPersistentStorage : cfgCacheStorage;
return (opt_specialDir && opt_specialDir !== cfgCacheStorage.cacheFolderName) ? cfgPersistentStorage : cfgCacheStorage;
}
function canCopyBetweenStorage(storageCfgSrc, storageCfgDst) {
return storageCfgSrc.name === storageCfgDst.name && storageCfgSrc.endpoint === storageCfgDst.endpoint;
@ -135,28 +144,65 @@ async function deletePath(ctx, strPath, opt_specialDir) {
async function getSignedUrl(ctx, baseUrl, strPath, urlType, optFilename, opt_creationDate, opt_specialDir) {
let storage = getStorage(opt_specialDir);
let storageCfg = getStorageCfg(ctx, opt_specialDir);
return await storage.getSignedUrl(ctx, storageCfg, baseUrl, getStoragePath(ctx, strPath, opt_specialDir), urlType, optFilename, opt_creationDate);
let storagePath = getStoragePath(ctx, strPath, opt_specialDir);
if (storageCfg.useDirectStorageUrls && storage.getDirectSignedUrl) {
return await storage.getDirectSignedUrl(ctx, storageCfg, baseUrl, storagePath, urlType, optFilename, opt_creationDate);
} else {
const storageSecretString = storageCfg.fs.secretString;
const storageUrlExpires = storageCfg.fs.urlExpires;
//use fixed bucket name because it hard-coded in nginx
const bucketName = storageCfg.name === 'storage-fs' ? 'cache' : 'storage-cache';
const storageFolderName = storageCfg.storageFolderName;
//replace '/' with %2f before encodeURIComponent becase nginx determine %2f as '/' and get wrong system path
const userFriendlyName = optFilename ? encodeURIComponent(optFilename.replace(/\//g, "%2f")) : path.basename(strPath);
var uri = '/' + bucketName + '/' + storageFolderName + '/' + storagePath + '/' + userFriendlyName;
//RFC 1123 does not allow underscores https://stackoverflow.com/questions/2180465/can-domain-name-subdomains-have-an-underscore-in-it
var url = utils.checkBaseUrl(ctx, baseUrl, storageCfg).replace(/_/g, "%5f");
url += uri;
var date = Date.now();
let creationDate = opt_creationDate || date;
let expiredAfter = (commonDefines.c_oAscUrlTypes.Session === urlType ? (cfgExpSessionAbsolute / 1000) : storageUrlExpires) || 31536000;
//todo creationDate can be greater because mysql CURRENT_TIMESTAMP uses local time, not UTC
var expires = creationDate + Math.ceil(Math.abs(date - creationDate) / expiredAfter) * expiredAfter;
expires = Math.ceil(expires / 1000);
expires += expiredAfter;
var md5 = crypto.createHash('md5').update(expires + decodeURIComponent(uri) + storageSecretString).digest("base64");
md5 = md5.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
url += '?md5=' + encodeURIComponent(md5);
url += '&expires=' + encodeURIComponent(expires);
if (ctx.shardKey) {
shardKeyCached = ctx.shardKey;
url += `&${constants.SHARD_KEY_API_NAME}=${encodeURIComponent(ctx.shardKey)}`;
} else if (ctx.wopiSrc) {
wopiSrcCached = ctx.wopiSrc;
url += `&${constants.SHARD_KEY_WOPI_NAME}=${encodeURIComponent(ctx.wopiSrc)}`;
} else if (process.env.DEFAULT_SHARD_KEY) {
//Set DEFAULT_SHARD_KEY from environment as shardkey in case of integrator did not pass this param
url += `&${constants.SHARD_KEY_API_NAME}=${encodeURIComponent(process.env.DEFAULT_SHARD_KEY)}`;
} else if (shardKeyCached) {
//Add stubs for shardkey params until integrators pass these parameters to all requests
url += `&${constants.SHARD_KEY_API_NAME}=${encodeURIComponent(shardKeyCached)}`;
} else if (wopiSrcCached) {
url += `&${constants.SHARD_KEY_WOPI_NAME}=${encodeURIComponent(wopiSrcCached)}`;
}
url += '&filename=' + userFriendlyName;
return url;
}
}
async function getSignedUrls(ctx, baseUrl, strPath, urlType, opt_creationDate, opt_specialDir) {
let storagePathSrc = getStoragePath(ctx, strPath, opt_specialDir);
let storage = getStorage(opt_specialDir);
let storageCfg = getStorageCfg(ctx, opt_specialDir);
let list = await storage.listObjects(storageCfg, storagePathSrc, storageCfg);
let urls = await Promise.all(list.map(function(curValue) {
return storage.getSignedUrl(ctx, storageCfg, baseUrl, curValue, urlType, undefined, opt_creationDate);
}));
let list = await listObjects(ctx, strPath, opt_specialDir);
let outputMap = {};
for (let i = 0; i < list.length && i < urls.length; ++i) {
outputMap[getRelativePath(storagePathSrc, list[i])] = urls[i];
for (let i = 0; i < list.length; ++i) {
outputMap[getRelativePath(strPath, list[i])] = await getSignedUrl(ctx, baseUrl, list[i], urlType, undefined, opt_creationDate, opt_specialDir);
}
return outputMap;
}
async function getSignedUrlsArrayByArray(ctx, baseUrl, list, urlType, opt_specialDir) {
return await Promise.all(list.map(function (curValue) {
let storage = getStorage(opt_specialDir);
let storageCfg = getStorageCfg(ctx, opt_specialDir);
let storagePathSrc = getStoragePath(ctx, curValue, opt_specialDir);
return storage.getSignedUrl(ctx, storageCfg, baseUrl, storagePathSrc, urlType, undefined);
return getSignedUrl(ctx, baseUrl, curValue, urlType, undefined, undefined, opt_specialDir);
}));
}
async function getSignedUrlsByArray(ctx, baseUrl, list, optPath, urlType, opt_specialDir) {

View File

@ -36,17 +36,7 @@ const { cp, rm, mkdir } = require('fs/promises');
const { stat, readFile, writeFile } = require('fs/promises');
var path = require('path');
var utils = require("../utils");
var crypto = require('crypto');
const ms = require('ms');
const config = require('config');
const commonDefines = require('../commondefines');
const constants = require('../constants');
const cfgExpSessionAbsolute = ms(config.get('services.CoAuthoring.expire.sessionabsolute'));
//Stubs are needed until integrators pass these parameters to all requests
let shardKeyCached;
let wopiSrcCached;
function getFilePath(storageCfg, strPath) {
const storageFolderPath = storageCfg.fs.folderPath;
@ -120,50 +110,6 @@ async function deletePath(storageCfg, strPath) {
return rm(fsPath, {force: true, recursive: true, maxRetries: 3});
}
async function getSignedUrl(ctx, storageCfg, baseUrl, strPath, urlType, optFilename, opt_creationDate) {
const storageSecretString = storageCfg.fs.secretString;
const storageUrlExpires = storageCfg.fs.urlExpires;
const bucketName = storageCfg.bucketName;
const storageFolderName = storageCfg.storageFolderName;
//replace '/' with %2f before encodeURIComponent becase nginx determine %2f as '/' and get wrong system path
const userFriendlyName = optFilename ? encodeURIComponent(optFilename.replace(/\//g, "%2f")) : path.basename(strPath);
var uri = '/' + bucketName + '/' + storageFolderName + '/' + strPath + '/' + userFriendlyName;
//RFC 1123 does not allow underscores https://stackoverflow.com/questions/2180465/can-domain-name-subdomains-have-an-underscore-in-it
var url = utils.checkBaseUrl(ctx, baseUrl, storageCfg).replace(/_/g, "%5f");
url += uri;
var date = Date.now();
let creationDate = opt_creationDate || date;
let expiredAfter = (commonDefines.c_oAscUrlTypes.Session === urlType ? (cfgExpSessionAbsolute / 1000) : storageUrlExpires) || 31536000;
//todo creationDate can be greater because mysql CURRENT_TIMESTAMP uses local time, not UTC
var expires = creationDate + Math.ceil(Math.abs(date - creationDate) / expiredAfter) * expiredAfter;
expires = Math.ceil(expires / 1000);
expires += expiredAfter;
var md5 = crypto.createHash('md5').update(expires + decodeURIComponent(uri) + storageSecretString).digest("base64");
md5 = md5.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
url += '?md5=' + encodeURIComponent(md5);
url += '&expires=' + encodeURIComponent(expires);
if (ctx.shardKey) {
shardKeyCached = ctx.shardKey;
url += `&${constants.SHARD_KEY_API_NAME}=${encodeURIComponent(ctx.shardKey)}`;
} else if (ctx.wopiSrc) {
wopiSrcCached = ctx.wopiSrc;
url += `&${constants.SHARD_KEY_WOPI_NAME}=${encodeURIComponent(ctx.wopiSrc)}`;
} else if (process.env.DEFAULT_SHARD_KEY) {
//Set DEFAULT_SHARD_KEY from environment as shardkey in case of integrator did not pass this param
url += `&${constants.SHARD_KEY_API_NAME}=${encodeURIComponent(process.env.DEFAULT_SHARD_KEY)}`;
} else if (shardKeyCached) {
//Add stubs for shardkey params until integrators pass these parameters to all requests
url += `&${constants.SHARD_KEY_API_NAME}=${encodeURIComponent(shardKeyCached)}`;
} else if (wopiSrcCached) {
url += `&${constants.SHARD_KEY_WOPI_NAME}=${encodeURIComponent(wopiSrcCached)}`;
}
url += '&filename=' + userFriendlyName;
return url;
}
function needServeStatic() {
return true;
}
@ -178,6 +124,5 @@ module.exports = {
listObjects,
deleteObject,
deletePath,
getSignedUrl,
needServeStatic
};

View File

@ -32,7 +32,6 @@
'use strict';
const fs = require('fs');
const url = require('url');
const { Agent: HttpsAgent } = require('https');
const { Agent: HttpAgent } = require('http');
const path = require('path');
@ -49,6 +48,7 @@ const commonDefines = require('../commondefines');
const cfgExpSessionAbsolute = ms(config.get('services.CoAuthoring.expire.sessionabsolute'));
const cfgRequestDefaults = config.util.cloneDeep(config.get('services.CoAuthoring.requestDefaults'));
const cfgCacheStorage = config.get('storage');
//This operation enables you to delete multiple objects from a bucket using a single HTTP request. You may specify up to 1000 keys.
const MAX_DELETE_OBJECTS = 1000;
@ -225,13 +225,15 @@ async function deletePath(storageCfg, strPath) {
let list = await listObjects(storageCfg, strPath);
await deleteObjects(storageCfg, list);
}
async function getSignedUrlWrapper(ctx, storageCfg, baseUrl, strPath, urlType, optFilename, opt_creationDate) {
async function getDirectSignedUrl(ctx, storageCfg, baseUrl, strPath, urlType, optFilename, opt_creationDate) {
const storageUrlExpires = storageCfg.fs.urlExpires;
let expires = (commonDefines.c_oAscUrlTypes.Session === urlType ? cfgExpSessionAbsolute / 1000 : storageUrlExpires) || 31536000;
// Signature version 4 presigned URLs must have an expiration date less than one week in the future
expires = Math.min(expires, 604800);
let userFriendlyName = optFilename ? optFilename.replace(/\//g, "%2f") : path.basename(strPath);
let contentDisposition = utils.getContentDisposition(userFriendlyName, null, null);
let userFriendlyName = optFilename ? optFilename.replace(/\//g, "%2f") : path.basename(strPath);
let contentDisposition = utils.getContentDisposition(userFriendlyName, null, null);
const input = {
Bucket: storageCfg.bucketName,
@ -242,7 +244,7 @@ async function getSignedUrlWrapper(ctx, storageCfg, baseUrl, strPath, urlType, o
//default Expires 900 seconds
let options = {
expiresIn: expires
};
};
return await getSignedUrl(getS3Client(storageCfg), command, options);
//extra query params cause SignatureDoesNotMatch
//https://stackoverflow.com/questions/55503009/amazon-s3-signature-does-not-match-when-extra-query-params-ga-added-in-url
@ -250,7 +252,7 @@ async function getSignedUrlWrapper(ctx, storageCfg, baseUrl, strPath, urlType, o
}
function needServeStatic() {
return false;
return !cfgCacheStorage.useDirectStorageUrls;
}
module.exports = {
@ -263,6 +265,6 @@ module.exports = {
listObjects,
deleteObject,
deletePath,
getSignedUrl: getSignedUrlWrapper,
getDirectSignedUrl,
needServeStatic
};

View File

@ -39,7 +39,7 @@ const license = require('./../../Common/sources/license');
const constants = require('./../../Common/sources/constants');
const commonDefines = require('./../../Common/sources/commondefines');
const utils = require('./../../Common/sources/utils');
const { readFile, readdir } = require('fs/promises');
const { readFile, readdir, writeFile } = require('fs/promises');
const path = require('path');
const cfgTenantsBaseDomain = config.get('tenants.baseDomain');
@ -123,6 +123,24 @@ async function getTenantConfig(ctx) {
}
return res;
}
/**
* Set tenant configuration for the current context
* @param {operationContext} ctx - Operation context
* @param {Object} config - Configuration data to save
* @returns {Object} Saved configuration object
*/
async function setTenantConfig(ctx, config) {
let newConfig = await getTenantConfig(ctx);
if (isMultitenantMode(ctx) && !isDefaultTenant(ctx)) {
newConfig = utils.deepMergeObjects(newConfig || {}, config);
let tenantPath = utils.removeIllegalCharacters(ctx.tenant);
let configPath = path.join(cfgTenantsBaseDir, tenantPath, cfgTenantsFilenameConfig);
await writeFile(configPath, JSON.stringify(newConfig, null, 2), 'utf8');
nodeCache.set(configPath, newConfig);
}
return newConfig;
}
function getTenantSecret(ctx, type) {
return co(function*() {
let cfgTenant;
@ -353,6 +371,11 @@ async function readLicenseTenant(ctx, licenseFile, baseVerifiedLicense) {
res.usersExpire = Math.max(constants.LICENSE_EXPIRE_USERS_ONE_DAY, (oLicense['users_expire'] >> 0) *
constants.LICENSE_EXPIRE_USERS_ONE_DAY);
}
// Read grace_days setting from license file if available
if (oLicense.hasOwnProperty('grace_days')) {
res.graceDays = Math.max(0, oLicense['grace_days'] >> 0);
}
const timeLimited = 0 !== (res.mode & c_LM.Limited);
@ -365,8 +388,8 @@ async function readLicenseTenant(ctx, licenseFile, baseVerifiedLicense) {
res.type = c_LR.NotBefore;
ctx.logger.warn('License: License not active before start_date:%s.', startDate.toISOString());
} else if (timeLimited) {
// 30 days after end license = limited mode with 20 Connections
if (res.endDate.setUTCDate(res.endDate.getUTCDate() + 30) >= checkDate) {
// Grace period after end license = limited mode with limited connections
if (res.endDate.setUTCDate(res.endDate.getUTCDate() + res.graceDays) >= checkDate) {
res.type = c_LR.SuccessLimit;
res.connections = Math.min(res.connections, constants.LICENSE_CONNECTIONS);
res.connectionsView = Math.min(res.connectionsView, constants.LICENSE_CONNECTIONS);
@ -374,7 +397,7 @@ async function readLicenseTenant(ctx, licenseFile, baseVerifiedLicense) {
res.usersViewCount = Math.min(res.usersViewCount, constants.LICENSE_USERS);
let errStr = res.usersCount ? `${res.usersCount} unique users` : `${res.connections} concurrent connections`;
ctx.logger.error(`License: License needs to be renewed.\nYour users have only ${errStr} ` +
`available for document editing for the next 30 days.\nPlease renew the ` +
`available for document editing for the next ${graceDays} days.\nPlease renew the ` +
'license to restore the full access');
} else {
res.type = c_LR.ExpiredLimited;
@ -423,6 +446,7 @@ exports.getTenantSecret = getTenantSecret;
exports.getTenantLicense = getTenantLicense;
exports.getServerLicense = getServerLicense;
exports.setDefLicense = setDefLicense;
exports.setTenantConfig = setTenantConfig;
exports.isMultitenantMode = isMultitenantMode;
exports.setMultitenantMode = setMultitenantMode;
exports.isDefaultTenant = isDefaultTenant;

View File

@ -45,7 +45,7 @@ const crypto = require('crypto');
var url = require('url');
var axios = require('../node_modules/axios/dist/node/axios.cjs');
var co = require('co');
var URI = require("uri-js");
var URI = require("uri-js-replace");
const escapeStringRegexp = require('escape-string-regexp');
const ipaddr = require('ipaddr.js');
const getDnsCache = require('dnscache');
@ -494,6 +494,88 @@ async function postRequestPromise(ctx, uri, postData, postDataStream, postDataSi
throw err;
}
}
/**
* Performs an HTTP request with specified method and returns the raw response with a stream.
* @param {operationContext.Context} ctx - The operation context.
* @param {string} method - HTTP method (GET, POST, PUT, DELETE, etc).
* @param {string} uri - The URL for the request.
* @param {object} opt_headers - Optional headers to include in the request.
* @param {*} opt_body - Optional request body data.
* @param {object} opt_timeout - Optional timeout configuration.
* @param {number} opt_limit - Optional limit on the size of the response.
* @param {boolean} opt_filterPrivate - Optional flag to filter private requests.
* @returns {Promise<{response: axios.AxiosResponse, stream: SizeLimitStream}>} - A promise that resolves to an object containing the raw Axios response and a SizeLimitStream.
*/
async function httpRequest(ctx, method, uri, opt_headers, opt_body, opt_timeout, opt_limit, opt_filterPrivate) {
const tenTenantRequestDefaults = ctx.getCfg('services.CoAuthoring.requestDefaults', cfgRequestDefaults);
uri = URI.serialize(URI.parse(uri));
const options = config.util.cloneDeep(tenTenantRequestDefaults);
const httpsAgentOptions = { ...https.globalAgent.options, ...options};
const httpAgentOptions = { ...http.globalAgent.options, ...options};
changeOptionsForCompatibilityWithRequest(options, httpAgentOptions, httpsAgentOptions);
if (!addExternalRequestOptions(ctx, uri, opt_filterPrivate, options, httpAgentOptions, httpsAgentOptions)) {
throw new Error('Block external request. See externalRequest config options');
}
if (!options.httpsAgent || !options.httpAgent) {
options.httpsAgent = new https.Agent(httpsAgentOptions);
options.httpAgent = new http.Agent(httpAgentOptions);
}
const requestHeaders = { ...options.headers };
if (opt_headers) {
Object.assign(requestHeaders, opt_headers);
}
const axiosConfig = {
...options,
url: uri,
method: method,
headers: requestHeaders,
responseType: 'stream',
signal: opt_timeout?.wholeCycle && AbortSignal.timeout ? AbortSignal.timeout(ms(opt_timeout.wholeCycle)) : undefined,
timeout: opt_timeout?.connectionAndInactivity ? ms(opt_timeout.connectionAndInactivity) : undefined,
};
if (opt_body) {
axiosConfig.data = opt_body;
}
try {
const response = await axios(axiosConfig);
const { status, headers, data } = response;
const contentLength = headers['content-length'];
if (opt_limit && contentLength && parseInt(contentLength) > opt_limit) {
const error = new Error('EMSGSIZE: Error response: content-length:' + contentLength);
error.code = 'EMSGSIZE';
response.data.destroy(error);
throw error;
}
const limitedStream = new SizeLimitStream(opt_limit || Number.MAX_VALUE);
response.data.pipe(limitedStream);
return {
response,
stream: limitedStream
};
} catch (err) {
if ('ERR_CANCELED' === err.code) {
err.code = 'ETIMEDOUT';
} else if (['ECONNABORTED', 'ECONNRESET'].includes(err.code)) {
err.code = 'ESOCKETTIMEDOUT';
}
if (err.status) {
err.statusCode = err.status;
}
throw err;
}
}
exports.httpRequest = httpRequest;
exports.postRequestPromise = postRequestPromise;
exports.downloadUrlPromise = downloadUrlPromise;
exports.mapAscServerErrorToOldError = function(error) {
@ -1118,6 +1200,7 @@ exports.convertLicenseInfoToFileParams = function(licenseInfo) {
license.customer_id = licenseInfo.customerId;
license.alias = licenseInfo.alias;
license.multitenancy = licenseInfo.multitenancy;
license.grace_days = licenseInfo.graceDays;
return license;
};
exports.convertLicenseInfoToServerParams = function(licenseInfo) {
@ -1276,3 +1359,5 @@ function deepMergeObjects(target, ...sources) {
}
exports.isObject = isObject;
exports.deepMergeObjects = deepMergeObjects;
exports.NodeCache = NodeCache;//todo via require

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
"prepare4shutdown": "sources/shutdown.js"
},
"dependencies": {
"ajv": "8.17.1",
"apicache": "1.6.3",
"body-parser": "1.20.3",
"bottleneck": "2.19.5",
@ -16,7 +17,7 @@
"co": "4.6.0",
"config": "3.3.12",
"cron": "1.5.0",
"dmdb": "1.0.33801",
"dmdb": "1.0.36002",
"ejs": "3.1.10",
"exif-parser": "0.1.12",
"express": "4.21.2",
@ -26,7 +27,7 @@
"mime": "2.3.1",
"mime-db": "1.53.0",
"ms": "2.1.3",
"mssql": "9.1.1",
"mssql": "11.0.1",
"multer": "1.4.4",
"multi-integer-range": "5.2.0",
"multiparty": "4.2.3",

View File

@ -103,6 +103,7 @@ const queueService = require('./../../Common/sources/taskqueueRabbitMQ');
const operationContext = require('./../../Common/sources/operationContext');
const tenantManager = require('./../../Common/sources/tenantManager');
const { notificationTypes, ...notificationService } = require('../../Common/sources/notificationService');
const aiProxyHandler = require('./ai/aiProxyHandler');
const cfgEditorDataStorage = config.get('services.CoAuthoring.server.editorDataStorage');
const cfgEditorStatStorage = config.get('services.CoAuthoring.server.editorStatStorage');
@ -865,22 +866,60 @@ function* setForceSave(ctx, docId, forceSave, cmd, success, url) {
}
}
}
/**
* @param {commonDefines.InputCommand} cmd - Information about the document conversion
* @returns {string|null} The constructed document path if saveKey and outputPath exist, null otherwise
*/
function getForceSaveDocPath(cmd) {
if (cmd) {
const saveKey = cmd.getDocId() + cmd.getSaveKey();
const outputPath = cmd.getOutputPath();
if (saveKey && outputPath) {
return saveKey + '/' + outputPath;
}
}
return null;
}
/**
* Checks if a force save cache exists and is valid for the provided conversion information
* @param {operationContext.Context} ctx - The request context
* @param {Object} convertInfo - Information about the document conversion
* @returns {Promise<Object>} Object containing cache status information:
* - hasCache {boolean} - Whether cache information exists
* - hasValidCache {boolean} - Whether the cache is valid
* - cmd {commonDefines.InputCommand|null} - Command object (if available)
*/
async function checkForceSaveCache(ctx, convertInfo) {
let res = {hasCache: false, hasValidCache: false, cmd: null};
if (convertInfo) {
res.hasCache = true;
let cmd = new commonDefines.InputCommand(convertInfo, true);
const saveKey = cmd.getDocId() + cmd.getSaveKey();
const outputPath = cmd.getOutputPath();
if (saveKey && outputPath) {
const savePathDoc = saveKey + '/' + outputPath;
const metadata = await storage.headObject(ctx, savePathDoc);
let docPath = getForceSaveDocPath(cmd);
if (docPath) {
const metadata = await storage.headObject(ctx, docPath);
res.hasValidCache = !!metadata;
res.cmd = cmd;
}
}
return res;
}
/**
* Generates a signed URL for accessing a force-saved document
* @param {operationContext.Context} ctx - The request context
* @param {string} baseUrl - Base URL for the document
* @param {Object} convertInfo - Information about the document conversion
* @returns {Promise<string|null>} The signed URL for the force-saved document or null if path cannot be generated
*/
async function getForceSaveUrl(ctx, baseUrl, convertInfo) {
let cmd = new commonDefines.InputCommand(convertInfo, true);
let docPath = getForceSaveDocPath(cmd);
if (docPath) {
return await storage.getSignedUrl(ctx, baseUrl, docPath, commonDefines.c_oAscUrlTypes.Temporary);
}
return null;
}
async function applyForceSaveCache(ctx, docId, forceSave, type, opt_userConnectionId, opt_userConnectionDocId,
opt_responseKey, opt_formdata, opt_userId, opt_userIndex, opt_prevTime) {
let res = {ok: false, notModified: false, inProgress: false, startedForceSave: null};
@ -896,6 +935,7 @@ async function applyForceSaveCache(ctx, docId, forceSave, type, opt_userConnecti
let cacheHasSameOptions = (commonDefines.c_oAscForceSaveTypes.Form === type && commonDefines.c_oAscForceSaveTypes.Form === forceSaveCached) ||
(commonDefines.c_oAscForceSaveTypes.Form !== type && commonDefines.c_oAscForceSaveTypes.Form !== forceSaveCached);
if (forceSaveCache.hasValidCache && cacheHasSameOptions) {
//compare opt_prevTime because Internal command can be called by different users
if (commonDefines.c_oAscForceSaveTypes.Internal === type && forceSave.time === opt_prevTime) {
res.notModified = true;
} else {
@ -922,9 +962,21 @@ async function applyForceSaveCache(ctx, docId, forceSave, type, opt_userConnecti
res.notModified = true;
}
} else if (!forceSave.started) {
res.startedForceSave = await editorData.checkAndStartForceSave(ctx, docId);
res.ok = !!res.startedForceSave;
return res;
const isTypeToSendFile = commonDefines.c_oAscForceSaveTypes.Command === type ||
commonDefines.c_oAscForceSaveTypes.Button === type ||
commonDefines.c_oAscForceSaveTypes.Timeout === type ||
commonDefines.c_oAscForceSaveTypes.Form === type;
if (isTypeToSendFile) {
const selectRes = await taskResult.selectWithCache(ctx, docId);
if (selectRes.length > 0 && !selectRes[0].callback) {
ctx.logger.debug('applyForceSaveCache empty callback: %s', docId);
res.notModified = true;
return res;
}
}
res.startedForceSave = await editorData.checkAndStartForceSave(ctx, docId);
res.ok = !!res.startedForceSave;
return res;
} else if (commonDefines.c_oAscForceSaveTypes.Form === type || commonDefines.c_oAscForceSaveTypes.Internal === type) {
res.ok = true;
res.inProgress = true;
@ -948,6 +1000,10 @@ async function startForceSave(ctx, docId, type, opt_userdata, opt_formdata, opt_
return !!JSON.parse(currentValue).encrypted;
});
if (!hasEncrypted) {
let baseUrl = opt_baseUrl || "";
if (opt_conn) {
baseUrl = utils.getBaseUrlByConnection(ctx, opt_conn);
}
let forceSave = await editorData.getForceSave(ctx, docId);
let forceSaveWithConnection = opt_conn && (commonDefines.c_oAscForceSaveTypes.Form === type ||
(commonDefines.c_oAscForceSaveTypes.Button === type && tenForceSaveUsingButtonWithoutChanges));
@ -957,10 +1013,8 @@ async function startForceSave(ctx, docId, type, opt_userdata, opt_formdata, opt_
let newChangesLastDate = new Date();
newChangesLastDate.setMilliseconds(0);//remove milliseconds avoid issues with MySQL datetime rounding
let newChangesLastTime = newChangesLastDate.getTime();
let baseUrl = opt_baseUrl || "";
let changeInfo = opt_changeInfo;
if (opt_conn) {
baseUrl = utils.getBaseUrlByConnection(ctx, opt_conn);
changeInfo = getExternalChangeInfo(opt_conn.user, newChangesLastTime, opt_conn.lang);
}
await editorData.setForceSave(ctx, docId, newChangesLastTime, 0, baseUrl, changeInfo, null);
@ -973,6 +1027,9 @@ async function startForceSave(ctx, docId, type, opt_userdata, opt_formdata, opt_
let selectRes = await taskResult.select(ctx, docId);
if (selectRes.length > 0) {
res.code = commonDefines.c_oAscServerCommandErrors.NotModified;
if (forceSave) {
res.url = await getForceSaveUrl(ctx, baseUrl, forceSave.convertInfo);
}
} else {
res.code = commonDefines.c_oAscServerCommandErrors.DocumentIdError;
}
@ -1034,7 +1091,8 @@ let resetForceSaveAfterChanges = co.wrap(function*(ctx, docId, newChangesLastTim
}
}
});
let saveRelativeFromChanges = co.wrap(function*(ctx, conn, responseKey, data) {
async function saveRelativeFromChanges(ctx, conn, responseKey, data) {
const tenTokenEnableBrowser = ctx.getCfg('services.CoAuthoring.token.enable.browser', cfgTokenEnableBrowser);
let docId = data.docId;
@ -1042,7 +1100,7 @@ let saveRelativeFromChanges = co.wrap(function*(ctx, conn, responseKey, data) {
let forceSaveRes;
if (tenTokenEnableBrowser) {
docId = null;
let checkJwtRes = yield checkJwt(ctx, token, commonDefines.c_oAscSecretType.Browser);
let checkJwtRes = await checkJwt(ctx, token, commonDefines.c_oAscSecretType.Browser);
if (checkJwtRes.decoded) {
docId = checkJwtRes.decoded.key;
} else {
@ -1051,13 +1109,13 @@ let saveRelativeFromChanges = co.wrap(function*(ctx, conn, responseKey, data) {
}
}
if (!forceSaveRes) {
forceSaveRes = yield startForceSave(ctx, docId, commonDefines.c_oAscForceSaveTypes.Internal, undefined, undefined, undefined, conn.user.id, conn.docId, undefined, responseKey,
undefined, undefined, undefined, undefined, undefined, undefined, undefined, data.time);
forceSaveRes = await startForceSave(ctx, docId, commonDefines.c_oAscForceSaveTypes.Internal, undefined, undefined, undefined, conn.user.id, conn.docId, undefined, responseKey,
undefined, undefined, undefined, conn, undefined, undefined, undefined, data.time);
}
if (commonDefines.c_oAscServerCommandErrors.NoError !== forceSaveRes.code || forceSaveRes.inProgress) {
sendDataRpc(ctx, conn, responseKey, forceSaveRes);
}
})
}
async function startWopiRPC(ctx, docId, userId, userIdOriginal, data) {
let res;
@ -1072,6 +1130,11 @@ async function startWopiRPC(ctx, docId, userId, userIdOriginal, data) {
switch (data.type) {
case 'wopi_RenameFile':
res = await wopiClient.renameFile(ctx, wopiParams, data.name);
//publish for coeditors
if (res?.Name) {
const meta = {"title": res.Name};
await publish(ctx, {type: commonDefines.c_oPublishType.meta, ctx: ctx, docId: docId, meta});
}
break;
case 'wopi_RefreshFile':
res = await wopiClient.refreshFile(ctx, wopiParams, row.baseurl);
@ -1356,13 +1419,13 @@ function* cleanDocumentOnExit(ctx, docId, deleteChanges, opt_userIndex) {
}
yield unlockWopiDoc(ctx, docId, opt_userIndex);
}
function* cleanDocumentOnExitNoChanges(ctx, docId, opt_userId, opt_userIndex, opt_forceClose) {
function* cleanDocumentOnExitNoChanges(ctx, docId, opt_userId, opt_userIndex, opt_forceClose, opt_deleteChanges) {
var userAction = opt_userId ? new commonDefines.OutputAction(commonDefines.c_oAscUserAction.Out, opt_userId) : null;
// We send that everyone is gone and there are no changes (to set the status on the server about the end of editing)
yield sendStatusDocument(ctx, docId, c_oAscChangeBase.No, userAction, opt_userIndex, undefined, undefined, undefined, opt_forceClose);
//if the user entered the document, the connection was broken, all information was deleted on the server,
//when the connection is restored, the userIndex will be saved and it will match the userIndex of the next user
yield* cleanDocumentOnExit(ctx, docId, false, opt_userIndex);
yield* cleanDocumentOnExit(ctx, docId, opt_deleteChanges || false, opt_userIndex);
}
function createSaveTimer(ctx, docId, opt_userId, opt_userIndex, opt_userLcid, opt_queue, opt_noDelay, opt_initShardKey) {
@ -3438,9 +3501,10 @@ exports.install = function(server, callbackFunction) {
}
let [licenseInfo] = yield tenantManager.getTenantLicense(ctx);
let pluginSettings = yield aiProxyHandler.getPluginSettingsForInterface(ctx);
sendData(ctx, conn, {
type: 'license', license: {
type: 'license',
license: {
type: licenseInfo.type,
light: false,//todo remove in sdk
mode: licenseInfo.mode,
@ -3453,7 +3517,8 @@ exports.install = function(server, callbackFunction) {
branding: licenseInfo.branding,
customization: licenseInfo.customization,
advancedApi: licenseInfo.advancedApi
}
},
aiPluginSettings: pluginSettings
});
ctx.logger.info('_checkLicense end');
} catch (err) {
@ -3934,6 +3999,8 @@ exports.install = function(server, callbackFunction) {
);
});
});
void aiProxyHandler.getPluginSettings(operationContext.global);
};
exports.setLicenseInfo = async function(globalCtx, data, original) {
tenantManager.setDefLicense(data, original);

View File

@ -0,0 +1,269 @@
/*
* (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 { buffer } = require('node:stream/consumers');
const config = require('config');
const utils = require('../../../Common/sources/utils');
const operationContext = require('../../../Common/sources/operationContext');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
// Configuration constants
const cfgAiApiTimeout = config.get('aiSettings.timeout');
const cfgAiPluginDir = config.get('aiSettings.pluginDir');
const engineScriptsDir = path.join(cfgAiPluginDir, 'scripts/engine');
function setCtx(ctx) {
sandbox.ctx = ctx;
console.log = ctx.logger.debug.bind(ctx.logger);//todo make default in logger
console.error = ctx.logger.error.bind(ctx.logger);
}
// Set up the environment for the client-side engine.js
const sandbox = {
ctx: null,
window: {
AI: {
TmpProviderForModels: null,
Providers: {},
InternalProviders: [],
_getHeaders: function() {return {};},
_getEndpointUrl: function() {return "";},
serializeProviders: function() {return [];},
ActionsGetSorted: function() {return [];},
getModels: function() {return [];},
onLoadInternalProviders: function() {},
Storage: {
serializeModels: function() {return [];}
},
CapabilitiesUI: {}
}
},
Asc: {
plugin: {
tr: function(text) {
// Just return the original text in the stub
return text;
}
}
},
/**
* Implementation of fetch that delegates to utils.httpRequest
*
* @param {string} url - The URL to fetch
* @param {Object} options - Fetch options (method, headers, body)
* @returns {Promise<Object>} - A promise that resolves to a response-like object
*/
fetch: function(url, options = {}) {
const ctx = sandbox.ctx;
const method = options.method || 'GET';
// Configure timeout options for the request
const timeoutOptions = {
connectionAndInactivity: cfgAiApiTimeout,
wholeCycle: cfgAiApiTimeout
};
ctx.logger.debug("engineWrapper fetch", url, options);
return utils.httpRequest(
sandbox.ctx,
method,
url,
options.headers || {},
options.body || null,
timeoutOptions,
null,
false
)
.then(async (result) => {
const responseBuffer = await buffer(result.stream);
const text = responseBuffer.toString('utf8');
return {
status: result.response.status,
statusText: result.response.statusText,
ok: result.response.status >= 200 && result.response.status < 300,
headers: result.response.headers,
text: () => Promise.resolve(text),
json: () => Promise.resolve(JSON.parse(text)),
arrayBuffer: () => Promise.resolve(responseBuffer.buffer)
};
});
}
};
// Initialize minimal AI object with required functionality
sandbox.AI = sandbox.window.AI;
setCtx(operationContext.global);
/**
* Simple loadInternalProviders implementation
*/
function loadInternalProviders() {
// Add simple provider loading logic
const enginePath = path.join(engineScriptsDir, 'providers/internal');
// Check if the providers directory exists before trying to read it
if (!fs.existsSync(enginePath)) {
sandbox.ctx.logger.warn('Internal providers directory not found:', enginePath);
sandbox.AI.onLoadInternalProviders();
return;
}
try {
// Read providers directory
const files = fs.readdirSync(enginePath);
// Load each provider
for (const file of files) {
if (file.endsWith('.js')) {
const providerPath = path.join(enginePath, file);
const providerCode = fs.readFileSync(providerPath, 'utf8');
try {
//sandbox.ctx.logger.debug(`Loading provider ${file}:`);
let content = "(function(){\n" + providerCode + "\nreturn new Provider();})();";
// Execute provider code in sandbox
let provider = vm.runInNewContext(content, sandbox, {
filename: file,
timeout: 5000
});
sandbox.AI.InternalProviders.push(provider);
} catch (error) {
sandbox.ctx.logger.error(`Error loading provider ${file}:`, error);
}
}
}
sandbox.AI.onLoadInternalProviders();
} catch (error) {
sandbox.ctx.logger.error('Error loading internal providers:', error);
}
}
// Load engine.js
let engineCode = '';
try {
engineCode += fs.readFileSync(path.join(engineScriptsDir, 'storage.js'), 'utf8');
engineCode += fs.readFileSync(path.join(engineScriptsDir, 'local_storage.js'), 'utf8');
engineCode += fs.readFileSync(path.join(engineScriptsDir, 'providers/base.js'), 'utf8');
engineCode += fs.readFileSync(path.join(engineScriptsDir, 'providers/provider.js'), 'utf8');
engineCode += fs.readFileSync(path.join(engineScriptsDir, 'engine.js'), 'utf8');
} catch (error) {
sandbox.ctx.logger.warn('Error reading engine script files:', error);
}
// Run engine code if available
if (engineCode) {
try {
vm.runInNewContext(engineCode, sandbox);
} catch (error) {
sandbox.ctx.logger.error('Error executing engine scripts:', error);
}
}
//start from engine/register.js
(function() {
const AI = sandbox.AI;
const Asc = sandbox.Asc;
AI.ActionType = {
Chat : "Chat",
Summarization : "Summarization",
Translation : "Translation",
TextAnalyze : "TextAnalyze",
ImageGeneration : "ImageGeneration",
OCR : "OCR",
Vision : "Vision"
};
AI.Actions = {};
function ActionUI(name, icon, modelId, capabilities) {
this.name = name || "";
this.icon = icon || "";
this.model = modelId || "";
this.capabilities = (capabilities === undefined) ? AI.CapabilitiesUI.Chat : capabilities;
}
AI.Actions[AI.ActionType.Chat] = new ActionUI("Chatbot", "ask-ai");
AI.Actions[AI.ActionType.Summarization] = new ActionUI("Summarization", "summarization");
AI.Actions[AI.ActionType.Translation] = new ActionUI("Translation", "translation");
AI.Actions[AI.ActionType.TextAnalyze] = new ActionUI("Text analysis", "text-analysis-ai");
AI.Actions[AI.ActionType.ImageGeneration] = new ActionUI("Image generation", "image-ai", "", AI.CapabilitiesUI.Image);
AI.Actions[AI.ActionType.OCR] = new ActionUI("OCR", "text-analysis-ai", "", AI.CapabilitiesUI.Vision);
AI.Actions[AI.ActionType.Vision] = new ActionUI("Vision", "vision-ai", "", AI.CapabilitiesUI.Vision);
AI.ActionsGetKeys = function()
{
return [
AI.ActionType.Chat,
AI.ActionType.Summarization,
AI.ActionType.Translation,
AI.ActionType.TextAnalyze,
AI.ActionType.ImageGeneration,
AI.ActionType.OCR,
AI.ActionType.Vision
];
};
AI.ActionsGetSorted = function()
{
let keys = AI.ActionsGetKeys();
let count = keys.length;
let actions = new Array(count);
for (let i = 0; i < count; i++)
{
let src = AI.Actions[keys[i]];
actions[i] = {
id : keys[i],
name : Asc.plugin.tr(src.name),
icon : src.icon,
model : src.model,
capabilities : src.capabilities
}
}
return actions;
};
//end from engine/register.js
})();
sandbox.AI.loadInternalProviders = loadInternalProviders;
loadInternalProviders();
exports.setCtx = setCtx;
exports.AI = sandbox.AI;

View File

@ -0,0 +1,470 @@
/*
* (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 docsCoServer = require('./../DocsCoServer');
const statsDClient = require('./../../../Common/sources/statsdclient');
// Import the new aiEngine module
const aiEngine = require('./aiEngineWrapper');
const cfgAiApiAllowedOrigins = config.get('aiSettings.allowedCorsOrigins');
const cfgAiApiTimeout = config.get('aiSettings.timeout');
const cfgTokenEnableBrowser = config.get('services.CoAuthoring.token.enable.browser');
const cfgAiSettings = config.get('aiSettings');
const AI = aiEngine.AI;
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
}
/**
* Appends API key to the request URI if the provider passes it as a query parameter.
*
* @param {operationContext.Context} ctx - The operation context for logging.
* @param {object} provider - The AI provider configuration.
* @param {string} uri - The original request URI.
* @returns {string} The updated URI with API key as a query parameter, if applicable.
*/
function appendApiKeyToQuery(ctx, provider, uri) {
const urlWithKey = AI._getEndpointUrl(provider, AI.Endpoints.Types.v1.Models);
// To check if the key is part of the query, we get the URL without the key.
const originalKey = provider.key;
provider.key = undefined;
const urlWithoutKey = AI._getEndpointUrl(provider, AI.Endpoints.Types.v1.Models);
provider.key = originalKey; // Restore the key on the provider object.
if (urlWithKey !== urlWithoutKey) {
try {
const parsedUrlWithKey = new URL(urlWithKey);
if (parsedUrlWithKey.search) {
const parsedUri = new URL(uri);
for (const [key, value] of parsedUrlWithKey.searchParams) {
if (originalKey === value) {
parsedUri.searchParams.set(key, value);
break;
}
}
ctx.logger.debug(`appendApiKeyToQuery: Appended API key to URI for provider ${provider.name}`);
return parsedUri.toString();
}
} catch (error) {
ctx.logger.error(`appendApiKeyToQuery: Failed to parse provider URL for ${provider.name}: ${urlWithKey}`, error);
}
}
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 tenTokenEnableBrowser = ctx.getCfg('services.CoAuthoring.token.enable.browser', cfgTokenEnableBrowser);
const tenAiApiTimeout = ctx.getCfg('aiSettings.timeout', cfgAiApiTimeout);
const tenAiApi = ctx.getCfg('aiSettings', cfgAiSettings);
// 1. Handle CORS preflight (OPTIONS) requests if necessary
if (handleCorsHeaders(req, res, ctx) === true) {
return; // OPTIONS request handled, stop further processing
}
if (tenTokenEnableBrowser) {
let 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;
}
}
if (!tenAiApi?.providers) {
ctx.logger.error('proxyRequest: No providers configured');
res.status(403).json({
"error": {
"message": "proxyRequest: No providers configured",
"code": "403"
}
});
return;
}
let body = JSON.parse(req.body);
let uri = body.target;
let 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) && AI.Providers[tenProvider.name]) {
providerMatched = true;
const provider = AI.Providers[tenProvider.name];
provider.key = tenProvider.key;
provider.url = tenProvider.url;
providerHeaders = AI._getHeaders(provider);
uri = appendApiKeyToQuery(ctx, provider, uri);
break;
}
}
}
// If body.target was provided but no provider was matched, return 403
if (!providerHeaders) {
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 };
// Configure timeout options for the request
const timeoutOptions = {
connectionAndInactivity: tenAiApiTimeout,
wholeCycle: tenAiApiTimeout
};
// Create request parameters object
const requestParams = {
method: body.method,
uri: uri,
headers,
body: body.data,
timeout: timeoutOptions,
limit: null,
filterPrivate: false
};
// Log the sanitized request parameters
ctx.logger.debug(`Proxying 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.filterPrivate // Filter private requests
);
// 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');
}
}
/**
* Process a single AI provider and its models
*
* @param {operationContext.Context} ctx - Operation context
* @param {Object} provider - Provider configuration
* @returns {Promise<Object|null>} Processed provider with models or null if provider is invalid
*/
async function processProvider(ctx, provider) {
const logger = ctx.logger;
if (!provider.url) {
return null;
}
let engineModels = [];
let engineModelsUI = [];
try {
// Call getModels from engine.js
if (provider.key && AI.Providers[provider.name]) {
AI.Providers[provider.name].key = provider.key;
// aiEngine.setCtx(ctx);
// await AI.getModels(provider);
// // Process result
// if (AI.TmpProviderForModels?.models) {
// engineModels = AI.TmpProviderForModels.models;
// engineModelsUI = AI.TmpProviderForModels.modelsUI;
// }
}
} catch (error) {
logger.error(`Error processing provider ${provider.name}:`, error);
}
// Return provider with any models we were able to get from config
return {
name: provider.name,
url: provider.url,
key: "",
models: engineModels,
modelsUI: engineModelsUI
};
}
/**
* 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) {
const logger = ctx.logger;
logger.info('Starting getPluginSettings');
const result = {
version: 3,
actions: {},
providers: {},
models: [],
customProviders: {}
};
try {
// Get AI API configuration
const tenProviders = ctx.getCfg('aiSettings.providers', cfgAiSettings.providers);
// Process providers and their models if configuration exists
if (tenProviders && Object.keys(tenProviders).length > 0) {
result.providers = tenProviders
} else {
const providers = AI.serializeProviders();
for (let i = 0; i < providers.length; i++) {
const provider = providers[i];
// const cfgProvider = aiApi.providers[provider.name];
// if (cfgProvider) {
// //todo clone
// provider.key = cfgProvider.key;
// }
try {
const providerProcessed = await processProvider(ctx, provider);
provider.models.push(...providerProcessed.models);
} catch (error) {
logger.warn('Error processing provider:', error);
}
result.providers[provider.name] = provider;
}
}
const tenModels = ctx.getCfg('aiSettings.models', cfgAiSettings.models);
// Process AI actions
if (tenModels && tenModels.length > 0) {
result.models = tenModels;
} else {
// result.actions = aiApi.actions;
result.models = AI.Storage.serializeModels();
}
// Process AI actions
const tenActions = ctx.getCfg('aiSettings.actions', cfgAiSettings.actions);
if (tenActions && Object.keys(tenActions).length > 0) {
result.actions = tenActions;
} else {
// result.actions = aiApi.actions;
const actionSoted = AI.ActionsGetSorted();
result.actions = {};
for (let i = 0; i < actionSoted.length; i++) {
const action = actionSoted[i];
result.actions[action.id] = action;
}
}
const tenVersion = ctx.getCfg('aiSettings.version', cfgAiSettings.version);
result.version = tenVersion;
} catch (error) {
logger.error('Error retrieving AI models from config:', error);
}
finally {
logger.info('Completed getPluginSettings');
}
return result;
}
async function getPluginSettingsForInterface(ctx) {
let pluginSettings = await getPluginSettings(ctx);
//check empty settings
if (pluginSettings && pluginSettings.actions) {
let isEmptySettings = true;
for (let key in pluginSettings.actions) {
if (pluginSettings.actions[key].model) {
isEmptySettings = false;
}
}
if (isEmptySettings) {
pluginSettings = undefined;
}
}
//remove keys from providers
if (pluginSettings && pluginSettings.providers) {
for (let key in pluginSettings.providers) {
pluginSettings.providers[key].key = "";
}
}
return pluginSettings;
}
async function requestSettings(req, res) {
const ctx = new operationContext.Context();
ctx.initFromRequest(req);
try {
await ctx.initTenantCache();
const result = await getPluginSettings(ctx);
res.json(result);
} catch (error) {
ctx.logger.error('getSettings error: %s', error.stack);
res.sendStatus(400);
}
}
async function requestModels(req, res) {
const ctx = new operationContext.Context();
ctx.initFromRequest(req);
try {
await ctx.initTenantCache();
let body = JSON.parse(req.body);
if (AI.Providers[body.name]) {
AI.Providers[body.name].key = body.key;
AI.Providers[body.name].url = body.url;
}
let getRes = await AI.getModels(body);
getRes.modelsApi = AI.TmpProviderForModels?.models;
res.json(getRes);
} catch (error) {
ctx.logger.error('getModels error: %s', error.stack);
res.sendStatus(400);
}
}
module.exports = {
proxyRequest,
getPluginSettings,
getPluginSettingsForInterface,
requestSettings,
requestModels
};

View File

@ -44,6 +44,7 @@ var sqlBase = require('./databaseConnectors/baseConnector');
const utilsDocService = require('./utilsDocService');
var docsCoServer = require('./DocsCoServer');
var taskResult = require('./taskresult');
var wopiUtils = require('./wopiUtils');
var wopiClient = require('./wopiClient');
var logger = require('./../../Common/sources/logger');
var utils = require('./../../Common/sources/utils');
@ -650,7 +651,7 @@ function* commandSendMailMerge(ctx, cmd, outputData) {
}
}
let commandSfctByCmd = co.wrap(function*(ctx, cmd, opt_priority, opt_expiration, opt_queue, opt_initShardKey) {
var selectRes = yield taskResult.select(ctx, cmd.getDocId());
var selectRes = yield taskResult.selectWithCache(ctx, cmd.getDocId());
var row = selectRes.length > 0 ? selectRes[0] : null;
if (!row) {
return false;
@ -886,9 +887,11 @@ function* commandSetPassword(ctx, conn, cmd, outputData) {
let hasDocumentPassword = false;
let isDocumentPasswordModified = true;
let originFormat;
let selectRes = yield taskResult.select(ctx, cmd.getDocId());
if (selectRes.length > 0) {
let row = selectRes[0];
originFormat = row.change_id;
hasPasswordCol = undefined !== row.password;
if (commonDefines.FileStatus.Ok === row.status) {
let documentPasswordCurEnc = sqlBase.DocumentPassword.prototype.getCurPassword(ctx, row.password);
@ -920,7 +923,7 @@ function* commandSetPassword(ctx, conn, cmd, outputData) {
var task = new taskResult.TaskResultData();
task.password = cmd.getPassword() || "";
let changeInfo = null;
if (conn.user) {
if (conn.user && (hasDocumentPassword || !formatChecker.isBrowserEditorFormat(originFormat))) {
changeInfo = task.innerPasswordChange = docsCoServer.getExternalChangeInfo(conn.user, newChangesLastDate.getTime(), conn.lang);
}
@ -930,9 +933,11 @@ function* commandSetPassword(ctx, conn, cmd, outputData) {
if (!conn.isEnterCorrectPassword) {
yield docsCoServer.modifyConnectionForPassword(ctx, conn, true);
}
let forceSave = yield docsCoServer.editorData.getForceSave(ctx, cmd.getDocId());
let index = forceSave?.index || 0;
yield docsCoServer.resetForceSaveAfterChanges(ctx, cmd.getDocId(), newChangesLastDate.getTime(), index, utils.getBaseUrlByConnection(ctx, conn), changeInfo);
if (changeInfo) {
let forceSave = yield docsCoServer.editorData.getForceSave(ctx, cmd.getDocId());
let index = forceSave?.index || 0;
yield docsCoServer.resetForceSaveAfterChanges(ctx, cmd.getDocId(), newChangesLastDate.getTime(), index, utils.getBaseUrlByConnection(ctx, conn), changeInfo);
}
} else {
ctx.logger.debug('commandSetPassword sql update error');
outputData.setStatus('err');
@ -1697,7 +1702,7 @@ exports.downloadFile = function(req, res) {
//editnew case
fromTemplate = pathModule.extname(decoded.fileInfo.BaseFileName).substring(1);
} else {
({url, headers} = yield wopiClient.getWopiFileUrl(ctx, decoded.fileInfo, decoded.userAuth));
({url, headers} = yield wopiUtils.getWopiFileUrl(ctx, decoded.fileInfo, decoded.userAuth));
let filterStatus = yield wopiClient.checkIpFilter(ctx, url);
if (0 === filterStatus) {
//todo false? (true because it passed checkIpFilter for wopi)
@ -1802,7 +1807,7 @@ exports.saveFromChanges = function(ctx, docId, statusInfo, optFormat, opt_userId
//we do a select, because during the timeout the information could change
var selectRes = yield taskResult.select(ctx, docId);
var row = selectRes.length > 0 ? selectRes[0] : null;
if (row && row.status == commonDefines.FileStatus.SaveVersion && row.status_info == statusInfo) {
if (row && row.status == commonDefines.FileStatus.SaveVersion && row.status_info == statusInfo && row.callback) {
if (null == optFormat) {
optFormat = changeFormatByOrigin(ctx, row, constants.AVS_OFFICESTUDIO_FILE_OTHER_OOXML);
}
@ -1832,6 +1837,10 @@ exports.saveFromChanges = function(ctx, docId, statusInfo, optFormat, opt_userId
yield docsCoServer.editorStat.addShutdown(redisKeyShutdown, docId);
}
ctx.logger.debug('AddTask saveFromChanges');
} else if(row && !row.callback) {
ctx.logger.debug('saveFromChanges empty callback: %s', docId);
yield docsCoServer.cleanDocumentOnExitNoChangesPromise(ctx, docId, opt_userId, opt_userIndex, false, true);
//todo restore status
} else {
if (row) {
ctx.logger.debug('saveFromChanges status mismatch: row: %d; %d; expected: %d', row.status, row.status_info, statusInfo);

View File

@ -0,0 +1,89 @@
/*
* (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
*
*/
const config = require('config');
const express = require('express');
const bodyParser = require('body-parser');
const tenantManager = require('../../../Common/sources/tenantManager');
const operationContext = require('../../../Common/sources/operationContext');
const runtimeConfigManager = require('../../../Common/sources/runtimeConfigManager');
const router = express.Router();
const rawFileParser = bodyParser.raw(
{inflate: true, limit: config.get('services.CoAuthoring.server.limits_tempfile_upload'), type: function() {return true;}});
router.get('/', async (req, res) => {
let ctx = new operationContext.Context();
let result = '{}';
try {
ctx.initFromRequest(req);
await ctx.initTenantCache();
ctx.logger.debug('config get start');
let cfg = ctx.getFullCfg();
result = JSON.stringify(cfg);
} catch (error) {
ctx.logger.error('config get error: %s', error.stack);
}
finally {
res.setHeader('Content-Type', 'application/json');
res.send(result);
ctx.logger.debug('config end');
}
});
router.post('/', rawFileParser, async (req, res) => {
let ctx = new operationContext.Context();
try {
ctx.initFromRequest(req);
await ctx.initTenantCache();
let newConfig = JSON.parse(req.body);
if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) {
await tenantManager.setTenantConfig(ctx, newConfig);
} else {
await runtimeConfigManager.saveConfig(ctx, newConfig);
}
res.sendStatus(200);
} catch (error) {
ctx.logger.error('Configuration save error: %s', error.stack);
res.status(500).json({
error: 'Failed to save configuration',
details: error.message
});
}
});
module.exports = router;

View File

@ -31,6 +31,8 @@
*/
'use strict';
const { pipeline } = require('node:stream/promises');
const express = require('express');
const config = require('config');
const operationContext = require('./../../../Common/sources/operationContext');
@ -39,6 +41,7 @@ const storage = require('./../../../Common/sources/storage/storage-base');
const urlModule = require("url");
const path = require("path");
const mime = require("mime");
const crypto = require('crypto');
const cfgStaticContent = config.has('services.CoAuthoring.server.static_content') ? config.util.cloneDeep(config.get('services.CoAuthoring.server.static_content')) : {};
const cfgCacheStorage = config.get('storage');
@ -49,40 +52,97 @@ const cfgErrorFiles = config.get('FileConverter.converter.errorfiles');
const router = express.Router();
function initCacheRouter(cfgStorage, routs) {
const bucketName = cfgStorage.bucketName;
const storageFolderName = cfgStorage.storageFolderName;
const folderPath = cfgStorage.fs.folderPath;
const { storageFolderName, fs: { folderPath, secretString: secret } } = cfgStorage;
routs.forEach((rout) => {
//special dirs are empty by default
if (!rout) {
if (!rout) return;
const rootPath = path.join(folderPath, rout);
['cache', 'storage-cache'].forEach(prefix => {
const route = `/${prefix}/${storageFolderName}/${rout}`;
router.use(route, createCacheMiddleware(prefix, rootPath, cfgStorage, secret, rout));
});
});
}
function createCacheMiddleware(prefix, rootPath, cfgStorage, secret, rout) {
return async (req, res) => {
const index = req.url.lastIndexOf('/');
if (req.method !== 'GET' || index <= 0) {
res.sendStatus(404);
return;
}
let rootPath = path.join(folderPath, rout);
router.use(`/${bucketName}/${storageFolderName}/${rout}`, (req, res, next) => {
const index = req.url.lastIndexOf('/');
if ('GET' === req.method && index > 0) {
let sendFileOptions = {
root: rootPath, dotfiles: 'deny', headers: {
'Content-Disposition': 'attachment'
try {
const urlParsed = urlModule.parse(req.url, true);
const { md5, expires } = urlParsed.query;
const numericExpires = parseInt(expires);
if (!md5 || !numericExpires) {
res.sendStatus(403);
return;
}
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime > numericExpires) {
res.sendStatus(410);
return;
}
const uri = req.url.split('?')[0];
const fullPath = `/${prefix}/${cfgStorage.storageFolderName}/${rout}${uri}`;
const signatureData = numericExpires + decodeURIComponent(fullPath) + secret;
const expectedMd5 = crypto
.createHash('md5')
.update(signatureData)
.digest('base64')
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
if (md5 !== expectedMd5) {
res.sendStatus(403);
return;
}
const filename = urlParsed.pathname && decodeURIComponent(path.basename(urlParsed.pathname));
const filePath = decodeURI(req.url.substring(1, index));
if (cfgStorage.name === 'storage-fs') {
const sendFileOptions = {
root: rootPath,
dotfiles: 'deny',
headers: {
'Content-Disposition': 'attachment',
...(filename && { 'Content-Type': mime.getType(filename) })
}
};
const urlParsed = urlModule.parse(req.url);
if (urlParsed && urlParsed.pathname) {
const filename = decodeURIComponent(path.basename(urlParsed.pathname));
sendFileOptions.headers['Content-Type'] = mime.getType(filename);
}
const realUrl = decodeURI(req.url.substring(0, index));
res.sendFile(realUrl, sendFileOptions, (err) => {
res.sendFile(filePath, sendFileOptions, (err) => {
if (err) {
operationContext.global.logger.error(err);
res.status(400).end();
}
});
} else if (['storage-s3', 'storage-az'].includes(cfgStorage.name)) {
const ctx = new operationContext.Context();
ctx.initFromRequest(req);
await ctx.initTenantCache();
const result = await storage.createReadStream(ctx, filePath, rout);
res.setHeader('Content-Type', mime.getType(filename));
res.setHeader('Content-Length', result.contentLength);
res.setHeader('Content-Disposition', utils.getContentDisposition(filename));
await pipeline(result.readStream, res);
} else {
res.sendStatus(404);
}
});
});
} catch (e) {
operationContext.global.logger.error(e);
res.sendStatus(400);
}
};
}
for (let i in cfgStaticContent) {
@ -95,9 +155,9 @@ if (storage.needServeStatic()) {
}
if (storage.needServeStatic(cfgForgottenFiles)) {
let persistentRouts = [cfgForgottenFiles, cfgErrorFiles];
persistentRouts.filter((rout) => {return rout && rout.length > 0;});
persistentRouts = persistentRouts.filter((rout) => {return rout && rout.length > 0;});
if (persistentRouts.length > 0) {
initCacheRouter(cfgPersistentStorage, [cfgForgottenFiles, cfgErrorFiles]);
initCacheRouter(cfgPersistentStorage, persistentRouts);
}
}

View File

@ -58,7 +58,9 @@ const commonDefines = require('./../../Common/sources/commondefines');
const operationContext = require('./../../Common/sources/operationContext');
const tenantManager = require('./../../Common/sources/tenantManager');
const staticRouter = require('./routes/static');
const configRouter = require('./routes/config');
const ms = require('ms');
const aiProxyHandler = require('./ai/aiProxyHandler');
const cfgWopiEnable = config.get('wopi.enable');
const cfgWopiDummyEnable = config.get('wopi.dummy.enable');
@ -235,6 +237,9 @@ docsCoServer.install(server, () => {
converterService.builder(req, res);
});
app.get('/info/info.json', utils.checkClientIp, docsCoServer.licenseInfo);
app.use('/info/config', utils.checkClientIp, configRouter);
app.get('/info/plugin/settings', utils.checkClientIp, aiProxyHandler.requestSettings);
app.post('/info/plugin/models', utils.checkClientIp, rawFileParser, aiProxyHandler.requestModels);
app.put('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.delete('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.get('/internal/connections/edit', docsCoServer.getEditorConnectionsCount);
@ -288,6 +293,8 @@ docsCoServer.install(server, () => {
app.get('/wopi/files/:docid/contents', apicache.middleware("5 minutes"), checkWopiDummyEnable, wopiClient.dummyGetFile);
app.post('/wopi/files/:docid/contents', checkWopiDummyEnable, wopiClient.dummyOk);
app.use('/ai-proxy', rawFileParser, aiProxyHandler.proxyRequest);
app.post('/dummyCallback', utils.checkClientIp, apicache.middleware("5 minutes"), rawFileParser, function(req, res){
let ctx = new operationContext.Context();
ctx.initFromRequest(req);

View File

@ -98,7 +98,20 @@ TaskResultData.prototype.completeDefaults = function() {
function upsert(ctx, task) {
return sqlBase.upsert(ctx, task);
}
/**
* Return TaskResult rows for docId, caching the last query result on ctx.taskResultCache or fetching from the database.
* @param {object} ctx
* @param {string} docId
* @returns {Promise<Array<object>>}
*/
async function selectWithCache(ctx, docId) {
//todo merge with select and remove on update
if (ctx.taskResultCache && ctx.taskResultCache[0].id === docId) {
return ctx.taskResultCache;
}
ctx.taskResultCache = await select(ctx, docId);
return ctx.taskResultCache;
}
function select(ctx, docId) {
return new Promise(function(resolve, reject) {
let values = [];
@ -316,6 +329,7 @@ function removeIf(ctx, mask) {
exports.TaskResultData = TaskResultData;
exports.upsert = upsert;
exports.select = select;
exports.selectWithCache = selectWithCache;
exports.update = update;
exports.updateIf = updateIf;
exports.restoreInitialPassword = restoreInitialPassword;

View File

@ -34,8 +34,6 @@
const path = require('path');
const { pipeline } = require('node:stream/promises');
const crypto = require('crypto');
let util = require('util');
const {URL} = require('url');
const co = require('co');
const jwt = require('jsonwebtoken');
@ -49,7 +47,7 @@ const logger = require('./../../Common/sources/logger');
const utils = require('./../../Common/sources/utils');
const constants = require('./../../Common/sources/constants');
const commonDefines = require('./../../Common/sources/commondefines');
const formatChecker = require('./../../Common/sources/formatchecker');
const wopiUtils = require('./wopiUtils');
const operationContext = require('./../../Common/sources/operationContext');
const tenantManager = require('./../../Common/sources/tenantManager');
const sqlBase = require('./databaseConnectors/baseConnector');
@ -95,8 +93,6 @@ const cfgWopiPrivateKeyOld = config.get('wopi.privateKeyOld');
const cfgWopiHost = config.get('wopi.host');
const cfgWopiDummySampleFilePath = config.get('wopi.dummy.sampleFilePath');
let cryptoSign = util.promisify(crypto.sign);
let templatesFolderLocalesCache = null;
let templatesFolderExtsCache = null;
const templateFilesSizeCache = {};
@ -373,22 +369,7 @@ function getFileTypeByInfo(fileInfo) {
fileType = fileInfo.FileExtension ? fileInfo.FileExtension.substr(1) : fileType;
return fileType.toLowerCase();
}
async function getWopiFileUrl(ctx, fileInfo, userAuth) {
const tenMaxDownloadBytes = ctx.getCfg('FileConverter.converter.maxDownloadBytes', cfgMaxDownloadBytes);
let url;
let headers = {'X-WOPI-MaxExpectedSize': tenMaxDownloadBytes};
if (fileInfo?.FileUrl) {
//Requests to the FileUrl can not be signed using proof keys. The FileUrl is used exactly as provided by the host, so it does not necessarily include the access token, which is required to construct the expected proof.
url = fileInfo.FileUrl;
} else if (fileInfo?.TemplateSource) {
url = fileInfo.TemplateSource;
} else if (userAuth) {
url = `${userAuth.wopiSrc}/contents?access_token=${encodeURIComponent(userAuth.access_token)}`;
await fillStandardHeaders(ctx, headers, url, userAuth.access_token);
}
ctx.logger.debug('getWopiFileUrl url=%s; headers=%j', url, headers);
return {url, headers};
}
function isWopiJwtToken(decoded) {
return !!decoded.fileInfo;
}
@ -751,7 +732,7 @@ function putFile(ctx, wopiParams, data, dataStream, dataSize, userLastChangeId,
let commonInfo = wopiParams.commonInfo;
//todo add all the users who contributed changes to the document in this PutFile request to X-WOPI-Editors
let headers = {'X-WOPI-Override': 'PUT', 'X-WOPI-Lock': commonInfo.lockId, 'X-WOPI-Editors': userLastChangeId};
yield fillStandardHeaders(ctx, headers, uri, userAuth.access_token);
yield wopiUtils.fillStandardHeaders(ctx, headers, uri, userAuth.access_token);
headers['X-LOOL-WOPI-IsModifiedByUser'] = isModifiedByUser;
headers['X-LOOL-WOPI-IsAutosave'] = isAutosave;
headers['X-LOOL-WOPI-IsExitSave'] = isExitSave;
@ -795,7 +776,7 @@ function putRelativeFile(ctx, wopiSrc, access_token, data, dataStream, dataSize,
if (isFileConversion) {
headers['X-WOPI-FileConversion'] = isFileConversion;
}
yield fillStandardHeaders(ctx, headers, uri, access_token);
yield wopiUtils.fillStandardHeaders(ctx, headers, uri, access_token);
headers['Content-Type'] = mime.getType(suggestedExt);
ctx.logger.debug('wopi putRelativeFile request uri=%s headers=%j', uri, headers);
@ -813,53 +794,58 @@ function putRelativeFile(ctx, wopiSrc, access_token, data, dataStream, dataSize,
return res;
});
}
function renameFile(ctx, wopiParams, name) {
return co(function* () {
let res = undefined;
try {
ctx.logger.info('wopi RenameFile start');
const tenCallbackRequestTimeout = ctx.getCfg('services.CoAuthoring.server.callbackRequestTimeout', cfgCallbackRequestTimeout);
/**
* Renames a file using the WOPI protocol
* @param {operationContext.Context} ctx - The operation context.
* @param {object} wopiParams - The WOPI parameters.
* @param {string} name - The new name for the file.
* @returns {Promise<{Name: string}|undefined>}
*/
async function renameFile(ctx, wopiParams, name) {
let res = undefined;
try {
ctx.logger.info('wopi RenameFile start');
const tenCallbackRequestTimeout = ctx.getCfg('services.CoAuthoring.server.callbackRequestTimeout', cfgCallbackRequestTimeout);
if (!wopiParams.userAuth || !wopiParams.commonInfo) {
return res;
}
let fileInfo = wopiParams.commonInfo.fileInfo;
let userAuth = wopiParams.userAuth;
let uri = `${userAuth.wopiSrc}?access_token=${encodeURIComponent(userAuth.access_token)}`;
let filterStatus = yield checkIpFilter(ctx, uri);
if (0 !== filterStatus) {
return res;
}
if (fileInfo && fileInfo.SupportsRename) {
let fileNameMaxLength = fileInfo.FileNameMaxLength || 255;
name = name.substring(0, fileNameMaxLength);
let commonInfo = wopiParams.commonInfo;
let headers = {'X-WOPI-Override': 'RENAME_FILE', 'X-WOPI-Lock': commonInfo.lockId, 'X-WOPI-RequestedName': utf7.encode(name)};
yield fillStandardHeaders(ctx, headers, uri, userAuth.access_token);
ctx.logger.debug('wopi RenameFile request uri=%s headers=%j', uri, headers);
//isInJwtToken is true because it passed checkIpFilter for wopi
let isInJwtToken = true;
let postRes = yield utils.postRequestPromise(ctx, uri, undefined, undefined, undefined, tenCallbackRequestTimeout, undefined, isInJwtToken, headers);
ctx.logger.debug('wopi RenameFile response headers=%j body=%s', postRes.response.headers, postRes.body);
if (postRes.body) {
res = JSON.parse(postRes.body);
} else {
//sharepoint send empty body(2016 allways, 2019 with same name)
res = {"Name": name};
}
} else {
ctx.logger.info('wopi SupportsRename = false');
}
} catch (err) {
ctx.logger.error('wopi error RenameFile:%s', err.stack);
} finally {
ctx.logger.info('wopi RenameFile end');
if (!wopiParams.userAuth || !wopiParams.commonInfo) {
return res;
}
return res;
});
const fileInfo = wopiParams.commonInfo.fileInfo;
const userAuth = wopiParams.userAuth;
const uri = `${userAuth.wopiSrc}?access_token=${encodeURIComponent(userAuth.access_token)}`;
const filterStatus = await checkIpFilter(ctx, uri);
if (0 !== filterStatus) {
return res;
}
if (fileInfo && fileInfo.SupportsRename) {
const fileNameMaxLength = fileInfo.FileNameMaxLength || 255;
name = name.substring(0, fileNameMaxLength);
const commonInfo = wopiParams.commonInfo;
const headers = {'X-WOPI-Override': 'RENAME_FILE', 'X-WOPI-Lock': commonInfo.lockId, 'X-WOPI-RequestedName': utf7.encode(name)};
await wopiUtils.fillStandardHeaders(ctx, headers, uri, userAuth.access_token);
ctx.logger.debug('wopi RenameFile request uri=%s headers=%j', uri, headers);
//isInJwtToken is true because it passed checkIpFilter for wopi
const isInJwtToken = true;
const postRes = await utils.postRequestPromise(ctx, uri, undefined, undefined, undefined, tenCallbackRequestTimeout, undefined, isInJwtToken, headers);
ctx.logger.debug('wopi RenameFile response headers=%j body=%s', postRes.response.headers, postRes.body);
if (postRes.body) {
res = JSON.parse(postRes.body);
} else {
//sharepoint send empty body(2016 allways, 2019 with same name)
res = {"Name": name};
}
} else {
ctx.logger.info('wopi SupportsRename = false');
}
} catch (err) {
ctx.logger.error('wopi error RenameFile:%s', err.stack);
} finally {
ctx.logger.info('wopi RenameFile end');
}
return res;
}
async function refreshFile(ctx, wopiParams, baseUrl) {
@ -918,7 +904,7 @@ function checkFileInfo(ctx, wopiSrc, access_token, opt_sc) {
if (opt_sc) {
headers['X-WOPI-SessionContext'] = opt_sc;
}
yield fillStandardHeaders(ctx, headers, uri, access_token);
yield wopiUtils.fillStandardHeaders(ctx, headers, uri, access_token);
ctx.logger.debug('wopi checkFileInfo request uri=%s headers=%j', uri, headers);
//isInJwtToken is true because it passed checkIpFilter for wopi
let isInJwtToken = true;
@ -953,7 +939,7 @@ function lock(ctx, command, lockId, fileInfo, userAuth) {
}
let headers = {"X-WOPI-Override": command, "X-WOPI-Lock": lockId};
yield fillStandardHeaders(ctx, headers, uri, access_token);
yield wopiUtils.fillStandardHeaders(ctx, headers, uri, access_token);
ctx.logger.debug('wopi %s request uri=%s headers=%j', command, uri, headers);
//isInJwtToken is true because it passed checkIpFilter for wopi
let isInJwtToken = true;
@ -992,7 +978,7 @@ async function unlock(ctx, wopiParams) {
}
let headers = {"X-WOPI-Override": "UNLOCK", "X-WOPI-Lock": lockId};
await fillStandardHeaders(ctx, headers, uri, access_token);
await wopiUtils.fillStandardHeaders(ctx, headers, uri, access_token);
ctx.logger.debug('wopi Unlock request uri=%s headers=%j', uri, headers);
//isInJwtToken is true because it passed checkIpFilter for wopi
let isInJwtToken = true;
@ -1009,31 +995,6 @@ async function unlock(ctx, wopiParams) {
}
return res;
}
function generateProofBuffer(url, accessToken, timeStamp) {
const accessTokenBytes = Buffer.from(accessToken, 'utf8');
const urlBytes = Buffer.from(url.toUpperCase(), 'utf8');
let offset = 0;
let buffer = Buffer.alloc(4 + accessTokenBytes.length + 4 + urlBytes.length + 4 + 8);
buffer.writeUInt32BE(accessTokenBytes.length, offset);
offset += 4;
accessTokenBytes.copy(buffer, offset, 0, accessTokenBytes.length);
offset += accessTokenBytes.length;
buffer.writeUInt32BE(urlBytes.length, offset);
offset += 4;
urlBytes.copy(buffer, offset, 0, urlBytes.length);
offset += urlBytes.length;
buffer.writeUInt32BE(8, offset);
offset += 4;
buffer.writeBigUInt64BE(timeStamp, offset);
return buffer;
}
async function generateProofSign(url, accessToken, timeStamp, privateKey) {
let data = generateProofBuffer(url, accessToken, timeStamp);
let sign = await cryptoSign('RSA-SHA256', data, privateKey);
return sign.toString('base64');
}
function numberToBase64(val) {
// Convert to hexadecimal
@ -1047,23 +1008,6 @@ function numberToBase64(val) {
return buffer.toString('base64');
}
async function fillStandardHeaders(ctx, headers, url, access_token) {
let timeStamp = utils.getDateTimeTicks(new Date());
const tenWopiPrivateKey = ctx.getCfg('wopi.privateKey', cfgWopiPrivateKey);
const tenWopiPrivateKeyOld = ctx.getCfg('wopi.privateKeyOld', cfgWopiPrivateKeyOld);
if (tenWopiPrivateKey && tenWopiPrivateKeyOld) {
headers['X-WOPI-Proof'] = await generateProofSign(url, access_token, timeStamp, tenWopiPrivateKey);
headers['X-WOPI-ProofOld'] = await generateProofSign(url, access_token, timeStamp, tenWopiPrivateKeyOld);
}
headers['X-WOPI-TimeStamp'] = timeStamp;
headers['X-WOPI-ClientVersion'] = commonDefines.buildVersion + '.' + commonDefines.buildNumber;
// todo
// headers['X-WOPI-CorrelationId '] = "";
// headers['X-WOPI-SessionId'] = "";
//remove redundant header https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/common-headers#request-headers
// headers['Authorization'] = `Bearer ${access_token}`;
}
function checkIpFilter(ctx, uri){
return co(function* () {
let urlParsed = new URL(uri);
@ -1169,11 +1113,9 @@ exports.renameFile = renameFile;
exports.refreshFile = refreshFile;
exports.lock = lock;
exports.unlock = unlock;
exports.fillStandardHeaders = fillStandardHeaders;
exports.getWopiUnlockMarker = getWopiUnlockMarker;
exports.getWopiModifiedMarker = getWopiModifiedMarker;
exports.getFileTypeByInfo = getFileTypeByInfo;
exports.getWopiFileUrl = getWopiFileUrl;
exports.isWopiJwtToken = isWopiJwtToken;
exports.setIsShutdown = setIsShutdown;
exports.dummyCheckFileInfo = dummyCheckFileInfo;

View File

@ -0,0 +1,144 @@
/*
* (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 crypto = require('crypto');
const util = require('util');
const config = require('config');
const utils = require('./../../Common/sources/utils');
const commonDefines = require('./../../Common/sources/commondefines');
// Configuration constants
const cfgMaxDownloadBytes = config.get('FileConverter.converter.maxDownloadBytes');
const cfgWopiPrivateKey = config.get('wopi.privateKey');
const cfgWopiPrivateKeyOld = config.get('wopi.privateKeyOld');
const cryptoSign = util.promisify(crypto.sign);
/**
* Generates a proof buffer for WOPI requests
*
* @param {string} url - The URL to generate proof for
* @param {string} accessToken - The access token
* @param {bigint} timeStamp - The timestamp in ticks
* @returns {Buffer} - The proof buffer
*/
function generateProofBuffer(url, accessToken, timeStamp) {
const accessTokenBytes = Buffer.from(accessToken, 'utf8');
const urlBytes = Buffer.from(url.toUpperCase(), 'utf8');
let offset = 0;
let buffer = Buffer.alloc(4 + accessTokenBytes.length + 4 + urlBytes.length + 4 + 8);
buffer.writeUInt32BE(accessTokenBytes.length, offset);
offset += 4;
accessTokenBytes.copy(buffer, offset, 0, accessTokenBytes.length);
offset += accessTokenBytes.length;
buffer.writeUInt32BE(urlBytes.length, offset);
offset += 4;
urlBytes.copy(buffer, offset, 0, urlBytes.length);
offset += urlBytes.length;
buffer.writeUInt32BE(8, offset);
offset += 4;
buffer.writeBigUInt64BE(timeStamp, offset);
return buffer;
}
/**
* Generates a proof signature for WOPI requests
*
* @param {string} url - The URL to generate proof for
* @param {string} accessToken - The access token
* @param {bigint} timeStamp - The timestamp in ticks
* @param {string} privateKey - The private key for signing
* @returns {string} - The base64-encoded signature
*/
async function generateProofSign(url, accessToken, timeStamp, privateKey) {
let data = generateProofBuffer(url, accessToken, timeStamp);
let sign = await cryptoSign('RSA-SHA256', data, privateKey);
return sign.toString('base64');
}
/**
* Fills standard WOPI headers for requests
*
* @param {Object} ctx - The operation context
* @param {Object} headers - The headers object to fill
* @param {string} url - The URL for the request
* @param {string} access_token - The access token
*/
async function fillStandardHeaders(ctx, headers, url, access_token) {
let timeStamp = utils.getDateTimeTicks(new Date());
const tenWopiPrivateKey = ctx.getCfg('wopi.privateKey', cfgWopiPrivateKey);
const tenWopiPrivateKeyOld = ctx.getCfg('wopi.privateKeyOld', cfgWopiPrivateKeyOld);
if (tenWopiPrivateKey && tenWopiPrivateKeyOld) {
headers['X-WOPI-Proof'] = await generateProofSign(url, access_token, timeStamp, tenWopiPrivateKey);
headers['X-WOPI-ProofOld'] = await generateProofSign(url, access_token, timeStamp, tenWopiPrivateKeyOld);
}
headers['X-WOPI-TimeStamp'] = timeStamp;
headers['X-WOPI-ClientVersion'] = commonDefines.buildVersion + '.' + commonDefines.buildNumber;
// todo
// headers['X-WOPI-CorrelationId '] = "";
// headers['X-WOPI-SessionId'] = "";
//remove redundant header https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/common-headers#request-headers
// headers['Authorization'] = `Bearer ${access_token}`;
}
/**
* Gets a WOPI file URL with appropriate headers
*
* @param {Object} ctx - The operation context
* @param {Object} fileInfo - Information about the file
* @param {Object} userAuth - User authentication details
* @returns {Object} - Object containing URL and headers
*/
async function getWopiFileUrl(ctx, fileInfo, userAuth) {
const tenMaxDownloadBytes = ctx.getCfg('FileConverter.converter.maxDownloadBytes', cfgMaxDownloadBytes);
let url;
let headers = {'X-WOPI-MaxExpectedSize': tenMaxDownloadBytes};
if (fileInfo?.FileUrl) {
//Requests to the FileUrl can not be signed using proof keys. The FileUrl is used exactly as provided by the host, so it does not necessarily include the access token, which is required to construct the expected proof.
url = fileInfo.FileUrl;
} else if (fileInfo?.TemplateSource) {
url = fileInfo.TemplateSource;
} else if (userAuth) {
url = `${userAuth.wopiSrc}/contents?access_token=${encodeURIComponent(userAuth.access_token)}`;
await fillStandardHeaders(ctx, headers, url, userAuth.access_token);
}
ctx.logger.debug('getWopiFileUrl url=%s; headers=%j', url, headers);
return {url, headers};
}
module.exports = {
getWopiFileUrl,
fillStandardHeaders
};

View File

@ -47,7 +47,7 @@ var storage = require('./../../Common/sources/storage/storage-base');
var utils = require('./../../Common/sources/utils');
var constants = require('./../../Common/sources/constants');
var baseConnector = require('../../DocService/sources/databaseConnectors/baseConnector');
const wopiClient = require('./../../DocService/sources/wopiClient');
const wopiUtils = require('./../../DocService/sources/wopiUtils');
const taskResult = require('./../../DocService/sources/taskresult');
var statsDClient = require('./../../Common/sources/statsdclient');
var queueService = require('./../../Common/sources/taskqueueRabbitMQ');
@ -345,15 +345,13 @@ function* isUselessConvertion(ctx, task, cmd) {
return constants.NO_ERROR;
}
async function changeFormatToExtendedPdf(ctx, dataConvert, cmd) {
let forceSave = cmd.getForceSave();
let isSendForm = forceSave && forceSave.getType() === commonDefines.c_oAscForceSaveTypes.Form;
let originFormat = cmd.getOriginFormat();
let isOriginFormatWithForms = constants.AVS_OFFICESTUDIO_FILE_CROSSPLATFORM_PDF === originFormat ||
constants.AVS_OFFICESTUDIO_FILE_DOCUMENT_OFORM === originFormat ||
constants.AVS_OFFICESTUDIO_FILE_DOCUMENT_DOCXF === originFormat;
let isFormatToPdf = constants.AVS_OFFICESTUDIO_FILE_CROSSPLATFORM_PDF === dataConvert.formatTo ||
constants.AVS_OFFICESTUDIO_FILE_CROSSPLATFORM_PDFA === dataConvert.formatTo;
if (isFormatToPdf && isOriginFormatWithForms && !isSendForm) {
if (isFormatToPdf && isOriginFormatWithForms) {
let format = await formatChecker.getDocumentFormatByFile(dataConvert.fileFrom);
if (constants.AVS_OFFICESTUDIO_FILE_CANVAS_WORD === format) {
ctx.logger.debug('change format to extended pdf');
@ -1074,7 +1072,7 @@ function* ExecuteTask(ctx, task) {
isInJwtToken = true;
let fileInfo = wopiParams.commonInfo?.fileInfo;
fileSize = fileInfo?.Size;
({url, headers} = yield wopiClient.getWopiFileUrl(ctx, fileInfo, wopiParams.userAuth));
({url, headers} = yield wopiUtils.getWopiFileUrl(ctx, fileInfo, wopiParams.userAuth));
}
if (undefined === fileSize || fileSize > 0) {
error = yield* downloadFile(ctx, url, dataConvert.fileFrom, withAuthorization, isInJwtToken, headers);

View File

@ -0,0 +1,91 @@
# Change Log
## 1.0.0
* Initial release.
## 1.0.1
* Add new model.
## 1.0.2
* Change plugin structure. Now it's non visual plugin and it can work by context menu.
* Add new model for chat variation.
## 1.1.0
* Commont improvements.
## 1.1.1
* Add new languages for translating and add translations for it.
* Change plugin type from "system" to "background".
* Increase minimal editor version to "7.5.0".
* Change type for chat window (now it's not a modal window).
## 1.1.2
* Disable plugin for IE.
## 1.1.3
* Add notification, that service isn't enable in user region (if we can't load list of models).
## 1.1.4
* Change list of models for custom requests (add new models and remove old).
* Change default model for request by context menu (now we use gpt-3.5-turbo-16k model for chat and gpt-4 for other request).
* Add new functions: "Fix spelling & grammar", "Rewrite differently", "Make longer", "Make shorter", "Make simpler" (it has restriction by gpt-4 model: 8k tokens)
## 2.0.0
* The plugin has been completely redesigned.
## 2.1.0
* Bug fix.
## 2.1.1
* Bug fix.
## 2.1.2
* Fix add provider.
## 2.1.3
* Bug fix. Remove v1 suffix for endpoints.
## 2.1.4
* Add proxy for together.ai.
* Add Groc as internal provider.
* Bug fix
## 2.1.5
* Bug fix
## 2.2.0
* Refactoring
## 2.2.1
* Bug fix
## 2.2.2
* Add xAI as internal provider.
## 2.2.3
* Fix translations.
## 2.2.4
* Refactoring chat. Add docked mode for chat window.
## 2.2.5
* Bug fix
## 2.2.6
* Add interface for system role detection.
* Fixed the work of providers antropic and gemini when working with a system role.
* Fix bug with custom functions in spreadsheets editor.
## 2.2.7
* Add image actions.
* Add Stability AI provider.
## 2.2.8
* Fix image actions. Add "OCR" and "Image to text" support.

View File

@ -0,0 +1,114 @@
<!--
(c) Copyright Ascensio System SIA 2020
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>Edit action</title>
<script type="text/javascript" src="../js/plugins.js"></script>
<script type="text/javascript" src="../js/plugins-ui.js"></script>
<script type="text/javascript" src="scripts/engine/providers/base.js"></script>
<script type="text/javascript" src="components/Tooltip/script.js"></script>
<script type="text/javascript" src="../js/plugin-stub.js"></script>
<script src="vendor/jquery/jquery-3.7.1.min.js"></script>
<script src="vendor/select2-4.0.6-rc.1/dist/js/select2.js"></script>
<link rel="stylesheet" href="../css/plugins.css">
<link rel="stylesheet" href="vendor/select2-4.0.6-rc.1/dist/css/select2.css"/>
<link rel="stylesheet" href="./resources/styles/common.css">
<link rel="stylesheet" href="./resources/styles/aiModelEdit.css">
<link rel="stylesheet" href="components/Tooltip/style.css">
</head>
<body class="noselect">
<div class="section">
<div class="form-vertical-item">
<div class="section-label">
<label class="d-flex-center">
<span class="i18n">Model name</span>
<span class="required-mark">*</span>
</label>
</div>
<input id="name-input" type="text" class="form-control" maxlength="80"/>
</div>
</div>
<div class="section">
<div class="section-label" id="provider-section-label">
<label class="i18n">Provider</label>
</div>
<div class="form-horizontal-item">
<label class="d-flex-center">
<span class="i18n">Name</span>
<span class="required-mark">*</span>
</label>
<select id="provider-name-cmb" class="form-control"></select>
</div>
<div class="form-horizontal-item">
<label class="d-flex-center">
<span class="i18n">URL</span>
<span class="required-mark">*</span>
</label>
<input id="provider-url-input" type="text" class="form-control"/>
</div>
<div class="form-horizontal-item">
<label class="i18n">Key</label>
<input id="provider-key-input" type="text" class="form-control"/>
</div>
</div>
<div class="section">
<div class="form-vertical-item">
<div class="section-label">
<label class="d-flex-center">
<span class="i18n">Model</span>
<span class="required-mark">*</span>
</label>
<div id="update-models-row">
<div id="update-models-loader-container" class="asc-loader-container"></div>
<button id="update-models-btn" class="action-btn i18n">Update models list</button>
<img id="update-models-error" class="icon" src="resources/icons/light/error.png" style="display: none;"/>
</div>
</div>
<select id="model-name-cmb" class="form-control"></select>
</div>
</div>
<div class="section" style="margin-bottom: 0;">
<div class="form-vertical-item">
<div class="section-label">
<label class="i18n">Use model for</label>
</div>
</div>
<div class="form-horizontal-item" id="block-use-for" style="margin-bottom: 0;">
<div id="use-for-text"></div>
<div id="use-for-image"></div>
<div id="use-for-embeddings"></div>
<div id="use-for-audio"></div>
<div id="use-for-moderations"></div>
<div id="use-for-realtime"></div>
<div id="use-for-code"></div>
<div id="use-for-vision"></div>
</div>
</div>
<div id="custom-providers-button">
<label class="i18n">
Custom providers
</label>
</div>
<script type="text/javascript" src="scripts/aiModelEdit.js"></script>
</body>
</html>

View File

@ -0,0 +1,50 @@
<!--
(c) Copyright Ascensio System SIA 2020
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>AI Models list</title>
<script type="text/javascript" src="../js/plugins.js"></script>
<script type="text/javascript" src="../js/plugins-ui.js"></script>
<script type="text/javascript" src="./components/ListView/script.js"></script>
<script type="text/javascript" src="../js/plugin-stub.js"></script>
<script src="vendor/jquery/jquery-3.7.1.min.js"></script>
<link rel="stylesheet" href="../css/plugins.css">
<link rel="stylesheet" href="./resources/styles/common.css">
<link rel="stylesheet" href="./components/ListView/style.css">
<link rel="stylesheet" href="./resources/styles/aiModelsList.css">
</head>
<body class="noselect">
<div id="ai-models-list" class="list">
<!-- Dynamic render items -->
</div>
<div id="buttons-block">
<button id="add-btn" class="btn-text-default">
<img src="resources/icons/light/btn-zoomup.png" class="icon"/>
</button>
<button id="edit-btn" class="btn-text-default">
<img src="resources/icons/light/btn-edit.png" class="icon"/>
</button>
<button id="delete-btn" class="btn-text-default">
<img src="resources/icons/light/btn-remove.png" class="icon"/>
</button>
</div>
<script type="text/javascript" src="scripts/aiModelsList.js"></script>
</body>
</html>

View File

@ -0,0 +1,87 @@
<!--
(c) Copyright Ascensio System SIA 2020
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>OpenAI</title>
<script type="text/javascript" src="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins.js"></script>
<script type="text/javascript" src="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins-ui.js"></script>
<link rel="stylesheet" href="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins.css">
<script src="vendor/jquery/jquery-3.7.1.min.js"></script>
<script type="text/javascript" src="vendor/GPT-3-Encoder/encoder.js"></script>
<script src="vendor/md/markdown-it.js"></script>
<script type="text/javascript" src="components/Tooltip/script.js"></script>
<link rel="stylesheet" href="components/Tooltip/style.css">
<link rel="stylesheet" href="./resources/styles/chat.css">
<script type="text/javascript" src="scripts/chat.js"></script>
</head>
<body style="display: flex;">
<div id="chat_window" class="chat_window hidden">
<div id="chat_wrapper" class="form-control empty">
<div id="chat" class="noselect">
<div id="start_panel">
<div id="welcome_text">
<br/>
<br/>
<span class="i18n">I'm here to assist you with all your text creation and editing needs. Feel free to ask me anything about your document or anything else that's on your mind.</span>
<br/>
<br/>
<span class="i18n">Lets make your content shine together!</span>
</div>
<div id="welcome_buttons_list"></div>
</div>
</div>
</div>
<div id="tokens_info" class="tokens_info">
<div class="form-control div_title_chat hidden">
<span class="i18n">Maximum 16000 tokens are available.</span>
<br>
<span class="i18n">For work with this model we should save chat history and sent it into a request.</span>
<span class="i18n">But we have a limit on the number of tokens in the request.</span>
<span class="i18n">That's why sometimes you should clear your chat history.</span>
<div class="text_align_end">
<button id="clear_history" style="margin-top: 5px;" class="form-control btn-text-default i18n">Clear history</button>
</div>
</div>
<div class="text_align_end">
<span class="i18n">Tokens in the request about: </span>
<span id="cur_tokens">0</span>
</div>
<div class="text_align_end">
<span class="i18n">Total tokens are used in last request: </span>
<span id="total_tokens">0</span>
</div>
</div>
<div id="attached_text_wrapper" class="hidden">
<div id="attached_text"></div>
<img id="attached_text_close" draggable="false" src="resources/icons/light/close.png" class="icon"/>
</div>
<div id="input_message_wrapper">
<textarea id="input_message" rows="1" class="form-control" spellcheck="false"></textarea>
<div id="input_message_submit" class="noselect">
<img src="resources/icons/light/btn-demote.png" draggable="false" class="icon"/>
</div>
</div>
</div>
<div id="loader-container" class="loader hidden"></div>
<div id="div_err" class="hidden div_error err_background">
<label class="lb_err i18n" style="font-weight: bold;">Error:</label>
<label id="lb_err" class="lb_err"></label>
</div>
</body>
</html>

View File

@ -0,0 +1,175 @@
function ListView($el, options) {
this._init = function() {
var defaults = {
emptyText: '',
renderItem: function(item, index) {
var itemEl = document.createElement('div');
itemEl.classList.add('item');
itemEl.innerText = item.label;
return itemEl;
}
};
this.options = Object.assign({}, defaults, options);
$el.classList.add('list-view');
this.$el = $el;
this.list = [];
this.selectedItem = null;
this.events = {
set: [],
add: [],
edit: [],
delete: [],
select: [],
deselect: [],
};
this._render();
};
this._render = function() {
var me = this;
this.$el.innerHTML = '';
if(this.list.length > 0) {
this.list.forEach(function(item, index) {
let itemEl = me.options.renderItem(item, index);
itemEl.addEventListener('click', function() {
me.setSelected(index);
});
me.$el.appendChild(itemEl);
});
} else if(this.options.emptyText) {
let emptyEl = document.createElement('div');
emptyEl.innerText = this.options.emptyText;
emptyEl.classList.add('empty-text');
this.$el.appendChild(emptyEl);
}
};
this._trigger = function(event, data) {
if(this.events[event]) {
this.events[event].forEach(function(callback) {
callback(data);
});
}
};
this.getList = function() {
return this.list;
};
this.set = function(list) {
this.list = list;
this._render();
if(list.length > 0) {
this.setSelected(0);
} else {
this.deselect();
}
scrollbarList.update();
scrollbarList.update();
this._trigger('set', list);
};
this.add = function(item) {
this.list.push(item);
this._render();
this.setSelected(this.list.length - 1);
this.$el.scrollTop = this.$el.scrollHeight;
scrollbarList.update();
scrollbarList.update();
this._trigger('add', item);
};
this.edit = function(item) {
var findedItem = this.list.filter(function(el) {
return el.id == item.id;
})[0];
if(!findedItem) return;
for (var key in item) {
if (findedItem[key]) {
findedItem[key] = item[key];
}
}
this._render();
this._trigger('edit', findedItem, item);
};
this.delete = function(item) {
var indexDeletedItem = this.list.indexOf(item);
if(indexDeletedItem != -1) {
this.list = this.list.filter(function(el) {
return el != item;
});
this._render();
if(this.list.length == 0) {
this.deselect();
} else if(indexDeletedItem < this.list.length) {
this.setSelected(indexDeletedItem);
} else {
this.setSelected(this.list.length - 1);
}
this._trigger('delete', item);
}
};
this.deleteByIndex = function(index) {
if(!this.list[index]) return;
this.delete(this.list[index]);
};
this.setSelected = function(index) {
if(this.list.length == 0) return;
if(index == -1) {
index = this.list.length - 1;
}
if(!this.list[index]) return;
this.deselect();
this.selectedItem = this.list[index];
this.$el.children[index].classList.add('selected');
this._trigger('select', this.selectedItem);
};
this.getSelected = function() {
return this.selectedItem;
};
this.deselect = function() {
var previouslySelectedItem = this.selectedItem;
this.selectedItem = null;
var itemsEl = this.$el.getElementsByClassName('item');
for (var i = 0; i < itemsEl.length; i++) {
itemsEl[i].classList.remove('selected');
}
if (previouslySelectedItem) {
this._trigger('deselect', previouslySelectedItem);
}
};
this.setEmptyText = function(text) {
this.options.emptyText = text;
this._render();
};
this.on = function(event, callback) {
if(this.events[event]) {
this.events[event].push(callback);
}
};
this._init();
}

View File

@ -0,0 +1,85 @@
.list-view {
flex: 1;
border: 1px solid;
position: relative;
}
.list-view .item {
padding: 6px;
cursor: pointer;
border-bottom: 1px solid;
}
.list-view .empty-text {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* Themes style for Models List */
body.theme-classic-light .list-view {
border-color: #cfcfcf;
}
body.theme-light .list-view,
body.theme-gray .list-view {
border-color: #c0c0c0;
}
body.theme-dark .list-view {
border-color: #666;
}
body.theme-contrast-dark .list-view {
border-color: #696969;
}
/* Themes style for Item Models List */
body.theme-classic-light .list-view .item {
border-color: #cfcfcf;
}
body.theme-classic-light .list-view .item:hover {
background-color: #d8dadc;
color: #444;
}
body.theme-classic-light .list-view .item.selected {
background-color: #7d858c;
color: #fff;
}
body.theme-light .list-view .item,
body.theme-gray .list-view .item {
border-color: #c0c0c0;
}
body.theme-light .list-view .item:hover,
body.theme-gray .list-view .item:hover {
background-color: #e0e0e0;
color: rgba(0, 0, 0, 0.8);
}
body.theme-light .list-view .item.selected,
body.theme-gray .list-view .item.selected {
background-color: #cbcbcb;
color: rgba(0, 0, 0, 0.8);
}
body.theme-dark .list-view .item {
border-color: #666;
}
body.theme-dark .list-view .item:hover {
background-color: #555;
color: rgba(255, 255, 255, 0.8);
}
body.theme-dark .list-view .item.selected {
background-color: #707070;
color: rgba(255, 255, 255, 0.8);
}
body.theme-contrast-dark .list-view .item {
border-color: #696969;
}
body.theme-contrast-dark .list-view .item:hover {
background-color: #424242;
color: #e8e8e8;
}
body.theme-contrast-dark .list-view .item.selected {
background-color: #666666;
color: #e8e8e8;
}

View File

@ -0,0 +1,128 @@
function Tooltip(targetEl, options) {
this._init = function() {
var me = this;
var defaults = {
renderInner: null,
text: '',
xAnchor: 'center',
yAnchor: 'bottom',
xOffset: 0,
yOffset: 3,
align: 'center',
width: null,
hasShadow: false,
keepAliveOnHover: false,
delay: 0,
hideDelay: 0
};
this.options = Object.assign({}, defaults, options);
targetEl.addEventListener('mouseover', function() {
// Set a timer for the delay before showing
me.showTimeout = setTimeout(function() {
me._createTooltipElement();
if(me.options.renderInner) {
me.tooltipEl.appendChild(me.options.renderInner());
} else {
me.tooltipEl.innerText = me.options.text;
}
if (me.options.width) {
me.tooltipEl.style.width = me.options.width + 'px';
me.tooltipEl.style.whiteSpace = '';
} else {
me.tooltipEl.style.width = '';
me.tooltipEl.style.whiteSpace = 'nowrap';
}
if (me.options.hasShadow) {
me.tooltipEl.classList.add('has-shadow');
} else {
me.tooltipEl.classList.remove('has-shadow');
}
me._updatePosition();
}, me.options.delay);
});
targetEl.addEventListener('mouseleave', function() {
clearTimeout(me.showTimeout); // Clear the show timer if the user leaves the element
// Set a timer for the delay before hiding
me.hideTimeout = setTimeout(function() {
me._deleteTooltipElement();
}, me.options.hideDelay);
});
};
this._createTooltipElement = function () {
if (!this.tooltipEl) {
let me = this;
this.tooltipEl = document.createElement("div");
this.tooltipEl.classList.add("tooltip");
this.tooltipEl.addEventListener('mouseenter', function() {
if (me.options.keepAliveOnHover) {
clearTimeout(me.hideTimeout); // Clear the hide timer when hovering over the tooltip
}
});
this.tooltipEl.addEventListener('mouseleave', function() {
if (me.options.keepAliveOnHover) {
// Set a timer for the delay before hiding the tooltip
me.hideTimeout = setTimeout(function() {
me._deleteTooltipElement();
}, me.options.hideDelay);
}
});
document.body.appendChild(this.tooltipEl);
}
};
this._deleteTooltipElement = function() {
if (this.tooltipEl) {
this.tooltipEl.remove();
this.tooltipEl = null;
}
};
this._updatePosition = function() {
var rectTooltip = this.tooltipEl.getBoundingClientRect();
var rectEl = targetEl.getBoundingClientRect();
var yOffset = this.options.yOffset;
var xOffset = this.options.xOffset;
if (this.options.align == 'right') {
xOffset = -rectTooltip.width;
} else if (this.options.align == 'center') {
xOffset = -rectTooltip.width / 2;
}
if (this.options.xAnchor == 'right') {
this.tooltipEl.style.left = rectEl.right + xOffset + 'px';
} else if (this.options.xAnchor == 'left') {
this.tooltipEl.style.left = rectEl.left + xOffset + 'px';
} else if (this.options.xAnchor == 'center') {
this.tooltipEl.style.left = rectEl.left + rectEl.width / 2 + xOffset + 'px';
}
if (this.options.yAnchor == 'bottom') {
this.tooltipEl.style.top = rectEl.bottom + yOffset + 'px';
} else if (this.options.yAnchor == 'top') {
this.tooltipEl.style.top = rectEl.top - yOffset - rectTooltip.height + 'px';
}
};
this.getText = function() {
return this.options.text;
};
this.setText = function(text) {
this.options.text = text;
if(this.tooltipEl) {
this.tooltipEl.innerText = text;
this._updatePosition();
}
};
this._init();
}

View File

@ -0,0 +1,29 @@
.tooltip {
line-height: 12px;
word-break: break-word;
position: absolute;
z-index: 10;
padding: 4px 8px;
border: 1px solid;
border-radius: 4px;
}
.tooltip.has-shadow {
box-shadow: 0px 2px 5px #00000033;
}
body.theme-light .tooltip,
body.theme-gray .tooltip {
background-color: #fff;
border-color: #c0c0c0;
}
body.theme-classic-light .tooltip {
background-color: #fff;
border-color: #cfcfcf;
}
body.theme-dark .tooltip {
background-color: #333;
border-color: #666;
}
body.theme-contrast-dark .tooltip {
background-color: #1e1e1e;
border-color: #696969;
}

View File

@ -0,0 +1,73 @@
{
"name" : "AI",
"nameLocale": {
"ru": "ИИ",
"fr": "AI",
"es": "AI",
"de": "AI",
"cs": "AI",
"zh": "AI",
"pt-BR": "AI",
"sr-Cyrl-RS": "AI",
"sr-Latn-RS": "AI",
"ja-JA": "AI",
"sq-AL": "AI",
"it": "IA"
},
"guid" : "asc.{9DC93CDB-B576-4F0C-B55E-FCC9C48DD007}",
"version": "2.2.8",
"minVersion" : "8.2.0",
"variations" : [
{
"description": "Use the AI chatbot to perform tasks which involve understanding or generating natural language or code.",
"descriptionLocale": {
"ru": "Используйте чат-бот AI для выполнения задач, связанных с пониманием или генерацией естественного языка или кода.",
"fr": "Utilisez le chatbot AI pour effectuer des tâches qui impliquent la compréhension ou la génération de langage naturel ou de code.",
"es": "Utilice el chatbot AI para realizar tareas que impliquen la comprensión o generación de lenguaje natural o de código.",
"pt-BR": "Use o chatbot AI para realizar tarefas que envolvam compreensão ou geração de linguagem ou código natural.",
"de": "Verwenden Sie den AI-Chatbot, um Aufgaben auszuführen, die das Verstehen oder Generieren von natürlicher Sprache oder Code beinhalten.",
"cs": "Použijte chatbota AI k provádění úkolů, který zahrnuje porozumění nebo generování přirozeného jazyka nebo kódu.",
"zh": "使用 AI 聊天机器人完成有关理解、生成自然语言或代码的任务。",
"sr-Cyrl-RS": "Користите AI чет робота за обављање задатака који укључују разумевање или генерисање природног језика или кода.",
"sr-Latn-RS": "Koristite AI čet robota za obavljanje zadataka koji uključuju razumevanje ili generisanje prirodnog jezika ili koda.",
"ja-JA": "自然言語やコードの理解または生成が必要なタスクを行うには、AIチャットボットを使用できます。",
"sq-AL": "Shtoni dhe selektoni modele AI për detyra të ndryshme.",
"it": "Utilizza il chatbot dell'IA per eseguire attività che implicano la comprensione o la generazione di codice o linguaggio naturale."
},
"url" : "index.html",
"icons": "resources/%theme-type%(light|dark)/icon%scale%(default).%extension%(png)",
"isViewer" : false,
"EditorsSupport" : ["word", "cell", "slide", "pdf"],
"type" : "background",
"initDataType" : "none",
"buttons" : [],
"events" : ["onContextMenuShow", "onContextMenuClick", "onToolbarMenuClick"],
"store": {
"background": {
"light" : "linear-gradient(90deg, #F9B6FF 0%, #E370EE 102.01%)",
"dark" : "linear-gradient(90deg, #F9B6FF 0%, #E370EE 102.01%)"
},
"screenshots" :
[
"resources/store/screenshots/screen_1.png",
"resources/store/screenshots/screen_2.png",
"resources/store/screenshots/screen_3.png",
"resources/store/screenshots/screen_4.png",
"resources/store/screenshots/screen_5.png",
"resources/store/screenshots/screen_6.png"
],
"icons" : {
"light" : "resources/store/icons",
"dark" : "resources/store/icons"
},
"categories": ["specAbilities", "work", "recommended"]
}
}
]
}

View File

@ -0,0 +1,56 @@
<!--
(c) Copyright Ascensio System SIA 2020
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>Custom providers</title>
<script type="text/javascript" src="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins.js"></script>
<script type="text/javascript" src="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins-ui.js"></script>
<script type="text/javascript" src="components/Tooltip/script.js"></script>
<script type="text/javascript" src="components/ListView/script.js"></script>
<script src="vendor/jquery/jquery-3.7.1.min.js"></script>
<link rel="stylesheet" href="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins.css">
<link rel="stylesheet" href="./resources/styles/common.css">
<link rel="stylesheet" href="./resources/styles/customProviders.css">
<link rel="stylesheet" href="components/Tooltip/style.css">
<link rel="stylesheet" href="components/ListView/style.css">
</head>
<body class="noselect">
<div id="label-row">
<label class="i18n">Connected custom providers</label>
<img id="alert-icon" class="icon" src="resources/icons/light/info.png"/>
</div>
<div id="list-row">
<div id="providers-list" class="empty">
<!-- Dynamic render items -->
</div>
<div id="buttons-block">
<button id="add-btn" class="btn-text-default">
<img src="resources/icons/light/btn-zoomup.png" class="icon"/>
</button>
<button id="delete-btn" class="btn-text-default" disabled>
<img src="resources/icons/light/btn-remove.png" class="icon"/>
</button>
</div>
</div>
<label id="error-label" class="hide"></label>
<input id="file-input" type="file" multiple accept=".js"/>
<script type="text/javascript" src="scripts/customProviders.js"></script>
</body>
</html>

Binary file not shown.

View File

@ -0,0 +1,35 @@
<!--
(c) Copyright Ascensio System SIA 2020
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>ChatGPT</title>
<script type="text/javascript" src="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins.js"></script>
<script type="text/javascript" src="scripts/hyperlink.js"></script>
<style>
html,body,iframe {
margin: 0px;
width: 100%;
height: 100%;
border: none;
}
</style>
</head>
<body>
<iframe id="iframe"></iframe>
</body>
</html>

View File

@ -0,0 +1,43 @@
<!--
(c) Copyright Ascensio System SIA 2020
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>AI Constructor</title>
<script type="text/javascript" src="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins.js"></script>
<script type="text/javascript" src="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins-ui.js"></script>
<link rel="stylesheet" href="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins.css">
<script type="text/javascript" src="vendor/GPT-3-Encoder/encoder.js"></script>
<script type="text/javascript" src="scripts/engine/storage.js"></script>
<script type="text/javascript" src="scripts/engine/local_storage.js"></script>
<script type="text/javascript" src="scripts/engine/providers/base.js"></script>
<script type="text/javascript" src="scripts/engine/providers/provider.js"></script>
<script type="text/javascript" src="scripts/engine/engine.js"></script>
<script type="text/javascript" src="scripts/engine/library.js"></script>
<script type="text/javascript" src="scripts/engine/buttons.js"></script>
<script type="text/javascript" src="scripts/engine/register.js"></script>
<script type="text/javascript" src="scripts/code.js"></script>
<script src="vendor/md/markdown-it.js"></script>
</head>
<body>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Some files were not shown because too many files have changed in this diff Show More