Skip to content
Merged
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
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
FROM node:24-alpine
FROM node:24-bookworm-slim

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

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

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

HEALTHCHECK --interval=60s --timeout=20s --start-period=30s --retries=3 CMD /app/docker/health_check.sh
HEALTHCHECK --interval=120s --timeout=30s --start-period=60s --retries=3 CMD /app/docker/health_check.sh
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"author": "Internxt <hello@internxt.com>",
"version": "1.6.4",
"version": "1.6.5",
"description": "Internxt CLI to manage your encrypted storage",
"scripts": {
"build": "yarn clean && tsc",
Expand Down
5 changes: 5 additions & 0 deletions src/services/cache.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export class CacheService {
private readonly store = new Map<string, CacheEntry<unknown>>();
private readonly defaultTtl = FIFTEEN_MINUTES;

public static readonly FETCH_USAGE_CACHE_KEY = 'usage:fetchUsage';
public static readonly FETCH_SPACE_LIMIT_CACHE_KEY = 'usage:fetchSpaceLimit';
public static readonly FETCH_LIMITS_CACHE_KEY = 'usage:fetchLimits';
public static readonly AUTH_CACHE_KEY = 'auth:details';

public get = <T>(key: string): T | null => {
const entry = this.store.get(key);
if (!entry) return null;
Expand Down
7 changes: 7 additions & 0 deletions src/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
WEBDAV_SSL_CERTS_DIR,
WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY,
} from '../constants/configs';
import { CacheService } from './cache.service';

export class ConfigService {
public static readonly instance: ConfigService = new ConfigService();
Expand Down Expand Up @@ -44,13 +45,16 @@ export class ConfigService {
const credentialsString = JSON.stringify(loginCredentials);
const encryptedCredentials = CryptoService.instance.encryptText(credentialsString);
await fs.writeFile(CREDENTIALS_FILE, encryptedCredentials, 'utf8');

CacheService.instance.set(CacheService.AUTH_CACHE_KEY, loginCredentials);
};

/**
* Clears the authenticated user from file
* @async
**/
public clearUser = async (): Promise<void> => {
CacheService.instance.set(CacheService.AUTH_CACHE_KEY, undefined);
try {
const stat = await fs.stat(CREDENTIALS_FILE);
if (stat.size === 0) return;
Expand All @@ -68,6 +72,9 @@ export class ConfigService {
* @async
**/
public readUser = async (): Promise<LoginCredentials | undefined> => {
const cached = CacheService.instance.get<LoginCredentials>(CacheService.AUTH_CACHE_KEY);
if (cached) return cached;

try {
const encryptedCredentials = await fs.readFile(CREDENTIALS_FILE, 'utf8');
const credentialsString = CryptoService.instance.decryptText(encryptedCredentials);
Expand Down
4 changes: 3 additions & 1 deletion src/services/database/database.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export class DatabaseService {
);

public initialize = () => {
return this.dataSource.initialize();
if (!this.dataSource.isInitialized) {
return this.dataSource.initialize();
}
};

public destroy = () => {
Expand Down
15 changes: 6 additions & 9 deletions src/services/usage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,37 @@ import { StorageTypes } from '@internxt/sdk/dist/drive/storage';
export class UsageService {
public static readonly instance: UsageService = new UsageService();
public static readonly INFINITE_LIMIT = 99 * Math.pow(1024, 4);
private static readonly FETCH_USAGE_CACHE_KEY = 'usage:fetchUsage';
private static readonly FETCH_SPACE_LIMIT_CACHE_KEY = 'usage:fetchSpaceLimit';
private static readonly FETCH_LIMITS_CACHE_KEY = 'usage:fetchLimits';

public fetchUsage = async (): Promise<number> => {
const cached = CacheService.instance.get<number>(UsageService.FETCH_USAGE_CACHE_KEY);
const cached = CacheService.instance.get<number>(CacheService.FETCH_USAGE_CACHE_KEY);
if (cached !== null) return cached;

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

CacheService.instance.set(UsageService.FETCH_USAGE_CACHE_KEY, driveUsage.total);
CacheService.instance.set(CacheService.FETCH_USAGE_CACHE_KEY, driveUsage.total);
return driveUsage.total;
};

public fetchSpaceLimit = async (): Promise<number> => {
const cached = CacheService.instance.get<number>(UsageService.FETCH_SPACE_LIMIT_CACHE_KEY);
const cached = CacheService.instance.get<number>(CacheService.FETCH_SPACE_LIMIT_CACHE_KEY);
if (cached !== null) return cached;

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

CacheService.instance.set(UsageService.FETCH_SPACE_LIMIT_CACHE_KEY, spaceLimit.maxSpaceBytes);
CacheService.instance.set(CacheService.FETCH_SPACE_LIMIT_CACHE_KEY, spaceLimit.maxSpaceBytes);
return spaceLimit.maxSpaceBytes;
};

public fetchLimits = async (): Promise<StorageTypes.FileLimitsResponse> => {
const cached = CacheService.instance.get<StorageTypes.FileLimitsResponse>(UsageService.FETCH_LIMITS_CACHE_KEY);
const cached = CacheService.instance.get<StorageTypes.FileLimitsResponse>(CacheService.FETCH_LIMITS_CACHE_KEY);
if (cached !== null) return cached;

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

CacheService.instance.set(UsageService.FETCH_LIMITS_CACHE_KEY, limits);
CacheService.instance.set(CacheService.FETCH_LIMITS_CACHE_KEY, limits);
return limits;
};
}
6 changes: 3 additions & 3 deletions src/webdav/handlers/MKCOL.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { WebDavUtils } from '../../utils/webdav.utils';
import { webdavLogger } from '../../utils/logger.utils';
import { XMLUtils } from '../../utils/xml.utils';
import { WebDavFolderService } from '../../services/webdav/webdav-folder.service';
import { MethodNotAllowed } from '../../utils/errors.utils';
import { AsyncUtils } from '../../utils/async.utils';

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

if (folderAlreadyExists) {
webdavLogger.info(`[MKCOL] ❌ Folder '${resource.url}' already exists`);
throw new MethodNotAllowed('Folder already exists');
webdavLogger.info(`[MKCOL] Folder '${resource.url}' already exists, ignoring the creation request`);
res.status(200).send(XMLUtils.toWebDavXML({}, {}));
return;
Comment thread
larryrider marked this conversation as resolved.
}

const newFolder = await WebDavFolderService.instance.createFolder({
Expand Down
30 changes: 15 additions & 15 deletions src/webdav/handlers/PUT.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import { DriveFileService } from '../../services/drive/drive-file.service';
import { AuthService } from '../../services/auth.service';
import { WebDavMethodHandler } from '../../types/webdav.types';
import { NotFoundError } from '../../utils/errors.utils';
import { ConflictError } from '../../utils/errors.utils';
import { WebDavUtils } from '../../utils/webdav.utils';
import { webdavLogger } from '../../utils/logger.utils';
import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types';
Expand All @@ -22,13 +22,6 @@ export class PUTRequestHandler implements WebDavMethodHandler {
}

const resource = await WebDavUtils.getRequestedResource(req.url);

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

try {
if (driveFileItem && driveFileItem.status === 'EXISTS') {
webdavLogger.info(
`[PUT] File '${resource.name}' already exists in '${resource.path.dir}', it will be replaced...`,
);
// If the file already exists, the WebDAV specification states that 'PUT /…/file' should replace it.
// http://www.webdav.org/specs/rfc4918.html#put-resources
const driveFileItem = await WebDavUtils.getDriveItemFromResource(resource);
if (driveFileItem && driveFileItem.status === 'EXISTS') {
if (driveFileItem.itemType === 'folder') {
webdavLogger.info('[PUT] ❌ A folder exists on the cloud with the same name.');
throw new ConflictError('A folder exists on the cloud with the same name');
}
webdavLogger.info(
`[PUT] File '${resource.name}' already exists in '${resource.path.dir}', it will be replaced...`,
);
try {
await WebDavUtils.deleteOrTrashItem(driveFileItem);
} catch {
//noop
}
} catch {
//noop
}

const { user } = await AuthService.instance.getAuthDetails();
Expand Down
14 changes: 12 additions & 2 deletions src/webdav/middewares/auth.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { RequestHandler } from 'express';
import { SdkManager } from '../../services/sdk-manager.service';
import { AuthService } from '../../services/auth.service';
import { CacheService } from '../../services/cache.service';
import { webdavLogger } from '../../utils/logger.utils';
import { XMLUtils } from '../../utils/xml.utils';
import { ErrorUtils } from '../../utils/errors.utils';
import { LoginCredentials } from '../../types/command.types';

export const AuthMiddleware = (): RequestHandler => {
return (_, res, next) => {
(async () => {
try {
const { token, workspace } = await AuthService.instance.getAuthDetails();
SdkManager.init({ token, workspaceToken: workspace?.workspaceCredentials.token });
const cached = CacheService.instance.get<LoginCredentials>(CacheService.AUTH_CACHE_KEY);

if (cached) {
SdkManager.init({ token: cached.token, workspaceToken: cached.workspace?.workspaceCredentials?.token });
next();
return;
}

const authDetails = await AuthService.instance.getAuthDetails();
CacheService.instance.set(CacheService.AUTH_CACHE_KEY, authDetails);
next();
} catch (error) {
let message = 'Authentication required to access this resource.';
Expand Down
22 changes: 12 additions & 10 deletions test/services/auth.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ import {
import { UserCredentialsFixture } from '../fixtures/login.fixture';
import { fail } from 'node:assert';
import { paths } from '@internxt/sdk/dist/schema';
import { CacheService } from '../../src/services/cache.service';

describe('Auth service', () => {
beforeEach(() => {
vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue(UserCredentialsFixture);
vi.spyOn(ConfigService.instance, 'saveUser').mockResolvedValue(undefined);
vi.spyOn(CacheService.instance, 'get').mockReturnValue(undefined);
});

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

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

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

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

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

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

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

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

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

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

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

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

const mockToken = {
Expand Down Expand Up @@ -185,7 +187,7 @@ describe('Auth service', () => {
expect(validateMnemonicStub).toHaveBeenCalledWith(UserCredentialsFixture.user.mnemonic);
});

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

const mockToken = {
Expand Down Expand Up @@ -213,7 +215,7 @@ describe('Auth service', () => {
expect(validateMnemonicStub).toHaveBeenCalledWith(UserCredentialsFixture.user.mnemonic);
});

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

const mockToken = {
Expand Down
3 changes: 3 additions & 0 deletions test/services/config.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY,
} from '../../src/constants/configs';
import { getWebdavConfigMock } from '../fixtures/webdav.fixture';
import { CacheService } from '../../src/services/cache.service';

const env = Object.assign({}, process.env);

Expand All @@ -37,6 +38,8 @@ describe('Config service', () => {

beforeEach(() => {
process.env = env;
vi.spyOn(CacheService.instance, 'get').mockReturnValue(null);
vi.spyOn(CacheService.instance, 'set').mockImplementation(() => {});
});

it('When an env property is requested, then the get method return its value', async () => {
Expand Down
Loading
Loading