mirror of
https://github.com/ONLYOFFICE/server.git
synced 2026-04-07 14:04:35 +08:00
[wopi] Add "convert" as wopi discovery action
This commit is contained in:
@ -81,7 +81,8 @@
|
||||
"favIconUrlCell" : "/web-apps/apps/spreadsheeteditor/main/resources/img/favicon.ico",
|
||||
"favIconUrlSlide" : "/web-apps/apps/presentationeditor/main/resources/img/favicon.ico",
|
||||
"fileInfoBlockList" : ["FileUrl"],
|
||||
"wordView": ["pdf", "djvu", "xps", "oxps", "doc", "dotx", "dotm", "dot", "fodt", "ott", "rtf", "mht", "html", "htm", "xml", "epub", "fb2"],
|
||||
"pdfView": ["pdf", "djvu", "xps", "oxps"],
|
||||
"wordView": ["doc", "dotx", "dotm", "dot", "fodt", "ott", "rtf", "mht", "html", "htm", "xml", "epub", "fb2"],
|
||||
"wordEdit": ["docx", "docm", "docxf", "oform", "odt", "txt"],
|
||||
"cellView": ["xls", "xlsb", "xltx", "xltm", "xlt", "fods", "ots"],
|
||||
"cellEdit": ["xlsx", "xlsm", "ods", "csv"],
|
||||
|
||||
@ -43,6 +43,7 @@ var constants = require('./../../Common/sources/constants');
|
||||
var commonDefines = require('./../../Common/sources/commondefines');
|
||||
var docsCoServer = require('./DocsCoServer');
|
||||
var canvasService = require('./canvasservice');
|
||||
var wopiClient = require('./wopiClient');
|
||||
var storage = require('./../../Common/sources/storage-base');
|
||||
var formatChecker = require('./../../Common/sources/formatchecker');
|
||||
var statsDClient = require('./../../Common/sources/statsdclient');
|
||||
@ -50,20 +51,20 @@ var storageBase = require('./../../Common/sources/storage-base');
|
||||
var operationContext = require('./../../Common/sources/operationContext');
|
||||
const sqlBase = require('./baseConnector');
|
||||
|
||||
const cfgTokenEnableBrowser = config.get('services.CoAuthoring.token.enable.browser');
|
||||
|
||||
var CONVERT_ASYNC_DELAY = 1000;
|
||||
|
||||
var clientStatsD = statsDClient.getClient();
|
||||
|
||||
function* getConvertStatus(ctx, cmd, selectRes, opt_checkPassword) {
|
||||
function* getConvertStatus(ctx, docId, encryptedUserPassword, selectRes, opt_checkPassword) {
|
||||
var status = new commonDefines.ConvertStatus(constants.NO_ERROR);
|
||||
if (selectRes.length > 0) {
|
||||
var docId = cmd.getDocId();
|
||||
var row = selectRes[0];
|
||||
let password = opt_checkPassword && sqlBase.DocumentPassword.prototype.getCurPassword(ctx, row.password);
|
||||
switch (row.status) {
|
||||
case taskResult.FileStatus.Ok:
|
||||
if (password) {
|
||||
let encryptedUserPassword = cmd.getPassword();
|
||||
let isCorrectPassword;
|
||||
if (encryptedUserPassword) {
|
||||
let decryptedPassword = yield utils.decryptPassword(password);
|
||||
@ -147,7 +148,7 @@ function* convertByCmd(ctx, cmd, async, opt_fileTo, opt_taskExist, opt_priority,
|
||||
var status;
|
||||
if (!bCreate) {
|
||||
selectRes = yield taskResult.select(ctx, docId);
|
||||
status = yield* getConvertStatus(ctx, cmd, selectRes, opt_checkPassword);
|
||||
status = yield* getConvertStatus(ctx, cmd.getDocId() ,cmd.getPassword(), selectRes, opt_checkPassword);
|
||||
} else {
|
||||
var queueData = new commonDefines.TaskQueueData();
|
||||
queueData.setCtx(ctx);
|
||||
@ -169,7 +170,7 @@ function* convertByCmd(ctx, cmd, async, opt_fileTo, opt_taskExist, opt_priority,
|
||||
}
|
||||
yield utils.sleep(CONVERT_ASYNC_DELAY);
|
||||
selectRes = yield taskResult.select(ctx, docId);
|
||||
status = yield* getConvertStatus(ctx, cmd, selectRes, opt_checkPassword);
|
||||
status = yield* getConvertStatus(ctx, cmd.getDocId() ,cmd.getPassword(), selectRes, opt_checkPassword);
|
||||
waitTime += CONVERT_ASYNC_DELAY;
|
||||
if (waitTime > utils.CONVERTION_TIMEOUT) {
|
||||
status.err = constants.CONVERT_TIMEOUT;
|
||||
@ -519,8 +520,110 @@ function convertTo(req, res) {
|
||||
}
|
||||
});
|
||||
}
|
||||
function convertAndEdit(ctx, wopiParams, filetypeFrom, filetypeTo) {
|
||||
return co(function*() {
|
||||
try {
|
||||
ctx.logger.info('convert-and-edit start');
|
||||
|
||||
let task = yield* taskResult.addRandomKeyTask(ctx, undefined, 'conv_', 8);
|
||||
let docId = task.key;
|
||||
let outputFormat = formatChecker.getFormatFromString(filetypeTo);
|
||||
if (constants.AVS_OFFICESTUDIO_FILE_UNKNOWN === outputFormat) {
|
||||
ctx.logger.debug('convert-and-edit unknown outputFormat %s', filetypeTo);
|
||||
return;
|
||||
}
|
||||
|
||||
let cmd = new commonDefines.InputCommand();
|
||||
cmd.setCommand('conv');
|
||||
cmd.setDocId(docId);
|
||||
cmd.setUrl('dummy-url');
|
||||
cmd.setWopiParams(wopiParams);
|
||||
cmd.setFormat(filetypeFrom);
|
||||
cmd.setOutputFormat(outputFormat);
|
||||
|
||||
let fileTo = constants.OUTPUT_NAME;
|
||||
let outputExt = formatChecker.getStringFromFormat(outputFormat);
|
||||
if (outputExt) {
|
||||
fileTo += '.' + outputExt;
|
||||
}
|
||||
|
||||
let queueData = new commonDefines.TaskQueueData();
|
||||
queueData.setCtx(ctx);
|
||||
queueData.setCmd(cmd);
|
||||
queueData.setToFile(fileTo);
|
||||
yield* docsCoServer.addTask(queueData, constants.QUEUE_PRIORITY_LOW);
|
||||
|
||||
let async = true;
|
||||
yield* convertByCmd(ctx, cmd, async, fileTo);
|
||||
return docId;
|
||||
} catch (err) {
|
||||
ctx.logger.error('convert-and-edit error:%s', err.stack);
|
||||
} finally {
|
||||
ctx.logger.info('convert-and-edit end');
|
||||
}
|
||||
});
|
||||
}
|
||||
function getConverterHtmlHandler(req, res) {
|
||||
return co(function*() {
|
||||
let isJson = true;
|
||||
let ctx = new operationContext.Context();
|
||||
try {
|
||||
ctx.initFromRequest(req);
|
||||
ctx.logger.info('convert-and-edit-handler start');
|
||||
|
||||
let wopiSrc = req.query['wopisrc'];
|
||||
let access_token = req.query['access_token'];
|
||||
let targetext = req.query['targetext'];
|
||||
let docId = req.query['docid'];
|
||||
ctx.setDocId(docId);
|
||||
if (!(wopiSrc && access_token && access_token && targetext && docId) ||
|
||||
constants.AVS_OFFICESTUDIO_FILE_UNKNOWN === formatChecker.getFormatFromString(targetext)) {
|
||||
ctx.logger.debug('convert-and-edit-handler invalid params: wopiSrc=%s; access_token=%s; targetext=%s; docId=%s', wopiSrc, access_token, targetext, docId);
|
||||
utils.fillResponse(req, res, new commonDefines.ConvertStatus(constants.CONVERT_PARAMS), isJson);
|
||||
return;
|
||||
}
|
||||
let token = req.query['token'];
|
||||
if (cfgTokenEnableBrowser) {
|
||||
let checkJwtRes = yield docsCoServer.checkJwt(ctx, token, commonDefines.c_oAscSecretType.Browser);
|
||||
if (checkJwtRes.decoded) {
|
||||
docId = checkJwtRes.decoded.docId;
|
||||
} else {
|
||||
ctx.logger.debug('convert-and-edit-handler invalid token %j', token);
|
||||
utils.fillResponse(req, res, new commonDefines.ConvertStatus(constants.VKEY), isJson);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ctx.setDocId(docId);
|
||||
|
||||
let selectRes = yield taskResult.select(ctx, docId);
|
||||
let status = yield* getConvertStatus(ctx, docId, undefined, selectRes);
|
||||
if (status.end && constants.NO_ERROR === status.err) {
|
||||
let fileTo = `${docId}/${constants.OUTPUT_NAME}.${targetext}`;
|
||||
|
||||
let metadata = yield storage.headObject(ctx, fileTo);
|
||||
let streamObj = yield storage.createReadStream(ctx, fileTo);
|
||||
let postRes = yield wopiClient.putRelativeFile(ctx, wopiSrc, access_token, null, streamObj.readStream, metadata.ContentLength, `.${targetext}`, true);
|
||||
if (postRes) {
|
||||
let fileInfo = JSON.parse(postRes.body);
|
||||
status.setUrl(fileInfo.HostEditUrl);
|
||||
status.setExtName('.' + targetext);
|
||||
} else {
|
||||
status.err = constants.UNKNOWN;
|
||||
}
|
||||
}
|
||||
utils.fillResponse(req, res, status, isJson);
|
||||
} catch (err) {
|
||||
ctx.logger.error('convert-and-edit-handler error:%s', err.stack);
|
||||
utils.fillResponse(req, res, new commonDefines.ConvertStatus(constants.UNKNOWN), isJson);
|
||||
} finally {
|
||||
ctx.logger.info('convert-and-edit-handler end');
|
||||
}
|
||||
});
|
||||
}
|
||||
exports.convertFromChanges = convertFromChanges;
|
||||
exports.convertJson = convertRequestJson;
|
||||
exports.convertXml = convertRequestXml;
|
||||
exports.convertTo = convertTo;
|
||||
exports.convertAndEdit = convertAndEdit;
|
||||
exports.getConverterHtmlHandler = getConverterHtmlHandler;
|
||||
exports.builder = builderRequest;
|
||||
|
||||
@ -251,6 +251,8 @@ docsCoServer.install(server, () => {
|
||||
app.post('/lool/convert-to/:format?', utils.checkClientIp, urleEcodedParser, fileForms.single('data'), converterService.convertTo);
|
||||
app.post('/cool/convert-to/:format?', utils.checkClientIp, urleEcodedParser, fileForms.single('data'), converterService.convertTo);
|
||||
app.post('/hosting/wopi/:documentType/:mode', urleEcodedParser, forms.none(), utils.lowercaseQueryString, wopiClient.getEditorHtml);
|
||||
app.post('/hosting/wopi/convert-and-edit/:ext/:targetext', urleEcodedParser, forms.none(), utils.lowercaseQueryString, wopiClient.getConverterHtml);
|
||||
app.get('/hosting/wopi/convert-and-edit-handler', utils.lowercaseQueryString, converterService.getConverterHtmlHandler);
|
||||
}
|
||||
|
||||
app.post('/dummyCallback', utils.checkClientIp, rawFileParser, function(req, res){
|
||||
|
||||
@ -49,6 +49,7 @@ const tenantManager = require('./../../Common/sources/tenantManager');
|
||||
const sqlBase = require('./baseConnector');
|
||||
const taskResult = require('./taskresult');
|
||||
const canvasService = require('./canvasservice');
|
||||
const converterService = require('./converterservice');
|
||||
|
||||
const cfgTokenOutboxAlgorithm = config.get('services.CoAuthoring.token.outbox.algorithm');
|
||||
const cfgTokenOutboxExpires = config.get('services.CoAuthoring.token.outbox.expires');
|
||||
@ -57,6 +58,7 @@ const cfgCallbackRequestTimeout = config.get('services.CoAuthoring.server.callba
|
||||
const cfgDownloadTimeout = config.get('FileConverter.converter.downloadTimeout');
|
||||
const cfgWopiFileInfoBlockList = config.get('wopi.fileInfoBlockList');
|
||||
const cfgWopiWopiZone = config.get('wopi.wopiZone');
|
||||
const cfgWopiPdfView = config.get('wopi.pdfView');
|
||||
const cfgWopiWordView = config.get('wopi.wordView');
|
||||
const cfgWopiWordEdit = config.get('wopi.wordEdit');
|
||||
const cfgWopiCellView = config.get('wopi.cellView');
|
||||
@ -105,8 +107,12 @@ function discovery(req, res) {
|
||||
let baseUrl = cfgWopiHost || utils.getBaseUrlByRequest(req);
|
||||
let names = ['Word','Excel','PowerPoint'];
|
||||
let favIconUrls = [cfgWopiFavIconUrlWord, cfgWopiFavIconUrlCell, cfgWopiFavIconUrlSlide];
|
||||
let exts = [{view: cfgWopiWordView, edit: cfgWopiWordEdit}, {view: cfgWopiCellView, edit: cfgWopiCellEdit},
|
||||
{view: cfgWopiSlideView, edit: cfgWopiSlideEdit}];
|
||||
let exts = [
|
||||
{targetext: 'docx', view: cfgWopiPdfView.concat(cfgWopiWordView), edit: cfgWopiWordEdit},
|
||||
{targetext: 'xlsx', view: cfgWopiCellView, edit: cfgWopiCellEdit},
|
||||
{targetext: 'pptx', view: cfgWopiSlideView, edit: cfgWopiSlideEdit}
|
||||
];
|
||||
|
||||
let templateStart = `${baseUrl}/hosting/wopi`;
|
||||
let templateEnd = `&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&>`;
|
||||
templateEnd += `<fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&>`;
|
||||
@ -129,6 +135,10 @@ function discovery(req, res) {
|
||||
for (let j = 0; j < ext.view.length; ++j) {
|
||||
output += `<action name="view" ext="${ext.view[j]}" urlsrc="${urlTemplateView}" />`;
|
||||
output += `<action name="embedview" ext="${ext.view[j]}" urlsrc="${urlTemplateEmbedView}" />`;
|
||||
if (-1 === cfgWopiPdfView.indexOf(ext.view[j])) {
|
||||
let urlConvert = `${templateStart}/convert-and-edit/${ext.view[j]}/${ext.targetext}?${templateEnd}`;
|
||||
output += `<action name="convert" ext="${ext.view[j]}" targetext="${ext.targetext}" requires="update" urlsrc="${urlConvert}" />`;
|
||||
}
|
||||
}
|
||||
for (let j = 0; j < ext.edit.length; ++j) {
|
||||
output += `<action name="view" ext="${ext.edit[j]}" urlsrc="${urlTemplateView}" />`;
|
||||
@ -155,6 +165,10 @@ function discovery(req, res) {
|
||||
output += `<app name="${value}">`;
|
||||
output += `<action name="view" ext="" default="true" urlsrc="${urlTemplateView}" />`;
|
||||
output += `<action name="embedview" ext="" urlsrc="${urlTemplateEmbedView}" />`;
|
||||
if (-1 === cfgWopiPdfView.indexOf(ext.view[j])) {
|
||||
let urlConvert = `${templateStart}/convert-and-edit/${ext.view[j]}/${ext.targetext}?${templateEnd}`;
|
||||
output += `<action name="convert" ext="" targetext="${ext.targetext}" requires="update" urlsrc="${urlConvert}" />`;
|
||||
}
|
||||
output += `</app>`;
|
||||
});
|
||||
}
|
||||
@ -328,9 +342,8 @@ function getEditorHtml(req, res) {
|
||||
let access_token = req.body['access_token'] || "";
|
||||
let access_token_ttl = parseInt(req.body['access_token_ttl']) || 0;
|
||||
|
||||
let uri = `${encodeURI(wopiSrc)}?access_token=${encodeURIComponent(access_token)}`;
|
||||
|
||||
let fileInfo = params.fileInfo = yield checkFileInfo(ctx, uri, access_token, sc);
|
||||
let fileInfo = params.fileInfo = yield checkFileInfo(ctx, wopiSrc, access_token, sc);
|
||||
if (!fileInfo) {
|
||||
params.fileInfo = {};
|
||||
return;
|
||||
@ -394,7 +407,7 @@ function getEditorHtml(req, res) {
|
||||
|
||||
if (cfgTokenEnableBrowser) {
|
||||
let options = {algorithm: cfgTokenOutboxAlgorithm, expiresIn: cfgTokenOutboxExpires};
|
||||
let secret = yield tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Inbox);
|
||||
let secret = yield tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Browser);
|
||||
params.token = jwt.sign(params, secret, options);
|
||||
}
|
||||
} catch (err) {
|
||||
@ -412,6 +425,64 @@ function getEditorHtml(req, res) {
|
||||
}
|
||||
});
|
||||
}
|
||||
function getConverterHtml(req, res) {
|
||||
return co(function*() {
|
||||
let params = {statusHandler: undefined};
|
||||
let ctx = new operationContext.Context();
|
||||
try {
|
||||
ctx.initFromRequest(req);
|
||||
let wopiSrc = req.query['wopisrc'];
|
||||
let fileId = wopiSrc.substring(wopiSrc.lastIndexOf('/') + 1);
|
||||
ctx.setDocId(fileId);
|
||||
ctx.logger.info('convert-and-edit start');
|
||||
|
||||
let access_token = req.body['access_token'] || "";
|
||||
let access_token_ttl = parseInt(req.body['access_token_ttl']) || 0;
|
||||
let ext = req.params.ext;
|
||||
let targetext = req.params.targetext;
|
||||
|
||||
if (!(wopiSrc && access_token && access_token_ttl && ext && targetext)) {
|
||||
ctx.logger.debug('convert-and-edit invalid params: wopiSrc=%s; access_token=%s; access_token_ttl=%s; ext=%s; targetext=%s', wopiSrc, access_token, access_token_ttl, ext, targetext);
|
||||
return;
|
||||
}
|
||||
|
||||
let fileInfo = yield checkFileInfo(ctx, wopiSrc, access_token);
|
||||
if (!fileInfo) {
|
||||
ctx.logger.info('convert-and-edit checkFileInfo error');
|
||||
return;
|
||||
}
|
||||
|
||||
let wopiParams = getWopiParams(null, fileInfo, wopiSrc, access_token, access_token_ttl);
|
||||
|
||||
let docId = yield converterService.convertAndEdit(ctx, wopiParams, ext, targetext);
|
||||
if (docId) {
|
||||
let baseUrl = cfgWopiHost || utils.getBaseUrlByRequest(req);
|
||||
params.statusHandler = `${baseUrl}/hosting/wopi/convert-and-edit-handler`;
|
||||
params.statusHandler += `?wopiSrc=${encodeURI(wopiSrc)}&access_token=${encodeURI(access_token)}`;
|
||||
params.statusHandler += `&targetext=${encodeURI(targetext)}&docId=${encodeURI(docId)}`;
|
||||
if (cfgTokenEnableBrowser) {
|
||||
let tokenData = {docId: docId};
|
||||
let options = {algorithm: cfgTokenOutboxAlgorithm, expiresIn: cfgTokenOutboxExpires};
|
||||
let secret = yield tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Browser);
|
||||
let token = jwt.sign(tokenData, secret, options);
|
||||
|
||||
params.statusHandler += `&token=${encodeURI(token)}`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.logger.error('convert-and-edit error:%s', err.stack);
|
||||
} finally {
|
||||
ctx.logger.debug('convert-and-edit render params=%j', params);
|
||||
try {
|
||||
res.render("convert-and-edit-wopi", params);
|
||||
} catch (err) {
|
||||
ctx.logger.error('convert-and-edit error:%s', err.stack);
|
||||
res.sendStatus(400);
|
||||
}
|
||||
ctx.logger.info('convert-and-edit end');
|
||||
}
|
||||
});
|
||||
}
|
||||
function putFile(ctx, wopiParams, data, dataStream, dataSize, userLastChangeId, isModifiedByUser, isAutosave, isExitSave) {
|
||||
return co(function* () {
|
||||
let postRes = null;
|
||||
@ -457,6 +528,34 @@ function putFile(ctx, wopiParams, data, dataStream, dataSize, userLastChangeId,
|
||||
return postRes;
|
||||
});
|
||||
}
|
||||
function putRelativeFile(ctx, wopiSrc, access_token, data, dataStream, dataSize, suggestedTarget, isFileConversion) {
|
||||
return co(function* () {
|
||||
let postRes = null;
|
||||
try {
|
||||
ctx.logger.info('wopi putRelativeFile start');
|
||||
|
||||
let uri = `${wopiSrc}?access_token=${access_token}`;
|
||||
let filterStatus = yield checkIpFilter(ctx, uri);
|
||||
if (0 !== filterStatus) {
|
||||
return postRes;
|
||||
}
|
||||
|
||||
let headers = {'X-WOPI-Override': 'PUT_RELATIVE', 'X-WOPI-SuggestedTarget': utf7.encode(suggestedTarget),
|
||||
'X-WOPI-FileConversion': isFileConversion};
|
||||
fillStandardHeaders(headers, uri, access_token);
|
||||
|
||||
ctx.logger.debug('wopi putRelativeFile request uri=%s headers=%j', uri, headers);
|
||||
postRes = yield utils.postRequestPromise(uri, data, dataStream, dataSize, cfgCallbackRequestTimeout, undefined, headers);
|
||||
ctx.logger.debug('wopi putRelativeFile response headers=%j', postRes.response.headers);
|
||||
ctx.logger.debug('wopi putRelativeFile response body:%s', postRes.body);
|
||||
} catch (err) {
|
||||
ctx.logger.error('wopi error putRelativeFile:%s', err.stack);
|
||||
} finally {
|
||||
ctx.logger.info('wopi putRelativeFile end');
|
||||
}
|
||||
return postRes;
|
||||
});
|
||||
}
|
||||
function renameFile(ctx, wopiParams, name) {
|
||||
return co(function* () {
|
||||
let res = undefined;
|
||||
@ -501,18 +600,19 @@ function renameFile(ctx, wopiParams, name) {
|
||||
return res;
|
||||
});
|
||||
}
|
||||
function checkFileInfo(ctx, uri, access_token, sc) {
|
||||
function checkFileInfo(ctx, wopiSrc, access_token, opt_sc) {
|
||||
return co(function* () {
|
||||
let fileInfo = undefined;
|
||||
try {
|
||||
ctx.logger.info('wopi checkFileInfo start');
|
||||
let uri = `${encodeURI(wopiSrc)}?access_token=${encodeURIComponent(access_token)}`;
|
||||
let filterStatus = yield checkIpFilter(ctx, uri);
|
||||
if (0 !== filterStatus) {
|
||||
return fileInfo;
|
||||
}
|
||||
let headers = {};
|
||||
if (sc) {
|
||||
headers['X-WOPI-SessionContext'] = sc;
|
||||
if (opt_sc) {
|
||||
headers['X-WOPI-SessionContext'] = opt_sc;
|
||||
}
|
||||
fillStandardHeaders(headers, uri, access_token);
|
||||
ctx.logger.debug('wopi checkFileInfo request uri=%s headers=%j', uri, headers);
|
||||
@ -650,12 +750,22 @@ function checkIpFilter(ctx, uri){
|
||||
return filterStatus;
|
||||
});
|
||||
}
|
||||
function getWopiParams(lockId, fileInfo, wopiSrc, access_token, access_token_ttl) {
|
||||
let commonInfo = {lockId: lockId, fileInfo: fileInfo};
|
||||
let userAuth = {
|
||||
wopiSrc: wopiSrc, access_token: access_token, access_token_ttl: access_token_ttl,
|
||||
hostSessionId: null, userSessionId: null, mode: null
|
||||
};
|
||||
return {commonInfo: commonInfo, userAuth: userAuth, LastModifiedTime: null};
|
||||
};
|
||||
|
||||
exports.discovery = discovery;
|
||||
exports.collaboraCapabilities = collaboraCapabilities;
|
||||
exports.parseWopiCallback = parseWopiCallback;
|
||||
exports.getEditorHtml = getEditorHtml;
|
||||
exports.getConverterHtml = getConverterHtml;
|
||||
exports.putFile = putFile;
|
||||
exports.putRelativeFile = putRelativeFile;
|
||||
exports.renameFile = renameFile;
|
||||
exports.lock = lock;
|
||||
exports.unlock = unlock;
|
||||
|
||||
Reference in New Issue
Block a user