From 27a61666bd1935de43aa403e275bae0e1fd1628a Mon Sep 17 00:00:00 2001 From: Cesare Date: Wed, 22 Jul 2026 10:17:45 +0000 Subject: [PATCH] Add generic WebDAV metadata cache --- src/services/webdav/webdav-cache.service.ts | 198 +++++++++++++++++++ src/services/webdav/webdav-folder.service.ts | 4 +- src/webdav/handlers/DELETE.handler.ts | 4 +- src/webdav/handlers/GET.handler.ts | 3 +- src/webdav/handlers/HEAD.handler.ts | 3 +- src/webdav/handlers/MKCOL.handler.ts | 6 +- src/webdav/handlers/MOVE.handler.ts | 5 +- src/webdav/handlers/PROPFIND.handler.ts | 53 +---- src/webdav/handlers/PUT.handler.ts | 5 +- test/vitest.setup.ts | 7 +- 10 files changed, 233 insertions(+), 55 deletions(-) create mode 100644 src/services/webdav/webdav-cache.service.ts diff --git a/src/services/webdav/webdav-cache.service.ts b/src/services/webdav/webdav-cache.service.ts new file mode 100644 index 00000000..531cbb1b --- /dev/null +++ b/src/services/webdav/webdav-cache.service.ts @@ -0,0 +1,198 @@ +import path from 'node:path'; +import { FileStatus } from '@internxt/sdk/dist/drive/storage/types'; +import { DriveFolderService } from '../drive/drive-folder.service'; +import { DriveFileItem, DriveFolderItem, DriveItem } from '../../types/drive.types'; +import { WebDavRequestedResource } from '../../types/webdav.types'; +import { DriveUtils } from '../../utils/drive.utils'; +import { WebDavUtils } from '../../utils/webdav.utils'; + +type CacheEntry = { + value: T; + expiresAt: number; +}; + +type FolderContent = { + folders: DriveFolderItem[]; + files: DriveFileItem[]; +}; + +export class WebDavCacheService { + public static readonly instance: WebDavCacheService = new WebDavCacheService(); + + private static readonly ttlMs = 2 * 60 * 1000; + + private readonly items = new Map>(); + private readonly folderContents = new Map>(); + + public getItemFromResource = async (resource: WebDavRequestedResource): Promise => { + const normalizedPath = resource.url.endsWith('/') + ? this.normalizeFolderPath(resource.url) + : this.normalizeFilePath(resource.url); + const cached = this.getCachedItem(normalizedPath); + if (cached) { + return cached; + } + + const driveItem = await WebDavUtils.getDriveItemFromResource({ ...resource, url: normalizedPath }); + if (driveItem?.status === FileStatus.EXISTS) { + this.setItem(driveItem.itemType === 'folder' ? this.normalizeFolderPath(normalizedPath) : normalizedPath, driveItem); + return driveItem; + } + }; + + public getFileFromPath = async (filePath: string): Promise => { + const normalizedPath = this.normalizeFilePath(filePath); + const cached = this.getCachedItem(normalizedPath); + if (cached?.itemType === 'file') { + return cached; + } + + const driveFile = await WebDavUtils.getDriveFileFromResource(normalizedPath); + if (driveFile?.status === FileStatus.EXISTS) { + this.setItem(normalizedPath, driveFile); + return driveFile; + } + }; + + public getFolderFromPath = async (folderPath: string): Promise => { + const normalizedPath = this.normalizeFolderPath(folderPath); + const cached = this.getCachedItem(normalizedPath); + if (cached?.itemType === 'folder') { + return cached; + } + + const driveFolder = await WebDavUtils.getDriveFolderFromResource(normalizedPath); + if (driveFolder?.status === FileStatus.EXISTS) { + this.setItem(normalizedPath, driveFolder); + return driveFolder; + } + }; + + public getFolderContent = async (folderPath: string, folderUuid: string): Promise => { + const normalizedPath = this.normalizeFolderPath(folderPath); + const cached = this.getCachedFolderContent(normalizedPath); + if (cached) { + return cached; + } + + const folderContent = await DriveFolderService.instance.getFolderContent(folderUuid); + const mappedContent: FolderContent = { + folders: folderContent.folders.map((folder) => ({ + itemType: 'folder', + name: folder.plainName, + bucket: folder.bucket, + status: folder.deleted || folder.removed ? FileStatus.TRASHED : FileStatus.EXISTS, + createdAt: new Date(folder.createdAt), + updatedAt: new Date(folder.updatedAt), + creationTime: new Date(folder.creationTime), + modificationTime: new Date(folder.modificationTime), + uuid: folder.uuid, + parentUuid: folder.parentUuid, + })), + files: folderContent.files.map((file) => ({ + itemType: 'file', + name: file.plainName, + bucket: file.bucket, + fileId: file.fileId, + uuid: file.uuid, + type: file.type, + status: file.status, + folderUuid: file.folderUuid, + size: DriveUtils.parseFileSize(file.size), + creationTime: new Date(file.creationTime), + modificationTime: new Date(file.modificationTime), + createdAt: new Date(file.createdAt), + updatedAt: new Date(file.updatedAt), + })), + }; + + for (const folder of mappedContent.folders) { + this.setItem(WebDavUtils.joinURL(normalizedPath, folder.name, '/'), folder); + } + for (const file of mappedContent.files) { + const fileName = file.type ? `${file.name}.${file.type}` : file.name; + this.setItem(WebDavUtils.joinURL(normalizedPath, fileName), file); + } + + this.setFolderContent(normalizedPath, mappedContent); + return mappedContent; + }; + + public registerFile = (filePath: string, file: DriveFileItem): void => { + const normalizedPath = this.normalizeFilePath(filePath); + this.setItem(normalizedPath, file); + this.invalidateFolderContent(this.getParentFolderPath(normalizedPath)); + }; + + public registerFolder = (folderPath: string, folder: DriveFolderItem): void => { + const normalizedPath = this.normalizeFolderPath(folderPath); + this.setItem(normalizedPath, folder); + this.invalidateFolderContent(this.getParentFolderPath(normalizedPath)); + }; + + public invalidateResource = (resourcePath: string): void => { + const itemPath = resourcePath.endsWith('/') ? this.normalizeFolderPath(resourcePath) : this.normalizeFilePath(resourcePath); + this.items.delete(itemPath); + this.folderContents.delete(this.normalizeFolderPath(resourcePath)); + this.invalidateFolderContent(this.getParentFolderPath(itemPath)); + }; + + public invalidateFolderContent = (folderPath: string): void => { + this.folderContents.delete(this.normalizeFolderPath(folderPath)); + }; + + public clear = (): void => { + this.items.clear(); + this.folderContents.clear(); + }; + + private getCachedItem(path: string): DriveItem | undefined { + return this.getCachedValue(this.items, path); + } + + private getCachedFolderContent(path: string): FolderContent | undefined { + return this.getCachedValue(this.folderContents, path); + } + + private getCachedValue(cache: Map>, key: string): T | undefined { + const entry = cache.get(key); + if (!entry) { + return; + } + + if (entry.expiresAt <= Date.now()) { + cache.delete(key); + return; + } + + return entry.value; + } + + private setItem(path: string, value: DriveItem): void { + this.items.set(path, this.createEntry(value)); + } + + private setFolderContent(path: string, value: FolderContent): void { + this.folderContents.set(path, this.createEntry(value)); + } + + private createEntry(value: T): CacheEntry { + return { + value, + expiresAt: Date.now() + WebDavCacheService.ttlMs, + }; + } + + private normalizeFilePath(filePath: string): string { + const normalizedPath = path.posix.normalize(filePath); + return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`; + } + + private normalizeFolderPath(folderPath: string): string { + return WebDavUtils.normalizeFolderPath(this.normalizeFilePath(folderPath)); + } + + private getParentFolderPath(resourcePath: string): string { + return WebDavUtils.normalizeFolderPath(path.posix.dirname(resourcePath)); + } +} diff --git a/src/services/webdav/webdav-folder.service.ts b/src/services/webdav/webdav-folder.service.ts index b49ed15d..0f8f2fc7 100644 --- a/src/services/webdav/webdav-folder.service.ts +++ b/src/services/webdav/webdav-folder.service.ts @@ -6,13 +6,14 @@ import { WebDavUtils } from '../../utils/webdav.utils'; import { AsyncUtils } from '../../utils/async.utils'; import { AuthService } from '../../services/auth.service'; import { DriveUtils } from '../../utils/drive.utils'; +import { WebDavCacheService } from './webdav-cache.service'; export class WebDavFolderService { public static readonly instance: WebDavFolderService = new WebDavFolderService(); public getDriveFolderItemFromPath = async (path: string): Promise => { const { url } = await WebDavUtils.getRequestedResource(path, false); - return await WebDavUtils.getDriveFolderFromResource(url); + return await WebDavCacheService.instance.getFolderFromPath(url); }; public createFolder = async ({ @@ -65,6 +66,7 @@ export class WebDavFolderService { const folder = (await this.getDriveFolderItemFromPath(folderPath)) ?? (await this.createFolder({ folderName: currentFolderName, parentFolderUuid })); + WebDavCacheService.instance.registerFolder(folderPath, folder); if (rest.length === 0) { return folder; diff --git a/src/webdav/handlers/DELETE.handler.ts b/src/webdav/handlers/DELETE.handler.ts index bbdba635..6ef5c87e 100644 --- a/src/webdav/handlers/DELETE.handler.ts +++ b/src/webdav/handlers/DELETE.handler.ts @@ -4,13 +4,14 @@ import { WebDavMethodHandler } from '../../types/webdav.types'; import { WebDavUtils } from '../../utils/webdav.utils'; import { webdavLogger } from '../../utils/logger.utils'; import { NotFoundError } from '../../utils/errors.utils'; +import { WebDavCacheService } from '../../services/webdav/webdav-cache.service'; export class DELETERequestHandler implements WebDavMethodHandler { handle = async (req: Request, res: Response) => { const resource = await WebDavUtils.getRequestedResource(req.url); webdavLogger.info(`[DELETE] Request received for item at ${resource.url}`); - const driveItem = await WebDavUtils.getDriveItemFromResource(resource); + const driveItem = await WebDavCacheService.instance.getItemFromResource(resource); if (!driveItem) { throw new NotFoundError(`Resource not found on Internxt Drive at ${resource.url}`); @@ -18,6 +19,7 @@ export class DELETERequestHandler implements WebDavMethodHandler { await WebDavUtils.deleteOrTrashItem(driveItem); await DriveItemRepository.instance.delete([driveItem.uuid]); + WebDavCacheService.instance.invalidateResource(resource.url); res.status(204).send(); }; diff --git a/src/webdav/handlers/GET.handler.ts b/src/webdav/handlers/GET.handler.ts index 1e28fdf0..1a32d3f3 100644 --- a/src/webdav/handlers/GET.handler.ts +++ b/src/webdav/handlers/GET.handler.ts @@ -7,13 +7,14 @@ import { webdavLogger } from '../../utils/logger.utils'; import { NetworkUtils } from '../../utils/network.utils'; import { NotValidFileIdError } from '../../types/command.types'; import { CLIUtils } from '../../utils/cli.utils'; +import { WebDavCacheService } from '../../services/webdav/webdav-cache.service'; export class GETRequestHandler implements WebDavMethodHandler { handle = async (req: Request, res: Response) => { const resource = await WebDavUtils.getRequestedResource(req.url); webdavLogger.info(`[GET] Request received item at ${resource.url}`); - const driveFile = await WebDavUtils.getDriveFileFromResource(resource.url); + const driveFile = await WebDavCacheService.instance.getFileFromPath(resource.url); if (!driveFile) { throw new NotFoundError( diff --git a/src/webdav/handlers/HEAD.handler.ts b/src/webdav/handlers/HEAD.handler.ts index 52c997be..dcf00433 100644 --- a/src/webdav/handlers/HEAD.handler.ts +++ b/src/webdav/handlers/HEAD.handler.ts @@ -4,6 +4,7 @@ import { WebDavUtils } from '../../utils/webdav.utils'; import { webdavLogger } from '../../utils/logger.utils'; import { NetworkUtils } from '../../utils/network.utils'; import { NotFoundError } from '../../utils/errors.utils'; +import { WebDavCacheService } from '../../services/webdav/webdav-cache.service'; export class HEADRequestHandler implements WebDavMethodHandler { handle = async (req: Request, res: Response) => { @@ -11,7 +12,7 @@ export class HEADRequestHandler implements WebDavMethodHandler { webdavLogger.info(`[HEAD] Request received for item at ${resource.url}`); - const driveItem = await WebDavUtils.getDriveItemFromResource(resource); + const driveItem = await WebDavCacheService.instance.getItemFromResource(resource); if (!driveItem) { throw new NotFoundError(`Resource not found on Internxt Drive at ${resource.url}`); diff --git a/src/webdav/handlers/MKCOL.handler.ts b/src/webdav/handlers/MKCOL.handler.ts index 904d9713..c7e6d185 100644 --- a/src/webdav/handlers/MKCOL.handler.ts +++ b/src/webdav/handlers/MKCOL.handler.ts @@ -7,6 +7,7 @@ import { XMLUtils } from '../../utils/xml.utils'; import { WebDavFolderService } from '../../services/webdav/webdav-folder.service'; import { AsyncUtils } from '../../utils/async.utils'; import { MethodNotAllowed } from '../../utils/errors.utils'; +import { WebDavCacheService } from '../../services/webdav/webdav-cache.service'; export class MKCOLRequestHandler implements WebDavMethodHandler { handle = async (req: Request, res: Response) => { @@ -15,10 +16,10 @@ export class MKCOLRequestHandler implements WebDavMethodHandler { webdavLogger.info(`[MKCOL] Request received for folder at ${resource.url}`); const parentDriveFolderItem = - (await WebDavFolderService.instance.getDriveFolderItemFromPath(resource.parentPath)) ?? + (await WebDavCacheService.instance.getFolderFromPath(resource.parentPath)) ?? (await WebDavFolderService.instance.createParentPathOrThrow(resource.parentPath)); - const driveFolderItem = await WebDavUtils.getDriveFolderFromResource(resource.url); + const driveFolderItem = await WebDavCacheService.instance.getFolderFromPath(resource.url); const folderAlreadyExists = !!driveFolderItem; @@ -45,6 +46,7 @@ export class MKCOLRequestHandler implements WebDavMethodHandler { updatedAt: new Date(), }, ]); + WebDavCacheService.instance.registerFolder(resource.url, newFolder); // This aims to prevent this issue: https://inxt.atlassian.net/browse/PB-1446 await AsyncUtils.sleep(500); diff --git a/src/webdav/handlers/MOVE.handler.ts b/src/webdav/handlers/MOVE.handler.ts index 0a4f467d..d09ad5f1 100644 --- a/src/webdav/handlers/MOVE.handler.ts +++ b/src/webdav/handlers/MOVE.handler.ts @@ -7,6 +7,7 @@ import { NotFoundError } from '../../utils/errors.utils'; import { webdavLogger } from '../../utils/logger.utils'; import { WebDavUtils } from '../../utils/webdav.utils'; import { WebDavFolderService } from '../../services/webdav/webdav-folder.service'; +import { WebDavCacheService } from '../../services/webdav/webdav-cache.service'; export class MOVERequestHandler implements WebDavMethodHandler { handle = async (req: Request, res: Response) => { @@ -23,7 +24,7 @@ export class MOVERequestHandler implements WebDavMethodHandler { webdavLogger.info('[MOVE] Destination resource found', { destinationResource }); - const originalDriveItem = await WebDavUtils.getDriveItemFromResource(resource); + const originalDriveItem = await WebDavCacheService.instance.getItemFromResource(resource); if (!originalDriveItem) { throw new NotFoundError(`Resource not found on Internxt Drive at ${resource.url}`); @@ -89,6 +90,8 @@ export class MOVERequestHandler implements WebDavMethodHandler { updatedAt: new Date(), }, ]); + WebDavCacheService.instance.invalidateResource(resource.url); + WebDavCacheService.instance.invalidateResource(destinationResource.url); res.status(204).send(); }; diff --git a/src/webdav/handlers/PROPFIND.handler.ts b/src/webdav/handlers/PROPFIND.handler.ts index 7db8dcac..6399d79d 100644 --- a/src/webdav/handlers/PROPFIND.handler.ts +++ b/src/webdav/handlers/PROPFIND.handler.ts @@ -3,8 +3,6 @@ import { XMLUtils } from '../../utils/xml.utils'; import { DriveFileItem, DriveFolderItem } from '../../types/drive.types'; import { DriveItemBD } from '../../services/database/drive-item/drive-item.domain'; import { DriveItemRepository } from '../../services/database/drive-item/drive-item.repository'; -import { DriveFolderService } from '../../services/drive/drive-folder.service'; -import { DriveUtils } from '../../utils/drive.utils'; import { FormatUtils } from '../../utils/format.utils'; import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; @@ -12,13 +10,14 @@ import mime from 'mime-types'; import { WebDavUtils } from '../../utils/webdav.utils'; import { webdavLogger } from '../../utils/logger.utils'; import { UsageService } from '../../services/usage.service'; +import { WebDavCacheService } from '../../services/webdav/webdav-cache.service'; export class PROPFINDRequestHandler implements WebDavMethodHandler { handle = async (req: Request, res: Response) => { const resource = await WebDavUtils.getRequestedResource(req.url); webdavLogger.info(`[PROPFIND] Request received for item at ${resource.url}`); - const driveItem = await WebDavUtils.getDriveItemFromResource(resource); + const driveItem = await WebDavCacheService.instance.getItemFromResource(resource); if (!driveItem) { res.status(404).send(); @@ -93,31 +92,15 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { }; private readonly getFolderChildsXMLNode = async (relativePath: string, folderUuid: string) => { - const folderContent = await DriveFolderService.instance.getFolderContent(folderUuid); + const folderContent = await WebDavCacheService.instance.getFolderContent(relativePath, folderUuid); const xmlNodes: object[] = []; const cachedItems: DriveItemBD[] = []; for (const folder of folderContent.folders) { - const folderRelativePath = WebDavUtils.joinURL(relativePath, folder.plainName, '/'); + const folderRelativePath = WebDavUtils.joinURL(relativePath, folder.name, '/'); - xmlNodes.push( - this.driveFolderItemToXMLNode( - { - itemType: 'folder', - name: folder.plainName, - bucket: folder.bucket, - status: folder.deleted || folder.removed ? 'TRASHED' : 'EXISTS', - createdAt: new Date(folder.createdAt), - updatedAt: new Date(folder.updatedAt), - creationTime: new Date(folder.creationTime), - modificationTime: new Date(folder.modificationTime), - uuid: folder.uuid, - parentUuid: folder.parentUuid, - }, - folderRelativePath, - ), - ); + xmlNodes.push(this.driveFolderItemToXMLNode(folder, folderRelativePath)); cachedItems.push( new DriveItemBD({ @@ -131,31 +114,9 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { } for (const file of folderContent.files) { - const fileRelativePath = WebDavUtils.joinURL( - relativePath, - file.type ? `${file.plainName}.${file.type}` : file.plainName, - ); + const fileRelativePath = WebDavUtils.joinURL(relativePath, file.type ? `${file.name}.${file.type}` : file.name); - xmlNodes.push( - this.driveFileItemToXMLNode( - { - itemType: 'file', - name: file.plainName, - bucket: file.bucket, - fileId: file.fileId, - uuid: file.uuid, - type: file.type, - status: file.status, - folderUuid: file.folderUuid, - size: DriveUtils.parseFileSize(file.size), - creationTime: new Date(file.creationTime), - modificationTime: new Date(file.modificationTime), - createdAt: new Date(file.createdAt), - updatedAt: new Date(file.updatedAt), - }, - fileRelativePath, - ), - ); + xmlNodes.push(this.driveFileItemToXMLNode(file, fileRelativePath)); cachedItems.push( new DriveItemBD({ diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index c8899801..7e0d02e2 100644 --- a/src/webdav/handlers/PUT.handler.ts +++ b/src/webdav/handlers/PUT.handler.ts @@ -12,6 +12,7 @@ import { WebDavFolderService } from '../../services/webdav/webdav-folder.service import { ThumbnailService } from '../../services/thumbnail.service'; import { FormatUtils } from '../../utils/format.utils'; import { UploadUtils } from '../../utils/upload.utils'; +import { WebDavCacheService } from '../../services/webdav/webdav-cache.service'; export class PUTRequestHandler implements WebDavMethodHandler { handle = async (req: Request, res: Response) => { @@ -42,7 +43,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { // If the file already exists, the WebDAV specification states that 'PUT /…/file' should replace it. // http://www.webdav.org/specs/rfc4918.html#put-resources - const driveFileItem = await WebDavUtils.getDriveItemFromResource(resource); + const driveFileItem = await WebDavCacheService.instance.getItemFromResource(resource); if (driveFileItem && driveFileItem.status === 'EXISTS') { if (driveFileItem.itemType === 'folder') { webdavLogger.info('[PUT] ❌ A folder exists on the cloud with the same name.'); @@ -55,6 +56,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { try { await WebDavUtils.deleteOrTrashItem(driveFileItem); await DriveItemRepository.instance.delete([driveFileItem.uuid]); + WebDavCacheService.instance.invalidateResource(resource.url); } catch { //noop } @@ -124,6 +126,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { updatedAt: new Date(), }, ]); + WebDavCacheService.instance.registerFile(resource.url, file); const thumbnailTimer = CLIUtils.timer(); await ThumbnailService.instance.tryUploadThumbnail({ diff --git a/test/vitest.setup.ts b/test/vitest.setup.ts index 26784920..29a8718f 100644 --- a/test/vitest.setup.ts +++ b/test/vitest.setup.ts @@ -1,4 +1,5 @@ -import { vi } from 'vitest'; +import { beforeEach, vi } from 'vitest'; +import { WebDavCacheService } from '../src/services/webdav/webdav-cache.service'; vi.mock('../src/utils/logger.utils', () => ({ logger: { @@ -14,3 +15,7 @@ vi.mock('../src/utils/logger.utils', () => ({ debug: vi.fn(), }, })); + +beforeEach(() => { + WebDavCacheService.instance.clear(); +});