Skip to content

Commit 2e2644d

Browse files
authored
Merge branch 'main' into dependabot-npm_and_yarn-oclif-4.17.4
2 parents f31bd00 + d539ef0 commit 2e2644d

23 files changed

Lines changed: 506 additions & 101 deletions

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"node-fetch": "2.7.0",
5858
"openpgp": "5.11.2",
5959
"pm2": "5.4.3",
60+
"range-parser": "^1.2.1",
6061
"reflect-metadata": "0.2.2",
6162
"selfsigned": "2.4.1",
6263
"sequelize": "6.37.5",
@@ -75,6 +76,7 @@
7576
"@types/mime-types": "2.1.4",
7677
"@types/node": "22.10.2",
7778
"@types/node-fetch": "2.6.12",
79+
"@types/range-parser": "^1.2.7",
7880
"@vitest/coverage-istanbul": "2.1.8",
7981
"@vitest/spy": "2.1.8",
8082
"eslint": "9.17.0",

src/commands/download-file.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export default class DownloadFile extends Command {
8080
user.mnemonic,
8181
driveFile.fileId,
8282
StreamUtils.writeStreamToWritableStream(fileWriteStream),
83+
undefined,
8384
{
8485
abortController: new AbortController(),
8586
progressCallback: (progress) => {

src/services/crypto.service.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CryptoProvider } from '@internxt/sdk';
22
import { Keys, Password } from '@internxt/sdk/dist/auth';
3-
import { createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomBytes } from 'node:crypto';
3+
import { createCipheriv, createDecipheriv, createHash, Decipher, pbkdf2Sync, randomBytes } from 'node:crypto';
44
import { Transform } from 'node:stream';
55
import { KeysService } from './keys.service';
66
import { ConfigService } from '../services/config.service';
@@ -116,8 +116,29 @@ export class CryptoService {
116116
return Buffer.concat([decipher.update(contentsToDecrypt), decipher.final()]).toString('utf8');
117117
};
118118

119-
public async decryptStream(inputSlices: ReadableStream<Uint8Array>[], key: Buffer, iv: Buffer) {
120-
const decipher = createDecipheriv('aes-256-ctr', key, iv);
119+
public async decryptStream(
120+
inputSlices: ReadableStream<Uint8Array>[],
121+
key: Buffer,
122+
iv: Buffer,
123+
startOffsetByte?: number,
124+
) {
125+
let decipher: Decipher;
126+
if (startOffsetByte) {
127+
const aesBlockSize = 16;
128+
const startOffset = startOffsetByte % aesBlockSize;
129+
const startBlockFirstByte = startOffsetByte - startOffset;
130+
const startBlockNumber = startBlockFirstByte / aesBlockSize;
131+
132+
const ivForRange = (BigInt('0x' + iv.toString('hex')) + BigInt(startBlockNumber)).toString(16).padStart(32, '0');
133+
const newIv = Buffer.from(ivForRange, 'hex');
134+
135+
const skipBuffer = Buffer.alloc(startOffset, 0);
136+
137+
decipher = createDecipheriv('aes-256-ctr', key, newIv);
138+
decipher.update(skipBuffer);
139+
} else {
140+
decipher = createDecipheriv('aes-256-ctr', key, iv);
141+
}
121142
const encryptedStream = StreamUtils.joinReadableBinaryStreams(inputSlices);
122143

123144
let keepReading = true;

src/services/network/download.service.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ export class DownloadService {
55

66
async downloadFile(
77
url: string,
8-
options: { progressCallback?: (progress: number) => void; abortController?: AbortController },
8+
options: {
9+
progressCallback?: (progress: number) => void;
10+
abortController?: AbortController;
11+
rangeHeader?: string;
12+
},
913
): Promise<ReadableStream<Uint8Array>> {
1014
const response = await axios.get(url, {
1115
responseType: 'stream',
@@ -16,6 +20,9 @@ export class DownloadService {
1620
options.progressCallback(reportedProgress);
1721
}
1822
},
23+
headers: {
24+
range: options.rangeHeader,
25+
},
1926
});
2027

2128
const readable = new ReadableStream<Uint8Array>({

src/services/network/network-facade.service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { DownloadService } from './download.service';
1717
import { ValidationService } from '../validation.service';
1818
import { HashStream } from '../../utils/hash.utils';
1919
import { ProgressTransform } from '../../utils/stream.utils';
20+
import { RangeOptions } from '../../utils/network.utils';
2021

2122
export class NetworkFacade {
2223
private readonly cryptoLib: Network.Crypto;
@@ -54,6 +55,7 @@ export class NetworkFacade {
5455
mnemonic: string,
5556
fileId: string,
5657
to: WritableStream,
58+
rangeOptions?: RangeOptions,
5759
options?: DownloadOptions,
5860
): Promise<[Promise<void>, AbortController]> {
5961
const encryptedContentStreams: ReadableStream<Uint8Array>[] = [];
@@ -70,16 +72,25 @@ export class NetworkFacade {
7072
};
7173

7274
const decryptFile: DecryptFileFunction = async (_, key, iv) => {
75+
let startOffsetByte;
76+
if (rangeOptions) {
77+
startOffsetByte = rangeOptions.parsed.start;
78+
}
7379
fileStream = await this.cryptoService.decryptStream(
7480
encryptedContentStreams,
7581
Buffer.from(key as ArrayBuffer),
7682
Buffer.from(iv as ArrayBuffer),
83+
startOffsetByte,
7784
);
7885

7986
await fileStream.pipeTo(to);
8087
};
8188

8289
const downloadFile: DownloadFileFunction = async (downloadables) => {
90+
if (rangeOptions && downloadables.length > 1) {
91+
throw new Error('Multi-Part Download with Range-Requests is not implemented');
92+
}
93+
8394
for (const downloadable of downloadables) {
8495
if (abortable.signal.aborted) {
8596
throw new Error('Download aborted');
@@ -88,6 +99,7 @@ export class NetworkFacade {
8899
const encryptedContentStream = await this.downloadService.downloadFile(downloadable.url, {
89100
progressCallback: onDownloadProgress,
90101
abortController: options?.abortController,
102+
rangeHeader: rangeOptions?.range,
91103
});
92104

93105
encryptedContentStreams.push(encryptedContentStream);

src/utils/errors.utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ export class UnsupportedMediaTypeError extends Error {
5050
}
5151
}
5252

53+
export class MethodNotAllowed extends Error {
54+
public statusCode = 405;
55+
56+
constructor(message: string) {
57+
super(message);
58+
this.name = 'MethodNotAllowed';
59+
Object.setPrototypeOf(this, MethodNotAllowed.prototype);
60+
}
61+
}
62+
5363
export class NotImplementedError extends Error {
5464
public statusCode = 501;
5565

src/utils/logger.utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const maxLogsFiles = 5;
66

77
export const logger = winston.createLogger({
88
level: 'info',
9-
format: winston.format.json(),
9+
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
1010
defaultMeta: { service: 'internxt-cli' },
1111
transports: [
1212
new winston.transports.File({
@@ -29,7 +29,7 @@ export const logger = winston.createLogger({
2929

3030
export const webdavLogger = winston.createLogger({
3131
level: 'info',
32-
format: winston.format.json(),
32+
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
3333
defaultMeta: { service: 'internxt-webdav' },
3434
transports: [
3535
new winston.transports.File({

src/utils/network.utils.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createHash, X509Certificate } from 'node:crypto';
33
import { readFile, stat, writeFile } from 'node:fs/promises';
44
import path from 'node:path';
55
import selfsigned from 'selfsigned';
6+
import parseRange from 'range-parser';
67
import { ConfigService } from '../services/config.service';
78

89
export class NetworkUtils {
@@ -74,4 +75,41 @@ export class NetworkUtils {
7475
const pems = selfsigned.generate(attrs, { days: 365, algorithm: 'sha256', keySize: 2048, extensions });
7576
return pems;
7677
}
78+
79+
static parseRangeHeader(rangeOptions: { range?: string; totalFileSize: number }): RangeOptions | undefined {
80+
if (!rangeOptions.range) {
81+
return;
82+
}
83+
const parsed = parseRange(rangeOptions.totalFileSize, rangeOptions.range);
84+
if (Array.isArray(parsed)) {
85+
if (parsed.length > 1) {
86+
throw new Error(`Multi Range-Requests functionality is not implemented. ${JSON.stringify(rangeOptions)}`);
87+
} else if (parsed.length <= 0) {
88+
throw new Error(`Empty Range-Request. ${JSON.stringify(rangeOptions)}`);
89+
} else if (parsed.type !== 'bytes') {
90+
throw new Error(`Unkwnown Range-Request type "${parsed.type}". ${JSON.stringify(rangeOptions)}`);
91+
} else {
92+
const rangeSize = parsed[0].end - parsed[0].start + 1;
93+
return {
94+
range: rangeOptions.range,
95+
rangeSize: rangeSize,
96+
totalFileSize: rangeOptions.totalFileSize,
97+
parsed: parsed[0],
98+
};
99+
}
100+
} else if (parsed === -1) {
101+
throw new Error(`Malformed Range-Request. ${JSON.stringify(rangeOptions)}`);
102+
} else if (parsed === -2) {
103+
throw new Error(`Unsatisfiable Range-Request. ${JSON.stringify(rangeOptions)}`);
104+
} else {
105+
throw new Error(`Unknown error from Range-Request. ${JSON.stringify(rangeOptions)}`);
106+
}
107+
}
108+
}
109+
110+
export interface RangeOptions {
111+
range: string;
112+
rangeSize: number;
113+
totalFileSize: number;
114+
parsed: parseRange.Range;
77115
}

src/webdav/handlers/DELETE.handler.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,8 @@ export class DELETERequestHandler implements WebDavMethodHandler {
1919

2020
handle = async (req: Request, res: Response) => {
2121
const { driveDatabaseManager, driveFileService, driveFolderService, trashService } = this.dependencies;
22-
webdavLogger.info('DELETE request received');
2322
const resource = await WebDavUtils.getRequestedResource(req);
24-
webdavLogger.info('Resource received for DELETE request', { resource });
23+
webdavLogger.info(`[DELETE] Request received for ${resource.type} at ${resource.url}`);
2524

2625
const driveItem = await WebDavUtils.getAndSearchItemFromResource({
2726
resource,
@@ -30,7 +29,7 @@ export class DELETERequestHandler implements WebDavMethodHandler {
3029
driveFileService: driveFileService,
3130
});
3231

33-
webdavLogger.info(`Trashing ${resource.type} with UUID ${driveItem.uuid}...`);
32+
webdavLogger.info(`[DELETE] [${driveItem.uuid}] Trashing ${resource.type}`);
3433
await trashService.trashItems({
3534
items: [{ type: resource.type, uuid: driveItem.uuid }],
3635
});
@@ -42,5 +41,7 @@ export class DELETERequestHandler implements WebDavMethodHandler {
4241
}
4342

4443
res.status(204).send();
44+
const type = resource.type.charAt(0).toUpperCase() + resource.type.substring(1);
45+
webdavLogger.info(`[DELETE] [${driveItem.uuid}] ${type} trashed successfully`);
4546
};
4647
}

src/webdav/handlers/GET.handler.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,19 @@ import { WebDavUtils } from '../../utils/webdav.utils';
44
import { DriveFileService } from '../../services/drive/drive-file.service';
55
import { DriveDatabaseManager } from '../../services/database/drive-database-manager.service';
66
import { NetworkFacade } from '../../services/network/network-facade.service';
7-
import { UploadService } from '../../services/network/upload.service';
87
import { DownloadService } from '../../services/network/download.service';
98
import { CryptoService } from '../../services/crypto.service';
109
import { AuthService } from '../../services/auth.service';
11-
import { NotFoundError, NotImplementedError } from '../../utils/errors.utils';
10+
import { NotFoundError } from '../../utils/errors.utils';
1211
import { webdavLogger } from '../../utils/logger.utils';
1312
import { DriveFileItem } from '../../types/drive.types';
13+
import { NetworkUtils } from '../../utils/network.utils';
1414

1515
export class GETRequestHandler implements WebDavMethodHandler {
1616
constructor(
1717
private readonly dependencies: {
1818
driveFileService: DriveFileService;
1919
driveDatabaseManager: DriveDatabaseManager;
20-
uploadService: UploadService;
2120
downloadService: DownloadService;
2221
cryptoService: CryptoService;
2322
authService: AuthService;
@@ -29,24 +28,20 @@ export class GETRequestHandler implements WebDavMethodHandler {
2928
const { driveDatabaseManager, driveFileService, authService, networkFacade } = this.dependencies;
3029
const resource = await WebDavUtils.getRequestedResource(req);
3130

32-
if (req.headers['content-range'] || req.headers['range'])
33-
throw new NotImplementedError('Range requests not supported');
3431
if (resource.name.startsWith('._')) throw new NotFoundError('File not found');
32+
if (resource.type === 'folder') throw new NotFoundError('Folders cannot be listed with GET. Use PROPFIND instead.');
3533

36-
webdavLogger.info(`GET request received for file at ${resource.url}`);
34+
webdavLogger.info(`[GET] Request received for ${resource.type} at ${resource.url}`);
3735
const driveFile = (await WebDavUtils.getAndSearchItemFromResource({
3836
resource,
3937
driveDatabaseManager,
4038
driveFileService,
4139
})) as DriveFileItem;
4240

43-
webdavLogger.info(`✅ Found Drive File with uuid ${driveFile.uuid}`);
44-
45-
res.set('Content-Type', 'application/octet-stream');
46-
res.set('Content-length', driveFile.size.toString());
41+
webdavLogger.info(`[GET] [${driveFile.uuid}] Found Drive File`);
4742

4843
const { user } = await authService.getAuthDetails();
49-
webdavLogger.info('✅ Network ready for download');
44+
webdavLogger.info(`[GET] [${driveFile.uuid}] Network ready for download`);
5045

5146
const writable = new WritableStream({
5247
write(chunk) {
@@ -57,28 +52,32 @@ export class GETRequestHandler implements WebDavMethodHandler {
5752
},
5853
});
5954

60-
let lastLoggedProgress = 0;
55+
const range = req.headers['range'];
56+
const rangeOptions = NetworkUtils.parseRangeHeader({
57+
range,
58+
totalFileSize: driveFile.size,
59+
});
60+
let contentLength = driveFile.size;
61+
if (rangeOptions) {
62+
webdavLogger.info(`[GET] [${driveFile.uuid}] Range request received:`, { rangeOptions });
63+
contentLength = rangeOptions.rangeSize;
64+
}
65+
66+
res.header('Content-Type', 'application/octet-stream');
67+
res.header('Content-length', contentLength.toString());
68+
6169
const [executeDownload] = await networkFacade.downloadToStream(
6270
driveFile.bucket,
6371
user.mnemonic,
6472
driveFile.fileId,
6573
writable,
66-
{
67-
progressCallback: (progress) => {
68-
const percentage = Math.floor(100 * progress);
69-
70-
if (percentage >= lastLoggedProgress + 1) {
71-
lastLoggedProgress = percentage;
72-
webdavLogger.info(`Download progress for file ${resource.name}: ${percentage}%`);
73-
}
74-
},
75-
},
74+
rangeOptions,
7675
);
77-
webdavLogger.info('✅ Download prepared, executing...');
76+
webdavLogger.info(`[GET] [${driveFile.uuid}] Download prepared, executing...`);
7877
res.status(200);
7978

8079
await executeDownload;
8180

82-
webdavLogger.info('✅ Download ready, replying to client');
81+
webdavLogger.info(`[GET] [${driveFile.uuid}] ✅ Download ready, replying to client`);
8382
};
8483
}

0 commit comments

Comments
 (0)