Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/utils/webdav.utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -109,4 +110,21 @@ export class WebDavUtils {
}
await DriveItemRepository.instance.delete([driveItem.uuid]);
}

static generateETag(parts: Array<string | number | Date | null | undefined>): 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,
]);
}
}
1 change: 1 addition & 0 deletions src/webdav/handlers/GET.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions src/webdav/handlers/HEAD.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
7 changes: 3 additions & 4 deletions src/webdav/handlers/PROPFIND.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:',
Expand Down Expand Up @@ -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')]: '',
},
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/webdav/handlers/PUT.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export class PUTRequestHandler implements WebDavMethodHandler {
`after ${CLIUtils.formatDuration(totalTime)}`,
);

res.header('ETag', WebDavUtils.getItemETag(file));
res.status(statusCode).send();
};
}
76 changes: 76 additions & 0 deletions test/utils/webdav.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('"')).toBe(true);
expect(etag.endsWith('"')).toBe(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();
Expand Down
3 changes: 3 additions & 0 deletions test/webdav/handlers/GET.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions test/webdav/handlers/HEAD.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
Expand Down
43 changes: 18 additions & 25 deletions test/webdav/handlers/PROPFIND.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('node:crypto')>('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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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')
Expand All @@ -163,7 +160,7 @@ describe('PROPFIND request handler', () => {
await sut.handle(request, response);
expect(response.status).toHaveBeenCalledWith(207);
expect(response.send).toHaveBeenCalledWith(
`<?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>`,
`<?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>`,
);
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce();
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -245,7 +239,6 @@ describe('PROPFIND request handler', () => {
);
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce();
expect(randomUUIDStub).toHaveBeenCalledOnce();
expect(mimeLookupStub).toHaveBeenCalledOnce();
});

Expand Down
3 changes: 3 additions & 0 deletions test/webdav/handlers/PUT.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading