Skip to content

Commit

Permalink
Merge pull request #46 from internxt/feat/PB-1779-enable-https-server
Browse files Browse the repository at this point in the history
[PB-1779]: Feat/Enable https WebDAV server
  • Loading branch information
larry-internxt authored Mar 14, 2024
2 parents d58b992 + 025fa03 commit 823d540
Show file tree
Hide file tree
Showing 15 changed files with 190 additions and 26 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"prepack": "yarn build && oclif manifest && oclif readme",
"prepare": "husky",
"test:unit": "nyc --reporter=lcov --reporter=text mocha \"test/**/*.test.ts\" --exit",
"dev:webdav": "nodemon -e ts --exec 'ts-node src/webdav/index.ts'",
"dev:webdav": "nodemon -e ts --exec ts-node src/webdav/index.ts",
"version": "oclif readme && git add README.md"
},
"homepage": "https://github.com/internxt/cli",
Expand Down Expand Up @@ -45,6 +45,7 @@
"fast-xml-parser": "^4.3.5",
"openpgp": "^5.11.1",
"realm": "^12.6.2",
"selfsigned": "^2.4.1",
"superagent": "^8.1.2",
"winston": "^3.12.0"
},
Expand Down
9 changes: 9 additions & 0 deletions src/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export class ConfigService {
static readonly INTERNXT_TMP_DIR = os.tmpdir();
static readonly CREDENTIALS_FILE = path.join(this.INTERNXT_CLI_DATA_DIR, '.inxtcli');
static readonly DRIVE_REALM_FILE = path.join(this.INTERNXT_CLI_DATA_DIR, 'internxt-cli-drive.realm');
static readonly WEBDAV_SSL_CERTS_DIR = path.join(this.INTERNXT_CLI_DATA_DIR, 'certs');
public static readonly instance: ConfigService = new ConfigService();

/**
Expand Down Expand Up @@ -75,4 +76,12 @@ export class ConfigService {
await fs.mkdir(ConfigService.INTERNXT_CLI_DATA_DIR);
}
};

ensureWebdavCertsDirExists = async () => {
try {
await fs.access(ConfigService.WEBDAV_SSL_CERTS_DIR);
} catch {
await fs.mkdir(ConfigService.WEBDAV_SSL_CERTS_DIR);
}
};
}
10 changes: 5 additions & 5 deletions src/services/realms/drive-realm-manager.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from 'path';
import { DriveFileItem, DriveFolderItem } from '../../types/drive.types';
import { DriveFileRealmSchema, DriveFilesRealm } from './drive-files.realm';
import { DriveFolderRealmSchema, DriveFoldersRealm } from './drive-folders.realm';
import { WebDavUtils } from '../../utils/webdav.utils';

export class DriveRealmManager {
constructor(
Expand Down Expand Up @@ -40,23 +40,23 @@ export class DriveRealmManager {
const parentFolder = await this.driveFoldersRealm.findByParentId(parentId);

if (!parentFolder) {
return path.join('/', fileName);
return WebDavUtils.joinURL('/', fileName);
}

const parentPath = await this.buildRelativePathForFile(parentFolder.name, parentFolder.parent_id ?? null);

return path.join(parentPath, fileName);
return WebDavUtils.joinURL(parentPath, fileName);
}

async buildRelativePathForFolder(folderName: string, parentId: number | null): Promise<string> {
const parentFolder = await this.driveFoldersRealm.findByParentId(parentId);

if (!parentFolder) {
return path.join('/', folderName, '/');
return WebDavUtils.joinURL('/', folderName, '/');
}

const parentPath = await this.buildRelativePathForFolder(parentFolder.name, parentFolder.parent_id ?? null);

return path.join(parentPath, folderName, '/');
return WebDavUtils.joinURL(parentPath, folderName, '/');
}
}
37 changes: 37 additions & 0 deletions src/utils/network.utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,47 @@
import { NetworkCredentials } from '../types/network.types';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import selfsigned from 'selfsigned';
import { ConfigService } from '../services/config.service';

export class NetworkUtils {
static getAuthFromCredentials(creds: NetworkCredentials): { username: string; password: string } {
return {
username: creds.user,
password: createHash('SHA256').update(Buffer.from(creds.pass)).digest().toString('hex'),
};
}

static readonly WEBDAV_SSL_CERTS_PATH = {
cert: path.join(ConfigService.WEBDAV_SSL_CERTS_DIR, 'cert.crt'),
privateKey: path.join(ConfigService.WEBDAV_SSL_CERTS_DIR, 'priv.key'),
};

static getWebdavSSLCerts() {
if (!existsSync(this.WEBDAV_SSL_CERTS_PATH.cert) || !existsSync(this.WEBDAV_SSL_CERTS_PATH.privateKey)) {
const newCerts = this.generateSelfSignedSSLCerts();
this.saveWebdavSSLCerts(newCerts);
return {
cert: newCerts.cert,
key: newCerts.private,
};
} else {
return {
cert: readFileSync(this.WEBDAV_SSL_CERTS_PATH.cert),
key: readFileSync(this.WEBDAV_SSL_CERTS_PATH.privateKey),
};
}
}

static saveWebdavSSLCerts(pems: selfsigned.GenerateResult): void {
writeFileSync(this.WEBDAV_SSL_CERTS_PATH.cert, pems.cert, 'utf8');
writeFileSync(this.WEBDAV_SSL_CERTS_PATH.privateKey, pems.private, 'utf8');
}

static generateSelfSignedSSLCerts(): selfsigned.GenerateResult {
const attrs = [{ name: 'internxt-cli', value: 'Internxt CLI', type: 'commonName' }];
const pems = selfsigned.generate(attrs, { days: 365, algorithm: 'sha256', keySize: 2048 });
return pems;
}
}
4 changes: 3 additions & 1 deletion src/utils/webdav.utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Request } from 'express';
import path from 'path';
import { WebDavRequestedResource } from '../types/webdav.types';

export class WebDavUtils {
static getHref(...pathComponents: string[]): string {
static joinURL(...pathComponents: string[]): string {
return path.posix.join(...pathComponents);
}

static getRequestedResource(req: Request): WebDavRequestedResource {
const decodedUrl = decodeURI(req.url);
const parsedPath = path.parse(decodedUrl);
Expand Down
4 changes: 2 additions & 2 deletions src/webdav/handlers/PROPFIND.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler {
const folderContent = await driveFolderService.getFolderContent(folderUuid);

const foldersXML = folderContent.folders.map((folder) => {
const folderRelativePath = WebDavUtils.getHref(relativePath, folder.plainName, '/');
const folderRelativePath = WebDavUtils.joinURL(relativePath, folder.plainName, '/');

return this.driveFolderItemToXMLNode(
{
Expand Down Expand Up @@ -114,7 +114,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler {
);

const filesXML = folderContent.files.map((file) => {
const fileRelativePath = WebDavUtils.getHref(
const fileRelativePath = WebDavUtils.joinURL(
relativePath,
file.type ? `${file.plainName}.${file.type}` : file.plainName,
);
Expand Down
1 change: 1 addition & 0 deletions src/webdav/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dotenv.config();

const init = async () => {
await ConfigService.instance.ensureInternxtCliDataDirExists();
await ConfigService.instance.ensureWebdavCertsDirExists();
const realm = await Realm.open({
path: ConfigService.DRIVE_REALM_FILE,
schema: [DriveFileRealmSchema, DriveFolderRealmSchema],
Expand Down
6 changes: 4 additions & 2 deletions src/webdav/webdav-server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Express } from 'express';
import https from 'https';
import { ConfigService } from '../services/config.service';
import { OPTIONSRequestHandler } from './handlers/OPTIONS.handler';
import { PROPFINDRequestHandler } from './handlers/PROPFIND.handler';
Expand All @@ -19,6 +20,7 @@ import { ErrorHandlingMiddleware } from './middewares/errors.middleware';
import asyncHandler from 'express-async-handler';
import { SdkManager } from '../services/sdk-manager.service';
import { NetworkFacade } from '../services/network/network-facade.service';
import { NetworkUtils } from '../utils/network.utils';

export class WebDavServer {
constructor(
Expand Down Expand Up @@ -92,8 +94,8 @@ export class WebDavServer {
this.registerMiddlewares();
this.registerHandlers();

this.app.listen(port, () => {
webdavLogger.info(`Internxt WebDav server listening at http://localhost:${port}`);
https.createServer(NetworkUtils.getWebdavSSLCerts(), this.app).listen(port, () => {
webdavLogger.info(`Internxt WebDav server listening at https://localhost:${port}`);
});
}
}
12 changes: 11 additions & 1 deletion test/services/config.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('Config service', () => {
expect(credentialsFileContent).to.be.empty;
});

it('When user credentials are cleared and the file is empty, an error is thrown', async () => {
it('When user credentials are cleared and the file is empty, then an error is thrown', async () => {
configServiceSandbox
.stub(fs, 'stat')
.withArgs(ConfigService.CREDENTIALS_FILE)
Expand All @@ -143,4 +143,14 @@ describe('Config service', () => {
expect((error as Error).message).to.equal('Credentials file is already empty');
}
});

it('When webdav certs directory is required to exist, then it is created', async () => {
configServiceSandbox.stub(fs, 'access').withArgs(ConfigService.WEBDAV_SSL_CERTS_DIR).rejects();

const stubMkdir = configServiceSandbox.stub(fs, 'mkdir').withArgs(ConfigService.WEBDAV_SSL_CERTS_DIR).resolves();

await ConfigService.instance.ensureWebdavCertsDirExists();

expect(stubMkdir).to.be.calledOnceWith(ConfigService.WEBDAV_SSL_CERTS_DIR);
});
});
4 changes: 2 additions & 2 deletions test/services/realms/drive-realm-manager.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('DriveRealmManager service', () => {

const relativePath = await sut.buildRelativePathForFolder('folderD', 1);

expect(relativePath).to.be.equal(path.join('/', 'folderA', 'folderB', 'folderC', 'folderD', '/'));
expect(relativePath).to.be.equal(path.posix.join('/', 'folderA', 'folderB', 'folderC', 'folderD', '/'));
});

it('When a file is created, should build the correct relative path', async () => {
Expand Down Expand Up @@ -124,6 +124,6 @@ describe('DriveRealmManager service', () => {

const relativePath = await sut.buildRelativePathForFile('file.png', 1);

expect(relativePath).to.be.equal(path.join('/', 'folderA', 'folderB', 'folderC', 'file.png'));
expect(relativePath).to.be.equal(path.posix.join('/', 'folderA', 'folderB', 'folderC', 'file.png'));
});
});
10 changes: 8 additions & 2 deletions test/utils/crypto.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { expect } from 'chai';
import { CryptoUtils } from '../../src/utils/crypto.utils';
import sinon from 'sinon';
import crypto from 'node:crypto';
import { CryptoUtils } from '../../src/utils/crypto.utils';
import { ConfigService } from '../../src/services/config.service';
import { AesInit } from '../../src/types/keys.types';
import crypto from 'node:crypto';

describe('Crypto utils', () => {
const sandbox = sinon.createSandbox();
const aesInit: AesInit = {
iv: crypto.randomBytes(16).toString('hex'),
salt: crypto.randomBytes(64).toString('hex'),
};

afterEach(() => {
sandbox.restore();
});

it('When Magic IV or Magic Salt are missing should throw an error', async () => {
try {
CryptoUtils.getAesInit();
Expand Down
59 changes: 59 additions & 0 deletions test/utils/network.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { expect } from 'chai';
import Sinon, { SinonSandbox } from 'sinon';
import fs from 'fs';
import { randomBytes } from 'crypto';
import selfsigned from 'selfsigned';
import { NetworkUtils } from '../../src/utils/network.utils';

describe('Network utils', () => {
let networkUtilsSandbox: SinonSandbox;

beforeEach(() => {
networkUtilsSandbox = Sinon.createSandbox();
});

afterEach(() => {
networkUtilsSandbox.restore();
});

it('When obtaining auth credentials, should return the password as a SHA256 hash', async () => {
const result = NetworkUtils.getAuthFromCredentials({
user: 'test',
Expand All @@ -10,4 +24,49 @@ describe('Network utils', () => {

expect(result.password).to.be.equal('5751a44782594819e4cb8aa27c2c9d87a420af82bc6a5a05bc7f19c3bb00452b');
});

it('When webdav ssl certs are required but they dont exist, then they are generated and self signed on the fly, and they are also saved to files', async () => {
const sslSelfSigned: selfsigned.GenerateResult = {
private: randomBytes(8).toString('hex'),
public: randomBytes(8).toString('hex'),
cert: randomBytes(8).toString('hex'),
fingerprint: randomBytes(8).toString('hex'),
};
networkUtilsSandbox.stub(fs, 'existsSync').returns(false);

const stubGerateSelfsignedCerts = networkUtilsSandbox
.stub(selfsigned, 'generate')
// @ts-expect-error - We stub the stat method partially
.returns(sslSelfSigned);

const stubSaveCerts = networkUtilsSandbox.stub(fs, 'writeFileSync').returns();

networkUtilsSandbox.stub(fs, 'readFileSync').rejects();

const result = NetworkUtils.getWebdavSSLCerts();

expect(result).to.deep.equal({ cert: sslSelfSigned.cert, key: sslSelfSigned.private });
expect(stubGerateSelfsignedCerts.calledOnce).to.be.true;
expect(stubSaveCerts.calledTwice).to.be.true;
});

it('When webdav ssl certs are required but they exist, then they are read from the files', async () => {
const sslMock = {
private: randomBytes(8).toString('hex'),
cert: randomBytes(8).toString('hex'),
};
networkUtilsSandbox.stub(fs, 'existsSync').returns(true);
networkUtilsSandbox.stub(fs, 'writeFileSync').rejects();

networkUtilsSandbox
.stub(fs, 'readFileSync')
.withArgs(NetworkUtils.WEBDAV_SSL_CERTS_PATH.cert)
.returns(sslMock.cert)
.withArgs(NetworkUtils.WEBDAV_SSL_CERTS_PATH.privateKey)
.returns(sslMock.private);

const result = NetworkUtils.getWebdavSSLCerts();

expect(result).to.deep.equal({ cert: sslMock.cert, key: sslMock.private });
});
});
4 changes: 2 additions & 2 deletions test/utils/webdav.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { createWebDavRequestFixture } from '../fixtures/webdav.fixture';

describe('Webdav utils', () => {
it('When a list of path components are given, should generate a correct href', () => {
const href = WebDavUtils.getHref('/path', 'to', 'file');
const href = WebDavUtils.joinURL('/path', 'to', 'file');
expect(href).to.equal('/path/to/file');
});

it('When a list of path components are given, should generate a correct href and remove incorrect characters', () => {
const href = WebDavUtils.getHref('/path', 'to', 'folder/');
const href = WebDavUtils.joinURL('/path', 'to', 'folder/');
expect(href).to.equal('/path/to/folder/');
});

Expand Down
Loading

0 comments on commit 823d540

Please sign in to comment.