From 9ec4cc66842427edfffcb86524bfd1a58baf12e7 Mon Sep 17 00:00:00 2001 From: larryrider Date: Fri, 24 Jul 2026 17:40:25 +0200 Subject: [PATCH 1/5] feat: implement ETag generation for WebDAV items and update PROPFIND handler to use it --- src/utils/webdav.utils.ts | 18 +++++ src/webdav/handlers/PROPFIND.handler.ts | 7 +- test/utils/webdav.utils.test.ts | 76 +++++++++++++++++++ test/webdav/handlers/PROPFIND.handler.test.ts | 43 +++++------ 4 files changed, 115 insertions(+), 29 deletions(-) diff --git a/src/utils/webdav.utils.ts b/src/utils/webdav.utils.ts index ff26c096..3949bd71 100644 --- a/src/utils/webdav.utils.ts +++ b/src/utils/webdav.utils.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { createHash } from 'node:crypto'; import { WebDavRequestedResource } from '../types/webdav.types'; import { DriveFileItem, DriveFolderItem, DriveItem } from '../types/drive.types'; import { DriveItemService } from '../services/drive/drive-item.service'; @@ -109,4 +110,21 @@ export class WebDavUtils { } await DriveItemRepository.instance.delete([driveItem.uuid]); } + + static generateETag(parts: Array): string { + const normalized = parts.map((part) => (part instanceof Date ? part.getTime() : (part ?? ''))); + const hash = createHash('sha256').update(normalized.join('|')).digest('hex'); + return `"${hash}"`; + } + + static getItemETag(driveItem: DriveFileItem | DriveFolderItem): string { + return this.generateETag([ + driveItem.uuid, + driveItem.itemType === 'file' ? driveItem.size : undefined, + driveItem.createdAt, + driveItem.updatedAt, + driveItem.creationTime, + driveItem.modificationTime, + ]); + } } diff --git a/src/webdav/handlers/PROPFIND.handler.ts b/src/webdav/handlers/PROPFIND.handler.ts index 7db8dcac..a14094d0 100644 --- a/src/webdav/handlers/PROPFIND.handler.ts +++ b/src/webdav/handlers/PROPFIND.handler.ts @@ -7,7 +7,6 @@ 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'; import mime from 'mime-types'; import { WebDavUtils } from '../../utils/webdav.utils'; import { webdavLogger } from '../../utils/logger.utils'; @@ -188,7 +187,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { [XMLUtils.addDefaultNamespace('status')]: 'HTTP/1.1 200 OK', [XMLUtils.addDefaultNamespace('prop')]: { [XMLUtils.addDefaultNamespace('getcontenttype')]: 'httpd/unix-directory', - [XMLUtils.addDefaultNamespace('getetag')]: '"' + randomUUID().replaceAll('-', '') + '"', + [XMLUtils.addDefaultNamespace('getetag')]: WebDavUtils.getItemETag(driveFolderItem), 'x1:lastmodified': { '#text': FormatUtils.formatDateForWebDav(driveFolderItem.updatedAt), '@_xmlns:x1': 'SAR:', @@ -224,7 +223,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { [XMLUtils.addDefaultNamespace('getlastmodified')]: FormatUtils.formatDateForWebDav(driveFolderItem.updatedAt), [XMLUtils.addDefaultNamespace('getcontentlength')]: 0, [XMLUtils.addDefaultNamespace('getcontenttype')]: 'httpd/unix-directory', - [XMLUtils.addDefaultNamespace('getetag')]: '"' + randomUUID().replaceAll('-', '') + '"', + [XMLUtils.addDefaultNamespace('getetag')]: WebDavUtils.getItemETag(driveFolderItem), [XMLUtils.addDefaultNamespace('resourcetype')]: { [XMLUtils.addDefaultNamespace('collection')]: '', }, @@ -245,7 +244,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { [XMLUtils.addDefaultNamespace('status')]: 'HTTP/1.1 200 OK', [XMLUtils.addDefaultNamespace('prop')]: { [XMLUtils.addDefaultNamespace('resourcetype')]: '', - [XMLUtils.addDefaultNamespace('getetag')]: '"' + randomUUID().replaceAll('-', '') + '"', + [XMLUtils.addDefaultNamespace('getetag')]: WebDavUtils.getItemETag(driveFileItem), [XMLUtils.addDefaultNamespace('displayname')]: displayName, [XMLUtils.addDefaultNamespace('getcontenttype')]: mime.lookup(displayName) || 'application/octet-stream', [XMLUtils.addDefaultNamespace('getlastmodified')]: lastModified, diff --git a/test/utils/webdav.utils.test.ts b/test/utils/webdav.utils.test.ts index 12750973..f9357942 100644 --- a/test/utils/webdav.utils.test.ts +++ b/test/utils/webdav.utils.test.ts @@ -166,6 +166,82 @@ describe('Webdav utils', () => { }); }); + describe('generateETag', () => { + test('when the same parts are given, then the same etag is generated', () => { + const date = new Date('2024-03-04T15:11:01.000Z'); + const etag1 = WebDavUtils.generateETag(['uuid-1', 100, date]); + const etag2 = WebDavUtils.generateETag(['uuid-1', 100, date]); + + expect(etag1).to.be.equal(etag2); + }); + + test('when the parts are wrapped in quotes, then a quoted etag is returned', () => { + const etag = WebDavUtils.generateETag(['uuid-1']); + + expect(etag.startsWith('"')).to.be.true; + expect(etag.endsWith('"')).to.be.true; + }); + + test('when any part differs, then a different etag is generated', () => { + const date = new Date('2024-03-04T15:11:01.000Z'); + const baseEtag = WebDavUtils.generateETag(['uuid-1', 100, date]); + + expect(WebDavUtils.generateETag(['uuid-2', 100, date])).to.not.be.equal(baseEtag); + expect(WebDavUtils.generateETag(['uuid-1', 200, date])).to.not.be.equal(baseEtag); + expect(WebDavUtils.generateETag(['uuid-1', 100, new Date('2024-03-04T15:11:02.000Z')])).to.not.be.equal(baseEtag); + }); + + test('when a Date is given, then it is normalized using its timestamp', () => { + const date = new Date('2024-03-04T15:11:01.000Z'); + const etagFromDate = WebDavUtils.generateETag(['uuid-1', date]); + const etagFromTimestamp = WebDavUtils.generateETag(['uuid-1', date.getTime()]); + + expect(etagFromDate).to.be.equal(etagFromTimestamp); + }); + + test('when null or undefined parts are given, then they are treated as equal empty values', () => { + const etagFromNull = WebDavUtils.generateETag(['uuid-1', null]); + const etagFromUndefined = WebDavUtils.generateETag(['uuid-1', undefined]); + + expect(etagFromNull).to.be.equal(etagFromUndefined); + }); + }); + + describe('getItemETag', () => { + test('when the same file is given, then the same etag is generated', () => { + const fileItem = newFileItem(); + + expect(WebDavUtils.getItemETag(fileItem)).to.be.equal(WebDavUtils.getItemETag(fileItem)); + }); + + test('when a file changes size, then the etag changes', () => { + const fileItem = newFileItem({ size: 100 }); + const resizedFileItem = { ...fileItem, size: 200 }; + + expect(WebDavUtils.getItemETag(resizedFileItem)).to.not.be.equal(WebDavUtils.getItemETag(fileItem)); + }); + + test('when a file changes modificationTime, then the etag changes', () => { + const fileItem = newFileItem({ modificationTime: new Date('2024-01-01T00:00:00.000Z') }); + const touchedFileItem = { ...fileItem, modificationTime: new Date('2024-02-02T00:00:00.000Z') }; + + expect(WebDavUtils.getItemETag(touchedFileItem)).to.not.be.equal(WebDavUtils.getItemETag(fileItem)); + }); + + test('when two folders share uuid and dates, then they get the same etag regardless of size', () => { + const folderItem = newFolderItem(); + + expect(WebDavUtils.getItemETag(folderItem)).to.be.equal(WebDavUtils.getItemETag({ ...folderItem })); + }); + + test('when two items have different uuids, then they get different etags', () => { + const fileItem = newFileItem({ uuid: 'uuid-1' }); + const otherFileItem = { ...fileItem, uuid: 'uuid-2' }; + + expect(WebDavUtils.getItemETag(fileItem)).to.not.be.equal(WebDavUtils.getItemETag(otherFileItem)); + }); + }); + describe('deleteOrTrashItem', () => { test('when permanent deletion is enabled for files, then files are deleted permanently and cache is cleared', async () => { const fileItem = newFileItem(); diff --git a/test/webdav/handlers/PROPFIND.handler.test.ts b/test/webdav/handlers/PROPFIND.handler.test.ts index 176de18f..ccee5681 100644 --- a/test/webdav/handlers/PROPFIND.handler.test.ts +++ b/test/webdav/handlers/PROPFIND.handler.test.ts @@ -15,19 +15,14 @@ import { FormatUtils } from '../../../src/utils/format.utils'; import { WebDavRequestedResource } from '../../../src/types/webdav.types'; import { WebDavUtils } from '../../../src/utils/webdav.utils'; import mime from 'mime-types'; -import crypto, { randomUUID } from 'node:crypto'; +import crypto from 'node:crypto'; import { UsageService } from '../../../src/services/usage.service'; import { XMLUtils } from '../../../src/utils/xml.utils'; +import { DriveFileItem, DriveFolderItem } from '../../../src/types/drive.types'; -vi.mock('node:crypto', async () => { - const actual = await vi.importActual('node:crypto'); - return { - ...(actual as object), - randomUUID: vi.fn().mockImplementation(actual.randomUUID), - }; -}); - -const randomUUIDStub = vi.mocked(randomUUID); +const getExpectedETag = (item: DriveFileItem | DriveFolderItem): string => { + return WebDavUtils.getItemETag(item).replaceAll('"', ''); +}; describe('PROPFIND request handler', () => { let sut: PROPFINDRequestHandler; @@ -57,11 +52,8 @@ describe('PROPFIND request handler', () => { }); const usageFixture = crypto.randomInt(2000000000); const spaceLimitFixture = crypto.randomInt(2000000000); - const uuidFixture = 'test-test-test-test-test'; - const etagFixture = uuidFixture.replaceAll('-', ''); + const etagFixture = getExpectedETag(folderFixture); - randomUUIDStub.mockClear(); - randomUUIDStub.mockImplementation(() => uuidFixture); const getRequestedResourceStub = vi .spyOn(WebDavUtils, 'getRequestedResource') .mockResolvedValue(requestedFolderResource); @@ -141,11 +133,16 @@ describe('PROPFIND request handler', () => { }); const usageFixture = crypto.randomInt(2000000000); const spaceLimitFixture = crypto.randomInt(2000000000); - const uuidFixture = 'test-test-test-test-test'; - const etagFixture = uuidFixture.replaceAll('-', ''); - - randomUUIDStub.mockClear(); - randomUUIDStub.mockImplementation(() => uuidFixture); + const etagFixture = getExpectedETag(folderFixture); + const childEtagFixture = getExpectedETag( + newFolderItem({ + uuid: paginatedFolder1.uuid, + createdAt: new Date(paginatedFolder1.createdAt), + updatedAt: new Date(paginatedFolder1.updatedAt), + creationTime: new Date(paginatedFolder1.creationTime), + modificationTime: new Date(paginatedFolder1.modificationTime), + }), + ); const getRequestedResourceStub = vi .spyOn(WebDavUtils, 'getRequestedResource') @@ -163,7 +160,7 @@ describe('PROPFIND request handler', () => { await sut.handle(request, response); expect(response.status).toHaveBeenCalledWith(207); expect(response.send).toHaveBeenCalledWith( - `${XMLUtils.encodeWebDavUri('/')}HTTP/1.1 200 OKhttpd/unix-directory"${etagFixture}"${FormatUtils.formatDateForWebDav(folderFixture.updatedAt)}F00000030${spaceLimitFixture - usageFixture}${usageFixture}${XMLUtils.encodeWebDavUri(`/${paginatedFolder1.plainName}/`)}HTTP/1.1 200 OK${paginatedFolder1.plainName}${FormatUtils.formatDateForWebDav(paginatedFolder1.updatedAt)}0httpd/unix-directory"${etagFixture}"`, + `${XMLUtils.encodeWebDavUri('/')}HTTP/1.1 200 OKhttpd/unix-directory"${etagFixture}"${FormatUtils.formatDateForWebDav(folderFixture.updatedAt)}F00000030${spaceLimitFixture - usageFixture}${usageFixture}${XMLUtils.encodeWebDavUri(`/${paginatedFolder1.plainName}/`)}HTTP/1.1 200 OK${paginatedFolder1.plainName}${FormatUtils.formatDateForWebDav(paginatedFolder1.updatedAt)}0httpd/unix-directory"${childEtagFixture}"`, ); expect(getRequestedResourceStub).toHaveBeenCalledOnce(); expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce(); @@ -224,8 +221,7 @@ describe('PROPFIND request handler', () => { }); const fileFixture = newFileItem({ name: 'file', type: 'png' }); - const uuidFixture = 'test-test-test-test-test'; - const etagFixture = uuidFixture.replaceAll('-', ''); + const etagFixture = getExpectedETag(fileFixture); const mimeFixture = 'image/png'; const getRequestedResourceStub = vi @@ -234,8 +230,6 @@ describe('PROPFIND request handler', () => { const getAndSearchItemFromResourceStub = vi .spyOn(WebDavUtils, 'getDriveItemFromResource') .mockResolvedValue(fileFixture); - randomUUIDStub.mockClear(); - randomUUIDStub.mockImplementation(() => uuidFixture); const mimeLookupStub = vi.spyOn(mime, 'lookup').mockReturnValue(mimeFixture); await sut.handle(request, response); @@ -245,7 +239,6 @@ describe('PROPFIND request handler', () => { ); expect(getRequestedResourceStub).toHaveBeenCalledOnce(); expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce(); - expect(randomUUIDStub).toHaveBeenCalledOnce(); expect(mimeLookupStub).toHaveBeenCalledOnce(); }); From 052dd2bdd217d026428b967f0050c836b9aa98ea Mon Sep 17 00:00:00 2001 From: larryrider Date: Fri, 24 Jul 2026 17:47:21 +0200 Subject: [PATCH 2/5] feat: add ETag header to GET handler and fix tests --- src/webdav/handlers/GET.handler.ts | 1 + test/webdav/handlers/GET.handler.test.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/webdav/handlers/GET.handler.ts b/src/webdav/handlers/GET.handler.ts index 1e28fdf0..15cd1cb6 100644 --- a/src/webdav/handlers/GET.handler.ts +++ b/src/webdav/handlers/GET.handler.ts @@ -28,6 +28,7 @@ export class GETRequestHandler implements WebDavMethodHandler { res.header('Content-Type', 'application/octet-stream'); res.header('Accept-Ranges', 'bytes'); + res.header('ETag', WebDavUtils.getItemETag(driveFile)); const fileSize = driveFile.size ?? 0; diff --git a/test/webdav/handlers/GET.handler.test.ts b/test/webdav/handlers/GET.handler.test.ts index 576979c3..ec7760b1 100644 --- a/test/webdav/handlers/GET.handler.test.ts +++ b/test/webdav/handlers/GET.handler.test.ts @@ -92,6 +92,7 @@ describe('GET request handler', () => { expect(response.status).toHaveBeenCalledWith(200); expect(response.header).toHaveBeenCalledWith('Content-length', mockFile.size.toString()); expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream'); + expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFile)); expect(getRequestedResourceStub).toHaveBeenCalledOnce(); expect(getFileMetadataStub).toHaveBeenCalledOnce(); expect(authDetailsStub).toHaveBeenCalledOnce(); @@ -147,6 +148,7 @@ describe('GET request handler', () => { expect(response.status).toHaveBeenCalledWith(200); expect(response.header).toHaveBeenCalledWith('Content-length', (mockSize - rangeStart).toString()); expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream'); + expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFile)); expect(getRequestedResourceStub).toHaveBeenCalledOnce(); expect(getFileMetadataStub).toHaveBeenCalledOnce(); expect(authDetailsStub).toHaveBeenCalledOnce(); @@ -190,6 +192,7 @@ describe('GET request handler', () => { expect(response.status).toHaveBeenCalledWith(200); expect(response.header).toHaveBeenCalledWith('Content-length', Number(0).toString()); expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream'); + expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFile)); expect(getRequestedResourceStub).toHaveBeenCalledOnce(); expect(getFileMetadataStub).toHaveBeenCalledOnce(); expect(authDetailsStub).toHaveBeenCalledOnce(); From 928e6582959deecb31280a27efc038ab6e62fe8c Mon Sep 17 00:00:00 2001 From: larryrider Date: Fri, 24 Jul 2026 17:47:51 +0200 Subject: [PATCH 3/5] feat: add ETag header to HEAD response and update tests --- src/webdav/handlers/HEAD.handler.ts | 2 ++ test/webdav/handlers/HEAD.handler.test.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/webdav/handlers/HEAD.handler.ts b/src/webdav/handlers/HEAD.handler.ts index 52c997be..db930612 100644 --- a/src/webdav/handlers/HEAD.handler.ts +++ b/src/webdav/handlers/HEAD.handler.ts @@ -19,6 +19,8 @@ export class HEADRequestHandler implements WebDavMethodHandler { webdavLogger.info(`[HEAD] [${driveItem.uuid}] Found Drive item`); + res.header('ETag', WebDavUtils.getItemETag(driveItem)); + if (driveItem.itemType === 'file') { const range = req.headers['range']; const rangeOptions = NetworkUtils.parseRangeHeader({ diff --git a/test/webdav/handlers/HEAD.handler.test.ts b/test/webdav/handlers/HEAD.handler.test.ts index 10ae1b32..067e3c56 100644 --- a/test/webdav/handlers/HEAD.handler.test.ts +++ b/test/webdav/handlers/HEAD.handler.test.ts @@ -40,6 +40,7 @@ describe('HEAD request handler', () => { await sut.handle(request, response); expect(response.status).toHaveBeenCalledWith(200); + expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFolder)); expect(getRequestedResourceStub).toHaveBeenCalledOnce(); expect(getFolderMetadataStub).toHaveBeenCalledOnce(); }); @@ -68,6 +69,7 @@ describe('HEAD request handler', () => { expect(response.status).toHaveBeenCalledWith(200); expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream'); expect(response.header).toHaveBeenCalledWith('Content-length', mockFile.size.toString()); + expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFile)); expect(getRequestedResourceStub).toHaveBeenCalledOnce(); expect(getFileMetadataStub).toHaveBeenCalledOnce(); }); @@ -100,6 +102,7 @@ describe('HEAD request handler', () => { expect(response.status).toHaveBeenCalledWith(200); expect(response.header).toHaveBeenCalledWith('Content-length', (mockSize - rangeStart).toString()); expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream'); + expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFile)); expect(getRequestedResourceStub).toHaveBeenCalledOnce(); expect(getFileMetadataStub).toHaveBeenCalledOnce(); }); From 988369f49fe3161b3af20045afb530947775d3e1 Mon Sep 17 00:00:00 2001 From: larryrider Date: Fri, 24 Jul 2026 17:49:09 +0200 Subject: [PATCH 4/5] feat: add ETag header to PUT response and update tests --- src/webdav/handlers/PUT.handler.ts | 1 + test/webdav/handlers/PUT.handler.test.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index c8899801..2eb1f9c3 100644 --- a/src/webdav/handlers/PUT.handler.ts +++ b/src/webdav/handlers/PUT.handler.ts @@ -150,6 +150,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { `after ${CLIUtils.formatDuration(totalTime)}`, ); + res.header('ETag', WebDavUtils.getItemETag(file)); res.status(statusCode).send(); }; } diff --git a/test/webdav/handlers/PUT.handler.test.ts b/test/webdav/handlers/PUT.handler.test.ts index ee20c94f..c0bccf35 100644 --- a/test/webdav/handlers/PUT.handler.test.ts +++ b/test/webdav/handlers/PUT.handler.test.ts @@ -76,6 +76,7 @@ describe('PUT request handler', () => { await sut.handle(request, response); expect(response.status).toHaveBeenCalledWith(201); + expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(fileFixture)); expect(getRequestedResourceStub).toHaveBeenCalledTimes(2); expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce(); expect(getDriveFolderFromResourceStub).toHaveBeenCalledOnce(); @@ -123,6 +124,7 @@ describe('PUT request handler', () => { await sut.handle(request, response); expect(response.status).toHaveBeenCalledWith(201); + expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(fileFixture)); expect(getRequestedResourceStub).toHaveBeenCalledTimes(2); expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce(); expect(getDriveFolderFromResourceStub).toHaveBeenCalledOnce(); @@ -171,6 +173,7 @@ describe('PUT request handler', () => { await sut.handle(request, response); expect(response.status).toHaveBeenCalledWith(204); + expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(fileFixture)); expect(getRequestedResourceStub).toHaveBeenCalledTimes(2); expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce(); expect(getDriveFolderFromResourceStub).toHaveBeenCalledOnce(); From 1bcb7bca0ce9bf20f4222db47a3ebaf83f1c6e00 Mon Sep 17 00:00:00 2001 From: larryrider Date: Fri, 24 Jul 2026 17:49:45 +0200 Subject: [PATCH 5/5] fix: ETag generation tests to use toBe matcher --- test/utils/webdav.utils.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/utils/webdav.utils.test.ts b/test/utils/webdav.utils.test.ts index f9357942..24a3bc95 100644 --- a/test/utils/webdav.utils.test.ts +++ b/test/utils/webdav.utils.test.ts @@ -178,8 +178,8 @@ describe('Webdav utils', () => { test('when the parts are wrapped in quotes, then a quoted etag is returned', () => { const etag = WebDavUtils.generateETag(['uuid-1']); - expect(etag.startsWith('"')).to.be.true; - expect(etag.endsWith('"')).to.be.true; + expect(etag.startsWith('"')).toBe(true); + expect(etag.endsWith('"')).toBe(true); }); test('when any part differs, then a different etag is generated', () => {