Skip to content
Open
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
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.direnv
.env
.git
.npmrc
coverage
dist
node_modules
oclif.manifest.json
tsconfig.tsbuildinfo
3 changes: 3 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ services:
WEBDAV_USERNAME: "" # (Optional) Custom username for WebDAV authentication
WEBDAV_PASSWORD: "" # (Optional) Custom password for WebDAV authentication
WEBDAV_DELETE_FILES_PERMANENTLY: "" # (Optional) Delete files permanently. Set to 'true' to enable
WEBDAV_HYPER_BACKUP_MODE: "" # (Optional) Enable Synology Hyper Backup compatibility and performance shortcuts. Set to 'true' to enable
ports:
- "127.0.0.1:3005:3005" # Map container port to host. Change if WEBDAV_PORT is customized
```
Expand Down Expand Up @@ -54,6 +55,7 @@ docker run -d \
-e WEBDAV_USERNAME="" \
-e WEBDAV_PASSWORD="" \
-e WEBDAV_DELETE_FILES_PERMANENTLY="" \
-e WEBDAV_HYPER_BACKUP_MODE="" \
-p 127.0.0.1:3005:3005 \
internxt/webdav:latest
```
Expand Down Expand Up @@ -94,6 +96,7 @@ You can also run the `internxt/webdav` image directly on popular NAS devices lik
| `WEBDAV_USERNAME` | ❌ No | Username for custom WebDAV authentication. Required if `WEBDAV_CUSTOM_AUTH` is enabled. |
| `WEBDAV_PASSWORD` | ❌ No | Password for custom WebDAV authentication. Required if `WEBDAV_CUSTOM_AUTH` is enabled. |
| `WEBDAV_DELETE_FILES_PERMANENTLY` | ❌ No | Delete files permanently instead of moving them to trash. Set to `true`to enable. |
| `WEBDAV_HYPER_BACKUP_MODE` | ❌ No | Enable Synology Hyper Backup compatibility and performance shortcuts. Set to `true` to enable. |

---

Expand Down
11 changes: 7 additions & 4 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,13 @@ if [ "$deleteFilesPermanently" = "true" ] || [ "$deleteFilesPermanently" = "1" ]
WEBDAV_ARGS="$WEBDAV_ARGS -d"
fi

internxt webdav-config $WEBDAV_ARGS
hyperBackupMode=$(echo "$WEBDAV_HYPER_BACKUP_MODE" | tr '[:upper:]' '[:lower:]')
if [ "$hyperBackupMode" = "true" ] || [ "$hyperBackupMode" = "1" ] || [ "$hyperBackupMode" = "yes" ] || [ "$hyperBackupMode" = "y" ]; then
echo "Enabling WebDAV Hyper Backup mode"
WEBDAV_ARGS="$WEBDAV_ARGS --hyperBackupMode"
fi

internxt webdav enable
internxt webdav-config $WEBDAV_ARGS

mkdir -p /root/.internxt-cli/logs
touch /root/.internxt-cli/logs/internxt-webdav-combined.log
tail -f /root/.internxt-cli/logs/internxt-webdav-combined.log
exec node /app/dist/webdav/index.js
11 changes: 8 additions & 3 deletions docker/health_check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ set -e
WHOAMI_OUTPUT=$(internxt whoami --json 2>/dev/null || true)
WHOAMI_EMAIL=$(echo "$WHOAMI_OUTPUT" | jq -r '.login.user.email // empty')

# Check WebDAV status
STATUS_OUTPUT=$(internxt webdav status --json 2>/dev/null || true)
WEBDAV_STATUS=$(echo "$STATUS_OUTPUT" | jq -r '.message | split(" ") | last // empty')
PORT="${WEBDAV_PORT:-3005}"

# Check the WebDAV listener. A TCP connection is enough here; protocol may be HTTP or HTTPS.
if node -e "const net = require('node:net'); const socket = net.createConnection({ host: '127.0.0.1', port: Number(process.env.WEBDAV_PORT || 3005) }, () => { socket.destroy(); process.exit(0); }); socket.setTimeout(2000); socket.on('timeout', () => { socket.destroy(); process.exit(1); }); socket.on('error', () => process.exit(1));"; then
WEBDAV_STATUS="online"
else
WEBDAV_STATUS="offline"
fi

if [ "$WHOAMI_EMAIL" = "$INXT_USER" ] && [ "$WEBDAV_STATUS" = "online" ]; then
echo "Healthcheck passed. User: $INXT_USER, WebDAV status: $WEBDAV_STATUS"
Expand Down
24 changes: 22 additions & 2 deletions src/commands/webdav-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,30 @@ export default class WebDAVConfig extends Command {
default: undefined,
allowNo: true,
}),
hyperBackupMode: Flags.boolean({
description: 'Enables Synology Hyper Backup compatibility and performance shortcuts.',
required: false,
default: undefined,
allowNo: true,
}),
};
static readonly enableJsonFlag = true;

public run = async () => {
const { flags } = await this.parse(WebDAVConfig);
const { host, port, https, http, timeout, createFullPath, customAuth, username, password, deleteFilesPermanently } =
flags;
const {
host,
port,
https,
http,
timeout,
createFullPath,
customAuth,
username,
password,
deleteFilesPermanently,
hyperBackupMode,
} = flags;
const nonInteractive = flags['non-interactive'];

const webdavConfig = await ConfigService.instance.readWebdavConfig();
Expand Down Expand Up @@ -129,6 +146,9 @@ export default class WebDAVConfig extends Command {
if (deleteFilesPermanently !== undefined) {
webdavConfig['deleteFilesPermanently'] = deleteFilesPermanently;
}
if (hyperBackupMode !== undefined) {
webdavConfig['hyperBackupMode'] = hyperBackupMode;
}

await ConfigService.instance.saveWebdavConfig(webdavConfig);

Expand Down
1 change: 1 addition & 0 deletions src/constants/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export const WEBDAV_DEFAULT_TIMEOUT = 0;
export const WEBDAV_DEFAULT_CREATE_FULL_PATH = true;
export const WEBDAV_DEFAULT_CUSTOM_AUTH = false;
export const WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY = false;
export const WEBDAV_DEFAULT_HYPER_BACKUP_MODE = false;
3 changes: 3 additions & 0 deletions src/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
WEBDAV_DEFAULT_CUSTOM_AUTH,
WEBDAV_SSL_CERTS_DIR,
WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY,
WEBDAV_DEFAULT_HYPER_BACKUP_MODE,
} from '../constants/configs';
import { CacheService } from './cache.service';

Expand Down Expand Up @@ -112,6 +113,7 @@ export class ConfigService {
username: configs?.username ?? '',
password: configs?.password ?? '',
deleteFilesPermanently: configs?.deleteFilesPermanently ?? WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY,
hyperBackupMode: configs?.hyperBackupMode ?? WEBDAV_DEFAULT_HYPER_BACKUP_MODE,
};
CacheService.instance.set(CacheService.WEBDAV_CONFIG_CACHE_KEY, webdavConfig);
return webdavConfig;
Expand All @@ -126,6 +128,7 @@ export class ConfigService {
username: '',
password: '',
deleteFilesPermanently: WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY,
hyperBackupMode: WEBDAV_DEFAULT_HYPER_BACKUP_MODE,
};
}
};
Expand Down
25 changes: 25 additions & 0 deletions src/services/drive/drive-file.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,31 @@ export class DriveFileService {
return driveFileItem;
};

public replaceFile = async (uuid: string, payload: StorageTypes.FileEntryByUuid): Promise<DriveFileItem> => {
const storageClient = SdkManager.instance.getStorage();
const driveFile = await storageClient.replaceFile(uuid, {
fileId: payload.fileId ?? '',
size: payload.size,
});

const driveFileItem: DriveFileItem = {
itemType: 'file',
name: driveFile.plainName ?? driveFile.name,
uuid: driveFile.uuid,
size: driveFile.size,
bucket: driveFile.bucket,
createdAt: new Date(driveFile.createdAt),
updatedAt: new Date(driveFile.updatedAt),
fileId: driveFile.fileId ?? null,
type: driveFile.type ?? null,
status: driveFile.status as DriveFileItem['status'],
folderUuid: driveFile.folderUuid,
creationTime: new Date(driveFile.creationTime ?? driveFile.createdAt),
modificationTime: new Date(driveFile.modificationTime ?? driveFile.updatedAt),
};
return driveFileItem;
};

private readonly createDriveFileEntry = async (
payload: StorageTypes.FileEntryByUuid,
): Promise<StorageTypes.DriveFileData> => {
Expand Down
85 changes: 85 additions & 0 deletions src/services/webdav/webdav-cache.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import path from 'node:path';
import { DriveFileItem, DriveFolderItem } from '../../types/drive.types';

type CacheEntry<T> = {
value: T;
expiresAt: number;
};

export type WebDavFolderContent = {
folders: DriveFolderItem[];
files: DriveFileItem[];
};

export class WebDavCacheService {
public static readonly instance = new WebDavCacheService();

private static readonly TTL_MS = 10 * 60 * 1000;

private readonly foldersByPath = new Map<string, CacheEntry<DriveFolderItem>>();
private readonly filesByPath = new Map<string, CacheEntry<DriveFileItem>>();
private readonly folderContentByPath = new Map<string, CacheEntry<WebDavFolderContent>>();

private readonly normalizeFolderPath = (folderPath: string): string => {
let normalizedPath = folderPath || '/';
if (!normalizedPath.startsWith('/')) normalizedPath = `/${normalizedPath}`;
if (!normalizedPath.endsWith('/')) normalizedPath = `${normalizedPath}/`;
return normalizedPath;
};

private readonly normalizeFilePath = (filePath: string): string => {
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
return normalizedPath.length > 1 ? normalizedPath.replace(/\/$/, '') : normalizedPath;
};

private readonly entry = <T>(value: T): CacheEntry<T> => ({
value,
expiresAt: Date.now() + WebDavCacheService.TTL_MS,
});

private readonly getFresh = <T>(map: Map<string, CacheEntry<T>>, key: string): T | undefined => {
const cached = map.get(key);
if (!cached) return;
if (cached.expiresAt < Date.now()) {
map.delete(key);
return;
}
return cached.value;
};

getFolder = (folderPath: string) => this.getFresh(this.foldersByPath, this.normalizeFolderPath(folderPath));

setFolder = (folderPath: string, folder: DriveFolderItem) => {
this.foldersByPath.set(this.normalizeFolderPath(folderPath), this.entry(folder));
};

getFile = (filePath: string) => this.getFresh(this.filesByPath, this.normalizeFilePath(filePath));

setFile = (filePath: string, file: DriveFileItem) => {
this.filesByPath.set(this.normalizeFilePath(filePath), this.entry(file));
};

getFolderContent = (folderPath: string) =>
this.getFresh(this.folderContentByPath, this.normalizeFolderPath(folderPath));

setFolderContent = (folderPath: string, folderContent: WebDavFolderContent) => {
this.folderContentByPath.set(this.normalizeFolderPath(folderPath), this.entry(folderContent));
};

invalidateResource = (resourcePath: string) => {
const filePath = this.normalizeFilePath(resourcePath);
const folderPath = this.normalizeFolderPath(resourcePath);
const parentFolderPath = this.normalizeFolderPath(path.posix.dirname(filePath));

this.filesByPath.delete(filePath);
this.foldersByPath.delete(folderPath);
this.folderContentByPath.delete(folderPath);
this.folderContentByPath.delete(parentFolderPath);
};

clear = () => {
this.foldersByPath.clear();
this.filesByPath.clear();
this.folderContentByPath.clear();
};
}
Loading