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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ oclif.manifest.json
coverage
cli.inxt
*.log
.vscode
.vscode

# Claude Code project instructions
CLAUDE.md
19 changes: 13 additions & 6 deletions src/commands/webdav-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,25 @@ export default class WebDAVConfig extends Command {
required: false,
min: 0,
}),
createFullPath: Flags.boolean({
char: 'c',
description: 'Auto-create missing parent directories during file uploads.',
required: false,
allowNo: true,
}),
};
static readonly enableJsonFlag = true;

public run = async () => {
const { flags } = await this.parse(WebDAVConfig);
const {
flags: { host, port, http, https, timeout, createFullPath },
} = await this.parse(WebDAVConfig);
const webdavConfig = await ConfigService.instance.readWebdavConfig();

const host = flags['host'];
if (host) {
webdavConfig['host'] = host;
}

const port = flags['port'];
if (port) {
if (ValidationService.instance.validateTCPIntegerPort(port)) {
webdavConfig['port'] = port;
Expand All @@ -59,21 +65,22 @@ export default class WebDAVConfig extends Command {
}
}

const http = flags['http'];
if (http) {
webdavConfig['protocol'] = 'http';
}

const https = flags['https'];
if (https) {
webdavConfig['protocol'] = 'https';
}

const timeout = flags['timeout'];
if (timeout !== undefined) {
webdavConfig['timeoutMinutes'] = timeout;
}

if (createFullPath !== undefined) {
webdavConfig['createFullPath'] = createFullPath;
}

await ConfigService.instance.saveWebdavConfig(webdavConfig);
const message = `On the next start, the WebDAV server will use the next config: ${JSON.stringify(webdavConfig)}`;
CLIUtils.success(this.log.bind(this), message);
Expand Down
3 changes: 3 additions & 0 deletions src/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class ConfigService {
static readonly WEBDAV_DEFAULT_PORT = '3005';
static readonly WEBDAV_DEFAULT_PROTOCOL = 'https';
static readonly WEBDAV_DEFAULT_TIMEOUT = 0;
static readonly WEBDAV_DEFAULT_CREATE_FULL_PATH = true;
public static readonly instance: ConfigService = new ConfigService();

/**
Expand Down Expand Up @@ -85,13 +86,15 @@ export class ConfigService {
port: configs?.port ?? ConfigService.WEBDAV_DEFAULT_PORT,
protocol: configs?.protocol ?? ConfigService.WEBDAV_DEFAULT_PROTOCOL,
timeoutMinutes: configs?.timeoutMinutes ?? ConfigService.WEBDAV_DEFAULT_TIMEOUT,
createFullPath: configs?.createFullPath ?? ConfigService.WEBDAV_DEFAULT_CREATE_FULL_PATH,
};
} catch {
return {
host: ConfigService.WEBDAV_DEFAULT_HOST,
port: ConfigService.WEBDAV_DEFAULT_PORT,
protocol: ConfigService.WEBDAV_DEFAULT_PROTOCOL,
timeoutMinutes: ConfigService.WEBDAV_DEFAULT_TIMEOUT,
createFullPath: ConfigService.WEBDAV_DEFAULT_CREATE_FULL_PATH,
};
}
};
Expand Down
1 change: 1 addition & 0 deletions src/types/command.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface WebdavConfig {
port: string;
protocol: 'http' | 'https';
timeoutMinutes: number;
createFullPath: boolean;
}

export class NotValidEmailError extends Error {
Expand Down
2 changes: 2 additions & 0 deletions src/types/drive.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ export type DriveFolderItem = Pick<DriveFolderData, 'name' | 'bucket' | 'id' | '
status: 'EXISTS' | 'TRASHED';
parentUuid: string | null;
};

export type DriveItem = DriveFileItem | DriveFolderItem;
17 changes: 16 additions & 1 deletion src/utils/drive.utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FileMeta, FolderMeta } from '@internxt/sdk/dist/drive/storage/types';
import { FileMeta, FolderMeta, CreateFolderResponse } from '@internxt/sdk/dist/drive/storage/types';
import { DriveFileItem, DriveFolderItem } from '../types/drive.types';

export class DriveUtils {
Expand Down Expand Up @@ -35,4 +35,19 @@ export class DriveUtils {
updatedAt: new Date(folderMeta.updatedAt),
};
}

static createFolderResponseToItem(folderResponse: CreateFolderResponse): DriveFolderItem {
return {
uuid: folderResponse.uuid,
id: folderResponse.id,
bucket: folderResponse.bucket,
status: folderResponse.deleted || folderResponse.removed ? 'TRASHED' : 'EXISTS',
name: folderResponse.plainName ?? folderResponse.name,
encryptedName: folderResponse.name,
parentId: folderResponse.parentId,
parentUuid: folderResponse.parentUuid,
createdAt: new Date(folderResponse.createdAt),
updatedAt: new Date(folderResponse.updatedAt),
};
}
}
47 changes: 40 additions & 7 deletions src/utils/webdav.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from 'node:path';
import { WebDavRequestedResource } from '../types/webdav.types';
import { DriveFolderService } from '../services/drive/drive-folder.service';
import { DriveFileService } from '../services/drive/drive-file.service';
import { DriveFileItem, DriveFolderItem } from '../types/drive.types';
import { DriveFileItem, DriveFolderItem, DriveItem } from '../types/drive.types';

export class WebDavUtils {
static joinURL(...pathComponents: string[]): string {
Expand All @@ -22,6 +22,23 @@ export class WebDavUtils {
return url;
}

static decodeUrl(requestUrl: string, decodeUri = true): string {
return (decodeUri ? decodeURIComponent(requestUrl) : requestUrl).replaceAll('/./', '/');
}

static normalizeFolderPath(path: string): string {
let normalizedPath = path;

if (!normalizedPath.startsWith('/')) {
normalizedPath = `/${normalizedPath}`;
}

if (!normalizedPath.endsWith('/')) {
normalizedPath = `${normalizedPath}/`;
}
return normalizedPath;
}

static async getRequestedResource(urlObject: string | Request, decodeUri = true): Promise<WebDavRequestedResource> {
let requestUrl: string;
if (typeof urlObject === 'string') {
Expand All @@ -30,11 +47,9 @@ export class WebDavUtils {
requestUrl = urlObject.url;
}

const decodedUrl = (decodeUri ? decodeURIComponent(requestUrl) : requestUrl).replaceAll('/./', '/');
const decodedUrl = this.decodeUrl(requestUrl, decodeUri);
const parsedPath = path.parse(decodedUrl);
let parentPath = path.dirname(decodedUrl);
if (!parentPath.startsWith('/')) parentPath = '/'.concat(parentPath);
if (!parentPath.endsWith('/')) parentPath = parentPath.concat('/');
const parentPath = this.normalizeFolderPath(path.dirname(decodedUrl));

const isFolder = requestUrl.endsWith('/');

Expand All @@ -57,6 +72,24 @@ export class WebDavUtils {
}
}

static async getDriveItemFromResource(params: {
resource: WebDavRequestedResource;
driveFolderService: DriveFolderService;
driveFileService?: never;
}): Promise<DriveFolderItem | undefined>;

static async getDriveItemFromResource(params: {
resource: WebDavRequestedResource;
driveFolderService?: never;
driveFileService: DriveFileService;
}): Promise<DriveFileItem | undefined>;

static async getDriveItemFromResource(params: {
resource: WebDavRequestedResource;
driveFolderService: DriveFolderService;
driveFileService: DriveFileService;
}): Promise<DriveItem | undefined>;

static async getDriveItemFromResource({
resource,
driveFolderService,
Expand All @@ -65,8 +98,8 @@ export class WebDavUtils {
resource: WebDavRequestedResource;
driveFolderService?: DriveFolderService;
driveFileService?: DriveFileService;
}): Promise<DriveFileItem | DriveFolderItem | undefined> {
let item: DriveFileItem | DriveFolderItem | undefined = undefined;
}): Promise<DriveItem | undefined> {
let item: DriveItem | undefined = undefined;

try {
if (resource.type === 'folder') {
Expand Down
36 changes: 10 additions & 26 deletions src/webdav/handlers/MKCOL.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,26 @@ import { WebDavUtils } from '../../utils/webdav.utils';
import { DriveFolderService } from '../../services/drive/drive-folder.service';
import { webdavLogger } from '../../utils/logger.utils';
import { XMLUtils } from '../../utils/xml.utils';
import { AsyncUtils } from '../../utils/async.utils';
import { DriveFolderItem } from '../../types/drive.types';
import { ConflictError, MethodNotAllowed } from '../../utils/errors.utils';
import { WebDavFolderService } from '../services/webdav-folder.service';
import { MethodNotAllowed } from '../../utils/errors.utils';

export class MKCOLRequestHandler implements WebDavMethodHandler {
constructor(
private readonly dependencies: {
driveFolderService: DriveFolderService;
webDavFolderService: WebDavFolderService;
},
) {}

handle = async (req: Request, res: Response) => {
const { driveFolderService } = this.dependencies;
const { driveFolderService, webDavFolderService } = this.dependencies;
const resource = await WebDavUtils.getRequestedResource(req);

webdavLogger.info(`[MKCOL] Request received for ${resource.type} at ${resource.url}`);

const parentResource = await WebDavUtils.getRequestedResource(resource.parentPath, false);

const parentDriveItem = await WebDavUtils.getDriveItemFromResource({
resource: parentResource,
driveFolderService,
});

if (!parentDriveItem) {
// WebDAV RFC
// When the MKCOL operation creates a new collection resource,
// all ancestors MUST already exist, or the method MUST fail
// with a 409 (Conflict) status code
throw new ConflictError(`Parent folders not found on Internxt Drive at ${resource.url}`);
}
const parentFolderItem = parentDriveItem as DriveFolderItem;
const parentDriveFolderItem =
(await webDavFolderService.getDriveFolderItemFromPath(resource.parentPath)) ??
(await webDavFolderService.createParentPathOrThrow(resource.parentPath));

const driveFolderItem = await WebDavUtils.getDriveItemFromResource({
resource,
Expand All @@ -49,17 +37,13 @@ export class MKCOLRequestHandler implements WebDavMethodHandler {
throw new MethodNotAllowed('Folder already exists');
}

const [createFolder] = driveFolderService.createFolder({
plainName: resource.path.base,
parentFolderUuid: parentFolderItem.uuid,
const newFolder = await webDavFolderService.createFolder({
folderName: resource.path.base,
parentFolderUuid: parentDriveFolderItem.uuid,
});

const newFolder = await createFolder;

webdavLogger.info(`[MKCOL] ✅ Folder created with UUID ${newFolder.uuid}`);

// This aims to prevent this issue: https://inxt.atlassian.net/browse/PB-1446
await AsyncUtils.sleep(500);
res.status(201).send(XMLUtils.toWebDavXML({}, {}));
};
}
17 changes: 6 additions & 11 deletions src/webdav/handlers/MOVE.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@ import { NotFoundError } from '../../utils/errors.utils';
import { webdavLogger } from '../../utils/logger.utils';
import { WebDavUtils } from '../../utils/webdav.utils';
import { DriveFileItem, DriveFolderItem } from '../../types/drive.types';
import { WebDavFolderService } from '../services/webdav-folder.service';

export class MOVERequestHandler implements WebDavMethodHandler {
constructor(
private readonly dependencies: {
driveFolderService: DriveFolderService;
driveFileService: DriveFileService;
webDavFolderService: WebDavFolderService;
},
) {}

handle = async (req: Request, res: Response) => {
const { driveFolderService, driveFileService } = this.dependencies;
const { driveFolderService, driveFileService, webDavFolderService } = this.dependencies;
const resource = await WebDavUtils.getRequestedResource(req);

webdavLogger.info(`[MOVE] Request received for ${resource.type} at ${resource.url}`);
Expand Down Expand Up @@ -65,17 +67,10 @@ export class MOVERequestHandler implements WebDavMethodHandler {
} else {
// MOVE (the operation is from different dirs)
webdavLogger.info(`[MOVE] Moving ${resource.type} with UUID ${originalDriveItem.uuid} to ${destinationPath}`);
const destinationFolderResource = await WebDavUtils.getRequestedResource(destinationResource.parentPath);

const destinationDriveFolderItem = await WebDavUtils.getDriveItemFromResource({
resource: destinationFolderResource,
driveFolderService,
});

if (!destinationDriveFolderItem) {
throw new NotFoundError(`Resource not found on Internxt Drive at ${resource.url}`);
}
const destinationFolderItem = destinationDriveFolderItem as DriveFileItem;
const destinationFolderItem =
(await webDavFolderService.getDriveFolderItemFromPath(destinationResource.parentPath)) ??
(await webDavFolderService.createParentPathOrThrow(destinationResource.parentPath));

if (resource.type === 'folder') {
const folder = originalDriveItem as DriveFolderItem;
Expand Down
Loading