Skip to content

Commit 3011c72

Browse files
author
Cesare
committed
Add Synology Hyper Backup WebDAV mode
1 parent 166fb5a commit 3011c72

19 files changed

Lines changed: 482 additions & 48 deletions

.dockerignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.direnv
2+
.env
3+
.git
4+
.npmrc
5+
coverage
6+
dist
7+
node_modules
8+
oclif.manifest.json
9+
tsconfig.tsbuildinfo

docker/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ services:
2727
WEBDAV_USERNAME: "" # (Optional) Custom username for WebDAV authentication
2828
WEBDAV_PASSWORD: "" # (Optional) Custom password for WebDAV authentication
2929
WEBDAV_DELETE_FILES_PERMANENTLY: "" # (Optional) Delete files permanently. Set to 'true' to enable
30+
WEBDAV_HYPER_BACKUP_MODE: "" # (Optional) Enable Synology Hyper Backup compatibility and performance shortcuts. Set to 'true' to enable
3031
ports:
3132
- "127.0.0.1:3005:3005" # Map container port to host. Change if WEBDAV_PORT is customized
3233
```
@@ -54,6 +55,7 @@ docker run -d \
5455
-e WEBDAV_USERNAME="" \
5556
-e WEBDAV_PASSWORD="" \
5657
-e WEBDAV_DELETE_FILES_PERMANENTLY="" \
58+
-e WEBDAV_HYPER_BACKUP_MODE="" \
5759
-p 127.0.0.1:3005:3005 \
5860
internxt/webdav:latest
5961
```
@@ -94,6 +96,7 @@ You can also run the `internxt/webdav` image directly on popular NAS devices lik
9496
| `WEBDAV_USERNAME` | ❌ No | Username for custom WebDAV authentication. Required if `WEBDAV_CUSTOM_AUTH` is enabled. |
9597
| `WEBDAV_PASSWORD` | ❌ No | Password for custom WebDAV authentication. Required if `WEBDAV_CUSTOM_AUTH` is enabled. |
9698
| `WEBDAV_DELETE_FILES_PERMANENTLY` | ❌ No | Delete files permanently instead of moving them to trash. Set to `true`to enable. |
99+
| `WEBDAV_HYPER_BACKUP_MODE` | ❌ No | Enable Synology Hyper Backup compatibility and performance shortcuts. Set to `true` to enable. |
97100

98101
---
99102

docker/entrypoint.sh

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,13 @@ if [ "$deleteFilesPermanently" = "true" ] || [ "$deleteFilesPermanently" = "1" ]
5757
WEBDAV_ARGS="$WEBDAV_ARGS -d"
5858
fi
5959

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

62-
internxt webdav enable
66+
internxt webdav-config $WEBDAV_ARGS
6367

6468
mkdir -p /root/.internxt-cli/logs
65-
touch /root/.internxt-cli/logs/internxt-webdav-combined.log
66-
tail -f /root/.internxt-cli/logs/internxt-webdav-combined.log
69+
exec node /app/dist/webdav/index.js

docker/health_check.sh

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ set -e
66
WHOAMI_OUTPUT=$(internxt whoami --json 2>/dev/null || true)
77
WHOAMI_EMAIL=$(echo "$WHOAMI_OUTPUT" | jq -r '.login.user.email // empty')
88

9-
# Check WebDAV status
10-
STATUS_OUTPUT=$(internxt webdav status --json 2>/dev/null || true)
11-
WEBDAV_STATUS=$(echo "$STATUS_OUTPUT" | jq -r '.message | split(" ") | last // empty')
9+
PORT="${WEBDAV_PORT:-3005}"
10+
11+
# Check the WebDAV listener. A TCP connection is enough here; protocol may be HTTP or HTTPS.
12+
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
13+
WEBDAV_STATUS="online"
14+
else
15+
WEBDAV_STATUS="offline"
16+
fi
1217

1318
if [ "$WHOAMI_EMAIL" = "$INXT_USER" ] && [ "$WEBDAV_STATUS" = "online" ]; then
1419
echo "Healthcheck passed. User: $INXT_USER, WebDAV status: $WEBDAV_STATUS"

src/commands/webdav-config.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,30 @@ export default class WebDAVConfig extends Command {
7474
default: undefined,
7575
allowNo: true,
7676
}),
77+
hyperBackupMode: Flags.boolean({
78+
description: 'Enables Synology Hyper Backup compatibility and performance shortcuts.',
79+
required: false,
80+
default: undefined,
81+
allowNo: true,
82+
}),
7783
};
7884
static readonly enableJsonFlag = true;
7985

8086
public run = async () => {
8187
const { flags } = await this.parse(WebDAVConfig);
82-
const { host, port, https, http, timeout, createFullPath, customAuth, username, password, deleteFilesPermanently } =
83-
flags;
88+
const {
89+
host,
90+
port,
91+
https,
92+
http,
93+
timeout,
94+
createFullPath,
95+
customAuth,
96+
username,
97+
password,
98+
deleteFilesPermanently,
99+
hyperBackupMode,
100+
} = flags;
84101
const nonInteractive = flags['non-interactive'];
85102

86103
const webdavConfig = await ConfigService.instance.readWebdavConfig();
@@ -129,6 +146,9 @@ export default class WebDAVConfig extends Command {
129146
if (deleteFilesPermanently !== undefined) {
130147
webdavConfig['deleteFilesPermanently'] = deleteFilesPermanently;
131148
}
149+
if (hyperBackupMode !== undefined) {
150+
webdavConfig['hyperBackupMode'] = hyperBackupMode;
151+
}
132152

133153
await ConfigService.instance.saveWebdavConfig(webdavConfig);
134154

src/constants/configs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export const WEBDAV_DEFAULT_TIMEOUT = 0;
1414
export const WEBDAV_DEFAULT_CREATE_FULL_PATH = true;
1515
export const WEBDAV_DEFAULT_CUSTOM_AUTH = false;
1616
export const WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY = false;
17+
export const WEBDAV_DEFAULT_HYPER_BACKUP_MODE = false;

src/services/config.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
WEBDAV_DEFAULT_CUSTOM_AUTH,
1717
WEBDAV_SSL_CERTS_DIR,
1818
WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY,
19+
WEBDAV_DEFAULT_HYPER_BACKUP_MODE,
1920
} from '../constants/configs';
2021
import { CacheService } from './cache.service';
2122

@@ -112,6 +113,7 @@ export class ConfigService {
112113
username: configs?.username ?? '',
113114
password: configs?.password ?? '',
114115
deleteFilesPermanently: configs?.deleteFilesPermanently ?? WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY,
116+
hyperBackupMode: configs?.hyperBackupMode ?? WEBDAV_DEFAULT_HYPER_BACKUP_MODE,
115117
};
116118
CacheService.instance.set(CacheService.WEBDAV_CONFIG_CACHE_KEY, webdavConfig);
117119
return webdavConfig;
@@ -126,6 +128,7 @@ export class ConfigService {
126128
username: '',
127129
password: '',
128130
deleteFilesPermanently: WEBDAV_DEFAULT_DELETE_FILES_PERMANENTLY,
131+
hyperBackupMode: WEBDAV_DEFAULT_HYPER_BACKUP_MODE,
129132
};
130133
}
131134
};

src/services/drive/drive-file.service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,31 @@ export class DriveFileService {
3131
return driveFileItem;
3232
};
3333

34+
public replaceFile = async (uuid: string, payload: StorageTypes.FileEntryByUuid): Promise<DriveFileItem> => {
35+
const storageClient = SdkManager.instance.getStorage();
36+
const driveFile = await storageClient.replaceFile(uuid, {
37+
fileId: payload.fileId ?? '',
38+
size: payload.size,
39+
});
40+
41+
const driveFileItem: DriveFileItem = {
42+
itemType: 'file',
43+
name: driveFile.plainName ?? driveFile.name,
44+
uuid: driveFile.uuid,
45+
size: driveFile.size,
46+
bucket: driveFile.bucket,
47+
createdAt: new Date(driveFile.createdAt),
48+
updatedAt: new Date(driveFile.updatedAt),
49+
fileId: driveFile.fileId ?? null,
50+
type: driveFile.type ?? null,
51+
status: driveFile.status as DriveFileItem['status'],
52+
folderUuid: driveFile.folderUuid,
53+
creationTime: new Date(driveFile.creationTime ?? driveFile.createdAt),
54+
modificationTime: new Date(driveFile.modificationTime ?? driveFile.updatedAt),
55+
};
56+
return driveFileItem;
57+
};
58+
3459
private readonly createDriveFileEntry = async (
3560
payload: StorageTypes.FileEntryByUuid,
3661
): Promise<StorageTypes.DriveFileData> => {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import path from 'node:path';
2+
import { DriveFileItem, DriveFolderItem } from '../../types/drive.types';
3+
4+
type CacheEntry<T> = {
5+
value: T;
6+
expiresAt: number;
7+
};
8+
9+
export type WebDavFolderContent = {
10+
folders: DriveFolderItem[];
11+
files: DriveFileItem[];
12+
};
13+
14+
export class WebDavCacheService {
15+
public static readonly instance = new WebDavCacheService();
16+
17+
private static readonly TTL_MS = 10 * 60 * 1000;
18+
19+
private readonly foldersByPath = new Map<string, CacheEntry<DriveFolderItem>>();
20+
private readonly filesByPath = new Map<string, CacheEntry<DriveFileItem>>();
21+
private readonly folderContentByPath = new Map<string, CacheEntry<WebDavFolderContent>>();
22+
23+
private readonly normalizeFolderPath = (folderPath: string): string => {
24+
let normalizedPath = folderPath || '/';
25+
if (!normalizedPath.startsWith('/')) normalizedPath = `/${normalizedPath}`;
26+
if (!normalizedPath.endsWith('/')) normalizedPath = `${normalizedPath}/`;
27+
return normalizedPath;
28+
};
29+
30+
private readonly normalizeFilePath = (filePath: string): string => {
31+
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
32+
return normalizedPath.length > 1 ? normalizedPath.replace(/\/$/, '') : normalizedPath;
33+
};
34+
35+
private readonly entry = <T>(value: T): CacheEntry<T> => ({
36+
value,
37+
expiresAt: Date.now() + WebDavCacheService.TTL_MS,
38+
});
39+
40+
private readonly getFresh = <T>(map: Map<string, CacheEntry<T>>, key: string): T | undefined => {
41+
const cached = map.get(key);
42+
if (!cached) return;
43+
if (cached.expiresAt < Date.now()) {
44+
map.delete(key);
45+
return;
46+
}
47+
return cached.value;
48+
};
49+
50+
getFolder = (folderPath: string) => this.getFresh(this.foldersByPath, this.normalizeFolderPath(folderPath));
51+
52+
setFolder = (folderPath: string, folder: DriveFolderItem) => {
53+
this.foldersByPath.set(this.normalizeFolderPath(folderPath), this.entry(folder));
54+
};
55+
56+
getFile = (filePath: string) => this.getFresh(this.filesByPath, this.normalizeFilePath(filePath));
57+
58+
setFile = (filePath: string, file: DriveFileItem) => {
59+
this.filesByPath.set(this.normalizeFilePath(filePath), this.entry(file));
60+
};
61+
62+
getFolderContent = (folderPath: string) =>
63+
this.getFresh(this.folderContentByPath, this.normalizeFolderPath(folderPath));
64+
65+
setFolderContent = (folderPath: string, folderContent: WebDavFolderContent) => {
66+
this.folderContentByPath.set(this.normalizeFolderPath(folderPath), this.entry(folderContent));
67+
};
68+
69+
invalidateResource = (resourcePath: string) => {
70+
const filePath = this.normalizeFilePath(resourcePath);
71+
const folderPath = this.normalizeFolderPath(resourcePath);
72+
const parentFolderPath = this.normalizeFolderPath(path.posix.dirname(filePath));
73+
74+
this.filesByPath.delete(filePath);
75+
this.foldersByPath.delete(folderPath);
76+
this.folderContentByPath.delete(folderPath);
77+
this.folderContentByPath.delete(parentFolderPath);
78+
};
79+
80+
clear = () => {
81+
this.foldersByPath.clear();
82+
this.filesByPath.clear();
83+
this.folderContentByPath.clear();
84+
};
85+
}

0 commit comments

Comments
 (0)