🐛 backend: adding editing session key (#525)
This commit is contained in:
committed by
ericlinagora
parent
3de9829bf0
commit
16dbf8077b
@@ -93,7 +93,7 @@ export class DriveFile {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* If this field is non-null, then an editing session is in progress (probably in OnlyOffice).
|
* If this field is non-null, then an editing session is in progress (probably in OnlyOffice).
|
||||||
* Should be in the format `appid-timestamp-hexuuid` where `appid` and `timestamp` have no `-`
|
* Should be in the format `timestamp-appid-hexuuid` where `appid` and `timestamp` have no `-`
|
||||||
* characters.
|
* characters.
|
||||||
*/
|
*/
|
||||||
@Type(() => String)
|
@Type(() => String)
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ import {
|
|||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
import internal from "stream";
|
import internal from "stream";
|
||||||
import config from "config";
|
import config from "config";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { MultipartFile } from "@fastify/multipart";
|
||||||
|
import { UploadOptions } from "../../files/types";
|
||||||
|
|
||||||
export class DocumentsService {
|
export class DocumentsService {
|
||||||
version: "1";
|
version: "1";
|
||||||
@@ -264,6 +267,45 @@ export class DocumentsService {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a drive element by its `editing_session_key`
|
||||||
|
*
|
||||||
|
* @param {string} editing_session_key - the editing_session_key of the DriveFile to fetch
|
||||||
|
* @param {DriveExecutionContext} context
|
||||||
|
* @returns {Promise<DriveItemDetails>}
|
||||||
|
*/
|
||||||
|
getByEditingSessionKey = async (
|
||||||
|
editing_session_key: string,
|
||||||
|
context: DriveExecutionContext & { public_token?: string },
|
||||||
|
): Promise<DriveFile> => {
|
||||||
|
if (!editing_session_key) {
|
||||||
|
this.logger.error("Invalid editing_session_key: " + JSON.stringify(editing_session_key));
|
||||||
|
throw new CrudException("Invalid editing_session_key", 400);
|
||||||
|
}
|
||||||
|
if (!context) {
|
||||||
|
this.logger.error("invalid context");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entity = await this.repository.findOne({ editing_session_key }, {}, context);
|
||||||
|
if (!entity) {
|
||||||
|
this.logger.error("Drive item not found");
|
||||||
|
throw new CrudException("Item not found", 404);
|
||||||
|
}
|
||||||
|
//Check access to entity
|
||||||
|
try {
|
||||||
|
//TODO: may need to check for read only if we permit readonly editors to join
|
||||||
|
const hasAccess = await checkAccess(entity.id, entity, "write", this.repository, context);
|
||||||
|
if (!hasAccess) {
|
||||||
|
this.logger.error("user does not have access drive item " + entity.id);
|
||||||
|
throw Error("user does not have access to this item");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ error: `${error}` }, "Failed to grant access to the drive item");
|
||||||
|
throw new CrudException("User does not have access to this item or its children", 401);
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
};
|
||||||
|
|
||||||
getAccess = async (
|
getAccess = async (
|
||||||
id: string,
|
id: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -879,6 +921,78 @@ export class DocumentsService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If not already in an editing session, uses the `editing_session_key` of the
|
||||||
|
* `DriveFile` entity to store a unique new value to expect an update later
|
||||||
|
* with only that key provided.
|
||||||
|
* @param id DriveFile ID of the document to begin editing
|
||||||
|
* @param editorApplicationId Editor/Application/Plugin specific identifier
|
||||||
|
* @param context
|
||||||
|
* @returns An object in the format `{}` with the unique identifier for the
|
||||||
|
* editing session
|
||||||
|
*/
|
||||||
|
beginEditing = async (
|
||||||
|
id: string,
|
||||||
|
editorApplicationId: string,
|
||||||
|
context: DriveExecutionContext,
|
||||||
|
) => {
|
||||||
|
const isoUTCDateNoSpecialCharsNoMS = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/\..+$/, "")
|
||||||
|
.replace(/[ZT:-]/g, "");
|
||||||
|
const newKey = [
|
||||||
|
isoUTCDateNoSpecialCharsNoMS,
|
||||||
|
editorApplicationId,
|
||||||
|
randomUUID().replace(/-+/g, ""),
|
||||||
|
].join("-");
|
||||||
|
// OnlyOffice key limits: 128 chars, [0-9a-zA-z=_-]
|
||||||
|
// This is specific to it, but the constraint seems strict enough
|
||||||
|
// that any other system needing such a unique identifier would find
|
||||||
|
// this compatible. This value must be ensured to be the strictest
|
||||||
|
// common denominator to all plugin/interop systems. Plugins that
|
||||||
|
// require something even stricter have the option of maintaining
|
||||||
|
// a look up table to an acceptable value.
|
||||||
|
if (newKey.length > 128 || !/^[0-9a-zA-Z=_]+$/m.test(editorApplicationId))
|
||||||
|
CrudException.throwMe(
|
||||||
|
new Error('Invalid "editorApplicationId" string. Must be short and only alpha numeric'),
|
||||||
|
new CrudException("Invalid editorApplicationId", 400),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
this.logger.error("invalid execution context");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAccess = await checkAccess(id, null, "write", this.repository, context);
|
||||||
|
if (!hasAccess) {
|
||||||
|
logger.error("user does not have access drive item " + id);
|
||||||
|
CrudException.throwMe(
|
||||||
|
new Error("user does not have access to the drive item"),
|
||||||
|
new CrudException("user does not have access drive item", 401),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const driveFile = await this.repository.findOne(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
company_id: context.company.id,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
const result = await this.repository.atomicCompareAndSet(
|
||||||
|
driveFile,
|
||||||
|
"editing_session_key",
|
||||||
|
null,
|
||||||
|
newKey,
|
||||||
|
);
|
||||||
|
return { editingSessionKey: result.currentValue };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error: `${error}` }, "Failed to begin editing Drive item");
|
||||||
|
CrudException.throwMe(error, new CrudException("Failed to begin editing Drive item", 500));
|
||||||
|
}
|
||||||
|
};
|
||||||
downloadGetToken = async (
|
downloadGetToken = async (
|
||||||
ids: string[],
|
ids: string[],
|
||||||
versionId: string | null,
|
versionId: string | null,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export type ItemRequestParams = RequestParams & {
|
|||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ItemRequestByEditingSessionKeyParams = RequestParams & {
|
||||||
|
editing_session_key: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DriveItemDetails = {
|
export type DriveItemDetails = {
|
||||||
path: DriveFile[];
|
path: DriveFile[];
|
||||||
item?: DriveFile;
|
item?: DriveFile;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
DriveItemDetails,
|
DriveItemDetails,
|
||||||
DriveTdriveTab,
|
DriveTdriveTab,
|
||||||
ItemRequestParams,
|
ItemRequestParams,
|
||||||
|
ItemRequestByEditingSessionKeyParams,
|
||||||
RequestParams,
|
RequestParams,
|
||||||
SearchDocumentsBody,
|
SearchDocumentsBody,
|
||||||
SearchDocumentsOptions,
|
SearchDocumentsOptions,
|
||||||
@@ -165,6 +166,26 @@ export class DocumentsController {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a DriveFile item by its `editing_session_key`.
|
||||||
|
*
|
||||||
|
* @param {FastifyRequest} request
|
||||||
|
* @returns {Promise<DriveItemDetails>}
|
||||||
|
*/
|
||||||
|
getByEditingSessionKey = async (
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Params: ItemRequestByEditingSessionKeyParams;
|
||||||
|
Querystring: PaginationQueryParameters & { public_token?: string };
|
||||||
|
}>,
|
||||||
|
): Promise<DriveFile> => {
|
||||||
|
const context = getDriveExecutionContext(request);
|
||||||
|
const { editing_session_key } = request.params;
|
||||||
|
return await globalResolver.services.documents.documents.getByEditingSessionKey(
|
||||||
|
editing_session_key,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse file, special endpoint for TDrive application widget.
|
* Browse file, special endpoint for TDrive application widget.
|
||||||
* Returns the current folder with the filtered content
|
* Returns the current folder with the filtered content
|
||||||
@@ -310,6 +331,35 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin an editing session if none exists, or return the existing one
|
||||||
|
* @returns The `editing_session_key` that was either set or already was there
|
||||||
|
*/
|
||||||
|
beginEditing = async (
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Params: ItemRequestParams;
|
||||||
|
Body: { editorApplicationId: string };
|
||||||
|
}>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const context = getDriveExecutionContext(request);
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
if (!id) throw new CrudException("Missing id", 400);
|
||||||
|
if (!request.body.editorApplicationId)
|
||||||
|
throw new CrudException("Missing editorApplicationId", 400);
|
||||||
|
|
||||||
|
return await globalResolver.services.documents.documents.beginEditing(
|
||||||
|
id,
|
||||||
|
request.body.editorApplicationId,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error: `${error}` }, "Failed to begin editing Drive item");
|
||||||
|
CrudException.throwMe(error, new CrudException("Failed to begin editing Drive item", 500));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
downloadGetToken = async (
|
downloadGetToken = async (
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Params: ItemRequestParams;
|
Params: ItemRequestParams;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance, FastifyPluginCallback } from "fastify";
|
import { FastifyInstance, FastifyPluginCallback } from "fastify";
|
||||||
import { DocumentsController } from "./controllers";
|
import { DocumentsController } from "./controllers";
|
||||||
import { createDocumentSchema, createVersionSchema } from "./schemas";
|
import { createDocumentSchema, createVersionSchema, beginEditingSchema } from "./schemas";
|
||||||
// import profilerPlugin from "../../../utils/profiler";
|
// import profilerPlugin from "../../../utils/profiler";
|
||||||
|
|
||||||
const baseUrl = "/companies/:company_id";
|
const baseUrl = "/companies/:company_id";
|
||||||
@@ -79,6 +79,21 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next)
|
|||||||
handler: documentsController.createVersion.bind(documentsController),
|
handler: documentsController.createVersion.bind(documentsController),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.route({
|
||||||
|
method: "POST",
|
||||||
|
url: `${serviceUrl}/:id/editing_session`,
|
||||||
|
preValidation: [fastify.authenticateOptional],
|
||||||
|
schema: beginEditingSchema,
|
||||||
|
handler: documentsController.beginEditing.bind(documentsController),
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.route({
|
||||||
|
method: "GET",
|
||||||
|
url: `${serviceUrl}/editing_session/:editing_session_key`,
|
||||||
|
preValidation: [fastify.authenticateOptional],
|
||||||
|
handler: documentsController.getByEditingSessionKey.bind(documentsController),
|
||||||
|
});
|
||||||
|
|
||||||
fastify.route({
|
fastify.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `${serviceUrl}/download/token`,
|
url: `${serviceUrl}/download/token`,
|
||||||
|
|||||||
@@ -109,3 +109,22 @@ export const createVersionSchema = {
|
|||||||
"2xx": fileVersionSchema,
|
"2xx": fileVersionSchema,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const beginEditingSchema = {
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
editorApplicationId: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["editorApplicationId"],
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
"2xx": {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
editingSessionKey: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["editingSessionKey"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class DriveFileMockClass {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
last_modified: number;
|
last_modified: number;
|
||||||
access_info: MockAccessInformation;
|
access_info: MockAccessInformation;
|
||||||
|
editing_session_key: string;
|
||||||
creator: string;
|
creator: string;
|
||||||
is_directory: boolean;
|
is_directory: boolean;
|
||||||
scope: "personal" | "shared";
|
scope: "personal" | "shared";
|
||||||
|
|||||||
@@ -396,6 +396,29 @@ export default class UserApi {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async beginEditingDocument(
|
||||||
|
driveFileId: string,
|
||||||
|
editorApplicationId: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
return await this.api.post(
|
||||||
|
`${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${driveFileId}/editing_session`,
|
||||||
|
{ editorApplicationId },
|
||||||
|
{
|
||||||
|
authorization: `Bearer ${this.jwt}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async beginEditingDocumentExpectOk(
|
||||||
|
driveFileId: string,
|
||||||
|
editorApplicationId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await this.beginEditingDocument(driveFileId, editorApplicationId);
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const {editingSessionKey} = result.json();
|
||||||
|
expect(editingSessionKey).toBeTruthy();
|
||||||
|
return editingSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
async searchDocument(
|
async searchDocument(
|
||||||
payload: Record<string, any>
|
payload: Record<string, any>
|
||||||
) {
|
) {
|
||||||
@@ -459,6 +482,16 @@ export default class UserApi {
|
|||||||
return doc;
|
return doc;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async getDocumentByEditingKey(editing_session_key: string) {
|
||||||
|
return await this.platform.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/editing_session/${encodeURIComponent(editing_session_key)}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${this.jwt}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
async sharedWithMeDocuments(
|
async sharedWithMeDocuments(
|
||||||
payload: Record<string, any>
|
payload: Record<string, any>
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -75,4 +75,43 @@ describe("the Drive's documents' editing session kind-of-lock", () => {
|
|||||||
expect(driveFileRepository?.atomicCompareAndSet(temporaryDocument, "editing_session_key", "124", null)).
|
expect(driveFileRepository?.atomicCompareAndSet(temporaryDocument, "editing_session_key", "124", null)).
|
||||||
rejects.toThrow("no row matched PK");
|
rejects.toThrow("no row matched PK");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid editorApplicationId', async () => {
|
||||||
|
let result = await currentUser.beginEditingDocument(temporaryDocument.id, '');
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
result = await currentUser.beginEditingDocument(temporaryDocument.id, 'e2e-testing');
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
result = await currentUser.beginEditingDocument(temporaryDocument.id, 'e2e_testing_but_this_one_is_likely_too_long_the_max_is_128_with_added_guid_and_ts_added_after_and_counting_towards_128');
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can begin an editing session on a document only once', async () => {
|
||||||
|
const editingSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing');
|
||||||
|
const secondKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing');
|
||||||
|
expect(editingSessionKey).toBe(secondKey);
|
||||||
|
const document = await currentUser.getDocumentOKCheck(temporaryDocument.id)
|
||||||
|
expect(document.item.editing_session_key).toBe(editingSessionKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot begin an editing session on a document without write permissions', async () => {
|
||||||
|
const secondUser = await UserApi.getInstance(platform!, true);
|
||||||
|
const editingResult = await secondUser.beginEditingDocument(temporaryDocument.id, 'e2e_testing');
|
||||||
|
expect(editingResult.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can retreive the document from the editing session key', async () => {
|
||||||
|
const editingSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing');
|
||||||
|
let foundDocumentResult = await currentUser.getDocumentByEditingKey(editingSessionKey + "-made-wrong");
|
||||||
|
expect(foundDocumentResult.statusCode).toBe(404);
|
||||||
|
foundDocumentResult = await currentUser.getDocumentByEditingKey("");
|
||||||
|
expect(foundDocumentResult.statusCode).toBe(400);
|
||||||
|
foundDocumentResult = await currentUser.getDocumentByEditingKey(editingSessionKey);
|
||||||
|
expect(foundDocumentResult.statusCode).toBe(200);
|
||||||
|
expect(temporaryDocument.id).toBe(foundDocumentResult.json().id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can end an editing session on a document only once with the right key', async () => {
|
||||||
|
const editingSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing');
|
||||||
|
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user