Skip to content

Commit 9eb5a17

Browse files
committed
feat(backend/frontend:files): improve file locking logic, enhance compatibility across apps such as WebDAV and Collabora and OnlyOffice
1 parent adc1dd3 commit 9eb5a17

30 files changed

+275
-163
lines changed

backend/src/applications/files/interfaces/file-lock.interface.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,29 @@
44
* See the LICENSE file for licensing details
55
*/
66

7+
import { SERVER_NAME } from '../../../common/shared'
78
import { Owner } from '../../users/interfaces/owner.interface'
8-
import { LOCK_DEPTH } from '../../webdav/constants/webdav'
9-
import { WebDAVLock } from '../../webdav/interfaces/webdav.interface'
9+
import { LOCK_DEPTH, LOCK_SCOPE, WEBDAV_APP_LOCK } from '../../webdav/constants/webdav'
10+
import { COLLABORA_APP_LOCK } from '../modules/collabora-online/collabora-online.constants'
11+
import { ONLY_OFFICE_APP_LOCK } from '../modules/only-office/only-office.constants'
12+
13+
export type LOCK_APP = typeof WEBDAV_APP_LOCK | typeof COLLABORA_APP_LOCK | typeof ONLY_OFFICE_APP_LOCK | typeof SERVER_NAME
14+
15+
// Optional lock parameters
16+
export interface FileLockOptions {
17+
// Only locktype write is currently implemented in RFC
18+
lockRoot: string // Used with webdav (uri)
19+
lockToken: string
20+
lockScope: LOCK_SCOPE
21+
lockInfo?: string // Provided by some WebDAV clients to identify the locking application.
22+
}
1023

1124
export interface FileLock {
1225
owner: Owner
1326
dbFilePath: string
1427
key: string
1528
depth: LOCK_DEPTH
1629
expiration: number
17-
davLock?: WebDAVLock
30+
app: LOCK_APP // Known application (internal)
31+
options?: FileLockOptions
1832
}

backend/src/applications/files/interfaces/file-props.interface.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import type { Owner } from '../../users/interfaces/owner.interface'
1212
import type { File } from '../schemas/file.interface'
1313

1414
export interface FileLockProps {
15-
owner: string
16-
ownerLogin: string
15+
owner: Owner
16+
app: string
17+
info?: string
1718
isExclusive: boolean
1819
}
1920

backend/src/applications/files/modules/collabora-online/collabora-online-manager.service.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import { haveSpaceEnvPermissions } from '../../../spaces/utils/permissions'
1919
import type { UserModel } from '../../../users/models/user.model'
2020
import { getAvatarBase64 } from '../../../users/utils/avatar'
2121
import { DEPTH, LOCK_SCOPE } from '../../../webdav/constants/webdav'
22-
import { WebDAVLock } from '../../../webdav/interfaces/webdav.interface'
2322
import { FILE_MODE } from '../../constants/operations'
23+
import { FileLockOptions } from '../../interfaces/file-lock.interface'
24+
import { FileLockProps } from '../../interfaces/file-props.interface'
25+
import { LockConflict } from '../../models/file-lock-error'
2426
import { FilesLockManager } from '../../services/files-lock-manager.service'
2527
import {
2628
copyFileContent,
@@ -35,10 +37,10 @@ import {
3537
writeFromStream
3638
} from '../../utils/files'
3739
import {
40+
COLLABORA_APP_LOCK,
3841
COLLABORA_HEADERS,
3942
COLLABORA_LOCK_ACTION,
4043
COLLABORA_ONLINE_EXTENSIONS,
41-
COLLABORA_OWNER_LOCK,
4244
COLLABORA_TOKEN_QUERY_PARAM_NAME,
4345
COLLABORA_URI,
4446
COLLABORA_WOPI_SRC_QUERY_PARAM_NAME
@@ -65,18 +67,29 @@ export class CollaboraOnlineManager {
6567
if (!COLLABORA_ONLINE_EXTENSIONS.has(fileExtension)) {
6668
throw new HttpException('Document not supported', HttpStatus.BAD_REQUEST)
6769
}
68-
const mode: FILE_MODE = haveSpaceEnvPermissions(space, SPACE_OPERATION.MODIFY) ? FILE_MODE.EDIT : FILE_MODE.VIEW
70+
let hasLock: false | FileLockProps = false
71+
let mode: FILE_MODE = haveSpaceEnvPermissions(space, SPACE_OPERATION.MODIFY) ? FILE_MODE.EDIT : FILE_MODE.VIEW
6972
if (mode === FILE_MODE.EDIT) {
7073
// Check lock conflicts
7174
try {
72-
await this.filesLockManager.checkConflicts(space.dbFile, DEPTH.RESOURCE, { userId: user.id, lockScope: LOCK_SCOPE.SHARED })
73-
} catch {
74-
throw new HttpException('The file is locked', HttpStatus.LOCKED)
75+
await this.filesLockManager.checkConflicts(space.dbFile, DEPTH.RESOURCE, {
76+
userId: user.id,
77+
app: COLLABORA_APP_LOCK,
78+
lockScope: LOCK_SCOPE.SHARED
79+
})
80+
} catch (e) {
81+
if (e instanceof LockConflict) {
82+
hasLock = this.filesLockManager.convertLockToFileLockProps(e.lock)
83+
mode = FILE_MODE.VIEW
84+
} else {
85+
this.logger.error(`${this.getSettings.name} - ${e}`)
86+
throw new HttpException('Unable to check file lock', HttpStatus.INTERNAL_SERVER_ERROR)
87+
}
7588
}
7689
}
7790
const dbFileHash = genUniqHashFromFileDBProps(space.dbFile)
7891
const authToken: string = await this.genAuthToken(user, space, dbFileHash)
79-
return { documentServerUrl: this.getDocumentUrl(dbFileHash, authToken), mode: mode }
92+
return { documentServerUrl: this.getDocumentUrl(dbFileHash, authToken), mode: mode, hasLock: hasLock }
8093
}
8194

8295
async checkFileInfo(req: FastifyCollaboraOnlineSpaceRequest): Promise<CollaboraOnlineCheckFileInfo> {
@@ -149,17 +162,17 @@ export class CollaboraOnlineManager {
149162
const [ok, fileLock] = await this.filesLockManager.create(
150163
req.user,
151164
req.space.dbFile,
165+
COLLABORA_APP_LOCK,
152166
DEPTH.RESOURCE,
153167
{
154-
lockroot: null,
155-
locktoken: reqLockToken,
156-
lockscope: LOCK_SCOPE.SHARED, // Collabora uses one lock for the session
157-
owner: `${COLLABORA_OWNER_LOCK} - ${req.user.fullName} (${req.user.email})`
158-
} satisfies WebDAVLock,
168+
lockRoot: null,
169+
lockToken: reqLockToken,
170+
lockScope: LOCK_SCOPE.SHARED // Collabora uses one lock for the session
171+
} satisfies FileLockOptions,
159172
this.expiration
160173
)
161174
if (!ok) {
162-
this.lockConflict(res, fileLock.davLock.locktoken)
175+
this.lockConflict(res, fileLock.options.lockToken)
163176
return
164177
}
165178
break
@@ -177,7 +190,7 @@ export class CollaboraOnlineManager {
177190
case COLLABORA_LOCK_ACTION.GET_LOCK: {
178191
const lock = await this.filesLockManager.getLocksByPath(req.space.dbFile)
179192
if (lock.length) {
180-
res.header(COLLABORA_HEADERS.LockToken, lock[0].davLock.locktoken)
193+
res.header(COLLABORA_HEADERS.LockToken, lock[0].options.lockToken)
181194
}
182195
break
183196
}

backend/src/applications/files/modules/collabora-online/collabora-online.constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const COLLABORA_URI = 'browser/dist/cool.html'
88
export const COLLABORA_CONTEXT = 'CollaboraOnlineEnvironment' as const
99
export const COLLABORA_WOPI_SRC_QUERY_PARAM_NAME = 'WOPISrc' as const
1010
export const COLLABORA_TOKEN_QUERY_PARAM_NAME = 'access_token' as const
11-
export const COLLABORA_OWNER_LOCK = 'Collabora' as const
11+
export const COLLABORA_APP_LOCK = 'Collabora' as const
1212

1313
export const COLLABORA_HEADERS = {
1414
Action: 'x-wopi-override',

backend/src/applications/files/modules/collabora-online/collabora-online.dtos.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
*/
66

77
import type { FILE_MODE } from '../../constants/operations'
8+
import type { FileLockProps } from '../../interfaces/file-props.interface'
89

910
export interface CollaboraOnlineReqDto {
1011
documentServerUrl: string
1112
mode: FILE_MODE
13+
hasLock: false | FileLockProps
1214
}
1315

1416
export interface CollaboraSaveDocumentDto {

backend/src/applications/files/modules/only-office/only-office-manager.service.ts

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ import { haveSpaceEnvPermissions } from '../../../spaces/utils/permissions'
2525
import type { UserModel } from '../../../users/models/user.model'
2626
import { getAvatarBase64 } from '../../../users/utils/avatar'
2727
import { DEPTH, LOCK_SCOPE } from '../../../webdav/constants/webdav'
28-
import { WebDAVLock } from '../../../webdav/interfaces/webdav.interface'
2928
import { FILE_MODE } from '../../constants/operations'
3029
import type { FileDBProps } from '../../interfaces/file-db-props.interface'
30+
import { FileLockOptions } from '../../interfaces/file-lock.interface'
31+
import { FileLockProps } from '../../interfaces/file-props.interface'
32+
import { LockConflict } from '../../models/file-lock-error'
3133
import { FilesLockManager } from '../../services/files-lock-manager.service'
3234
import {
3335
copyFileContent,
@@ -41,12 +43,12 @@ import {
4143
writeFromStream
4244
} from '../../utils/files'
4345
import {
46+
ONLY_OFFICE_APP_LOCK,
4447
ONLY_OFFICE_CACHE_KEY,
4548
ONLY_OFFICE_CONVERT_ERROR,
4649
ONLY_OFFICE_CONVERT_EXTENSIONS,
4750
ONLY_OFFICE_EXTENSIONS,
4851
ONLY_OFFICE_INTERNAL_URI,
49-
ONLY_OFFICE_OWNER_LOCK,
5052
ONLY_OFFICE_TOKEN_QUERY_PARAM_NAME
5153
} from './only-office.constants'
5254
import { OnlyOfficeReqDto } from './only-office.dtos'
@@ -81,20 +83,31 @@ export class OnlyOfficeManager {
8183
if (!ONLY_OFFICE_EXTENSIONS.has(fileExtension)) {
8284
throw new HttpException('Document not supported', HttpStatus.BAD_REQUEST)
8385
}
84-
const mode: FILE_MODE = haveSpaceEnvPermissions(space, SPACE_OPERATION.MODIFY) ? FILE_MODE.EDIT : FILE_MODE.VIEW
86+
let hasLock: false | FileLockProps = false
87+
let mode: FILE_MODE = haveSpaceEnvPermissions(space, SPACE_OPERATION.MODIFY) ? FILE_MODE.EDIT : FILE_MODE.VIEW
8588
if (mode === FILE_MODE.EDIT) {
8689
// Check lock conflicts
8790
try {
88-
await this.filesLockManager.checkConflicts(space.dbFile, DEPTH.RESOURCE, { userId: user.id, lockScope: LOCK_SCOPE.SHARED })
89-
} catch {
90-
throw new HttpException('The file is locked', HttpStatus.LOCKED)
91+
await this.filesLockManager.checkConflicts(space.dbFile, DEPTH.RESOURCE, {
92+
userId: user.id,
93+
app: ONLY_OFFICE_APP_LOCK,
94+
lockScope: LOCK_SCOPE.SHARED
95+
})
96+
} catch (e) {
97+
if (e instanceof LockConflict) {
98+
hasLock = this.filesLockManager.convertLockToFileLockProps(e.lock)
99+
mode = FILE_MODE.VIEW
100+
} else {
101+
this.logger.error(`${this.getSettings.name} - ${e}`)
102+
throw new HttpException('Unable to check file lock', HttpStatus.INTERNAL_SERVER_ERROR)
103+
}
91104
}
92105
}
93106
const isMobile: boolean = this.mobileRegex.test(req.headers['user-agent'])
94107
const authToken: string = await this.genAuthToken(user)
95108
const fileUrl = this.buildUrl(API_ONLY_OFFICE_DOCUMENT, space.url, authToken)
96109
const callBackUrl = this.buildUrl(API_ONLY_OFFICE_CALLBACK, space.url, authToken)
97-
const config: OnlyOfficeReqDto = await this.genConfiguration(user, space, mode, fileUrl, fileExtension, callBackUrl, isMobile)
110+
const config: OnlyOfficeReqDto = await this.genConfiguration(user, space, mode, fileUrl, fileExtension, callBackUrl, isMobile, hasLock)
98111
config.config.token = await this.genPayloadToken(config.config)
99112
return config
100113
}
@@ -104,24 +117,28 @@ export class OnlyOfficeManager {
104117
try {
105118
switch (callBackData.status) {
106119
case 1:
120+
// users connect / disconnect
107121
await this.checkFileLock(user, space, callBackData)
108122
this.logger.debug(`document is being edited : ${space.url}`)
109123
break
110124
case 2:
125+
// No active users on the document
111126
await this.checkFileLock(user, space, callBackData)
112127
if (callBackData.notmodified) {
113128
this.logger.debug(`document was edited but closed with no changes : ${space.url}`)
114129
} else {
115-
this.logger.debug(`document was edited but not saved (let's do it) : ${space.url}`)
130+
this.logger.debug(`document was edited and closed but not saved (let's do it) : ${space.url}`)
116131
await this.saveDocument(space, callBackData.url)
117132
}
133+
await this.removeFileLock(user.id, space)
118134
await this.removeDocumentKey(space)
119135
break
120136
case 3:
121137
this.logger.error(`document cannot be saved, an error has occurred (try to save it) : ${space.url}`)
122138
await this.saveDocument(space, callBackData.url)
123139
break
124140
case 4:
141+
// No active users on the document
125142
await this.removeFileLock(user.id, space)
126143
await this.removeDocumentKey(space)
127144
this.logger.debug(`document was closed with no changes : ${space.url}`)
@@ -151,10 +168,13 @@ export class OnlyOfficeManager {
151168
fileUrl: string,
152169
fileExtension: string,
153170
callBackUrl: string,
154-
isMobile: boolean
171+
isMobile: boolean,
172+
hasLock: false | FileLockProps
155173
): Promise<OnlyOfficeReqDto> {
174+
const canEdit = mode === FILE_MODE.EDIT
156175
const documentType = ONLY_OFFICE_EXTENSIONS.get(fileExtension)
157176
return {
177+
hasLock: hasLock,
158178
documentServerUrl: this.externalOnlyOfficeServer || `${this.contextManager.headerOriginUrl()}${ONLY_OFFICE_INTERNAL_URI}`,
159179
config: {
160180
type: isMobile ? 'mobile' : 'desktop',
@@ -167,12 +187,12 @@ export class OnlyOfficeManager {
167187
key: await this.getDocumentKey(space),
168188
permissions: {
169189
download: true,
170-
edit: mode === FILE_MODE.EDIT,
190+
edit: canEdit,
171191
changeHistory: false,
172-
comment: true,
173-
fillForms: true,
192+
comment: canEdit,
193+
fillForms: canEdit,
174194
print: true,
175-
review: mode === FILE_MODE.EDIT
195+
review: canEdit
176196
},
177197
url: fileUrl
178198
},
@@ -240,11 +260,17 @@ export class OnlyOfficeManager {
240260
private async checkFileLock(user: UserModel, space: SpaceEnv, callBackData: OnlyOfficeCallBack) {
241261
for (const action of callBackData.actions) {
242262
if (action.type === 0) {
243-
// disconnect
244-
await this.removeFileLock(parseInt(action.userid), space)
263+
// Disconnect
264+
// Remove the lock if no other users are active on the document
265+
if (!Array.isArray(callBackData.users)) {
266+
await this.removeFileLock(parseInt(action.userid), space)
267+
}
245268
} else if (action.type === 1) {
246-
// connect
247-
await this.createFileLock(user, space)
269+
// Connect
270+
// Create the lock if it's the first user to open the document
271+
if (Array.isArray(callBackData.users) && callBackData.users.length === 1) {
272+
await this.createFileLock(user, space)
273+
}
248274
}
249275
}
250276
}
@@ -253,13 +279,13 @@ export class OnlyOfficeManager {
253279
const [ok, _fileLock] = await this.filesLockManager.create(
254280
user,
255281
space.dbFile,
282+
ONLY_OFFICE_APP_LOCK,
256283
DEPTH.RESOURCE,
257284
{
258-
lockroot: null,
259-
locktoken: null,
260-
lockscope: LOCK_SCOPE.SHARED,
261-
owner: `${ONLY_OFFICE_OWNER_LOCK} - ${user.fullName} (${user.email})`
262-
} satisfies WebDAVLock,
285+
lockRoot: null,
286+
lockToken: null,
287+
lockScope: LOCK_SCOPE.SHARED
288+
} satisfies FileLockOptions,
263289
this.expiration
264290
)
265291
if (!ok) {
@@ -284,6 +310,7 @@ export class OnlyOfficeManager {
284310
}
285311

286312
private async getDocumentKey(space: SpaceEnv): Promise<string> {
313+
// Uniq key to identify the document in OnlyOffice
287314
const cacheKey = this.getCacheKey(space.dbFile)
288315
const existingDocKey: string = await this.cache.get(cacheKey)
289316
if (existingDocKey) {

backend/src/applications/files/modules/only-office/only-office.constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
export const ONLY_OFFICE_INTERNAL_URI = '/onlyoffice' // used by nginx as a proxy
88
export const ONLY_OFFICE_CONTEXT = 'OnlyOfficeEnvironment' as const
99
export const ONLY_OFFICE_TOKEN_QUERY_PARAM_NAME = 'token' as const
10-
export const ONLY_OFFICE_OWNER_LOCK = 'OnlyOffice' as const
10+
export const ONLY_OFFICE_APP_LOCK = 'OnlyOffice' as const
1111
export const ONLY_OFFICE_CACHE_KEY = 'foffice' as const
1212

1313
export const ONLY_OFFICE_EXTENSIONS = new Map<string, 'word' | 'cell' | 'slide' | 'pdf' | 'diagram'>([

backend/src/applications/files/modules/only-office/only-office.dtos.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
* See the LICENSE file for licensing details
55
*/
66

7-
import { OnlyOfficeConfig } from './only-office.interface'
7+
import type { FileLockProps } from '../../interfaces/file-props.interface'
8+
import type { OnlyOfficeConfig } from './only-office.interface'
89

910
export interface OnlyOfficeReqDto {
1011
documentServerUrl: string
1112
config: OnlyOfficeConfig
13+
hasLock: false | FileLockProps
1214
}

0 commit comments

Comments
 (0)