Skip to content

Commit d84577b

Browse files
authored
Merge pull request #259 from nextcloud/feat/quarantine-recovery
Data management improvements
2 parents 5700064 + 16ae3a5 commit d84577b

File tree

8 files changed

+697
-44
lines changed

8 files changed

+697
-44
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
/css/
88
/vendor/
99
/node_modules/
10+
/backup/
1011

1112
.php-cs-fixer.cache
1213
.phpunit.result.cache

websocket_server/ApiService.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,18 @@ export default class ApiService {
5959
console.log(`[${roomID}] Saving room data to server: ${roomData.length} elements, ${Object.keys(files).length} files`)
6060

6161
const url = `${this.NEXTCLOUD_URL}/index.php/apps/whiteboard/${roomID}`
62-
const body = { data: { type: 'excalidraw', elements: roomData, files: this.cleanupFiles(roomData, files) } }
62+
63+
const body = {
64+
data: {
65+
type: 'excalidraw',
66+
elements: roomData,
67+
files: this.cleanupFiles(roomData, files),
68+
savedAt: Date.now(),
69+
},
70+
}
71+
6372
const options = this.fetchOptions('PUT', null, body, roomID, lastEditedUser)
73+
6474
return this.fetchData(url, options)
6575
}
6676

websocket_server/BackupManager.js

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
/* eslint-disable no-console */
7+
8+
import fs from 'fs/promises'
9+
import path from 'path'
10+
import crypto from 'crypto'
11+
import zlib from 'zlib'
12+
import { promisify } from 'util'
13+
14+
const gzip = promisify(zlib.gzip)
15+
const gunzip = promisify(zlib.gunzip)
16+
17+
/**
18+
* @typedef {object} BackupOptions
19+
* @property {string} [backupDir='./backup'] - Directory to store backups
20+
* @property {number} [maxBackupsPerRoom=5] - Maximum number of backups to keep per room
21+
* @property {number} [lockTimeout=5000] - Maximum time in ms to wait for a lock
22+
* @property {number} [lockRetryInterval=50] - Time in ms between lock retry attempts
23+
*/
24+
25+
/**
26+
* @typedef {object} BackupData
27+
* @property {string} id - Unique identifier for the backup
28+
* @property {number} timestamp - Timestamp when backup was created
29+
* @property {number} roomId - ID of the room
30+
* @property {string} checksum - SHA-256 hash of the data
31+
* @property {object} data - The actual backup data
32+
* @property {number} savedAt - Timestamp when the data was last saved
33+
*/
34+
35+
/**
36+
* Manages backup operations for whiteboard rooms
37+
*/
38+
export default class BackupManager {
39+
40+
/**
41+
* Creates a new BackupManager instance
42+
* @param {BackupOptions} [options] - Configuration options
43+
*/
44+
constructor(options = {}) {
45+
const { backupDir = './backup', maxBackupsPerRoom = 5 } = options
46+
this.backupDir = backupDir
47+
this.maxBackupsPerRoom = maxBackupsPerRoom
48+
this.locks = new Map()
49+
this.lockTimeout = options.lockTimeout || 5000 // 5 seconds
50+
this.lockRetryInterval = options.lockRetryInterval || 50 // 50ms
51+
this.init()
52+
}
53+
54+
/**
55+
* Initializes the backup directory and cleans up temporary files
56+
* @throws {Error} If initialization fails
57+
*/
58+
async init() {
59+
try {
60+
await fs.mkdir(this.backupDir, { recursive: true })
61+
await this.cleanupTemporaryFiles()
62+
} catch (error) {
63+
console.error('Failed to initialize BackupManager:', error)
64+
throw error
65+
}
66+
}
67+
68+
/**
69+
* Removes temporary files from the backup directory
70+
*/
71+
async cleanupTemporaryFiles() {
72+
try {
73+
const files = await fs.readdir(this.backupDir)
74+
const tmpFiles = files.filter((f) => f.endsWith('.tmp'))
75+
await Promise.all(
76+
tmpFiles.map((file) =>
77+
fs
78+
.unlink(path.join(this.backupDir, file))
79+
.catch(console.error),
80+
),
81+
)
82+
} catch (error) {
83+
console.error('Failed to cleanup temporary files:', error)
84+
}
85+
}
86+
87+
/**
88+
* Acquires a lock for a specific room
89+
* @param {number} roomId - The room ID to lock
90+
* @throws {Error} If lock cannot be acquired within timeout period
91+
*/
92+
async acquireLock(roomId) {
93+
const startTime = Date.now()
94+
while (this.locks.get(roomId)) {
95+
if (Date.now() - startTime > this.lockTimeout) {
96+
throw new Error(`Lock acquisition timeout for room ${roomId}`)
97+
}
98+
await new Promise((resolve) =>
99+
setTimeout(resolve, this.lockRetryInterval),
100+
)
101+
}
102+
this.locks.set(roomId, Date.now())
103+
}
104+
105+
/**
106+
* Releases a lock for a specific room
107+
* @param {number} roomId - The room ID to unlock
108+
*/
109+
async releaseLock(roomId) {
110+
this.locks.delete(roomId)
111+
}
112+
113+
/**
114+
* Ensures roomId is a valid number
115+
* @param {number|string} roomId - The room ID to validate
116+
* @return {number} The validated room ID
117+
* @throws {Error} If roomId is invalid
118+
*/
119+
sanitizeRoomId(roomId) {
120+
const numericRoomId = Number(roomId)
121+
if (isNaN(numericRoomId) || numericRoomId <= 0) {
122+
throw new Error('Invalid room ID: must be a positive number')
123+
}
124+
return numericRoomId
125+
}
126+
127+
/**
128+
* Calculates SHA-256 checksum of data
129+
* @param {string | object} data - Data to calculate checksum for
130+
* @return {string} Hex string of SHA-256 hash
131+
*/
132+
calculateChecksum(data) {
133+
return crypto
134+
.createHash('sha256')
135+
.update(typeof data === 'string' ? data : JSON.stringify(data))
136+
.digest('hex')
137+
}
138+
139+
/**
140+
* Creates a new backup for a room
141+
* @param {number} roomId - The room ID
142+
* @param {object} data - The data to backup
143+
* @return {Promise<string>} The backup ID
144+
* @throws {Error} If backup creation fails
145+
*/
146+
async createBackup(roomId, data) {
147+
if (!roomId || !data) {
148+
throw new Error('Invalid backup parameters')
149+
}
150+
151+
const sanitizedRoomId = this.sanitizeRoomId(roomId)
152+
153+
try {
154+
await this.acquireLock(sanitizedRoomId)
155+
156+
const backupData = this.prepareBackupData(sanitizedRoomId, data)
157+
await this.writeBackupFile(sanitizedRoomId, backupData)
158+
await this.cleanupOldBackups(sanitizedRoomId)
159+
160+
return backupData.id
161+
} finally {
162+
await this.releaseLock(sanitizedRoomId)
163+
}
164+
}
165+
166+
/**
167+
* Prepares backup data structure
168+
* @param {number} roomId - The room ID
169+
* @param {object} data - The data to backup
170+
* @return {BackupData} Prepared backup data
171+
*/
172+
prepareBackupData(roomId, data) {
173+
return {
174+
id: crypto.randomUUID(),
175+
timestamp: Date.now(),
176+
roomId,
177+
checksum: this.calculateChecksum(data),
178+
data,
179+
savedAt: data.savedAt || Date.now(),
180+
}
181+
}
182+
183+
/**
184+
* Writes backup data to file
185+
* @param {number} roomId - The room ID
186+
* @param {BackupData} backupData - The data to write
187+
*/
188+
async writeBackupFile(roomId, backupData) {
189+
const backupFile = path.join(
190+
this.backupDir,
191+
`${roomId}_${backupData.timestamp}.bak`,
192+
)
193+
const tempFile = `${backupFile}.tmp`
194+
195+
const compressed = await gzip(JSON.stringify(backupData))
196+
await fs.writeFile(tempFile, compressed)
197+
await fs.rename(tempFile, backupFile)
198+
}
199+
200+
/**
201+
* Retrieves the latest backup for a room
202+
* @param {number} roomId - The room ID
203+
* @return {Promise<BackupData|null>} The latest backup or null if none exists
204+
* @throws {Error} If backup retrieval fails
205+
*/
206+
async getLatestBackup(roomId) {
207+
const sanitizedRoomId = this.sanitizeRoomId(roomId)
208+
const files = await fs.readdir(this.backupDir)
209+
const roomBackups = files
210+
.filter(
211+
(f) =>
212+
f.startsWith(`${sanitizedRoomId}_`) && f.endsWith('.bak'),
213+
)
214+
.sort()
215+
.reverse()
216+
217+
if (roomBackups.length === 0) return null
218+
219+
try {
220+
const compressed = await fs.readFile(
221+
path.join(this.backupDir, roomBackups[0]),
222+
)
223+
const decompressed = await gunzip(compressed)
224+
const backup = JSON.parse(decompressed.toString())
225+
226+
const calculatedChecksum = this.calculateChecksum(backup.data)
227+
if (calculatedChecksum !== backup.checksum) {
228+
throw new Error('Backup data corruption detected')
229+
}
230+
231+
return backup
232+
} catch (error) {
233+
console.error(
234+
`Failed to read latest backup for room ${sanitizedRoomId}:`,
235+
error,
236+
)
237+
throw error
238+
}
239+
}
240+
241+
/**
242+
* Removes old backups exceeding maxBackupsPerRoom
243+
* @param {number} roomId - The room ID
244+
*/
245+
async cleanupOldBackups(roomId) {
246+
const sanitizedRoomId = this.sanitizeRoomId(roomId)
247+
248+
try {
249+
const files = await fs.readdir(this.backupDir)
250+
const roomBackups = files
251+
.filter(
252+
(f) =>
253+
f.startsWith(`${sanitizedRoomId}_`)
254+
&& f.endsWith('.bak'),
255+
)
256+
.sort()
257+
.reverse()
258+
259+
if (roomBackups.length <= this.maxBackupsPerRoom) {
260+
return
261+
}
262+
263+
const filesToDelete = roomBackups.slice(this.maxBackupsPerRoom)
264+
await Promise.all(
265+
filesToDelete.map((file) =>
266+
fs
267+
.unlink(path.join(this.backupDir, file))
268+
.catch((error) => {
269+
console.error(
270+
`Failed to delete backup ${file}:`,
271+
error,
272+
)
273+
}),
274+
),
275+
)
276+
} catch (error) {
277+
console.error(`Failed to cleanup old backups for ${roomId}:`, error)
278+
}
279+
}
280+
281+
/**
282+
* Gets all backup files for a room
283+
* @param {number} roomId - The room ID
284+
* @return {Promise<string[]>} Array of backup filenames
285+
*/
286+
async getAllBackups(roomId) {
287+
const sanitizedRoomId = this.sanitizeRoomId(roomId)
288+
const files = await fs.readdir(this.backupDir)
289+
return files
290+
.filter(
291+
(f) =>
292+
f.startsWith(`${sanitizedRoomId}_`) && f.endsWith('.bak'),
293+
)
294+
.sort()
295+
.reverse()
296+
}
297+
298+
/**
299+
* Recovers data from the latest backup
300+
* @param {number} roomId - The room ID
301+
* @return {Promise<object | null>} Recovered data or null if no backup exists
302+
*/
303+
async recoverFromBackup(roomId) {
304+
const backup = await this.getLatestBackup(roomId)
305+
if (!backup) {
306+
console.log(`No backup found for room ${roomId}`)
307+
return null
308+
}
309+
return backup.data
310+
}
311+
312+
/**
313+
* Checks if server data is newer than the latest backup
314+
* @param {number} roomId - The room ID
315+
* @param {object} serverData - Current server data
316+
* @return {Promise<boolean>} True if server data is newer
317+
*/
318+
async isDataFresher(roomId, serverData) {
319+
const latestBackup = await this.getLatestBackup(roomId)
320+
321+
if (!latestBackup) return true
322+
323+
const serverTimestamp = serverData?.savedAt || 0
324+
const backupTimestamp = latestBackup.savedAt || 0
325+
326+
return serverTimestamp >= backupTimestamp
327+
}
328+
329+
}

websocket_server/LRUCacheStrategy.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default class LRUCacheStrategy extends StorageStrategy {
2020
ttlAutopurge: true,
2121
dispose: async (value, key) => {
2222
console.log(`[${key}] Disposing room`)
23+
2324
if (value?.data && value?.lastEditedUser) {
2425
try {
2526
await this.apiService.saveRoomDataToServer(
@@ -53,7 +54,9 @@ export default class LRUCacheStrategy extends StorageStrategy {
5354
}
5455

5556
getRooms() {
56-
const rooms = Array.from(this.cache.values()).filter((room) => room instanceof Room)
57+
const rooms = Array.from(this.cache.values()).filter(
58+
(room) => room instanceof Room,
59+
)
5760

5861
return rooms
5962
}

0 commit comments

Comments
 (0)