Skip to content
Merged
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"node-fetch": "2.7.0",
"openpgp": "5.11.2",
"pm2": "5.4.3",
"range-parser": "^1.2.1",
"reflect-metadata": "0.2.2",
"selfsigned": "2.4.1",
"sequelize": "6.37.5",
Expand All @@ -75,6 +76,7 @@
"@types/mime-types": "2.1.4",
"@types/node": "22.10.2",
"@types/node-fetch": "2.6.12",
"@types/range-parser": "^1.2.7",
"@vitest/coverage-istanbul": "2.1.8",
"@vitest/spy": "2.1.8",
"eslint": "9.17.0",
Expand Down
1 change: 1 addition & 0 deletions src/commands/download-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export default class DownloadFile extends Command {
user.mnemonic,
driveFile.fileId,
StreamUtils.writeStreamToWritableStream(fileWriteStream),
undefined,
{
abortController: new AbortController(),
progressCallback: (progress) => {
Expand Down
27 changes: 24 additions & 3 deletions src/services/crypto.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CryptoProvider } from '@internxt/sdk';
import { Keys, Password } from '@internxt/sdk/dist/auth';
import { createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomBytes } from 'node:crypto';
import { createCipheriv, createDecipheriv, createHash, Decipher, pbkdf2Sync, randomBytes } from 'node:crypto';
import { Transform } from 'node:stream';
import { KeysService } from './keys.service';
import { ConfigService } from '../services/config.service';
Expand Down Expand Up @@ -116,8 +116,29 @@ export class CryptoService {
return Buffer.concat([decipher.update(contentsToDecrypt), decipher.final()]).toString('utf8');
};

public async decryptStream(inputSlices: ReadableStream<Uint8Array>[], key: Buffer, iv: Buffer) {
const decipher = createDecipheriv('aes-256-ctr', key, iv);
public async decryptStream(
inputSlices: ReadableStream<Uint8Array>[],
key: Buffer,
iv: Buffer,
startOffsetByte?: number,
) {
let decipher: Decipher;
if (startOffsetByte) {
const aesBlockSize = 16;
const startOffset = startOffsetByte % aesBlockSize;
const startBlockFirstByte = startOffsetByte - startOffset;
const startBlockNumber = startBlockFirstByte / aesBlockSize;

const ivForRange = (BigInt('0x' + iv.toString('hex')) + BigInt(startBlockNumber)).toString(16).padStart(32, '0');
const newIv = Buffer.from(ivForRange, 'hex');

const skipBuffer = Buffer.alloc(startOffset, 0);

decipher = createDecipheriv('aes-256-ctr', key, newIv);
decipher.update(skipBuffer);
} else {
decipher = createDecipheriv('aes-256-ctr', key, iv);
}
const encryptedStream = StreamUtils.joinReadableBinaryStreams(inputSlices);

let keepReading = true;
Expand Down
9 changes: 8 additions & 1 deletion src/services/network/download.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ export class DownloadService {

async downloadFile(
url: string,
options: { progressCallback?: (progress: number) => void; abortController?: AbortController },
options: {
progressCallback?: (progress: number) => void;
abortController?: AbortController;
rangeHeader?: string;
},
): Promise<ReadableStream<Uint8Array>> {
const response = await axios.get(url, {
responseType: 'stream',
Expand All @@ -16,6 +20,9 @@ export class DownloadService {
options.progressCallback(reportedProgress);
}
},
headers: {
range: options.rangeHeader,
},
});

const readable = new ReadableStream<Uint8Array>({
Expand Down
12 changes: 12 additions & 0 deletions src/services/network/network-facade.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { DownloadService } from './download.service';
import { ValidationService } from '../validation.service';
import { HashStream } from '../../utils/hash.utils';
import { ProgressTransform } from '../../utils/stream.utils';
import { RangeOptions } from '../../utils/network.utils';

export class NetworkFacade {
private readonly cryptoLib: Network.Crypto;
Expand Down Expand Up @@ -54,6 +55,7 @@ export class NetworkFacade {
mnemonic: string,
fileId: string,
to: WritableStream,
rangeOptions?: RangeOptions,
options?: DownloadOptions,
): Promise<[Promise<void>, AbortController]> {
const encryptedContentStreams: ReadableStream<Uint8Array>[] = [];
Expand All @@ -70,16 +72,25 @@ export class NetworkFacade {
};

const decryptFile: DecryptFileFunction = async (_, key, iv) => {
let startOffsetByte;
if (rangeOptions) {
startOffsetByte = rangeOptions.parsed.start;
}
fileStream = await this.cryptoService.decryptStream(
encryptedContentStreams,
Buffer.from(key as ArrayBuffer),
Buffer.from(iv as ArrayBuffer),
startOffsetByte,
);

await fileStream.pipeTo(to);
};

const downloadFile: DownloadFileFunction = async (downloadables) => {
if (rangeOptions && downloadables.length > 1) {
throw new Error('Multi-Part Download with Range-Requests is not implemented');
}

for (const downloadable of downloadables) {
if (abortable.signal.aborted) {
throw new Error('Download aborted');
Expand All @@ -88,6 +99,7 @@ export class NetworkFacade {
const encryptedContentStream = await this.downloadService.downloadFile(downloadable.url, {
progressCallback: onDownloadProgress,
abortController: options?.abortController,
rangeHeader: rangeOptions?.range,
});

encryptedContentStreams.push(encryptedContentStream);
Expand Down
10 changes: 10 additions & 0 deletions src/utils/errors.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ export class UnsupportedMediaTypeError extends Error {
}
}

export class MethodNotAllowed extends Error {
public statusCode = 405;

constructor(message: string) {
super(message);
this.name = 'MethodNotAllowed';
Object.setPrototypeOf(this, MethodNotAllowed.prototype);
}
}

export class NotImplementedError extends Error {
public statusCode = 501;

Expand Down
4 changes: 2 additions & 2 deletions src/utils/logger.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const maxLogsFiles = 5;

export const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
defaultMeta: { service: 'internxt-cli' },
transports: [
new winston.transports.File({
Expand All @@ -29,7 +29,7 @@ export const logger = winston.createLogger({

export const webdavLogger = winston.createLogger({
level: 'info',
format: winston.format.json(),
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
defaultMeta: { service: 'internxt-webdav' },
transports: [
new winston.transports.File({
Expand Down
38 changes: 38 additions & 0 deletions src/utils/network.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createHash, X509Certificate } from 'node:crypto';
import { readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import selfsigned from 'selfsigned';
import parseRange from 'range-parser';
import { ConfigService } from '../services/config.service';

export class NetworkUtils {
Expand Down Expand Up @@ -74,4 +75,41 @@ export class NetworkUtils {
const pems = selfsigned.generate(attrs, { days: 365, algorithm: 'sha256', keySize: 2048, extensions });
return pems;
}

static parseRangeHeader(rangeOptions: { range?: string; totalFileSize: number }): RangeOptions | undefined {
if (!rangeOptions.range) {
return;
}
const parsed = parseRange(rangeOptions.totalFileSize, rangeOptions.range);
if (Array.isArray(parsed)) {
if (parsed.length > 1) {
throw new Error(`Multi Range-Requests functionality is not implemented. ${JSON.stringify(rangeOptions)}`);
} else if (parsed.length <= 0) {
throw new Error(`Empty Range-Request. ${JSON.stringify(rangeOptions)}`);
} else if (parsed.type !== 'bytes') {
throw new Error(`Unkwnown Range-Request type "${parsed.type}". ${JSON.stringify(rangeOptions)}`);
} else {
const rangeSize = parsed[0].end - parsed[0].start + 1;
return {
range: rangeOptions.range,
rangeSize: rangeSize,
totalFileSize: rangeOptions.totalFileSize,
parsed: parsed[0],
};
}
} else if (parsed === -1) {
throw new Error(`Malformed Range-Request. ${JSON.stringify(rangeOptions)}`);
} else if (parsed === -2) {
throw new Error(`Unsatisfiable Range-Request. ${JSON.stringify(rangeOptions)}`);
} else {
throw new Error(`Unknown error from Range-Request. ${JSON.stringify(rangeOptions)}`);
}
}
}

export interface RangeOptions {
range: string;
rangeSize: number;
totalFileSize: number;
parsed: parseRange.Range;
}
7 changes: 4 additions & 3 deletions src/webdav/handlers/DELETE.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ export class DELETERequestHandler implements WebDavMethodHandler {

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

const driveItem = await WebDavUtils.getAndSearchItemFromResource({
resource,
Expand All @@ -30,7 +29,7 @@ export class DELETERequestHandler implements WebDavMethodHandler {
driveFileService: driveFileService,
});

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

res.status(204).send();
const type = resource.type.charAt(0).toUpperCase() + resource.type.substring(1);
webdavLogger.info(`[DELETE] [${driveItem.uuid}] ${type} trashed successfully`);
};
}
47 changes: 23 additions & 24 deletions src/webdav/handlers/GET.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,19 @@ import { WebDavUtils } from '../../utils/webdav.utils';
import { DriveFileService } from '../../services/drive/drive-file.service';
import { DriveDatabaseManager } from '../../services/database/drive-database-manager.service';
import { NetworkFacade } from '../../services/network/network-facade.service';
import { UploadService } from '../../services/network/upload.service';
import { DownloadService } from '../../services/network/download.service';
import { CryptoService } from '../../services/crypto.service';
import { AuthService } from '../../services/auth.service';
import { NotFoundError, NotImplementedError } from '../../utils/errors.utils';
import { NotFoundError } from '../../utils/errors.utils';
import { webdavLogger } from '../../utils/logger.utils';
import { DriveFileItem } from '../../types/drive.types';
import { NetworkUtils } from '../../utils/network.utils';

export class GETRequestHandler implements WebDavMethodHandler {
constructor(
private readonly dependencies: {
driveFileService: DriveFileService;
driveDatabaseManager: DriveDatabaseManager;
uploadService: UploadService;
downloadService: DownloadService;
cryptoService: CryptoService;
authService: AuthService;
Expand All @@ -29,24 +28,20 @@ export class GETRequestHandler implements WebDavMethodHandler {
const { driveDatabaseManager, driveFileService, authService, networkFacade } = this.dependencies;
const resource = await WebDavUtils.getRequestedResource(req);

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

webdavLogger.info(`GET request received for file at ${resource.url}`);
webdavLogger.info(`[GET] Request received for ${resource.type} at ${resource.url}`);
const driveFile = (await WebDavUtils.getAndSearchItemFromResource({
resource,
driveDatabaseManager,
driveFileService,
})) as DriveFileItem;

webdavLogger.info(`✅ Found Drive File with uuid ${driveFile.uuid}`);

res.set('Content-Type', 'application/octet-stream');
res.set('Content-length', driveFile.size.toString());
webdavLogger.info(`[GET] [${driveFile.uuid}] Found Drive File`);

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

const writable = new WritableStream({
write(chunk) {
Expand All @@ -57,28 +52,32 @@ export class GETRequestHandler implements WebDavMethodHandler {
},
});

let lastLoggedProgress = 0;
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 [executeDownload] = await networkFacade.downloadToStream(
driveFile.bucket,
user.mnemonic,
driveFile.fileId,
writable,
{
progressCallback: (progress) => {
const percentage = Math.floor(100 * progress);

if (percentage >= lastLoggedProgress + 1) {
lastLoggedProgress = percentage;
webdavLogger.info(`Download progress for file ${resource.name}: ${percentage}%`);
}
},
},
rangeOptions,
);
webdavLogger.info('✅ Download prepared, executing...');
webdavLogger.info(`[GET] [${driveFile.uuid}] Download prepared, executing...`);
res.status(200);

await executeDownload;

webdavLogger.info('✅ Download ready, replying to client');
webdavLogger.info(`[GET] [${driveFile.uuid}] ✅ Download ready, replying to client`);
};
}
Loading
Loading