Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

onlyoffice rework #650

Merged
merged 55 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
688217d
🚧 WIP: backend: beginnings of a transactional editing for plugins (#525)
ericlinagora Aug 25, 2024
ca3ebcc
🚧Changing file identifier with editing_session_id
shepilov Aug 25, 2024
3c156c4
🚨🩹 ooconnector: pass linter, fix bug where ignored promises in poller…
ericlinagora Aug 25, 2024
7941ba2
💄Added user friendly error page instead of JSON response
shepilov Aug 25, 2024
d94ec46
✨Added editing session key to the connector services
shepilov Aug 25, 2024
0914db9
✨ ooconnector: poll forgotten files, wip: extract code from oocallbac…
ericlinagora Aug 25, 2024
a692eab
🐛Fixed document identifier for OnlyOffice in view mode
shepilov Aug 25, 2024
b6be90f
🚧 ooconnector: show forgotten files in health endpoint for debugging …
ericlinagora Aug 25, 2024
c66123f
✨Added support of the session editing key for the OnlyOffice connector
shepilov Aug 25, 2024
3b81a21
♻️ backend: move editing_session_key generation and parsing to speci…
ericlinagora Aug 25, 2024
9b0dcc1
✅♻️🩹 backend: add company_id to editing_session_key (#525)
ericlinagora Aug 25, 2024
483b1b0
✨ backend: Add callback to application to check editing key status (…
ericlinagora Aug 25, 2024
afe6562
♻️ backend: adding freeform instanceid to identify multiple instances…
ericlinagora Sep 3, 2024
9bc9dca
♻️ oo-connector: refactored router system to something more coherent …
ericlinagora Sep 4, 2024
996c7a9
💄 oo-connector: Fixed error page to show 500, correct e-mail, removed…
ericlinagora Sep 5, 2024
66525d7
🐛 backend: fix bug with postgres support when uploading files
ericlinagora Sep 14, 2024
93351d5
🔀 Merge branch 'postgres-support-fix' into 525-515-548-523-onlyoffice…
ericlinagora Sep 14, 2024
53b34a0
✨ backend: Adding support for select based on null values and negatio…
ericlinagora Sep 14, 2024
1e16e17
✨ backend cli: add editing_session list viewer command (and minor ins…
ericlinagora Sep 14, 2024
22f04a5
🐛 ooconnector: minor error page dark mode (#525)
ericlinagora Sep 14, 2024
1d81c94
🩹 ooconnector: using user token for post to backend (#525)
ericlinagora Sep 14, 2024
5cf1fa5
✨ ooconnector: callback command retreival system (#525)
ericlinagora Sep 14, 2024
87d0de9
♻️ ooconnector: routing refactor (#525)
ericlinagora Sep 14, 2024
ee63bec
✨ cli: improve editing session listing (#525)
ericlinagora Sep 16, 2024
b8814c0
🩹 backend,oo: small fixes, related to instance id (#525)
ericlinagora Sep 16, 2024
2eb6f73
♻️🩹🚨 backend,oo: remove company_id when key available and minor clean…
ericlinagora Sep 16, 2024
c9db0bf
♻️ cli: better information about file history and key (#525)
ericlinagora Sep 17, 2024
816fb58
♻️ backend,oo: split endEditing into add version and actual end, via …
ericlinagora Sep 17, 2024
7b78c32
♻️ oo-connector: wip on callbacks and key reactions, and minor cleanu…
ericlinagora Sep 17, 2024
15ca46b
🔀 Merge remote-tracking branch 'origin/main' into 525-515-548-523-onl…
ericlinagora Sep 17, 2024
18f9208
✨ oo-connector: OO forgotten files batch processor, and testing, but …
ericlinagora Sep 17, 2024
6685b7b
♻️ oo-connector: centralising forgotten file batch management with se…
ericlinagora Sep 18, 2024
488d5a5
♻️ oo-connector: single request endediting, refactor URLs, bit of log…
ericlinagora Sep 18, 2024
be10393
♻️ oo-connector: refactored health (#525)
ericlinagora Sep 18, 2024
9db0fdd
♻️ backend: fix e2e editing session test, make ApplicationApiService …
ericlinagora Sep 18, 2024
c239843
🩹 backend,oo: fixed begin editing session process (#525)
ericlinagora Sep 18, 2024
1ee4f57
✅ backend: mock oo-connector response to fix an e2e test (#525)
ericlinagora Sep 18, 2024
021d82c
📝 doc: bit of documentation about the editing session key system for …
ericlinagora Sep 18, 2024
d353ec8
🩹 cli: fix details of editing_session list command (#525)
ericlinagora Sep 19, 2024
5ae1782
🐛 backend: when adding a FileVersion, update DriveFile.last_modified …
ericlinagora Sep 19, 2024
c855c29
🩹 backend,oo: adding userId override for application updatingEditingS…
ericlinagora Sep 19, 2024
b6d66d6
🚨 oo-connector: minor linter cleanup (#525)
ericlinagora Sep 19, 2024
c0182cf
🩹 oo-connector: respect user picked by OO for callback url in updateS…
ericlinagora Sep 19, 2024
ea18aaf
⚰️ backend: deleting rather quite boring file (#525)
ericlinagora Sep 19, 2024
5285de4
♻️🩹 backend,oo: fixing instances of controllers, and wip rename from …
ericlinagora Sep 19, 2024
a40cb7e
🩹 backend,oo: minor fixes for file renames (#525)
ericlinagora Sep 19, 2024
5616ff1
🌐 frontend: fixed typo in rename modal
ericlinagora Sep 22, 2024
77d58d0
💄 front: fix rename dialog for keyboard use
ericlinagora Sep 22, 2024
ebe8a78
♻️ oo-connector: use drive_file_id as much as possible in OO flow (#525)
ericlinagora Sep 23, 2024
528d883
✨ oo-connector: rename from OO (#525)
ericlinagora Sep 23, 2024
1a43a75
💄 frontend: remove exit from trash context menu entry (#525)
ericlinagora Sep 23, 2024
d86165d
✨ cli: add `editing_session parse` to output key information (#525)
ericlinagora Sep 24, 2024
e7e4db6
📝 documentation: describing editing_session_key in more detail (#525)
ericlinagora Sep 26, 2024
9018222
🔀 Merge remote-tracking branch 'origin/main' into 525-515-548-523-onl…
ericlinagora Sep 26, 2024
630d266
💄 front: trim filename before rename operation (fixes #660)
ericlinagora Sep 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 114 additions & 1 deletion Documentation/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
16 changes: 16 additions & 0 deletions tdrive/backend/node/src/cli/cmds/editing_session.ts
Original file line number Diff line number Diff line change
@@ -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;
166 changes: 166 additions & 0 deletions tdrive/backend/node/src/cli/cmds/editing_session_cmds/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import yargs from "yargs";

import runWithPlatform from "../../lib/run-with-platform";
shepilov marked this conversation as resolved.
Show resolved Hide resolved
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);
shepilov marked this conversation as resolved.
Show resolved Hide resolved
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;
34 changes: 34 additions & 0 deletions tdrive/backend/node/src/cli/cmds/editing_session_cmds/parse.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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";
shepilov marked this conversation as resolved.
Show resolved Hide resolved
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);
}
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading