onlyoffice rework (#650)
#525 #515 #548 #523 This is a rewrite to have a more robust connexion with only office, recovering forgotten files, handling editing sessions as global locks, that kind
This commit is contained in:
@@ -43,7 +43,7 @@ Links in top right drop down application grid can be configured in the `applicat
|
||||
|
||||
Plugins are defined as entries under the `applications.plugins` array, with at least the following properties:
|
||||
|
||||
- `id` is mandatory and must be unique to each plugin
|
||||
- `id` is mandatory and must be unique to each plugin. It must be rather short, and alphanumeric with `_`s only.
|
||||
- `internal_domain` is the internal domain of the plugin's server, it must be the same as the one in the docker-compose.yml file for ex. or resolvable and accessible to the Twake Drive backend server.
|
||||
- `external_prefix` is the external URL prefix of the plugin exposed by the backend and proxied to the `internal_domain`.
|
||||
- `api.private_key` is the shared secret used by the plugin's server to authentify to the backend
|
||||
@@ -133,6 +133,119 @@ When a user requests a preview and then possibly to edit the file, an IFrame is
|
||||
|
||||
In the case of the OnlyOffice application, these URLs are pointing to the connector plugin, which then proxies back and forth with the OnlyOffice document server.
|
||||
|
||||
#### Editing session key
|
||||
|
||||
When editing a file, at first, a new session key is created on the file by the
|
||||
backend and atomically swapped.
|
||||
|
||||
This session key uniquely identifies the user, company, and plugin and it is not encrypted.
|
||||
|
||||
An editing session key prevents others from starting, can be updated with new versions, and
|
||||
ended with and without a new version.
|
||||
|
||||
See operations `beginEditing` and `updateEditing` in `tdrive/backend/node/src/services/documents/services/index.ts` for operations available on that key.
|
||||
|
||||
Basic flow is:
|
||||
- Call `beginEditing`
|
||||
- If a key previously was set for that file, uses the `/check` endpoint of the plugin to update the key status first
|
||||
- Twake Drive generates an editing session key and returns it (or returns the existing one).
|
||||
- Optionally call `updateEditing?keepEditing=true` with a file stream to generate intermediary versions
|
||||
- End the session by calling `updateEditing` (without `keepEditing=true`). If a file is provided, a new (and final) FileVersion
|
||||
is created with that content. Otherwise, the editing session is cleared without creating a new version.
|
||||
|
||||
|
||||
#### API to expose by the application
|
||||
|
||||
Authentication from the Twake Drive backend is a JWT with the property `type` being
|
||||
`"tdriveToApplication"` and signed with the shared secret. The application must accept
|
||||
multiple parallel requests for the same key gracefully.
|
||||
|
||||
- `POST /tdriveApi/1/session/${editing_session_key}/check`
|
||||
|
||||
Sent by Twake Drive backend when beginning a new editing session or investigating
|
||||
stored keys. The application is expected to process the key if possible before responding,
|
||||
and provide a response in JSON. The `error` key of that body should be truthy if the response
|
||||
is not known. Otherwise it should respond:
|
||||
|
||||
- `{ status: 'unknown' }`: the key isn't known and maybe used for a new session
|
||||
- `{ status: 'updated' }`: the key needed updating but is now invalid
|
||||
- `{ status: 'expired' }`: the key was already used in a finished session and can't be used again
|
||||
- `{ status: 'live' }`: the key is valid and current and should be used again for the same file (if multiple writers are allowed)
|
||||
|
||||
#### Example flow of editing session
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as User/Browser
|
||||
participant Front as Frontend
|
||||
box Server side
|
||||
participant Back as Backend
|
||||
participant Conn as onlyoffice<br>connector
|
||||
participant OO as Only Office<br>Edition Server
|
||||
end
|
||||
|
||||
User->>Front: Open in<br>editor
|
||||
Front->>Back: Get plugin config
|
||||
Front->>User: Open plugin config<br>editor.edition_url
|
||||
|
||||
User->>Conn: Open editor_url<br>(proxied by backend)
|
||||
Conn->>Back: beginEditing
|
||||
alt existing key
|
||||
Back->>Conn: checkSessionStatus
|
||||
Conn->>OO: getForgotten
|
||||
note over Conn, OO: recover forgotten<br>process, and new key
|
||||
Conn->>OO: info command
|
||||
note right of Conn: decide status of key<br>live or stale
|
||||
note over Conn, OO: detect ended but<br>not changed keys
|
||||
note over Conn, OO: normal callback processing with<br>update if required
|
||||
OO->>Conn: callback with key status
|
||||
Conn->>Back: key status<br>(live/expired/updated/etc)
|
||||
end
|
||||
activate Back
|
||||
Back->>Conn: editing_session_key
|
||||
Conn->>User: HTML host for Editor with<br>special callback URL
|
||||
User->>OO: Load JS Editor directly from OO server
|
||||
activate User
|
||||
loop User editing
|
||||
User->>User: Furious Document<br>Editing
|
||||
User-->>OO: Periodic saving
|
||||
end
|
||||
deactivate User
|
||||
note left of User: Closes editor
|
||||
note over User,OO: 10 seconds after last user closes their editor
|
||||
OO->>Conn: callback to save the new version<br>or close without changes
|
||||
Conn->>Back: updateEditing?keepEditing=false<br>with URL to new version from OO
|
||||
deactivate Back
|
||||
```
|
||||
|
||||
#### Batch processing of unknown keys
|
||||
|
||||
Periodically, the plugin, and twake drive, should run batch cleanup operations on editing session keys
|
||||
to ensure they are live, or removed, as they may block other operations until then.
|
||||
|
||||
Here is an example initiated by the plugin:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as User/Browser
|
||||
participant Front as Frontend
|
||||
box Server side
|
||||
participant Back as Backend
|
||||
participant Conn as onlyoffice<br>connector
|
||||
participant OO as Only Office<br>Edition Server
|
||||
end
|
||||
|
||||
alt Periodic scheduled task
|
||||
Conn->>OO: getForgottenList
|
||||
loop Each forgotten file
|
||||
Conn->>Back: Save new version<br>end editing session
|
||||
Conn->>OO: Delete forgotten file
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Example: OnlyOffice plugin
|
||||
|
||||
The [OnlyOffice connector plugin](https://github.com/linagora/twake-drive/tree/main/tdrive/connectors/onlyoffice-connector) is an example of plugin. It's readme includes an example configuration for the backend.
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,34 @@
|
||||
import yargs from "yargs";
|
||||
|
||||
import { NonPlatformCommandYargsBuilder } from "../../utils/non-plaform-command-yargs-builder";
|
||||
import { EditingSessionKeyFormat } from "../../../services/documents/entities/drive-file";
|
||||
|
||||
interface ParseArguments {
|
||||
editing_session_key: string;
|
||||
}
|
||||
|
||||
const command: yargs.CommandModule<unknown, unknown> = {
|
||||
command: "parse <editing_session_key>",
|
||||
describe: `
|
||||
Parse the provided editing_session_key and output json data (to stderr)
|
||||
`.trim(),
|
||||
|
||||
builder: {
|
||||
...NonPlatformCommandYargsBuilder,
|
||||
},
|
||||
handler: async argv => {
|
||||
const args = argv as unknown as ParseArguments;
|
||||
const parsed = EditingSessionKeyFormat.parse(decodeURIComponent("" + args.editing_session_key));
|
||||
console.error(
|
||||
JSON.stringify(
|
||||
{
|
||||
ageH: (new Date().getTime() - parsed.timestamp.getTime()) / (60 * 60 * 1000),
|
||||
...parsed,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -409,6 +409,40 @@ export default class UserApi {
|
||||
});
|
||||
}
|
||||
|
||||
async updateEditingDocument(
|
||||
editingSessionKey: string,
|
||||
keepEditing: boolean = false,
|
||||
userId: string | null = null,
|
||||
): Promise<Response> {
|
||||
const fullPath = `${__dirname}/assets/${UserApi.ALL_FILES[0]}`;
|
||||
const readable= Readable.from(fs.createReadStream(fullPath));
|
||||
const form = formAutoContent({ file: readable });
|
||||
form.headers["authorization"] = `Bearer ${this.jwt}`;
|
||||
let queryString = keepEditing ? "keepEditing=true" : "";
|
||||
if (userId)
|
||||
queryString += `${queryString.length ? "&" : ""}userId=${encodeURIComponent(userId)}`;
|
||||
return await this.platform.app.inject({
|
||||
method: "POST",
|
||||
url: `${UserApi.DOC_URL}/editing_session/${encodeURIComponent(editingSessionKey)}${queryString ? "?" : ""}${queryString}`,
|
||||
headers: {
|
||||
authorization: `Bearer ${this.jwt}`
|
||||
},
|
||||
...form,
|
||||
});
|
||||
}
|
||||
|
||||
async cancelEditingDocument(
|
||||
editingSessionKey: string,
|
||||
): Promise<Response> {
|
||||
return await this.platform.app.inject({
|
||||
method: "DELETE",
|
||||
url: `${UserApi.DOC_URL}/editing_session/${editingSessionKey}`,
|
||||
headers: {
|
||||
authorization: `Bearer ${this.jwt}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async beginEditingDocumentExpectOk(
|
||||
driveFileId: string,
|
||||
editorApplicationId: string,
|
||||
@@ -486,7 +520,7 @@ export default class UserApi {
|
||||
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)}`,
|
||||
url: `${UserApi.DOC_URL}/editing_session/${encodeURIComponent(editing_session_key)}`,
|
||||
headers: {
|
||||
authorization: `Bearer ${this.jwt}`
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, beforeAll, beforeEach, it, expect, afterAll } from "@jest/globals";
|
||||
import { describe, beforeAll, beforeEach, it, expect, afterAll, jest } from "@jest/globals";
|
||||
|
||||
import { init, TestPlatform } from "../setup";
|
||||
import UserApi from "../common/user-api";
|
||||
|
||||
import { DriveFile, TYPE as DriveFileType } from "../../../src/services/documents/entities/drive-file";
|
||||
import ApplicationsApiService, { ApplicationEditingKeyStatus } from "../../../src/services/applications-api";
|
||||
import { afterEach } from "node:test";
|
||||
import Application from "../../../src/services/applications/entities/application";
|
||||
|
||||
describe("the Drive's documents' editing session kind-of-lock", () => {
|
||||
let platform: TestPlatform | null;
|
||||
@@ -48,6 +51,12 @@ describe("the Drive's documents' editing session kind-of-lock", () => {
|
||||
parent_id: currentUserRoot,
|
||||
scope: "personal",
|
||||
});
|
||||
jest.spyOn(ApplicationsApiService.getDefault(), 'getApplicationConfig').mockImplementation((id) => id === "e2e_testing" ? {} as Application : undefined);
|
||||
jest.spyOn(ApplicationsApiService.getDefault(), 'checkPendingEditingStatus').mockImplementation(async () => ApplicationEditingKeyStatus.unknown);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("atomicCompareAndSet allows a single value at a time", async () => {
|
||||
@@ -110,8 +119,28 @@ describe("the Drive's documents' editing session kind-of-lock", () => {
|
||||
expect(temporaryDocument.id).toBe(foundDocumentResult.json().id);
|
||||
});
|
||||
|
||||
it('can end an editing session on a document only once with the right key', async () => {
|
||||
it('can cancel an editing session on a document only once with the right key', async () => {
|
||||
//given
|
||||
const editingSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing');
|
||||
|
||||
//when
|
||||
const response = await currentUser.cancelEditingDocument(editingSessionKey);
|
||||
//then
|
||||
expect(response.statusCode).toBe(200);
|
||||
const newSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing');
|
||||
expect(newSessionKey).not.toEqual(editingSessionKey);
|
||||
});
|
||||
|
||||
it('can end editing with a new version of document', async () => {
|
||||
//given
|
||||
const editingSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing');
|
||||
//when
|
||||
const response = await currentUser.updateEditingDocument(editingSessionKey);
|
||||
|
||||
//then
|
||||
expect(response.statusCode).toBe(200);
|
||||
const document = await currentUser.getDocumentOKCheck(temporaryDocument.id);
|
||||
expect(document.versions.length).toEqual(2);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
+36
@@ -105,6 +105,42 @@ describe('The PostgresQueryBuilder', () => {
|
||||
expect(query[1]).toEqual(options.$like.map(e=> `%${e[1]}%`));
|
||||
});
|
||||
|
||||
test('buildSelect query with IS NULL filter', async () => {
|
||||
//given
|
||||
const filters = { companu_id: null };
|
||||
|
||||
//when
|
||||
const query = subj.buildSelect(TestDbEntity, filters, {});
|
||||
|
||||
//then
|
||||
expect(normalizeWhitespace(query[0] as string)).toBe(`SELECT * FROM \"test_table\" WHERE companu_id IS NULL ORDER BY id DESC LIMIT 100 OFFSET 0`);
|
||||
expect(query[1]).toEqual([]);
|
||||
});
|
||||
|
||||
test('buildSelect query with not equal filter', async () => {
|
||||
//given
|
||||
const filters = { company_id: { $ne: '123' } };
|
||||
|
||||
//when
|
||||
const query = subj.buildSelect(TestDbEntity, filters, {});
|
||||
|
||||
//then
|
||||
expect(normalizeWhitespace(query[0] as string)).toBe(`SELECT * FROM "test_table" WHERE company_id != $1 ORDER BY id DESC LIMIT 100 OFFSET 0`);
|
||||
expect(query[1]).toEqual([ '123' ]);
|
||||
});
|
||||
|
||||
test('buildSelect query with IS NOT NULL filter', async () => {
|
||||
//given
|
||||
const filters = { company_id: { $ne: null } };
|
||||
|
||||
//when
|
||||
const query = subj.buildSelect(TestDbEntity, filters, {});
|
||||
|
||||
//then
|
||||
expect(normalizeWhitespace(query[0] as string)).toBe(`SELECT * FROM "test_table" WHERE company_id IS NOT NULL ORDER BY id DESC LIMIT 100 OFFSET 0`);
|
||||
expect(query[1]).toEqual([]);
|
||||
});
|
||||
|
||||
test('buildDelete query', async () => {
|
||||
//given
|
||||
const entity = newTestDbEntity();
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import "reflect-metadata";
|
||||
import { expect, jest, test, describe, beforeEach, afterEach } from "@jest/globals";
|
||||
import { randomUUID } from "crypto";
|
||||
import { EditingSessionKeyFormat } from "../../../../../src/services/documents/entities/drive-file";
|
||||
|
||||
describe('DriveFile EditingSessionKeyFormat', () => {
|
||||
const mockAppId = 'tdrive_random_application_id';
|
||||
const mockCompanyId = randomUUID();
|
||||
const mockUserId = randomUUID();
|
||||
const mockInstanceId = "super-instance-id";
|
||||
const mockTimestamp = new Date();
|
||||
const mockTimestampWith0MS = mockTimestamp.getTime() - (mockTimestamp.getTime() % 1000);
|
||||
|
||||
const checkIsUUID = (value: string) => {
|
||||
expect(value).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
expect(value).not.toMatch(/^0{8}-0{4}-0{4}-0{4}-0{12}$/i);
|
||||
};
|
||||
|
||||
const checkKeyIsOOCompatible = (key: string) => {
|
||||
// OnlyOffice key limits: see https://api.onlyoffice.com/editors/config/document#key
|
||||
expect(key).toMatch(/^[0-9a-zA-Z._=-]{1,128}$/);
|
||||
};
|
||||
|
||||
test('generates a valid value that can be parsed', async () => {
|
||||
checkIsUUID(mockUserId);
|
||||
checkIsUUID(mockCompanyId);
|
||||
const key = EditingSessionKeyFormat.generate(mockAppId, mockInstanceId, mockCompanyId, mockUserId, mockTimestamp);
|
||||
checkKeyIsOOCompatible(key);
|
||||
const parsed = EditingSessionKeyFormat.parse(key);
|
||||
expect(parsed.applicationId).toBe(mockAppId);
|
||||
expect(parsed.instanceId).toBe(mockInstanceId);
|
||||
expect(parsed.userId).toBe(mockUserId);
|
||||
expect(parsed.companyId).toBe(mockCompanyId);
|
||||
expect(parsed.timestamp.getTime()).toBe(mockTimestampWith0MS);
|
||||
});
|
||||
|
||||
test('generates unique values', async () => {
|
||||
const key = EditingSessionKeyFormat.generate(mockAppId, mockInstanceId, mockCompanyId, mockUserId, mockTimestamp);
|
||||
const key2 = EditingSessionKeyFormat.generate(mockAppId, mockInstanceId, mockCompanyId, mockUserId, mockTimestamp);
|
||||
expect(key).not.toBe(key2);
|
||||
});
|
||||
|
||||
test('checks the appId', async () => {
|
||||
expect(() => {
|
||||
EditingSessionKeyFormat.generate('invalid app id !', mockInstanceId, mockCompanyId, mockUserId);
|
||||
}).toThrow('Invalid applicationId value');
|
||||
});
|
||||
|
||||
test('checks final length', async () => {
|
||||
expect(() => {
|
||||
const tooLongAppID = new Array(100).join('x');
|
||||
EditingSessionKeyFormat.generate(tooLongAppID, mockInstanceId, mockCompanyId, mockUserId);
|
||||
}).toThrow('Must be <128 chars,');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ Documentation of the [API used with OnlyOffice is here](https://api.onlyoffice.c
|
||||
- The inline configuration provides a read and a write URL to the DocsAPI editor, these point to the routes of `OnlyOfficeController`.
|
||||
- When all the clients have disconnected from the editing session on the OnlyOffice document editing service, the OnlyOffice server will call the `callbackUrl` of this connector.
|
||||
- The connector then downloads the new file from Only Office, and creates a new version in Twake Drive.
|
||||
- Periodically gets a list of forgotten files and updates the backend with them
|
||||
|
||||
## Configuration example
|
||||
|
||||
|
||||
@@ -1,65 +1,49 @@
|
||||
import path from 'path';
|
||||
import express from 'express';
|
||||
import { Routes } from '@interfaces/routes.interface';
|
||||
import { NODE_ENV } from '@config';
|
||||
import cors from 'cors';
|
||||
import { renderFile } from 'eta';
|
||||
import path from 'path';
|
||||
import errorMiddleware from './middlewares/error.middleware';
|
||||
import { SERVER_PORT, SERVER_PREFIX } from '@config';
|
||||
import logger from './lib/logger';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import apiService from './services/api.service';
|
||||
import onlyofficeService from './services/onlyoffice.service';
|
||||
|
||||
import { NODE_ENV, SERVER_BIND, SERVER_PORT } from '@config';
|
||||
import logger from './lib/logger';
|
||||
import errorMiddleware from './middlewares/error.middleware';
|
||||
import { mountRoutes } from './routes';
|
||||
|
||||
import forgottenProcessorService from './services/forgotten-processor.service';
|
||||
|
||||
class App {
|
||||
public app: express.Application;
|
||||
public env: string;
|
||||
public port: string | number;
|
||||
|
||||
constructor(routes: Routes[]) {
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.env = NODE_ENV;
|
||||
|
||||
this.initViews();
|
||||
this.initMiddlewares();
|
||||
this.initRoutes(routes);
|
||||
this.initRoutes();
|
||||
this.initErrorHandling();
|
||||
|
||||
forgottenProcessorService.makeSureItsLoaded();
|
||||
}
|
||||
|
||||
public listen = () => {
|
||||
this.app.listen(parseInt(SERVER_PORT, 10), '0.0.0.0', () => {
|
||||
logger.info(`🚀 App listening on port ${SERVER_PORT}`);
|
||||
const binding_host = SERVER_BIND || '0.0.0.0';
|
||||
this.app.listen(parseInt(SERVER_PORT, 10), binding_host, () => {
|
||||
logger.info(`🚀 App listening at http://${binding_host}:${SERVER_PORT}`);
|
||||
});
|
||||
};
|
||||
|
||||
public getServer = () => this.app;
|
||||
|
||||
private initRoutes = (routes: Routes[]) => {
|
||||
private initRoutes = () => {
|
||||
this.app.use((req, res, next) => {
|
||||
logger.info(`Received request: ${req.method} ${req.originalUrl} from ${req.header('user-agent')} (${req.ip})`);
|
||||
next();
|
||||
});
|
||||
|
||||
routes.forEach(route => {
|
||||
this.app.use(SERVER_PREFIX, route.router);
|
||||
});
|
||||
|
||||
this.app.get('/health', (_req, res) => {
|
||||
Promise.all([onlyofficeService.getLatestVersion(), apiService.hasToken()]).then(
|
||||
([version, twakeDriveToken]) => res.status(version && twakeDriveToken ? 200 : 500).send({ version, twakeDriveToken }),
|
||||
err => res.status(500).send(err),
|
||||
);
|
||||
});
|
||||
|
||||
this.app.use(
|
||||
SERVER_PREFIX.replace(/\/$/, '') + '/assets',
|
||||
(req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Headers', 'X-Requested-With');
|
||||
next();
|
||||
},
|
||||
express.static(path.join(__dirname, '../assets')),
|
||||
);
|
||||
mountRoutes(this.app);
|
||||
};
|
||||
|
||||
private initMiddlewares = () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ config({ path: `.env.${process.env.NODE_ENV || 'development'}.local` });
|
||||
export const {
|
||||
NODE_ENV,
|
||||
SERVER_PORT,
|
||||
SERVER_BIND,
|
||||
SECRET_KEY,
|
||||
CREDENTIALS_ENDPOINT,
|
||||
ONLY_OFFICE_SERVER,
|
||||
@@ -11,4 +12,14 @@ export const {
|
||||
CREDENTIALS_SECRET,
|
||||
SERVER_PREFIX,
|
||||
SERVER_ORIGIN,
|
||||
INSTANCE_ID,
|
||||
OOCONNECTOR_HEALTH_SECRET,
|
||||
} = process.env;
|
||||
|
||||
const secs = 1000,
|
||||
mins = 60 * secs;
|
||||
|
||||
export const twakeDriveTokenRefrehPeriodMS = 10 * mins;
|
||||
export const onlyOfficeForgottenFilesCheckPeriodMS = 10 * mins;
|
||||
export const onlyOfficeConnectivityCheckPeriodMS = 10 * mins;
|
||||
export const onlyOfficeCallbackTimeoutMS = 10 * secs;
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { Request, Response } from 'express';
|
||||
import logger from '@/lib/logger';
|
||||
import { createSingleProcessorLock } from '@/lib/single-processor-lock';
|
||||
import onlyofficeService, { Callback, CommandError, ErrorCode } from '@/services/onlyoffice.service';
|
||||
import driveService from '@/services/drive.service';
|
||||
import forgottenProcessorService from '@/services/forgotten-processor.service';
|
||||
import { registerHealthProvider } from '@/services/health-providers.service';
|
||||
|
||||
interface RequestQuery {
|
||||
editing_session_key: string;
|
||||
}
|
||||
interface RenameRequestBody {
|
||||
title: string;
|
||||
}
|
||||
const keyCheckLock = createSingleProcessorLock<[status: number, body: unknown]>();
|
||||
|
||||
registerHealthProvider({
|
||||
async getHealthData() {
|
||||
return { checks: { locks: keyCheckLock.getWorstStats() } };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* These routes are called by Twake Drive backend, for ex. before editing or retreiving a file,
|
||||
* if it has an editing_session_key still, get the status of that and force a resolution.
|
||||
*/
|
||||
export default class TwakeDriveBackendCallbackController {
|
||||
/**
|
||||
* Get status of an `editing_session_key` from OO, and return a URL to get the latest version,
|
||||
* or an object with no `url` property, in which case the key is not known as forgotten by OO and should
|
||||
* be considered lost after an admin alert.
|
||||
*
|
||||
* @returns
|
||||
* - `{ status: 'unknown' }`: the key isn't known and maybe used for a new session
|
||||
* - `{ status: 'updated' }`: the key needed updating but is now invalid
|
||||
* - `{ status: 'expired' }`: the key was already used in a finished session and can't be used again
|
||||
* - `{ status: 'live' }`: the key is valid and current and should be used again for the same file
|
||||
* - `{ error: number }`: there was an error retreiving the status of the key, http status `!= 200`
|
||||
*/
|
||||
public async checkSessionStatus(req: Request<RequestQuery>, res: Response): Promise<void> {
|
||||
const [status, body] = await keyCheckLock.runWithLock(req.params.editing_session_key, async () => {
|
||||
try {
|
||||
const forgottenURL = await onlyofficeService.getForgotten(req.params.editing_session_key);
|
||||
try {
|
||||
await forgottenProcessorService.processForgottenFile(req.params.editing_session_key, forgottenURL);
|
||||
} catch (error) {
|
||||
logger.error(`processForgottenFile failed`, { error });
|
||||
return [502, { error: -57650 }];
|
||||
}
|
||||
return [200, { status: 'updated' }];
|
||||
} catch (e) {
|
||||
if (!(e instanceof CommandError && e.errorCode == ErrorCode.KEY_MISSING_OR_DOC_NOT_FOUND)) {
|
||||
logger.error(`getForgotten failed`, { error: e });
|
||||
return [e instanceof CommandError ? 502 : 500, { error: -57651 }];
|
||||
}
|
||||
}
|
||||
const info = await onlyofficeService.getInfoAndWaitForCallbackUnsafe(req.params.editing_session_key);
|
||||
if (info.error === ErrorCode.KEY_MISSING_OR_DOC_NOT_FOUND) {
|
||||
// just start using it
|
||||
return [200, { status: 'unknown' }];
|
||||
}
|
||||
if (info.error !== undefined) {
|
||||
logger.error(`getInfo failed`, { error: info });
|
||||
return [502, { error: -57652 }];
|
||||
}
|
||||
switch (info.result.status) {
|
||||
case Callback.Status.BEING_EDITED:
|
||||
case Callback.Status.BEING_EDITED_BUT_IS_SAVED:
|
||||
// use it as is
|
||||
return [200, { status: 'live' }];
|
||||
|
||||
case Callback.Status.CLOSED_WITHOUT_CHANGES:
|
||||
// just cancel it
|
||||
return [200, { status: 'expired' }];
|
||||
|
||||
case Callback.Status.ERROR_FORCE_SAVING:
|
||||
case Callback.Status.ERROR_SAVING:
|
||||
return [502, { error: info.result.status }];
|
||||
|
||||
case Callback.Status.READY_FOR_SAVING:
|
||||
// upload it, have to do it here for correct user stored in url in OO
|
||||
await driveService.endEditing(req.params.editing_session_key, info.result.url);
|
||||
return [200, { status: 'updated' }];
|
||||
|
||||
default:
|
||||
throw new Error(`Unexpected callback status: ${JSON.stringify(info.result)}`);
|
||||
}
|
||||
});
|
||||
await res.status(status).send(body);
|
||||
}
|
||||
|
||||
public async updateSessionFilename(req: Request<RequestQuery, {}, RenameRequestBody>, res: Response): Promise<void> {
|
||||
try {
|
||||
await onlyofficeService.meta(req.params.editing_session_key, req.body.title);
|
||||
res.send({ ok: 1 });
|
||||
} catch (err) {
|
||||
res.status(500).send({ error: -58650 });
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-11
@@ -1,13 +1,13 @@
|
||||
import editorService from '@/services/editor.service';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { CREDENTIALS_SECRET, SERVER_ORIGIN, SERVER_PREFIX } from '@config';
|
||||
import { CREDENTIALS_SECRET } from '@config';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import driveService from '@/services/drive.service';
|
||||
import { DriveFileType } from '@/interfaces/drive.interface';
|
||||
import fileService from '@/services/file.service';
|
||||
import { OfficeToken } from '@/interfaces/routes.interface';
|
||||
import { OfficeToken } from '@/interfaces/office-token.interface';
|
||||
import logger from '@/lib/logger';
|
||||
import * as Utils from '@/utils';
|
||||
import { makeURLTo } from '@/routes';
|
||||
|
||||
interface RequestQuery {
|
||||
mode: string;
|
||||
@@ -22,6 +22,7 @@ interface RequestEditorQuery {
|
||||
office_token: string;
|
||||
company_id: string;
|
||||
file_id: string;
|
||||
drive_file_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +30,7 @@ interface RequestEditorQuery {
|
||||
* The user is redirected from there to open directly the OnlyOffice edition server's web UI, with appropriate preview or not
|
||||
* and rights checks.
|
||||
*/
|
||||
class IndexController {
|
||||
class BrowserEditorController {
|
||||
/**
|
||||
* Opened by the user's browser, proxied through the Twake Drive backend. Checks access to the
|
||||
* file with the backend, then redirects the user to the `editor` method but directly on this
|
||||
@@ -74,25 +75,36 @@ class IndexController {
|
||||
throw new Error('You do not have access to this file');
|
||||
}
|
||||
|
||||
let editingSessionKey = null;
|
||||
if (!preview) {
|
||||
editingSessionKey = await driveService.beginEditingSession(company_id, drive_file_id, token);
|
||||
//TODO catch error and display to the user when we can't stopped editing
|
||||
|
||||
//TODO Log error with format to be able to set up grafana alert fir such king of errors
|
||||
}
|
||||
|
||||
const officeToken = jwt.sign(
|
||||
{
|
||||
user_id: user.id, //To verify that link is opened by the same user
|
||||
company_id,
|
||||
drive_file_id,
|
||||
editing_session_key: editingSessionKey,
|
||||
file_id: file.id,
|
||||
file_name: file.filename || file?.metadata?.name || '',
|
||||
file_name: driveFile?.item?.name || file.filename || file.metadata?.name || '',
|
||||
preview: !!preview,
|
||||
} as OfficeToken,
|
||||
CREDENTIALS_SECRET,
|
||||
{
|
||||
//one month, never expiring token
|
||||
expiresIn: 60 * 60 * 24 * 30,
|
||||
},
|
||||
);
|
||||
|
||||
res.redirect(
|
||||
Utils.joinURL([SERVER_ORIGIN ?? '', SERVER_PREFIX, 'editor'], {
|
||||
makeURLTo.editorAbsolute({
|
||||
token,
|
||||
file_id,
|
||||
drive_file_id,
|
||||
editing_session_key: editingSessionKey,
|
||||
company_id,
|
||||
preview,
|
||||
office_token: officeToken,
|
||||
@@ -112,13 +124,16 @@ class IndexController {
|
||||
const { user } = req;
|
||||
|
||||
const officeTokenPayload = jwt.verify(office_token, CREDENTIALS_SECRET) as OfficeToken;
|
||||
const { preview, user_id, company_id, file_name, file_id, drive_file_id } = officeTokenPayload;
|
||||
const { preview, user_id, company_id, file_name, file_id, drive_file_id, editing_session_key } = officeTokenPayload;
|
||||
|
||||
if (user_id !== user.id) {
|
||||
throw new Error('You do not have access to this link');
|
||||
}
|
||||
if (!preview && !editing_session_key) {
|
||||
throw new Error('Cant start editing without "editing session key"');
|
||||
}
|
||||
|
||||
const initResponse = await editorService.init(company_id, file_name, file_id, user, preview, drive_file_id || file_id);
|
||||
const initResponse = await editorService.init(company_id, file_name, file_id, user, preview, drive_file_id);
|
||||
|
||||
const inPageToken = jwt.sign(
|
||||
{
|
||||
@@ -130,7 +145,8 @@ class IndexController {
|
||||
|
||||
res.render('index', {
|
||||
...initResponse,
|
||||
server: Utils.joinURL([SERVER_ORIGIN, SERVER_PREFIX]),
|
||||
docId: preview ? file_id : editing_session_key,
|
||||
server: makeURLTo.rootAbsolute(),
|
||||
token: inPageToken,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -140,4 +156,4 @@ class IndexController {
|
||||
};
|
||||
}
|
||||
|
||||
export default IndexController;
|
||||
export default BrowserEditorController;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getProviders } from '@/services/health-providers.service';
|
||||
import { OOCONNECTOR_HEALTH_SECRET } from '@/config';
|
||||
|
||||
/**
|
||||
* Health response for devops operational purposes. Should not be exposed.
|
||||
*/
|
||||
export const HealthStatusController = {
|
||||
async get(req: Request<{}, {}, {}, { secret: string }>, res: Response): Promise<void> {
|
||||
if (req.query.secret !== OOCONNECTOR_HEALTH_SECRET) return void res.status(404).send(`Cannot GET ${req.path}`);
|
||||
const entries = await Promise.all(getProviders().map(p => p.getHealthData()));
|
||||
let result = {};
|
||||
entries.forEach(entry => (result = { ...result, ...entry }));
|
||||
res.send(result);
|
||||
},
|
||||
};
|
||||
@@ -1,18 +1,24 @@
|
||||
import { CREDENTIALS_SECRET } from '@/config';
|
||||
import { OfficeToken } from '@/interfaces/routes.interface';
|
||||
import { OfficeToken } from '@/interfaces/office-token.interface';
|
||||
import driveService from '@/services/drive.service';
|
||||
import fileService from '@/services/file.service';
|
||||
import logger from '@/lib/logger';
|
||||
import * as OnlyOffice from '@/services/onlyoffice.service';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import * as Utils from '@/utils';
|
||||
|
||||
interface RequestQuery {
|
||||
company_id: string;
|
||||
file_id: string;
|
||||
drive_file_id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface RenameRequestBody {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** These expose a OnlyOffice document storage service methods, called by the OnlyOffice document editing service
|
||||
* to load and save files
|
||||
*/
|
||||
@@ -28,12 +34,12 @@ class OnlyOfficeController {
|
||||
const { token } = req.query;
|
||||
|
||||
const officeTokenPayload = jwt.verify(token, CREDENTIALS_SECRET) as OfficeToken;
|
||||
const { company_id, drive_file_id, file_id, in_page_token } = officeTokenPayload;
|
||||
let fileId = file_id;
|
||||
const { company_id, file_id, drive_file_id, in_page_token } = officeTokenPayload;
|
||||
|
||||
// check token is an in_page_token
|
||||
if (!in_page_token) throw new Error('Invalid token, must be a in_page_token');
|
||||
|
||||
let fileId = file_id;
|
||||
if (drive_file_id) {
|
||||
//Get the drive file
|
||||
const driveFile = await driveService.get({
|
||||
@@ -45,6 +51,7 @@ class OnlyOfficeController {
|
||||
}
|
||||
}
|
||||
|
||||
if (!file_id) throw new Error(`File id is missing in the last version cache for ${JSON.stringify(file_id)}`);
|
||||
const file = await fileService.download({
|
||||
company_id,
|
||||
file_id: fileId,
|
||||
@@ -74,57 +81,91 @@ class OnlyOfficeController {
|
||||
try {
|
||||
const { url, key } = req.body;
|
||||
const { token } = req.query;
|
||||
logger.info('Save request', { key, url, token });
|
||||
|
||||
logger.info(
|
||||
`OO callback status: ${Utils.getKeyForValueSafe(req.body.status, OnlyOffice.Callback.Status, 'Callback.Status')} - ${JSON.stringify(
|
||||
req.body.userdata,
|
||||
)}`,
|
||||
req.body,
|
||||
);
|
||||
const officeTokenPayload = jwt.verify(token, CREDENTIALS_SECRET) as OfficeToken;
|
||||
const { preview, company_id, file_id, /* user_id, */ drive_file_id, in_page_token } = officeTokenPayload;
|
||||
const { preview, user_id, /* company_id, file_id, drive_file_id, */ in_page_token /* editing_session_key */ } = officeTokenPayload;
|
||||
|
||||
// check token is an in_page_token and allow save
|
||||
if (!in_page_token) throw new Error('Invalid token, must be a in_page_token');
|
||||
if (preview) throw new Error('Invalid token, must not be a preview token for save operation');
|
||||
if (!in_page_token) throw new Error('OO Callback invalid token, must be a in_page_token');
|
||||
if (preview) throw new Error('OO Callback invalid token, must not be a preview token for save operation');
|
||||
|
||||
switch (req.body.status) {
|
||||
case OnlyOffice.Callback.Status.BEING_EDITED:
|
||||
case OnlyOffice.Callback.Status.BEING_EDITED_BUT_IS_SAVED:
|
||||
// TODO this call back we recieve almost all the time, and here we save
|
||||
// the user identifiers who start file editing and even control the amount of onlin users
|
||||
// to have license constraint warning before OnlyOffice error about this
|
||||
// No-op
|
||||
break;
|
||||
|
||||
case OnlyOffice.Callback.Status.READY_FOR_SAVING:
|
||||
const newVersionFile = await fileService.save({
|
||||
company_id,
|
||||
file_id,
|
||||
url,
|
||||
create_new: true,
|
||||
});
|
||||
case OnlyOffice.Callback.Status.BEING_EDITED_BUT_IS_SAVED:
|
||||
logger.info(`OO Callback force save for session ${key} for reason: ${OnlyOffice.Callback.ForceSaveTypeToString(req.body.forcesavetype)}`);
|
||||
await driveService.addEditingSessionVersion(key, url, user_id);
|
||||
break;
|
||||
|
||||
const version = await driveService.createVersion({
|
||||
company_id,
|
||||
drive_file_id,
|
||||
file_id: newVersionFile?.resource?.id,
|
||||
});
|
||||
logger.info('New version created', version);
|
||||
return respondToOO();
|
||||
case OnlyOffice.Callback.Status.READY_FOR_SAVING:
|
||||
logger.info(`OO Callback new version for session ${key} created`);
|
||||
await driveService.endEditing(key, url, user_id);
|
||||
break;
|
||||
|
||||
case OnlyOffice.Callback.Status.CLOSED_WITHOUT_CHANGES:
|
||||
// Save end of transaction
|
||||
await driveService.cancelEditing(key);
|
||||
break;
|
||||
|
||||
case OnlyOffice.Callback.Status.ERROR_SAVING:
|
||||
// Save end of transaction
|
||||
logger.info(`Error saving file ${req.body.url} (key: ${req.body.key})`);
|
||||
// TODO: Save end of transaction ?
|
||||
logger.error(`OO Callback with Status.ERROR_SAVING: ${req.body.url} (key: ${req.body.key})`);
|
||||
break;
|
||||
|
||||
case OnlyOffice.Callback.Status.ERROR_FORCE_SAVING:
|
||||
// TODO: notify user ?
|
||||
logger.info(`Error force saving (reason: ${req.body.forcesavetype}) file ${req.body.url} (key: ${req.body.key})`);
|
||||
return void res.send({ error: 0 });
|
||||
logger.error(
|
||||
`OO Callback with Status.ERROR_FORCE_SAVING (reason: ${OnlyOffice.Callback.ForceSaveTypeToString(req.body.forcesavetype)}) file ${
|
||||
req.body.url
|
||||
} (key: ${req.body.key})`,
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unexpected OO Callback status field: ${req.body.status}`);
|
||||
throw new Error(
|
||||
`OO Callback unexpected status field: ${OnlyOffice.Callback.StatusToString(req.body.status)} in ${JSON.stringify(req.body)}`,
|
||||
);
|
||||
}
|
||||
return respondToOO();
|
||||
|
||||
// Ignore errors generated by pending request
|
||||
// try-catch not needed because it is async
|
||||
// there may be later reasons to wait for callbacks
|
||||
// to process and eventually respond accordingly to
|
||||
// OO an error for certain statuses
|
||||
void OnlyOffice.default.ooCallbackCalled(req.body); // has to be single thread per key
|
||||
|
||||
return respondToOO(0);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
logger.error(`OO Callback root error`, { error });
|
||||
next(error || 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/** This route is called directly by the inline JS in the editor page, called by the client-side OO editor component */
|
||||
public rename = async (req: Request<{}, {}, RenameRequestBody, RequestQuery>, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const { token } = req.query;
|
||||
const officeTokenPayload = jwt.verify(token, CREDENTIALS_SECRET) as OfficeToken;
|
||||
const { company_id, drive_file_id } = officeTokenPayload;
|
||||
const { name } = req.body;
|
||||
|
||||
if (!drive_file_id) throw new Error('OO Rename request missing drive_file_id');
|
||||
if (!name) throw new Error('OO Rename request missing name');
|
||||
|
||||
const result = await driveService.update({ company_id, drive_file_id, changes: { name } });
|
||||
res.send(result);
|
||||
} catch (error) {
|
||||
logger.error(`OO Rename request root error`, { error });
|
||||
next(error || 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export type DriveFileType = {
|
||||
access: 'manage' | 'write' | 'read' | 'none';
|
||||
item: {
|
||||
name: string;
|
||||
last_version_cache: {
|
||||
id: string;
|
||||
date_added: number;
|
||||
file_metadata: {
|
||||
external_id: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -19,4 +21,7 @@ export type DriveRequestParams = {
|
||||
export interface IDriveService {
|
||||
get: (params: DriveRequestParams) => Promise<DriveFileType>;
|
||||
createVersion: (params: { company_id: string; drive_file_id: string; file_id: string }) => Promise<DriveFileType['item']['last_version_cache']>;
|
||||
beginEditingSession: (company_id: string, drive_file_id: string) => Promise<string>;
|
||||
addEditingSessionVersion: (editing_session_key: string, url: string) => Promise<void>;
|
||||
endEditing: (editing_session_key: string, url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export type EditConfigInitResult = {
|
||||
onlyoffice_server: string;
|
||||
color: string;
|
||||
company_id: string;
|
||||
file_id: string;
|
||||
drive_file_id: string;
|
||||
file_version_id: string;
|
||||
filename: string;
|
||||
file_type: string;
|
||||
@@ -33,6 +33,7 @@ export interface IEditorService {
|
||||
user: UserType,
|
||||
preview: boolean,
|
||||
file_id: string,
|
||||
drive_file_id: string,
|
||||
) => Promise<EditConfigInitResult>;
|
||||
}
|
||||
|
||||
|
||||
+1
-7
@@ -1,16 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
export interface Routes {
|
||||
path?: string;
|
||||
router: Router;
|
||||
}
|
||||
|
||||
export interface OfficeToken {
|
||||
user_id: string;
|
||||
company_id: string;
|
||||
file_id: string;
|
||||
file_name: string;
|
||||
preview: boolean;
|
||||
editing_session_key: string;
|
||||
drive_file_id?: string;
|
||||
in_page_token?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import logger from './logger';
|
||||
|
||||
export type PendingRequestCallback<TResult> = (timeout: boolean, result?: TResult) => Promise<void>;
|
||||
|
||||
/** Represents a single pending query that can be resolved with success or failure */
|
||||
class PendingRequest<TResult> {
|
||||
private readonly startedAt = new Date();
|
||||
private callbacks?: PendingRequestCallback<TResult>[] = [];
|
||||
|
||||
constructor(public readonly key: string, public readonly userdata: string, callback: PendingRequestCallback<TResult>) {
|
||||
this.addCallback(callback);
|
||||
}
|
||||
|
||||
public addCallback(callback: PendingRequestCallback<TResult>) {
|
||||
if (!this.callbacks) throw new Error("Cannot add callback to PendingRequest after it's been resolved");
|
||||
this.callbacks.push(callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
public getAge() {
|
||||
return new Date().getTime() - this.startedAt.getTime();
|
||||
}
|
||||
|
||||
public matches(key: string, userdata: string) {
|
||||
return this.key === key && this.userdata === userdata;
|
||||
}
|
||||
|
||||
public async resolve(...cbArgs: Parameters<PendingRequestCallback<TResult>>) {
|
||||
if (!this.callbacks) throw new Error("Cannot resolve PendingRequest after it's already been resolved");
|
||||
const callbacks = this.callbacks;
|
||||
this.callbacks = null;
|
||||
return Promise.all(callbacks.map(fn => fn(...cbArgs)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a list of pending requests that are started with a pair
|
||||
* of `key` and `userdata` strings. (Note. `userdata` is not the
|
||||
* typical use of user data as a `void *`, but instead used to
|
||||
* identify the specific request asynchroneously).
|
||||
* Both must match.
|
||||
*
|
||||
* There is no timing garantee as to when expired requests' callbacks
|
||||
* are executed.
|
||||
*/
|
||||
export class PendingRequestQueue<TResult> {
|
||||
private queue: PendingRequest<TResult>[] = [];
|
||||
|
||||
constructor(private readonly timeoutMs: number, niquystSamplingRatio = 4) {
|
||||
setInterval(() => {
|
||||
this.flush();
|
||||
}, timeoutMs / niquystSamplingRatio);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback to be called when `gotResult` or `cancelPending` are
|
||||
* called with identical `key` and `userdata`
|
||||
* @param key First half of identifying string
|
||||
* @param userdata Second half of identifying string
|
||||
* @param callback When the request is resolved, succesfully or in error
|
||||
*/
|
||||
public enqueue(key: string, userdata: string, callback: PendingRequestCallback<TResult>) {
|
||||
const existing = this.queue.find(pending => pending.key === key && pending.userdata === userdata);
|
||||
if (existing) return void existing.addCallback(callback);
|
||||
this.queue.push(new PendingRequest(key, userdata, callback));
|
||||
}
|
||||
|
||||
private remove(predicate: (request: PendingRequest<TResult>) => boolean): PendingRequest<TResult>[] {
|
||||
// Warning: This operation must be synchroneous
|
||||
const foundRequests = [];
|
||||
this.queue = this.queue.filter(pending => {
|
||||
if (!predicate(pending)) return true;
|
||||
foundRequests.push(pending);
|
||||
return false;
|
||||
});
|
||||
return foundRequests;
|
||||
}
|
||||
|
||||
private async resolve(predicate: (request: PendingRequest<TResult>) => boolean, ...cbArgs: Parameters<PendingRequestCallback<TResult>>) {
|
||||
const foundRequests = this.remove(predicate);
|
||||
if (!foundRequests.length) return null;
|
||||
return Promise.all(foundRequests.map(request => request.resolve(...cbArgs)));
|
||||
}
|
||||
|
||||
protected async flush() {
|
||||
const expiredRequests = this.remove(request => request.getAge() > this.timeoutMs);
|
||||
return Promise.all(expiredRequests.map(request => request.resolve(true)));
|
||||
}
|
||||
|
||||
public async gotResult(key: string, userdata: string, result?: TResult) {
|
||||
const resolutions = this.resolve(request => request.matches(key, userdata), false, result);
|
||||
if (!resolutions)
|
||||
logger.error(`Got resolution on pending request that was unknown`, {
|
||||
key,
|
||||
userdata,
|
||||
result,
|
||||
});
|
||||
await this.flush();
|
||||
return resolutions;
|
||||
}
|
||||
|
||||
/** Equivalent to `gotResult` with an `undefined` result. (Callbacks are called) */
|
||||
public async cancelPending(key: string, userdata: string) {
|
||||
return this.gotResult(key, userdata, undefined);
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@ export class PolledThingieValue<ValueType> {
|
||||
private readonly getTheThingieValue: () => Promise<NotUndefined<ValueType>>,
|
||||
private readonly intervalMs: number,
|
||||
) {
|
||||
this.run();
|
||||
setInterval(() => this.run(), this.intervalMs);
|
||||
this.runIgnoringRejection();
|
||||
setInterval(() => this.runIgnoringRejection(), this.intervalMs);
|
||||
}
|
||||
|
||||
protected setResult(value: undefined, error?: NotUndefined<ValueType>, ts?: number);
|
||||
@@ -52,6 +52,12 @@ export class PolledThingieValue<ValueType> {
|
||||
}));
|
||||
}
|
||||
|
||||
private runIgnoringRejection() {
|
||||
return this.run().catch(() => {
|
||||
/* active ignoring going on here */
|
||||
});
|
||||
}
|
||||
|
||||
public lastFailed() {
|
||||
return !!this.lastKoTimeMs;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import logger from './logger';
|
||||
|
||||
export class CannotSettleAlreadyReleasedLockError extends Error {}
|
||||
|
||||
const debugLocks = false;
|
||||
|
||||
/** Simplifies use of the Promise constructor function out of its scope */
|
||||
class ExplodedPromise<T> {
|
||||
public readonly startAtMs = new Date().getTime();
|
||||
public readonly promise: Promise<T>;
|
||||
private _waiters = 0;
|
||||
private _resolve: (value: T) => void;
|
||||
private _reject: (reason?: any) => void;
|
||||
|
||||
constructor() {
|
||||
this.promise = new Promise((resolve, reject) => {
|
||||
// Works because this is garanteed to be called synchroneously
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
});
|
||||
}
|
||||
private check() {
|
||||
if (!this._resolve) throw new CannotSettleAlreadyReleasedLockError(`Promise is already settled`);
|
||||
}
|
||||
private finish() {
|
||||
this._resolve = this._reject = null;
|
||||
}
|
||||
/** Increase waiter count and return new count */
|
||||
public addWaiter() {
|
||||
return ++this._waiters;
|
||||
}
|
||||
get waiting() {
|
||||
return this._waiters;
|
||||
}
|
||||
public resolve(...args: Parameters<ExplodedPromise<T>['_resolve']>): void {
|
||||
this.check();
|
||||
this._resolve(...args);
|
||||
this.finish();
|
||||
}
|
||||
public reject(...args: Parameters<ExplodedPromise<T>['_reject']>): void {
|
||||
this.check();
|
||||
this._reject(...args);
|
||||
this.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this instance to release the lock acquired by {@link SingleProcessorLock.acquire}.
|
||||
* Only call either `resolve` or `reject` once or they will
|
||||
* throw a {@link CannotSettleAlreadyReleasedLockError}.
|
||||
*/
|
||||
export interface LockReleaser<T> {
|
||||
/** To distinguish return from {@link LockWaiter} */
|
||||
didAcquire: true;
|
||||
/** This is the same Promise that `resolve` and `reject` will settle, and waiters get */
|
||||
promise: LockWaiter<T>['promise'];
|
||||
/** Release the lock and resolve the promise of all the waiters */
|
||||
resolve: ExplodedPromise<T>['resolve'];
|
||||
/** Release the lock and reject the promise of all the waiters */
|
||||
reject: ExplodedPromise<T>['reject'];
|
||||
}
|
||||
|
||||
/** Use this instance to wait if {@link SingleProcessorLock.acquire} was already processing that key */
|
||||
export interface LockWaiter<T> {
|
||||
/** To distinguish return from {@link LockReleaser} */
|
||||
didAcquire: false;
|
||||
/** Number of waiters in the queue at the start of this one, including this one (ie. first to wait is `1`) */
|
||||
numberInQueue: number;
|
||||
/** Promise that must be waited for after the acquire failed. Will settle as per called by the {@see LockReleaser} */
|
||||
promise: Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an synchronisation primitive that permits a single caller
|
||||
* to process for a given `key`. Other callers with the same `key`
|
||||
* will receive an `Promise` that should be waited for. When the
|
||||
* processor caller releases the lock, the promise is settled accordingly.
|
||||
*/
|
||||
export function createSingleProcessorLock<T>() {
|
||||
const promisesByKey = new Map<string, ExplodedPromise<T>>();
|
||||
|
||||
/**
|
||||
* The first caller for the given `key` is the processor that must settle the lock. The
|
||||
* return value will be an instance of {@link LockReleaser} that can be tested because
|
||||
* {@link LockReleaser.didAcquire} will be `true`. This caller has the responsibility to
|
||||
* ensure a call to either {@link LockReleaser.resolve} or {@link LockReleaser.reject}.
|
||||
* There is no other cleanup mecanism.
|
||||
*
|
||||
* All callers following them for the same `key` will get an instance of {@link LockWaiter},
|
||||
* that can be tested because {@link LockWaiter.didAcquire} will be `false`. They must
|
||||
* `await` {@link LockWaiter.promise}. It will be settled by the processor when they call
|
||||
* the methods of {@link LockReleaser}. must be ensured, there is no cleanup mecanism.
|
||||
*
|
||||
* @param key Unique key to lock on
|
||||
*/
|
||||
const acquire = (key: string): LockReleaser<T> | LockWaiter<T> => {
|
||||
const existing = promisesByKey.get(key);
|
||||
if (existing) {
|
||||
const numberInQueue = existing.addWaiter();
|
||||
debugLocks && logger.debug(`LOCK blocked ${numberInQueue}: ${key}`);
|
||||
return {
|
||||
didAcquire: false,
|
||||
numberInQueue,
|
||||
promise: existing.promise,
|
||||
};
|
||||
}
|
||||
const processorPromise = new ExplodedPromise<T>();
|
||||
promisesByKey.set(key, processorPromise);
|
||||
debugLocks && logger.debug(`LOCK started: ${key}`);
|
||||
return {
|
||||
didAcquire: true,
|
||||
promise: processorPromise.promise,
|
||||
resolve(...args: Parameters<LockReleaser<T>['resolve']>) {
|
||||
debugLocks && logger.debug(`LOCK resolved: ${key}`);
|
||||
promisesByKey.delete(key);
|
||||
processorPromise.resolve(...args);
|
||||
},
|
||||
reject(...args: Parameters<LockReleaser<T>['reject']>) {
|
||||
debugLocks && logger.debug(`LOCK rejected: ${key}`);
|
||||
promisesByKey.delete(key);
|
||||
processorPromise.reject(...args);
|
||||
},
|
||||
};
|
||||
};
|
||||
return {
|
||||
acquire,
|
||||
|
||||
/**
|
||||
* Attempt to acquire a lock. If succesful, settle the promise with the
|
||||
* return value of `processor`. In both cases return the Promise.
|
||||
*/
|
||||
async runWithLock(key: string, processor: () => Promise<T>): Promise<T> {
|
||||
const lock = acquire(key);
|
||||
if (lock.didAcquire)
|
||||
try {
|
||||
lock.resolve(await processor());
|
||||
} catch (e) {
|
||||
lock.reject(e);
|
||||
}
|
||||
return lock.promise;
|
||||
},
|
||||
|
||||
/** Get statistics about pending locks */
|
||||
getWorstStats() {
|
||||
const all = [...promisesByKey.values()];
|
||||
const now = new Date().getTime();
|
||||
return all.reduce(
|
||||
({ oldestMs, waiting, total }, cur) => ({
|
||||
oldestMs: Math.max(oldestMs, now - cur.startAtMs),
|
||||
waiting: Math.max(waiting, cur.waiting),
|
||||
total: total + 1,
|
||||
}),
|
||||
{
|
||||
oldestMs: 0,
|
||||
waiting: 0,
|
||||
total: 0,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,21 @@ export default async (req: Request<{}, {}, {}, RequestQuery>, res: Response, nex
|
||||
return res.status(401).send({ message: 'invalid token' });
|
||||
}
|
||||
|
||||
const authHeaderToken = req.header('authorization');
|
||||
try {
|
||||
if (authHeaderToken) {
|
||||
const fromTwakeDriveToken = jwt.verify(authHeaderToken, CREDENTIALS_SECRET) as Record<string, string>;
|
||||
// The following constant comes from tdrive/backend/node/src/services/applications-api/index.ts
|
||||
// This is in the case when Twake Drive backend makes requests from the connector,
|
||||
// but not on the behalf of a specific user, eg. when checking stale only office keys
|
||||
if (fromTwakeDriveToken['type'] != 'tdriveToApplication') throw new Error(`Bad type in token ${JSON.stringify(fromTwakeDriveToken)}`);
|
||||
return next();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Invalid token in Authorization header from Twake Drive backend`, e);
|
||||
return res.status(401).json({ message: 'invalid token' });
|
||||
}
|
||||
|
||||
const user = await userService.getCurrentUser(token);
|
||||
|
||||
if (!user || !user.id) {
|
||||
|
||||
@@ -4,11 +4,14 @@ import { NextFunction, Request, Response } from 'express';
|
||||
export default (error: Error & { status?: number }, req: Request, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
const status: number = error.status || 500;
|
||||
const message: string = error.message || 'something went wrong';
|
||||
const message: string = error.message || 'Something went wrong';
|
||||
|
||||
logger.error(`[${req.method}] ${req.path} >> StatusCode:: ${status}, Message:: ${message}`, error.stack);
|
||||
|
||||
res.status(status).json({ message });
|
||||
res.status(status);
|
||||
res.render('error', {
|
||||
errorMessage: message,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import TwakeDriveBackendCallbackController from '@/controllers/backend-callbacks.controller';
|
||||
import authMiddleware from '@/middlewares/auth.middleware';
|
||||
import type { Router } from 'express';
|
||||
|
||||
/**
|
||||
* These routes are called by Twake Drive backend, for ex. before editing or retreiving a file,
|
||||
* if it has an editing_session_key still, get the status of that and force a resolution.
|
||||
*/
|
||||
export const TwakeDriveBackendCallbackRoutes = {
|
||||
mount(router: Router) {
|
||||
const controller = new TwakeDriveBackendCallbackController();
|
||||
// Why post ? to garantee it is never cached and always ran
|
||||
router.post('/session/:editing_session_key/check', authMiddleware, controller.checkSessionStatus.bind(controller));
|
||||
router.post('/session/:editing_session_key/title', authMiddleware, controller.updateSessionFilename.bind(controller));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import BrowserEditorController from '@/controllers/browser-editor.controller';
|
||||
import authMiddleware from '@/middlewares/auth.middleware';
|
||||
import requirementsMiddleware from '@/middlewares/requirements.middleware';
|
||||
import type { Router } from 'express';
|
||||
|
||||
/**
|
||||
* When the user previews or edits a file in Twake Drive, their browser is sent to these routes
|
||||
* which return a webpage that instantiates the client side JS Only Office component.
|
||||
*/
|
||||
export const BrowserEditorRoutes = {
|
||||
mount(router: Router) {
|
||||
const controller = new BrowserEditorController();
|
||||
router.get('/', requirementsMiddleware, authMiddleware, controller.index.bind(controller));
|
||||
router.get('/editor', requirementsMiddleware, authMiddleware, controller.editor.bind(controller));
|
||||
},
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import IndexController from '@/controllers/index.controller';
|
||||
import { Routes } from '@/interfaces/routes.interface';
|
||||
import authMiddleware from '@/middlewares/auth.middleware';
|
||||
import requirementsMiddleware from '@/middlewares/requirements.middleware';
|
||||
import { Router } from 'express';
|
||||
|
||||
/**
|
||||
* When the user previews or edits a file in Twake Drive, their browser is sent to these routes
|
||||
* which return a webpage that instantiates the client side JS Only Office component.
|
||||
*/
|
||||
class IndexRoute implements Routes {
|
||||
public path = '/';
|
||||
public router = Router();
|
||||
public indexController: IndexController;
|
||||
|
||||
constructor() {
|
||||
this.indexController = new IndexController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
private initRoutes = () => {
|
||||
this.router.get(this.path, requirementsMiddleware, authMiddleware, this.indexController.index);
|
||||
this.router.get(this.path + 'editor', requirementsMiddleware, authMiddleware, this.indexController.editor);
|
||||
};
|
||||
}
|
||||
|
||||
export default IndexRoute;
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as path from 'path';
|
||||
import { static as expressStatic, Application, Router } from 'express';
|
||||
|
||||
import * as Utils from '@/utils';
|
||||
import { SERVER_ORIGIN, SERVER_PREFIX } from '@config';
|
||||
|
||||
import { HealthStatusController } from '@/controllers/health-status.controller';
|
||||
import { TwakeDriveBackendCallbackRoutes } from './backend-callbacks.route';
|
||||
import { BrowserEditorRoutes } from './browser-editor.route';
|
||||
import { OnlyOfficeRoutes } from './onlyoffice.route';
|
||||
|
||||
function mountAssetsRoutes(router: Router) {
|
||||
router.use(
|
||||
'/assets',
|
||||
(req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Headers', 'X-Requested-With');
|
||||
next();
|
||||
},
|
||||
expressStatic(path.join(__dirname, '../../assets')),
|
||||
);
|
||||
}
|
||||
|
||||
export function mountRoutes(app: Application) {
|
||||
// Technical routes for ops. Should not be exposed.
|
||||
app.get('/health', HealthStatusController.get);
|
||||
|
||||
// These routes are forwarded through the Twake Drive front, back and here
|
||||
const proxiedRouter = Router();
|
||||
BrowserEditorRoutes.mount(proxiedRouter);
|
||||
OnlyOfficeRoutes.mount(proxiedRouter);
|
||||
mountAssetsRoutes(proxiedRouter);
|
||||
app.use(SERVER_PREFIX /* usually "plugins/onlyoffice" */, proxiedRouter);
|
||||
|
||||
// These endpoints should only be accessible to the Twake Drive backend
|
||||
const apiRouter = Router();
|
||||
TwakeDriveBackendCallbackRoutes.mount(apiRouter);
|
||||
app.use('/tdriveApi/1', apiRouter);
|
||||
}
|
||||
|
||||
export const makeURLTo = {
|
||||
rootAbsolute: () => Utils.joinURL([SERVER_ORIGIN, SERVER_PREFIX]),
|
||||
assets: () => Utils.joinURL([SERVER_PREFIX, 'assets']),
|
||||
editorAbsolute(params: {
|
||||
token: string;
|
||||
drive_file_id: string;
|
||||
file_id: string;
|
||||
editing_session_key: string;
|
||||
company_id: string;
|
||||
preview: string;
|
||||
office_token: string;
|
||||
}) {
|
||||
return Utils.joinURL([SERVER_ORIGIN ?? '', SERVER_PREFIX, 'editor'], params);
|
||||
},
|
||||
};
|
||||
@@ -1,22 +1,16 @@
|
||||
import OnlyOfficeController from '@/controllers/onlyoffice.controller';
|
||||
import { Routes } from '@/interfaces/routes.interface';
|
||||
import requirementsMiddleware from '@/middlewares/requirements.middleware';
|
||||
import { Router } from 'express';
|
||||
import type { Router } from 'express';
|
||||
|
||||
class OnlyOfficeRoute implements Routes {
|
||||
public path = '/';
|
||||
public router = Router();
|
||||
public onlyOfficeController: OnlyOfficeController;
|
||||
|
||||
constructor() {
|
||||
this.onlyOfficeController = new OnlyOfficeController();
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
private initRoutes = () => {
|
||||
this.router.get(`${this.path}:mode/read`, requirementsMiddleware, this.onlyOfficeController.read);
|
||||
this.router.post(`${this.path}:mode/save`, requirementsMiddleware, this.onlyOfficeController.ooCallback);
|
||||
};
|
||||
}
|
||||
|
||||
export default OnlyOfficeRoute;
|
||||
/**
|
||||
* These routes are called by the Only Office server
|
||||
* when reading a document or updating an editing session
|
||||
*/
|
||||
export const OnlyOfficeRoutes = {
|
||||
mount(router: Router) {
|
||||
const controller = new OnlyOfficeController();
|
||||
router.get(`/:mode/read`, requirementsMiddleware, controller.read.bind(controller));
|
||||
router.post(`/:mode/callback`, requirementsMiddleware, controller.ooCallback.bind(controller));
|
||||
router.post(`/:mode/rename`, requirementsMiddleware, controller.rename.bind(controller));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import App from '@/app';
|
||||
import IndexRoute from './routes/index.route';
|
||||
import OnlyOfficeRoute from './routes/onlyoffice.route';
|
||||
|
||||
const app = new App([new IndexRoute(), new OnlyOfficeRoute()]);
|
||||
const app = new App();
|
||||
|
||||
app.listen();
|
||||
|
||||
@@ -5,28 +5,34 @@ import {
|
||||
IApiServiceApplicationTokenResponse,
|
||||
} from '@/interfaces/api.interface';
|
||||
import axios, { Axios, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { CREDENTIALS_ENDPOINT, CREDENTIALS_ID, CREDENTIALS_SECRET } from '@config';
|
||||
import { CREDENTIALS_ENDPOINT, CREDENTIALS_ID, CREDENTIALS_SECRET, twakeDriveTokenRefrehPeriodMS } from '@config';
|
||||
import logger from '../lib/logger';
|
||||
import * as Utils from '@/utils';
|
||||
import { PolledThingieValue } from '@/lib/polled-thingie-value';
|
||||
import { IHealthProvider, registerHealthProvider } from './health-providers.service';
|
||||
|
||||
/**
|
||||
* Client for the Twake Drive backend API on behalf of the plugin (or provided token in parameters).
|
||||
* Periodically updates authorization and adds to requests.
|
||||
*/
|
||||
class ApiService implements IApiService {
|
||||
private readonly poller: PolledThingieValue<Axios>;
|
||||
class ApiService implements IApiService, IHealthProvider {
|
||||
private readonly tokenPoller: PolledThingieValue<Axios>;
|
||||
|
||||
constructor() {
|
||||
this.poller = new PolledThingieValue('Refresh Twake Drive token', async () => this.refreshToken(), 1000 * 60); //TODO: should be Every 10 minutes
|
||||
this.tokenPoller = new PolledThingieValue('Refresh Twake Drive token', async () => await this.refreshToken(), twakeDriveTokenRefrehPeriodMS);
|
||||
registerHealthProvider(this);
|
||||
}
|
||||
|
||||
public async hasToken() {
|
||||
return (await this.poller.latestValueWithTry()) !== undefined;
|
||||
return (await this.tokenPoller.latestValueWithTry()) !== undefined;
|
||||
}
|
||||
|
||||
async getHealthData() {
|
||||
return { TwakeDriveApi: { tokenAgeS: this.tokenPoller.latest()?.ageS ?? -1 } };
|
||||
}
|
||||
|
||||
private requireAxios() {
|
||||
return this.poller.requireLatestValueWithTry('Token Kind 538 not ready');
|
||||
return this.tokenPoller.requireLatestValueWithTry('No Twake Drive app token.');
|
||||
}
|
||||
|
||||
public get = async <T>(params: IApiServiceRequestParams<T>): Promise<T> => {
|
||||
@@ -49,20 +55,43 @@ class ApiService implements IApiService {
|
||||
return await axiosWithToken.get(url, config);
|
||||
};
|
||||
|
||||
public post = async <T, R>(params: IApiServiceRequestParams<T>): Promise<R> => {
|
||||
const { url, payload, headers } = params;
|
||||
public delete = async <T>(params: IApiServiceRequestParams<T>): Promise<T> => {
|
||||
const { url, token, responseType, headers } = params;
|
||||
|
||||
const axiosWithToken = await this.requireAxios();
|
||||
|
||||
const config: AxiosRequestConfig = {};
|
||||
|
||||
if (token) {
|
||||
config['headers'] = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...headers,
|
||||
};
|
||||
}
|
||||
|
||||
if (responseType) {
|
||||
config['responseType'] = responseType;
|
||||
}
|
||||
return await axiosWithToken.delete(url, config);
|
||||
};
|
||||
|
||||
public post = async <T, R>(params: IApiServiceRequestParams<T>): Promise<R> => {
|
||||
const { url, token, payload, headers } = params;
|
||||
|
||||
const axiosWithToken = await this.requireAxios();
|
||||
|
||||
logger.info(`POST to Twake Drive ${url} - payload: ${payload}`);
|
||||
try {
|
||||
return await axiosWithToken.post(url, payload, {
|
||||
headers: {
|
||||
...headers,
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to post to Twake drive: ', error.stack);
|
||||
logger.error('Failed to post to Twake Drive: ', { error: error.stack });
|
||||
this.refreshToken();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { DriveFileType, IDriveService } from '@/interfaces/drive.interface';
|
||||
import apiService from './api.service';
|
||||
import logger from '../lib/logger';
|
||||
import * as Utils from '../utils';
|
||||
import { Stream } from 'stream';
|
||||
import FormData from 'form-data';
|
||||
import { INSTANCE_ID } from '@/config';
|
||||
|
||||
/** Client for Twake Drive's APIs dealing with `DriveItem`s, using {@see apiService}
|
||||
/** Thrown when Twake Drive returns a 404 for a key */
|
||||
export class UnknownKeyInDriveError extends Error {}
|
||||
|
||||
const makeTwakeDriveApiUrl = (paths: string[], params?: Utils.QueryParams) => Utils.joinURL(['/internal/services/documents/v1', ...paths], params);
|
||||
const makeNonEditingSessionItemUrl = (company_id: string, drive_file_id: string, paths: string[] = [], params?: Utils.QueryParams) =>
|
||||
makeTwakeDriveApiUrl(['companies', company_id, 'item', drive_file_id, ...paths], params);
|
||||
const makeEditingSessionItemUrl = (editing_session: string, params?: Utils.QueryParams) =>
|
||||
makeTwakeDriveApiUrl(['editing_session', editing_session], params);
|
||||
|
||||
/**
|
||||
* Client for Twake Drive's APIs dealing with `DriveItem`s, using {@see apiService}
|
||||
* to handle authorization
|
||||
*/
|
||||
class DriveService implements IDriveService {
|
||||
@@ -10,7 +24,7 @@ class DriveService implements IDriveService {
|
||||
try {
|
||||
const { company_id, drive_file_id } = params;
|
||||
const resource = await apiService.get<DriveFileType>({
|
||||
url: `/internal/services/documents/v1/companies/${company_id}/item/${drive_file_id}`,
|
||||
url: makeNonEditingSessionItemUrl(company_id, drive_file_id),
|
||||
token: params.user_token,
|
||||
});
|
||||
|
||||
@@ -22,6 +36,28 @@ class DriveService implements IDriveService {
|
||||
}
|
||||
};
|
||||
|
||||
public update = async (params: {
|
||||
company_id: string;
|
||||
drive_file_id: string;
|
||||
changes: Partial<DriveFileType['item']>;
|
||||
completeResult?: boolean;
|
||||
}): Promise<(typeof params)['changes']> => {
|
||||
try {
|
||||
const { company_id, drive_file_id } = params;
|
||||
const resource = await apiService.post<(typeof params)['changes'], ReturnType<DriveService['update']>>({
|
||||
url: makeNonEditingSessionItemUrl(company_id, drive_file_id),
|
||||
payload: params.changes,
|
||||
});
|
||||
if (params.completeResult) return resource;
|
||||
const result = {};
|
||||
Object.keys(params.changes).forEach(k => (result[k] = resource[k]));
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update file metadata: ', error.stack);
|
||||
return Promise.reject();
|
||||
}
|
||||
};
|
||||
|
||||
public createVersion = async (params: {
|
||||
company_id: string;
|
||||
drive_file_id: string;
|
||||
@@ -29,8 +65,8 @@ class DriveService implements IDriveService {
|
||||
}): Promise<DriveFileType['item']['last_version_cache']> => {
|
||||
try {
|
||||
const { company_id, drive_file_id, file_id } = params;
|
||||
const resource = await apiService.post<{}, DriveFileType['item']['last_version_cache']>({
|
||||
url: `/internal/services/documents/v1/companies/${company_id}/item/${drive_file_id}/version`,
|
||||
return await apiService.post<{}, DriveFileType['item']['last_version_cache']>({
|
||||
url: makeNonEditingSessionItemUrl(company_id, drive_file_id, ['version']),
|
||||
payload: {
|
||||
drive_item_id: drive_file_id,
|
||||
provider: 'internal',
|
||||
@@ -40,13 +76,90 @@ class DriveService implements IDriveService {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return resource;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create version: ', error.stack);
|
||||
return Promise.reject();
|
||||
}
|
||||
};
|
||||
|
||||
public async beginEditingSession(company_id: string, drive_file_id: string, user_token?: string) {
|
||||
try {
|
||||
const resource = await apiService.post<{}, { editingSessionKey: string }>({
|
||||
url: makeNonEditingSessionItemUrl(company_id, drive_file_id, ['editing_session']),
|
||||
token: user_token,
|
||||
payload: {
|
||||
editorApplicationId: 'tdrive_onlyoffice',
|
||||
appInstanceId: INSTANCE_ID ?? '',
|
||||
},
|
||||
});
|
||||
if (resource?.editingSessionKey) {
|
||||
return resource.editingSessionKey;
|
||||
} else {
|
||||
throw new Error(`Failed to obtain editing session key, response: ${JSON.stringify(resource)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to begin editing session: ', error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async cancelEditing(editing_session_key: string) {
|
||||
try {
|
||||
await apiService.delete<{}>({
|
||||
url: makeEditingSessionItemUrl(editing_session_key),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to begin editing session: ', error.stack);
|
||||
throw error;
|
||||
//TODO make monitoring for such kind of errors
|
||||
}
|
||||
}
|
||||
|
||||
public async addEditingSessionVersion(editing_session_key: string, url: string, userId?: string) {
|
||||
return this.updateEditing(editing_session_key, url, true, userId);
|
||||
}
|
||||
|
||||
public async endEditing(editing_session_key: string, url: string, userId?: string) {
|
||||
return this.updateEditing(editing_session_key, url, false, userId);
|
||||
}
|
||||
|
||||
private async updateEditing(editing_session_key: string, url: string, keepEditing: boolean, userId?: string) {
|
||||
try {
|
||||
if (!url) {
|
||||
throw Error('no url found');
|
||||
}
|
||||
|
||||
const newFile = await apiService.get<Stream>({
|
||||
url,
|
||||
responseType: 'stream',
|
||||
});
|
||||
|
||||
const form = new FormData();
|
||||
|
||||
form.append('file', newFile);
|
||||
|
||||
logger.info(`Saving file version to Twake Drive`, { editing_session_key, url });
|
||||
|
||||
await apiService.post({
|
||||
url: makeEditingSessionItemUrl(editing_session_key, {
|
||||
keepEditing: keepEditing ? 'true' : null,
|
||||
userId,
|
||||
}),
|
||||
payload: form,
|
||||
headers: form.getHeaders(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
logger.error('Forgotten OO document that Twake Drive doesnt know the key of.', { editing_session_key, url });
|
||||
throw new UnknownKeyInDriveError(`Unknown key ${JSON.stringify(editing_session_key)}`);
|
||||
//TODO: Distinguish this case from a long disconnected browser tab waking up
|
||||
//TODO make monitoring for such kind of errors
|
||||
} else {
|
||||
logger.error('Failed to end editing session: ', error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new DriveService();
|
||||
|
||||
@@ -10,7 +10,7 @@ class EditorService implements IEditorService {
|
||||
file_version_id: string,
|
||||
user: UserType,
|
||||
preview: boolean,
|
||||
file_id: string,
|
||||
drive_file_id: string,
|
||||
): Promise<EditConfigInitResult> => {
|
||||
const { color, mode: fileMode } = this.getFileMode(file_name);
|
||||
let [, extension] = Utils.splitFilename(file_name);
|
||||
@@ -18,7 +18,7 @@ class EditorService implements IEditorService {
|
||||
extension = extension.toLocaleLowerCase();
|
||||
return {
|
||||
color,
|
||||
file_id,
|
||||
drive_file_id,
|
||||
file_version_id,
|
||||
file_type: extension,
|
||||
filename: file_name,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import logger from '@/lib/logger';
|
||||
import { onlyOfficeForgottenFilesCheckPeriodMS } from '@/config';
|
||||
import { PolledThingieValue } from '@/lib/polled-thingie-value';
|
||||
import { createSingleProcessorLock } from '@/lib/single-processor-lock';
|
||||
|
||||
import apiService from './api.service';
|
||||
import onlyofficeService from './onlyoffice.service';
|
||||
import driveService, { UnknownKeyInDriveError } from './drive.service';
|
||||
import { IHealthProvider, registerHealthProvider } from './health-providers.service';
|
||||
|
||||
/**
|
||||
* Periodically poll the Only Office document server for forgotten
|
||||
* files and try to upload to Twake Drive or get rid if unknown.
|
||||
*/
|
||||
class ForgottenProcessor implements IHealthProvider {
|
||||
private readonly forgottenFilesPoller: PolledThingieValue<number>;
|
||||
public readonly forgottenSynchroniser = createSingleProcessorLock<boolean>();
|
||||
private lastStart = 0;
|
||||
|
||||
constructor() {
|
||||
let skippedFirst = false;
|
||||
this.forgottenFilesPoller = new PolledThingieValue(
|
||||
'Process forgotten files in OO',
|
||||
async () => (skippedFirst ? await this.processForgottenFiles() : ((skippedFirst = true), -1)),
|
||||
onlyOfficeForgottenFilesCheckPeriodMS,
|
||||
);
|
||||
registerHealthProvider(this);
|
||||
}
|
||||
|
||||
public async getHealthData() {
|
||||
const keys = await onlyofficeService.getForgottenList();
|
||||
return {
|
||||
forgotten: {
|
||||
timeSinceLastStartS: this.lastStart ? ~~((new Date().getTime() - this.lastStart) / 1000) : -1,
|
||||
count: keys?.length ?? 0,
|
||||
locks: this.forgottenSynchroniser.getWorstStats(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of this is to ensure this file is imported,
|
||||
* which is needed for side-effect of starting this timer.
|
||||
* The only other use being the health stuff it could easily
|
||||
* be refactored out as unused.
|
||||
*/
|
||||
public makeSureItsLoaded() {
|
||||
return 'yup this module is loaded !';
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to upload the forgotten file, optionally delete it from OO, will return if it was
|
||||
* (or should be) deleted. Does not throw unless the OO deletion itself threw.
|
||||
*/
|
||||
private async safeEndEditing(key: string, url: string, deleteForgotten: boolean) {
|
||||
return this.forgottenSynchroniser.runWithLock(key, async () => {
|
||||
let succeded = false;
|
||||
try {
|
||||
await driveService.endEditing(key, url);
|
||||
succeded = true;
|
||||
} catch (error) {
|
||||
if (!(error instanceof UnknownKeyInDriveError))
|
||||
logger.error(`Error processing forgotten file by key ${JSON.stringify(key)}: ${error}`, { key, url, error });
|
||||
// Can't do much about it here, hope it goes in retry, but don't
|
||||
// throw to keep processing
|
||||
//TODO: Maybe make a date string, compare to key, if old enough, delete...
|
||||
// this logic should probably in Twake Drive backend though
|
||||
}
|
||||
if (succeded && deleteForgotten) await onlyofficeService.deleteForgotten(key);
|
||||
return succeded;
|
||||
});
|
||||
}
|
||||
|
||||
private async processForgottenFiles() {
|
||||
this.lastStart = new Date().getTime();
|
||||
if (!(await apiService.hasToken())) return -1;
|
||||
return await onlyofficeService.processForgotten((key, url) =>
|
||||
this.safeEndEditing(key, url, false /* `onlyofficeService.processForgotten` will do it */),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to upload the forgotten OO file, only throws if deleting it failed,
|
||||
* returns wether it was deleted.
|
||||
*/
|
||||
public async processForgottenFile(key: string, url: string) {
|
||||
return this.safeEndEditing(key, url, true);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ForgottenProcessor();
|
||||
@@ -0,0 +1,13 @@
|
||||
const providers: IHealthProvider[] = [];
|
||||
|
||||
export interface IHealthProvider {
|
||||
getHealthData(): Promise<{ [key: string]: unknown }>;
|
||||
}
|
||||
|
||||
export function registerHealthProvider(provider: IHealthProvider) {
|
||||
providers.push(provider);
|
||||
}
|
||||
|
||||
export function getProviders() {
|
||||
return providers;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { ONLY_OFFICE_SERVER } from '@config';
|
||||
import { ONLY_OFFICE_SERVER, onlyOfficeConnectivityCheckPeriodMS, onlyOfficeCallbackTimeoutMS } from '@config';
|
||||
import { PolledThingieValue } from '@/lib/polled-thingie-value';
|
||||
import { PendingRequestQueue, PendingRequestCallback } from '@/lib/pending-request-matcher';
|
||||
import logger from '@/lib/logger';
|
||||
import * as Utils from '@/utils';
|
||||
import { IHealthProvider, registerHealthProvider } from './health-providers.service';
|
||||
|
||||
/** @see https://api.onlyoffice.com/editors/basic */
|
||||
export enum ErrorCode {
|
||||
@@ -14,6 +17,12 @@ export enum ErrorCode {
|
||||
COMMAND_NOT_CORRECT = 5,
|
||||
INVALID_TOKEN = 6,
|
||||
}
|
||||
|
||||
/** Error generated by this connector. Should be string to share field with {@link ErrorCode} */
|
||||
export enum ConnectorErrorCode {
|
||||
CALLBACK_TIMEOUT = 'callback_timeout',
|
||||
}
|
||||
|
||||
/** Return the name of the error code in the `ErrorCode` enum if recognised, or a descript string */
|
||||
export const ErrorCodeFromValue = (value: number) => Utils.getKeyForValueSafe(value, ErrorCode, 'OnlyOffice.ErrorCode');
|
||||
|
||||
@@ -24,6 +33,12 @@ export namespace Callback {
|
||||
USER_CONNECTED = 1,
|
||||
USER_INITIATED_FORCE_SAVE = 2,
|
||||
}
|
||||
export const ActionTypeToString = (value: number) => Utils.getKeyForValueSafe(value, ActionType, 'OnlyOffice.Callback.ActionType');
|
||||
|
||||
interface Action {
|
||||
type: ActionType;
|
||||
userid: string;
|
||||
}
|
||||
|
||||
enum ForceSaveType {
|
||||
FROM_COMMAND_SERVICE = 0,
|
||||
@@ -31,6 +46,7 @@ export namespace Callback {
|
||||
SERVER_TIMER = 2,
|
||||
FORM_SUBMITTED = 3,
|
||||
}
|
||||
export const ForceSaveTypeToString = (value: number) => Utils.getKeyForValueSafe(value, ForceSaveType, 'OnlyOffice.Callback.ForceSaveType');
|
||||
|
||||
export enum Status {
|
||||
BEING_EDITED = 1,
|
||||
@@ -44,11 +60,8 @@ export namespace Callback {
|
||||
/** `url` and `forcesavetype` fields present with this status */
|
||||
ERROR_FORCE_SAVING = 7,
|
||||
}
|
||||
export const StatusToString = (value: number) => Utils.getKeyForValueSafe(value, Status, 'OnlyOffice.Callback.Status');
|
||||
|
||||
interface Action {
|
||||
type: ActionType;
|
||||
userid: string;
|
||||
}
|
||||
/** Parameters given to the callback by the editing service */
|
||||
export interface Parameters {
|
||||
key: string;
|
||||
@@ -58,6 +71,16 @@ export namespace Callback {
|
||||
url?: string;
|
||||
actions?: Action[];
|
||||
users?: string[];
|
||||
userdata?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/** For error responses from the {@see CommandService} */
|
||||
export class CommandError extends Error {
|
||||
constructor(public readonly errorCode: ErrorCode, req: any, res: any) {
|
||||
super(
|
||||
`OnlyOffice command service error ${ErrorCodeFromValue(errorCode)} (${errorCode}): Requested ${JSON.stringify(req)} got ${JSON.stringify(res)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,28 +99,18 @@ namespace CommandService {
|
||||
error: Exclude<ErrorCode, ErrorCode.SUCCESS>;
|
||||
}
|
||||
|
||||
export class CommandError extends Error {
|
||||
constructor(errorCode: ErrorCode, req: any, res: any) {
|
||||
super(
|
||||
`OnlyOffice command service error ${ErrorCodeFromValue(errorCode)} (${errorCode}): Requested ${JSON.stringify(req)} got ${JSON.stringify(
|
||||
res,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BaseRequest<TSuccessResponse extends BaseResponse> {
|
||||
constructor(public c: string) {}
|
||||
abstract class BaseRequest<TSuccessResponse extends SuccessResponse> {
|
||||
constructor(public readonly c: string) {}
|
||||
|
||||
/** POST this OnlyOffice command, does not check the `error` field of the response */
|
||||
async postUnsafe(): Promise<ErrorResponse | TSuccessResponse> {
|
||||
logger.silly(`OnlyOffice command ${this.c} sent: ${JSON.stringify(this)}`);
|
||||
const result = await axios.post(`${ONLY_OFFICE_SERVER}coauthoring/CommandService.ashx`, this);
|
||||
logger.info(`OnlyOffice command ${this.c} response: ${result.status}: ${JSON.stringify(result.data)}`);
|
||||
const result = await axios.post(Utils.joinURL([ONLY_OFFICE_SERVER, 'coauthoring/CommandService.ashx']), this);
|
||||
logger.info(`OnlyOffice command ${this.c} response ${result.status}: ${ErrorCodeFromValue(result.data.error)}: ${JSON.stringify(result.data)}`);
|
||||
return result.data as ErrorResponse | TSuccessResponse;
|
||||
}
|
||||
|
||||
/** POST this request, and return the result, or throw if the `errorCode` returned isn't 0 */
|
||||
/** POST this request, and return the result, or throws if the `errorCode` returned isn't `ErrorCode.SUCCESS` */
|
||||
async post(): Promise<TSuccessResponse> {
|
||||
const result = await this.postUnsafe();
|
||||
if (result.error === ErrorCode.SUCCESS) return result;
|
||||
@@ -121,7 +134,7 @@ namespace CommandService {
|
||||
key: string;
|
||||
}
|
||||
export class Request extends BaseRequest<Response> {
|
||||
constructor(public key: string, public userdata: string = '') {
|
||||
constructor(public readonly key: string, public readonly userdata: string = '') {
|
||||
super('forcesave');
|
||||
}
|
||||
}
|
||||
@@ -133,7 +146,7 @@ namespace CommandService {
|
||||
url: string;
|
||||
}
|
||||
export class Request extends BaseRequest<Response> {
|
||||
constructor(public key: string) {
|
||||
constructor(public readonly key: string) {
|
||||
super('getForgotten');
|
||||
}
|
||||
}
|
||||
@@ -155,30 +168,188 @@ namespace CommandService {
|
||||
key: string;
|
||||
}
|
||||
export class Request extends BaseRequest<Response> {
|
||||
constructor(public key: string) {
|
||||
constructor(public readonly key: string) {
|
||||
super('deleteForgotten');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace License {
|
||||
export interface Response extends SuccessResponse {
|
||||
// @see https://api.onlyoffice.com/editors/command/license
|
||||
license: object;
|
||||
server: object;
|
||||
quota: object;
|
||||
}
|
||||
export class Request extends BaseRequest<Response> {
|
||||
constructor() {
|
||||
super('license');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Info {
|
||||
export type Response = SuccessResponse;
|
||||
export class Request extends BaseRequest<Response> {
|
||||
constructor(public readonly key: string, public readonly userdata: string = '') {
|
||||
super('info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Meta {
|
||||
export interface Response extends SuccessResponse {
|
||||
key: string;
|
||||
}
|
||||
export class Request extends BaseRequest<Response> {
|
||||
constructor(public readonly key: string, public readonly meta: { title: string }) {
|
||||
super('meta');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This object holds possible outcomes for commands like `info` who's result
|
||||
* is sent to the callback instead of replied to the request
|
||||
*/
|
||||
class CallbackResponseFromCommand {
|
||||
/**
|
||||
* If the `info` command returned an error code, this is it, or it can
|
||||
* be an internal error to this connector (eg.: timeout waiting for callback).
|
||||
* (If the `info` command returned {@link ErrorCode.SUCCESS} then this field will be `undefined`)
|
||||
*/
|
||||
public readonly error: Exclude<ErrorCode, ErrorCode.SUCCESS> | ConnectorErrorCode | undefined;
|
||||
|
||||
public constructor(error: ErrorCode | ConnectorErrorCode, public readonly result?: Callback.Parameters) {
|
||||
this.error = error === ErrorCode.SUCCESS ? undefined : error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed OnlyOffice command service
|
||||
* @see https://api.onlyoffice.com/editors/command/
|
||||
*/
|
||||
class OnlyOfficeService {
|
||||
private readonly poller: PolledThingieValue<string>;
|
||||
class OnlyOfficeService implements IHealthProvider {
|
||||
private readonly poller: PolledThingieValue<CommandService.License.Response>;
|
||||
// Technically the timeout field is from the PendingRequestQueue but avoid 2 classes
|
||||
private readonly pendingRequests = new PendingRequestQueue<CallbackResponseFromCommand>(onlyOfficeCallbackTimeoutMS);
|
||||
|
||||
constructor() {
|
||||
this.poller = new PolledThingieValue('Connect to Only Office', () => this.getVersion(), 10 * 1000 * 60);
|
||||
this.poller = new PolledThingieValue(
|
||||
'Connect to Only Office',
|
||||
async () => {
|
||||
logger.info('Only Office license status');
|
||||
return await this.getLicense();
|
||||
},
|
||||
onlyOfficeConnectivityCheckPeriodMS,
|
||||
);
|
||||
registerHealthProvider(this);
|
||||
}
|
||||
/** Get the latest Only Office version */
|
||||
public getLatestVersion() {
|
||||
/** Get the latest Only Office licence status from polling. If the return is `undefined`
|
||||
* it probably means there is a connection issue contacting the OnlyOffice server
|
||||
* from the connector.
|
||||
*/
|
||||
public getLatestLicence() {
|
||||
return this.poller.latest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over all the forgotten files as returned by OnlyOffice, call the processor for each..
|
||||
* @param processor Handler to process the forgotten file (available at `url`). If `true` is returned,
|
||||
* the file is deleted from the forgotten file list in OnlyOffice. If false is returned, the
|
||||
* same forgotten file will reappear in a future batch
|
||||
* @returns The number of files processed and deleted
|
||||
*/
|
||||
public async processForgotten(processor: (key: string, url: string) => Promise<boolean>): Promise<number> {
|
||||
logger.info(`Begin to process forgotten files in OnlyOffice`);
|
||||
//TODO: filter by instance id of the key
|
||||
const forgottenFiles = await this.getForgottenList();
|
||||
if (forgottenFiles.length === 0) {
|
||||
logger.info(`No forgotten files in OnlyOffice`);
|
||||
return 0;
|
||||
}
|
||||
Utils.fisherYattesShuffleInPlace(forgottenFiles);
|
||||
logger.info(`Forgotten files found: ${forgottenFiles.length}`);
|
||||
let deleted = 0;
|
||||
for (const forgottenFileKey of forgottenFiles) {
|
||||
const forgottenFileURL = await this.getForgotten(forgottenFileKey);
|
||||
logger.info(`Forgotten file about to process: ${JSON.stringify(forgottenFileKey)}`, { url: forgottenFileURL });
|
||||
const shouldDelete = await processor(forgottenFileKey, forgottenFileURL);
|
||||
if (shouldDelete) {
|
||||
logger.info(`Forgotten file about to be deleted: ${JSON.stringify(forgottenFileKey)}`);
|
||||
await this.deleteForgotten(forgottenFileKey);
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Generates and returns a random UUID that has a matching pending task enqueued for */
|
||||
private enqueuePendingCallback(key: string, callback: PendingRequestCallback<CallbackResponseFromCommand>): string {
|
||||
const userdata = randomUUID();
|
||||
this.pendingRequests.enqueue(key, userdata, callback);
|
||||
return userdata;
|
||||
}
|
||||
|
||||
/** Called by the OnlyOffice controller when the OO document editing services uses our callback */
|
||||
async ooCallbackCalled(result: Callback.Parameters) {
|
||||
if (!result.userdata) return;
|
||||
logger.info('OO Callback pending request response received', result);
|
||||
return this.pendingRequests.gotResult(result.key, result.userdata, new CallbackResponseFromCommand(ErrorCode.SUCCESS, result));
|
||||
}
|
||||
|
||||
// Note that `async` is important in the functions below. While they avoid the overhead
|
||||
// of `await`, the `async` is still required to catch the throw in `.post()`
|
||||
|
||||
/** Return the version string of OnlyOffice */
|
||||
async getVersion(): Promise<string> {
|
||||
return new CommandService.Version.Request().post().then(response => response.version);
|
||||
}
|
||||
/** Return the version string of OnlyOffice */
|
||||
async getLicense(): Promise<CommandService.License.Response> {
|
||||
//TODO: When typing the response more fully, don't return the response object itself as here
|
||||
return new CommandService.License.Request().post();
|
||||
}
|
||||
|
||||
async getHealthData() {
|
||||
return { OO: { license: this.poller.latest() } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a document status and the list of the identifiers of the users who opened the document for editing.
|
||||
* The response will be sent to the callback handler.
|
||||
* This method just sends the command. The response from the callback will be ignored if
|
||||
* this called by itself
|
||||
*
|
||||
* *Warning*: returns non succesful error codes instead of throwing errors like the other
|
||||
* methods. This is because the immediate response is likely to be itself usefull
|
||||
* for detecting errors.
|
||||
*/
|
||||
async getInfoUnsafe(key: string, userdata: string): Promise<ErrorCode> {
|
||||
return await new CommandService.Info.Request(key, userdata).postUnsafe().then(({ error }) => error);
|
||||
}
|
||||
|
||||
/** Send the info command, wait for the callback if warranted, and return the error or callback body */
|
||||
async getInfoAndWaitForCallbackUnsafe(key: string): Promise<CallbackResponseFromCommand> {
|
||||
// const userdata = randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
const userdata = this.enqueuePendingCallback(key, async (timeout, result) => {
|
||||
// The callback has called, unless timeout = true, or result is undefined (cancelled request)
|
||||
return resolve(timeout ? new CallbackResponseFromCommand(ConnectorErrorCode.CALLBACK_TIMEOUT) : result);
|
||||
});
|
||||
void this.getInfoUnsafe(key, userdata).then(
|
||||
response => {
|
||||
// The command service responded to the `info` command request
|
||||
if (response !== ErrorCode.SUCCESS) return this.pendingRequests.gotResult(key, userdata, new CallbackResponseFromCommand(response));
|
||||
// If it succeded, just wait for timeout or resolution by the callback
|
||||
},
|
||||
error => {
|
||||
this.pendingRequests.cancelPending(key, userdata).then(() => reject(error));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
/** Force a save in the editing session key provided. `userdata` will be forwarded to the callback */
|
||||
async forceSave(key: string, userdata = ''): Promise<string> {
|
||||
return new CommandService.ForceSave.Request(key, userdata).post().then(response => response.key);
|
||||
@@ -195,6 +366,14 @@ class OnlyOfficeService {
|
||||
async deleteForgotten(key: string): Promise<string> {
|
||||
return new CommandService.DeleteForgotten.Request(key).post().then(response => response.key);
|
||||
}
|
||||
/**
|
||||
* Updates the meta information of the document for all collaborative editors.
|
||||
*
|
||||
* That's the official description. It send file renames to OO.
|
||||
*/
|
||||
async meta(key: string, title: string): Promise<string> {
|
||||
return new CommandService.Meta.Request(key, { title }).post().then(response => response.key);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OnlyOfficeService();
|
||||
|
||||
@@ -16,7 +16,7 @@ export type QueryParams = { [key: string]: string | number };
|
||||
export function joinURL(path: string[], params?: QueryParams) {
|
||||
let joinedPath = path.map(x => x.replace(/(?:^\/+)+|(?:\/+$)/g, '')).join('/');
|
||||
if (path[path.length - 1].endsWith('/')) joinedPath += '/';
|
||||
const paramEntries = Object.entries(params || {});
|
||||
const paramEntries = Object.entries(params || {}).filter(([, value]) => value !== undefined && value !== null);
|
||||
if (paramEntries.length === 0) return joinedPath;
|
||||
const query = paramEntries.map(p => p.map(encodeURIComponent).join('=')).join('&');
|
||||
return joinedPath + (joinedPath.indexOf('?') > -1 ? '&' : '?') + query;
|
||||
@@ -44,3 +44,14 @@ export function splitFilename(filename: string): [string, string] {
|
||||
const extension = parts.pop();
|
||||
return [parts.join('.'), extension];
|
||||
}
|
||||
|
||||
/** Shuffle an array in place, returns its parameter for convenience */
|
||||
export function fisherYattesShuffleInPlace<T>(list: T[]): T[] {
|
||||
let index = list.length;
|
||||
while (index) {
|
||||
const randomIndex = Math.floor(Math.random() * index);
|
||||
index--;
|
||||
[list[index], list[randomIndex]] = [list[randomIndex], list[index]];
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>500 Internal Server Error</title>
|
||||
|
||||
<style id="" media="all">/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Maven Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts.gstatic.com/s/mavenpro/v36/7Au9p_AqnyWWAxW2Wk3GwmQIElsO02Ex.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Maven Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts.gstatic.com/s/mavenpro/v36/7Au9p_AqnyWWAxW2Wk3Gw2QIElsO02Ex.woff2) format('woff2');
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Maven Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts.gstatic.com/s/mavenpro/v36/7Au9p_AqnyWWAxW2Wk3GzWQIElsO0w.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Maven Pro';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url(/fonts.gstatic.com/s/mavenpro/v36/7Au9p_AqnyWWAxW2Wk3GwmQIElsO02Ex.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Maven Pro';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url(/fonts.gstatic.com/s/mavenpro/v36/7Au9p_AqnyWWAxW2Wk3Gw2QIElsO02Ex.woff2) format('woff2');
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Maven Pro';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url(/fonts.gstatic.com/s/mavenpro/v36/7Au9p_AqnyWWAxW2Wk3GzWQIElsO0w.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
* {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
#notfound {
|
||||
position: relative;
|
||||
height: 100vh
|
||||
}
|
||||
|
||||
#notfound .notfound {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%)
|
||||
}
|
||||
|
||||
.notfound {
|
||||
max-width: 920px;
|
||||
width: 100%;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px
|
||||
}
|
||||
|
||||
.notfound .notfound-404 {
|
||||
position: absolute;
|
||||
height: 100px;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
-webkit-transform: translateX(-50%);
|
||||
-ms-transform: translateX(-50%);
|
||||
transform: translateX(-50%);
|
||||
z-index: -1
|
||||
}
|
||||
|
||||
.notfound .notfound-404 h1 {
|
||||
font-family: maven pro, sans-serif;
|
||||
color: #ececec;
|
||||
font-weight: 900;
|
||||
font-size: 276px;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%)
|
||||
}
|
||||
|
||||
.notfound h2 {
|
||||
font-family: maven pro, sans-serif;
|
||||
font-size: 46px;
|
||||
color: #000;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
.notfound p {
|
||||
font-family: maven pro, sans-serif;
|
||||
font-size: 16px;
|
||||
color: #000;
|
||||
font-weight: 400;
|
||||
margin-top: 15px
|
||||
}
|
||||
|
||||
.notfound a {
|
||||
font-family: maven pro, sans-serif;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
background: #189cf0;
|
||||
display: inline-block;
|
||||
padding: 16px 38px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 40px;
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
-webkit-transition: .2s all;
|
||||
transition: .2s all
|
||||
}
|
||||
|
||||
.notfound a:hover {
|
||||
background-color: #fff;
|
||||
border-color: #189cf0;
|
||||
color: #189cf0
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.notfound .notfound-404 h1 {
|
||||
font-size: 162px
|
||||
}
|
||||
|
||||
.notfound h2 {
|
||||
font-size: 26px
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.notfound .notfound-404 h1 {
|
||||
color: #444444;
|
||||
}
|
||||
|
||||
.notfound h2 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.notfound p {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<meta name="robots" content="noindex, follow">
|
||||
</head>
|
||||
<body>
|
||||
<div id="notfound">
|
||||
<div class="notfound">
|
||||
<div class="notfound-404">
|
||||
<h1>500</h1>
|
||||
</div>
|
||||
<h2>We are sorry, internal server error!</h2>
|
||||
<p>Please, contact our support at :</p>
|
||||
<a href="mailto:support@twake.app">support@twake.app</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,6 +5,7 @@
|
||||
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.0/jquery.min.js"></script>
|
||||
<script type="text/javascript" src="<%= it.onlyoffice_server %>web-apps/apps/api/documents/api.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no,shrink-to-fit=no" />
|
||||
<title>Twake Drive</title>
|
||||
</head>
|
||||
|
||||
<body style="height: 100%">
|
||||
@@ -22,18 +23,24 @@
|
||||
|
||||
$('#onlyoffice_container').html("<div id='onlyoffice_container_instance'></div>");
|
||||
|
||||
const callbackQueryString = '?drive_file_id=<%= it.drive_file_id %>&company_id=<%= it.company_id %>&token=<%= it.token %>';
|
||||
let doc = {
|
||||
title: "<%= it.filename %>",
|
||||
url: `${window.baseURL}read?file_id=<%= it.file_id %>&company_id=<%= it.company_id %>&token=<%= it.token %>`,
|
||||
url: `${window.baseURL}read${callbackQueryString}`,
|
||||
fileType: "<%= it.file_type %>",
|
||||
key: "<%= it.file_version_id %>",
|
||||
token: "<%= it.file_id %>",
|
||||
key: "<%= it.docId %>",
|
||||
token: "<%= it.drive_file_id %>",
|
||||
permissions: {
|
||||
download: true,
|
||||
edit: <%= it.editable %>,
|
||||
preview: <%= it.preview %>,
|
||||
}
|
||||
}
|
||||
function updateTitle(title) {
|
||||
const el = document.querySelector('head title');
|
||||
if (el) el.innerText = title + ' — Twake Drive';
|
||||
}
|
||||
updateTitle(doc.title);
|
||||
|
||||
window.docEditor = new DocsAPI.DocEditor('onlyoffice_container_instance', {
|
||||
scrollSensitivity: window.mode === 'text' ? 100 : 40,
|
||||
@@ -41,10 +48,28 @@
|
||||
height: '100%',
|
||||
documentType: window.mode,
|
||||
document: doc,
|
||||
token: "<%= it.file_id %>",
|
||||
token: "<%= it.drive_file_id %>",
|
||||
type: screen.width < 600 ? 'mobile' : 'desktop',
|
||||
events: {
|
||||
onRequestRename: function (event) {
|
||||
let name = event.data;
|
||||
const prevExtension = /\.[^.]+$/.exec(doc.title);
|
||||
if (prevExtension) name += prevExtension[0];
|
||||
$.
|
||||
post(`${window.baseURL}rename${callbackQueryString}`, { name }).
|
||||
done((changed) => {
|
||||
if (changed.name != name)
|
||||
window.docEditor.showMessage(`✅ Renamed to: ${changed.name}`);
|
||||
}).
|
||||
fail(() => window.docEditor.showMessage('🚨 Error renaming the file'));
|
||||
},
|
||||
onMetaChange: function (event) {
|
||||
const { title } = event.data;
|
||||
if (title) updateTitle(title);
|
||||
},
|
||||
},
|
||||
editorConfig: {
|
||||
callbackUrl: `${window.baseURL}save?file_id=<%= it.file_id %>&company_id=<%= it.company_id %>&token=<%= it.token %>`,
|
||||
callbackUrl: `${window.baseURL}callback${callbackQueryString}`,
|
||||
lang: window.user.language,
|
||||
user: {
|
||||
id: window.user.id,
|
||||
@@ -52,7 +77,7 @@
|
||||
},
|
||||
customization: {
|
||||
chat: false,
|
||||
compactToolbar: true,
|
||||
compactToolbar: false,
|
||||
about: false,
|
||||
feedback: false,
|
||||
goback: {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
version: "3.4"
|
||||
|
||||
services:
|
||||
|
||||
onlyoffice-rabbitmq:
|
||||
image: rabbitmq:management
|
||||
hostname: onlyoffice-rabbitmq
|
||||
container_name: rabbitmq
|
||||
environment:
|
||||
- RABBITMQ_DEFAULT_USER=guest
|
||||
- RABBITMQ_DEFAULT_PASS=guest
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
volumes:
|
||||
- ./.docker-conf/rabbitmq/data/:/var/lib/rabbitmq/
|
||||
- ./.docker-conf/rabbitmq/log/:/var/log/rabbitmq
|
||||
networks:
|
||||
- tdrive_network
|
||||
|
||||
onlyoffice-postgresql:
|
||||
image: postgres:13
|
||||
hostname: onlyoffice-postgresql
|
||||
environment:
|
||||
- POSTGRES_DB=onlyoffice
|
||||
- POSTGRES_USER=onlyoffice
|
||||
- POSTGRES_PASSWORD=onlyoffice
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- ./onlyoffice_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- tdrive_network
|
||||
|
||||
onlyoffice:
|
||||
image: docker.io/onlyoffice/documentserver
|
||||
hostname: onlyoffice
|
||||
ports:
|
||||
- 8090:80
|
||||
networks:
|
||||
- tdrive_network
|
||||
environment:
|
||||
- AMQP_URI=amqp://guest:guest@onlyoffice-rabbitmq
|
||||
- DB_HOST=onlyoffice-postgresql
|
||||
- DB_NAME=onlyoffice
|
||||
- DB_PORT=5432
|
||||
- DB_TYPE=postgres
|
||||
- DB_USER=onlyoffice
|
||||
- JWT_ENABLED=false
|
||||
- ALLOW_META_IP_ADDRESS=true
|
||||
- ALLOW_PRIVATE_IP_ADDRESS=true
|
||||
depends_on:
|
||||
- onlyoffice-rabbitmq
|
||||
- onlyoffice-postgresql
|
||||
volumes:
|
||||
- ./onlyoffice_data:/var/www/onlyoffice/Data
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
networks:
|
||||
tdrive_network:
|
||||
driver: bridge
|
||||
@@ -20,10 +20,6 @@
|
||||
"compenents.ConfirmTrashModalContent_move_to_trash": "Move to trash",
|
||||
"compenents.ConfirmTrashModalContent_move_to_trash_desc": "Click 'Move to trash' to move the selected items to the trash folder. You can restore them later from the trash.",
|
||||
"compenents.ConfirmTrashModalContent_to_trash": "to trash",
|
||||
"compenents.ProprietiesModalContent_name": "Name",
|
||||
"compenents.ProprietiesModalContent_place_holder": "Document or folder name",
|
||||
"compenents.ProprietiesModalContent_rename": "Rename",
|
||||
"compenents.ProprietiesModalContent_update_button": "Update name",
|
||||
"compenents.VersionModalContent_create": "Create version",
|
||||
"compenents.VersionModalContent_donwload": "Download",
|
||||
"compenents.VersionModalContent_version": "Versions of",
|
||||
@@ -100,7 +96,6 @@
|
||||
"components.item_context_menu.to_trash_multiple": "Move selected items to trash",
|
||||
"components.item_context_menu.today": "Today",
|
||||
"components.item_context_menu.trash.empty": "Empty trash",
|
||||
"components.item_context_menu.trash.exit": "Exit trash",
|
||||
"components.item_context_menu.versions": "Versions",
|
||||
"components.locked_features.locked_drive_popup.subtitle": "In the free version, you can store only 6GB.",
|
||||
"components.locked_features.locked_drive_popup.title": "You have just reached the drive limit",
|
||||
@@ -127,6 +122,10 @@
|
||||
"components.open_desktop_popup.subtitle": "Opened in Tdrive app",
|
||||
"components.pending_file_list.estimation.approximations": "Waiting for time approximations...",
|
||||
"components.pending_file_list.estimation.end": "Will end",
|
||||
"components.PropertiesModalContent_name": "Name",
|
||||
"components.PropertiesModalContent_place_holder": "Document or folder name",
|
||||
"components.PropertiesModalContent_rename": "Rename",
|
||||
"components.PropertiesModalContent_update_button": "Update name",
|
||||
"components.public-link-access-level-create": "Anyone with the link will have access to",
|
||||
"components.public-link-access-level-delete": "Delete link",
|
||||
"components.public-link-access-level-update-subtitle": "Anyone with the link has access to",
|
||||
|
||||
@@ -218,10 +218,10 @@
|
||||
"components.SelectorModalContent_no_items": "Pas de fichier sélectionné",
|
||||
"components.SelectorModalContent_select": "sélectionné(s)",
|
||||
"components.SelectorModalContent_files": "fichier(s)",
|
||||
"compenents.ProprietiesModalContent_rename": "Renommer",
|
||||
"compenents.ProprietiesModalContent_name": "Nom",
|
||||
"compenents.ProprietiesModalContent_place_holder": "Nom du fichier ou du document",
|
||||
"compenents.ProprietiesModalContent_update_button": "Renommer",
|
||||
"components.PropertiesModalContent_rename": "Renommer",
|
||||
"components.PropertiesModalContent_name": "Nom",
|
||||
"components.PropertiesModalContent_place_holder": "Nom du fichier ou du document",
|
||||
"components.PropertiesModalContent_update_button": "Renommer",
|
||||
"compenents.ConfirmTrashModalContent_move": "Déplacer",
|
||||
"compenents.ConfirmTrashModalContent_to_trash": "vers la corbeille",
|
||||
"compenents.ConfirmTrashModalContent_items_to_trash": "éléments vers la corbeille",
|
||||
@@ -337,7 +337,6 @@
|
||||
"components.item_context_menu.clear_selection": "Annuler la sélection",
|
||||
"components.item_context_menu.delete_multiple": "Supprimer",
|
||||
"components.item_context_menu.to_trash_multiple": "Supprimer",
|
||||
"components.item_context_menu.trash.exit": "Quitter la corbeille",
|
||||
"components.item_context_menu.trash.empty": "Vider la corbeille",
|
||||
"components.item_context_menu.add_documents": "Ajouter un document ou un dossier",
|
||||
"components.item_context_menu.download_folder": "Télécharger le dossier",
|
||||
|
||||
@@ -20,10 +20,6 @@
|
||||
"compenents.ConfirmTrashModalContent_move_to_trash": "Удалить",
|
||||
"compenents.ConfirmTrashModalContent_move_to_trash_desc": "Нажмите 'Удалить' чтобы переместить выделенные элементы в корзину. Вы сможете восстановить их позже.",
|
||||
"compenents.ConfirmTrashModalContent_to_trash": "в корзину",
|
||||
"compenents.ProprietiesModalContent_name": "Имя",
|
||||
"compenents.ProprietiesModalContent_place_holder": "Имя папки или документа",
|
||||
"compenents.ProprietiesModalContent_rename": "Переименовать",
|
||||
"compenents.ProprietiesModalContent_update_button": "Переименовать",
|
||||
"compenents.VersionModalContent_create": "Создать версию",
|
||||
"compenents.VersionModalContent_donwload": "Скачать",
|
||||
"compenents.VersionModalContent_version": "Версии ...",
|
||||
@@ -100,7 +96,6 @@
|
||||
"components.item_context_menu.to_trash_multiple": "Удалить",
|
||||
"components.item_context_menu.today": "Сегодня",
|
||||
"components.item_context_menu.trash.empty": "Очистить корзину",
|
||||
"components.item_context_menu.trash.exit": "Выйти из корзины",
|
||||
"components.item_context_menu.versions": "История изменений",
|
||||
"components.locked_features.locked_drive_popup.subtitle": "В бесплатной версии можно хранить только 6 ГБ.",
|
||||
"components.locked_features.locked_drive_popup.title": "Вы не можете загружать больше файлов",
|
||||
@@ -127,6 +122,10 @@
|
||||
"components.open_desktop_popup.subtitle": "открыто в приложении Tdrive",
|
||||
"components.pending_file_list.estimation.approximations": "Примерное время ожидания...",
|
||||
"components.pending_file_list.estimation.end": "Закончится через",
|
||||
"components.PropertiesModalContent_name": "Имя",
|
||||
"components.PropertiesModalContent_place_holder": "Имя папки или документа",
|
||||
"components.PropertiesModalContent_rename": "Переименовать",
|
||||
"components.PropertiesModalContent_update_button": "Переименовать",
|
||||
"components.public-link-access-level-create": "Все кто имеет ссылку буду иметь доступ",
|
||||
"components.public-link-access-level-delete": "Удалить ссылку",
|
||||
"components.public-link-access-level-update-subtitle": "У каждого, кто имеет ссылку, есть доступ к",
|
||||
|
||||
@@ -233,10 +233,10 @@
|
||||
"components.SelectorModalContent_no_items": "Không có mục nào được chọn",
|
||||
"components.SelectorModalContent_select": "Đã chọn",
|
||||
"components.SelectorModalContent_files": "tệp",
|
||||
"compenents.ProprietiesModalContent_rename": "Đổi tên",
|
||||
"compenents.ProprietiesModalContent_name": "Tên",
|
||||
"compenents.ProprietiesModalContent_place_holder": "Tên tài liệu hoặc thư mục",
|
||||
"compenents.ProprietiesModalContent_update_button": "Cập nhật tên",
|
||||
"components.PropertiesModalContent_rename": "Đổi tên",
|
||||
"components.PropertiesModalContent_name": "Tên",
|
||||
"components.PropertiesModalContent_place_holder": "Tên tài liệu hoặc thư mục",
|
||||
"components.PropertiesModalContent_update_button": "Cập nhật tên",
|
||||
"compenents.ConfirmTrashModalContent_move": "Di chuyển",
|
||||
"compenents.ConfirmTrashModalContent_to_trash": "vào thùng rác",
|
||||
"compenents.ConfirmTrashModalContent_items_to_trash": "mục vào thùng rác",
|
||||
@@ -326,7 +326,6 @@
|
||||
"components.item_context_menu.clear_selection": "Xóa lựa chọn",
|
||||
"components.item_context_menu.delete_multiple": "Xóa",
|
||||
"components.item_context_menu.to_trash_multiple": "Di chuyển các mục đã chọn vào thùng rác",
|
||||
"components.item_context_menu.trash.exit": "Thoát thùng rác",
|
||||
"components.item_context_menu.trash.empty": "Làm trống thùng rác",
|
||||
"components.item_context_menu.add_documents": "Thêm tài liệu hoặc thư mục",
|
||||
"components.item_context_menu.download_folder": "Tải xuống thư mục",
|
||||
|
||||
@@ -275,12 +275,6 @@ export const useOnBuildContextMenu = (
|
||||
//Add parent related menus
|
||||
const newMenuActions: any[] = inTrash
|
||||
? [
|
||||
{
|
||||
type: 'menu',
|
||||
text: Languages.t('components.item_context_menu.trash.exit'),
|
||||
onClick: () => setParentId('root'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
type: 'menu',
|
||||
text: Languages.t('components.item_context_menu.trash.empty'),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Input } from '@atoms/input/input-text';
|
||||
import { Modal, ModalContent } from '@atoms/modal';
|
||||
import { useDriveActions } from '@features/drive/hooks/use-drive-actions';
|
||||
import { useDriveItem } from '@features/drive/hooks/use-drive-item';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { atom, useRecoilState } from 'recoil';
|
||||
import Languages from '@features/global/services/languages-service';
|
||||
|
||||
@@ -44,54 +44,79 @@ const PropertiesModalContent = ({ id, onClose, inPublicSharing }: { id: string;
|
||||
const { update } = useDriveActions(inPublicSharing);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
refresh(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
const lastDot = inputRef.current.value.lastIndexOf('.');
|
||||
const endRange = lastDot >= 0 ? lastDot : inputRef.current.value.length;
|
||||
inputRef.current.setSelectionRange(0, endRange);
|
||||
}
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) setName(item?.name || '');
|
||||
}, [item?.name]);
|
||||
|
||||
const doSave = async () => {
|
||||
setLoading(true);
|
||||
if (item) {
|
||||
let finalName = (name || '').trim();
|
||||
//TODO: Confirm rename if extension changed ?
|
||||
if (!item?.is_directory) {
|
||||
//TODO: Why do we trim extensions on folders ?
|
||||
const lastDotIndex = finalName.lastIndexOf('.');
|
||||
if (lastDotIndex !== -1) {
|
||||
const fileExtension = name.slice(lastDotIndex);
|
||||
finalName = finalName.slice(0, lastDotIndex) + fileExtension;
|
||||
}
|
||||
}
|
||||
await update({ name: finalName }, id, item.parent_id);
|
||||
}
|
||||
onClose();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
title={Languages.t('compenents.ProprietiesModalContent_rename') + ' ' + item?.name}
|
||||
title={Languages.t('components.PropertiesModalContent_rename') + ' ' + item?.name}
|
||||
>
|
||||
<InputLabel
|
||||
className="mt-4"
|
||||
label={Languages.t('compenents.ProprietiesModalContent_name')}
|
||||
label={Languages.t('components.PropertiesModalContent_name')}
|
||||
input={
|
||||
<Input
|
||||
value={name}
|
||||
inputRef={inputRef}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={Languages.t('compenents.ProprietiesModalContent_place_holder')}
|
||||
onKeyUp={({ key }) => {
|
||||
if (!loading) {
|
||||
if (key === 'Enter')
|
||||
doSave();
|
||||
else if (key === "Escape")
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
placeholder={Languages.t('components.PropertiesModalContent_place_holder')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
<Button
|
||||
disabled={!name}
|
||||
disabled={!((name || '').trim())}
|
||||
className="float-right mt-4"
|
||||
theme="primary"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
if (item) {
|
||||
let finalName = name;
|
||||
if (!item?.is_directory) {
|
||||
const lastDotIndex = finalName.lastIndexOf('.');
|
||||
if (lastDotIndex !== -1) {
|
||||
const fileExtension = name.slice(lastDotIndex);
|
||||
finalName = finalName.slice(0, lastDotIndex) + fileExtension;
|
||||
}
|
||||
}
|
||||
await update({ name: finalName }, id, item.parent_id);
|
||||
}
|
||||
onClose();
|
||||
setLoading(false);
|
||||
}}
|
||||
onClick={doSave}
|
||||
>
|
||||
{Languages.t('compenents.ProprietiesModalContent_update_button')}
|
||||
{Languages.t('components.PropertiesModalContent_update_button')}
|
||||
</Button>
|
||||
</ModalContent>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user