510 lines
16 KiB
JavaScript
510 lines
16 KiB
JavaScript
|
|
const { createClient, AuthType } = require("webdav");
|
||
|
|
const https = require("https");
|
||
|
|
const { NodeHttpHandler } = require("@smithy/node-http-handler");
|
||
|
|
const {
|
||
|
|
S3Client,
|
||
|
|
HeadObjectCommand,
|
||
|
|
PutObjectCommand,
|
||
|
|
GetObjectCommand,
|
||
|
|
DeleteObjectCommand,
|
||
|
|
} = require("@aws-sdk/client-s3");
|
||
|
|
const {
|
||
|
|
resolveOutboundHttpAgent,
|
||
|
|
} = require("./httpNetworkProxyAgent.cjs");
|
||
|
|
|
||
|
|
const SYNC_FILE_NAME = "netcatty-vault.json";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Some WebDAV servers (esp. lightweight / local ones) overwrite existing
|
||
|
|
* files without truncating when the new body is shorter. That leaves trailing
|
||
|
|
* non-whitespace bytes from the previous longer body and breaks JSON.parse
|
||
|
|
* (#2223).
|
||
|
|
*
|
||
|
|
* Strategy:
|
||
|
|
* 1) Prefer fixed-name temp PUT + MOVE (atomic, exact size) when supported.
|
||
|
|
* 2) Otherwise in-place PUT padded with spaces to at least the previous
|
||
|
|
* length, then re-read and verify JSON. Retry with a larger pad if a
|
||
|
|
* concurrent writer grew the file (stale-length race). JSON.parse
|
||
|
|
* ignores trailing whitespace, so the canonical path never needs DELETE.
|
||
|
|
*/
|
||
|
|
const serializeSyncedFileBody = (syncedFile) =>
|
||
|
|
Buffer.from(JSON.stringify(syncedFile), "utf8");
|
||
|
|
|
||
|
|
const safeDeleteWebdavPath = async (client, path) => {
|
||
|
|
try {
|
||
|
|
if (await client.exists(path)) {
|
||
|
|
await client.deleteFile(path);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Best-effort cleanup only (temp files).
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Pad body with spaces so non-truncating PUTs cannot leave garbage. */
|
||
|
|
const padBodyToAtLeast = (bodyBuffer, minLength) => {
|
||
|
|
if (!minLength || minLength <= bodyBuffer.length) return bodyBuffer;
|
||
|
|
return Buffer.concat([bodyBuffer, Buffer.alloc(minLength - bodyBuffer.length, 0x20)]);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Strict upload check: remote must be exactly the intended body, optionally
|
||
|
|
* followed only by whitespace. Do NOT use parseSyncedFileJson here — that
|
||
|
|
* helper intentionally recovers trailing garbage on download.
|
||
|
|
*/
|
||
|
|
const remoteMatchesUploadedBody = (remoteText, bodyBuffer) => {
|
||
|
|
const bodyText = bodyBuffer.toString("utf8");
|
||
|
|
if (!remoteText.startsWith(bodyText)) return false;
|
||
|
|
return /^\s*$/.test(remoteText.slice(bodyText.length));
|
||
|
|
};
|
||
|
|
|
||
|
|
const readExistingByteLengthStrict = async (client, path) => {
|
||
|
|
let exists = false;
|
||
|
|
try {
|
||
|
|
exists = await client.exists(path);
|
||
|
|
} catch (error) {
|
||
|
|
throw new Error(
|
||
|
|
`WebDAV replace aborted: could not check existing file (${
|
||
|
|
error instanceof Error ? error.message : String(error)
|
||
|
|
})`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (!exists) return 0;
|
||
|
|
try {
|
||
|
|
const existing = await client.getFileContents(path, { format: "text" });
|
||
|
|
if (existing == null) return 0;
|
||
|
|
return Buffer.byteLength(String(existing), "utf8");
|
||
|
|
} catch (error) {
|
||
|
|
throw new Error(
|
||
|
|
`WebDAV replace aborted: could not read existing file length (${
|
||
|
|
error instanceof Error ? error.message : String(error)
|
||
|
|
})`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const readRemoteText = async (client, path) => {
|
||
|
|
const remote = await client.getFileContents(path, { format: "text" });
|
||
|
|
return String(remote ?? "");
|
||
|
|
};
|
||
|
|
|
||
|
|
const putWebdavFileReplacing = async (client, path, bodyBuffer) => {
|
||
|
|
// Fixed temp name so failed cleanups cannot accumulate one full vault per sync.
|
||
|
|
const tmpPath = `${path}.tmp`;
|
||
|
|
|
||
|
|
// Optional atomic path: pad temp (in case a stale non-truncated .tmp remains),
|
||
|
|
// MOVE into place, then strictly verify the destination.
|
||
|
|
try {
|
||
|
|
let tmpMinLen = 0;
|
||
|
|
try {
|
||
|
|
tmpMinLen = await readExistingByteLengthStrict(client, tmpPath);
|
||
|
|
} catch {
|
||
|
|
tmpMinLen = 0;
|
||
|
|
}
|
||
|
|
await client.putFileContents(tmpPath, padBodyToAtLeast(bodyBuffer, tmpMinLen), {
|
||
|
|
overwrite: true,
|
||
|
|
});
|
||
|
|
await client.moveFile(tmpPath, path, { overwrite: true });
|
||
|
|
const movedText = await readRemoteText(client, path);
|
||
|
|
if (remoteMatchesUploadedBody(movedText, bodyBuffer)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// fall through to padded in-place PUT
|
||
|
|
}
|
||
|
|
await safeDeleteWebdavPath(client, tmpPath);
|
||
|
|
|
||
|
|
// Keep the canonical path visible. Pad + strict verify so non-truncating
|
||
|
|
// servers and concurrent writers that grow the remote cannot leave garbage.
|
||
|
|
let minLen = await readExistingByteLengthStrict(client, path);
|
||
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||
|
|
const payload = padBodyToAtLeast(bodyBuffer, minLen);
|
||
|
|
await client.putFileContents(path, payload, { overwrite: true });
|
||
|
|
let remoteText = "";
|
||
|
|
try {
|
||
|
|
remoteText = await readRemoteText(client, path);
|
||
|
|
} catch (error) {
|
||
|
|
throw new Error(
|
||
|
|
`WebDAV upload verification failed: could not re-read file (${
|
||
|
|
error instanceof Error ? error.message : String(error)
|
||
|
|
})`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (remoteMatchesUploadedBody(remoteText, bodyBuffer)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
minLen = Math.max(minLen, Buffer.byteLength(remoteText, "utf8"), bodyBuffer.length);
|
||
|
|
}
|
||
|
|
throw new Error(
|
||
|
|
"WebDAV upload verification failed: remote file still does not match uploaded body after padded PUT"
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Recover from trailing garbage left by non-truncating WebDAV PUT overwrites.
|
||
|
|
* Node/V8 reports: "Unexpected non-whitespace character after JSON at position N".
|
||
|
|
*/
|
||
|
|
const parseSyncedFileJson = (raw) => {
|
||
|
|
const text = String(raw ?? "");
|
||
|
|
if (!text) return null;
|
||
|
|
try {
|
||
|
|
return JSON.parse(text);
|
||
|
|
} catch (error) {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
const match = /Unexpected non-whitespace character after JSON at position (\d+)/i.exec(
|
||
|
|
message
|
||
|
|
);
|
||
|
|
if (match) {
|
||
|
|
const pos = Number(match[1]);
|
||
|
|
if (Number.isFinite(pos) && pos > 0 && pos <= text.length) {
|
||
|
|
return JSON.parse(text.slice(0, pos));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
let electronModuleRef = null;
|
||
|
|
|
||
|
|
const isHttpsEndpoint = (endpoint) => {
|
||
|
|
try {
|
||
|
|
return new URL(endpoint).protocol === "https:";
|
||
|
|
} catch {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const resolveCloudSyncTransportAgent = async (targetUrl, allowInsecure = false) => {
|
||
|
|
const session = electronModuleRef?.session?.defaultSession || null;
|
||
|
|
const agent = await resolveOutboundHttpAgent(targetUrl, {
|
||
|
|
session,
|
||
|
|
rejectUnauthorized: allowInsecure ? false : undefined,
|
||
|
|
});
|
||
|
|
if (agent) return agent;
|
||
|
|
if (allowInsecure && isHttpsEndpoint(targetUrl)) {
|
||
|
|
return new https.Agent({ rejectUnauthorized: false });
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
};
|
||
|
|
|
||
|
|
const assignTransportAgent = (extraOpts, endpoint, agent) => {
|
||
|
|
if (!agent) return;
|
||
|
|
if (isHttpsEndpoint(endpoint)) {
|
||
|
|
extraOpts.httpsAgent = agent;
|
||
|
|
} else {
|
||
|
|
extraOpts.httpAgent = agent;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const normalizeEndpoint = (endpoint) => {
|
||
|
|
const trimmed = String(endpoint || "").trim();
|
||
|
|
if (!trimmed) return trimmed;
|
||
|
|
if (!/^https?:\/\//i.test(trimmed)) {
|
||
|
|
return `https://${trimmed}`;
|
||
|
|
}
|
||
|
|
return trimmed;
|
||
|
|
};
|
||
|
|
|
||
|
|
const ensureLeadingSlash = (value) => (value.startsWith("/") ? value : `/${value}`);
|
||
|
|
|
||
|
|
const toBodyString = async (body) => {
|
||
|
|
if (!body) return "";
|
||
|
|
if (typeof body === "string") return body;
|
||
|
|
if (Buffer.isBuffer(body)) return body.toString("utf8");
|
||
|
|
if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8");
|
||
|
|
if (typeof body.transformToString === "function") {
|
||
|
|
return await body.transformToString();
|
||
|
|
}
|
||
|
|
if (typeof body.on === "function") {
|
||
|
|
return await new Promise((resolve, reject) => {
|
||
|
|
let data = "";
|
||
|
|
body.on("data", (chunk) => {
|
||
|
|
data += chunk.toString();
|
||
|
|
});
|
||
|
|
body.on("end", () => resolve(data));
|
||
|
|
body.on("error", reject);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
throw new Error("Unsupported S3 response body");
|
||
|
|
};
|
||
|
|
|
||
|
|
const buildError = (message, details) => {
|
||
|
|
const err = new Error(message);
|
||
|
|
err.cause = details;
|
||
|
|
return err;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Per RFC 7617, Basic Auth credentials must be UTF-8 encoded before base64.
|
||
|
|
// The upstream `webdav` package routes through `base-64`, which encodes as
|
||
|
|
// Latin1 — silently corrupting non-ASCII characters (e.g. `ö`, `ä`) and
|
||
|
|
// causing 401s against servers that follow the spec, like Hetzner Storage
|
||
|
|
// Box (#891). We build the header ourselves to avoid that path.
|
||
|
|
const buildBasicAuthHeader = (username, password) =>
|
||
|
|
"Basic " +
|
||
|
|
Buffer.from(`${username || ""}:${password || ""}`, "utf8").toString("base64");
|
||
|
|
|
||
|
|
const buildWebdavClient = async (config) => {
|
||
|
|
if (!config) throw new Error("Missing WebDAV config");
|
||
|
|
const endpoint = normalizeEndpoint(config.endpoint);
|
||
|
|
const extraOpts = {};
|
||
|
|
const agent = await resolveCloudSyncTransportAgent(endpoint, Boolean(config.allowInsecure));
|
||
|
|
assignTransportAgent(extraOpts, endpoint, agent);
|
||
|
|
if (config.authType === "token") {
|
||
|
|
return createClient(endpoint, {
|
||
|
|
authType: AuthType.Token,
|
||
|
|
token: {
|
||
|
|
access_token: config.token || "",
|
||
|
|
token_type: "Bearer",
|
||
|
|
},
|
||
|
|
...extraOpts,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (config.authType === "digest") {
|
||
|
|
return createClient(endpoint, {
|
||
|
|
authType: AuthType.Digest,
|
||
|
|
username: config.username || "",
|
||
|
|
password: config.password || "",
|
||
|
|
...extraOpts,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return createClient(endpoint, {
|
||
|
|
authType: AuthType.None,
|
||
|
|
headers: {
|
||
|
|
Authorization: buildBasicAuthHeader(config.username, config.password),
|
||
|
|
},
|
||
|
|
...extraOpts,
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const getWebdavPath = () => ensureLeadingSlash(SYNC_FILE_NAME);
|
||
|
|
|
||
|
|
const buildS3Client = async (config) => {
|
||
|
|
const clientOptions = {
|
||
|
|
region: config.region,
|
||
|
|
endpoint: normalizeEndpoint(config.endpoint),
|
||
|
|
forcePathStyle: config.forcePathStyle ?? true,
|
||
|
|
requestChecksumCalculation: "WHEN_REQUIRED",
|
||
|
|
responseChecksumValidation: "WHEN_REQUIRED",
|
||
|
|
credentials: {
|
||
|
|
accessKeyId: config.accessKeyId,
|
||
|
|
secretAccessKey: config.secretAccessKey,
|
||
|
|
sessionToken: config.sessionToken,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const endpoint = clientOptions.endpoint || "https://s3.amazonaws.com";
|
||
|
|
const agent = await resolveCloudSyncTransportAgent(endpoint, Boolean(config.allowInsecure));
|
||
|
|
if (agent) {
|
||
|
|
const handlerOpts = isHttpsEndpoint(endpoint)
|
||
|
|
? { httpsAgent: agent }
|
||
|
|
: { httpAgent: agent };
|
||
|
|
clientOptions.requestHandler = new NodeHttpHandler(handlerOpts);
|
||
|
|
}
|
||
|
|
|
||
|
|
return new S3Client(clientOptions);
|
||
|
|
};
|
||
|
|
|
||
|
|
const getS3ObjectKey = (config) => {
|
||
|
|
const prefix = String(config.prefix || "").trim().replace(/^\/+|\/+$/g, "");
|
||
|
|
if (!prefix) return SYNC_FILE_NAME;
|
||
|
|
return `${prefix}/${SYNC_FILE_NAME}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const isS3NotFound = (error) => error?.$metadata?.httpStatusCode === 404;
|
||
|
|
const isS3AccessDenied = (error) => error?.$metadata?.httpStatusCode === 403;
|
||
|
|
|
||
|
|
const wrapWebdavError = (operation, error, config) => {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
const details = {
|
||
|
|
operation,
|
||
|
|
endpoint: normalizeEndpoint(config?.endpoint),
|
||
|
|
authType: config?.authType,
|
||
|
|
status: error?.status || error?.response?.status,
|
||
|
|
statusText: error?.statusText || error?.response?.statusText,
|
||
|
|
url: error?.url || error?.response?.url,
|
||
|
|
method: error?.method,
|
||
|
|
code: error?.code,
|
||
|
|
};
|
||
|
|
return buildError(`WebDAV ${operation} failed: ${message}`, details);
|
||
|
|
};
|
||
|
|
|
||
|
|
const wrapS3Error = (operation, error, config) => {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
const details = {
|
||
|
|
operation,
|
||
|
|
endpoint: normalizeEndpoint(config?.endpoint),
|
||
|
|
region: config?.region,
|
||
|
|
bucket: config?.bucket,
|
||
|
|
forcePathStyle: config?.forcePathStyle ?? true,
|
||
|
|
allowInsecure: Boolean(config?.allowInsecure),
|
||
|
|
code: error?.code || error?.name,
|
||
|
|
status: error?.$metadata?.httpStatusCode,
|
||
|
|
};
|
||
|
|
return buildError(`S3 ${operation} failed: ${message}`, details);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleWebdavInitialize = async (config) => {
|
||
|
|
try {
|
||
|
|
const client = await buildWebdavClient(config);
|
||
|
|
const path = getWebdavPath();
|
||
|
|
await client.exists(path);
|
||
|
|
return { resourceId: path };
|
||
|
|
} catch (error) {
|
||
|
|
throw wrapWebdavError("initialize", error, config);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleWebdavUpload = async (config, syncedFile) => {
|
||
|
|
try {
|
||
|
|
const client = await buildWebdavClient(config);
|
||
|
|
const path = getWebdavPath();
|
||
|
|
const body = serializeSyncedFileBody(syncedFile);
|
||
|
|
await putWebdavFileReplacing(client, path, body);
|
||
|
|
return { resourceId: path };
|
||
|
|
} catch (error) {
|
||
|
|
throw wrapWebdavError("upload", error, config);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleWebdavDownload = async (config) => {
|
||
|
|
try {
|
||
|
|
const client = await buildWebdavClient(config);
|
||
|
|
const path = getWebdavPath();
|
||
|
|
const exists = await client.exists(path);
|
||
|
|
if (!exists) return { syncedFile: null };
|
||
|
|
const data = await client.getFileContents(path, { format: "text" });
|
||
|
|
if (!data) return { syncedFile: null };
|
||
|
|
return { syncedFile: parseSyncedFileJson(data) };
|
||
|
|
} catch (error) {
|
||
|
|
throw wrapWebdavError("download", error, config);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleWebdavDelete = async (config) => {
|
||
|
|
try {
|
||
|
|
const client = await buildWebdavClient(config);
|
||
|
|
const path = getWebdavPath();
|
||
|
|
const exists = await client.exists(path);
|
||
|
|
if (!exists) return { ok: true };
|
||
|
|
await client.deleteFile(path);
|
||
|
|
return { ok: true };
|
||
|
|
} catch (error) {
|
||
|
|
throw wrapWebdavError("delete", error, config);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleS3Initialize = async (config) => {
|
||
|
|
if (!config) throw new Error("Missing S3 config");
|
||
|
|
const client = await buildS3Client(config);
|
||
|
|
const key = getS3ObjectKey(config);
|
||
|
|
try {
|
||
|
|
await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: key }));
|
||
|
|
} catch (error) {
|
||
|
|
if (isS3NotFound(error)) {
|
||
|
|
// Missing file is OK.
|
||
|
|
} else if (isS3AccessDenied(error)) {
|
||
|
|
throw buildError("S3 access denied", {
|
||
|
|
operation: "initialize",
|
||
|
|
endpoint: normalizeEndpoint(config.endpoint),
|
||
|
|
region: config.region,
|
||
|
|
bucket: config.bucket,
|
||
|
|
forcePathStyle: config.forcePathStyle ?? true,
|
||
|
|
allowInsecure: Boolean(config.allowInsecure),
|
||
|
|
status: error?.$metadata?.httpStatusCode,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
throw wrapS3Error("initialize", error, config);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { resourceId: key };
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleS3Upload = async (config, syncedFile) => {
|
||
|
|
if (!config) throw new Error("Missing S3 config");
|
||
|
|
const client = await buildS3Client(config);
|
||
|
|
const key = getS3ObjectKey(config);
|
||
|
|
try {
|
||
|
|
await client.send(
|
||
|
|
new PutObjectCommand({
|
||
|
|
Bucket: config.bucket,
|
||
|
|
Key: key,
|
||
|
|
Body: JSON.stringify(syncedFile),
|
||
|
|
ContentType: "application/json",
|
||
|
|
})
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
throw wrapS3Error("upload", error, config);
|
||
|
|
}
|
||
|
|
return { resourceId: key };
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleS3Download = async (config) => {
|
||
|
|
if (!config) throw new Error("Missing S3 config");
|
||
|
|
const client = await buildS3Client(config);
|
||
|
|
const key = getS3ObjectKey(config);
|
||
|
|
try {
|
||
|
|
const response = await client.send(
|
||
|
|
new GetObjectCommand({ Bucket: config.bucket, Key: key })
|
||
|
|
);
|
||
|
|
const text = await toBodyString(response.Body);
|
||
|
|
if (!text) return { syncedFile: null };
|
||
|
|
return { syncedFile: JSON.parse(text) };
|
||
|
|
} catch (error) {
|
||
|
|
if (isS3NotFound(error)) return { syncedFile: null };
|
||
|
|
throw wrapS3Error("download", error, config);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleS3Delete = async (config) => {
|
||
|
|
if (!config) throw new Error("Missing S3 config");
|
||
|
|
const client = await buildS3Client(config);
|
||
|
|
const key = getS3ObjectKey(config);
|
||
|
|
try {
|
||
|
|
await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }));
|
||
|
|
} catch (error) {
|
||
|
|
if (isS3NotFound(error)) return { ok: true };
|
||
|
|
throw wrapS3Error("delete", error, config);
|
||
|
|
}
|
||
|
|
return { ok: true };
|
||
|
|
};
|
||
|
|
|
||
|
|
const registerHandlers = (ipcMain, electronModule) => {
|
||
|
|
electronModuleRef = electronModule || null;
|
||
|
|
ipcMain.handle("netcatty:cloudSync:webdav:initialize", async (_event, payload) => {
|
||
|
|
return handleWebdavInitialize(payload?.config);
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:cloudSync:webdav:upload", async (_event, payload) => {
|
||
|
|
return handleWebdavUpload(payload?.config, payload?.syncedFile);
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:cloudSync:webdav:download", async (_event, payload) => {
|
||
|
|
return handleWebdavDownload(payload?.config);
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:cloudSync:webdav:delete", async (_event, payload) => {
|
||
|
|
return handleWebdavDelete(payload?.config);
|
||
|
|
});
|
||
|
|
|
||
|
|
ipcMain.handle("netcatty:cloudSync:s3:initialize", async (_event, payload) => {
|
||
|
|
return handleS3Initialize(payload?.config);
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:cloudSync:s3:upload", async (_event, payload) => {
|
||
|
|
return handleS3Upload(payload?.config, payload?.syncedFile);
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:cloudSync:s3:download", async (_event, payload) => {
|
||
|
|
return handleS3Download(payload?.config);
|
||
|
|
});
|
||
|
|
ipcMain.handle("netcatty:cloudSync:s3:delete", async (_event, payload) => {
|
||
|
|
return handleS3Delete(payload?.config);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
registerHandlers,
|
||
|
|
// Exposed for tests
|
||
|
|
handleWebdavInitialize,
|
||
|
|
handleWebdavUpload,
|
||
|
|
handleWebdavDownload,
|
||
|
|
parseSyncedFileJson,
|
||
|
|
putWebdavFileReplacing,
|
||
|
|
buildBasicAuthHeader,
|
||
|
|
buildS3Client,
|
||
|
|
};
|