Skip to content

Commit

Permalink
Merge pull request #47 from internxt/feature/webdav-download
Browse files Browse the repository at this point in the history
  • Loading branch information
PixoDev authored Mar 13, 2024
2 parents 1ab7686 + c355181 commit d58b992
Show file tree
Hide file tree
Showing 31 changed files with 750 additions and 109 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"dayjs": "^1.11.10",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"express-async-handler": "^1.2.0",
"express-basic-auth": "^1.2.1",
"fast-xml-parser": "^4.3.5",
"openpgp": "^5.11.1",
Expand Down
2 changes: 1 addition & 1 deletion src/commands/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default class Whoami extends Command {
if (userCredentials?.user?.email) {
CLIUtils.success(`You are logged in with: ${userCredentials.user.email}`);
} else {
CLIUtils.success('You are not logged in');
CLIUtils.error('You are not logged in');
}
}
}
1 change: 1 addition & 0 deletions src/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CryptoService } from './crypto.service';

export class ConfigService {
static readonly INTERNXT_CLI_DATA_DIR = path.join(os.homedir(), '.internxt-cli');
static readonly INTERNXT_TMP_DIR = os.tmpdir();
static readonly CREDENTIALS_FILE = path.join(this.INTERNXT_CLI_DATA_DIR, '.inxtcli');
static readonly DRIVE_REALM_FILE = path.join(this.INTERNXT_CLI_DATA_DIR, 'internxt-cli-drive.realm');
public static readonly instance: ConfigService = new ConfigService();
Expand Down
4 changes: 4 additions & 0 deletions src/services/drive/drive-file.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export class DriveFileService {
fileId: payload.fileId,
id: driveFile.id,
type: payload.type,
status: driveFile.status,
folderId: driveFile.folderId,
};
}

Expand All @@ -55,6 +57,8 @@ export class DriveFileService {
const fileMetadata = await getFileMetadata;
return {
uuid,
status: fileMetadata.status,
folderId: fileMetadata.folder_id,
size: fileMetadata.size,
encryptedName: fileMetadata.name,
name: fileMetadata.plainName ?? fileMetadata.name,
Expand Down
21 changes: 11 additions & 10 deletions src/services/network/download.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import superagent from 'superagent';
import axios from 'axios';

export class DownloadService {
static readonly instance = new DownloadService();
Expand All @@ -7,22 +7,23 @@ export class DownloadService {
url: string,
options: { progressCallback?: (progress: number) => void; abortController?: AbortController },
): Promise<ReadableStream<Uint8Array>> {
const request = superagent.get(url).on('progress', (progressEvent) => {
if (options.progressCallback && progressEvent.total) {
const reportedProgress = progressEvent.loaded / parseInt(progressEvent.total);
const response = await axios.get(url, {
responseType: 'stream',
onDownloadProgress(progressEvent) {
if (options.progressCallback && progressEvent.total) {
const reportedProgress = progressEvent.loaded / progressEvent.total;

options.progressCallback(reportedProgress);
}
options.progressCallback(reportedProgress);
}
},
});

const response = await request;

const readable = new ReadableStream<Uint8Array>({
start(controller) {
response.on('data', (chunk: Uint8Array) => {
response.data.on('data', (chunk: Uint8Array) => {
controller.enqueue(chunk);
});
response.on('end', () => {
response.data.on('end', () => {
controller.close();
});
},
Expand Down
30 changes: 27 additions & 3 deletions src/services/realms/drive-files.realm.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import Realm, { ObjectSchema } from 'realm';
import { DriveFileItem } from '../../types/drive.types';

export class DriveFileRealmSchema extends Realm.Object<DriveFileRealmSchema> {
id!: number;
name!: string;
type?: string;
uuid!: string;
fileId!: string;
file_id!: string;
folder_id!: number;
folder_uuid!: string;
bucket!: string;
relative_path!: string;
created_at!: Date;
Expand All @@ -23,7 +23,6 @@ export class DriveFileRealmSchema extends Realm.Object<DriveFileRealmSchema> {
uuid: { type: 'string', indexed: true },
file_id: 'string',
folder_id: 'int',
folder_uuid: 'string',
bucket: 'string',
relative_path: { type: 'string', indexed: true },
created_at: 'date',
Expand All @@ -46,4 +45,29 @@ export class DriveFilesRealm {

return object ?? null;
}

async createOrReplace(driveFile: DriveFileItem, relativePath: string) {
const existingObject = this.realm.objectForPrimaryKey<DriveFileRealmSchema>('DriveFile', driveFile.id);

this.realm.write(() => {
if (existingObject) {
this.realm.delete(existingObject);
}

this.realm.create<DriveFileRealmSchema>('DriveFile', {
id: driveFile.id,
name: driveFile.name,
type: driveFile.type,
uuid: driveFile.uuid,
file_id: driveFile.fileId,
folder_id: driveFile.folderId,
bucket: driveFile.bucket,
relative_path: relativePath,
created_at: driveFile.createdAt,
updated_at: driveFile.updatedAt,
size: driveFile.size,
status: 'EXISTS',
});
});
}
}
23 changes: 22 additions & 1 deletion src/services/realms/drive-realm-manager.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'path';
import { DriveFolderItem } from '../../types/drive.types';
import { DriveFileItem, DriveFolderItem } from '../../types/drive.types';
import { DriveFileRealmSchema, DriveFilesRealm } from './drive-files.realm';
import { DriveFolderRealmSchema, DriveFoldersRealm } from './drive-folders.realm';

Expand Down Expand Up @@ -27,6 +27,27 @@ export class DriveRealmManager {
return this.driveFoldersRealm.createOrReplace(driveFolder, relativePath);
}

async createFile(driveFile: DriveFileItem) {
const relativePath = await this.buildRelativePathForFile(
driveFile.type ? `${driveFile.name}.${driveFile.type}` : driveFile.name,
driveFile.folderId,
);

return this.driveFilesRealm.createOrReplace(driveFile, relativePath);
}

async buildRelativePathForFile(fileName: string, parentId: number | null): Promise<string> {
const parentFolder = await this.driveFoldersRealm.findByParentId(parentId);

if (!parentFolder) {
return path.join('/', fileName);
}

const parentPath = await this.buildRelativePathForFile(parentFolder.name, parentFolder.parent_id ?? null);

return path.join(parentPath, fileName);
}

async buildRelativePathForFolder(folderName: string, parentId: number | null): Promise<string> {
const parentFolder = await this.driveFoldersRealm.findByParentId(parentId);

Expand Down
6 changes: 5 additions & 1 deletion src/types/drive.types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { DriveFileData, DriveFolderData } from '@internxt/sdk/dist/drive/storage/types';

export type DriveFileItem = Pick<DriveFileData, 'name' | 'bucket' | 'fileId' | 'id' | 'uuid' | 'type'> & {
export type DriveFileItem = Pick<
DriveFileData,
'name' | 'bucket' | 'fileId' | 'id' | 'uuid' | 'folderId' | 'status'
> & {
encryptedName: string;
size: number;
createdAt: Date;
updatedAt: Date;
type?: string;
};

export type DriveFolderItem = Pick<DriveFolderData, 'name' | 'bucket' | 'id' | 'parentId'> & {
Expand Down
8 changes: 8 additions & 0 deletions src/types/webdav.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { ParsedPath } from 'path';

export abstract class WebDavMethodHandler {
abstract handle(request: Request, response: Response): Promise<void>;
Expand All @@ -7,3 +8,10 @@ export abstract class WebDavMethodHandler {
export type WebDavMethodHandlerOptions = {
debug: boolean;
};

export type WebDavRequestedResource = {
type: 'file' | 'folder' | 'root';
url: string;
name: string;
path: ParsedPath;
};
30 changes: 30 additions & 0 deletions src/utils/errors.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,33 @@ export class ErrorUtils {
}
}
}

export class NotFoundError extends Error {
public statusCode = 404;

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

export class BadRequestError extends Error {
public statusCode = 400;

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

export class NotImplementedError extends Error {
public statusCode = 501;

constructor(message: string) {
super(message);
this.name = 'NotImplementedError';
Object.setPrototypeOf(this, NotImplementedError.prototype);
}
}
28 changes: 28 additions & 0 deletions src/utils/webdav.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Request } from 'express';
import path from 'path';
import { WebDavRequestedResource } from '../types/webdav.types';
export class WebDavUtils {
static getHref(...pathComponents: string[]): string {
return path.posix.join(...pathComponents);
}
static getRequestedResource(req: Request): WebDavRequestedResource {
const decodedUrl = decodeURI(req.url);
const parsedPath = path.parse(decodedUrl);

if (req.url.endsWith('/')) {
return {
url: decodedUrl,
type: 'folder',
name: parsedPath.name,
path: parsedPath,
};
} else {
return {
type: 'file',
url: decodedUrl,
name: parsedPath.name,
path: parsedPath,
};
}
}
}
84 changes: 84 additions & 0 deletions src/webdav/handlers/GET.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { WebDavMethodHandler, WebDavRequestedResource } from '../../types/webdav.types';
import { Request, Response } from 'express';
import { WebDavUtils } from '../../utils/webdav.utils';
import { DriveFileService } from '../../services/drive/drive-file.service';
import { DriveRealmManager } from '../../services/realms/drive-realm-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 { DriveFileRealmSchema } from '../../services/realms/drive-files.realm';
import { NotFoundError, NotImplementedError } from '../../utils/errors.utils';
import { webdavLogger } from '../../utils/logger.utils';

export class GETRequestHandler implements WebDavMethodHandler {
constructor(
private dependencies: {
driveFileService: DriveFileService;
driveRealmManager: DriveRealmManager;
uploadService: UploadService;
downloadService: DownloadService;
cryptoService: CryptoService;
authService: AuthService;
networkFacade: NetworkFacade;
},
) {}
handle = async (req: Request, res: Response) => {
const resource = 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');

webdavLogger.info(`GET request received for file at ${resource.url}`);
const driveFile = await this.getDriveFileRealmObject(resource);

if (!driveFile) {
throw new NotFoundError('Drive file not found');
}

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

res.set('Content-Type', 'application/octet-stream');
res.set('Content-length', driveFile.size.toString());

const { mnemonic } = await this.dependencies.authService.getAuthDetails();
webdavLogger.info('✅ Network ready for download');

const writable = new WritableStream({
write(chunk) {
res.write(chunk);
},
close() {
res.end();
},
});

const [executeDownload] = await this.dependencies.networkFacade.downloadToStream(
driveFile.bucket,
mnemonic,
driveFile.file_id,
writable,
{
progressCallback: (progress) => {
webdavLogger.info(`Download progress for file ${resource.name}: ${progress}%`);
},
},
);
webdavLogger.info('✅ Download prepared, executing...');
res.status(200);

await executeDownload;

webdavLogger.info('✅ Download ready, replying to client');
};

private async getDriveFileRealmObject(resource: WebDavRequestedResource) {
const { driveRealmManager } = this.dependencies;

const result = await driveRealmManager.findByRelativePath(resource.url);

return result as DriveFileRealmSchema | null;
}
}
8 changes: 8 additions & 0 deletions src/webdav/handlers/HEAD.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { WebDavMethodHandler } from '../../types/webdav.types';
import { Request, Response } from 'express';
export class HEADRequestHandler implements WebDavMethodHandler {
async handle(req: Request, res: Response) {
// This is a NOOP request handler, clients like CyberDuck uses this.
res.status(405).send();
}
}
Loading

0 comments on commit d58b992

Please sign in to comment.