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
24 changes: 12 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@
"/oclif.manifest.json"
],
"dependencies": {
"@inquirer/prompts": "7.10.1",
"@inquirer/prompts": "8.1.0",
"@internxt/inxt-js": "2.2.9",
"@internxt/lib": "1.4.1",
"@internxt/sdk": "1.11.17",
"@internxt/sdk": "1.11.23",
"@oclif/core": "4.8.0",
"@oclif/plugin-autocomplete": "3.2.39",
"axios": "1.13.2",
Expand All @@ -48,40 +48,40 @@
"cli-progress": "3.12.0",
"dayjs": "1.11.19",
"dotenv": "17.2.3",
"express": "5.2.0",
"express": "5.2.1",
"express-async-handler": "1.2.0",
"fast-xml-parser": "5.3.2",
"fast-xml-parser": "5.3.3",
"mime-types": "3.0.2",
"open": "11.0.0",
"openpgp": "6.2.2",
"otpauth": "9.4.1",
"pm2": "6.0.13",
"pm2": "6.0.14",
"range-parser": "1.2.1",
"selfsigned": "4.0.0",
"tty-table": "5.0.0",
"winston": "3.18.3"
"winston": "3.19.0"
},
"devDependencies": {
"@internxt/eslint-config-internxt": "2.0.1",
"@internxt/prettier-config": "internxt/prettier-config#v1.0.2",
"@openpgp/web-stream-tools": "0.1.3",
"@types/cli-progress": "3.11.6",
"@types/express": "5.0.5",
"@types/express": "5.0.6",
"@types/mime-types": "3.0.1",
"@types/node": "22.19.1",
"@types/range-parser": "1.2.7",
"@vitest/coverage-istanbul": "4.0.15",
"@vitest/spy": "4.0.14",
"eslint": "9.39.1",
"@vitest/coverage-istanbul": "4.0.16",
"@vitest/spy": "4.0.16",
"eslint": "9.39.2",
"husky": "9.1.7",
"lint-staged": "16.2.7",
"nodemon": "3.1.11",
"oclif": "4.22.52",
"oclif": "4.22.57",
"prettier": "3.7.4",
"rimraf": "6.1.2",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.15",
"vitest": "4.0.16",
"vitest-mock-express": "2.2.0"
},
"optionalDependencies": {
Expand Down
62 changes: 36 additions & 26 deletions src/commands/download-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { DriveFileItem } from '../types/drive.types';
import fs from 'node:fs/promises';
import path from 'node:path';
import { StreamUtils } from '../utils/stream.utils';
import { NotValidDirectoryError, NotValidFileUuidError } from '../types/command.types';
import { NotValidDirectoryError, NotValidFileIdError, NotValidFileUuidError } from '../types/command.types';
import { ValidationService } from '../services/validation.service';

export default class DownloadFile extends Command {
static readonly args = {};
static readonly description =
Expand Down Expand Up @@ -50,17 +51,11 @@ export default class DownloadFile extends Command {

const fileUuid = await this.getFileUuid(flags['id'], nonInteractive);

// 1. Get file metadata
// Get file metadata
const driveFile = await this.getFileMetadata(fileUuid, flags['json']);

const downloadPath = await this.getDownloadPath(downloadDirectory, driveFile, overwrite);

// 2. Prepare the network
const { user } = await AuthService.instance.getAuthDetails();
const networkFacade = await CLIUtils.prepareNetwork({ loginUserDetails: user, jsonFlag: flags['json'] });
// 3. Download the file
const fileWriteStream = createWriteStream(downloadPath);

const progressBar = CLIUtils.progress(
{
format: 'Downloading file [{bar}] {percentage}%',
Expand All @@ -70,27 +65,42 @@ export default class DownloadFile extends Command {
);

progressBar?.start(100, 0);
const [executeDownload, abortable] = await networkFacade.downloadToStream(
driveFile.bucket,
user.mnemonic,
driveFile.fileId,
driveFile.size,
StreamUtils.writeStreamToWritableStream(fileWriteStream),
undefined,
{
abortController: new AbortController(),
progressCallback: (progress) => {
progressBar?.update(progress * 0.99);

if (driveFile.size === 0) {
await fs.writeFile(downloadPath, '');
} else {
if (!driveFile.fileId) {
throw new NotValidFileIdError();
}

// Prepare the network
const { user } = await AuthService.instance.getAuthDetails();
const networkFacade = CLIUtils.prepareNetwork({ loginUserDetails: user, jsonFlag: flags['json'] });
// Download the file
const fileWriteStream = createWriteStream(downloadPath);

const [executeDownload, abortable] = await networkFacade.downloadToStream(
driveFile.bucket,
user.mnemonic,
driveFile.fileId,
driveFile.size,
StreamUtils.writeStreamToWritableStream(fileWriteStream),
undefined,
{
abortController: new AbortController(),
progressCallback: (progress) => {
progressBar?.update(progress * 0.99);
},
},
},
);
);

process.on('SIGINT', () => {
abortable.abort('SIGINT received');
process.exit(1);
});
process.on('SIGINT', () => {
abortable.abort('SIGINT received');
process.exit(1);
});

await executeDownload;
await executeDownload;
}

try {
await fs.utimes(downloadPath, new Date(), driveFile.modificationTime ?? driveFile.updatedAt);
Expand Down
9 changes: 9 additions & 0 deletions src/types/command.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export class NotValidFolderUuidError extends Error {
Object.setPrototypeOf(this, NotValidFolderUuidError.prototype);
}
}

export class NotValidFileUuidError extends Error {
constructor() {
super('File UUID is not valid (it must be a valid v4 UUID)');
Expand All @@ -76,6 +77,14 @@ export class NotValidFileUuidError extends Error {
}
}

export class NotValidFileIdError extends Error {
constructor() {
super('FileId is not valid');

Object.setPrototypeOf(this, NotValidFileIdError.prototype);
}
}

export class NoRootFolderIdFoundError extends Error {
constructor() {
super('No root folder id found on your account');
Expand Down
1 change: 1 addition & 0 deletions src/utils/cli.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export class CLIUtils {
};

static readonly parseEmpty = async (input: string) => (input.trim().length === 0 ? ' ' : input);

static readonly prepareNetwork = ({
jsonFlag,
loginUserDetails,
Expand Down
83 changes: 51 additions & 32 deletions src/webdav/handlers/GET.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AuthService } from '../../services/auth.service';
import { NotFoundError } from '../../utils/errors.utils';
import { webdavLogger } from '../../utils/logger.utils';
import { NetworkUtils } from '../../utils/network.utils';
import { NotValidFileIdError } from '../../types/command.types';

export class GETRequestHandler implements WebDavMethodHandler {
constructor(
Expand Down Expand Up @@ -44,42 +45,60 @@ export class GETRequestHandler implements WebDavMethodHandler {
const { user } = await authService.getAuthDetails();
webdavLogger.info(`[GET] [${driveFile.uuid}] Network ready for download`);

const range = req.headers['range'];
const rangeOptions = NetworkUtils.parseRangeHeader({
range,
totalFileSize: driveFile.size,
});
let contentLength = driveFile.size;
if (rangeOptions) {
webdavLogger.info(`[GET] [${driveFile.uuid}] Range request received:`, { rangeOptions });
contentLength = rangeOptions.rangeSize;
}

res.header('Content-Type', 'application/octet-stream');
res.header('Content-length', contentLength.toString());

const writable = new WritableStream({
write(chunk) {
res.write(chunk);
},
close() {
res.end();
},
});
const fileSize = driveFile.size ?? 0;

if (fileSize > 0) {
const range = req.headers['range'];
const rangeOptions = NetworkUtils.parseRangeHeader({
range,
totalFileSize: fileSize,
});
let contentLength = fileSize;
if (rangeOptions) {
webdavLogger.info(`[GET] [${driveFile.uuid}] Range request received:`, { rangeOptions });
contentLength = rangeOptions.rangeSize;
}
res.header('Content-length', contentLength.toString());

const [executeDownload] = await networkFacade.downloadToStream(
driveFile.bucket,
user.mnemonic,
driveFile.fileId,
contentLength,
writable,
rangeOptions,
);
webdavLogger.info(`[GET] [${driveFile.uuid}] Download prepared, executing...`);
res.status(200);
const writable = new WritableStream({
write(chunk) {
res.write(chunk);
},
close() {
res.end();
},
});

await executeDownload;
if (!driveFile.fileId) {
throw new NotValidFileIdError();
}

webdavLogger.info(`[GET] [${driveFile.uuid}] ✅ Download ready, replying to client`);
const [executeDownload] = await networkFacade.downloadToStream(
driveFile.bucket,
user.mnemonic,
driveFile.fileId,
contentLength,
writable,
rangeOptions,
);
webdavLogger.info(`[GET] [${driveFile.uuid}] Download prepared, executing...`);

/**
* If the client doesn't receive a 200 status code, the download can be aborted.
* We need to respond with status 200 while the file is being downloaded via streams
* so the client can keep the connection open and receive the file completely.
*/
res.status(200);

await executeDownload;
webdavLogger.info(`[GET] [${driveFile.uuid}] ✅ Download ready, replying to client`);
} else {
webdavLogger.info(`[GET] [${driveFile.uuid}] File is empty, replying to client with no content`);
res.header('Content-length', '0');
res.status(200);
res.end();
}
};
}
44 changes: 41 additions & 3 deletions test/webdav/handlers/GET.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('GET request handler', () => {
vi.restoreAllMocks();
});

it('When the Drive file is not found, then it should throw a NotFoundError', async () => {
it('should throw a NotFoundError when the Drive file is not found', async () => {
const requestedFileResource: WebDavRequestedResource = getRequestedFileResource();

const request = createWebDavRequestFixture({
Expand Down Expand Up @@ -91,7 +91,7 @@ describe('GET request handler', () => {
expect(getFileMetadataStub).toHaveBeenCalledOnce();
});

it('When file is requested, then it should write a response with the content', async () => {
it('should write a response with the content when a file is requested', async () => {
const requestedFileResource: WebDavRequestedResource = getRequestedFileResource();

const request = createWebDavRequestFixture({
Expand Down Expand Up @@ -136,7 +136,7 @@ describe('GET request handler', () => {
);
});

it('When file is requested with Range, then it should write a response with the ranged content', async () => {
it('should write a response with the ranged content when a file is requested with Range', async () => {
const requestedFileResource: WebDavRequestedResource = getRequestedFileResource();

const mockSize = randomInt(500, 10000);
Expand Down Expand Up @@ -192,4 +192,42 @@ describe('GET request handler', () => {
expectedRangeOptions,
);
});

it('should write a response with no content when an empty file is requested', async () => {
const requestedFileResource: WebDavRequestedResource = getRequestedFileResource();

const request = createWebDavRequestFixture({
method: 'GET',
url: requestedFileResource.url,
headers: {},
});
const response = createWebDavResponseFixture({
status: vi.fn().mockReturnValue({ send: vi.fn() }),
header: vi.fn(),
});

const mockFile = newFileItem({ size: 0 });
const mockAuthDetails: LoginCredentials = UserCredentialsFixture;

const getRequestedResourceStub = vi
.spyOn(WebDavUtils, 'getRequestedResource')
.mockResolvedValue(requestedFileResource);
const getFileMetadataStub = vi
.spyOn(DriveFileService.instance, 'getFileMetadataByPath')
.mockResolvedValue(mockFile);
const authDetailsStub = vi.spyOn(AuthService.instance, 'getAuthDetails').mockResolvedValue(mockAuthDetails);
const downloadStreamStub = vi
.spyOn(networkFacade, 'downloadToStream')
.mockResolvedValue([Promise.resolve(), new AbortController()]);

await sut.handle(request, response);

expect(response.status).toHaveBeenCalledWith(200);
expect(response.header).toHaveBeenCalledWith('Content-length', Number(0).toString());
expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream');
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
expect(getFileMetadataStub).toHaveBeenCalledOnce();
expect(authDetailsStub).toHaveBeenCalledOnce();
expect(downloadStreamStub).not.toHaveBeenCalled();
});
});
Loading