Skip to content

Commit 23b0de3

Browse files
authored
Merge pull request #592 from internxt/feat/add-webdav-improvements
[_]: feat/add-webdav-improvements
2 parents d8bcbd3 + b9f4805 commit 23b0de3

12 files changed

Lines changed: 98 additions & 47 deletions

File tree

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
FROM node:24-alpine
1+
FROM node:24-bookworm-slim
22

3-
RUN apk add --no-cache jq
3+
RUN apt-get update && apt-get install -y jq && rm -rf /var/lib/apt/lists/*
44

55
WORKDIR /app
66
COPY . .
@@ -17,4 +17,4 @@ RUN ln -s '/app/bin/run.js' /usr/local/bin/internxt
1717

1818
ENTRYPOINT ["/app/docker/entrypoint.sh"]
1919

20-
HEALTHCHECK --interval=60s --timeout=20s --start-period=30s --retries=3 CMD /app/docker/health_check.sh
20+
HEALTHCHECK --interval=120s --timeout=30s --start-period=60s --retries=3 CMD /app/docker/health_check.sh

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"author": "Internxt <hello@internxt.com>",
3-
"version": "1.6.4",
3+
"version": "1.6.5",
44
"description": "Internxt CLI to manage your encrypted storage",
55
"scripts": {
66
"build": "yarn clean && tsc",

src/services/cache.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ export class CacheService {
1111
private readonly store = new Map<string, CacheEntry<unknown>>();
1212
private readonly defaultTtl = FIFTEEN_MINUTES;
1313

14+
public static readonly FETCH_USAGE_CACHE_KEY = 'usage:fetchUsage';
15+
public static readonly FETCH_SPACE_LIMIT_CACHE_KEY = 'usage:fetchSpaceLimit';
16+
public static readonly FETCH_LIMITS_CACHE_KEY = 'usage:fetchLimits';
17+
public static readonly AUTH_CACHE_KEY = 'auth:details';
18+
1419
public get = <T>(key: string): T | null => {
1520
const entry = this.store.get(key);
1621
if (!entry) return null;

src/services/config.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
WEBDAV_SSL_CERTS_DIR,
1818
WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY,
1919
} from '../constants/configs';
20+
import { CacheService } from './cache.service';
2021

2122
export class ConfigService {
2223
public static readonly instance: ConfigService = new ConfigService();
@@ -44,13 +45,16 @@ export class ConfigService {
4445
const credentialsString = JSON.stringify(loginCredentials);
4546
const encryptedCredentials = CryptoService.instance.encryptText(credentialsString);
4647
await fs.writeFile(CREDENTIALS_FILE, encryptedCredentials, 'utf8');
48+
49+
CacheService.instance.set(CacheService.AUTH_CACHE_KEY, loginCredentials);
4750
};
4851

4952
/**
5053
* Clears the authenticated user from file
5154
* @async
5255
**/
5356
public clearUser = async (): Promise<void> => {
57+
CacheService.instance.set(CacheService.AUTH_CACHE_KEY, undefined);
5458
try {
5559
const stat = await fs.stat(CREDENTIALS_FILE);
5660
if (stat.size === 0) return;
@@ -68,6 +72,9 @@ export class ConfigService {
6872
* @async
6973
**/
7074
public readUser = async (): Promise<LoginCredentials | undefined> => {
75+
const cached = CacheService.instance.get<LoginCredentials>(CacheService.AUTH_CACHE_KEY);
76+
if (cached) return cached;
77+
7178
try {
7279
const encryptedCredentials = await fs.readFile(CREDENTIALS_FILE, 'utf8');
7380
const credentialsString = CryptoService.instance.decryptText(encryptedCredentials);

src/services/database/database.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export class DatabaseService {
2626
);
2727

2828
public initialize = () => {
29-
return this.dataSource.initialize();
29+
if (!this.dataSource.isInitialized) {
30+
return this.dataSource.initialize();
31+
}
3032
};
3133

3234
public destroy = () => {

src/services/usage.service.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,37 @@ import { StorageTypes } from '@internxt/sdk/dist/drive/storage';
55
export class UsageService {
66
public static readonly instance: UsageService = new UsageService();
77
public static readonly INFINITE_LIMIT = 99 * Math.pow(1024, 4);
8-
private static readonly FETCH_USAGE_CACHE_KEY = 'usage:fetchUsage';
9-
private static readonly FETCH_SPACE_LIMIT_CACHE_KEY = 'usage:fetchSpaceLimit';
10-
private static readonly FETCH_LIMITS_CACHE_KEY = 'usage:fetchLimits';
118

129
public fetchUsage = async (): Promise<number> => {
13-
const cached = CacheService.instance.get<number>(UsageService.FETCH_USAGE_CACHE_KEY);
10+
const cached = CacheService.instance.get<number>(CacheService.FETCH_USAGE_CACHE_KEY);
1411
if (cached !== null) return cached;
1512

1613
const storageClient = SdkManager.instance.getStorage();
1714
const driveUsage = await storageClient.spaceUsageV2();
1815

19-
CacheService.instance.set(UsageService.FETCH_USAGE_CACHE_KEY, driveUsage.total);
16+
CacheService.instance.set(CacheService.FETCH_USAGE_CACHE_KEY, driveUsage.total);
2017
return driveUsage.total;
2118
};
2219

2320
public fetchSpaceLimit = async (): Promise<number> => {
24-
const cached = CacheService.instance.get<number>(UsageService.FETCH_SPACE_LIMIT_CACHE_KEY);
21+
const cached = CacheService.instance.get<number>(CacheService.FETCH_SPACE_LIMIT_CACHE_KEY);
2522
if (cached !== null) return cached;
2623

2724
const storageClient = SdkManager.instance.getStorage();
2825
const spaceLimit = await storageClient.spaceLimitV2();
2926

30-
CacheService.instance.set(UsageService.FETCH_SPACE_LIMIT_CACHE_KEY, spaceLimit.maxSpaceBytes);
27+
CacheService.instance.set(CacheService.FETCH_SPACE_LIMIT_CACHE_KEY, spaceLimit.maxSpaceBytes);
3128
return spaceLimit.maxSpaceBytes;
3229
};
3330

3431
public fetchLimits = async (): Promise<StorageTypes.FileLimitsResponse> => {
35-
const cached = CacheService.instance.get<StorageTypes.FileLimitsResponse>(UsageService.FETCH_LIMITS_CACHE_KEY);
32+
const cached = CacheService.instance.get<StorageTypes.FileLimitsResponse>(CacheService.FETCH_LIMITS_CACHE_KEY);
3633
if (cached !== null) return cached;
3734

3835
const storageClient = SdkManager.instance.getStorage();
3936
const limits = await storageClient.getFileVersionLimits();
4037

41-
CacheService.instance.set(UsageService.FETCH_LIMITS_CACHE_KEY, limits);
38+
CacheService.instance.set(CacheService.FETCH_LIMITS_CACHE_KEY, limits);
4239
return limits;
4340
};
4441
}

src/webdav/handlers/MKCOL.handler.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { WebDavUtils } from '../../utils/webdav.utils';
44
import { webdavLogger } from '../../utils/logger.utils';
55
import { XMLUtils } from '../../utils/xml.utils';
66
import { WebDavFolderService } from '../../services/webdav/webdav-folder.service';
7-
import { MethodNotAllowed } from '../../utils/errors.utils';
87
import { AsyncUtils } from '../../utils/async.utils';
98

109
export class MKCOLRequestHandler implements WebDavMethodHandler {
@@ -22,8 +21,9 @@ export class MKCOLRequestHandler implements WebDavMethodHandler {
2221
const folderAlreadyExists = !!driveFolderItem;
2322

2423
if (folderAlreadyExists) {
25-
webdavLogger.info(`[MKCOL] ❌ Folder '${resource.url}' already exists`);
26-
throw new MethodNotAllowed('Folder already exists');
24+
webdavLogger.info(`[MKCOL] Folder '${resource.url}' already exists, ignoring the creation request`);
25+
res.status(200).send(XMLUtils.toWebDavXML({}, {}));
26+
return;
2727
}
2828

2929
const newFolder = await WebDavFolderService.instance.createFolder({

src/webdav/handlers/PUT.handler.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
22
import { DriveFileService } from '../../services/drive/drive-file.service';
33
import { AuthService } from '../../services/auth.service';
44
import { WebDavMethodHandler } from '../../types/webdav.types';
5-
import { NotFoundError } from '../../utils/errors.utils';
5+
import { ConflictError } from '../../utils/errors.utils';
66
import { WebDavUtils } from '../../utils/webdav.utils';
77
import { webdavLogger } from '../../utils/logger.utils';
88
import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types';
@@ -22,13 +22,6 @@ export class PUTRequestHandler implements WebDavMethodHandler {
2222
}
2323

2424
const resource = await WebDavUtils.getRequestedResource(req.url);
25-
26-
// If the file already exists, the WebDAV specification states that 'PUT /…/file' should replace it.
27-
// http://www.webdav.org/specs/rfc4918.html#put-resources
28-
const driveFileItem = await WebDavUtils.getDriveItemFromResource(resource);
29-
if (driveFileItem?.itemType === 'folder') {
30-
throw new NotFoundError('Folders cannot be created with PUT. Use MKCOL instead.');
31-
}
3225
webdavLogger.info(`[PUT] Request received for file at ${resource.url}`);
3326
webdavLogger.info(
3427
`[PUT] Uploading '${resource.name}' (${FormatUtils.humanFileSize(contentLength)}) to '${resource.parentPath}'`,
@@ -44,15 +37,22 @@ export class PUTRequestHandler implements WebDavMethodHandler {
4437
(await WebDavFolderService.instance.getDriveFolderItemFromPath(resource.parentPath)) ??
4538
(await WebDavFolderService.instance.createParentPathOrThrow(resource.parentPath));
4639

47-
try {
48-
if (driveFileItem && driveFileItem.status === 'EXISTS') {
49-
webdavLogger.info(
50-
`[PUT] File '${resource.name}' already exists in '${resource.path.dir}', it will be replaced...`,
51-
);
40+
// If the file already exists, the WebDAV specification states that 'PUT /…/file' should replace it.
41+
// http://www.webdav.org/specs/rfc4918.html#put-resources
42+
const driveFileItem = await WebDavUtils.getDriveItemFromResource(resource);
43+
if (driveFileItem && driveFileItem.status === 'EXISTS') {
44+
if (driveFileItem.itemType === 'folder') {
45+
webdavLogger.info('[PUT] ❌ A folder exists on the cloud with the same name.');
46+
throw new ConflictError('A folder exists on the cloud with the same name');
47+
}
48+
webdavLogger.info(
49+
`[PUT] File '${resource.name}' already exists in '${resource.path.dir}', it will be replaced...`,
50+
);
51+
try {
5252
await WebDavUtils.deleteOrTrashItem(driveFileItem);
53+
} catch {
54+
//noop
5355
}
54-
} catch {
55-
//noop
5656
}
5757

5858
const { user } = await AuthService.instance.getAuthDetails();

src/webdav/middewares/auth.middleware.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import { RequestHandler } from 'express';
22
import { SdkManager } from '../../services/sdk-manager.service';
33
import { AuthService } from '../../services/auth.service';
4+
import { CacheService } from '../../services/cache.service';
45
import { webdavLogger } from '../../utils/logger.utils';
56
import { XMLUtils } from '../../utils/xml.utils';
67
import { ErrorUtils } from '../../utils/errors.utils';
8+
import { LoginCredentials } from '../../types/command.types';
79

810
export const AuthMiddleware = (): RequestHandler => {
911
return (_, res, next) => {
1012
(async () => {
1113
try {
12-
const { token, workspace } = await AuthService.instance.getAuthDetails();
13-
SdkManager.init({ token, workspaceToken: workspace?.workspaceCredentials.token });
14+
const cached = CacheService.instance.get<LoginCredentials>(CacheService.AUTH_CACHE_KEY);
15+
16+
if (cached) {
17+
SdkManager.init({ token: cached.token, workspaceToken: cached.workspace?.workspaceCredentials?.token });
18+
next();
19+
return;
20+
}
21+
22+
const authDetails = await AuthService.instance.getAuthDetails();
23+
CacheService.instance.set(CacheService.AUTH_CACHE_KEY, authDetails);
1424
next();
1525
} catch (error) {
1626
let message = 'Authentication required to access this resource.';

test/services/auth.service.test.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@ import {
1616
import { UserCredentialsFixture } from '../fixtures/login.fixture';
1717
import { fail } from 'node:assert';
1818
import { paths } from '@internxt/sdk/dist/schema';
19+
import { CacheService } from '../../src/services/cache.service';
1920

2021
describe('Auth service', () => {
2122
beforeEach(() => {
2223
vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue(UserCredentialsFixture);
2324
vi.spyOn(ConfigService.instance, 'saveUser').mockResolvedValue(undefined);
25+
vi.spyOn(CacheService.instance, 'get').mockReturnValue(undefined);
2426
});
2527

26-
it('When user logs in, then login user credentials are generated', async () => {
28+
it('should generate login user credentials when user logs in', async () => {
2729
const loginResponse = {
2830
token: crypto.randomBytes(16).toString('hex'),
2931
newToken: crypto.randomBytes(16).toString('hex'),
@@ -48,7 +50,7 @@ describe('Auth service', () => {
4850
expect(responseLogin).to.be.deep.equal(expectedResponseLogin);
4951
});
5052

51-
it('When user logs in and credentials are not correct, then an error is thrown', async () => {
53+
it('should throw an error when user logs in and credentials are not correct', async () => {
5254
const loginDetails: LoginDetails = {
5355
email: crypto.randomBytes(16).toString('hex'),
5456
password: crypto.randomBytes(8).toString('hex'),
@@ -67,7 +69,7 @@ describe('Auth service', () => {
6769
expect(loginStub).toHaveBeenCalledOnce();
6870
});
6971

70-
it('When two factor authentication is enabled, then it is returned from is2FANeeded functionality', async () => {
72+
it('should return true from is2FANeeded when two factor authentication is enabled', async () => {
7173
const email = crypto.randomBytes(16).toString('hex');
7274
const securityDetails: SecurityDetails = {
7375
encryptedSalt: crypto.randomBytes(16).toString('hex'),
@@ -83,7 +85,7 @@ describe('Auth service', () => {
8385
expect(responseLogin).to.be.equal(securityDetails.tfaEnabled);
8486
});
8587

86-
it('When email is not correct when checking two factor authentication, then an error is thrown', async () => {
88+
it('should throw an error when checking two factor authentication with an incorrect email', async () => {
8789
const email = crypto.randomBytes(16).toString('hex');
8890

8991
const securityStub = vi.spyOn(Auth.prototype, 'securityDetails').mockRejectedValue(new Error());
@@ -98,7 +100,7 @@ describe('Auth service', () => {
98100
expect(securityStub).toHaveBeenCalledOnce();
99101
});
100102

101-
it('When getting auth details, should get them if all are found', async () => {
103+
it('should return auth details when all credentials are found', async () => {
102104
const sut = AuthService.instance;
103105

104106
const loginCreds: LoginCredentials = UserCredentialsFixture;
@@ -125,7 +127,7 @@ describe('Auth service', () => {
125127
expect(result).to.deep.equal(loginCreds);
126128
});
127129

128-
it('When credentials are missing, should throw an error', async () => {
130+
it('should throw an error when credentials are missing', async () => {
129131
const sut = AuthService.instance;
130132

131133
const readUserStub = vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue(undefined);
@@ -139,7 +141,7 @@ describe('Auth service', () => {
139141
expect(readUserStub).toHaveBeenCalledOnce();
140142
});
141143

142-
it('When auth token is missing, should throw an error', async () => {
144+
it('should throw an error when auth token is missing', async () => {
143145
const sut = AuthService.instance;
144146

145147
const readUserStub = vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue({
@@ -157,7 +159,7 @@ describe('Auth service', () => {
157159
expect(readUserStub).toHaveBeenCalledOnce();
158160
});
159161

160-
it('When mnemonic is invalid, should throw an error', async () => {
162+
it('should throw an error when mnemonic is invalid', async () => {
161163
const sut = AuthService.instance;
162164

163165
const mockToken = {
@@ -185,7 +187,7 @@ describe('Auth service', () => {
185187
expect(validateMnemonicStub).toHaveBeenCalledWith(UserCredentialsFixture.user.mnemonic);
186188
});
187189

188-
it('When token has expired, should throw an error', async () => {
190+
it('should throw an error when token has expired', async () => {
189191
const sut = AuthService.instance;
190192

191193
const mockToken = {
@@ -213,7 +215,7 @@ describe('Auth service', () => {
213215
expect(validateMnemonicStub).toHaveBeenCalledWith(UserCredentialsFixture.user.mnemonic);
214216
});
215217

216-
it('When tokens are going to expire soon, then they are refreshed', async () => {
218+
it('should refresh tokens when they are going to expire soon', async () => {
217219
const sut = AuthService.instance;
218220

219221
const mockToken = {

0 commit comments

Comments
 (0)