Merge branch '525-515-548-523-onlyoffice-rework' into release/v1.0.4-rc1
This commit is contained in:
@@ -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;
|
||||
+12
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+6
-4
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user