Merge branch '525-515-548-523-onlyoffice-rework' into release/v1.0.4-rc1

This commit is contained in:
Monta
2024-09-20 14:19:40 +01:00
45 changed files with 2276 additions and 244 deletions
@@ -0,0 +1,16 @@
import { CommandModule } from "yargs";
const command: CommandModule = {
describe: "Editing sessions tools",
command: "editing_session",
builder: yargs =>
yargs.commandDir("editing_session_cmds", {
visit: commandModule => commandModule.default,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
handler: () => {
throw new Error("Missing sub-command");
},
};
export default command;
@@ -0,0 +1,166 @@
import yargs from "yargs";
import runWithPlatform from "../../lib/run-with-platform";
import type { TdrivePlatform } from "../../../core/platform/platform";
import type { DatabaseServiceAPI } from "../../../core/platform/services/database/api";
import {
DriveFile,
EditingSessionKeyFormat,
TYPE as DriveFile_TYPE,
} from "../../../services/documents/entities/drive-file";
import {
FileVersion,
TYPE as FileVersion_TYPE,
} from "../../../services/documents/entities/file-version";
import User, { TYPE as User_TYPE } from "../../../services/user/entities/user";
import { FindOptions } from "../../../core/platform/services/database/services/orm/repository/repository";
async function makeUserCache(platform: TdrivePlatform) {
const usersRepo = await platform
.getProvider<DatabaseServiceAPI>("database")
.getRepository<User>(User_TYPE, User);
const cache: { [id: string]: User } = {};
return async (id): Promise<[boolean, User]> => {
if (id in cache) return [true, cache[id]];
const user = await usersRepo.findOne({ id });
if (user) cache[id] = user;
return [false, user];
};
}
interface ListArguments {
all: boolean;
name: string;
}
async function report(platform: TdrivePlatform, args: ListArguments) {
const users = await makeUserCache(platform);
async function formatUser(id) {
const [wasKnown, user] = await users(id);
return user?.email_canonical
? user.email_canonical + (wasKnown ? "" : ` (${id})`)
: `${JSON.stringify(id)} (user id not found)`;
}
const drivesRepo = await platform
.getProvider<DatabaseServiceAPI>("database")
.getRepository<DriveFile>(DriveFile_TYPE, DriveFile);
const versionsRepo = await platform
.getProvider<DatabaseServiceAPI>("database")
.getRepository<FileVersion>(FileVersion_TYPE, FileVersion);
const filter = {
is_in_trash: false,
};
if (!args.all) filter["editing_session_key"] = { $ne: null };
const opts: FindOptions = { sort: { name: "asc" } };
if (args.name) filter["name"] = args.name;
const editedFiles = (await drivesRepo.find(filter, opts)).getEntities();
const formatDate = (date: Date) => date.toISOString();
const formatTS = (ts: number) => formatDate(new Date(ts));
console.error(`DriveFiles${args.all ? "" : " with non-null editing_session_key"}:`);
console.error("");
for (const dfile of editedFiles) {
console.error(`- ${dfile.name} (${dfile.id}) of ${await formatUser(dfile.creator)}`);
if (dfile.scope !== "personal") console.error(` - scope: ${dfile.scope}`);
if (dfile.is_directory) console.error(" - directory !");
if (dfile.is_in_trash) console.error(" - in trash !");
console.error(` - modified: ${formatTS(dfile.last_modified)}`);
if (dfile.editing_session_key) {
const parsed = EditingSessionKeyFormat.parse(dfile.editing_session_key);
console.error(" - editing_session_key:");
console.error(` - URL encoded: ${encodeURIComponent(dfile.editing_session_key)}`);
console.error(` - applicationId: ${parsed.applicationId}`);
console.error(` - companyId: ${parsed.companyId}`);
console.error(` - instanceId: ${JSON.stringify(parsed.instanceId)}`);
console.error(
` - userId: ${await formatUser(parsed.userId)} (${
parsed.userId === dfile.creator ? "same as creator ID" : "not the creator"
})`,
);
console.error(
` - timestamp: ${formatDate(parsed.timestamp)} (${Math.floor(
(new Date().getTime() - parsed.timestamp.getTime()) / 1000,
)}s ago)`,
);
}
const versions = (
await versionsRepo.find({ drive_item_id: dfile.id }, { sort: { date_added: "asc" } })
).getEntities();
let previousSize = 0;
let lastVersion: FileVersion;
console.error(" - Versions:");
for (const version of versions) {
console.error(
` - ${formatTS(version.date_added)} by ${await formatUser(version.creator_id)}`,
);
console.error(` - id: ${version.id}`);
console.error(
` - size: ${version.file_metadata.size} (${
version.file_metadata.size > previousSize ? "+" : ""
}${version.file_metadata.size - previousSize})`,
);
previousSize = version.file_metadata.size;
lastVersion = version;
console.error(` - application: ${JSON.stringify(version.application_id)}`);
}
if (previousSize != dfile.size)
console.error(
` - mismatched sizes: DriveFile.size is ${dfile.size} but last Version.file_metadata is ${previousSize}`,
);
if (lastVersion) {
const lastTimestamp = lastVersion.date_added;
if (lastTimestamp != dfile.last_modified)
console.error(
` - mismatched FileVersion.date_added (${formatTS(
lastTimestamp,
)}) != DriveFile.last_modified (${formatTS(dfile.last_modified)}) - delta: ${
(lastTimestamp - dfile.last_modified) / 1000
}s`,
);
if (lastTimestamp != dfile.last_version_cache.date_added)
console.error(
` - mismatched FileVersion.date_added (${formatTS(
lastTimestamp,
)}) != DriveFile.last_version_cache.date_added (${formatTS(
dfile.last_version_cache.date_added,
)}) - delta: ${(lastTimestamp - dfile.last_version_cache.date_added) / 1000}s`,
);
if (lastVersion.file_size != dfile.size)
console.error(
` - mismatched FileVersion.file_size (${lastVersion.file_size}) != DriveFile.dfile.size (${dfile.size})`,
);
}
}
if (!editedFiles.length) console.error(" (no matching DriveFiles)");
}
const command: yargs.CommandModule<unknown, unknown> = {
command: "list",
describe: `
List current DriveFile items that have an editing_session_key set
`.trim(),
builder: {
all: {
type: "boolean",
alias: "a",
describe: "Include all DriveFiles (not just the ones with editing_session_keys)",
default: false,
},
name: {
type: "string",
alias: "n",
describe: "Filter DriveFiles by name (must be exact)",
default: false,
},
},
handler: async argv => {
const args = argv as unknown as ListArguments;
await runWithPlatform("editing_session list", async ({ spinner: _spinner, platform }) => {
console.error("\n");
await report(platform, args);
console.error("\n");
});
},
};
export default command;
@@ -47,9 +47,18 @@ export class PostgresQueryBuilder {
values.push(...inClause);
}
} else {
const value = `${this.dataTransformer.toDbString(filter, columnsDefinition[key].type)}`;
whereClause += `${key} = $${idx++} AND `;
values.push(value);
const isANotEqualFilter = filter && Object.keys(filter).join("!") === "$ne";
if (filter === null || (isANotEqualFilter && filter["$ne"] === null)) {
whereClause += `${key} IS${filter === null ? "" : " NOT"} NULL`;
} else {
const filterValue = isANotEqualFilter ? filter["$ne"] : filter;
const value = `${this.dataTransformer.toDbString(
filterValue,
columnsDefinition[key].type,
)}`;
whereClause += `${key} ${isANotEqualFilter ? "!=" : "="} $${idx++} AND `;
values.push(value);
}
}
});
}
@@ -43,6 +43,11 @@ export type FindOptions = {
sort?: SortOption;
};
export type AtomicCompareAndSetResult<FieldValueType> = {
didSet: boolean;
currentValue: FieldValueType | null;
};
/**
* Repository to work with entities. Each entity type has its own repository instance.
*/
@@ -129,10 +134,7 @@ export default class Repository<EntityType> {
fieldName: keyof EntityType,
previousValue: FieldValueType | null,
newValue: FieldValueType | null,
): Promise<{
didSet: boolean;
currentValue: FieldValueType | null;
}> {
): Promise<AtomicCompareAndSetResult<FieldValueType>> {
if (previousValue === newValue)
throw new Error(`Previous and new values are identical: ${JSON.stringify(previousValue)}`);
return this.connector.atomicCompareAndSet(entity, fieldName, previousValue, newValue);
@@ -6,12 +6,30 @@ import WebServerAPI from "../../core/platform/services/webserver/provider";
import Application from "../applications/entities/application";
import web from "./web/index";
import { logger } from "../../core/platform/framework/logger";
import { EditingSessionKeyFormat } from "../documents/entities/drive-file";
import jwt from "jsonwebtoken";
export enum ApplicationEditingKeyStatus {
/** the key isn't known and maybe used for a new session */
unknown = "unknown",
/** the key needed updating but is now invalid */
updated = "updated",
/** the key was already used in a finished session and can't be used again */
expired = "expired",
/** the key is valid and current and should be used again for the same file */
live = "live",
}
@Prefix("/api")
export default class ApplicationsApiService extends TdriveService<undefined> {
version = "1";
name = "applicationsapi";
private static default: ApplicationsApiService;
public static getDefault() {
return this.default;
}
public async doInit(): Promise<this> {
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _opts, next) => {
@@ -68,10 +86,95 @@ export default class ApplicationsApiService extends TdriveService<undefined> {
}
}
}
ApplicationsApiService.default = this;
return this;
}
/** Get the configuration of a given `appId` or `undefined` if unknown */
public getApplicationConfig(appId: string) {
const apps = config.get<Application[]>("applications.plugins") || [];
return apps.find(app => app.id === appId);
}
/** Get the configuration of a given `appId` or throw an error if unknown */
public requireApplicationConfig(appId: string) {
const app = this.getApplicationConfig(appId);
if (!app) throw new Error(`Unknown application.id ${JSON.stringify(appId)}`);
return app;
}
/** Send a request to the plugin by its application id
* @param url Full URL that doesn't start with a `/`
*/
private async requestFromApplication(
method: "GET" | "POST" | "DELETE",
url: string,
appId: string,
data?: unknown,
) {
const app = this.requireApplicationConfig(appId);
if (!app.internal_domain)
throw new Error(`application.id ${JSON.stringify(appId)} missing an internal_domain`);
const signature = jwt.sign(
{
ts: new Date().getTime(),
type: "tdriveToApplication",
application_id: appId,
},
app.api.private_key,
);
const domain = app.internal_domain.replace(/(\/$|^\/)/gm, "");
const finalURL = `${domain}/${url}${
url.indexOf("?") > -1 ? "&" : "?"
}token=${encodeURIComponent(signature)}`;
return axios.request({
url: finalURL,
method: method,
data,
headers: {
Authorization: signature,
},
maxRedirects: 0,
});
}
/**
* Check status of `editing_session_key` in the corresponding application.
* @param editingSessionKey {@see DriveFile.editing_session_key} to check
* @returns status of the provided key as far as the application knows
*/
async checkPendingEditingStatus(editingSessionKey: string): Promise<ApplicationEditingKeyStatus> {
const parsedKey = EditingSessionKeyFormat.parse(editingSessionKey);
const response = await this.requestFromApplication(
"POST",
"tdriveApi/1/session/" + encodeURIComponent(editingSessionKey) + "/check",
parsedKey.applicationId,
);
if (response.status != 200 || response.data.error)
throw new Error(
`Application check key ${editingSessionKey} failed with HTTP ${
response.status
}: ${JSON.stringify(response.data)}`,
);
return (response.data.status as ApplicationEditingKeyStatus) || null;
}
/**
* Change the filename in the external editing session
* @param editingSessionKey {@see DriveFile.editing_session_key} to change
* @param filename The new filename
*/
async renameEditingKeyFilename(editingSessionKey: string, filename: string): Promise<boolean> {
const parsedKey = EditingSessionKeyFormat.parse(editingSessionKey);
const response = await this.requestFromApplication(
"POST",
`tdriveApi/1/session/${encodeURIComponent(editingSessionKey)}/title`,
parsedKey.applicationId,
{ title: filename },
);
return !!response.data.done as boolean;
}
// TODO: remove
api(): undefined {
return undefined;
@@ -1,8 +1,10 @@
import { Type } from "class-transformer";
import { randomUUID } from "crypto";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { DriveFileAccessLevel, publicAccessLevel } from "../types";
import { FileVersion } from "./file-version";
import search from "./drive-file.search";
import * as UUIDTools from "../../../utils/uuid";
export const TYPE = "drive_files";
export type DriveScope = "personal" | "shared";
@@ -93,8 +95,10 @@ export class DriveFile {
/**
* If this field is non-null, then an editing session is in progress (probably in OnlyOffice).
* Should be in the format `timestamp-appid-hexuuid` where `appid` and `timestamp` have no `-`
* characters.
* Use {@see EditingSessionKeyFormat} to generate and interpret it.
* Values should ensure that sorting lexicographically is chronological (assuming perfect clocks everywhere),
* and that the application, company and user that started the edit session are retrievable.
* It is not encrypted.
*/
@Type(() => String)
@Column("editing_session_key", "string")
@@ -120,6 +124,105 @@ export class DriveFile {
scope: DriveScope;
}
const OnlyOfficeSafeDocKeyBase64 = {
// base64 uses `+/`, but base64url uses `-_` instead. Both use `=` as padding,
// which conflicts with EditingSessionKeyFormat so using `.` instead.
fromBuffer(buffer: Buffer) {
return buffer.toString("base64url").replace(/=/g, ".");
},
toBuffer(base64: string) {
return Buffer.from(base64.replace(/\./g, "="), "base64url");
},
};
function checkFieldValue(field: string, value: string, required: boolean = true) {
if (!required && !value) return;
if (!/^[0-9a-zA-Z_-]+$/m.test(value))
throw new Error(
`Invalid ${field} value (${JSON.stringify(
value,
)}). Must be short and only alpha numeric or '_' and '-'`,
);
}
/**
* Reference implementation for generating then parsing the {@link DriveFile.editing_session_key} field.
*
* Fields should be explicit, `instanceId` is for the case when we have multiple
* clients
*/
export const EditingSessionKeyFormat = {
// OnlyOffice key limits: 128 chars, [0-9a-zA-Z.=_-]
// See https://api.onlyoffice.com/editors/config/document#key
// 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.
generate(
applicationId: string,
instanceId: string,
companyId: string,
userId: string,
overrideTimeStamp?: Date,
) {
checkFieldValue("applicationId", applicationId);
checkFieldValue("instanceId", instanceId, false);
const isoUTCDateNoSpecialCharsNoMS = (overrideTimeStamp ?? new Date())
.toISOString()
.replace(/\..+$/, "")
.replace(/[ZT:-]/g, "");
const userIdBuffer = UUIDTools.bufferFromUUIDString(userId) as unknown as Uint8Array;
const companyIdBuffer = UUIDTools.bufferFromUUIDString(companyId) as unknown as Uint8Array;
const entropyBuffer = UUIDTools.bufferFromUUIDString(randomUUID()) as unknown as Uint8Array;
const idsString = OnlyOfficeSafeDocKeyBase64.fromBuffer(
Buffer.concat([companyIdBuffer, userIdBuffer, entropyBuffer]),
);
const newKey = [isoUTCDateNoSpecialCharsNoMS, applicationId, instanceId, idsString].join("=");
if (newKey.length > 128 || !/^[0-9a-zA-Z=_-]+$/m.test(newKey))
throw new Error(
`Invalid generated editingSessionKey (${JSON.stringify(
newKey,
)}) string. Must be <128 chars, and only contain [0-9a-zA-z=_-]`,
);
return newKey;
},
parse(editingSessionKey: string) {
const parts = editingSessionKey.split("=");
const expectedParts = 4;
if (parts.length !== expectedParts)
throw new Error(
`Invalid editingSessionKey (${JSON.stringify(
editingSessionKey,
)}). Expected ${expectedParts} parts`,
);
const [timestampStr, applicationId, instanceId, idsOOBase64String] = parts;
const timestampMatch = timestampStr.match(
/^(?<year>\d{4})(?<month>\d\d)(?<day>\d\d)(?<hour>\d\d)(?<minute>\d\d)(?<second>\d\d)$/,
);
if (!timestampMatch)
throw new Error(
`Invalid editingSessionKey (${JSON.stringify(
editingSessionKey,
)}). Didn't start with valid timestamp`,
);
const { year, month, day, hour, minute, second } = timestampMatch.groups!;
const idsBuffer = OnlyOfficeSafeDocKeyBase64.toBuffer(idsOOBase64String);
const companyId = UUIDTools.formattedUUIDInBufferArray(idsBuffer, 0);
const userId = UUIDTools.formattedUUIDInBufferArray(idsBuffer, 1);
return {
timestamp: new Date(
Date.parse(`${[year, month, day].join("-")}T${[hour, minute, second].join(":")}Z`),
),
applicationId,
instanceId,
companyId,
userId,
};
},
};
export type AccessInformation = {
public?: {
token: string;
@@ -6,6 +6,7 @@ import {
Pagination,
} from "../../../core/platform/framework/api/crud-service";
import Repository, {
AtomicCompareAndSetResult,
comparisonType,
inType,
} from "../../../core/platform/services/database/services/orm/repository/repository";
@@ -13,7 +14,7 @@ import { PublicFile } from "../../../services/files/entities/file";
import globalResolver from "../../../services/global-resolver";
import { hasCompanyAdminLevel } from "../../../utils/company";
import gr from "../../global-resolver";
import { DriveFile, TYPE } from "../entities/drive-file";
import { DriveFile, EditingSessionKeyFormat, TYPE } from "../entities/drive-file";
import { FileVersion, TYPE as FileVersionType } from "../entities/file-version";
import User, { TYPE as UserType } from "../../user/entities/user";
@@ -58,8 +59,10 @@ import {
import archiver from "archiver";
import internal from "stream";
import config from "config";
import { randomUUID } from "crypto";
import { MultipartFile } from "@fastify/multipart";
import { UploadOptions } from "src/services/files/types";
import { SortType } from "src/core/platform/services/search/api";
import ApplicationsApiService, { ApplicationEditingKeyStatus } from "../../applications-api";
export class DocumentsService {
version: "1";
@@ -546,7 +549,7 @@ export class DocumentsService {
}
const updatable = ["access_info", "name", "tags", "parent_id", "description", "is_in_trash"];
let renamedTo: string | undefined;
for (const key of updatable) {
if ((content as any)[key]) {
if (
@@ -587,7 +590,7 @@ export class DocumentsService {
}
});
} else if (key === "name") {
item.name = await getItemName(
renamedTo = item.name = await getItemName(
content.parent_id || item.parent_id,
item.id,
content.name,
@@ -618,7 +621,17 @@ export class DocumentsService {
await updateItemSize(oldParent, this.repository, context);
}
if (renamedTo && item.editing_session_key)
ApplicationsApiService.getDefault()
.renameEditingKeyFilename(item.editing_session_key, renamedTo)
.catch(err => {
logger.error("Error rename editing session to new name", {
err,
editing_session_key: item.editing_session_key,
renamedTo,
});
/* Ignore errors, just throw it out there... */
});
if (item.parent_id === this.TRASH) {
//When moving to trash we recompute the access level to make them flat
item.access_info = await makeStandaloneAccessLevel(
@@ -915,6 +928,7 @@ export class DocumentsService {
item.last_version_cache = driveItemVersion;
item.size = driveItemVersion.file_size;
item.last_modified = driveItemVersion.date_added;
await this.repository.save(item);
@@ -958,6 +972,9 @@ export class DocumentsService {
* with only that key provided.
* @param id DriveFile ID of the document to begin editing
* @param editorApplicationId Editor/Application/Plugin specific identifier
* @param appInstanceId For that `editorApplicationId` a unique identifier
* when multiple instances are running. Unused today - would need a mapping
* from `appInstanceId` to server host.
* @param context
* @returns An object in the format `{}` with the unique identifier for the
* editing session
@@ -965,34 +982,62 @@ export class DocumentsService {
beginEditing = async (
id: string,
editorApplicationId: string,
appInstanceId: 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;
}
if (
!editorApplicationId ||
!ApplicationsApiService.getDefault().getApplicationConfig(editorApplicationId)
) {
logger.error(`Missing or invalid application ID: ${JSON.stringify(editorApplicationId)}`);
CrudException.throwMe(
new Error("Unknown appId"),
new CrudException("Missing or invalid application ID", 400),
);
}
const spinLoopUntilEditable = async (
provider: {
generateKey: () => string;
atomicSet: (
key: string | null,
previous: string | null,
) => Promise<AtomicCompareAndSetResult<string>>;
getPluginKeyStatus: (key: string) => Promise<ApplicationEditingKeyStatus>;
},
attemptCount = 8,
tarpitS = 1,
tarpitWorsenCoeff = 1.2,
) => {
while (attemptCount-- > 0) {
const newKey = provider.generateKey();
const swapResult = await provider.atomicSet(newKey, null);
logger.debug(`Begin edit try ${newKey}, got: ${JSON.stringify(swapResult)}`);
if (swapResult.didSet) return newKey;
if (!swapResult.currentValue) continue; // glitch in the matrix but ok because atomicCompareAndSet is not actually completely atomic
const existingStatus = await provider.getPluginKeyStatus(swapResult.currentValue);
logger.debug(`Begin edit get status of ${newKey}: ${JSON.stringify(existingStatus)}`);
switch (existingStatus) {
case ApplicationEditingKeyStatus.unknown:
case ApplicationEditingKeyStatus.live:
return swapResult.currentValue;
case ApplicationEditingKeyStatus.updated:
case ApplicationEditingKeyStatus.expired:
logger.debug(`Begin edit emptying previous ${swapResult.currentValue}`);
await provider.atomicSet(null, swapResult.currentValue);
break;
default:
throw new Error(
`Unexpected ApplicationEditingKeyStatus: ${JSON.stringify(existingStatus)}`,
);
}
await new Promise(resolve => setTimeout(resolve, tarpitS * 1000));
tarpitS *= tarpitWorsenCoeff;
}
};
const hasAccess = await checkAccess(id, null, "write", this.repository, context);
if (!hasAccess) {
@@ -1012,18 +1057,139 @@ export class DocumentsService {
{},
context,
);
const result = await this.repository.atomicCompareAndSet(
driveFile,
"editing_session_key",
null,
newKey,
);
return { editingSessionKey: result.currentValue };
const editingSessionKey = await spinLoopUntilEditable({
atomicSet: (key, previous) =>
this.repository.atomicCompareAndSet(driveFile, "editing_session_key", previous, key),
generateKey: () =>
EditingSessionKeyFormat.generate(
editorApplicationId,
appInstanceId,
context.company.id,
context.user.id,
),
getPluginKeyStatus: key =>
ApplicationsApiService.getDefault().checkPendingEditingStatus(key),
});
return { editingSessionKey };
} catch (error) {
logger.error({ error: `${error}` }, "Failed to begin editing Drive item");
CrudException.throwMe(error, new CrudException("Failed to begin editing Drive item", 500));
}
};
/**
* End editing session either by providing a URL to a new file to create a version,
* or not, to just cancel the session.
* @param editing_session_key Editing key of the DriveFile
* @param file Multipart files from the incoming http request
* @param options Optional upload information from the request
* @param keepEditing If `true`, the file will be saved as a new version,
* and the DriveFile will keep its editing_session_key. If `true`, a file is required.
* @param userId When authentified by the root token of an application, this user
* will override the creator of this version
* @param context
*/
updateEditing = async (
editing_session_key: string,
file: MultipartFile,
options: UploadOptions,
keepEditing: boolean,
userId: string | null,
context: CompanyExecutionContext,
) => {
//TODO rethink the locking stuff shouldn't be just forgotten
//TODO Make this accept even if missing and act ok about it,
// store to dump folder or such
if (!context) {
this.logger.error("invalid execution context");
return null;
}
if (!editing_session_key) {
this.logger.error("Invalid editing_session_key: " + JSON.stringify(editing_session_key));
throw new CrudException("Invalid editing_session_key", 400);
}
try {
const parsedKey = EditingSessionKeyFormat.parse(editing_session_key);
context = {
...context,
company: { id: parsedKey.companyId },
};
if (context.user.id === context.user.application_id && context.user.application_id) {
context = {
...context,
user: {
...context.user,
id: userId || parsedKey.userId,
},
};
}
} catch (e) {
this.logger.error(
"Invalid editing_session_key value: " + JSON.stringify(editing_session_key),
e,
);
throw new CrudException("Invalid editing_session_key", 400);
}
const driveFile = await this.repository.findOne({ editing_session_key }, {}, context);
if (!driveFile) {
this.logger.error("Drive item not found by editing session key");
throw new CrudException("Item not found by editing session key", 404);
}
const hasAccess = await checkAccess(driveFile.id, driveFile, "write", this.repository, context);
if (!hasAccess) {
logger.error("user does not have access drive item " + driveFile.id);
CrudException.throwMe(
new Error("user does not have access to the drive item"),
new CrudException("user does not have access drive item", 401),
);
}
if (file) {
const fileEntity = await globalResolver.services.files.save(null, file, options, context);
await globalResolver.services.documents.documents.createVersion(
driveFile.id,
{
drive_item_id: driveFile.id,
provider: "internal",
file_metadata: {
external_id: fileEntity.id,
source: "internal",
},
},
context,
);
} else if (keepEditing) {
this.logger.error("Inconsistent endEditing call");
throw new CrudException("Inconsistent endEditing call", 500);
}
if (!keepEditing) {
try {
const result = await this.repository.atomicCompareAndSet(
driveFile,
"editing_session_key",
editing_session_key,
null,
);
if (!result.didSet)
throw new Error(
`Couldn't set editing_session_key ${JSON.stringify(
editing_session_key,
)} on DriveFile ${JSON.stringify(driveFile.id)} because it is ${JSON.stringify(
result.currentValue,
)}`,
);
} catch (error) {
logger.error({ error: `${error}` }, "Failed to cancel editing Drive item");
CrudException.throwMe(error, new CrudException("Failed to cancel editing Drive item", 500));
}
}
};
downloadGetToken = async (
ids: string[],
versionId: string | null,
@@ -341,10 +341,12 @@ export class DocumentsController {
beginEditing = async (
request: FastifyRequest<{
Params: ItemRequestParams;
Body: { editorApplicationId: string };
//TODO application id should be received from the token that we have during the login
Body: { editorApplicationId: string; instanceId: string };
}>,
) => {
try {
//TODO create application execution context with the application identifier inside
const context = getDriveExecutionContext(request);
const { id } = request.params;
@@ -355,6 +357,7 @@ export class DocumentsController {
return await globalResolver.services.documents.documents.beginEditing(
id,
request.body.editorApplicationId,
request.body.instanceId || "",
context,
);
} catch (error) {
@@ -363,6 +366,86 @@ export class DocumentsController {
}
};
/**
* Finish an editing session by cancelling it.
*/
cancelEditing = async (
request: FastifyRequest<{
Params: ItemRequestByEditingSessionKeyParams;
Body: { editorApplicationId: string };
}>,
) => {
try {
const context = getDriveExecutionContext(request);
const { editing_session_key } = request.params;
if (!editing_session_key) throw new CrudException("Missing editing_session_key", 400);
return await globalResolver.services.documents.documents.updateEditing(
editing_session_key,
null,
null,
false,
null,
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));
}
};
//TODO: will need a save under session key, but without ending the edit (for force saves)
/**
* Finish an editing session for a given `editing_session_key` by uploading the new version of the File.
* Unless the `keepEditing` query param is `true`, then just save and stay in editing mode.
*/
updateEditing = async (
request: FastifyRequest<{
Params: ItemRequestByEditingSessionKeyParams;
Querystring: { keepEditing?: string; userId?: string };
Body: {
item: Partial<DriveFile>;
version: Partial<FileVersion>;
};
}>,
) => {
const { editing_session_key } = request.params;
if (!editing_session_key) throw new CrudException("Editing session key must be set", 400);
const context = getDriveExecutionContext(request);
if (request.isMultipart()) {
const file = await request.file();
const q: Record<string, string> = request.query;
const options: UploadOptions = {
totalChunks: parseInt(q.resumableTotalChunks || q.total_chunks) || 1,
totalSize: parseInt(q.resumableTotalSize || q.total_size) || 0,
chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1,
filename: q.resumableFilename || q.filename || file?.filename || undefined,
type: q.resumableType || q.type || file?.mimetype || undefined,
waitForThumbnail: !!q.thumbnail_sync,
ignoreThumbnails: false,
};
return await globalResolver.services.documents.documents.updateEditing(
editing_session_key,
file,
options,
request.query.keepEditing == "true",
request.query.userId,
context,
);
} else {
return await globalResolver.services.documents.documents.updateEditing(
editing_session_key,
null,
null,
true,
request.query.userId,
context,
);
}
};
downloadGetToken = async (
request: FastifyRequest<{
Params: ItemRequestParams;
@@ -5,6 +5,7 @@ import { createDocumentSchema, createVersionSchema, beginEditingSchema } from ".
const baseUrl = "/companies/:company_id";
const serviceUrl = `${baseUrl}/item`;
const editingSessionBase = "/editing_session/:editing_session_key";
const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next) => {
const documentsController = new DocumentsController();
@@ -89,11 +90,25 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next)
fastify.route({
method: "GET",
url: `${serviceUrl}/editing_session/:editing_session_key`,
url: editingSessionBase, //TODO NONONO check authenticate*Optional*
preValidation: [fastify.authenticateOptional],
handler: documentsController.getByEditingSessionKey.bind(documentsController),
});
fastify.route({
method: "POST",
url: editingSessionBase,
preValidation: [fastify.authenticateOptional],
handler: documentsController.updateEditing.bind(documentsController),
});
fastify.route({
method: "DELETE",
url: editingSessionBase,
preValidation: [fastify.authenticateOptional],
handler: documentsController.cancelEditing.bind(documentsController),
});
fastify.route({
method: "GET",
url: `${serviceUrl}/download/token`,
@@ -115,6 +115,7 @@ export const beginEditingSchema = {
type: "object",
properties: {
editorApplicationId: { type: "string" },
appInstanceId: { type: "string" },
},
required: ["editorApplicationId"],
},
+32
View File
@@ -12,3 +12,35 @@ export function timeuuidToDate(time_str: string): number {
return parseInt(time_string, 16);
}
/** Remove `-`s from a formatted UUID to get a hex a string */
export function hexFromFormatted(uuid: string) {
const result = uuid.replace(/-+/g, "");
if (result.length !== 32)
throw new Error(`Invalid UUID (${JSON.stringify(uuid)}). Wrong number of digits`);
return result;
}
/** Add `-`s back in a hex string to make a formatted UUID */
export function formattedFromHex(hex: string) {
const idMatch = hex.match(
/^([a-z0-f]{8})([a-z0-f]{4})([a-z0-f]{4})([a-z0-f]{4})([a-z0-f]{12})$/i,
);
if (!idMatch)
throw new Error(`Invalid UUID hex (${JSON.stringify(hex)}). Wrong number of digits`);
const [, ...parts] = idMatch;
return parts.join("-");
}
/** Convert a UUID formatted or hex string into a binary buffer */
export function bufferFromUUIDString(uuidOrHex: string) {
return Buffer.from(hexFromFormatted(uuidOrHex), "hex");
}
/** In a buffer with concatenated UUIDs in binary, extract the `index`th and return as formatted UUID */
export function formattedUUIDInBufferArray(buffer: Buffer, index: number) {
if (buffer.length < (index + 1) * 16)
throw new Error(`Cannot get UUID ${JSON.stringify(index)} because the buffer is too small`);
const slice = buffer.subarray(index * 16, (index + 1) * 16);
return formattedFromHex(slice.toString("hex").toLowerCase());
}