/*
 * (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 pathModule = require('path');
const urlModule = require('url');
const {pipeline} = require('node:stream/promises');
const co = require('co');
const ms = require('ms');
const retry = require('retry');
const MultiRange = require('multi-integer-range').MultiRange;
const sqlBase = require('./databaseConnectors/baseConnector');
const utilsDocService = require('./utilsDocService');
const docsCoServer = require('./DocsCoServer');
const taskResult = require('./taskresult');
const wopiUtils = require('./wopiUtils');
const wopiClient = require('./wopiClient');
const utils = require('./../../Common/sources/utils');
const constants = require('./../../Common/sources/constants');
const commonDefines = require('./../../Common/sources/commondefines');
const storage = require('./../../Common/sources/storage/storage-base');
const formatChecker = require('./../../Common/sources/formatchecker');
const statsDClient = require('./../../Common/sources/statsdclient');
const operationContext = require('./../../Common/sources/operationContext');
const tenantManager = require('./../../Common/sources/tenantManager');
const config = require('config');

const cfgTypesUpload = config.get('services.CoAuthoring.utils.limits_image_types_upload');
const cfgDocumentTypesUpload = config.get('services.CoAuthoring.utils.limits_document_types_upload');
const cfgImageSize = config.get('services.CoAuthoring.server.limits_image_size');
const cfgImageDownloadTimeout = config.get('services.CoAuthoring.server.limits_image_download_timeout');
const cfgRedisPrefix = config.get('services.CoAuthoring.redis.prefix');
const cfgTokenEnableBrowser = config.get('services.CoAuthoring.token.enable.browser');
const cfgTokenSessionAlgorithm = config.get('services.CoAuthoring.token.session.algorithm');
const cfgTokenSessionExpires = config.get('services.CoAuthoring.token.session.expires');
const cfgForgottenFiles = config.get('services.CoAuthoring.server.forgottenfiles');
const cfgForgottenFilesName = config.get('services.CoAuthoring.server.forgottenfilesname');
const cfgOpenProtectedFile = config.get('services.CoAuthoring.server.openProtectedFile');
const cfgExpUpdateVersionStatus = config.get('services.CoAuthoring.expire.updateVersionStatus');
const cfgCallbackBackoffOptions = config.get('services.CoAuthoring.callbackBackoffOptions');
const cfgAssemblyFormatAsOrigin = config.get('services.CoAuthoring.server.assemblyFormatAsOrigin');
const cfgDownloadMaxBytes = config.get('FileConverter.converter.maxDownloadBytes');
const cfgDownloadTimeout = config.get('FileConverter.converter.downloadTimeout');
const cfgDownloadFileAllowExt = config.get('services.CoAuthoring.server.downloadFileAllowExt');
const cfgNewFileTemplate = config.get('services.CoAuthoring.server.newFileTemplate');

const SAVE_TYPE_PART_START = 0;
const SAVE_TYPE_COMPLETE = 2;
const SAVE_TYPE_COMPLETE_ALL = 3;

const clientStatsD = statsDClient.getClient();
const redisKeyShutdown = cfgRedisPrefix + constants.REDIS_KEY_SHUTDOWN;
let hasPasswordCol = false; //stub on upgradev630.sql update failure
exports.hasAdditionalCol = false; //stub on upgradev710.sql update failure

function OutputDataWrap(type, data) {
  this['type'] = type;
  this['data'] = data;
}
OutputDataWrap.prototype = {
  fromObject(data) {
    this['type'] = data['type'];
    this['data'] = new OutputData();
    this['data'].fromObject(data['data']);
  },
  getType() {
    return this['type'];
  },
  setType(data) {
    this['type'] = data;
  },
  getData() {
    return this['data'];
  },
  setData(data) {
    this['data'] = data;
  }
};
function OutputData(type) {
  this['type'] = type;
  this['status'] = undefined;
  this['data'] = undefined;
  this['filetype'] = undefined;
  this['openedAt'] = undefined;
}
OutputData.prototype = {
  fromObject(data) {
    this['type'] = data['type'];
    this['status'] = data['status'];
    this['data'] = data['data'];
    this['filetype'] = data['filetype'];
    this['openedAt'] = data['openedAt'];
  },
  getType() {
    return this['type'];
  },
  setType(data) {
    this['type'] = data;
  },
  getStatus() {
    return this['status'];
  },
  setStatus(data) {
    this['status'] = data;
  },
  getData() {
    return this['data'];
  },
  setData(data) {
    this['data'] = data;
  },
  getExtName() {
    return this['filetype'];
  },
  setExtName(data) {
    this['filetype'] = data.substring(1);
  },
  getOpenedAt() {
    return this['openedAt'];
  },
  setOpenedAt(data) {
    this['openedAt'] = data;
  }
};

function getOpenedAt(row) {
  if (row) {
    return sqlBase.DocumentAdditional.prototype.getOpenedAt(row.additional);
  }
}
function getOpenedAtJSONParams(row) {
  const documentLayout = row && sqlBase.DocumentAdditional.prototype.getDocumentLayout(row.additional);
  if (documentLayout) {
    return {documentLayout};
  }
  return undefined;
}

async function getOutputData(ctx, cmd, outputData, key, optConn, optAdditionalOutput, opt_bIsRestore) {
  const tenExpUpdateVersionStatus = ms(ctx.getCfg('services.CoAuthoring.expire.updateVersionStatus', cfgExpUpdateVersionStatus));

  let status, statusInfo, password, creationDate, openedAt, originFormat, row;
  const selectRes = await taskResult.select(ctx, key);
  if (selectRes.length > 0) {
    row = selectRes[0];
    status = row.status;
    statusInfo = row.status_info;
    password = sqlBase.DocumentPassword.prototype.getCurPassword(ctx, row.password);
    creationDate = row.created_at && row.created_at.getTime();
    openedAt = getOpenedAt(row);
    originFormat = row.change_id;
    if (optAdditionalOutput) {
      optAdditionalOutput.row = row;
    }
  }
  switch (status) {
    case commonDefines.FileStatus.SaveVersion:
    case commonDefines.FileStatus.UpdateVersion:
    case commonDefines.FileStatus.Ok: {
      if (commonDefines.FileStatus.Ok === status) {
        outputData.setStatus('ok');
      } else if (optConn && optConn.isCloseCoAuthoring) {
        outputData.setStatus(constants.FILE_STATUS_UPDATE_VERSION);
      } else if (optConn && optConn.user.view) {
        outputData.setStatus('ok');
      } else if (
        commonDefines.FileStatus.SaveVersion === status ||
        (!opt_bIsRestore && commonDefines.FileStatus.UpdateVersion === status && Date.now() - statusInfo * 60000 > tenExpUpdateVersionStatus)
      ) {
        if (commonDefines.FileStatus.UpdateVersion === status) {
          ctx.logger.warn('UpdateVersion expired');
        }
        const updateMask = new taskResult.TaskResultData();
        updateMask.tenant = ctx.tenant;
        updateMask.key = key;
        updateMask.status = status;
        updateMask.statusInfo = statusInfo;
        const updateTask = new taskResult.TaskResultData();
        updateTask.status = commonDefines.FileStatus.Ok;
        updateTask.statusInfo = constants.NO_ERROR;
        const updateIfRes = await taskResult.updateIf(ctx, updateTask, updateMask);
        if (updateIfRes.affectedRows > 0) {
          outputData.setStatus('ok');
        } else {
          outputData.setStatus(constants.FILE_STATUS_UPDATE_VERSION);
        }
      } else {
        outputData.setStatus(constants.FILE_STATUS_UPDATE_VERSION);
      }
      const command = cmd.getCommand();
      if ('open' != command && 'reopen' != command && !cmd.getOutputUrls()) {
        const strPath = key + '/' + cmd.getOutputPath();
        if (optConn) {
          let url;
          if (cmd.getInline()) {
            url = await getPrintFileUrl(ctx, key, optConn.baseUrl, cmd.getTitle());
          } else {
            url = await storage.getSignedUrl(ctx, optConn.baseUrl, strPath, commonDefines.c_oAscUrlTypes.Temporary, cmd.getTitle());
          }
          outputData.setData(url);
          outputData.setExtName(pathModule.extname(strPath));
        } else if (optAdditionalOutput) {
          optAdditionalOutput.needUrlKey = cmd.getInline() ? key : strPath;
          optAdditionalOutput.needUrlMethod = 2;
          optAdditionalOutput.needUrlType = commonDefines.c_oAscUrlTypes.Temporary;
        }
      } else {
        const encryptedUserPassword = cmd.getPassword();
        let userPassword;
        let decryptedPassword;
        let isCorrectPassword;
        if (password && encryptedUserPassword) {
          decryptedPassword = await utils.decryptPassword(ctx, password);
          userPassword = await utils.decryptPassword(ctx, encryptedUserPassword);
          isCorrectPassword = decryptedPassword === userPassword;
        }
        let isNeedPassword = password && !isCorrectPassword;
        if (isNeedPassword && formatChecker.isBrowserEditorFormat(originFormat)) {
          //check pdf form
          //todo check without storage
          const formEditor = await storage.listObjects(ctx, key + '/Editor.bin');
          isNeedPassword = 0 !== formEditor.length;
        }
        if (isNeedPassword) {
          ctx.logger.debug('getOutputData password mismatch');
          if (encryptedUserPassword) {
            outputData.setStatus('needpassword');
            outputData.setData(constants.CONVERT_PASSWORD);
          } else {
            outputData.setStatus('needpassword');
            outputData.setData(constants.CONVERT_DRM);
          }
        } else if (optConn) {
          outputData.setOpenedAt(openedAt);
          outputData.setData(await storage.getSignedUrls(ctx, optConn.baseUrl, key, commonDefines.c_oAscUrlTypes.Session, creationDate));
          if (Object.keys(outputData.getData()).length === 0) {
            ctx.logger.warn('getOutputData empty storage');
            const updateMask = new taskResult.TaskResultData();
            updateMask.tenant = ctx.tenant;
            updateMask.key = key;
            updateMask.status = status;
            updateMask.statusInfo = statusInfo;
            const updateTask = new taskResult.TaskResultData();
            updateTask.status = commonDefines.FileStatus.None;
            updateTask.statusInfo = constants.NO_ERROR;
            const updateIfRes = await taskResult.updateIf(ctx, updateTask, updateMask);
            if (updateIfRes.affectedRows > 0) {
              status = commonDefines.FileStatus.None;
              outputData.setStatus(undefined);
            }
          }
        } else if (optAdditionalOutput) {
          optAdditionalOutput.needUrlKey = key;
          optAdditionalOutput.needUrlMethod = 0;
          optAdditionalOutput.needUrlType = commonDefines.c_oAscUrlTypes.Session;
          optAdditionalOutput.needUrlIsCorrectPassword = isCorrectPassword;
          optAdditionalOutput.creationDate = creationDate;
          optAdditionalOutput.openedAt = openedAt;
        }
      }
      break;
    }
    case commonDefines.FileStatus.NeedParams: {
      outputData.setStatus('needparams');
      const settingsPath = key + '/' + 'origin.' + cmd.getFormat();
      if (optConn) {
        const url = await storage.getSignedUrl(ctx, optConn.baseUrl, settingsPath, commonDefines.c_oAscUrlTypes.Temporary);
        outputData.setData(url);
      } else if (optAdditionalOutput) {
        optAdditionalOutput.needUrlKey = settingsPath;
        optAdditionalOutput.needUrlMethod = 1;
        optAdditionalOutput.needUrlType = commonDefines.c_oAscUrlTypes.Temporary;
      }
      break;
    }
    case commonDefines.FileStatus.NeedPassword:
      outputData.setStatus('needpassword');
      outputData.setData(statusInfo);
      break;
    case commonDefines.FileStatus.Err:
      outputData.setStatus('err');
      outputData.setData(statusInfo);
      break;
    case commonDefines.FileStatus.ErrToReload:
      outputData.setStatus('err');
      outputData.setData(statusInfo);
      await cleanupErrToReload(ctx, key);
      break;
    case commonDefines.FileStatus.None:
      //this status has no handler
      break;
    case commonDefines.FileStatus.WaitQueue:
      {
        const timeout = await utils.getConvertionTimeout(ctx);
        console.log(timeout);
        console.log(statusInfo);
        console.log(Date.now() - statusInfo * 60000);
        console.log(Date.now() - statusInfo * 60000 > timeout);
        if (Date.now() - statusInfo * 60000 > timeout) {
          ctx.logger.warn('WaitQueue expired');
          const updateMask = new taskResult.TaskResultData();
          updateMask.tenant = ctx.tenant;
          updateMask.key = key;
          updateMask.status = status;
          updateMask.statusInfo = statusInfo;
          const updateTask = new taskResult.TaskResultData();
          updateTask.status = commonDefines.FileStatus.None;
          updateTask.statusInfo = constants.NO_ERROR;
          const updateIfRes = await taskResult.updateIf(ctx, updateTask, updateMask);
          if (updateIfRes.affectedRows > 0) {
            status = commonDefines.FileStatus.None;
          }
        }
      }
      //task in the queue. response will be after convertion
      break;
    default:
      outputData.setStatus('err');
      outputData.setData(constants.UNKNOWN);
      break;
  }
  return status;
}
function* addRandomKeyTaskCmd(ctx, cmd) {
  const docId = cmd.getDocId();
  const task = yield* taskResult.addRandomKeyTask(ctx, docId);
  //set saveKey as postfix to fix vulnerability with path traversal to docId or other files
  cmd.setSaveKey(task.key.substring(docId.length));
}
function addPasswordToCmd(ctx, cmd, docPasswordStr, originFormat) {
  const docPassword = sqlBase.DocumentPassword.prototype.getDocPassword(ctx, docPasswordStr);
  if (docPassword.current) {
    if (formatChecker.isBrowserEditorFormat(originFormat)) {
      //todo not allowed different password
      cmd.setPassword(docPassword.current);
    }
    cmd.setSavePassword(docPassword.current);
  }
  if (docPassword.change) {
    cmd.setExternalChangeInfo(docPassword.change);
  }
}
function addOriginFormat(ctx, cmd, row) {
  cmd.setOriginFormat(row && row.change_id);
}

function changeFormatByOrigin(ctx, row, format) {
  const tenAssemblyFormatAsOrigin = ctx.getCfg('services.CoAuthoring.server.assemblyFormatAsOrigin', cfgAssemblyFormatAsOrigin);

  const originFormat = row && row.change_id;
  if (originFormat && constants.AVS_OFFICESTUDIO_FILE_UNKNOWN !== originFormat) {
    if (tenAssemblyFormatAsOrigin) {
      format = originFormat;
    } else {
      //for wopi always save origin
      const userAuthStr = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, row.callback);
      const wopiParams = wopiClient.parseWopiCallback(ctx, userAuthStr, row.callback);
      if (wopiParams) {
        format = originFormat;
      }
    }
  }
  return format;
}
function* saveParts(ctx, cmd, filename) {
  let result = false;
  const saveType = cmd.getSaveType();
  if (SAVE_TYPE_COMPLETE_ALL !== saveType) {
    const ext = pathModule.extname(filename);
    const saveIndex = parseInt(cmd.getSaveIndex()) || 1; //prevent path traversal
    filename = pathModule.basename(filename, ext) + saveIndex + ext;
  }
  if ((SAVE_TYPE_PART_START === saveType || SAVE_TYPE_COMPLETE_ALL === saveType) && !cmd.getSaveKey()) {
    yield* addRandomKeyTaskCmd(ctx, cmd);
  }
  if (cmd.getUrl()) {
    result = true;
  } else if (cmd.getData() && cmd.getData().length > 0 && cmd.getSaveKey()) {
    const buffer = cmd.getData();
    yield storage.putObject(ctx, cmd.getDocId() + cmd.getSaveKey() + '/' + filename, buffer, buffer.length);
    //delete data to prevent serialize into json
    cmd.data = null;
    result = SAVE_TYPE_COMPLETE_ALL === saveType || SAVE_TYPE_COMPLETE === saveType;
  } else {
    result = true;
  }
  return result;
}
function getSaveTask(ctx, cmd) {
  cmd.setData(null);
  const queueData = new commonDefines.TaskQueueData();
  queueData.setCtx(ctx);
  queueData.setCmd(cmd);
  queueData.setToFile(constants.OUTPUT_NAME + '.' + formatChecker.getStringFromFormat(cmd.getOutputFormat()));
  //todo paid
  //if (cmd.vkey) {
  //  bool
  //  bPaid;
  //  Signature.getVKeyParams(cmd.vkey, out bPaid);
  //  oTaskQueueData.m_bPaid = bPaid;
  //}
  return queueData;
}
async function getUpdateResponse(ctx, cmd) {
  const tenOpenProtectedFile = ctx.getCfg('services.CoAuthoring.server.openProtectedFile', cfgOpenProtectedFile);

  const updateTask = new taskResult.TaskResultData();
  updateTask.tenant = ctx.tenant;
  updateTask.key = cmd.getDocId();
  if (cmd.getSaveKey()) {
    updateTask.key += cmd.getSaveKey();
  }
  const statusInfo = cmd.getStatusInfo();
  if (constants.NO_ERROR === statusInfo) {
    updateTask.status = commonDefines.FileStatus.Ok;
    const password = cmd.getPassword();
    if (password) {
      if (false === hasPasswordCol) {
        const selectRes = await taskResult.select(ctx, updateTask.key);
        hasPasswordCol = selectRes.length > 0 && undefined !== selectRes[0].password;
      }
      if (hasPasswordCol) {
        updateTask.password = password;
      }
    }
  } else if (constants.CONVERT_TEMPORARY === statusInfo) {
    updateTask.status = commonDefines.FileStatus.ErrToReload;
  } else if (constants.CONVERT_DOWNLOAD === statusInfo) {
    updateTask.status = commonDefines.FileStatus.ErrToReload;
  } else if (constants.CONVERT_LIMITS === statusInfo) {
    updateTask.status = commonDefines.FileStatus.ErrToReload;
  } else if (constants.CONVERT_NEED_PARAMS === statusInfo) {
    updateTask.status = commonDefines.FileStatus.NeedParams;
  } else if (constants.CONVERT_DRM === statusInfo || constants.CONVERT_PASSWORD === statusInfo) {
    if (tenOpenProtectedFile) {
      updateTask.status = commonDefines.FileStatus.NeedPassword;
    } else {
      updateTask.status = commonDefines.FileStatus.Err;
    }
  } else if (constants.CONVERT_DRM_UNSUPPORTED === statusInfo) {
    updateTask.status = commonDefines.FileStatus.Err;
  } else if (constants.CONVERT_DEAD_LETTER === statusInfo) {
    updateTask.status = commonDefines.FileStatus.ErrToReload;
  } else {
    updateTask.status = commonDefines.FileStatus.Err;
  }
  updateTask.statusInfo = statusInfo;
  return updateTask;
}
const cleanupCache = co.wrap(function* (ctx, docId) {
  //todo redis ?
  let res = false;
  const removeRes = yield taskResult.remove(ctx, docId);
  if (removeRes.affectedRows > 0) {
    yield storage.deletePath(ctx, docId);
    if (docsCoServer?.editorStatProxy?.deleteKey) {
      yield docsCoServer.editorStatProxy.deleteKey(docId);
    }
    res = true;
  }
  ctx.logger.debug('cleanupCache docId=%s db.affectedRows=%d', docId, removeRes.affectedRows);
  return res;
});
const cleanupCacheIf = co.wrap(function* (ctx, mask) {
  //todo redis ?
  let res = false;
  const removeRes = yield taskResult.removeIf(ctx, mask);
  if (removeRes.affectedRows > 0) {
    sqlBase.deleteChanges(ctx, mask.key, null);
    yield storage.deletePath(ctx, mask.key);
    if (docsCoServer?.editorStatProxy?.deleteKey) {
      yield docsCoServer.editorStatProxy.deleteKey(mask.key);
    }
    res = true;
  }
  ctx.logger.debug('cleanupCacheIf db.affectedRows=%d', removeRes.affectedRows);
  return res;
});
async function cleanupErrToReload(ctx, key) {
  const updateTask = new taskResult.TaskResultData();
  updateTask.tenant = ctx.tenant;
  updateTask.key = key;
  updateTask.status = commonDefines.FileStatus.None;
  updateTask.statusInfo = constants.NO_ERROR;
  await taskResult.update(ctx, updateTask);
}

function commandOpenStartPromise(ctx, docId, baseUrl, opt_documentCallbackUrl, opt_format) {
  const task = new taskResult.TaskResultData();
  task.tenant = ctx.tenant;
  task.key = docId;
  //None instead WaitQueue to prevent: conversion task is lost when entering and leaving the editor quickly(that leads to an endless opening)
  task.status = commonDefines.FileStatus.None;
  task.statusInfo = constants.NO_ERROR;
  task.baseurl = baseUrl;
  if (opt_documentCallbackUrl) {
    task.callback = opt_documentCallbackUrl;
  }
  if (opt_format) {
    task.changeId = formatChecker.getFormatFromString(opt_format);
  }
  return taskResult.upsert(ctx, task);
}
function* commandOpen(ctx, conn, cmd, outputData, opt_upsertRes, opt_bIsRestore) {
  const tenForgottenFiles = ctx.getCfg('services.CoAuthoring.server.forgottenfiles', cfgForgottenFiles);

  let upsertRes;
  if (opt_upsertRes) {
    upsertRes = opt_upsertRes;
  } else {
    upsertRes = yield commandOpenStartPromise(ctx, cmd.getDocId(), utils.getBaseUrlByConnection(ctx, conn), undefined, cmd.getFormat());
  }
  const bCreate = upsertRes.isInsert;
  let needAddTask = bCreate;
  if (!bCreate) {
    needAddTask = yield* commandOpenFillOutput(ctx, conn, cmd, outputData, opt_bIsRestore);
  }
  if (conn.encrypted) {
    ctx.logger.debug('commandOpen encrypted %j', outputData);
    if (constants.FILE_STATUS_UPDATE_VERSION !== outputData.getStatus()) {
      //don't send output data
      outputData.setStatus(undefined);
    }
  } else if (needAddTask) {
    const updateMask = new taskResult.TaskResultData();
    updateMask.tenant = ctx.tenant;
    updateMask.key = cmd.getDocId();
    updateMask.status = commonDefines.FileStatus.None;

    const task = new taskResult.TaskResultData();
    task.status = commonDefines.FileStatus.WaitQueue;
    task.statusInfo = Math.floor(Date.now() / 60000); //minutes

    const updateIfRes = yield taskResult.updateIf(ctx, task, updateMask);
    if (updateIfRes.affectedRows > 0) {
      const forgotten = yield storage.listObjects(ctx, cmd.getDocId(), tenForgottenFiles);
      //replace url with forgotten file because it absorbed all lost changes
      if (forgotten.length > 0) {
        ctx.logger.debug('commandOpen from forgotten');
        cmd.setUrl(undefined);
        cmd.setForgotten(cmd.getDocId());
      }
      //add task
      if (!cmd.getOutputFormat()) {
        //todo remove getOpenFormatByEditor after 8.2.1
        cmd.setOutputFormat(docsCoServer.getOpenFormatByEditor(conn.editorType));
      }
      cmd.setEmbeddedFonts(false);
      const dataQueue = new commonDefines.TaskQueueData();
      dataQueue.setCtx(ctx);
      dataQueue.setCmd(cmd);
      dataQueue.setToFile('Editor.bin');
      yield* docsCoServer.addTask(dataQueue, constants.QUEUE_PRIORITY_HIGH);
    } else {
      yield* commandOpenFillOutput(ctx, conn, cmd, outputData, opt_bIsRestore);
    }
  }
}
function* commandOpenFillOutput(ctx, conn, cmd, outputData, opt_bIsRestore) {
  const status = yield getOutputData(ctx, cmd, outputData, cmd.getDocId(), conn, undefined, opt_bIsRestore);
  return commonDefines.FileStatus.None === status;
}
function* commandReopen(ctx, conn, cmd, outputData) {
  const tenOpenProtectedFile = ctx.getCfg('services.CoAuthoring.server.openProtectedFile', cfgOpenProtectedFile);

  let res = true;
  const isPassword = undefined !== cmd.getPassword();
  if (isPassword) {
    const selectRes = yield taskResult.select(ctx, cmd.getDocId());
    if (selectRes.length > 0) {
      const row = selectRes[0];
      if (sqlBase.DocumentPassword.prototype.getCurPassword(ctx, row.password)) {
        ctx.logger.debug('commandReopen has password');
        yield* commandOpenFillOutput(ctx, conn, cmd, outputData, false);
        yield docsCoServer.modifyConnectionForPassword(ctx, conn, constants.FILE_STATUS_OK === outputData.getStatus());
        return res;
      }
    }
  }
  if (!isPassword || tenOpenProtectedFile) {
    const updateMask = new taskResult.TaskResultData();
    updateMask.tenant = ctx.tenant;
    updateMask.key = cmd.getDocId();
    updateMask.status = isPassword ? commonDefines.FileStatus.NeedPassword : commonDefines.FileStatus.NeedParams;

    const task = new taskResult.TaskResultData();
    task.status = commonDefines.FileStatus.WaitQueue;
    task.statusInfo = Math.floor(Date.now() / 60000); //minutes

    const upsertRes = yield taskResult.updateIf(ctx, task, updateMask);
    if (upsertRes.affectedRows > 0) {
      //add task
      cmd.setUrl(null); //url may expire
      if (!cmd.getOutputFormat()) {
        //todo remove getOpenFormatByEditor after 8.2.1
        cmd.setOutputFormat(docsCoServer.getOpenFormatByEditor(conn.editorType));
      }
      cmd.setEmbeddedFonts(false);
      if (isPassword) {
        cmd.setUserConnectionId(conn.user.id);
      }
      const dataQueue = new commonDefines.TaskQueueData();
      dataQueue.setCtx(ctx);
      dataQueue.setCmd(cmd);
      dataQueue.setToFile('Editor.bin');
      dataQueue.setFromSettings(true);
      yield* docsCoServer.addTask(dataQueue, constants.QUEUE_PRIORITY_HIGH);
    } else {
      outputData.setStatus('needpassword');
      outputData.setData(constants.CONVERT_PASSWORD);
    }
  } else {
    res = false;
  }
  return res;
}
function* commandSave(ctx, cmd, outputData) {
  const format = cmd.getFormat() || 'bin';
  const completeParts = yield* saveParts(ctx, cmd, 'Editor.' + format);
  if (completeParts) {
    const queueData = getSaveTask(ctx, cmd);
    yield* docsCoServer.addTask(queueData, constants.QUEUE_PRIORITY_LOW);
  }
  outputData.setStatus('ok');
  outputData.setData(cmd.getSaveKey());
}
function* commandSendMailMerge(ctx, cmd, outputData) {
  const mailMergeSend = cmd.getMailMergeSend();
  const isJson = mailMergeSend.getIsJsonKey();
  const completeParts = yield* saveParts(ctx, cmd, isJson ? 'Editor.json' : 'Editor.bin');
  let isErr = false;
  if (completeParts && !isJson) {
    isErr = true;
    const getRes = yield docsCoServer.getCallback(ctx, cmd.getDocId(), cmd.getUserIndex());
    if (getRes && !getRes.wopiParams) {
      mailMergeSend.setUrl(getRes.server.href);
      mailMergeSend.setBaseUrl(getRes.baseUrl);
      //we change JsonKey and SaveKey, a new key is needed because a part is done in one conversion, and json is always needed
      mailMergeSend.setJsonKey(cmd.getSaveKey());
      mailMergeSend.setRecordErrorCount(0);
      yield* addRandomKeyTaskCmd(ctx, cmd);
      const queueData = getSaveTask(ctx, cmd);
      yield* docsCoServer.addTask(queueData, constants.QUEUE_PRIORITY_LOW);
      isErr = false;
    } else if (getRes.wopiParams) {
      ctx.logger.warn('commandSendMailMerge unexpected with wopi');
    }
  }
  if (isErr) {
    outputData.setStatus('err');
    outputData.setData(constants.UNKNOWN);
  } else {
    outputData.setStatus('ok');
    outputData.setData(cmd.getSaveKey());
  }
}
const commandSfctByCmd = co.wrap(function* (ctx, cmd, opt_priority, opt_expiration, opt_queue, opt_initShardKey) {
  const selectRes = yield taskResult.selectWithCache(ctx, cmd.getDocId());
  const row = selectRes.length > 0 ? selectRes[0] : null;
  if (!row) {
    return false;
  }
  if (opt_initShardKey) {
    ctx.setShardKey(sqlBase.DocumentAdditional.prototype.getShardKey(row.additional));
    ctx.setWopiSrc(sqlBase.DocumentAdditional.prototype.getWopiSrc(row.additional));
  }
  yield* addRandomKeyTaskCmd(ctx, cmd);
  addPasswordToCmd(ctx, cmd, row.password, row.change_id);
  addOriginFormat(ctx, cmd, row);
  const userAuthStr = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, row.callback);
  cmd.setWopiParams(wopiClient.parseWopiCallback(ctx, userAuthStr, row.callback));
  cmd.setOutputFormat(changeFormatByOrigin(ctx, row, cmd.getOutputFormat()));
  cmd.appendJsonParams(getOpenedAtJSONParams(row));
  const queueData = getSaveTask(ctx, cmd);
  queueData.setFromChanges(true);
  const priority = null != opt_priority ? opt_priority : constants.QUEUE_PRIORITY_LOW;
  yield* docsCoServer.addTask(queueData, priority, opt_queue, opt_expiration);
  return true;
});
function isDisplayedImage(strName) {
  let res = 0;
  if (strName) {
    //template display[N]image.ext
    const findStr = constants.DISPLAY_PREFIX;
    const index = strName.indexOf(findStr);
    if (-1 != index) {
      if (index + findStr.length < strName.length) {
        const displayN = parseInt(strName[index + findStr.length]);
        if (!isNaN(displayN)) {
          const imageIndex = index + findStr.length + 1;
          if (imageIndex == strName.indexOf('image', imageIndex)) {
            res = displayN;
          }
        }
      }
    }
  }
  return res;
}
function* commandImgurls(ctx, conn, cmd, outputData) {
  const tenTypesUpload = ctx.getCfg('services.CoAuthoring.utils.limits_image_types_upload', cfgTypesUpload);
  const tenDocumentTypesUpload = ctx.getCfg('services.CoAuthoring.utils.limits_document_types_upload', cfgDocumentTypesUpload);
  const tenImageSize = ctx.getCfg('services.CoAuthoring.server.limits_image_size', cfgImageSize);
  const tenImageDownloadTimeout = ctx.getCfg('services.CoAuthoring.server.limits_image_download_timeout', cfgImageDownloadTimeout);
  const tenTokenEnableBrowser = ctx.getCfg('services.CoAuthoring.token.enable.browser', cfgTokenEnableBrowser);

  let errorCode = constants.NO_ERROR;
  let urls = cmd.getData();
  const authorizations = [];
  let isInJwtToken = false;
  const token = cmd.getTokenDownload();
  if (tenTokenEnableBrowser && token) {
    // allow requests without token
    const checkJwtRes = yield docsCoServer.checkJwt(ctx, token, commonDefines.c_oAscSecretType.Browser);
    if (checkJwtRes.decoded) {
      //todo multiple url case
      if (checkJwtRes.decoded.images) {
        urls = checkJwtRes.decoded.images.map(curValue => {
          return curValue.url;
        });
      } else {
        urls = [checkJwtRes.decoded.url];
      }
      for (let i = 0; i < urls.length; ++i) {
        if (utils.canIncludeOutboxAuthorization(ctx, urls[i])) {
          const secret = yield tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Outbox);
          authorizations[i] = [utils.fillJwtForRequest(ctx, {url: urls[i]}, secret, false)];
        }
      }
      isInJwtToken = true;
    } else {
      ctx.logger.warn('Error commandImgurls jwt: %s', checkJwtRes.description);
      errorCode = constants.VKEY_ENCRYPT;
    }
  }
  const supportedFormats = tenTypesUpload || 'jpg';
  const supportedDocumentFormats = tenDocumentTypesUpload || 'xlsx';
  const outputUrls = [];
  if (constants.NO_ERROR === errorCode && !conn.user.view && !conn.isCloseCoAuthoring) {
    //todo Promise.all()
    const displayedImageMap = {}; //to make one prefix for ole object urls
    for (let i = 0; i < urls.length; ++i) {
      const urlSource = urls[i];
      let urlParsed;
      let isDocument = false;
      let data = undefined;
      if (urlSource?.startsWith('data:')) {
        const delimiterIndex = urlSource.indexOf(',');
        if (-1 != delimiterIndex) {
          const dataLen = urlSource.length - (delimiterIndex + 1);
          if ('hex' === urlSource.substring(delimiterIndex - 3, delimiterIndex).toLowerCase()) {
            if (dataLen * 0.5 <= tenImageSize) {
              data = Buffer.from(urlSource.substring(delimiterIndex + 1), 'hex');
            } else {
              errorCode = constants.UPLOAD_CONTENT_LENGTH;
            }
          } else {
            if (dataLen * 0.75 <= tenImageSize) {
              data = Buffer.from(urlSource.substring(delimiterIndex + 1), 'base64');
            } else {
              errorCode = constants.UPLOAD_CONTENT_LENGTH;
            }
          }
        }
      } else if (urlSource) {
        try {
          if (authorizations[i]) {
            const urlParsed = urlModule.parse(urlSource);
            const filterStatus = yield* utils.checkHostFilter(ctx, urlParsed.hostname);
            if (0 !== filterStatus) {
              throw Error('checkIpFilter');
            }
          }
          //todo stream
          const getRes = yield utils.downloadUrlPromise(ctx, urlSource, tenImageDownloadTimeout, tenImageSize, authorizations[i], isInJwtToken);
          data = getRes.body;
          urlParsed = urlModule.parse(urlSource);
        } catch (e) {
          data = undefined;
          ctx.logger.error('error commandImgurls download: url = %s; %s', urlSource, e.stack);
          if (e.code === 'EMSGSIZE') {
            errorCode = constants.UPLOAD_CONTENT_LENGTH;
          } else {
            errorCode = constants.UPLOAD_URL;
          }
        }
      }

      let outputUrl = {url: 'error', path: 'error'};
      if (data) {
        // process image: fix EXIF rotation and convert unsupported formats to optimal format
        data = yield utilsDocService.processImageOptimal(ctx, data);

        const format = formatChecker.getImageFormat(ctx, data);
        let formatStr;
        let isAllow = false;
        if (constants.AVS_OFFICESTUDIO_FILE_UNKNOWN !== format) {
          formatStr = formatChecker.getStringFromFormat(format);
          if (formatStr && -1 !== supportedFormats.indexOf(formatStr)) {
            isAllow = true;
          }
        } else if (constants.AVS_OFFICESTUDIO_FILE_SPREADSHEET_XLSX === formatChecker.getOfficeZipFormatBySignature(data)) {
          formatStr = 'xlsx';
          if (-1 !== supportedDocumentFormats.indexOf(formatStr)) {
            isAllow = true;
            isDocument = true;
          }
        }
        if (!isAllow && urlParsed) {
          //for ole object, presentation video/audio
          const ext = pathModule.extname(urlParsed.pathname).substring(1);
          const urlBasename = pathModule.basename(urlParsed.pathname);
          const displayedImageName = urlBasename.substring(0, urlBasename.length - ext.length - 1);
          if (Object.hasOwn(displayedImageMap, displayedImageName)) {
            formatStr = ext;
            isAllow = true;
          }
        }
        if (isAllow) {
          let strLocalPath = 'media/' + crypto.randomBytes(16).toString('hex') + '_';
          if (urlParsed) {
            const urlBasename = pathModule.basename(urlParsed.pathname);
            const displayN = isDisplayedImage(urlBasename);
            if (displayN > 0) {
              const displayedImageName = urlBasename.substring(0, urlBasename.length - formatStr.length - 1);
              if (displayedImageMap[displayedImageName]) {
                strLocalPath = displayedImageMap[displayedImageName];
              } else {
                displayedImageMap[displayedImageName] = strLocalPath;
              }
              strLocalPath += constants.DISPLAY_PREFIX + displayN;
            }
          }
          if (isDocument) {
            strLocalPath += 'document1' + '.' + formatStr;
          } else {
            strLocalPath += 'image1' + '.' + formatStr;
          }
          const strPath = cmd.getDocId() + '/' + strLocalPath;
          yield storage.putObject(ctx, strPath, data, data.length);
          const imgUrl = yield storage.getSignedUrl(ctx, conn.baseUrl, strPath, commonDefines.c_oAscUrlTypes.Session);
          outputUrl = {url: imgUrl, path: strLocalPath};
        }
      }
      if (constants.NO_ERROR === errorCode && ('error' === outputUrl.url || 'error' === outputUrl.path)) {
        errorCode = constants.UPLOAD_EXTENSION;
      }
      outputUrls.push(outputUrl);
    }
  } else if (constants.NO_ERROR === errorCode) {
    ctx.logger.warn('error commandImgurls: access deny');
    errorCode = constants.UPLOAD;
  }
  if (constants.NO_ERROR !== errorCode && 0 == outputUrls.length) {
    outputData.setStatus('err');
    outputData.setData(errorCode);
  } else {
    outputData.setStatus('ok');
    outputData.setData({error: errorCode, urls: outputUrls});
  }
}
function* commandPathUrls(ctx, conn, data, outputData) {
  const listImages = data.map(currentValue => {
    return conn.docId + '/' + currentValue;
  });
  const urls = yield storage.getSignedUrlsArrayByArray(ctx, conn.baseUrl, listImages, commonDefines.c_oAscUrlTypes.Session);
  outputData.setStatus('ok');
  outputData.setData(urls);
}
function* commandPathUrl(ctx, conn, cmd, outputData) {
  const strPath = conn.docId + '/' + cmd.getData();
  const url = yield storage.getSignedUrl(ctx, conn.baseUrl, strPath, commonDefines.c_oAscUrlTypes.Temporary, cmd.getTitle());
  const errorCode = constants.NO_ERROR;
  if (constants.NO_ERROR !== errorCode) {
    outputData.setStatus('err');
    outputData.setData(errorCode);
  } else {
    outputData.setStatus('ok');
    outputData.setData(url);
    outputData.setExtName(pathModule.extname(strPath));
  }
}
function* commandSaveFromOrigin(ctx, cmd, outputData, password) {
  const completeParts = yield* saveParts(ctx, cmd, 'changes0.json');
  if (completeParts) {
    const docPassword = sqlBase.DocumentPassword.prototype.getDocPassword(ctx, password);
    //Use current password for pdf because password is entered in the browser when opening and is set via setPassword
    if (docPassword.initial || docPassword.current) {
      cmd.setPassword(docPassword.initial || docPassword.current);
    }
    //todo setLCID in browser
    const queueData = getSaveTask(ctx, cmd);
    queueData.setFromOrigin(true);
    queueData.setFromChanges(true);
    yield* docsCoServer.addTask(queueData, constants.QUEUE_PRIORITY_LOW);
  }
  outputData.setStatus('ok');
  outputData.setData(cmd.getSaveKey());
}
function* commandSetPassword(ctx, conn, cmd, outputData) {
  const tenOpenProtectedFile = ctx.getCfg('services.CoAuthoring.server.openProtectedFile', cfgOpenProtectedFile);

  let hasDocumentPassword = false;
  let isDocumentPasswordModified = true;
  let originFormat;
  const selectRes = yield taskResult.select(ctx, cmd.getDocId());
  if (selectRes.length > 0) {
    const row = selectRes[0];
    originFormat = row.change_id;
    hasPasswordCol = undefined !== row.password;
    if (commonDefines.FileStatus.Ok === row.status) {
      const documentPasswordCurEnc = sqlBase.DocumentPassword.prototype.getCurPassword(ctx, row.password);
      if (documentPasswordCurEnc) {
        hasDocumentPassword = true;
        if (cmd.getPassword()) {
          const passwordCurPlain = yield utils.decryptPassword(ctx, documentPasswordCurEnc);
          const passwordPlain = yield utils.decryptPassword(ctx, cmd.getPassword());
          isDocumentPasswordModified = passwordCurPlain !== passwordPlain;
        }
      }
    }
  }
  //https://github.com/ONLYOFFICE/web-apps/blob/4a7879b4f88f315fe94d9f7d97c0ed8aa9f82221/apps/documenteditor/main/app/controller/Main.js#L1652
  //this.appOptions.isPasswordSupport = this.appOptions.isEdit && this.api.asc_isProtectionSupport() && (this.permissions.protect!==false);
  const isPasswordSupport = tenOpenProtectedFile && !conn.user?.view && false !== conn.permissions?.protect;
  ctx.logger.debug(
    'commandSetPassword isEnterCorrectPassword=%s, hasDocumentPassword=%s, hasPasswordCol=%s, isPasswordSupport=%s',
    conn.isEnterCorrectPassword,
    hasDocumentPassword,
    hasPasswordCol,
    isPasswordSupport
  );
  if (isPasswordSupport && hasPasswordCol && hasDocumentPassword && !isDocumentPasswordModified) {
    outputData.setStatus('ok');
  } else if (isPasswordSupport && (conn.isEnterCorrectPassword || !hasDocumentPassword) && hasPasswordCol) {
    const updateMask = new taskResult.TaskResultData();
    updateMask.tenant = ctx.tenant;
    updateMask.key = cmd.getDocId();
    updateMask.status = commonDefines.FileStatus.Ok;

    const newChangesLastDate = new Date();
    newChangesLastDate.setMilliseconds(0); //remove milliseconds avoid issues with MySQL datetime rounding

    const task = new taskResult.TaskResultData();
    task.password = cmd.getPassword() || '';
    let changeInfo = null;
    if (conn.user && (hasDocumentPassword || !formatChecker.isBrowserEditorFormat(originFormat))) {
      changeInfo = task.innerPasswordChange = docsCoServer.getExternalChangeInfo(conn.user, newChangesLastDate.getTime(), conn.lang);
    }

    const upsertRes = yield taskResult.updateIf(ctx, task, updateMask);
    if (upsertRes.affectedRows > 0) {
      outputData.setStatus('ok');
      if (!conn.isEnterCorrectPassword) {
        yield docsCoServer.modifyConnectionForPassword(ctx, conn, true);
      }
      if (changeInfo) {
        const forceSave = yield docsCoServer.editorData.getForceSave(ctx, cmd.getDocId());
        const 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');
      outputData.setData(constants.PASSWORD);
    }
  } else {
    outputData.setStatus('err');
    outputData.setData(constants.PASSWORD);
  }
}
function* commandChangeDocInfo(ctx, conn, cmd, outputData) {
  const res = yield docsCoServer.changeConnectionInfo(ctx, conn, cmd);
  if (res) {
    outputData.setStatus('ok');
  } else {
    outputData.setStatus('err');
    outputData.setData(constants.CHANGE_DOC_INFO);
  }
}
function checkAndFixAuthorizationLength(authorization, data) {
  //todo it is stub (remove in future versions)
  //8kb(https://stackoverflow.com/questions/686217/maximum-on-http-header-values) - 1kb(for other headers)
  const res = authorization.length < 7168;
  if (!res) {
    data.setChangeUrl(undefined);
    data.setChangeHistory({});
  }
  return res;
}
const commandSfcCallback = co.wrap(function* (ctx, cmd, isSfcm, isEncrypted) {
  const tenForgottenFiles = ctx.getCfg('services.CoAuthoring.server.forgottenfiles', cfgForgottenFiles);
  const tenForgottenFilesName = ctx.getCfg('services.CoAuthoring.server.forgottenfilesname', cfgForgottenFilesName);
  const tenCallbackBackoffOptions = ctx.getCfg('services.CoAuthoring.callbackBackoffOptions', cfgCallbackBackoffOptions);

  const docId = cmd.getDocId();
  ctx.logger.debug('Start commandSfcCallback');
  const statusInfo = cmd.getStatusInfo();
  //setUserId - set from changes in convert
  //setUserActionId - used in case of save without changes(forgotten files)
  const userLastChangeId = cmd.getUserId() || cmd.getUserActionId();
  const userLastChangeIndex = cmd.getUserIndex() || cmd.getUserActionIndex();
  let replyStr;
  let isSfcmSuccess = false;
  let isSfcSuccess = false;
  let needRetry = false;
  let needUpdateVersionEvent = !isSfcm && !isEncrypted;
  if (constants.EDITOR_CHANGES !== statusInfo || isSfcm) {
    const saveKey = docId + cmd.getSaveKey();
    let isError = constants.NO_ERROR != statusInfo;
    const isErrorCorrupted = constants.CONVERT_CORRUPTED == statusInfo;
    const savePathDoc = saveKey + '/' + cmd.getOutputPath();
    const savePathChanges = saveKey + '/changes.zip';
    const savePathHistory = saveKey + '/changesHistory.json';
    const forceSave = cmd.getForceSave();
    const forceSaveType = forceSave ? forceSave.getType() : commonDefines.c_oAscForceSaveTypes.Command;
    const forceSaveUserId = forceSave ? forceSave.getAuthorUserId() : undefined;
    const forceSaveUserIndex = forceSave ? forceSave.getAuthorUserIndex() : undefined;
    const callbackUserIndex = forceSaveUserIndex || 0 === forceSaveUserIndex ? forceSaveUserIndex : userLastChangeIndex;
    let uri, baseUrl, wopiParams, lastOpenDate;
    const selectRes = yield taskResult.select(ctx, docId);
    const row = selectRes.length > 0 ? selectRes[0] : null;
    if (row) {
      if (row.callback) {
        uri = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, row.callback, callbackUserIndex);
        wopiParams = wopiClient.parseWopiCallback(ctx, uri, row.callback);
      }
      if (row.baseurl) {
        baseUrl = row.baseurl;
      }
      lastOpenDate = row.last_open_date;
    }
    let storeForgotten = false;
    let statusOk;
    let statusErr;
    if (isSfcm) {
      statusOk = docsCoServer.c_oAscServerStatus.MustSaveForce;
      statusErr = docsCoServer.c_oAscServerStatus.CorruptedForce;
    } else {
      statusOk = docsCoServer.c_oAscServerStatus.MustSave;
      statusErr = docsCoServer.c_oAscServerStatus.Corrupted;
    }
    const recoverTask = new taskResult.TaskResultData();
    recoverTask.status = commonDefines.FileStatus.Ok;
    recoverTask.statusInfo = constants.NO_ERROR;
    let updateIfTask = new taskResult.TaskResultData();
    updateIfTask.status = commonDefines.FileStatus.UpdateVersion;
    updateIfTask.statusInfo = Math.floor(Date.now() / 60000); //minutes
    let updateIfRes;

    const updateMask = new taskResult.TaskResultData();
    updateMask.tenant = ctx.tenant;
    updateMask.key = docId;
    if (row) {
      if (isEncrypted) {
        recoverTask.status = updateMask.status = row.status;
        recoverTask.statusInfo = updateMask.statusInfo = row.status_info;
      } else if (
        (commonDefines.FileStatus.SaveVersion === row.status && cmd.getStatusInfoIn() === row.status_info) ||
        commonDefines.FileStatus.UpdateVersion === row.status
      ) {
        if (commonDefines.FileStatus.UpdateVersion === row.status) {
          updateIfRes = {affectedRows: 1};
        }
        recoverTask.status = commonDefines.FileStatus.SaveVersion;
        recoverTask.statusInfo = cmd.getStatusInfoIn();
        updateMask.status = row.status;
        updateMask.statusInfo = row.status_info;
      } else {
        updateIfRes = {affectedRows: 0};
      }
    } else {
      isError = true;
    }
    let outputSfc;
    if (uri && baseUrl && userLastChangeId) {
      ctx.logger.debug('Callback commandSfcCallback: callback = %s', uri);
      outputSfc = new commonDefines.OutputSfcData(docId);
      outputSfc.setEncrypted(isEncrypted);
      const users = [];
      let isOpenFromForgotten = false;
      if (userLastChangeId) {
        users.push(userLastChangeId);
      }
      outputSfc.setUsers(users);
      if (!isSfcm) {
        const actions = [];
        //use UserId case UserActionId miss in gc convertion
        const userActionId = cmd.getUserActionId() || cmd.getUserId();
        if (userActionId) {
          actions.push(new commonDefines.OutputAction(commonDefines.c_oAscUserAction.Out, userActionId));
        }
        outputSfc.setActions(actions);
      } else if (forceSaveUserId) {
        outputSfc.setActions([new commonDefines.OutputAction(commonDefines.c_oAscUserAction.ForceSaveButton, forceSaveUserId)]);
      }
      outputSfc.setUserData(cmd.getUserData());
      const formsData = cmd.getFormData();
      if (formsData) {
        const formsDataPath = saveKey + '/formsdata.json';
        const formsBuffer = Buffer.from(JSON.stringify(formsData), 'utf8');
        yield storage.putObject(ctx, formsDataPath, formsBuffer, formsBuffer.length);
        const formsDataUrl = yield storage.getSignedUrl(ctx, baseUrl, formsDataPath, commonDefines.c_oAscUrlTypes.Temporary);
        outputSfc.setFormsDataUrl(formsDataUrl);
      }
      if (!isError || isErrorCorrupted) {
        try {
          const forgotten = yield storage.listObjects(ctx, docId, tenForgottenFiles);
          let isSendHistory = 0 === forgotten.length;
          if (!isSendHistory) {
            //check indicator file to determine if opening was from the forgotten file
            const forgottenMarkPath = docId + '/' + tenForgottenFilesName + '.txt';
            const forgottenMark = yield storage.listObjects(ctx, forgottenMarkPath);
            isOpenFromForgotten = 0 !== forgottenMark.length;
            isSendHistory = !isOpenFromForgotten;
            ctx.logger.debug('commandSfcCallback forgotten no empty: isSendHistory = %s', isSendHistory);
          }
          if (isSendHistory && !isEncrypted) {
            //don't send history info because changes isn't from file in storage
            const data = yield storage.getObject(ctx, savePathHistory);
            outputSfc.setChangeHistory(JSON.parse(data.toString('utf-8')));
            const changeUrl = yield storage.getSignedUrl(ctx, baseUrl, savePathChanges, commonDefines.c_oAscUrlTypes.Temporary);
            outputSfc.setChangeUrl(changeUrl);
          } else {
            //for backward compatibility. remove this when Community is ready
            outputSfc.setChangeHistory({});
          }
          const url = yield storage.getSignedUrl(ctx, baseUrl, savePathDoc, commonDefines.c_oAscUrlTypes.Temporary);
          outputSfc.setUrl(url);
          outputSfc.setExtName(pathModule.extname(savePathDoc));
        } catch (e) {
          ctx.logger.error('Error commandSfcCallback: %s', e.stack);
        }
        if (outputSfc.getUrl() && outputSfc.getUsers().length > 0) {
          outputSfc.setStatus(statusOk);
        } else {
          isError = true;
        }
      }
      if (isError) {
        outputSfc.setStatus(statusErr);
      }
      if (isSfcm) {
        const selectRes = yield taskResult.select(ctx, docId);
        const row = selectRes.length > 0 ? selectRes[0] : null;
        //send only if FileStatus.Ok to prevent forcesave after final save
        if (row && row.status == commonDefines.FileStatus.Ok) {
          if (forceSave) {
            const forceSaveDate = forceSave.getTime() ? new Date(forceSave.getTime()) : new Date();
            outputSfc.setForceSaveType(forceSaveType);
            outputSfc.setLastSave(forceSaveDate.toISOString());
          }
          if (forceSave && forceSaveType === commonDefines.c_oAscForceSaveTypes.Internal) {
            //send to browser only if internal forcesave
            isSfcmSuccess = true;
          } else {
            try {
              if (wopiParams) {
                if (outputSfc.getUrl()) {
                  if (forceSaveType === commonDefines.c_oAscForceSaveTypes.Form) {
                    yield processWopiSaveAs(ctx, cmd);
                    replyStr = JSON.stringify({error: 0});
                  } else {
                    const isAutoSave =
                      forceSaveType !== commonDefines.c_oAscForceSaveTypes.Button && forceSaveType !== commonDefines.c_oAscForceSaveTypes.Form;
                    replyStr = yield processWopiPutFile(ctx, docId, wopiParams, savePathDoc, userLastChangeId, true, isAutoSave, false);
                  }
                } else {
                  replyStr = JSON.stringify({error: 1, descr: 'wopi: no file'});
                }
              } else {
                replyStr = yield docsCoServer.sendServerRequest(ctx, uri, outputSfc, checkAndFixAuthorizationLength);
              }
              const replyData = docsCoServer.parseReplyData(ctx, replyStr);
              isSfcmSuccess = replyData && commonDefines.c_oAscServerCommandErrors.NoError == replyData.error;
              if (replyData && commonDefines.c_oAscServerCommandErrors.NoError != replyData.error) {
                ctx.logger.warn('sendServerRequest returned an error: data = %s', replyStr);
              }
            } catch (err) {
              ctx.logger.error('sendServerRequest error: url = %s;data = %j %s', uri, outputSfc, err.stack);
            }
          }
        }
      } else {
        //if anybody in document stop save
        const editorsCount = yield docsCoServer.getEditorsCountPromise(ctx, docId);
        ctx.logger.debug('commandSfcCallback presence: count = %d', editorsCount);
        if (0 === editorsCount || (isEncrypted && 1 === editorsCount)) {
          if (!updateIfRes) {
            updateIfRes = yield taskResult.updateIf(ctx, updateIfTask, updateMask);
          }
          if (updateIfRes.affectedRows > 0) {
            const actualForceSave = yield docsCoServer.editorData.getForceSave(ctx, docId);
            const forceSaveDate = actualForceSave && actualForceSave.time ? new Date(actualForceSave.time) : new Date();
            const notModified = actualForceSave && true === actualForceSave.ended;
            outputSfc.setLastSave(forceSaveDate.toISOString());
            outputSfc.setNotModified(notModified);

            updateMask.status = updateIfTask.status;
            updateMask.statusInfo = updateIfTask.statusInfo;
            try {
              if (wopiParams) {
                if (outputSfc.getUrl()) {
                  replyStr = yield processWopiPutFile(ctx, docId, wopiParams, savePathDoc, userLastChangeId, !notModified, false, true);
                } else {
                  replyStr = JSON.stringify({error: 1, descr: 'wopi: no file'});
                }
              } else {
                replyStr = yield docsCoServer.sendServerRequest(ctx, uri, outputSfc, checkAndFixAuthorizationLength);
              }
            } catch (err) {
              ctx.logger.error('sendServerRequest error: url = %s;data = %j %s', uri, outputSfc, err.stack);
              const retryHttpStatus = new MultiRange(tenCallbackBackoffOptions.httpStatus);
              if (!isEncrypted && !docsCoServer.getIsShutdown() && (!err.statusCode || retryHttpStatus.has(err.statusCode.toString()))) {
                const attempt = cmd.getAttempt() || 0;
                if (attempt < tenCallbackBackoffOptions.retries) {
                  needRetry = true;
                } else {
                  ctx.logger.warn('commandSfcCallback backoff limit exceeded');
                }
              }
            }
            let requestRes = false;
            const replyData = docsCoServer.parseReplyData(ctx, replyStr);
            if (replyData && commonDefines.c_oAscServerCommandErrors.NoError == replyData.error) {
              //in the case of a community server, a request will come to the Command Service, check the result
              const savedVal = yield docsCoServer.editorData.getdelSaved(ctx, docId);
              requestRes = null == savedVal || '1' === savedVal;
            }
            if (replyData && commonDefines.c_oAscServerCommandErrors.NoError != replyData.error) {
              ctx.logger.warn('sendServerRequest returned an error: data = %s', replyStr);
            }
            if (requestRes) {
              isSfcSuccess = true;
              updateIfTask = undefined;
              yield docsCoServer.cleanDocumentOnExitPromise(ctx, docId, true, callbackUserIndex);
              if (isOpenFromForgotten) {
                //remove forgotten file in cache
                yield cleanupCache(ctx, docId);
              }
              if (lastOpenDate) {
                //todo error case
                const time = new Date() - lastOpenDate;
                ctx.logger.debug('commandSfcCallback saveAfterEditingSessionClosed=%d', time);
                if (clientStatsD) {
                  clientStatsD.timing('coauth.saveAfterEditingSessionClosed', time);
                }
              }
            } else {
              storeForgotten = true;
            }
          } else {
            updateIfTask = undefined;
            needUpdateVersionEvent = false;
          }
        } else {
          needUpdateVersionEvent = false;
        }
      }
    } else {
      ctx.logger.warn('Empty Callback=%s or baseUrl=%s or userLastChangeId=%s commandSfcCallback', uri, baseUrl, userLastChangeId);
      storeForgotten = true;
    }
    if (undefined !== updateIfTask && !isSfcm) {
      ctx.logger.debug('commandSfcCallback restore %d status', recoverTask.status);
      updateIfTask.status = recoverTask.status;
      updateIfTask.statusInfo = recoverTask.statusInfo;
      updateIfRes = yield taskResult.updateIf(ctx, updateIfTask, updateMask);
      if (updateIfRes.affectedRows > 0) {
        updateMask.status = updateIfTask.status;
        updateMask.statusInfo = updateIfTask.statusInfo;
      } else {
        ctx.logger.debug('commandSfcCallback restore %d status failed', recoverTask.status);
      }
    }
    if (storeForgotten && !needRetry && !isEncrypted && (!isError || isErrorCorrupted)) {
      try {
        ctx.logger.warn('storeForgotten');
        const forgottenName = tenForgottenFilesName + pathModule.extname(cmd.getOutputPath());
        yield storage.copyObject(ctx, savePathDoc, docId + '/' + forgottenName, undefined, tenForgottenFiles);
      } catch (err) {
        ctx.logger.error('Error storeForgotten: %s', err.stack);
      }
      if (!isSfcm) {
        //todo simultaneous opening
        //clean redis (redisKeyPresenceSet and redisKeyPresenceHash removed with last element)
        yield docsCoServer.editorData.cleanDocumentOnExit(ctx, docId);
        if (docsCoServer.getIsPreStop() && docsCoServer?.editorStatProxy?.deleteKey) {
          yield docsCoServer.editorStatProxy.deleteKey(docId);
        }
        //to unlock wopi file
        yield docsCoServer.unlockWopiDoc(ctx, docId, callbackUserIndex);
        //cleanupRes can be false in case of simultaneous opening. it is OK
        const cleanupRes = yield cleanupCacheIf(ctx, updateMask);
        ctx.logger.debug('storeForgotten cleanupRes=%s', cleanupRes);
      }
    }
    if (forceSave) {
      yield* docsCoServer.setForceSave(ctx, docId, forceSave, cmd, isSfcmSuccess && !isError, outputSfc?.getUrl());
    }
    if (needRetry) {
      const attempt = cmd.getAttempt() || 0;
      cmd.setAttempt(attempt + 1);
      const queueData = new commonDefines.TaskQueueData();
      queueData.setCtx(ctx);
      queueData.setCmd(cmd);
      const timeout = retry.createTimeout(attempt, tenCallbackBackoffOptions.timeout);
      ctx.logger.debug('commandSfcCallback backoff timeout = %d', timeout);
      yield* docsCoServer.addDelayed(queueData, timeout);
    }
  } else {
    ctx.logger.debug('commandSfcCallback cleanDocumentOnExitNoChangesPromise');
    yield docsCoServer.cleanDocumentOnExitNoChangesPromise(ctx, docId, undefined, userLastChangeIndex, true);
  }

  if (needUpdateVersionEvent && !needRetry) {
    yield docsCoServer.publish(ctx, {type: commonDefines.c_oPublishType.updateVersion, ctx, docId, success: isSfcSuccess});
  }

  if ((docsCoServer.getIsShutdown() && !isSfcm) || cmd.getRedisKey()) {
    const keyRedis = cmd.getRedisKey() ? cmd.getRedisKey() : redisKeyShutdown;
    yield docsCoServer.editorStat.removeShutdown(keyRedis, docId);
  }
  ctx.logger.debug('End commandSfcCallback');
  return replyStr;
});
function* processWopiPutFile(ctx, docId, wopiParams, savePathDoc, userLastChangeId, isModifiedByUser, isAutosave, isExitSave) {
  let res = '{"error": 1}';
  const metadata = yield storage.headObject(ctx, savePathDoc);
  const streamObj = yield storage.createReadStream(ctx, savePathDoc);
  const postRes = yield wopiClient.putFile(
    ctx,
    wopiParams,
    null,
    streamObj.readStream,
    metadata.ContentLength,
    userLastChangeId,
    isModifiedByUser,
    isAutosave,
    isExitSave
  );
  if (postRes) {
    res = '{"error": 0}';
    const body = wopiClient.parsePutFileResponse(ctx, postRes);
    //collabora nexcloud connector
    if (body?.LastModifiedTime) {
      const lastModifiedTimeInfo = wopiClient.getWopiModifiedMarker(wopiParams, body.LastModifiedTime);
      yield commandOpenStartPromise(ctx, docId, undefined, lastModifiedTimeInfo);
    }
  }
  return res;
}
function* commandSendMMCallback(ctx, cmd) {
  const docId = cmd.getDocId();
  ctx.logger.debug('Start commandSendMMCallback');
  const saveKey = docId + cmd.getSaveKey();
  const statusInfo = cmd.getStatusInfo();
  const outputSfc = new commonDefines.OutputSfcData(docId);
  if (constants.NO_ERROR == statusInfo) {
    outputSfc.setStatus(docsCoServer.c_oAscServerStatus.MailMerge);
  } else {
    outputSfc.setStatus(docsCoServer.c_oAscServerStatus.Corrupted);
  }
  const mailMergeSendData = cmd.getMailMergeSend();
  const outputMailMerge = new commonDefines.OutputMailMerge(mailMergeSendData);
  outputSfc.setMailMerge(outputMailMerge);
  outputSfc.setUsers([mailMergeSendData.getUserId()]);
  const data = yield storage.getObject(ctx, saveKey + '/' + cmd.getOutputPath());
  const xml = data.toString('utf8');
  const files = xml.match(/[< ]file.*?\/>/g);
  const recordRemain = mailMergeSendData.getRecordTo() - mailMergeSendData.getRecordFrom() + 1;
  const recordIndexStart = mailMergeSendData.getRecordCount() - recordRemain;
  for (let i = 0; i < files.length; ++i) {
    const file = files[i];
    const fieldRes = /field=["'](.*?)["']/.exec(file);
    outputMailMerge.setTo(fieldRes[1]);
    outputMailMerge.setRecordIndex(recordIndexStart + i);
    const pathRes = /path=["'](.*?)["']/.exec(file);
    const signedUrl = yield storage.getSignedUrl(
      ctx,
      mailMergeSendData.getBaseUrl(),
      saveKey + '/' + pathRes[1],
      commonDefines.c_oAscUrlTypes.Temporary
    );
    outputSfc.setUrl(signedUrl);
    outputSfc.setExtName(pathModule.extname(pathRes[1]));
    const uri = mailMergeSendData.getUrl();
    let replyStr = null;
    try {
      replyStr = yield docsCoServer.sendServerRequest(ctx, uri, outputSfc);
    } catch (err) {
      replyStr = null;
      ctx.logger.error('sendServerRequest error: url = %s;data = %j %s', uri, outputSfc, err.stack);
    }
    const replyData = docsCoServer.parseReplyData(ctx, replyStr);
    if (!(replyData && commonDefines.c_oAscServerCommandErrors.NoError == replyData.error)) {
      let recordErrorCount = mailMergeSendData.getRecordErrorCount();
      recordErrorCount++;
      outputMailMerge.setRecordErrorCount(recordErrorCount);
      mailMergeSendData.setRecordErrorCount(recordErrorCount);
    }
    if (replyData && commonDefines.c_oAscServerCommandErrors.NoError != replyData.error) {
      ctx.logger.warn('sendServerRequest returned an error: data = %s', docId, replyStr);
    }
  }
  const newRecordFrom = mailMergeSendData.getRecordFrom() + Math.max(files.length, 1);
  if (newRecordFrom <= mailMergeSendData.getRecordTo()) {
    mailMergeSendData.setRecordFrom(newRecordFrom);
    yield* addRandomKeyTaskCmd(ctx, cmd);
    const queueData = getSaveTask(ctx, cmd);
    yield* docsCoServer.addTask(queueData, constants.QUEUE_PRIORITY_LOW);
  } else {
    ctx.logger.debug('End MailMerge');
  }
  ctx.logger.debug('End commandSendMMCallback');
}

exports.openDocument = function (ctx, conn, cmd, opt_upsertRes, opt_bIsRestore) {
  return co(function* () {
    let outputData;
    try {
      let startDate = null;
      if (clientStatsD) {
        startDate = new Date();
      }
      ctx.logger.debug('Start command: %s', JSON.stringify(cmd));
      outputData = new OutputData(cmd.getCommand());
      let res = true;
      switch (cmd.getCommand()) {
        case 'open':
          yield* commandOpen(ctx, conn, cmd, outputData, opt_upsertRes, opt_bIsRestore);
          break;
        case 'reopen':
          res = yield* commandReopen(ctx, conn, cmd, outputData);
          break;
        case 'imgurls':
          yield* commandImgurls(ctx, conn, cmd, outputData);
          break;
        case 'pathurl':
          yield* commandPathUrl(ctx, conn, cmd, outputData);
          break;
        case 'pathurls':
          yield* commandPathUrls(ctx, conn, cmd.getData(), outputData);
          break;
        case 'setpassword':
          yield* commandSetPassword(ctx, conn, cmd, outputData);
          break;
        case 'changedocinfo':
          yield* commandChangeDocInfo(ctx, conn, cmd, outputData);
          break;
        default:
          res = false;
          break;
      }
      if (!res) {
        outputData.setStatus('err');
        outputData.setData(constants.UNKNOWN);
      }
      if (clientStatsD) {
        clientStatsD.timing('coauth.openDocument.' + cmd.getCommand(), new Date() - startDate);
      }
    } catch (e) {
      ctx.logger.error('Error openDocument: %s', e.stack);
      if (!outputData) {
        outputData = new OutputData();
      }
      outputData.setStatus('err');
      outputData.setData(constants.UNKNOWN);
    } finally {
      if (outputData?.getStatus()) {
        ctx.logger.debug('Response command: %s', JSON.stringify(outputData));
        docsCoServer.sendData(ctx, conn, new OutputDataWrap('documentOpen', outputData));
      }
      ctx.logger.debug('End command');
    }
  });
};
exports.downloadAs = function (req, res) {
  return co(function* () {
    let docId = 'null';
    const ctx = new operationContext.Context();
    try {
      let startDate = null;
      if (clientStatsD) {
        startDate = new Date();
      }
      ctx.initFromRequest(req);
      yield ctx.initTenantCache();
      const strCmd = req.query['cmd'];
      const cmd = new commonDefines.InputCommand(JSON.parse(strCmd));
      docId = cmd.getDocId();
      let userId = cmd.getUserId();
      ctx.setDocId(docId);
      ctx.setUserId(userId);
      ctx.logger.debug('Start downloadAs: %s', strCmd);
      const tenTokenEnableBrowser = ctx.getCfg('services.CoAuthoring.token.enable.browser', cfgTokenEnableBrowser);

      if (tenTokenEnableBrowser || cmd.getTokenDownload() || cmd.getTokenSession()) {
        let isValidJwt = false;
        if (cmd.getTokenDownload()) {
          const checkJwtRes = yield docsCoServer.checkJwt(ctx, cmd.getTokenDownload(), commonDefines.c_oAscSecretType.Browser);
          if (checkJwtRes.decoded) {
            isValidJwt = true;
            cmd.setFormat(checkJwtRes.decoded.fileType);
            cmd.setUrl(checkJwtRes.decoded.url);
            cmd.setWithAuthorization(true);
          } else {
            ctx.logger.warn('Error downloadAs jwt: %s', checkJwtRes.description);
          }
        } else {
          const checkJwtRes = yield docsCoServer.checkJwt(ctx, cmd.getTokenSession(), commonDefines.c_oAscSecretType.Session);
          if (checkJwtRes.decoded) {
            const decoded = checkJwtRes.decoded;
            const doc = checkJwtRes.decoded.document;
            if (!doc.permissions || false !== doc.permissions.download || false !== doc.permissions.print) {
              isValidJwt = true;
              docId = doc.key;
              cmd.setDocId(doc.key);
              userId = decoded.editorConfig?.user?.id;
              cmd.setUserIndex(decoded.editorConfig?.user?.index);
            } else {
              ctx.logger.warn('Error downloadAs jwt: %s', 'access deny');
            }
          } else {
            ctx.logger.warn('Error downloadAs jwt: %s', checkJwtRes.description);
          }
        }
        if (!isValidJwt) {
          res.sendStatus(403);
          return;
        }
      }
      ctx.setDocId(docId);
      ctx.setUserId(userId);
      const selectRes = yield taskResult.select(ctx, docId);
      const row = selectRes.length > 0 ? selectRes[0] : null;
      if (!cmd.getWithoutPassword()) {
        addPasswordToCmd(ctx, cmd, row && row.password, row && row.change_id);
      }
      addOriginFormat(ctx, cmd, row);
      cmd.setData(req.body);
      const outputData = new OutputData(cmd.getCommand());
      switch (cmd.getCommand()) {
        case 'save':
          yield* commandSave(ctx, cmd, outputData);
          break;
        case 'savefromorigin':
          yield* commandSaveFromOrigin(ctx, cmd, outputData, row && row.password);
          break;
        case 'sendmm':
          yield* commandSendMailMerge(ctx, cmd, outputData);
          break;
        default:
          outputData.setStatus('err');
          outputData.setData(constants.UNKNOWN);
          break;
      }
      const strRes = JSON.stringify(outputData);
      res.setHeader('Content-Type', 'application/json');
      res.send(strRes);
      ctx.logger.debug('End downloadAs: %s', strRes);
      if (clientStatsD) {
        clientStatsD.timing('coauth.downloadAs.' + cmd.getCommand(), new Date() - startDate);
      }
    } catch (e) {
      ctx.logger.error('Error downloadAs: %s', e.stack);
      res.sendStatus(400);
    }
  });
};
exports.saveFile = function (req, res) {
  return co(function* () {
    let docId = 'null';
    const ctx = new operationContext.Context();
    try {
      let startDate = null;
      if (clientStatsD) {
        startDate = new Date();
      }
      ctx.initFromRequest(req);
      yield ctx.initTenantCache();
      const strCmd = req.query['cmd'];
      const cmd = new commonDefines.InputCommand(JSON.parse(strCmd));
      docId = cmd.getDocId();
      ctx.setDocId(docId);
      ctx.logger.debug('Start saveFile');

      let isValidJwt = false;
      const checkJwtRes = yield docsCoServer.checkJwt(ctx, cmd.getTokenSession(), commonDefines.c_oAscSecretType.Session);
      if (checkJwtRes.decoded) {
        const doc = checkJwtRes.decoded.document;
        const edit = checkJwtRes.decoded.editorConfig;
        if (doc.ds_encrypted && !edit.ds_view && !edit.ds_isCloseCoAuthoring) {
          isValidJwt = true;
          docId = doc.key;
          cmd.setDocId(doc.key);
        } else {
          ctx.logger.warn('Error saveFile jwt: %s', 'access deny');
        }
      } else {
        ctx.logger.warn('Error saveFile jwt: %s', checkJwtRes.description);
      }
      if (!isValidJwt) {
        res.sendStatus(403);
        return;
      }
      ctx.setDocId(docId);
      cmd.setStatusInfo(constants.NO_ERROR);
      yield* addRandomKeyTaskCmd(ctx, cmd);
      cmd.setOutputPath(constants.OUTPUT_NAME + pathModule.extname(cmd.getOutputPath()));
      yield storage.putObject(ctx, docId + cmd.getSaveKey() + '/' + cmd.getOutputPath(), req.body, req.body.length);
      const replyStr = yield commandSfcCallback(ctx, cmd, false, true);
      if (replyStr) {
        utils.fillResponseSimple(res, replyStr, 'application/json');
      } else {
        res.sendStatus(400);
      }
      ctx.logger.debug('End saveFile: %s', replyStr);
      if (clientStatsD) {
        clientStatsD.timing('coauth.saveFile', new Date() - startDate);
      }
    } catch (e) {
      ctx.logger.error('Error saveFile: %s', e.stack);
      res.sendStatus(400);
    }
  });
};
function getPrintFileUrl(ctx, docId, baseUrl, filename) {
  return co(function* () {
    const tenTokenSessionAlgorithm = ctx.getCfg('services.CoAuthoring.token.session.algorithm', cfgTokenSessionAlgorithm);
    const tenTokenSessionExpires = ms(ctx.getCfg('services.CoAuthoring.token.session.expires', cfgTokenSessionExpires));

    baseUrl = utils.checkBaseUrl(ctx, baseUrl);
    const payload = {document: {key: docId}};
    const token = yield docsCoServer.signToken(
      ctx,
      payload,
      tenTokenSessionAlgorithm,
      tenTokenSessionExpires / 1000,
      commonDefines.c_oAscSecretType.Session
    );
    //while save printed file Chrome's extension seems to rely on the resource name set in the URI https://stackoverflow.com/a/53593453
    //replace '/' with %2f before encodeURIComponent becase nginx determine %2f as '/' and get wrong system path
    const userFriendlyName = encodeURIComponent(filename.replace(/\//g, '%2f'));
    let res = `${baseUrl}/printfile/${encodeURIComponent(docId)}/${userFriendlyName}?token=${encodeURIComponent(token)}`;
    if (ctx.shardKey) {
      res += `&${constants.SHARD_KEY_API_NAME}=${encodeURIComponent(ctx.shardKey)}`;
    }
    if (ctx.wopiSrc) {
      res += `&${constants.SHARD_KEY_WOPI_NAME}=${encodeURIComponent(ctx.wopiSrc)}`;
    }
    if (ctx.userSessionId) {
      res += `&${constants.USER_SESSION_ID_NAME}=${encodeURIComponent(ctx.userSessionId)}`;
    }
    res += `&filename=${userFriendlyName}`;
    return res;
  });
}
exports.getPrintFileUrl = getPrintFileUrl;
exports.printFile = function (req, res) {
  return co(function* () {
    let docId = 'null';
    const ctx = new operationContext.Context();
    try {
      let startDate = null;
      if (clientStatsD) {
        startDate = new Date();
      }
      ctx.initFromRequest(req);
      yield ctx.initTenantCache();
      const filename = req.query['filename'];
      const token = req.query['token'];
      docId = req.params.docid;
      ctx.setDocId(docId);
      ctx.logger.info('Start printFile');

      const checkJwtRes = yield docsCoServer.checkJwt(ctx, token, commonDefines.c_oAscSecretType.Session);
      if (checkJwtRes.decoded) {
        const docIdBase = checkJwtRes.decoded.document.key;
        if (!docId.startsWith(docIdBase)) {
          ctx.logger.warn('Error printFile jwt: description = %s', 'access deny');
          res.sendStatus(403);
          return;
        }
      } else {
        ctx.logger.warn('Error printFile jwt: description = %s', checkJwtRes.description);
        res.sendStatus(403);
        return;
      }
      ctx.setDocId(docId);
      const streamObj = yield storage.createReadStream(ctx, `${docId}/${constants.OUTPUT_NAME}.pdf`);
      res.setHeader('Content-Disposition', utils.getContentDisposition(filename, null, constants.CONTENT_DISPOSITION_INLINE));
      res.setHeader('Content-Length', streamObj.contentLength);
      res.setHeader('Content-Type', 'application/pdf');
      yield utils.pipeHttpStreams(streamObj.readStream, res);

      if (clientStatsD) {
        clientStatsD.timing('coauth.printFile', new Date() - startDate);
      }
    } catch (e) {
      ctx.logger.error('Error printFile: %s', e.stack);
      res.sendStatus(400);
    } finally {
      ctx.logger.info('End printFile');
    }
  });
};
/**
 * Proxy download file request to the file storage
 * @param {object} req - The HTTP request object
 * @param {object} res - The HTTP response object
 * @returns {Promise}
 */
exports.downloadFile = function (req, res) {
  return co(function* () {
    const ctx = new operationContext.Context();
    let stream = null;
    try {
      let startDate = null;
      if (clientStatsD) {
        startDate = new Date();
      }

      const docId = req.params.docid;
      if (!docId) {
        res.status(400).send('docid is required');
        return;
      }

      ctx.initFromRequest(req);
      yield ctx.initTenantCache();
      ctx.setDocId(docId);

      //todo remove in 8.1. For compatibility
      let url = req.get('x-url');
      if (url) {
        url = decodeURI(url);
      }
      ctx.logger.info('Start downloadFile');
      const tenTokenEnableBrowser = ctx.getCfg('services.CoAuthoring.token.enable.browser', cfgTokenEnableBrowser);
      const tenDownloadMaxBytes = ctx.getCfg('FileConverter.converter.maxDownloadBytes', cfgDownloadMaxBytes);
      const tenDownloadTimeout = ctx.getCfg('FileConverter.converter.downloadTimeout', cfgDownloadTimeout);
      const tenDownloadFileAllowExt = ctx.getCfg('services.CoAuthoring.server.downloadFileAllowExt', cfgDownloadFileAllowExt);
      const tenNewFileTemplate = ctx.getCfg('services.CoAuthoring.server.newFileTemplate', cfgNewFileTemplate);

      let authorization;
      let isInJwtToken = false;
      let errorDescription;
      let headers, fromTemplate;
      const authRes = yield docsCoServer.getRequestParams(ctx, req);
      if (authRes.code === constants.NO_ERROR) {
        const decoded = authRes.params;
        if (decoded.changesUrl) {
          url = decoded.changesUrl;
          isInJwtToken = true;
        } else if (decoded.document && -1 !== tenDownloadFileAllowExt.indexOf(decoded.document.fileType)) {
          url = decoded.document.url;
          isInJwtToken = true;
        } else if (decoded.url && -1 !== tenDownloadFileAllowExt.indexOf(decoded.fileType)) {
          url = decoded.url;
          isInJwtToken = true;
        } else if (wopiClient.isWopiJwtToken(decoded)) {
          if (decoded.fileInfo.Size === 0) {
            //editnew case
            fromTemplate = pathModule.extname(decoded.fileInfo.BaseFileName).substring(1);
          } else {
            ({url, headers} = yield wopiUtils.getWopiFileUrl(ctx, decoded.fileInfo, decoded.userAuth));
            const filterStatus = yield wopiClient.checkIpFilter(ctx, url);
            if (0 === filterStatus) {
              //todo false? (true because it passed checkIpFilter for wopi)
              //todo use directIfIn
              isInJwtToken = true;
            } else {
              errorDescription = 'access deny';
            }
          }
        } else if (!tenTokenEnableBrowser) {
          //todo token required
          if (decoded.url) {
            url = decoded.url;
            isInJwtToken = true;
          }
        } else {
          errorDescription = 'access deny';
        }
      } else {
        errorDescription = authRes.description || 'need token';
      }
      if (errorDescription) {
        ctx.logger.warn('Error downloadFile jwt: description = %s', errorDescription);
        res.sendStatus(403);
        return;
      }
      if (fromTemplate) {
        ctx.logger.debug('downloadFile from file template: %s', fromTemplate);
        const locale = constants.TEMPLATES_DEFAULT_LOCALE;
        const fileTemplatePath = pathModule.join(tenNewFileTemplate, locale, 'new.' + fromTemplate);
        res.sendFile(pathModule.resolve(fileTemplatePath));
      } else {
        if (utils.canIncludeOutboxAuthorization(ctx, url)) {
          const secret = yield tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Outbox);
          authorization = utils.fillJwtForRequest(ctx, {url}, secret, false);
        }
        const urlParsed = urlModule.parse(url);
        const filterStatus = yield* utils.checkHostFilter(ctx, urlParsed.hostname);
        if (0 !== filterStatus) {
          ctx.logger.warn('Error downloadFile checkIpFilter error: url = %s', url);
          res.sendStatus(filterStatus);
          return;
        }

        if (req.get('Range')) {
          if (!headers) {
            headers = {};
          }
          headers['Range'] = req.get('Range');
        }

        const downloadResult = yield utils.downloadUrlPromise(
          ctx,
          url,
          tenDownloadTimeout,
          tenDownloadMaxBytes,
          authorization,
          isInJwtToken,
          headers,
          true
        );
        const response = downloadResult.response;
        stream = downloadResult.stream;
        // Sanitize Content-Disposition by removing control chars (prevents CRLF/header injection)
        if (response.headers['content-disposition']) {
          response.headers['content-disposition'] = response.headers['content-disposition'].replace(/\p{Cc}/gu, '');
        }
        //Set-Cookie resets browser session
        delete response.headers['set-cookie'];
        // Set the response headers to match the target response
        res.set(response.headers);

        // Use pipeline to pipe the response data to the client
        yield pipeline(stream, res);
      }

      if (clientStatsD) {
        clientStatsD.timing('coauth.downloadFile', new Date() - startDate);
      }
    } catch (err) {
      if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') {
        ctx.logger.debug('Error downloadFile: %s', err.stack);
        if (!res.headersSent) {
          res.sendStatus(499);
        }
      } else {
        ctx.logger.error('Error downloadFile: %s', err.stack);
        //catch errors because status may be sent while piping to response
        if (!res.headersSent) {
          try {
            if (err.code === 'ETIMEDOUT' || err.code === 'ESOCKETTIMEDOUT') {
              res.sendStatus(408);
            } else if (err.code === 'EMSGSIZE') {
              res.sendStatus(413);
            } else if (err.statusCode) {
              res.sendStatus(err.statusCode);
            } else {
              res.sendStatus(400);
            }
          } catch (err) {
            ctx.logger.error('Error downloadFile: %s', err.stack);
          }
        }
      }
    } finally {
      // Ensure stream is properly destroyed
      if (stream && typeof stream.destroy === 'function') {
        try {
          stream.destroy();
        } catch (destroyErr) {
          ctx.logger.warn('Error destroying stream: %s', destroyErr.stack);
        }
      }
      ctx.logger.info('End downloadFile');
    }
  });
};
exports.saveFromChanges = function (ctx, docId, statusInfo, optFormat, opt_userId, opt_userIndex, opt_userLcid, opt_queue, opt_initShardKey) {
  return co(function* () {
    try {
      let startDate = null;
      if (clientStatsD) {
        startDate = new Date();
      }
      ctx.logger.debug('Start saveFromChanges');
      //we do a select, because during the timeout the information could change
      const selectRes = yield taskResult.select(ctx, docId);
      const row = selectRes.length > 0 ? selectRes[0] : null;
      if (row && row.status == commonDefines.FileStatus.SaveVersion && row.status_info == statusInfo) {
        if (null == optFormat) {
          optFormat = changeFormatByOrigin(ctx, row, constants.AVS_OFFICESTUDIO_FILE_OTHER_OOXML);
        }
        if (opt_initShardKey) {
          ctx.setShardKey(sqlBase.DocumentAdditional.prototype.getShardKey(row.additional));
          ctx.setWopiSrc(sqlBase.DocumentAdditional.prototype.getWopiSrc(row.additional));
        }
        const cmd = new commonDefines.InputCommand();
        cmd.setCommand('sfc');
        cmd.setDocId(docId);
        cmd.setOutputFormat(optFormat);
        cmd.setStatusInfoIn(statusInfo);
        cmd.setUserActionId(opt_userId);
        cmd.setUserActionIndex(opt_userIndex);
        cmd.appendJsonParams(getOpenedAtJSONParams(row));
        //todo lang and region are different
        cmd.setLCID(opt_userLcid);
        const userAuthStr = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, row.callback);
        cmd.setWopiParams(wopiClient.parseWopiCallback(ctx, userAuthStr, row.callback));
        addPasswordToCmd(ctx, cmd, row && row.password, row && row.change_id);
        addOriginFormat(ctx, cmd, row);
        yield* addRandomKeyTaskCmd(ctx, cmd);
        const queueData = getSaveTask(ctx, cmd);
        queueData.setFromChanges(true);
        yield* docsCoServer.addTask(queueData, constants.QUEUE_PRIORITY_NORMAL, opt_queue);
        if (docsCoServer.getIsShutdown()) {
          yield docsCoServer.editorStat.addShutdown(redisKeyShutdown, docId);
        }
        ctx.logger.debug('AddTask saveFromChanges');
      } else {
        if (row) {
          ctx.logger.debug('saveFromChanges status mismatch: row: %d; %d; expected: %d', row.status, row.status_info, statusInfo);
        }
      }
      if (clientStatsD) {
        clientStatsD.timing('coauth.saveFromChanges', new Date() - startDate);
      }
    } catch (e) {
      ctx.logger.error('Error saveFromChanges: %s', e.stack);
    }
  });
};

async function processWopiSaveAs(ctx, cmd) {
  let res;
  const info = await docsCoServer.getCallback(ctx, cmd.getDocId(), cmd.getUserIndex());
  // info.wopiParams is null if it is not wopi
  if (info?.wopiParams) {
    const suggestedExt = `.${formatChecker.getStringFromFormat(cmd.getOutputFormat())}`;
    const suggestedTarget = cmd.getSaveAsPath();
    const storageFilePath = `${cmd.getDocId()}${cmd.getSaveKey()}/${cmd.getOutputPath()}`;
    const stream = await storage.createReadStream(ctx, storageFilePath);
    const {wopiSrc, access_token} = info.wopiParams.userAuth;
    res = await wopiClient.putRelativeFile(
      ctx,
      wopiSrc,
      access_token,
      null,
      stream.readStream,
      stream.contentLength,
      suggestedExt,
      suggestedTarget,
      false
    );
  }
  return {res, wopiParams: info?.wopiParams};
}
exports.receiveTask = function (data, ack) {
  return co(function* () {
    const ctx = new operationContext.Context();
    try {
      const task = new commonDefines.TaskQueueData(JSON.parse(data));
      if (task) {
        const cmd = task.getCmd();
        ctx.initFromTaskQueueData(task);
        yield ctx.initTenantCache();
        ctx.logger.info('receiveTask start: %s', data);
        const updateTask = yield getUpdateResponse(ctx, cmd);
        const updateRes = yield taskResult.update(ctx, updateTask);
        if (updateRes.affectedRows > 0) {
          const outputData = new OutputData(cmd.getCommand());
          const command = cmd.getCommand();
          const additionalOutput = {
            needUrlKey: null,
            needUrlMethod: null,
            needUrlType: null,
            needUrlIsCorrectPassword: undefined,
            creationDate: undefined,
            openedAt: undefined,
            row: undefined
          };
          if ('open' === command || 'reopen' === command) {
            yield getOutputData(ctx, cmd, outputData, cmd.getDocId(), null, additionalOutput);
            //wopi from TemplateSource
            if (additionalOutput.row) {
              const row = additionalOutput.row;
              const userAuthStr = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, row.callback);
              const wopiParams = wopiClient.parseWopiCallback(ctx, userAuthStr, row.callback);
              if (wopiParams?.commonInfo?.fileInfo?.TemplateSource) {
                ctx.logger.debug('receiveTask: save document opened from TemplateSource');
                //todo
                //no need to wait to open file faster
                void docsCoServer.startForceSave(
                  ctx,
                  cmd.getDocId(),
                  commonDefines.c_oAscForceSaveTypes.Timeout,
                  undefined,
                  undefined,
                  undefined,
                  undefined,
                  undefined,
                  undefined,
                  undefined,
                  row.baseurl,
                  undefined,
                  undefined,
                  undefined,
                  undefined,
                  undefined,
                  cmd.getExternalChangeInfo()
                );
              }
            }
          } else if ('save' === command || 'savefromorigin' === command) {
            const status = yield getOutputData(ctx, cmd, outputData, cmd.getDocId() + cmd.getSaveKey(), null, additionalOutput);
            if (commonDefines.FileStatus.Ok === status && (cmd.getSaveAsPath() || cmd.getIsSaveAs())) {
              //todo in case of wopi no need to send url. send it to avoid stubs in sdk
              const saveAsRes = yield processWopiSaveAs(ctx, cmd);
              if (!saveAsRes.res && saveAsRes.wopiParams) {
                outputData.setStatus('err');
                outputData.setData(constants.CONVERT);
                additionalOutput.needUrlKey = null;
              }
            }
          } else if ('sfcm' === command) {
            yield commandSfcCallback(ctx, cmd, true);
          } else if ('sfc' === command) {
            yield commandSfcCallback(ctx, cmd, false);
          } else if ('sendmm' === command) {
            yield* commandSendMMCallback(ctx, cmd);
          } else if ('conv' === command) {
            //nothing
          }
          if (outputData.getStatus()) {
            ctx.logger.debug('receiveTask publish: %s', JSON.stringify(outputData));
            const output = new OutputDataWrap('documentOpen', outputData);
            yield docsCoServer.publish(ctx, {
              type: commonDefines.c_oPublishType.receiveTask,
              ctx,
              cmd,
              output,
              needUrlKey: additionalOutput.needUrlKey,
              needUrlMethod: additionalOutput.needUrlMethod,
              needUrlType: additionalOutput.needUrlType,
              needUrlIsCorrectPassword: additionalOutput.needUrlIsCorrectPassword,
              creationDate: additionalOutput.creationDate,
              openedAt: additionalOutput.openedAt
            });
          }
        }
      }
    } catch (err) {
      ctx.logger.error('receiveTask error: %s', err.stack);
    } finally {
      ctx.logger.info('receiveTask end');
      ack();
    }
  });
};

exports.cleanupCache = cleanupCache;
exports.cleanupCacheIf = cleanupCacheIf;
exports.cleanupErrToReload = cleanupErrToReload;
exports.getOpenedAt = getOpenedAt;
exports.commandSfctByCmd = commandSfctByCmd;
exports.commandOpenStartPromise = commandOpenStartPromise;
exports.commandPathUrls = commandPathUrls;
exports.commandSfcCallback = commandSfcCallback;
exports.OutputDataWrap = OutputDataWrap;
exports.OutputData = OutputData;
