Skip to content

Commit 9ec4cc6

Browse files
committed
feat: implement ETag generation for WebDAV items and update PROPFIND handler to use it
1 parent cbb3613 commit 9ec4cc6

4 files changed

Lines changed: 115 additions & 29 deletions

File tree

src/utils/webdav.utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from 'node:path';
2+
import { createHash } from 'node:crypto';
23
import { WebDavRequestedResource } from '../types/webdav.types';
34
import { DriveFileItem, DriveFolderItem, DriveItem } from '../types/drive.types';
45
import { DriveItemService } from '../services/drive/drive-item.service';
@@ -109,4 +110,21 @@ export class WebDavUtils {
109110
}
110111
await DriveItemRepository.instance.delete([driveItem.uuid]);
111112
}
113+
114+
static generateETag(parts: Array<string | number | Date | null | undefined>): string {
115+
const normalized = parts.map((part) => (part instanceof Date ? part.getTime() : (part ?? '')));
116+
const hash = createHash('sha256').update(normalized.join('|')).digest('hex');
117+
return `"${hash}"`;
118+
}
119+
120+
static getItemETag(driveItem: DriveFileItem | DriveFolderItem): string {
121+
return this.generateETag([
122+
driveItem.uuid,
123+
driveItem.itemType === 'file' ? driveItem.size : undefined,
124+
driveItem.createdAt,
125+
driveItem.updatedAt,
126+
driveItem.creationTime,
127+
driveItem.modificationTime,
128+
]);
129+
}
112130
}

src/webdav/handlers/PROPFIND.handler.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { DriveFolderService } from '../../services/drive/drive-folder.service';
77
import { DriveUtils } from '../../utils/drive.utils';
88
import { FormatUtils } from '../../utils/format.utils';
99
import { Request, Response } from 'express';
10-
import { randomUUID } from 'node:crypto';
1110
import mime from 'mime-types';
1211
import { WebDavUtils } from '../../utils/webdav.utils';
1312
import { webdavLogger } from '../../utils/logger.utils';
@@ -188,7 +187,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler {
188187
[XMLUtils.addDefaultNamespace('status')]: 'HTTP/1.1 200 OK',
189188
[XMLUtils.addDefaultNamespace('prop')]: {
190189
[XMLUtils.addDefaultNamespace('getcontenttype')]: 'httpd/unix-directory',
191-
[XMLUtils.addDefaultNamespace('getetag')]: '"' + randomUUID().replaceAll('-', '') + '"',
190+
[XMLUtils.addDefaultNamespace('getetag')]: WebDavUtils.getItemETag(driveFolderItem),
192191
'x1:lastmodified': {
193192
'#text': FormatUtils.formatDateForWebDav(driveFolderItem.updatedAt),
194193
'@_xmlns:x1': 'SAR:',
@@ -224,7 +223,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler {
224223
[XMLUtils.addDefaultNamespace('getlastmodified')]: FormatUtils.formatDateForWebDav(driveFolderItem.updatedAt),
225224
[XMLUtils.addDefaultNamespace('getcontentlength')]: 0,
226225
[XMLUtils.addDefaultNamespace('getcontenttype')]: 'httpd/unix-directory',
227-
[XMLUtils.addDefaultNamespace('getetag')]: '"' + randomUUID().replaceAll('-', '') + '"',
226+
[XMLUtils.addDefaultNamespace('getetag')]: WebDavUtils.getItemETag(driveFolderItem),
228227
[XMLUtils.addDefaultNamespace('resourcetype')]: {
229228
[XMLUtils.addDefaultNamespace('collection')]: '',
230229
},
@@ -245,7 +244,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler {
245244
[XMLUtils.addDefaultNamespace('status')]: 'HTTP/1.1 200 OK',
246245
[XMLUtils.addDefaultNamespace('prop')]: {
247246
[XMLUtils.addDefaultNamespace('resourcetype')]: '',
248-
[XMLUtils.addDefaultNamespace('getetag')]: '"' + randomUUID().replaceAll('-', '') + '"',
247+
[XMLUtils.addDefaultNamespace('getetag')]: WebDavUtils.getItemETag(driveFileItem),
249248
[XMLUtils.addDefaultNamespace('displayname')]: displayName,
250249
[XMLUtils.addDefaultNamespace('getcontenttype')]: mime.lookup(displayName) || 'application/octet-stream',
251250
[XMLUtils.addDefaultNamespace('getlastmodified')]: lastModified,

test/utils/webdav.utils.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,82 @@ describe('Webdav utils', () => {
166166
});
167167
});
168168

169+
describe('generateETag', () => {
170+
test('when the same parts are given, then the same etag is generated', () => {
171+
const date = new Date('2024-03-04T15:11:01.000Z');
172+
const etag1 = WebDavUtils.generateETag(['uuid-1', 100, date]);
173+
const etag2 = WebDavUtils.generateETag(['uuid-1', 100, date]);
174+
175+
expect(etag1).to.be.equal(etag2);
176+
});
177+
178+
test('when the parts are wrapped in quotes, then a quoted etag is returned', () => {
179+
const etag = WebDavUtils.generateETag(['uuid-1']);
180+
181+
expect(etag.startsWith('"')).to.be.true;
182+
expect(etag.endsWith('"')).to.be.true;
183+
});
184+
185+
test('when any part differs, then a different etag is generated', () => {
186+
const date = new Date('2024-03-04T15:11:01.000Z');
187+
const baseEtag = WebDavUtils.generateETag(['uuid-1', 100, date]);
188+
189+
expect(WebDavUtils.generateETag(['uuid-2', 100, date])).to.not.be.equal(baseEtag);
190+
expect(WebDavUtils.generateETag(['uuid-1', 200, date])).to.not.be.equal(baseEtag);
191+
expect(WebDavUtils.generateETag(['uuid-1', 100, new Date('2024-03-04T15:11:02.000Z')])).to.not.be.equal(baseEtag);
192+
});
193+
194+
test('when a Date is given, then it is normalized using its timestamp', () => {
195+
const date = new Date('2024-03-04T15:11:01.000Z');
196+
const etagFromDate = WebDavUtils.generateETag(['uuid-1', date]);
197+
const etagFromTimestamp = WebDavUtils.generateETag(['uuid-1', date.getTime()]);
198+
199+
expect(etagFromDate).to.be.equal(etagFromTimestamp);
200+
});
201+
202+
test('when null or undefined parts are given, then they are treated as equal empty values', () => {
203+
const etagFromNull = WebDavUtils.generateETag(['uuid-1', null]);
204+
const etagFromUndefined = WebDavUtils.generateETag(['uuid-1', undefined]);
205+
206+
expect(etagFromNull).to.be.equal(etagFromUndefined);
207+
});
208+
});
209+
210+
describe('getItemETag', () => {
211+
test('when the same file is given, then the same etag is generated', () => {
212+
const fileItem = newFileItem();
213+
214+
expect(WebDavUtils.getItemETag(fileItem)).to.be.equal(WebDavUtils.getItemETag(fileItem));
215+
});
216+
217+
test('when a file changes size, then the etag changes', () => {
218+
const fileItem = newFileItem({ size: 100 });
219+
const resizedFileItem = { ...fileItem, size: 200 };
220+
221+
expect(WebDavUtils.getItemETag(resizedFileItem)).to.not.be.equal(WebDavUtils.getItemETag(fileItem));
222+
});
223+
224+
test('when a file changes modificationTime, then the etag changes', () => {
225+
const fileItem = newFileItem({ modificationTime: new Date('2024-01-01T00:00:00.000Z') });
226+
const touchedFileItem = { ...fileItem, modificationTime: new Date('2024-02-02T00:00:00.000Z') };
227+
228+
expect(WebDavUtils.getItemETag(touchedFileItem)).to.not.be.equal(WebDavUtils.getItemETag(fileItem));
229+
});
230+
231+
test('when two folders share uuid and dates, then they get the same etag regardless of size', () => {
232+
const folderItem = newFolderItem();
233+
234+
expect(WebDavUtils.getItemETag(folderItem)).to.be.equal(WebDavUtils.getItemETag({ ...folderItem }));
235+
});
236+
237+
test('when two items have different uuids, then they get different etags', () => {
238+
const fileItem = newFileItem({ uuid: 'uuid-1' });
239+
const otherFileItem = { ...fileItem, uuid: 'uuid-2' };
240+
241+
expect(WebDavUtils.getItemETag(fileItem)).to.not.be.equal(WebDavUtils.getItemETag(otherFileItem));
242+
});
243+
});
244+
169245
describe('deleteOrTrashItem', () => {
170246
test('when permanent deletion is enabled for files, then files are deleted permanently and cache is cleared', async () => {
171247
const fileItem = newFileItem();

test/webdav/handlers/PROPFIND.handler.test.ts

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,14 @@ import { FormatUtils } from '../../../src/utils/format.utils';
1515
import { WebDavRequestedResource } from '../../../src/types/webdav.types';
1616
import { WebDavUtils } from '../../../src/utils/webdav.utils';
1717
import mime from 'mime-types';
18-
import crypto, { randomUUID } from 'node:crypto';
18+
import crypto from 'node:crypto';
1919
import { UsageService } from '../../../src/services/usage.service';
2020
import { XMLUtils } from '../../../src/utils/xml.utils';
21+
import { DriveFileItem, DriveFolderItem } from '../../../src/types/drive.types';
2122

22-
vi.mock('node:crypto', async () => {
23-
const actual = await vi.importActual<typeof import('node:crypto')>('node:crypto');
24-
return {
25-
...(actual as object),
26-
randomUUID: vi.fn().mockImplementation(actual.randomUUID),
27-
};
28-
});
29-
30-
const randomUUIDStub = vi.mocked(randomUUID);
23+
const getExpectedETag = (item: DriveFileItem | DriveFolderItem): string => {
24+
return WebDavUtils.getItemETag(item).replaceAll('"', '');
25+
};
3126

3227
describe('PROPFIND request handler', () => {
3328
let sut: PROPFINDRequestHandler;
@@ -57,11 +52,8 @@ describe('PROPFIND request handler', () => {
5752
});
5853
const usageFixture = crypto.randomInt(2000000000);
5954
const spaceLimitFixture = crypto.randomInt(2000000000);
60-
const uuidFixture = 'test-test-test-test-test';
61-
const etagFixture = uuidFixture.replaceAll('-', '');
55+
const etagFixture = getExpectedETag(folderFixture);
6256

63-
randomUUIDStub.mockClear();
64-
randomUUIDStub.mockImplementation(() => uuidFixture);
6557
const getRequestedResourceStub = vi
6658
.spyOn(WebDavUtils, 'getRequestedResource')
6759
.mockResolvedValue(requestedFolderResource);
@@ -141,11 +133,16 @@ describe('PROPFIND request handler', () => {
141133
});
142134
const usageFixture = crypto.randomInt(2000000000);
143135
const spaceLimitFixture = crypto.randomInt(2000000000);
144-
const uuidFixture = 'test-test-test-test-test';
145-
const etagFixture = uuidFixture.replaceAll('-', '');
146-
147-
randomUUIDStub.mockClear();
148-
randomUUIDStub.mockImplementation(() => uuidFixture);
136+
const etagFixture = getExpectedETag(folderFixture);
137+
const childEtagFixture = getExpectedETag(
138+
newFolderItem({
139+
uuid: paginatedFolder1.uuid,
140+
createdAt: new Date(paginatedFolder1.createdAt),
141+
updatedAt: new Date(paginatedFolder1.updatedAt),
142+
creationTime: new Date(paginatedFolder1.creationTime),
143+
modificationTime: new Date(paginatedFolder1.modificationTime),
144+
}),
145+
);
149146

150147
const getRequestedResourceStub = vi
151148
.spyOn(WebDavUtils, 'getRequestedResource')
@@ -163,7 +160,7 @@ describe('PROPFIND request handler', () => {
163160
await sut.handle(request, response);
164161
expect(response.status).toHaveBeenCalledWith(207);
165162
expect(response.send).toHaveBeenCalledWith(
166-
`<?xml version="1.0" encoding="utf-8" ?><D:multistatus xmlns:D="DAV:"><D:response><D:href>${XMLUtils.encodeWebDavUri('/')}</D:href><D:propstat><D:status>HTTP/1.1 200 OK</D:status><D:prop><D:getcontenttype>httpd/unix-directory</D:getcontenttype><D:getetag>&quot;${etagFixture}&quot;</D:getetag><x1:lastmodified xmlns:x1="SAR:">${FormatUtils.formatDateForWebDav(folderFixture.updatedAt)}</x1:lastmodified><x2:executable xmlns:x2="http://apache.org/dav/props/">F</x2:executable><x3:Win32FileAttributes xmlns:x3="urn:schemas-microsoft-com:">00000030</x3:Win32FileAttributes><D:quota-available-bytes>${spaceLimitFixture - usageFixture}</D:quota-available-bytes><D:quota-used-bytes>${usageFixture}</D:quota-used-bytes><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response><D:response><D:href>${XMLUtils.encodeWebDavUri(`/${paginatedFolder1.plainName}/`)}</D:href><D:propstat><D:status>HTTP/1.1 200 OK</D:status><D:prop><D:displayname>${paginatedFolder1.plainName}</D:displayname><D:getlastmodified>${FormatUtils.formatDateForWebDav(paginatedFolder1.updatedAt)}</D:getlastmodified><D:getcontentlength>0</D:getcontentlength><D:getcontenttype>httpd/unix-directory</D:getcontenttype><D:getetag>&quot;${etagFixture}&quot;</D:getetag><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response></D:multistatus>`,
163+
`<?xml version="1.0" encoding="utf-8" ?><D:multistatus xmlns:D="DAV:"><D:response><D:href>${XMLUtils.encodeWebDavUri('/')}</D:href><D:propstat><D:status>HTTP/1.1 200 OK</D:status><D:prop><D:getcontenttype>httpd/unix-directory</D:getcontenttype><D:getetag>&quot;${etagFixture}&quot;</D:getetag><x1:lastmodified xmlns:x1="SAR:">${FormatUtils.formatDateForWebDav(folderFixture.updatedAt)}</x1:lastmodified><x2:executable xmlns:x2="http://apache.org/dav/props/">F</x2:executable><x3:Win32FileAttributes xmlns:x3="urn:schemas-microsoft-com:">00000030</x3:Win32FileAttributes><D:quota-available-bytes>${spaceLimitFixture - usageFixture}</D:quota-available-bytes><D:quota-used-bytes>${usageFixture}</D:quota-used-bytes><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response><D:response><D:href>${XMLUtils.encodeWebDavUri(`/${paginatedFolder1.plainName}/`)}</D:href><D:propstat><D:status>HTTP/1.1 200 OK</D:status><D:prop><D:displayname>${paginatedFolder1.plainName}</D:displayname><D:getlastmodified>${FormatUtils.formatDateForWebDav(paginatedFolder1.updatedAt)}</D:getlastmodified><D:getcontentlength>0</D:getcontentlength><D:getcontenttype>httpd/unix-directory</D:getcontenttype><D:getetag>&quot;${childEtagFixture}&quot;</D:getetag><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response></D:multistatus>`,
167164
);
168165
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
169166
expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce();
@@ -224,8 +221,7 @@ describe('PROPFIND request handler', () => {
224221
});
225222

226223
const fileFixture = newFileItem({ name: 'file', type: 'png' });
227-
const uuidFixture = 'test-test-test-test-test';
228-
const etagFixture = uuidFixture.replaceAll('-', '');
224+
const etagFixture = getExpectedETag(fileFixture);
229225
const mimeFixture = 'image/png';
230226

231227
const getRequestedResourceStub = vi
@@ -234,8 +230,6 @@ describe('PROPFIND request handler', () => {
234230
const getAndSearchItemFromResourceStub = vi
235231
.spyOn(WebDavUtils, 'getDriveItemFromResource')
236232
.mockResolvedValue(fileFixture);
237-
randomUUIDStub.mockClear();
238-
randomUUIDStub.mockImplementation(() => uuidFixture);
239233
const mimeLookupStub = vi.spyOn(mime, 'lookup').mockReturnValue(mimeFixture);
240234

241235
await sut.handle(request, response);
@@ -245,7 +239,6 @@ describe('PROPFIND request handler', () => {
245239
);
246240
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
247241
expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce();
248-
expect(randomUUIDStub).toHaveBeenCalledOnce();
249242
expect(mimeLookupStub).toHaveBeenCalledOnce();
250243
});
251244

0 commit comments

Comments
 (0)